From 3b048a80a374ff720a5afe3b35f007b31fddd576 Mon Sep 17 00:00:00 2001 From: "IceHe.xyz" Date: Thu, 19 May 2022 11:24:26 +0800 Subject: [PATCH] feat(core,schemas): log token exchange success (#809) --- packages/core/src/oidc/adapter.ts | 4 +- packages/core/src/oidc/init.ts | 4 + .../oidc-provider-event-listener.test.ts | 98 +++++++++++++++++++ .../src/utils/oidc-provider-event-listener.ts | 51 ++++++++++ packages/schemas/src/types/log.ts | 11 +++ packages/schemas/src/types/oidc-config.ts | 5 + 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/oidc-provider-event-listener.test.ts create mode 100644 packages/core/src/utils/oidc-provider-event-listener.ts diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index 7d0603218..22f452d4e 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -1,4 +1,4 @@ -import { CreateApplication } from '@logto/schemas'; +import { CreateApplication, GrantType } from '@logto/schemas'; import dayjs from 'dayjs'; import { AdapterFactory, AllClientMetadata } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; @@ -28,7 +28,7 @@ export default function postgresAdapter(modelName: string): ReturnType { const { issuer, privateKey, defaultIdTokenTtl, defaultRefreshTokenTtl } = envSet.values.oidc; @@ -116,6 +117,9 @@ export default async function initOidc(app: Koa): Promise { }, }, }); + + oidc.on('grant.success', grantSuccessListener); + app.use(mount('/oidc', oidc.app)); return oidc; diff --git a/packages/core/src/utils/oidc-provider-event-listener.test.ts b/packages/core/src/utils/oidc-provider-event-listener.test.ts new file mode 100644 index 000000000..15f86540a --- /dev/null +++ b/packages/core/src/utils/oidc-provider-event-listener.test.ts @@ -0,0 +1,98 @@ +import { grantSuccessListener } from '@/utils/oidc-provider-event-listener'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +describe('grantSuccessListener', () => { + const userId = 'userIdValue'; + const sessionId = 'sessionIdValue'; + const applicationId = 'applicationIdValue'; + const entities = { + Account: { accountId: userId }, + Grant: { jti: sessionId }, + Client: { clientId: applicationId }, + }; + + const addLogContext = jest.fn(); + const log = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should log type CodeExchangeToken when grant type is authorization_code', async () => { + const parameters = { grant_type: 'authorization_code', code: 'codeValue' }; + const ctx = { + ...createContextWithRouteParameters(), + addLogContext, + log, + oidc: { entities, params: parameters }, + body: { + access_token: 'newAccessTokenValue', + refresh_token: 'newRefreshTokenValue', + id_token: 'newIdToken', + scope: 'openid offline-access', + }, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + await grantSuccessListener(ctx); + expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId }); + expect(log).toHaveBeenCalledWith('CodeExchangeToken', { + issued: ['accessToken', 'refreshToken', 'idToken'], + params: parameters, + scope: 'openid offline-access', + userId, + }); + }); + + it('should log type RefreshTokenExchangeToken when grant type is refresh_code', async () => { + const parameters = { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' }; + const ctx = { + ...createContextWithRouteParameters(), + addLogContext, + log, + oidc: { entities, params: parameters }, + body: { + access_token: 'newAccessTokenValue', + refresh_token: 'newRefreshTokenValue', + id_token: 'newIdToken', + scope: 'openid offline-access', + }, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + await grantSuccessListener(ctx); + expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId }); + expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', { + issued: ['accessToken', 'refreshToken', 'idToken'], + params: parameters, + scope: 'openid offline-access', + userId, + }); + }); + + test('issued field should not contain "idToken" when there is no issued idToken', async () => { + const parameters = { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' }; + const ctx = { + ...createContextWithRouteParameters(), + addLogContext, + log, + oidc: { entities, params: parameters }, + body: { + // There is no idToken here. + access_token: 'newAccessTokenValue', + refresh_token: 'newRefreshTokenValue', + scope: 'offline-access', + }, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + await grantSuccessListener(ctx); + expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId }); + expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', { + issued: ['accessToken', 'refreshToken'], + params: parameters, + scope: 'offline-access', + userId, + }); + }); +}); diff --git a/packages/core/src/utils/oidc-provider-event-listener.ts b/packages/core/src/utils/oidc-provider-event-listener.ts new file mode 100644 index 000000000..7af8d892b --- /dev/null +++ b/packages/core/src/utils/oidc-provider-event-listener.ts @@ -0,0 +1,51 @@ +import { GrantType, IssuedTokenType, LogType } from '@logto/schemas'; +import { notFalsy } from '@silverhand/essentials'; +import { KoaContextWithOIDC } from 'oidc-provider'; + +import { WithLogContext } from '@/middleware/koa-log'; + +/** + * See https://github.com/panva/node-oidc-provider/tree/main/lib/actions/grants + * - https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/actions/grants/authorization_code.js#L209 + * - https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/actions/grants/refresh_token.js#L225 + * - …… + */ +interface GrantBody { + access_token?: string; + refresh_token?: string; + id_token?: string; + scope?: string; +} + +export const grantSuccessListener = async ( + ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody } +) => { + const { + oidc: { + entities: { Account: account, Grant: grant, Client: client }, + params, + }, + body, + } = ctx; + ctx.addLogContext({ + applicationId: client?.clientId, + sessionId: grant?.jti, + }); + + const { access_token, refresh_token, id_token, scope } = body; + const issued: IssuedTokenType[] = [ + access_token && 'accessToken', + refresh_token && 'refreshToken', + id_token && 'idToken', + ].filter((value): value is IssuedTokenType => notFalsy(value)); + + const grantType = params?.grant_type; + const type: LogType = + grantType === GrantType.AuthorizationCode ? 'CodeExchangeToken' : 'RefreshTokenExchangeToken'; + ctx.log(type, { + userId: account?.accountId, + params, + issued, + scope, + }); +}; diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index 969b5579b..ac78bbf2e 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -100,6 +100,15 @@ interface SignInSocialLogPayload extends SignInSocialBindLogPayload { redirectTo?: string; } +export type IssuedTokenType = 'accessToken' | 'refreshToken' | 'idToken'; + +interface ExchangeTokenLogPayload extends ArbitraryLogPayload { + userId?: string; + params?: Record; + issued?: IssuedTokenType[]; + scope?: string; +} + export type LogPayloads = { RegisterUsernamePassword: RegisterUsernamePasswordLogPayload; RegisterEmailSendPasscode: RegisterEmailSendPasscodeLogPayload; @@ -115,6 +124,8 @@ export type LogPayloads = { SignInSms: SignInSmsLogPayload; SignInSocialBind: SignInSocialBindLogPayload; SignInSocial: SignInSocialLogPayload; + CodeExchangeToken: ExchangeTokenLogPayload; + RefreshTokenExchangeToken: ExchangeTokenLogPayload; }; export type LogType = keyof LogPayloads; diff --git a/packages/schemas/src/types/oidc-config.ts b/packages/schemas/src/types/oidc-config.ts index df95b07e1..38b29c2f9 100644 --- a/packages/schemas/src/types/oidc-config.ts +++ b/packages/schemas/src/types/oidc-config.ts @@ -7,3 +7,8 @@ export type SnakeCaseOidcConfig = { }; export type OidcConfig = KeysToCamelCase; + +export enum GrantType { + AuthorizationCode = 'authorization_code', + RefreshToken = 'refresh_token', +}