diff --git a/packages/console/src/components/AuditLogTable/index.tsx b/packages/console/src/components/AuditLogTable/index.tsx index a8a23a212..ffe94e2a4 100644 --- a/packages/console/src/components/AuditLogTable/index.tsx +++ b/packages/console/src/components/AuditLogTable/index.tsx @@ -1,5 +1,5 @@ -import type { LogDto } from '@logto/schemas'; import { LogResult } from '@logto/schemas'; +import type { LogDto } from '@logto/schemas/lib/types/log-legacy.js'; import { conditional, conditionalString } from '@silverhand/essentials'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; diff --git a/packages/console/src/pages/AuditLogDetails/index.tsx b/packages/console/src/pages/AuditLogDetails/index.tsx index 764c5dc77..de82e18d2 100644 --- a/packages/console/src/pages/AuditLogDetails/index.tsx +++ b/packages/console/src/pages/AuditLogDetails/index.tsx @@ -1,4 +1,5 @@ -import type { LogDto, User } from '@logto/schemas'; +import type { User } from '@logto/schemas'; +import type { LogDto } from '@logto/schemas/lib/types/log-legacy.js'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router-dom'; diff --git a/packages/core/package.json b/packages/core/package.json index f395ebe05..f4dd523e6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -111,7 +111,8 @@ "error", 11 ], - "default-case": "off" + "default-case": "off", + "import/extensions": "off" } }, "prettier": "@silverhand/eslint-config/.prettierrc" diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index 0ea5765f2..f750a27a7 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -8,7 +8,7 @@ const { jest } = import.meta; const middlewareList = [ 'error-handler', 'i18next', - 'log', + 'audit-log', 'oidc-error-handler', 'slonik-error-handler', 'spa-proxy', diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 348b6c85f..0a52826a8 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -13,7 +13,6 @@ import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js'; import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js'; import koaErrorHandler from '#src/middleware/koa-error-handler.js'; import koaI18next from '#src/middleware/koa-i18next.js'; -import koaLog from '#src/middleware/koa-log.js'; import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js'; import koaRootProxy from '#src/middleware/koa-root-proxy.js'; import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js'; @@ -32,13 +31,11 @@ const logListening = () => { }; export default async function initApp(app: Koa): Promise { + app.use(koaLogger()); app.use(koaErrorHandler()); app.use(koaOIDCErrorHandler()); app.use(koaSlonikErrorHandler()); app.use(koaConnectorErrorHandler()); - - app.use(koaLog()); - app.use(koaLogger()); app.use(koaI18next()); const provider = await initOidc(app); diff --git a/packages/core/src/event-listeners/grant.test.ts b/packages/core/src/event-listeners/grant.test.ts new file mode 100644 index 000000000..70eb00725 --- /dev/null +++ b/packages/core/src/event-listeners/grant.test.ts @@ -0,0 +1,210 @@ +import type { LogKey } from '@logto/schemas'; +import { LogResult, token } from '@logto/schemas'; + +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; +import { stringifyError } from '#src/utils/format.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import { grantListener, grantRevocationListener } from './grant.js'; + +const { jest } = import.meta; + +const userId = 'userIdValue'; +const sessionId = 'sessionIdValue'; +const applicationId = 'applicationIdValue'; + +const log = createMockLogContext(); + +const entities = { + Account: { accountId: userId }, + Session: { jti: sessionId }, + Client: { clientId: applicationId }, +}; + +const baseCallArgs = { applicationId, sessionId, userId }; + +const testGrantListener = ( + parameters: { grant_type: string } & Record, + body: Record, + expectLogKey: LogKey, + expectLogTokenTypes: token.TokenType[], + expectError?: Error +) => { + const ctx = { + ...createContextWithRouteParameters(), + createLog: log.createLog, + oidc: { entities, params: parameters }, + body, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + grantListener(ctx, expectError); + expect(log.createLog).toHaveBeenCalledWith(expectLogKey); + expect(log.mockAppend).toHaveBeenCalledWith({ + ...baseCallArgs, + result: expectError && LogResult.Error, + tokenTypes: expectLogTokenTypes, + error: expectError && stringifyError(expectError), + params: parameters, + }); +}; + +describe('grantSuccessListener', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should log type ExchangeTokenBy when grant type is authorization_code', () => { + testGrantListener( + { grant_type: 'authorization_code', code: 'codeValue' }, + { + access_token: 'newAccessTokenValue', + refresh_token: 'newRefreshTokenValue', + id_token: 'newIdToken', + }, + 'ExchangeTokenBy.AuthorizationCode', + [token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken] + ); + }); + + it('should log type ExchangeTokenBy when grant type is refresh_code', () => { + testGrantListener( + { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' }, + { + access_token: 'newAccessTokenValue', + refresh_token: 'newRefreshTokenValue', + id_token: 'newIdToken', + }, + 'ExchangeTokenBy.RefreshToken', + [token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken] + ); + }); + + test('issued field should not contain "idToken" when there is no issued idToken', () => { + testGrantListener( + { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' }, + { access_token: 'newAccessTokenValue', refresh_token: 'newRefreshTokenValue' }, + 'ExchangeTokenBy.RefreshToken', + [token.TokenType.AccessToken, token.TokenType.RefreshToken] + ); + }); + + it('should log type ExchangeTokenBy when grant type is client_credentials', () => { + testGrantListener( + { grant_type: 'client_credentials' }, + { access_token: 'newAccessTokenValue' }, + 'ExchangeTokenBy.ClientCredentials', + [token.TokenType.AccessToken] + ); + }); + + it('should log type ExchangeTokenBy when grant type is unknown', () => { + testGrantListener( + { grant_type: 'foo' }, + { access_token: 'newAccessTokenValue' }, + 'ExchangeTokenBy.Unknown', + [token.TokenType.AccessToken] + ); + }); +}); + +describe('grantErrorListener', () => { + const errorMessage = 'error ocurred'; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should log type ExchangeTokenBy when error occurred', () => { + testGrantListener( + { grant_type: 'authorization_code', code: 'codeValue' }, + { + access_token: 'newAccessTokenValue', + refresh_token: 'newRefreshTokenValue', + id_token: 'newIdToken', + }, + 'ExchangeTokenBy.AuthorizationCode', + [token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken], + new Error(errorMessage) + ); + }); + + it('should log unknown grant when error occurred', () => { + testGrantListener( + { grant_type: 'foo', code: 'codeValue' }, + { access_token: 'newAccessTokenValue' }, + 'ExchangeTokenBy.Unknown', + [token.TokenType.AccessToken], + new Error(errorMessage) + ); + }); +}); + +describe('grantRevocationListener', () => { + const grantId = 'grantIdValue'; + const tokenValue = 'tokenValue'; + const parameters = { token: tokenValue }; + + const client = { clientId: applicationId }; + const accessToken = { accountId: userId }; + const refreshToken = { accountId: userId }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should log token types properly', () => { + const ctx = { + ...createContextWithRouteParameters(), + createLog: log.createLog, + oidc: { + entities: { Client: client, AccessToken: accessToken }, + params: parameters, + }, + body: { client_id: applicationId, token: tokenValue }, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + grantRevocationListener(ctx, grantId); + expect(log.createLog).toHaveBeenCalledWith('RevokeToken'); + expect(log.mockAppend).toHaveBeenCalledWith({ + applicationId, + userId, + params: parameters, + grantId, + tokenTypes: [token.TokenType.AccessToken], + }); + }); + + it('should log token types properly 2', () => { + const ctx = { + ...createContextWithRouteParameters(), + createLog: log.createLog, + oidc: { + entities: { + Client: client, + AccessToken: accessToken, + RefreshToken: refreshToken, + DeviceCode: 'mock', + }, + params: parameters, + }, + body: { client_id: applicationId, token: tokenValue }, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + grantRevocationListener(ctx, grantId); + expect(log.createLog).toHaveBeenCalledWith('RevokeToken'); + expect(log.mockAppend).toHaveBeenCalledWith({ + applicationId, + userId, + params: parameters, + grantId, + tokenTypes: [ + token.TokenType.AccessToken, + token.TokenType.RefreshToken, + token.TokenType.DeviceCode, + ], + }); + }); +}); diff --git a/packages/core/src/event-listeners/grant.ts b/packages/core/src/event-listeners/grant.ts new file mode 100644 index 000000000..8f18b3775 --- /dev/null +++ b/packages/core/src/event-listeners/grant.ts @@ -0,0 +1,90 @@ +import { GrantType, LogResult, token } from '@logto/schemas'; +import type { errors, KoaContextWithOIDC } from 'oidc-provider'; + +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; + +import { stringifyError } from '../utils/format.js'; +import { isEnum } from '../utils/type.js'; +import { extractInteractionContext } from './utils.js'; + +/** + * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/lib/actions/token.js#L71 Success event emission} + * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/lib/shared/error_handler.js OIDC Provider error handler} + */ +export const grantListener = ( + ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody }, + error?: errors.OIDCProviderError +) => { + const { params } = ctx.oidc; + + const log = ctx.createLog( + `${token.Flow.ExchangeTokenBy}.${getExchangeByType(params?.grant_type)}` + ); + + const { access_token, refresh_token, id_token, scope } = ctx.body; + const tokenTypes = [ + access_token && token.TokenType.AccessToken, + refresh_token && token.TokenType.RefreshToken, + id_token && token.TokenType.IdToken, + ].filter(Boolean); + + log.append({ + ...extractInteractionContext(ctx), + result: error && LogResult.Error, + tokenTypes, + scope, + error: error && stringifyError(error), + }); +}; + +// The grant.revoked event is emitted at https://github.com/panva/node-oidc-provider/blob/v7.x/lib/helpers/revoke.js#L25 +export const grantRevocationListener = ( + ctx: KoaContextWithOIDC & WithLogContext, + grantId: string +) => { + const { + entities: { AccessToken, RefreshToken }, + } = ctx.oidc; + + // TODO: Check if this is needed or just use `Account?.accountId` + const userId = AccessToken?.accountId ?? RefreshToken?.accountId; + const tokenTypes = getRevocationTokenTypes(ctx.oidc); + + const log = ctx.createLog('RevokeToken'); + log.append({ ...extractInteractionContext(ctx), userId, grantId, tokenTypes }); +}; + +/** + * @see {@link https://github.com/panva/node-oidc-provider/tree/v7.x/lib/actions/grants grants source code} for predefined grant implementations and types. + */ +type GrantBody = { + access_token?: string; + refresh_token?: string; + id_token?: string; + scope?: string; // AccessToken.scope +}; + +const grantTypeToExchangeByType: Record = { + [GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode, + [GrantType.RefreshToken]: token.ExchangeByType.RefreshToken, + [GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials, +}; + +const getExchangeByType = (grantType: unknown): token.ExchangeByType => { + if (!isEnum(Object.values(GrantType), grantType)) { + return token.ExchangeByType.Unknown; + } + + return grantTypeToExchangeByType[grantType]; +}; + +/** + * Note the revocation may revoke related tokens as well. In oidc-provider, it will revoke the whole Grant when revoking Refresh Token. + * So we don't assume the token type here. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc7009 OAuth 2.0 Token Revocation} for RFC reference. + * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/lib/actions/revocation.js#L56 this function} for code reference. + **/ +const getRevocationTokenTypes = (oidc: KoaContextWithOIDC['oidc']): token.TokenType[] => { + return Object.values(token.TokenType).filter((value) => oidc.entities[value]); +}; diff --git a/packages/core/src/event-listeners/index.test.ts b/packages/core/src/event-listeners/index.test.ts new file mode 100644 index 000000000..f58951acf --- /dev/null +++ b/packages/core/src/event-listeners/index.test.ts @@ -0,0 +1,24 @@ +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; + +import { grantListener, grantRevocationListener } from './grant.js'; +import { addOidcEventListeners } from './index.js'; +import { interactionEndedListener, interactionStartedListener } from './interaction.js'; + +const { jest } = import.meta; + +describe('addOidcEventListeners', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should add proper listeners', () => { + const provider = createMockProvider(); + const addListener = jest.spyOn(provider, 'addListener'); + addOidcEventListeners(provider); + expect(addListener).toHaveBeenCalledWith('grant.success', grantListener); + expect(addListener).toHaveBeenCalledWith('grant.error', grantListener); + expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevocationListener); + expect(addListener).toHaveBeenCalledWith('interaction.started', interactionStartedListener); + expect(addListener).toHaveBeenCalledWith('interaction.ended', interactionEndedListener); + }); +}); diff --git a/packages/core/src/event-listeners/index.ts b/packages/core/src/event-listeners/index.ts new file mode 100644 index 000000000..776aba380 --- /dev/null +++ b/packages/core/src/event-listeners/index.ts @@ -0,0 +1,16 @@ +import type { Provider } from 'oidc-provider'; + +import { grantListener, grantRevocationListener } from './grant.js'; +import { interactionEndedListener, interactionStartedListener } from './interaction.js'; + +/** + * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details Getting auth error with no details?} + * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md OIDC Provider events} + */ +export const addOidcEventListeners = (provider: Provider) => { + provider.addListener('grant.success', grantListener); + provider.addListener('grant.error', grantListener); + provider.addListener('grant.revoked', grantRevocationListener); + provider.addListener('interaction.started', interactionStartedListener); + provider.addListener('interaction.ended', interactionEndedListener); +}; diff --git a/packages/core/src/event-listeners/interaction.test.ts b/packages/core/src/event-listeners/interaction.test.ts new file mode 100644 index 000000000..2277eb037 --- /dev/null +++ b/packages/core/src/event-listeners/interaction.test.ts @@ -0,0 +1,76 @@ +import type { LogKey } from '@logto/schemas'; +import type { PromptDetail } from 'oidc-provider'; + +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import { interactionEndedListener, interactionStartedListener } from './interaction.js'; + +const { jest } = import.meta; + +const userId = 'userIdValue'; +const sessionId = 'sessionIdValue'; +const applicationId = 'applicationIdValue'; + +const log = createMockLogContext(); + +const entities = { + Account: { accountId: userId }, + Session: { jti: sessionId }, + Client: { clientId: applicationId }, +}; + +const prompt: PromptDetail = { + name: 'login', + reasons: ['foo', 'bar'], + details: { + foo: 'bar', + }, +}; + +const baseCallArgs = { applicationId, sessionId, userId }; + +const testInteractionListener = ( + listener: typeof interactionStartedListener | typeof interactionEndedListener, + parameters: { grant_type: string } & Record, + expectLogKey: LogKey, + expectPrompt?: PromptDetail +) => { + const ctx = { + ...createContextWithRouteParameters(), + createLog: log.createLog, + oidc: { entities, params: parameters }, + }; + + // @ts-expect-error pass complex type check to mock ctx directly + listener(ctx, expectPrompt); + expect(log.createLog).toHaveBeenCalledWith(expectLogKey); + expect(log.mockAppend).toHaveBeenCalledWith({ + ...baseCallArgs, + params: parameters, + prompt: expectPrompt, + }); +}; + +describe('interactionStartedListener', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should log proper interaction started info', async () => { + testInteractionListener( + interactionStartedListener, + { grant_type: 'authorization_code', code: 'codeValue' }, + 'Interaction.Create', + prompt + ); + }); + + it('should log proper interaction ended info', async () => { + testInteractionListener( + interactionEndedListener, + { grant_type: 'authorization_code', code: 'codeValue' }, + 'Interaction.End' + ); + }); +}); diff --git a/packages/core/src/event-listeners/interaction.ts b/packages/core/src/event-listeners/interaction.ts new file mode 100644 index 000000000..7799f63a5 --- /dev/null +++ b/packages/core/src/event-listeners/interaction.ts @@ -0,0 +1,25 @@ +import type { KoaContextWithOIDC, PromptDetail } from 'oidc-provider'; + +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; + +import { extractInteractionContext } from './utils.js'; + +const interactionListener = ( + event: 'started' | 'ended', + ctx: KoaContextWithOIDC & WithLogContext, + prompt?: PromptDetail +) => { + const log = ctx.createLog(`Interaction.${event === 'started' ? 'Create' : 'End'}`); + log.append({ ...extractInteractionContext(ctx), prompt }); +}; + +export const interactionStartedListener = ( + ctx: KoaContextWithOIDC & WithLogContext, + prompt: PromptDetail +) => { + interactionListener('started', ctx, prompt); +}; + +export const interactionEndedListener = (ctx: KoaContextWithOIDC & WithLogContext) => { + interactionListener('ended', ctx); +}; diff --git a/packages/core/src/event-listeners/utils.ts b/packages/core/src/event-listeners/utils.ts new file mode 100644 index 000000000..1cdb66763 --- /dev/null +++ b/packages/core/src/event-listeners/utils.ts @@ -0,0 +1,21 @@ +import type { IRouterParamContext } from 'koa-router'; +import type { KoaContextWithOIDC } from 'oidc-provider'; + +import type { LogPayload } from '#src/middleware/koa-audit-log.js'; + +export const extractInteractionContext = ( + ctx: IRouterParamContext & KoaContextWithOIDC +): LogPayload => { + const { + entities: { Account, Session, Client, Interaction }, + params, + } = ctx.oidc; + + return { + applicationId: Client?.clientId, + sessionId: Session?.jti, + interactionId: Interaction?.jti, + userId: Account?.accountId, + params, + }; +}; diff --git a/packages/core/src/middleware/koa-log.test.ts b/packages/core/src/middleware/koa-audit-log-legacy.test.ts similarity index 87% rename from packages/core/src/middleware/koa-log.test.ts rename to packages/core/src/middleware/koa-audit-log-legacy.test.ts index cedf4346f..cda3d0226 100644 --- a/packages/core/src/middleware/koa-log.test.ts +++ b/packages/core/src/middleware/koa-audit-log-legacy.test.ts @@ -1,12 +1,12 @@ -import type { LogPayload } from '@logto/schemas'; import { LogResult } from '@logto/schemas'; +import type { LogPayload } from '@logto/schemas/lib/types/log-legacy.js'; import { mockEsm, pickDefault } from '@logto/shared/esm'; import i18next from 'i18next'; import RequestError from '#src/errors/RequestError/index.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import type { WithLogContext } from './koa-log.js'; +import type { WithLogContextLegacy } from './koa-audit-log-legacy.js'; const { jest } = import.meta; @@ -23,7 +23,7 @@ mockEsm('nanoid', () => ({ nanoid: () => nanoIdMock, })); -const koaLog = await pickDefault(import('./koa-log.js')); +const koaLog = await pickDefault(import('./koa-audit-log-legacy.js')); describe('koaLog middleware', () => { const type = 'SignInUsernamePassword'; @@ -41,7 +41,7 @@ describe('koaLog middleware', () => { }); it('should insert a success log when next() does not throw an error', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), // Bypass middleware context type assert addLogContext, @@ -70,7 +70,7 @@ describe('koaLog middleware', () => { }); it('should not insert a log when there is no log type', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), // Bypass middleware context type assert addLogContext, @@ -86,7 +86,7 @@ describe('koaLog middleware', () => { describe('should insert an error log with the error message when next() throws an error', () => { it('should log with error message when next throws a normal Error', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), // Bypass middleware context type assert addLogContext, @@ -117,7 +117,7 @@ describe('koaLog middleware', () => { }); it('should insert an error log with the error body when next() throws a RequestError', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), // Bypass middleware context type assert addLogContext, diff --git a/packages/core/src/middleware/koa-log.ts b/packages/core/src/middleware/koa-audit-log-legacy.ts similarity index 78% rename from packages/core/src/middleware/koa-log.ts rename to packages/core/src/middleware/koa-audit-log-legacy.ts index 5c406f624..a1469caba 100644 --- a/packages/core/src/middleware/koa-log.ts +++ b/packages/core/src/middleware/koa-audit-log-legacy.ts @@ -1,5 +1,10 @@ -import type { BaseLogPayload, LogPayload, LogPayloads, LogType } from '@logto/schemas'; import { LogResult } from '@logto/schemas'; +import type { + BaseLogPayload, + LogPayload, + LogPayloads, + LogType, +} from '@logto/schemas/lib/types/log-legacy.js'; import deepmerge from 'deepmerge'; import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -18,13 +23,15 @@ type SessionPayload = { type AddLogContext = (sessionPayload: SessionPayload) => void; -export type LogContext = { +/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */ +export type LogContextLegacy = { addLogContext: AddLogContext; log: MergeLog; }; -export type WithLogContext = ContextT & - LogContext; +/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */ +export type WithLogContextLegacy = + ContextT & LogContextLegacy; type Logger = { type?: LogType; @@ -77,11 +84,12 @@ const initLogger = (basePayload?: Readonly) => { }; /* eslint-enable @silverhand/fp/no-mutation */ -export default function koaLog< +/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */ +export default function koaAuditLogLegacy< StateT, ContextT extends IRouterParamContext, ResponseBodyT ->(): MiddlewareType, ResponseBodyT> { +>(): MiddlewareType, ResponseBodyT> { return async (ctx, next) => { const { ip, diff --git a/packages/core/src/middleware/koa-audit-log.test.ts b/packages/core/src/middleware/koa-audit-log.test.ts new file mode 100644 index 000000000..7e76a6cb9 --- /dev/null +++ b/packages/core/src/middleware/koa-audit-log.test.ts @@ -0,0 +1,191 @@ +import type { LogKey } from '@logto/schemas'; +import { LogResult } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; +import i18next from 'i18next'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { WithLogContext, LogPayload } from './koa-audit-log.js'; + +const { jest } = import.meta; + +const nanoIdMock = 'mockId'; + +const { insertLog } = mockEsm('#src/queries/log.js', () => ({ + insertLog: jest.fn(), +})); + +mockEsm('nanoid', () => ({ + nanoid: () => nanoIdMock, +})); + +const koaLog = await pickDefault(import('./koa-audit-log.js')); + +describe('koaAuditLog middleware', () => { + const logKey: LogKey = 'Interaction.SignIn.Identifier.VerificationCode.Submit'; + const mockPayload: LogPayload = { + userId: 'foo', + username: 'Bar', + }; + + const ip = '192.168.0.1'; + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should insert a success log when next() does not throw an error', async () => { + // @ts-expect-error for testing + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + }; + ctx.request.ip = ip; + const additionalMockPayload: LogPayload = { foo: 'bar' }; + + const next = async () => { + const log = ctx.createLog(logKey); + log.append(mockPayload); + log.append(additionalMockPayload); + }; + await koaLog()(ctx, next); + + expect(insertLog).toBeCalledWith({ + id: nanoIdMock, + type: logKey, + payload: { + ...mockPayload, + ...additionalMockPayload, + key: logKey, + result: LogResult.Success, + ip, + userAgent, + }, + }); + }); + + it('should insert multiple success logs when needed', async () => { + // @ts-expect-error for testing + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + }; + ctx.request.ip = ip; + const additionalMockPayload: LogPayload = { foo: 'bar' }; + + const next = async () => { + const log = ctx.createLog(logKey); + log.append(mockPayload); + log.append(additionalMockPayload); + const log2 = ctx.createLog(logKey); + log2.append(mockPayload); + }; + await koaLog()(ctx, next); + + const basePayload = { + ...mockPayload, + key: logKey, + result: LogResult.Success, + ip, + userAgent, + }; + + expect(insertLog).toHaveBeenCalledWith({ + id: nanoIdMock, + type: logKey, + payload: basePayload, + }); + expect(insertLog).toHaveBeenCalledWith({ + id: nanoIdMock, + type: logKey, + payload: { + ...basePayload, + ...additionalMockPayload, + }, + }); + }); + + it('should not log when there is no log type', async () => { + // @ts-expect-error for testing + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + }; + ctx.request.ip = ip; + + // eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function + const next = async () => {}; + await koaLog()(ctx, next); + expect(insertLog).not.toBeCalled(); + }); + + describe('should insert an error log with the error message when next() throws an error', () => { + it('should log with error message when next throws a normal Error', async () => { + // @ts-expect-error for testing + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + }; + ctx.request.ip = ip; + + const message = 'Normal error'; + const error = new Error(message); + + const next = async () => { + const log = ctx.createLog(logKey); + log.append(mockPayload); + throw error; + }; + await expect(koaLog()(ctx, next)).rejects.toMatchError(error); + + expect(insertLog).toBeCalledWith({ + id: nanoIdMock, + type: logKey, + payload: { + ...mockPayload, + key: logKey, + result: LogResult.Error, + error: { message: `Error: ${message}` }, + ip, + userAgent, + }, + }); + }); + + it('should update all logs with error result when next() throws a RequestError', async () => { + // @ts-expect-error for testing + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + }; + ctx.request.ip = ip; + + const message = 'Error message'; + jest.spyOn(i18next, 't').mockReturnValueOnce(message); // Used in + const code = 'connector.general'; + const data = { foo: 'bar', num: 123 }; + const error = new RequestError(code, data); + + const next = async () => { + const log = ctx.createLog(logKey); + log.append(mockPayload); + const log2 = ctx.createLog(logKey); + log2.append(mockPayload); + throw error; + }; + await expect(koaLog()(ctx, next)).rejects.toMatchError(error); + + expect(insertLog).toHaveBeenCalledTimes(2); + expect(insertLog).toBeCalledWith({ + id: nanoIdMock, + type: logKey, + payload: { + ...mockPayload, + key: logKey, + result: LogResult.Error, + error: { message, code, data }, + ip, + userAgent, + }, + }); + }); + }); +}); diff --git a/packages/core/src/middleware/koa-audit-log.ts b/packages/core/src/middleware/koa-audit-log.ts new file mode 100644 index 000000000..50916395a --- /dev/null +++ b/packages/core/src/middleware/koa-audit-log.ts @@ -0,0 +1,149 @@ +import type { LogContextPayload, LogKey } from '@logto/schemas'; +import { LogResult } from '@logto/schemas'; +import type { MiddlewareType } from 'koa'; +import type { IRouterParamContext } from 'koa-router'; +import pick from 'lodash.pick'; +import { nanoid } from 'nanoid'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { insertLog } from '#src/queries/log.js'; + +const removeUndefinedKeys = (object: Record) => + Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)); + +export class LogEntry { + payload: LogContextPayload; + + constructor(public readonly key: LogKey) { + this.payload = { + key, + result: LogResult.Success, + }; + } + + /** Update payload by spreading `data` first, then spreading `this.payload`. */ + prepend(data: Readonly) { + this.payload = { + ...removeUndefinedKeys(data), + ...this.payload, + }; + } + + /** Update payload by spreading `this.payload` first, then spreading `data`. */ + append(data: Readonly) { + this.payload = { + ...this.payload, + ...removeUndefinedKeys(data), + }; + } +} + +export type LogPayload = Partial & Record; + +export type LogContext = { + createLog: (key: LogKey) => LogEntry; + prependAllLogEntries: (payload: LogPayload) => void; +}; + +export type WithLogContext = ContextT & + LogContext; + +/** + * The factory to create a new audit log middleware function. + * It will inject a `createLog` function the context to enable audit logging. + * + * #### Create a log entry + * + * You need to explicitly call `ctx.createLog()` to create a new {@link LogEntry} instance, + * which accepts a read-only parameter {@link LogKey} thus the log can be categorized and indexed in database. + * + * ```ts + * const log = ctx.createLog('Interaction.Create'); // Key is typed + * ``` + * + * Note every time you call `ctx.createLog()`, it will create a new log entry instance for inserting. So multiple log entries may be inserted within one request. + * + * Remember to keep the log entry instance properly if you want to collect log data from multiple places. + * + * #### Log data + * + * To update log payload, call `log.append()`. It will use object spread operators to update payload (i.e. merge with one-level overwrite and shallow copy). + * + * ```ts + * log.append({ applicationId: 'foo' }); + * ``` + * + * This function can be called multiple times. + * + * #### Log context + * + * By default, before inserting the logs, it will extract the request context and prepend request IP and User Agent to every log entry: + * + * ```ts + * { + * ip: 'request-ip-addr', + * userAgent: 'request-user-agent', + * ...log.payload, + * } + * ``` + * + * To add more common data to log entries, try to create another middleware function after this one, and call `ctx.prependAllLogEntries()`. + * + * @returns An audit log middleware function. + * @see {@link LogKey} for all available log keys, and {@link LogResult} for result enums. + * @see {@link LogContextPayload} for the basic type suggestion of log data. + */ +export default function koaAuditLog< + StateT, + ContextT extends IRouterParamContext, + ResponseBodyT +>(): MiddlewareType, ResponseBodyT> { + return async (ctx, next) => { + const entries: LogEntry[] = []; + + ctx.createLog = (key: LogKey) => { + const entry = new LogEntry(key); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + entries.push(entry); + + return entry; + }; + + ctx.prependAllLogEntries = (payload) => { + for (const entry of entries) { + entry.prepend(payload); + } + }; + + try { + await next(); + } catch (error: unknown) { + for (const entry of entries) { + entry.append({ + result: LogResult.Error, + error: + error instanceof RequestError + ? pick(error, 'message', 'code', 'data') + : { message: String(error) }, + }); + } + throw error; + } finally { + // Predefined context + const { + ip, + headers: { 'user-agent': userAgent }, + } = ctx.request; + + await Promise.all( + entries.map(async ({ payload }) => { + return insertLog({ + id: nanoid(), + type: payload.key, + payload: { ip, userAgent, ...payload }, + }); + }) + ); + } + }; +} diff --git a/packages/core/src/middleware/koa-log-session.test.ts b/packages/core/src/middleware/koa-log-session-legacy.test.ts similarity index 62% rename from packages/core/src/middleware/koa-log-session.test.ts rename to packages/core/src/middleware/koa-log-session-legacy.test.ts index 2cfb4467c..255ce87fb 100644 --- a/packages/core/src/middleware/koa-log-session.test.ts +++ b/packages/core/src/middleware/koa-log-session-legacy.test.ts @@ -1,7 +1,7 @@ import { Provider } from 'oidc-provider'; -import koaLogSession from '#src/middleware/koa-log-session.js'; -import type { WithLogContext } from '#src/middleware/koa-log.js'; +import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; +import koaLogSessionLegacy from '#src/middleware/koa-log-session-legacy.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; const { jest } = import.meta; @@ -9,7 +9,7 @@ const { jest } = import.meta; const provider = new Provider('https://logto.test'); const interactionDetails = jest.spyOn(provider, 'interactionDetails'); -describe('koaLogSession', () => { +describe('koaLogSessionLegacy', () => { const sessionId = 'sessionId'; const applicationId = 'applicationId'; const addLogContext = jest.fn(); @@ -29,40 +29,40 @@ describe('koaLogSession', () => { }); it('should get session info from the provider', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters(), addLogContext, log, }; - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); expect(interactionDetails).toHaveBeenCalled(); }); it('should log session id and application id', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters(), addLogContext, log, }; - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId }); }); it('should call next', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters(), addLogContext, log, }; - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); expect(next).toHaveBeenCalled(); }); it('should not throw when interactionDetails throw error', async () => { - const ctx: WithLogContext> = { + const ctx: WithLogContextLegacy> = { ...createContextWithRouteParameters(), addLogContext, log, @@ -72,6 +72,6 @@ describe('koaLogSession', () => { throw new Error('message'); }); - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); }); }); diff --git a/packages/core/src/middleware/koa-log-session-legacy.ts b/packages/core/src/middleware/koa-log-session-legacy.ts new file mode 100644 index 000000000..2b0168786 --- /dev/null +++ b/packages/core/src/middleware/koa-log-session-legacy.ts @@ -0,0 +1,25 @@ +import type { MiddlewareType } from 'koa'; +import type { Provider } from 'oidc-provider'; + +import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; + +/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */ +export default function koaLogSessionLegacy< + StateT, + ContextT extends WithLogContextLegacy, + ResponseBodyT +>(provider: Provider): MiddlewareType { + return async (ctx, next) => { + await next(); + + try { + const { + jti, + params: { client_id }, + } = await provider.interactionDetails(ctx.req, ctx.res); + ctx.addLogContext({ sessionId: jti, applicationId: String(client_id) }); + } catch (error: unknown) { + console.error(`Failed to get oidc provider interaction`, error); + } + }; +} diff --git a/packages/core/src/middleware/koa-log-session.ts b/packages/core/src/middleware/koa-log-session.ts deleted file mode 100644 index dcc2e7111..000000000 --- a/packages/core/src/middleware/koa-log-session.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MiddlewareType } from 'koa'; -import type { Provider } from 'oidc-provider'; - -import type { WithLogContext } from '#src/middleware/koa-log.js'; - -export default function koaLogSession( - provider: Provider -): MiddlewareType { - return async (ctx, next) => { - await next(); - - try { - const { - jti, - params: { client_id }, - } = await provider.interactionDetails(ctx.req, ctx.res); - ctx.addLogContext({ sessionId: jti, applicationId: String(client_id) }); - } catch (error: unknown) { - console.error(`"${ctx.url}" failed to get oidc provider interaction`, error); - } - }; -} diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 25ac84417..cf58f11ba 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -11,6 +11,8 @@ import { Provider, errors } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; import envSet from '#src/env-set/index.js'; +import { addOidcEventListeners } from '#src/event-listeners/index.js'; +import koaAuditLog from '#src/middleware/koa-audit-log.js'; import postgresAdapter from '#src/oidc/adapter.js'; import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js'; import { findApplicationById } from '#src/queries/application.js'; @@ -18,7 +20,6 @@ import { findResourceByIndicator } from '#src/queries/resource.js'; import { findUserById } from '#src/queries/user.js'; import { routes } from '#src/routes/consts.js'; import assertThat from '#src/utils/assert-that.js'; -import { addOidcEventListeners } from '#src/utils/oidc-provider-event-listener.js'; import { claimToUserKey, getUserClaims } from './scope.js'; @@ -188,6 +189,9 @@ export default async function initOidc(app: Koa): Promise { addOidcEventListeners(oidc); + // Provide audit log context for event listeners + oidc.use(koaAuditLog()); + app.use(mount('/oidc', oidc.app)); return oidc; diff --git a/packages/core/src/queries/log.ts b/packages/core/src/queries/log.ts index 623027ec9..2b3acffac 100644 --- a/packages/core/src/queries/log.ts +++ b/packages/core/src/queries/log.ts @@ -1,5 +1,5 @@ -import type { CreateLog, Log, LogType } from '@logto/schemas'; -import { Logs } from '@logto/schemas'; +import type { CreateLog, Log } from '@logto/schemas'; +import { token, Logs } from '@logto/schemas'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; @@ -50,10 +50,6 @@ export const findLogs = async (limit: number, offset: number, logCondition: LogC export const findLogById = buildFindEntityById(Logs); -// The active user should exchange the tokens by the authorization code (i.e. sign-in) -// or exchange the access token, which will expire in 2 hours, by the refresh token. -const activeUserLogTypes: LogType[] = ['CodeExchangeToken', 'RefreshTokenExchangeToken']; - export const getDailyActiveUserCountsByTimeInterval = async ( startTimeExclusive: number, endTimeInclusive: number @@ -63,7 +59,7 @@ export const getDailyActiveUserCountsByTimeInterval = async ( from ${table} where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000) and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000) - and ${fields.type} in (${sql.join(activeUserLogTypes, sql`, `)}) + and ${fields.type} like ${`${token.Flow.ExchangeTokenBy}.%`} and ${fields.payload}->>'result' = 'Success' group by date(${fields.createdAt}) `); @@ -77,6 +73,6 @@ export const countActiveUsersByTimeInterval = async ( from ${table} where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000) and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000) - and ${fields.type} in (${sql.join(activeUserLogTypes, sql`, `)}) + and ${fields.type} like ${`${token.Flow.ExchangeTokenBy}.%`} and ${fields.payload}->>'result' = 'Success' `); diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 474a586a8..b8a70d671 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -4,8 +4,10 @@ import mount from 'koa-mount'; import Router from 'koa-router'; import type { Provider } from 'oidc-provider'; +import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js'; + import koaAuth from '../middleware/koa-auth.js'; -import koaLogSession from '../middleware/koa-log-session.js'; +import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js'; import adminUserRoutes from './admin-user.js'; import applicationRoutes from './application.js'; import authnRoutes from './authn.js'; @@ -23,16 +25,15 @@ import settingRoutes from './setting.js'; import signInExperiencesRoutes from './sign-in-experience.js'; import statusRoutes from './status.js'; import swaggerRoutes from './swagger.js'; -import type { AnonymousRouter, AuthedRouter } from './types.js'; +import type { AnonymousRouter, AnonymousRouterLegacy, AuthedRouter } from './types.js'; import wellKnownRoutes from './well-known.js'; const createRouters = (provider: Provider) => { - const sessionRouter: AnonymousRouter = new Router(); - sessionRouter.use(koaLogSession(provider)); + const sessionRouter: AnonymousRouterLegacy = new Router(); + sessionRouter.use(koaAuditLogLegacy(), koaLogSessionLegacy(provider)); sessionRoutes(sessionRouter, provider); const interactionRouter: AnonymousRouter = new Router(); - interactionRouter.use(koaLogSession(provider)); interactionRoutes(interactionRouter, provider); const managementRouter: AuthedRouter = new Router(); @@ -72,6 +73,7 @@ export default function initRouter(app: Koa, provider: Provider) { const apisApp = new Koa(); for (const router of createRouters(provider)) { + // @ts-expect-error will remove once interaction refactor finished apisApp.use(router.routes()).use(router.allowedMethods()); } diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index 33884a4b2..44f60ce16 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -1,6 +1,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, pickDefault } from '@logto/shared/esm'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -51,10 +52,9 @@ jest.useFakeTimers().setSystemTime(now); describe('submit action', () => { const provider = createMockProvider(); - const log = jest.fn(); const ctx: InteractionContext = { ...createContextWithRouteParameters(), - log, + ...createMockLogContext(), interactionPayload: { event: Event.SignIn }, }; const profile = { diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index ca0e34c18..cb073317f 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -5,6 +5,8 @@ import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/ import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; import RequestError from '#src/errors/RequestError/index.js'; +import type koaAuditLog from '#src/middleware/koa-audit-log.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createRequester } from '#src/utils/test-utils.js'; @@ -76,7 +78,17 @@ const { getInteractionStorage } = mockEsm('./utils/interaction.js', () => ({ getInteractionStorage: jest.fn(), })); -const log = jest.fn(); +const { createLog, prependAllLogEntries } = createMockLogContext(); +mockEsmDefault( + '#src/middleware/koa-audit-log.js', + // eslint-disable-next-line unicorn/consistent-function-scoping + (): typeof koaAuditLog => () => async (ctx, next) => { + ctx.createLog = createLog; + ctx.prependAllLogEntries = prependAllLogEntries; + + return next(); + } +); const koaInteractionBodyGuard = await pickDefault( import('./middleware/koa-interaction-body-guard.js') @@ -107,14 +119,6 @@ describe('session -> interactionRoutes', () => { provider: createMockProvider( jest.fn().mockResolvedValue({ params: {}, jti: 'jti', client_id: demoAppApplicationId }) ), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = log; - - return next(); - }, - ], }); afterEach(() => { @@ -244,7 +248,7 @@ describe('session -> interactionRoutes', () => { }; const response = await sessionRequest.post(path).send(body); - expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', log); + expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', createLog); expect(response.status).toEqual(204); }); }); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 5c7ef9bae..57c357704 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -1,9 +1,11 @@ import type { LogtoErrorCode } from '@logto/phrases'; import { Event } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import type { Provider } from 'oidc-provider'; import RequestError from '#src/errors/RequestError/index.js'; import { assignInteractionResults } from '#src/libraries/session.js'; +import koaAuditLog from '#src/middleware/koa-audit-log.js'; import koaGuard from '#src/middleware/koa-guard.js'; import assertThat from '#src/utils/assert-that.js'; @@ -28,6 +30,24 @@ export default function interactionRoutes( router: T, provider: Provider ) { + router.use(koaAuditLog(), async (ctx, next) => { + await next(); + + // Prepend interaction context to log entries + try { + const { + jti, + params: { client_id }, + } = await provider.interactionDetails(ctx.req, ctx.res); + ctx.prependAllLogEntries({ + sessionId: jti, + applicationId: conditional(typeof client_id === 'string' && client_id), + }); + } catch (error: unknown) { + console.error(`Failed to get oidc provider interaction details`, error); + } + }); + router.put( interactionPrefix, koaInteractionBodyGuard(), @@ -117,7 +137,7 @@ export default function interactionRoutes( async (ctx, next) => { // Check interaction session const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.log); + await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.createLog); ctx.status = 204; diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index 8ff0f49e8..f53c3139b 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -11,6 +11,7 @@ import type { IRouterParamContext } from 'koa-router'; import type { z } from 'zod'; import type { SocialUserInfo } from '#src/connectors/types.js'; +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js'; import type { @@ -108,7 +109,9 @@ export type VerifiedInteractionResult = | VerifiedSignInInteractionResult | VerifiedForgotPasswordInteractionResult; -export type InteractionContext = WithGuardedIdentifierPayloadContext; +export type InteractionContext = WithGuardedIdentifierPayloadContext< + WithLogContext +>; export type UserIdentity = | { username: string } diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts index 764b85793..a6d7d65db 100644 --- a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts +++ b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts @@ -1,6 +1,8 @@ import { PasscodeType, Event } from '@logto/schemas'; import { mockEsmWithActual } from '@logto/shared/esm'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; + import type { SendPasscodePayload } from '../types/index.js'; const { jest } = import.meta; @@ -41,7 +43,7 @@ const sendPasscodeTestCase = [ ]; describe('passcode-validation utils', () => { - const log = jest.fn(); + const log = createMockLogContext(); afterEach(() => { jest.clearAllMocks(); @@ -50,7 +52,7 @@ describe('passcode-validation utils', () => { it.each(sendPasscodeTestCase)( 'send passcode successfully', async ({ payload, createPasscodeParams }) => { - await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log); + await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log.createLog); expect(passcode.createPasscode).toBeCalledWith('jti', ...createPasscodeParams); expect(passcode.sendPasscode).toBeCalled(); } diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.ts b/packages/core/src/routes/interaction/utils/passcode-validation.ts index abb5c04ed..d9a31fc9f 100644 --- a/packages/core/src/routes/interaction/utils/passcode-validation.ts +++ b/packages/core/src/routes/interaction/utils/passcode-validation.ts @@ -2,8 +2,7 @@ import type { Event } from '@logto/schemas'; import { PasscodeType } from '@logto/schemas'; import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js'; -import type { LogContext } from '#src/middleware/koa-log.js'; -import { getPasswordlessRelatedLogType } from '#src/routes/session/utils.js'; +import type { LogContext } from '#src/middleware/koa-audit-log.js'; import type { SendPasscodePayload, PasscodeIdentifierPayload } from '../types/index.js'; @@ -22,41 +21,31 @@ const getPasscodeTypeByEvent = (event: Event): PasscodeType => eventToPasscodeTy export const sendPasscodeToIdentifier = async ( payload: SendPasscodePayload, jti: string, - log: LogContext['log'] + createLog: LogContext['createLog'] ) => { const { event, ...identifier } = payload; const passcodeType = getPasscodeTypeByEvent(event); - const logType = getPasswordlessRelatedLogType( - passcodeType, - 'email' in identifier ? 'email' : 'sms', - 'send' - ); - - log(logType, identifier); + const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Create`); + log.append(identifier); const passcode = await createPasscode(jti, passcodeType, identifier); - const { dbEntry } = await sendPasscode(passcode); - log(logType, { connectorId: dbEntry.id }); + log.append({ connectorId: dbEntry.id }); }; export const verifyIdentifierByPasscode = async ( payload: PasscodeIdentifierPayload & { event: Event }, jti: string, - log: LogContext['log'] + createLog: LogContext['createLog'] ) => { const { event, passcode, ...identifier } = payload; const passcodeType = getPasscodeTypeByEvent(event); - const logType = getPasswordlessRelatedLogType( - passcodeType, - 'email' in identifier ? 'email' : 'sms', - 'verify' - ); - - log(logType, identifier); + // TODO: @Simeng maybe we should just log all interaction payload in every request? + const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`); + log.append(identifier); await verifyPasscode(jti, passcodeType, passcode, identifier); }; diff --git a/packages/core/src/routes/interaction/utils/social-verification.test.ts b/packages/core/src/routes/interaction/utils/social-verification.test.ts index 7774fc729..910f17849 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.test.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.test.ts @@ -1,6 +1,8 @@ import { ConnectorType } from '@logto/connector-kit'; import { mockEsm } from '@logto/shared/esm'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; + const { jest } = import.meta; const { getUserInfoByAuthCode } = mockEsm('#src/libraries/social.js', () => ({ @@ -18,13 +20,13 @@ mockEsm('#src/connectors.js', () => ({ })); const { verifySocialIdentity } = await import('./social-verification.js'); -const log = jest.fn(); +const log = createMockLogContext(); describe('social-verification', () => { it('verifySocialIdentity', async () => { const connectorId = 'connector'; const connectorData = { authCode: 'code' }; - const userInfo = await verifySocialIdentity({ connectorId, connectorData }, log); + const userInfo = await verifySocialIdentity({ connectorId, connectorData }, log.createLog); expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData); expect(userInfo).toEqual({ id: 'foo' }); diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index 0574dfb54..a6cda4038 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -1,10 +1,10 @@ -import type { SocialConnectorPayload, LogType } from '@logto/schemas'; +import type { SocialConnectorPayload } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas'; import { getLogtoConnectorById } from '#src/connectors/index.js'; import type { SocialUserInfo } from '#src/connectors/types.js'; import { getUserInfoByAuthCode } from '#src/libraries/social.js'; -import type { LogContext } from '#src/middleware/koa-log.js'; +import type { LogContext } from '#src/middleware/koa-audit-log.js'; import assertThat from '#src/utils/assert-that.js'; import type { SocialAuthorizationUrlPayload } from '../types/index.js'; @@ -22,14 +22,14 @@ export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationU export const verifySocialIdentity = async ( { connectorId, connectorData }: SocialConnectorPayload, - log: LogContext['log'] + createLog: LogContext['createLog'] ): Promise => { - const logType: LogType = 'SignInSocial'; - log(logType, { connectorId, connectorData }); + const log = createLog('Interaction.SignIn.Identifier.Social.Submit'); + log.append({ connectorId, connectorData }); const userInfo = await getUserInfoByAuthCode(connectorId, connectorData); - log(logType, userInfo); + log.append(userInfo); return userInfo; }; diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts index d9a360cd2..8c373077f 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts @@ -2,6 +2,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -31,10 +32,10 @@ const identifierPayloadVerification = await pickDefault( import('./identifier-payload-verification.js') ); -const log = jest.fn(); +const logContext = createMockLogContext(); describe('identifier verification', () => { - const baseCtx = { ...createContextWithRouteParameters(), log }; + const baseCtx = { ...createContextWithRouteParameters(), ...logContext }; afterEach(() => { jest.clearAllMocks(); @@ -152,7 +153,7 @@ describe('identifier verification', () => { expect(verifyIdentifierByPasscode).toBeCalledWith( { ...identifier, event: Event.SignIn }, 'jti', - log + logContext.createLog ); expect(result).toEqual({ @@ -176,7 +177,7 @@ describe('identifier verification', () => { expect(verifyIdentifierByPasscode).toBeCalledWith( { ...identifier, event: Event.SignIn }, 'jti', - log + logContext.createLog ); expect(result).toEqual({ @@ -198,7 +199,7 @@ describe('identifier verification', () => { const result = await identifierPayloadVerification(ctx, createMockProvider()); - expect(verifySocialIdentity).toBeCalledWith(identifier, log); + expect(verifySocialIdentity).toBeCalledWith(identifier, logContext.createLog); expect(findUserByIdentifier).not.toBeCalled(); expect(result).toEqual({ @@ -323,7 +324,7 @@ describe('identifier verification', () => { expect(verifyIdentifierByPasscode).toBeCalledWith( { ...identifier, event: Event.SignIn }, 'jti', - log + logContext.createLog ); expect(result).toEqual({ diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts index bbf6c7a13..1c8224778 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -46,7 +46,7 @@ const verifyPasscodeIdentifier = async ( ): Promise => { const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log); + await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.createLog); return 'email' in identifier ? { key: 'emailVerified', value: identifier.email } @@ -57,7 +57,7 @@ const verifySocialIdentifier = async ( identifier: SocialConnectorPayload, ctx: InteractionContext ): Promise => { - const userInfo = await verifySocialIdentity(identifier, ctx.log); + const userInfo = await verifySocialIdentity(identifier, ctx.createLog); return { key: 'social', connectorId: identifier.connectorId, userInfo }; }; diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts index 8647d8aff..867c39881 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts @@ -2,6 +2,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -25,7 +26,7 @@ const verifyProfile = await pickDefault(import('./profile-verification.js')); describe('forgot password interaction profile verification', () => { const provider = createMockProvider(); - const baseCtx = createContextWithRouteParameters(); + const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() }; const interaction = { event: Event.ForgotPassword, diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts index 992b361cb..2782ca4b5 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts @@ -2,6 +2,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -32,7 +33,7 @@ const verifyProfile = await pickDefault(import('./profile-verification.js')); describe('Should throw when providing existing identifiers in profile', () => { const provider = createMockProvider(); - const baseCtx = createContextWithRouteParameters(); + const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() }; const identifiers: Identifier[] = [ { key: 'accountId', value: 'foo' }, { key: 'emailVerified', value: 'email' }, diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts index 295af32bd..e01d98a97 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts @@ -2,6 +2,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -34,7 +35,7 @@ mockEsm('#src/connectors/index.js', () => ({ const verifyProfile = await pickDefault(import('./profile-verification.js')); -const baseCtx = createContextWithRouteParameters(); +const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() }; const identifiers: Identifier[] = [ { key: 'accountId', value: 'foo' }, { key: 'emailVerified', value: 'email@logto.io' }, diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts index 571a94aad..b93b6e6e3 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts @@ -2,6 +2,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -29,7 +30,7 @@ mockEsm('#src/connectors/index.js', () => ({ const verifyProfile = await pickDefault(import('./profile-verification.js')); describe('profile protected identifier verification', () => { - const baseCtx = createContextWithRouteParameters(); + const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() }; const interaction = { event: Event.SignIn, accountId: 'foo' }; const provider = createMockProvider(); diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts index 5d63f98eb..b0cd5c756 100644 --- a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts @@ -2,6 +2,7 @@ import { Event } from '@logto/schemas'; import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -26,6 +27,7 @@ describe('userAccountVerification', () => { const ctx: InteractionContext = { ...createContextWithRouteParameters(), + ...createMockLogContext(), interactionPayload: { event: Event.SignIn, }, diff --git a/packages/core/src/routes/log.ts b/packages/core/src/routes/log.ts index 29772854b..53d84fe62 100644 --- a/packages/core/src/routes/log.ts +++ b/packages/core/src/routes/log.ts @@ -24,6 +24,7 @@ export default function logRoutes(router: T) { query: { userId, applicationId, logType }, } = ctx.guard; + // TODO: @Gao refactor like user search const [{ count }, logs] = await Promise.all([ countLogs({ logType, applicationId, userId }), findLogs(limit, offset, { logType, userId, applicationId }), diff --git a/packages/core/src/routes/session/continue.test.ts b/packages/core/src/routes/session/continue.test.ts index 9471bdba2..d46fa0424 100644 --- a/packages/core/src/routes/session/continue.test.ts +++ b/packages/core/src/routes/session/continue.test.ts @@ -51,6 +51,7 @@ afterEach(() => { describe('session -> continueRoutes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: continueRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/continue.ts b/packages/core/src/routes/session/continue.ts index 79ef06767..dd91d68ed 100644 --- a/packages/core/src/routes/session/continue.ts +++ b/packages/core/src/routes/session/continue.ts @@ -19,7 +19,7 @@ import { } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types.js'; +import type { AnonymousRouterLegacy } from '../types.js'; import { continueEmailSessionResultGuard, continueSmsSessionResultGuard } from './types.js'; import { checkRequiredProfile, @@ -31,7 +31,10 @@ import { export const continueRoute = getRoutePrefix('sign-in', 'continue'); -export default function continueRoutes(router: T, provider: Provider) { +export default function continueRoutes( + router: T, + provider: Provider +) { router.post( `${continueRoute}/password`, koaGuard({ diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index f4333cab0..88630d211 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -74,6 +74,7 @@ afterEach(() => { describe('session -> forgotPasswordRoutes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: forgotPasswordRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts index e5a2acdb7..b2a8aab18 100644 --- a/packages/core/src/routes/session/forgot-password.ts +++ b/packages/core/src/routes/session/forgot-password.ts @@ -9,7 +9,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import { findUserById, updateUserById } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types.js'; +import type { AnonymousRouterLegacy } from '../types.js'; import { forgotPasswordSessionResultGuard } from './types.js'; import { clearVerificationResult, @@ -20,7 +20,7 @@ import { export const forgotPasswordRoute = getRoutePrefix('forgot-password'); -export default function forgotPasswordRoutes( +export default function forgotPasswordRoutes( router: T, provider: Provider ) { diff --git a/packages/core/src/routes/session/index.test.ts b/packages/core/src/routes/session/index.test.ts index 3d3a4aedb..de1577edc 100644 --- a/packages/core/src/routes/session/index.test.ts +++ b/packages/core/src/routes/session/index.test.ts @@ -52,6 +52,7 @@ afterEach(() => { describe('sessionRoutes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: sessionRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/index.ts b/packages/core/src/routes/session/index.ts index ef7157c37..126048c90 100644 --- a/packages/core/src/routes/session/index.ts +++ b/packages/core/src/routes/session/index.ts @@ -12,7 +12,7 @@ import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libr import { findUserById } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types.js'; +import type { AnonymousRouterLegacy } from '../types.js'; import continueRoutes from './continue.js'; import forgotPasswordRoutes from './forgot-password.js'; import koaGuardSessionAction from './middleware/koa-guard-session-action.js'; @@ -21,7 +21,10 @@ import passwordlessRoutes from './passwordless.js'; import socialRoutes from './social.js'; import { getRoutePrefix } from './utils.js'; -export default function sessionRoutes(router: T, provider: Provider) { +export default function sessionRoutes( + router: T, + provider: Provider +) { router.use(getRoutePrefix('sign-in'), koaGuardSessionAction(provider, 'sign-in')); router.use(getRoutePrefix('register'), koaGuardSessionAction(provider, 'register')); diff --git a/packages/core/src/routes/session/middleware/passwordless-action.ts b/packages/core/src/routes/session/middleware/passwordless-action.ts index fca8bc870..416e7e694 100644 --- a/packages/core/src/routes/session/middleware/passwordless-action.ts +++ b/packages/core/src/routes/session/middleware/passwordless-action.ts @@ -9,7 +9,7 @@ import { } from '#src/libraries/session.js'; import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; import { generateUserId, insertUser } from '#src/libraries/user.js'; -import type { WithLogContext } from '#src/middleware/koa-log.js'; +import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; import { hasUserWithPhone, hasUserWithEmail, @@ -27,7 +27,7 @@ import { checkRequiredProfile, } from '../utils.js'; -export const smsSignInAction = ( +export const smsSignInAction = ( provider: Provider ): MiddlewareType => { return async (ctx, next) => { @@ -72,7 +72,7 @@ export const smsSignInAction = ( +export const emailSignInAction = ( provider: Provider ): MiddlewareType => { return async (ctx, next) => { @@ -117,7 +117,7 @@ export const emailSignInAction = ( +export const smsRegisterAction = ( provider: Provider ): MiddlewareType => { return async (ctx, next) => { @@ -161,7 +161,7 @@ export const smsRegisterAction = ( +export const emailRegisterAction = ( provider: Provider ): MiddlewareType => { return async (ctx, next) => { diff --git a/packages/core/src/routes/session/password.test.ts b/packages/core/src/routes/session/password.test.ts index a8ba0a44a..334d90fc0 100644 --- a/packages/core/src/routes/session/password.test.ts +++ b/packages/core/src/routes/session/password.test.ts @@ -93,6 +93,7 @@ afterEach(() => { describe('session -> password routes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: passwordRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/password.ts b/packages/core/src/routes/session/password.ts index 39ea74d9c..0ef8447f8 100644 --- a/packages/core/src/routes/session/password.ts +++ b/packages/core/src/routes/session/password.ts @@ -21,13 +21,16 @@ import { } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types.js'; +import type { AnonymousRouterLegacy } from '../types.js'; import { checkRequiredProfile, getRoutePrefix, signInWithPassword } from './utils.js'; export const registerRoute = getRoutePrefix('register', 'password'); export const signInRoute = getRoutePrefix('sign-in', 'password'); -export default function passwordRoutes(router: T, provider: Provider) { +export default function passwordRoutes( + router: T, + provider: Provider +) { router.post( `${signInRoute}/username`, koaGuard({ diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index 070485221..966a13b5a 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -85,6 +85,7 @@ afterEach(() => { describe('session -> passwordlessRoutes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: passwordlessRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index 0adcaf26a..0c6bfa468 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -10,7 +10,7 @@ import { findUserByEmail, findUserByPhone } from '#src/queries/user.js'; import { passcodeTypeGuard } from '#src/routes/session/types.js'; import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types.js'; +import type { AnonymousRouterLegacy } from '../types.js'; import { smsSignInAction, emailSignInAction, @@ -26,7 +26,7 @@ import { export const registerRoute = getRoutePrefix('register', 'passwordless'); export const signInRoute = getRoutePrefix('sign-in', 'passwordless'); -export default function passwordlessRoutes( +export default function passwordlessRoutes( router: T, provider: Provider ) { diff --git a/packages/core/src/routes/session/social.bind-social.test.ts b/packages/core/src/routes/session/social.bind-social.test.ts index f3e7c4703..9f0ab6668 100644 --- a/packages/core/src/routes/session/social.bind-social.test.ts +++ b/packages/core/src/routes/session/social.bind-social.test.ts @@ -115,6 +115,7 @@ afterEach(() => { describe('session -> socialRoutes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: socialRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/social.test.ts b/packages/core/src/routes/session/social.test.ts index 67c0740e8..d4338584a 100644 --- a/packages/core/src/routes/session/social.test.ts +++ b/packages/core/src/routes/session/social.test.ts @@ -122,6 +122,7 @@ afterEach(() => { describe('session -> socialRoutes', () => { const sessionRequest = createRequester({ + // @ts-expect-error will remove once interaction refactor finished anonymousRoutes: socialRoutes, provider: new Provider(''), middlewares: [ diff --git a/packages/core/src/routes/session/social.ts b/packages/core/src/routes/session/social.ts index 95fa83b04..d23efb01a 100644 --- a/packages/core/src/routes/session/social.ts +++ b/packages/core/src/routes/session/social.ts @@ -28,13 +28,16 @@ import { import assertThat from '#src/utils/assert-that.js'; import { maskUserInfo } from '#src/utils/format.js'; -import type { AnonymousRouter } from '../types.js'; +import type { AnonymousRouterLegacy } from '../types.js'; import { checkRequiredProfile, getRoutePrefix } from './utils.js'; export const registerRoute = getRoutePrefix('register', 'social'); export const signInRoute = getRoutePrefix('sign-in', 'social'); -export default function socialRoutes(router: T, provider: Provider) { +export default function socialRoutes( + router: T, + provider: Provider +) { router.post( `${signInRoute}`, koaGuard({ diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 6c56970c8..1d70e61fa 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,5 +1,7 @@ -import type { LogPayload, LogType, PasscodeType, SignInExperience, User } from '@logto/schemas'; -import { SignInIdentifier, logTypeGuard } from '@logto/schemas'; +import type { PasscodeType, SignInExperience, User } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; +import type { LogPayload, LogType } from '@logto/schemas/lib/types/log-legacy.js'; +import { logTypeGuard } from '@logto/schemas/lib/types/log-legacy.js'; import type { Nullable, Truthy } from '@silverhand/essentials'; import { isSameArray } from '@silverhand/essentials'; import { addSeconds, isAfter, isValid } from 'date-fns'; @@ -15,7 +17,7 @@ import { } from '#src/libraries/session.js'; import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; import { verifyUserPassword } from '#src/libraries/user.js'; -import type { LogContext } from '#src/middleware/koa-log.js'; +import type { LogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; import { updateUserById } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; @@ -212,7 +214,7 @@ type SignInWithPasswordParameter = { }; export const signInWithPassword = async ( - ctx: Context & LogContext, + ctx: Context & LogContextLegacy, provider: Provider, { identifier, findUser, password, logType, logPayload }: SignInWithPasswordParameter ) => { diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index e45c93cd3..70803b5ff 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -1,9 +1,17 @@ +import type { ExtendableContext } from 'koa'; import type Router from 'koa-router'; +import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; import type { WithAuthContext } from '#src/middleware/koa-auth.js'; import type { WithI18nContext } from '#src/middleware/koa-i18next.js'; -import type { WithLogContext } from '#src/middleware/koa-log.js'; -export type AnonymousRouter = Router; +export type AnonymousRouter = Router; -export type AuthedRouter = Router; +/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */ +export type AnonymousRouterLegacy = Router; + +export type AuthedRouter = Router< + unknown, + WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext +>; diff --git a/packages/core/src/test-utils/koa-audit-log.ts b/packages/core/src/test-utils/koa-audit-log.ts new file mode 100644 index 000000000..5052a0f89 --- /dev/null +++ b/packages/core/src/test-utils/koa-audit-log.ts @@ -0,0 +1,18 @@ +import type { LogContext } from '#src/middleware/koa-audit-log.js'; +import { LogEntry } from '#src/middleware/koa-audit-log.js'; + +const { jest } = import.meta; + +class MockLogEntry extends LogEntry { + append = jest.fn(); +} + +export const createMockLogContext = (): LogContext & { mockAppend: jest.Mock } => { + const mockLogEntry = new MockLogEntry('Unknown'); + + return { + createLog: jest.fn(() => mockLogEntry), + prependAllLogEntries: jest.fn(), + mockAppend: mockLogEntry.append, + }; +}; diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index c6524ce00..c09fd3475 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -13,3 +13,6 @@ export const maskUserInfo = ({ type, value }: { type: 'email' | 'phone'; value: return `${preview}****@${domain}`; }; + +export const stringifyError = (error: Error) => + JSON.stringify(error, Object.getOwnPropertyNames(error)); diff --git a/packages/core/src/utils/oidc-provider-event-listener.test.ts b/packages/core/src/utils/oidc-provider-event-listener.test.ts deleted file mode 100644 index bea89da3c..000000000 --- a/packages/core/src/utils/oidc-provider-event-listener.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { LogResult, TokenType } from '@logto/schemas'; -import { Provider } from 'oidc-provider'; - -import { - addOidcEventListeners, - grantErrorListener, - grantRevokedListener, - grantSuccessListener, -} from '#src/utils/oidc-provider-event-listener.js'; -import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; - -const { jest } = import.meta; - -const userId = 'userIdValue'; -const sessionId = 'sessionIdValue'; -const applicationId = 'applicationIdValue'; - -const addLogContext = jest.fn(); -const log = jest.fn(); - -describe('addOidcEventListeners', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should add proper listeners', () => { - const provider = new Provider('https://logto.test'); - const addListener = jest.spyOn(provider, 'addListener'); - addOidcEventListeners(provider); - expect(addListener).toHaveBeenCalledWith('grant.success', grantSuccessListener); - expect(addListener).toHaveBeenCalledWith('grant.error', grantErrorListener); - expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevokedListener); - }); -}); - -describe('grantSuccessListener', () => { - const entities = { - Account: { accountId: userId }, - Grant: { jti: sessionId }, - Client: { clientId: applicationId }, - }; - - 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: Object.values(TokenType), - 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: Object.values(TokenType), - 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: [TokenType.AccessToken, TokenType.RefreshToken], - params: parameters, - scope: 'offline-access', - userId, - }); - }); - - it('should not log when it found unexpected grant_type', async () => { - const parameters = { grant_type: 'client_credentials' }; - const ctx = { - ...createContextWithRouteParameters(), - addLogContext, - log, - oidc: { entities, params: parameters }, - body: {}, - }; - - // @ts-expect-error pass complex type check to mock ctx directly - await grantSuccessListener(ctx); - expect(addLogContext).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalled(); - }); -}); - -describe('grantErrorListener', () => { - const entities = { Client: { clientId: applicationId } }; - const errorMessage = 'invalid grant'; - - 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 grantErrorListener(ctx, new Error(errorMessage)); - expect(addLogContext).toHaveBeenCalledWith({ applicationId }); - expect(log).toHaveBeenCalledWith('CodeExchangeToken', { - result: LogResult.Error, - error: `Error: ${errorMessage}`, - params: parameters, - }); - }); - - 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 grantErrorListener(ctx, new Error(errorMessage)); - expect(addLogContext).toHaveBeenCalledWith({ applicationId }); - expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', { - result: LogResult.Error, - error: `Error: ${errorMessage}`, - params: parameters, - }); - }); - - it('should not log when it found unexpected grant_type', async () => { - const parameters = { grant_type: 'client_credentials' }; - const ctx = { - ...createContextWithRouteParameters(), - addLogContext, - log, - oidc: { entities, params: parameters }, - body: {}, - }; - - // @ts-expect-error pass complex type check to mock ctx directly - await grantErrorListener(ctx, new Error(errorMessage)); - expect(addLogContext).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalled(); - }); -}); - -describe('grantRevokedListener', () => { - const grantId = 'grantIdValue'; - const token = 'tokenValue'; - const parameters = { token }; - - const client = { clientId: applicationId }; - const accessToken = { accountId: userId }; - const refreshToken = { accountId: userId }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should log token type AccessToken when the token is an access token', async () => { - const ctx = { - ...createContextWithRouteParameters(), - addLogContext, - log, - oidc: { - entities: { Client: client, AccessToken: accessToken }, - params: parameters, - }, - body: { client_id: applicationId, token }, - }; - - // @ts-expect-error pass complex type check to mock ctx directly - await grantRevokedListener(ctx, grantId); - expect(addLogContext).toHaveBeenCalledWith({ applicationId }); - expect(log).toHaveBeenCalledWith('RevokeToken', { - userId, - params: parameters, - grantId, - tokenType: TokenType.AccessToken, - }); - }); - - it('should log token type RefreshToken when the token is a refresh code', async () => { - const ctx = { - ...createContextWithRouteParameters(), - addLogContext, - log, - oidc: { - entities: { Client: client, RefreshToken: refreshToken }, - params: parameters, - }, - body: { client_id: applicationId, token }, - }; - - // @ts-expect-error pass complex type check to mock ctx directly - await grantRevokedListener(ctx, grantId); - expect(addLogContext).toHaveBeenCalledWith({ applicationId }); - expect(log).toHaveBeenCalledWith('RevokeToken', { - userId, - params: parameters, - grantId, - tokenType: TokenType.RefreshToken, - }); - }); - - it('should not log when the revoked token is neither access token nor refresh token', async () => { - const ctx = { - ...createContextWithRouteParameters(), - addLogContext, - log, - oidc: { - entities: { Client: client }, - params: parameters, - }, - body: { client_id: applicationId, token }, - }; - - // @ts-expect-error pass complex type check to mock ctx directly - await grantRevokedListener(ctx, grantId); - expect(addLogContext).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/core/src/utils/oidc-provider-event-listener.ts b/packages/core/src/utils/oidc-provider-event-listener.ts deleted file mode 100644 index 86a170469..000000000 --- a/packages/core/src/utils/oidc-provider-event-listener.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { GrantType, TokenType, LogResult } from '@logto/schemas'; -import { notFalsy } from '@silverhand/essentials'; -import type { errors, KoaContextWithOIDC, Provider } from 'oidc-provider'; - -import type { WithLogContext } from '#src/middleware/koa-log.js'; - -export const addOidcEventListeners = (provider: Provider) => { - /** - * OIDC provider listeners and events - * https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details - * https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md - */ - provider.addListener('grant.success', grantSuccessListener); - provider.addListener('grant.error', grantErrorListener); - provider.addListener('grant.revoked', grantRevokedListener); -}; - -/** - * 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 - * - …… - */ -type GrantBody = { - access_token?: string; - refresh_token?: string; - id_token?: string; - scope?: string; // AccessToken.scope -}; - -const getLogType = (grantType: unknown) => { - const allowedGrantType = new Set([GrantType.AuthorizationCode, GrantType.RefreshToken]); - - // Only log token exchange by authorization code or refresh token. - if (!grantType || !allowedGrantType.has(grantType)) { - return; - } - - return grantType === GrantType.AuthorizationCode - ? 'CodeExchangeToken' - : 'RefreshTokenExchangeToken'; -}; - -// The grant.success event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/actions/token.js#L71 -export const grantSuccessListener = async ( - ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody } -) => { - const { - oidc: { - entities: { Account: account, Grant: grant, Client: client }, - params, - }, - body, - } = ctx; - - const logType = getLogType(params?.grant_type); - - if (!logType) { - return; - } - - ctx.addLogContext({ - applicationId: client?.clientId, - sessionId: grant?.jti, - }); - - const { access_token, refresh_token, id_token, scope } = body; - const issued = [ - access_token && TokenType.AccessToken, - refresh_token && TokenType.RefreshToken, - id_token && TokenType.IdToken, - ].filter((value): value is TokenType => notFalsy(value)); - - ctx.log(logType, { - userId: account?.accountId, - params, - issued, - scope, - }); -}; - -// The grant.error event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/initialize_app.js#L153 -export const grantErrorListener = async ( - ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody }, - error: errors.OIDCProviderError -) => { - const { - oidc: { - entities: { Client: client }, - params, - }, - } = ctx; - - const logType = getLogType(params?.grant_type); - - if (!logType) { - return; - } - - ctx.addLogContext({ - applicationId: client?.clientId, - }); - ctx.log(logType, { - result: LogResult.Error, - error: String(error), - params, - }); -}; - -// OAuth 2.0 Token Revocation: https://datatracker.ietf.org/doc/html/rfc7009 -// The grant.revoked event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/revoke.js#L25 -export const grantRevokedListener = async ( - ctx: KoaContextWithOIDC & WithLogContext, - grantId: string -) => { - const { - oidc: { - entities: { Client: client, AccessToken: accessToken, RefreshToken: refreshToken }, - params, - }, - } = ctx; - - if (!refreshToken && !accessToken) { - // Only log token revocation of access token or refresh token. - return; - } - - ctx.addLogContext({ applicationId: client?.clientId }); - const userId = accessToken?.accountId ?? refreshToken?.accountId; - const tokenType = accessToken ? TokenType.AccessToken : TokenType.RefreshToken; - ctx.log('RevokeToken', { userId, params, grantId, tokenType }); -}; diff --git a/packages/core/src/utils/search.ts b/packages/core/src/utils/search.ts index ba118a3f2..0a531e715 100644 --- a/packages/core/src/utils/search.ts +++ b/packages/core/src/utils/search.ts @@ -7,6 +7,7 @@ import { snakeCase } from 'snake-case'; import { isTrue } from '#src/env-set/parameters.js'; import assertThat from './assert-that.js'; +import { isEnum } from './type.js'; const searchJointModes = Object.values(SearchJointMode); const searchMatchModes = Object.values(SearchMatchMode); @@ -23,10 +24,6 @@ export type Search = { isCaseSensitive: boolean; }; -const isEnum = (list: T[], value: string): value is T => - // @ts-expect-error the easiest way to perform type checking for a string enum - list.includes(value); - /** * Parse a field string with "search." prefix to the actual first-level field. * If `allowedFields` is not `undefined`, ensure the parsed field is included in the list. diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index 1635fa1d7..1f9720731 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -87,9 +87,9 @@ export const emptyMiddleware = }; export const createContextWithRouteParameters = ( - mockContestOptions?: Options> + mockContextOptions?: Options> ): Context & IRouterParamContext => { - const ctx = createMockContext(mockContestOptions); + const ctx = createMockContext(mockContextOptions); return { ...ctx, diff --git a/packages/core/src/utils/type.ts b/packages/core/src/utils/type.ts new file mode 100644 index 000000000..744b07f3a --- /dev/null +++ b/packages/core/src/utils/type.ts @@ -0,0 +1,3 @@ +export const isEnum = (list: T[], value: unknown): value is T => + // @ts-expect-error the easiest way to perform type checking for a string enum + list.includes(value); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 464924d89..a537a10a8 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -12,10 +12,9 @@ "scripts": { "build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", - "test": "pnpm build && pnpm test:api && pnpm test:ui && pnpm test:interaction", + "test": "pnpm build && pnpm test:api && pnpm test:ui", "test:api": "pnpm test:only -i ./lib/tests/api", "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui", - "test:interaction": "pnpm test:only -i ./lib/tests/interaction", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "start": "pnpm test" diff --git a/packages/integration-tests/src/api/logs.ts b/packages/integration-tests/src/api/logs.ts index 22c0469a4..331a0af10 100644 --- a/packages/integration-tests/src/api/logs.ts +++ b/packages/integration-tests/src/api/logs.ts @@ -1,7 +1,10 @@ import type { Log } from '@logto/schemas'; +import { conditionalString } from '@silverhand/essentials'; import { authedAdminApi } from './api.js'; -export const getLogs = () => authedAdminApi.get('logs').json(); +// eslint-disable-next-line unicorn/prevent-abbreviations +export const getLogs = (params?: URLSearchParams) => + authedAdminApi.get('logs?' + conditionalString(params?.toString())).json(); export const getLog = (logId: string) => authedAdminApi.get(`logs/${logId}`).json(); diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index ea4e886ff..2af732f2c 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -1,12 +1,12 @@ import type { LogtoConfig } from '@logto/node'; import LogtoClient from '@logto/node'; import { demoAppApplicationId } from '@logto/schemas/lib/seeds/index.js'; +import type { Optional } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials'; import { got } from 'got'; import { consent } from '#src/api/index.js'; import { demoAppRedirectUri, logtoUrl } from '#src/constants.js'; -import { extractCookie } from '#src/utils.js'; import { MemoryStorage } from './storage.js'; @@ -17,7 +17,7 @@ export const defaultConfig = { }; export default class MockClient { - public interactionCookie?: string; + public rawCookies: string[] = []; private navigateUrl?: string; private readonly storage: MemoryStorage; @@ -37,6 +37,27 @@ export default class MockClient { ); } + // TODO: Rename to sessionCookies or something accurate + public get interactionCookie(): string { + return this.rawCookies.join('; '); + } + + public get parsedCookies(): Map> { + const map = new Map>(); + + for (const cookie of this.rawCookies) { + for (const element of cookie.split(';')) { + const [key, value] = element.trim().split('='); + + if (key) { + map.set(key, value); + } + } + } + + return map; + } + public async initSession(callbackUri = demoAppRedirectUri) { await this.logto.signIn(callbackUri); @@ -58,7 +79,7 @@ export default class MockClient { ); // Get session cookie - this.interactionCookie = extractCookie(response); + this.rawCookies = response.headers['set-cookie'] ?? []; assert(this.interactionCookie, new Error('Get cookie from authorization endpoint failed')); } @@ -79,9 +100,10 @@ export default class MockClient { new Error('Invoke auth before consent failed') ); - this.interactionCookie = extractCookie(authResponse); + this.rawCookies = authResponse.headers['set-cookie'] ?? []; - await this.consent(); + const signInCallbackUri = await this.consent(); + await this.logto.handleSignInCallback(signInCallbackUri); } public async getAccessToken(resource?: string) { @@ -93,7 +115,12 @@ export default class MockClient { } public async signOut(postSignOutRedirectUri?: string) { - return this.logto.signOut(postSignOutRedirectUri); + if (!this.navigateUrl) { + throw new Error('No navigate URL found for sign-out'); + } + + await this.logto.signOut(postSignOutRedirectUri); + await got(this.navigateUrl); } public async isAuthenticated() { @@ -105,7 +132,7 @@ export default class MockClient { } public assignCookie(cookie: string) { - this.interactionCookie = cookie; + this.rawCookies = cookie.split(';').map((value) => value.trim()); } private readonly consent = async () => { @@ -130,6 +157,6 @@ export default class MockClient { const signInCallbackUri = authCodeResponse.headers.location; assert(signInCallbackUri, new Error('Get sign in callback uri failed')); - await this.logto.handleSignInCallback(signInCallbackUri); + return signInCallbackUri; }; } diff --git a/packages/integration-tests/src/tests/api/audit-logs/index.test.ts b/packages/integration-tests/src/tests/api/audit-logs/index.test.ts new file mode 100644 index 000000000..3f4425f61 --- /dev/null +++ b/packages/integration-tests/src/tests/api/audit-logs/index.test.ts @@ -0,0 +1,57 @@ +import { Event, interaction, SignInIdentifier } from '@logto/schemas'; +import { assert } from '@silverhand/essentials'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { putInteraction } from '#src/api/interaction.js'; +import { getLogs } from '#src/api/logs.js'; +import MockClient from '#src/client/index.js'; + +import { enableAllPasswordSignInMethods } from '../interaction/utils/sign-in-experience.js'; +import { generateNewUserProfile } from '../interaction/utils/user.js'; + +describe('audit logs for interaction', () => { + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + }); + + it('should insert log after interaction started and ended', async () => { + const client = new MockClient(); + await client.initSession(); + const interactionId = client.parsedCookies.get('_interaction'); + + assert(interactionId, new Error('No interaction found in cookie')); + console.debug('Testing interaction', interactionId); + + // Expect interaction create log + const createLogs = await getLogs( + new URLSearchParams({ logType: `${interaction.prefix}.${interaction.Action.Create}` }) + ); + expect(createLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy(); + + // Process interaction with minimum effort + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const response = await putInteraction( + { + event: Event.Register, + profile: { username, password }, + }, + client.interactionCookie + ); + await client.processSession(response.redirectTo); + + // Expect interaction end log + const endLogs = await getLogs( + new URLSearchParams({ logType: `${interaction.prefix}.${interaction.Action.End}` }) + ); + expect(endLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy(); + + // Clean up + const { sub: userId } = await client.getIdTokenClaims(); + await client.signOut(); + await deleteUser(userId); + }); +}); diff --git a/packages/integration-tests/src/tests/interaction/register-with-identifier.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier.test.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/register-with-identifier.test.ts rename to packages/integration-tests/src/tests/api/interaction/register-with-identifier.test.ts diff --git a/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier.test.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts rename to packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier.test.ts diff --git a/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier.test.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts rename to packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier.test.ts diff --git a/packages/integration-tests/src/tests/interaction/utils/client.ts b/packages/integration-tests/src/tests/api/interaction/utils/client.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/utils/client.ts rename to packages/integration-tests/src/tests/api/interaction/utils/client.ts diff --git a/packages/integration-tests/src/tests/interaction/utils/connector.ts b/packages/integration-tests/src/tests/api/interaction/utils/connector.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/utils/connector.ts rename to packages/integration-tests/src/tests/api/interaction/utils/connector.ts diff --git a/packages/integration-tests/src/tests/interaction/utils/sign-in-experience.ts b/packages/integration-tests/src/tests/api/interaction/utils/sign-in-experience.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/utils/sign-in-experience.ts rename to packages/integration-tests/src/tests/api/interaction/utils/sign-in-experience.ts diff --git a/packages/integration-tests/src/tests/interaction/utils/user.ts b/packages/integration-tests/src/tests/api/interaction/utils/user.ts similarity index 100% rename from packages/integration-tests/src/tests/interaction/utils/user.ts rename to packages/integration-tests/src/tests/api/interaction/utils/user.ts diff --git a/packages/integration-tests/src/tests/api/logs.test.ts b/packages/integration-tests/src/tests/api/logs-legacy.test.ts similarity index 82% rename from packages/integration-tests/src/tests/api/logs.test.ts rename to packages/integration-tests/src/tests/api/logs-legacy.test.ts index efcf89141..c970ad27b 100644 --- a/packages/integration-tests/src/tests/api/logs.test.ts +++ b/packages/integration-tests/src/tests/api/logs-legacy.test.ts @@ -5,7 +5,8 @@ import { signUpIdentifiers } from '#src/constants.js'; import { registerNewUser, setSignUpIdentifier } from '#src/helpers.js'; import { generateUsername, generatePassword } from '#src/utils.js'; -describe('admin console logs', () => { +/** @deprecated This will be removed soon. */ +describe('admin console logs (legacy)', () => { const username = generateUsername(); const password = generatePassword(); @@ -19,9 +20,7 @@ describe('admin console logs', () => { const logs = await getLogs(); const registerLog = logs.filter( - ({ type, payload }) => - type === 'RegisterUsernamePassword' && - (payload as Record).username === username + ({ type, payload }) => type === 'RegisterUsernamePassword' && payload.username === username ); expect(registerLog.length).toBeGreaterThan(0); diff --git a/packages/integration-tests/src/utils.ts b/packages/integration-tests/src/utils.ts index 75c3c4435..045d9a95b 100644 --- a/packages/integration-tests/src/utils.ts +++ b/packages/integration-tests/src/utils.ts @@ -1,11 +1,3 @@ -import type { Response } from 'got'; - -export const extractCookie = (response: Response) => { - const { headers } = response; - - return headers['set-cookie']?.join('; ') ?? ''; -}; - export const generateName = () => crypto.randomUUID(); export const generateUsername = () => `usr_${crypto.randomUUID().replaceAll('-', '_')}`; export const generatePassword = () => `pwd_${crypto.randomUUID()}`; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 3d1520ea3..74bdfa447 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -11,9 +11,7 @@ export { * Commonly Used */ -// Cannot declare `z.object({}).catchall(z.unknown().optional())` to guard `{ [key: string]?: unknown }` (invalid type), -// so do it another way to guard `{ [x: string]: unknown; } | {}`. -export const arbitraryObjectGuard = z.union([z.object({}).catchall(z.unknown()), z.object({})]); +export const arbitraryObjectGuard = z.record(z.unknown()); export type ArbitraryObject = z.infer; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index e9a062cb1..cc583ff5c 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -1,5 +1,5 @@ export * from './connector.js'; -export * from './log.js'; +export * from './log/index.js'; export * from './oidc-config.js'; export * from './user.js'; export * from './logto-config.js'; diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 082f62c89..d9cf6a97d 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -47,9 +47,9 @@ export const socialIdentityPayloadGuard = z.object({ }); export type SocialIdentityPayload = z.infer; -/** - * Interaction Payload Guard - */ +// Interaction Payload Guard + +/** Interaction flow (main flow) types. */ export enum Event { SignIn = 'SignIn', Register = 'Register', diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log-legacy.ts similarity index 86% rename from packages/schemas/src/types/log.ts rename to packages/schemas/src/types/log-legacy.ts index b359a2f90..3fe03fc44 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log-legacy.ts @@ -2,13 +2,16 @@ import { z } from 'zod'; import type { Log } from '../db-entries/index.js'; +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export enum LogResult { Success = 'Success', Error = 'Error', } +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export const logResultGuard = z.nativeEnum(LogResult); +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export const baseLogPayloadGuard = z.object({ result: logResultGuard.optional(), error: z.record(z.string(), z.unknown()).optional(), @@ -18,10 +21,12 @@ export const baseLogPayloadGuard = z.object({ sessionId: z.string().optional(), }); +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export type BaseLogPayload = z.infer; const arbitraryLogPayloadGuard = z.record(z.string(), z.unknown()); +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export type ArbitraryLogPayload = z.infer; const registerUsernamePasswordLogPayloadGuard = arbitraryLogPayloadGuard.and( @@ -257,14 +262,19 @@ const logPayloadsGuard = z.object({ RevokeToken: revokeTokenLogPayloadGuard, }); +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export type LogPayloads = z.infer; +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export const logTypeGuard = logPayloadsGuard.keyof(); +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export type LogType = z.infer; +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export type LogPayload = LogPayloads[LogType]; +/** @deprecated This will be removed soon. Use log types that can be directly imported from `@logto/schemas` instead. */ export type LogDto = Omit & { payload: { userId?: string; diff --git a/packages/schemas/src/types/log/index.ts b/packages/schemas/src/types/log/index.ts new file mode 100644 index 000000000..2e88f08e2 --- /dev/null +++ b/packages/schemas/src/types/log/index.ts @@ -0,0 +1,52 @@ +import type { ZodType } from 'zod'; +import { z } from 'zod'; + +import type * as interaction from './interaction.js'; +import type * as token from './token.js'; + +export * as interaction from './interaction.js'; +export * as token from './token.js'; + +/** Fallback for empty or unrecognized log keys. */ +export const LogKeyUnknown = 'Unknown'; + +/** + * The union type of all available log keys. + * Note duplicate keys are allowed but should be avoided. + * + * @see {@link interaction.LogKey} for interaction log keys. + * @see {@link token.LogKey} for token log keys. + **/ +export type LogKey = typeof LogKeyUnknown | interaction.LogKey | token.LogKey; + +export enum LogResult { + Success = 'Success', + Error = 'Error', +} + +/** + * The basic log context type. It's more about a type hint instead of forcing the log shape. + * + * Note when setting up a log function, the type of log key in function arguments should be `LogKey`. + * Here we use `string` to make it compatible with the Zod guard. + **/ +export type LogContextPayload = { + key: string; + result: LogResult; + error?: Record | string; + ip?: string; + userAgent?: string; + applicationId?: string; + sessionId?: string; +}; + +/** Type guard for {@link LogContextPayload} */ +export const logContextGuard: ZodType = z.object({ + key: z.string(), + result: z.nativeEnum(LogResult), + error: z.record(z.string(), z.unknown()).or(z.string()).optional(), + ip: z.string().optional(), + userAgent: z.string().optional(), + applicationId: z.string().optional(), + sessionId: z.string().optional(), +}); diff --git a/packages/schemas/src/types/log/interaction.ts b/packages/schemas/src/types/log/interaction.ts new file mode 100644 index 000000000..b40baab2c --- /dev/null +++ b/packages/schemas/src/types/log/interaction.ts @@ -0,0 +1,79 @@ +import type { Event } from '../interactions.js'; + +export type Prefix = 'Interaction'; + +export const prefix: Prefix = 'Interaction'; + +/** The interaction field to update. This is valid based on we only allow users update one field at a time. */ +export enum Field { + Event = 'Event', + Identifier = 'Identifier', + Profile = 'Profile', +} + +/** Method to verify the identifier */ +export enum Method { + Password = 'Password', + VerificationCode = 'VerificationCode', + Social = 'Social', +} + +export enum Action { + /** Create a new entity. (E.g. create an interaction, create a verification code) */ + Create = 'Create', + /** Update an existing entity. (E.g. change interaction type) */ + Update = 'Update', + /** Submit updated info to an entity, or submit to the system. (E.g. submit an interaction, submit a verification code to get verified) */ + Submit = 'Submit', + /** Change an entity to the end state. (E.g. end an interaction) */ + End = 'End', +} + +/** + * The union type of all available log keys for interaction. + * The key MUST describe an {@link Action}. + * + * ### Keys breakdown + * + * ```ts + * `Interaction.${Action.Create | Action.End}` + * ``` + * + * - Indicates an interaction is started or ended. Normally it is performed by OIDC Provider. + * + * ```ts + * `Interaction.${Event}.${Action.Update | Action.Submit}` + * ``` + * + * Since {@link Event} is the primary identifier of interaction type, most of log keys include this info for better query experience. + * The only exception is the initial creation of an interaction, which has a key of `Interaction.Create`, + * since we cannot know the type at that time. + * + * - When {@link Action} is `Update`, it indicates the type of interaction is updating to {@link Event}. + * - When {@link Action} is `Submit`, it indicates the whole interaction is being submitted. + * + * ```ts + * `Interaction.${Event}.${Field.Profile}.${Action.Update}` + * ``` + * + * - Indicates the profile of an interaction is being updated. It may add or remove profile data. + * + * ```ts + * `Interaction.${Event}.${Field.Identifier}.${Method}.${Action}` + * ``` + * + * - Indicates an identifier method is being created or submitted to an interaction. + * - When {@link Method} is `VerificationCode`, {@link Action} can be `Create` (generate and send a code) or `Submit` (verify and submit to the identifiers); + * - Otherwise, {@link Action} is fixed to `Submit` (other methods can be verified on submitting). + */ +export type LogKey = + | `${Prefix}.${Action.Create | Action.End}` + | `${Prefix}.${Event}.${Action.Update | Action.Submit}` + | `${Prefix}.${Event}.${Field.Profile}.${Action.Update}` + | `${Prefix}.${Event}.${Field.Identifier}.${Method.VerificationCode}.${ + | Action.Create + | Action.Submit}` + | `${Prefix}.${Event}.${Field.Identifier}.${Exclude< + Method, + Method.VerificationCode + >}.${Action.Submit}`; diff --git a/packages/schemas/src/types/log/token.ts b/packages/schemas/src/types/log/token.ts new file mode 100644 index 000000000..8be1fcd2f --- /dev/null +++ b/packages/schemas/src/types/log/token.ts @@ -0,0 +1,25 @@ +/** The type of a token flow. */ +export enum Flow { + ExchangeTokenBy = 'ExchangeTokenBy', + RevokeToken = 'RevokeToken', +} + +/** Available grant token types extracted from [oidc-provider](https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/revoke.js#L13). */ +export enum TokenType { + AccessToken = 'AccessToken', + RefreshToken = 'RefreshToken', + IdToken = 'IdToken', + AuthorizationCode = 'AuthorizationCode', + DeviceCode = 'DeviceCode', + BackchannelAuthenticationRequest = 'BackchannelAuthenticationRequest', +} + +/** The credential to request a grant. */ +export enum ExchangeByType { + Unknown = 'Unknown', + AuthorizationCode = 'AuthorizationCode', + RefreshToken = 'RefreshToken', + ClientCredentials = 'ClientCredentials', +} + +export type LogKey = `${Flow.ExchangeTokenBy}.${ExchangeByType}` | `${Flow.RevokeToken}`;