Skip to content

Commit

Permalink
feat(core): add koa oidc auth for profile API
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Sep 9, 2024
1 parent c83fd6f commit ee5d3be
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 39 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/middleware/koa-auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithAuthContext } from './index.js';

const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);

mockEsm('./utils.js', () => ({
await mockEsmWithActual('./utils.js', () => ({
getAdminTenantTokenValidationSet: jest.fn().mockResolvedValue({ keys: [], issuer: [] }),
}));

Expand Down
39 changes: 3 additions & 36 deletions packages/core/src/middleware/koa-auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { IncomingHttpHeaders } from 'node:http';

import { adminTenantId, defaultManagementApi, PredefinedScope } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import type { JWK } from 'jose';
Expand All @@ -14,41 +12,10 @@ import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
import { devConsole } from '#src/utils/console.js';

import { getAdminTenantTokenValidationSet } from './utils.js';

export type Auth = {
type: 'user' | 'app';
id: string;
};

export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
ContextT & {
auth: Auth;
};

const bearerTokenIdentifier = 'Bearer';

export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
assertThat(
authorization,
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
);
assertThat(
authorization.startsWith(bearerTokenIdentifier),
new RequestError(
{ code: 'auth.authorization_token_type_not_supported', status: 401 },
{ supportedTypes: [bearerTokenIdentifier] }
)
);

return authorization.slice(bearerTokenIdentifier.length + 1);
};
import { type WithAuthContext, type TokenInfo } from './types.js';
import { extractBearerTokenFromHeaders, getAdminTenantTokenValidationSet } from './utils.js';

type TokenInfo = {
sub: string;
clientId: unknown;
scopes: string[];
};
export * from './types.js';

export const verifyBearerTokenFromRequest = async (
envSet: EnvSet,
Expand Down
146 changes: 146 additions & 0 deletions packages/core/src/middleware/koa-auth/koa-oidc-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import Provider from 'oidc-provider';
import Sinon from 'sinon';

import RequestError from '#src/errors/RequestError/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import type { WithAuthContext } from './index.js';

const { jest } = import.meta;

const provider = new Provider('https://logto.test');
const mockAccessToken = {
accountId: 'fooUser',
clientId: 'fooClient',
scopes: new Set(['openid']),
};

const koaOidcAuth = await pickDefault(import('./koa-oidc-auth.js'));

afterEach(() => {
Sinon.restore();
});

describe('koaOidcAuth middleware', () => {
const baseCtx = createContextWithRouteParameters();

const ctx: WithAuthContext<Context & IRouterParamContext> = {
...baseCtx,
auth: {
type: 'user',
id: '',
},
};

const authHeaderMissingError = new RequestError({
code: 'auth.authorization_header_missing',
status: 401,
});
const tokenNotSupportedError = new RequestError(
{
code: 'auth.authorization_token_type_not_supported',
status: 401,
},
{ supportedTypes: ['Bearer'] }
);
const unauthorizedError = new RequestError({ code: 'auth.unauthorized', status: 401 });
const forbiddenError = new RequestError({ code: 'auth.forbidden', status: 403 });

const next = jest.fn();

beforeEach(() => {
ctx.auth = {
type: 'user',
id: '',
};
ctx.request = baseCtx.request;
jest.resetModules();
});

it('should set user auth with given sub returned from accessToken', async () => {
ctx.request = {
...ctx.request,
headers: {
authorization: 'Bearer access_token',
},
};
Sinon.stub(provider.AccessToken, 'find').resolves(mockAccessToken);
await koaOidcAuth(provider)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' });
});

it('expect to throw if authorization header is missing', async () => {
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
});

it('expect to throw if authorization header token type not recognized ', async () => {
ctx.request = {
...ctx.request,
headers: {
authorization: 'dummy access_token',
},
};

await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
});

it('expect to throw if access token is not found', async () => {
ctx.request = {
...ctx.request,
headers: {
authorization: 'Bearer access_token',
},
};
Sinon.stub(provider.AccessToken, 'find').resolves();

await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
});

it('expect to throw if sub is missing', async () => {
ctx.request = {
...ctx.request,
headers: {
authorization: 'Bearer access_token',
},
};
Sinon.stub(provider.AccessToken, 'find').resolves({
...mockAccessToken,
accountId: undefined,
});

await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
});

it('expect to throw if access token is issued to the client', async () => {
ctx.request = {
...ctx.request,
headers: {
authorization: 'Bearer access_token',
},
};
Sinon.stub(provider.AccessToken, 'find').resolves({
...mockAccessToken,
accountId: 'fooClient',
});

await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
});

it('expect to throw if access token does not have openid scope', async () => {
ctx.request = {
...ctx.request,
headers: {
authorization: 'Bearer access_token',
},
};
Sinon.stub(provider.AccessToken, 'find').resolves({
...mockAccessToken,
scopes: new Set(['foo']),
});

await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(forbiddenError);
});
});
47 changes: 47 additions & 0 deletions packages/core/src/middleware/koa-auth/koa-oidc-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type Provider from 'oidc-provider';

import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';

import { type WithAuthContext } from './types.js';
import { extractBearerTokenFromHeaders } from './utils.js';

/**
* Auth middleware for OIDC opaque token
*/
export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
const authMiddleware: MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> = async (
ctx,
next
) => {
const { request } = ctx;
const accessTokenValue = extractBearerTokenFromHeaders(request.headers);
const accessToken = await provider.AccessToken.find(accessTokenValue);

assertThat(accessToken, new RequestError({ code: 'auth.unauthorized', status: 401 }));

const { accountId, clientId, scopes } = accessToken;
assertThat(accountId, new RequestError({ code: 'auth.unauthorized', status: 401 }));

// The access token must be issued to a user, not to the client
assertThat(
accountId !== clientId,
new RequestError({ code: 'auth.unauthorized', status: 401 })
);

assertThat(scopes.has('openid'), new RequestError({ code: 'auth.forbidden', status: 403 }));

ctx.auth = {
type: 'user',
id: accountId,
};

return next();
};

return authMiddleware;
}
17 changes: 17 additions & 0 deletions packages/core/src/middleware/koa-auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type IRouterParamContext } from 'koa-router';

type Auth = {
type: 'user' | 'app';
id: string;
};

export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
ContextT & {
auth: Auth;
};

export type TokenInfo = {
sub: string;
clientId: unknown;
scopes: string[];
};
23 changes: 23 additions & 0 deletions packages/core/src/middleware/koa-auth/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { type IncomingHttpHeaders } from 'node:http';

import { adminTenantId } from '@logto/schemas';
import { TtlCache } from '@logto/shared';
import { appendPath, isKeyInObject } from '@silverhand/essentials';
Expand All @@ -6,6 +8,9 @@ import ky from 'ky';

import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';

import RequestError from '../../errors/RequestError/index.js';
import assertThat from '../../utils/assert-that.js';

const jwksCache = new TtlCache<string, JWK[]>(60 * 60 * 1000); // 1 hour

/**
Expand Down Expand Up @@ -58,3 +63,21 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
issuer: [issuer.href],
};
};

const bearerTokenIdentifier = 'Bearer';

export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
assertThat(
authorization,
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
);
assertThat(
authorization.startsWith(bearerTokenIdentifier),
new RequestError(
{ code: 'auth.authorization_token_type_not_supported', status: 401 },
{ supportedTypes: [bearerTokenIdentifier] }
)
);

return authorization.slice(bearerTokenIdentifier.length + 1);
};
8 changes: 7 additions & 1 deletion packages/core/src/routes/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js';
import logtoConfigRoutes from './logto-config/index.js';
import organizationRoutes from './organization/index.js';
import profileRoutes from './profile/index.js';
import resourceRoutes from './resource.js';
import resourceScopeRoutes from './resource.scope.js';
import roleRoutes from './role.js';
Expand All @@ -42,7 +43,7 @@ import statusRoutes from './status.js';
import subjectTokenRoutes from './subject-token.js';
import swaggerRoutes from './swagger/index.js';
import systemRoutes from './system.js';
import type { AnonymousRouter, ManagementApiRouter } from './types.js';
import type { AnonymousRouter, ManagementApiRouter, ProfileRouter } from './types.js';
import userAssetsRoutes from './user-assets.js';
import verificationCodeRoutes from './verification-code.js';
import wellKnownRoutes from './well-known/index.js';
Expand Down Expand Up @@ -105,6 +106,11 @@ const createRouters = (tenant: TenantContext) => {
statusRoutes(anonymousRouter, tenant);
authnRoutes(anonymousRouter, tenant);

if (EnvSet.values.isDevFeaturesEnabled) {
const profileRouter: ProfileRouter = new Router();
profileRoutes(profileRouter, tenant);
}

// The swagger.json should contain all API routers.
swaggerRoutes(anonymousRouter, [
managementRouter,
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/routes/profile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { z } from 'zod';

import koaGuard from '#src/middleware/koa-guard.js';

import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
import type { ProfileRouter, RouterInitArgs } from '../types.js';

/**
* Authn stands for authentication.
* This router will have a route `/authn` to authenticate tokens with a general manner.
*/
export default function profileRoutes<T extends ProfileRouter>(
...[router, { provider }]: RouterInitArgs<T>
) {
router.use(koaOidcAuth(provider));

// TODO: test route only, will implement a better one later

Check warning on line 17 in packages/core/src/routes/profile/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/profile/index.ts#L17

[no-warning-comments] Unexpected 'todo' comment: 'TODO: test route only, will implement a...'.
router.get(
'/profile',
koaGuard({
response: z.object({
sub: z.string(),
}),
status: [200],
}),
async (ctx, next) => {
ctx.body = {
sub: ctx.auth.id,
};
ctx.status = 200;

return next();
}

Check warning on line 33 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L27-L33

Added lines #L27 - L33 were not covered by tests
);
}
2 changes: 2 additions & 0 deletions packages/core/src/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &

export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;

export type ProfileRouter = Router<unknown, WithAuthContext & WithLogContext & WithI18nContext>;

Check warning on line 21 in packages/core/src/routes/types.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/types.ts#L20-L21

Added lines #L20 - L21 were not covered by tests
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;

0 comments on commit ee5d3be

Please sign in to comment.