From b6392491594e5716686c5e18f6feb731ec6a14a3 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Tue, 10 Sep 2024 18:30:26 +0800 Subject: [PATCH] feat(core): add koa oidc auth for profile API (#6559) --- .../src/middleware/koa-auth/index.test.ts | 18 +-- .../core/src/middleware/koa-auth/index.ts | 40 +----- .../middleware/koa-auth/koa-oidc-auth.test.ts | 133 ++++++++++++++++++ .../src/middleware/koa-auth/koa-oidc-auth.ts | 41 ++++++ .../core/src/middleware/koa-auth/types.ts | 18 +++ .../core/src/middleware/koa-auth/utils.ts | 23 +++ packages/core/src/routes/init.ts | 8 +- packages/core/src/routes/profile/index.ts | 35 +++++ packages/core/src/routes/types.ts | 2 + packages/core/src/utils/test-utils.ts | 2 +- 10 files changed, 274 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/middleware/koa-auth/koa-oidc-auth.test.ts create mode 100644 packages/core/src/middleware/koa-auth/koa-oidc-auth.ts create mode 100644 packages/core/src/middleware/koa-auth/types.ts create mode 100644 packages/core/src/routes/profile/index.ts diff --git a/packages/core/src/middleware/koa-auth/index.test.ts b/packages/core/src/middleware/koa-auth/index.test.ts index 18c8ee3aa..0d110ae39 100644 --- a/packages/core/src/middleware/koa-auth/index.test.ts +++ b/packages/core/src/middleware/koa-auth/index.test.ts @@ -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: [] }), })); @@ -39,6 +39,7 @@ describe('koaAuth middleware', () => { auth: { type: 'user', id: '', + scopes: new Set(), }, }; @@ -62,6 +63,7 @@ describe('koaAuth middleware', () => { ctx.auth = { type: 'user', id: '', + scopes: new Set(), }; ctx.request = baseCtx.request; jest.resetModules(); @@ -74,7 +76,7 @@ describe('koaAuth middleware', () => { }); await koaAuth(mockEnvSet, audience)(ctx, next); - expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); + expect(ctx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) }); stub.restore(); }); @@ -89,7 +91,7 @@ describe('koaAuth middleware', () => { }; await koaAuth(mockEnvSet, audience)(mockCtx, next); - expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); + expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) }); }); it('should read DEVELOPMENT_USER_ID from env variable first if is in production and integration test', async () => { @@ -101,7 +103,7 @@ describe('koaAuth middleware', () => { }); await koaAuth(mockEnvSet, audience)(ctx, next); - expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); + expect(ctx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) }); stub.restore(); }); @@ -122,7 +124,7 @@ describe('koaAuth middleware', () => { }; await koaAuth(mockEnvSet, audience)(mockCtx, next); - expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); + expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) }); stub.restore(); }); @@ -135,7 +137,7 @@ describe('koaAuth middleware', () => { }, }; await koaAuth(mockEnvSet, audience)(ctx, next); - expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' }); + expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser', scopes: new Set(['all']) }); }); it('expect to throw if authorization header is missing', async () => { @@ -183,7 +185,7 @@ describe('koaAuth middleware', () => { }; await koaAuth(mockEnvSet, audience)(ctx, next); - expect(ctx.auth).toEqual({ type: 'app', id: 'bar' }); + expect(ctx.auth).toEqual({ type: 'app', id: 'bar', scopes: new Set(['all']) }); }); it('expect to throw if jwt scope is missing', async () => { diff --git a/packages/core/src/middleware/koa-auth/index.ts b/packages/core/src/middleware/koa-auth/index.ts index bcfd39d29..ca7d9916f 100644 --- a/packages/core/src/middleware/koa-auth/index.ts +++ b/packages/core/src/middleware/koa-auth/index.ts @@ -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'; @@ -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'; +import { type WithAuthContext, type TokenInfo } from './types.js'; +import { extractBearerTokenFromHeaders, getAdminTenantTokenValidationSet } from './utils.js'; -export type Auth = { - type: 'user' | 'app'; - id: string; -}; - -export type WithAuthContext = - 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); -}; - -type TokenInfo = { - sub: string; - clientId: unknown; - scopes: string[]; -}; +export * from './types.js'; export const verifyBearerTokenFromRequest = async ( envSet: EnvSet, @@ -146,6 +113,7 @@ export default function koaAuth { + Sinon.restore(); +}); + +describe('koaOidcAuth middleware', () => { + const baseCtx = createContextWithRouteParameters(); + + const ctx: WithAuthContext = { + ...baseCtx, + auth: { + type: 'user', + id: '', + scopes: new Set(), + }, + }; + + 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: '', + scopes: new Set(), + }; + 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', scopes: new Set(['openid']) }); + }); + + 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 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); + }); +}); diff --git a/packages/core/src/middleware/koa-auth/koa-oidc-auth.ts b/packages/core/src/middleware/koa-auth/koa-oidc-auth.ts new file mode 100644 index 000000000..fdfadc4e5 --- /dev/null +++ b/packages/core/src/middleware/koa-auth/koa-oidc-auth.ts @@ -0,0 +1,41 @@ +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( + provider: Provider +): MiddlewareType, ResponseBodyT> { + const authMiddleware: MiddlewareType, 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, scopes } = accessToken; + assertThat(accountId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + assertThat(scopes.has('openid'), new RequestError({ code: 'auth.forbidden', status: 403 })); + + ctx.auth = { + type: 'user', + id: accountId, + scopes, + }; + + return next(); + }; + + return authMiddleware; +} diff --git a/packages/core/src/middleware/koa-auth/types.ts b/packages/core/src/middleware/koa-auth/types.ts new file mode 100644 index 000000000..579723311 --- /dev/null +++ b/packages/core/src/middleware/koa-auth/types.ts @@ -0,0 +1,18 @@ +import { type IRouterParamContext } from 'koa-router'; + +type Auth = { + type: 'user' | 'app'; + id: string; + scopes: Set; +}; + +export type WithAuthContext = + ContextT & { + auth: Auth; + }; + +export type TokenInfo = { + sub: string; + clientId: unknown; + scopes: string[]; +}; diff --git a/packages/core/src/middleware/koa-auth/utils.ts b/packages/core/src/middleware/koa-auth/utils.ts index 1834ca283..faad959f5 100644 --- a/packages/core/src/middleware/koa-auth/utils.ts +++ b/packages/core/src/middleware/koa-auth/utils.ts @@ -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'; @@ -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(60 * 60 * 1000); // 1 hour /** @@ -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); +}; diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 7ebeb1df6..64deb50f4 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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'; @@ -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'; @@ -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, diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts new file mode 100644 index 000000000..b7e9cc6f3 --- /dev/null +++ b/packages/core/src/routes/profile/index.ts @@ -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( + ...[router, { provider }]: RouterInitArgs +) { + router.use(koaOidcAuth(provider)); + + // TODO: test route only, will implement a better one later + 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(); + } + ); +} diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index c1d754ecf..06aa0c152 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -17,5 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext & export type ManagementApiRouter = Router; +export type ProfileRouter = Router; + type RouterInit = (router: T, tenant: TenantContext) => void; export type RouterInitArgs = Parameters>; diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index 3143dcff4..3b9f3d85b 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -146,7 +146,7 @@ export function createRequester { - ctx.auth = { type: 'user', id: 'foo' }; + ctx.auth = { type: 'user', id: 'foo', scopes: new Set() }; return next(); });