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..ab07336fa 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -9,11 +9,11 @@ import koaLogger from 'koa-logger'; import mount from 'koa-mount'; import envSet, { MountedApps } from '#src/env-set/index.js'; +import koaAuditLog from '#src/middleware/koa-audit-log.js'; 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 +32,12 @@ 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(koaAuditLog()); app.use(koaI18next()); const provider = await initOidc(app); 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-log-session.test.ts b/packages/core/src/middleware/koa-audit-log-session.test.ts similarity index 70% rename from packages/core/src/middleware/koa-log-session.test.ts rename to packages/core/src/middleware/koa-audit-log-session.test.ts index 2cfb4467c..02cd2a7ed 100644 --- a/packages/core/src/middleware/koa-log-session.test.ts +++ b/packages/core/src/middleware/koa-audit-log-session.test.ts @@ -1,7 +1,8 @@ import { Provider } from 'oidc-provider'; -import koaLogSession from '#src/middleware/koa-log-session.js'; -import type { WithLogContext } from '#src/middleware/koa-log.js'; +import koaAuditLogSession from '#src/middleware/koa-audit-log-session.js'; +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { createMockLogContext } from '#src/test-utils/koa-log.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; const { jest } = import.meta; @@ -9,11 +10,10 @@ const { jest } = import.meta; const provider = new Provider('https://logto.test'); const interactionDetails = jest.spyOn(provider, 'interactionDetails'); -describe('koaLogSession', () => { +describe('koaAuditLogSession', () => { const sessionId = 'sessionId'; const applicationId = 'applicationId'; - const addLogContext = jest.fn(); - const log = jest.fn(); + const log = createMockLogContext(); const next = jest.fn(); // @ts-expect-error for testing @@ -31,40 +31,36 @@ describe('koaLogSession', () => { it('should get session info from the provider', async () => { const ctx: WithLogContext> = { ...createContextWithRouteParameters(), - addLogContext, log, }; - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow(); expect(interactionDetails).toHaveBeenCalled(); }); it('should log session id and application id', async () => { const ctx: WithLogContext> = { ...createContextWithRouteParameters(), - addLogContext, log, }; - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); - expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId }); + await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow(); + expect(log).toHaveBeenCalledWith({ sessionId, applicationId }); }); it('should call next', async () => { const ctx: WithLogContext> = { ...createContextWithRouteParameters(), - addLogContext, log, }; - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow(); expect(next).toHaveBeenCalled(); }); it('should not throw when interactionDetails throw error', async () => { const ctx: WithLogContext> = { ...createContextWithRouteParameters(), - addLogContext, log, }; @@ -72,6 +68,6 @@ describe('koaLogSession', () => { throw new Error('message'); }); - await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); + await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow(); }); }); diff --git a/packages/core/src/middleware/koa-log-session.ts b/packages/core/src/middleware/koa-audit-log-session.ts similarity index 66% rename from packages/core/src/middleware/koa-log-session.ts rename to packages/core/src/middleware/koa-audit-log-session.ts index dcc2e7111..2d4bf92d5 100644 --- a/packages/core/src/middleware/koa-log-session.ts +++ b/packages/core/src/middleware/koa-audit-log-session.ts @@ -1,9 +1,9 @@ import type { MiddlewareType } from 'koa'; import type { Provider } from 'oidc-provider'; -import type { WithLogContext } from '#src/middleware/koa-log.js'; +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; -export default function koaLogSession( +export default function koaAuditLogSession( provider: Provider ): MiddlewareType { return async (ctx, next) => { @@ -14,7 +14,7 @@ export default function koaLogSession ({ + insertLog: jest.fn(), +})); + +mockEsm('nanoid', () => ({ + nanoid: () => nanoIdMock, +})); + +const koaLog = await pickDefault(import('./koa-audit-log.js')); + +describe('koaLog middleware', () => { + const logKey: LogKey = 'SignIn.Username.Passcode.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 () => { + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + log, + }; + ctx.request.ip = ip; + const additionalMockPayload: LogPayload = { foo: 'bar' }; + + const next = async () => { + ctx.log.setKey(logKey); + ctx.log(mockPayload); + ctx.log(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 a log with unknown key when there is no log type', async () => { + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + log, + }; + 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).toBeCalledWith({ + id: nanoIdMock, + type: 'Unknown', + payload: { + key: 'Unknown', + result: LogResult.Success, + ip, + userAgent, + }, + }); + }); + + 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> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + log, + }; + ctx.request.ip = ip; + + const message = 'Normal error'; + const error = new Error(message); + + const next = async () => { + ctx.log.setKey(logKey); + ctx.log(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 insert an error log with the error body when next() throws a RequestError', async () => { + const ctx: WithLogContext> = { + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), + log, + }; + 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 () => { + ctx.log.setKey(logKey); + ctx.log(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, 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..19de2825d --- /dev/null +++ b/packages/core/src/middleware/koa-audit-log.ts @@ -0,0 +1,85 @@ +import type { LogContextPayload, LogKey } from '@logto/schemas'; +import { LogKeyUnknown, 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 type LogPayload = Partial & Record; + +type LogFunction = { + (data: Readonly): void; + setKey: (key: LogKey) => void; +}; + +export type LogContext = { + log: LogFunction; +}; + +export type WithLogContext = ContextT & + LogContext; + +export default function koaAuditLog< + StateT, + ContextT extends IRouterParamContext, + ResponseBodyT +>(): MiddlewareType, ResponseBodyT> { + return async (ctx, next) => { + const { + ip, + headers: { 'user-agent': userAgent }, + } = ctx.request; + + // eslint-disable-next-line @silverhand/fp/no-let + let payload: LogContextPayload = { + key: LogKeyUnknown, + result: LogResult.Success, + ip, + userAgent, + }; + + const log: LogFunction = Object.assign( + (data: Readonly) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + payload = { + ...payload, + ...removeUndefinedKeys(data), + }; + }, + { + setKey: (key: LogKey) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + payload = { ...payload, key }; + }, + } + ); + + ctx.log = log; + + try { + await next(); + } catch (error: unknown) { + log({ + result: LogResult.Error, + error: + error instanceof RequestError + ? pick(error, 'message', 'code', 'data') + : { message: String(error) }, + }); + throw error; + } finally { + // TODO: If no `payload.key` found, should we trigger an alert or something? + await insertLog({ + id: nanoid(), + type: payload.key, + payload, + }); + } + }; +} diff --git a/packages/core/src/middleware/koa-log-session-legacy.test.ts b/packages/core/src/middleware/koa-log-session-legacy.test.ts new file mode 100644 index 000000000..255ce87fb --- /dev/null +++ b/packages/core/src/middleware/koa-log-session-legacy.test.ts @@ -0,0 +1,77 @@ +import { Provider } from 'oidc-provider'; + +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; + +const provider = new Provider('https://logto.test'); +const interactionDetails = jest.spyOn(provider, 'interactionDetails'); + +describe('koaLogSessionLegacy', () => { + const sessionId = 'sessionId'; + const applicationId = 'applicationId'; + const addLogContext = jest.fn(); + const log = jest.fn(); + const next = jest.fn(); + + // @ts-expect-error for testing + interactionDetails.mockResolvedValue({ + jti: sessionId, + params: { + client_id: applicationId, + }, + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get session info from the provider', async () => { + const ctx: WithLogContextLegacy> = { + ...createContextWithRouteParameters(), + addLogContext, + log, + }; + + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); + expect(interactionDetails).toHaveBeenCalled(); + }); + + it('should log session id and application id', async () => { + const ctx: WithLogContextLegacy> = { + ...createContextWithRouteParameters(), + addLogContext, + log, + }; + + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); + expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId }); + }); + + it('should call next', async () => { + const ctx: WithLogContextLegacy> = { + ...createContextWithRouteParameters(), + addLogContext, + log, + }; + + await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); + expect(next).toHaveBeenCalled(); + }); + + it('should not throw when interactionDetails throw error', async () => { + const ctx: WithLogContextLegacy> = { + ...createContextWithRouteParameters(), + addLogContext, + log, + }; + + interactionDetails.mockImplementationOnce(() => { + throw new Error('message'); + }); + + 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..0c8dabeff --- /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(`"${ctx.url}" failed to get oidc provider interaction`, error); + } + }; +} diff --git a/packages/core/src/queries/log.ts b/packages/core/src/queries/log.ts index 623027ec9..d06167003 100644 --- a/packages/core/src/queries/log.ts +++ b/packages/core/src/queries/log.ts @@ -1,5 +1,6 @@ -import type { CreateLog, Log, LogType } from '@logto/schemas'; +import type { CreateLog, Log } from '@logto/schemas'; import { Logs } from '@logto/schemas'; +import type { LogType } from '@logto/schemas/lib/types/log-legacy.js'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 474a586a8..cb34ad255 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -4,8 +4,9 @@ import mount from 'koa-mount'; import Router from 'koa-router'; import type { Provider } from 'oidc-provider'; +import koaAuditLogSession from '../middleware/koa-audit-log-session.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 +24,16 @@ 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(koaLogSessionLegacy(provider)); sessionRoutes(sessionRouter, provider); const interactionRouter: AnonymousRouter = new Router(); - interactionRouter.use(koaLogSession(provider)); + interactionRouter.use(koaAuditLogSession(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/utils/passcode-validation.test.ts b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts index 764b85793..c2893adc3 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-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(); diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.ts b/packages/core/src/routes/interaction/utils/passcode-validation.ts index abb5c04ed..10a164bd8 100644 --- a/packages/core/src/routes/interaction/utils/passcode-validation.ts +++ b/packages/core/src/routes/interaction/utils/passcode-validation.ts @@ -1,9 +1,8 @@ import type { Event } from '@logto/schemas'; -import { PasscodeType } from '@logto/schemas'; +import { interaction, 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'; @@ -26,20 +25,18 @@ export const sendPasscodeToIdentifier = async ( ) => { const { event, ...identifier } = payload; const passcodeType = getPasscodeTypeByEvent(event); + // TODO: @Simeng this can be refactored + const identifierType = + 'email' in identifier ? interaction.Identifier.Email : interaction.Identifier.Phone; - const logType = getPasswordlessRelatedLogType( - passcodeType, - 'email' in identifier ? 'email' : 'sms', - 'send' - ); - - log(logType, identifier); + log.setKey(`${event}.${identifierType}.Passcode.Create`); + log(identifier); const passcode = await createPasscode(jti, passcodeType, identifier); const { dbEntry } = await sendPasscode(passcode); - log(logType, { connectorId: dbEntry.id }); + log({ connectorId: dbEntry.id }); }; export const verifyIdentifierByPasscode = async ( @@ -49,14 +46,12 @@ export const verifyIdentifierByPasscode = async ( ) => { const { event, passcode, ...identifier } = payload; const passcodeType = getPasscodeTypeByEvent(event); + // TODO: @Simeng this can be refactored - const logType = getPasswordlessRelatedLogType( - passcodeType, - 'email' in identifier ? 'email' : 'sms', - 'verify' - ); + const identifierType = + 'email' in identifier ? interaction.Identifier.Email : interaction.Identifier.Phone; - log(logType, identifier); + log.setKey(`${event}.${identifierType}.Passcode.Submit`); 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..b46542810 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-log.js'; + const { jest } = import.meta; const { getUserInfoByAuthCode } = mockEsm('#src/libraries/social.js', () => ({ @@ -18,7 +20,7 @@ mockEsm('#src/connectors.js', () => ({ })); const { verifySocialIdentity } = await import('./social-verification.js'); -const log = jest.fn(); +const log = createMockLogContext(); describe('social-verification', () => { it('verifySocialIdentity', async () => { diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index 0574dfb54..dea2d56c0 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'; @@ -24,12 +24,12 @@ export const verifySocialIdentity = async ( { connectorId, connectorData }: SocialConnectorPayload, log: LogContext['log'] ): Promise => { - const logType: LogType = 'SignInSocial'; - log(logType, { connectorId, connectorData }); + log.setKey('SignIn.SocialId.Social.Submit'); + log({ connectorId, connectorData }); const userInfo = await getUserInfoByAuthCode(connectorId, connectorData); - log(logType, userInfo); + log(userInfo); return userInfo; }; 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..96b69cbe8 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -1,9 +1,13 @@ 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; +/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */ +export type AnonymousRouterLegacy = Router; + export type AuthedRouter = Router; diff --git a/packages/core/src/test-utils/koa-log.ts b/packages/core/src/test-utils/koa-log.ts new file mode 100644 index 000000000..af080b9be --- /dev/null +++ b/packages/core/src/test-utils/koa-log.ts @@ -0,0 +1,9 @@ +import type { LogKey } from '@logto/schemas'; + +import type { LogPayload } from '#src/middleware/koa-audit-log.js'; + +const { jest } = import.meta; + +export const createMockLogContext = () => + // eslint-disable-next-line @silverhand/fp/no-mutating-assign + Object.assign(jest.fn(), { setKey: jest.fn() }); 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 index bea89da3c..2ee08e4b6 100644 --- a/packages/core/src/utils/oidc-provider-event-listener.test.ts +++ b/packages/core/src/utils/oidc-provider-event-listener.test.ts @@ -1,22 +1,24 @@ -import { LogResult, TokenType } from '@logto/schemas'; -import { Provider } from 'oidc-provider'; +import type { LogKey } from '@logto/schemas'; +import { LogResult, token } from '@logto/schemas'; +import { createMockLogContext } from '#src/test-utils/koa-log.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { addOidcEventListeners, - grantErrorListener, - grantRevokedListener, - grantSuccessListener, + grantListener, + grantRevocationListener, } from '#src/utils/oidc-provider-event-listener.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; +import { stringifyError } from './format.js'; + const { jest } = import.meta; const userId = 'userIdValue'; const sessionId = 'sessionIdValue'; const applicationId = 'applicationIdValue'; -const addLogContext = jest.fn(); -const log = jest.fn(); +const log = createMockLogContext(); describe('addOidcEventListeners', () => { afterEach(() => { @@ -24,200 +26,144 @@ describe('addOidcEventListeners', () => { }); it('should add proper listeners', () => { - const provider = new Provider('https://logto.test'); + const provider = createMockProvider(); 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); + expect(addListener).toHaveBeenCalledWith('grant.success', grantListener); + expect(addListener).toHaveBeenCalledWith('grant.error', grantListener); + expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevocationListener); }); }); -describe('grantSuccessListener', () => { - const entities = { - Account: { accountId: userId }, - Grant: { jti: sessionId }, - Client: { clientId: applicationId }, +const entities = { + Account: { accountId: userId }, + Grant: { jti: sessionId }, + Client: { clientId: applicationId }, +}; + +const baseCallArgs = { applicationId, sessionId, userId }; + +const testGrantListener = async ( + parameters: { grant_type: string } & Record, + body: Record, + expectLogKey: LogKey, + expectLogTokenTypes: token.TokenType[], + expectError?: Error +) => { + const ctx = { + ...createContextWithRouteParameters(), + log, + oidc: { entities, params: parameters }, + body, }; + // @ts-expect-error pass complex type check to mock ctx directly + await grantListener(ctx, expectError); + expect(log.setKey).toHaveBeenCalledWith(expectLogKey); + expect(log).toHaveBeenCalledWith({ + ...baseCallArgs, + result: expectError && LogResult.Error, + tokenTypes: expectLogTokenTypes, + error: expectError && stringifyError(expectError), + params: parameters, + }); +}; + +describe('grantSuccessListener', () => { 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: { + it('should log type ExchangeTokenBy when grant type is authorization_code', async () => { + await testGrantListener( + { grant_type: 'authorization_code', code: 'codeValue' }, + { 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, - }); + 'ExchangeTokenBy.AuthorizationCode', + [token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken] + ); }); - 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: { + it('should log type ExchangeTokenBy when grant type is refresh_code', async () => { + await testGrantListener( + { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' }, + { 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, - }); + 'ExchangeTokenBy.RefreshToken', + [token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken] + ); }); 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, - }); + await testGrantListener( + { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' }, + { access_token: 'newAccessTokenValue', refresh_token: 'newRefreshTokenValue' }, + 'ExchangeTokenBy.RefreshToken', + [token.TokenType.AccessToken, token.TokenType.RefreshToken] + ); }); - 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: {}, - }; + it('should log type ExchangeTokenBy when grant type is client_credentials', async () => { + await testGrantListener( + { grant_type: 'client_credentials' }, + { access_token: 'newAccessTokenValue' }, + 'ExchangeTokenBy.ClientCredentials', + [token.TokenType.AccessToken] + ); + }); - // @ts-expect-error pass complex type check to mock ctx directly - await grantSuccessListener(ctx); - expect(addLogContext).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalled(); + it('should log type ExchangeTokenBy when grant type is unknown', async () => { + await testGrantListener( + { grant_type: 'foo' }, + { access_token: 'newAccessTokenValue' }, + 'ExchangeTokenBy.Unknown', + [token.TokenType.AccessToken] + ); }); }); describe('grantErrorListener', () => { - const entities = { Client: { clientId: applicationId } }; - const errorMessage = 'invalid grant'; + const errorMessage = 'error ocurred'; 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: { + it('should log type ExchangeTokenBy when error occurred', async () => { + await testGrantListener( + { grant_type: 'authorization_code', code: 'codeValue' }, + { 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, - }); + 'ExchangeTokenBy.AuthorizationCode', + [token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken], + new Error(errorMessage) + ); }); - 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(); + it('should log unknown grant when error occurred', async () => { + await testGrantListener( + { grant_type: 'foo', code: 'codeValue' }, + { access_token: 'newAccessTokenValue' }, + 'ExchangeTokenBy.Unknown', + [token.TokenType.AccessToken], + new Error(errorMessage) + ); }); }); -describe('grantRevokedListener', () => { +describe('grantRevocationListener', () => { const grantId = 'grantIdValue'; - const token = 'tokenValue'; - const parameters = { token }; + const tokenValue = 'tokenValue'; + const parameters = { token: tokenValue }; const client = { clientId: applicationId }; const accessToken = { accountId: userId }; @@ -227,67 +173,58 @@ describe('grantRevokedListener', () => { jest.clearAllMocks(); }); - it('should log token type AccessToken when the token is an access token', async () => { + it('should log token types properly', async () => { const ctx = { ...createContextWithRouteParameters(), - addLogContext, log, oidc: { entities: { Client: client, AccessToken: accessToken }, params: parameters, }, - body: { client_id: applicationId, token }, + body: { client_id: applicationId, token: tokenValue }, }; // @ts-expect-error pass complex type check to mock ctx directly - await grantRevokedListener(ctx, grantId); - expect(addLogContext).toHaveBeenCalledWith({ applicationId }); - expect(log).toHaveBeenCalledWith('RevokeToken', { + await grantRevocationListener(ctx, grantId); + expect(log.setKey).toHaveBeenCalledWith('RevokeToken'); + expect(log).toHaveBeenCalledWith({ + applicationId, userId, params: parameters, grantId, - tokenType: TokenType.AccessToken, + tokenTypes: [token.TokenType.AccessToken], }); }); - it('should log token type RefreshToken when the token is a refresh code', async () => { + it('should log token types properly 2', async () => { const ctx = { ...createContextWithRouteParameters(), - addLogContext, log, oidc: { - entities: { Client: client, RefreshToken: refreshToken }, + entities: { + Client: client, + AccessToken: accessToken, + RefreshToken: refreshToken, + DeviceCode: 'mock', + }, params: parameters, }, - body: { client_id: applicationId, token }, + body: { client_id: applicationId, token: tokenValue }, }; // @ts-expect-error pass complex type check to mock ctx directly - await grantRevokedListener(ctx, grantId); - expect(addLogContext).toHaveBeenCalledWith({ applicationId }); - expect(log).toHaveBeenCalledWith('RevokeToken', { + await grantRevocationListener(ctx, grantId); + expect(log.setKey).toHaveBeenCalledWith('RevokeToken'); + expect(log).toHaveBeenCalledWith({ + applicationId, userId, params: parameters, grantId, - tokenType: TokenType.RefreshToken, + tokenTypes: [ + token.TokenType.AccessToken, + token.TokenType.RefreshToken, + token.TokenType.DeviceCode, + ], }); }); - - 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 index 86a170469..e5c10ae6f 100644 --- a/packages/core/src/utils/oidc-provider-event-listener.ts +++ b/packages/core/src/utils/oidc-provider-event-listener.ts @@ -1,25 +1,73 @@ -import { GrantType, TokenType, LogResult } from '@logto/schemas'; -import { notFalsy } from '@silverhand/essentials'; +import { GrantType, LogResult, token } from '@logto/schemas'; import type { errors, KoaContextWithOIDC, Provider } from 'oidc-provider'; -import type { WithLogContext } from '#src/middleware/koa-log.js'; +import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { stringifyError } from './format.js'; +import { isEnum } from './type.js'; + +/** + * 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 + */ 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); + provider.addListener('grant.success', grantListener); + provider.addListener('grant.error', grantListener); + provider.addListener('grant.revoked', grantRevocationListener); +}; + +export const grantListener = async ( + ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody }, + error?: errors.OIDCProviderError +) => { + const { + entities: { Account: account, Grant: grant, Client: client }, + params, + } = ctx.oidc; + + ctx.log.setKey(`${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); + + ctx.log({ + result: error && LogResult.Error, + applicationId: client?.clientId, + sessionId: grant?.jti, + userId: account?.accountId, + params, + tokenTypes, + scope, + error: error && stringifyError(error), + }); +}; + +// The grant.revoked event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/revoke.js#L25 +export const grantRevocationListener = async ( + ctx: KoaContextWithOIDC & WithLogContext, + grantId: string +) => { + const { + entities: { Client: client, AccessToken, RefreshToken }, + params, + } = ctx.oidc; + + const userId = AccessToken?.accountId ?? RefreshToken?.accountId; + const tokenTypes = getRevocationTokenTypes(ctx.oidc); + + ctx.log.setKey('RevokeToken'); + ctx.log({ userId, applicationId: client?.clientId, params, grantId, tokenTypes }); }; /** * 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; @@ -28,105 +76,28 @@ type GrantBody = { 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'; +const grantTypeToExchangeByType: Record = { + [GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode, + [GrantType.RefreshToken]: token.ExchangeByType.RefreshToken, + [GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials, }; -// 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; +const getExchangeByType = (grantType: unknown): token.ExchangeByType => { + if (!isEnum(Object.values(GrantType), grantType)) { + return token.ExchangeByType.Unknown; } - 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, - }); + return grantTypeToExchangeByType[grantType]; }; -// 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 }); +/** + * See [OAuth 2.0 Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009) for RFC reference. + * + * 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 [this function](https://github.com/panva/node-oidc-provider/blob/433d131989558e24c0c74970d2d700af2199485d/lib/actions/revocation.js#L56) 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/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/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/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..0b3726d86 --- /dev/null +++ b/packages/schemas/src/types/log/index.ts @@ -0,0 +1,37 @@ +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'; + +export const LogKeyUnknown = 'Unknown'; + +export type LogKey = interaction.LogKey | token.LogKey | typeof LogKeyUnknown; + +export enum LogResult { + Success = 'Success', + Error = 'Error', +} + +export type LogContextPayload = { + key: string; + result: LogResult; + error?: Record | string; + ip?: string; + userAgent?: string; + applicationId?: string; + sessionId?: string; +}; + +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..ffaf949d4 --- /dev/null +++ b/packages/schemas/src/types/log/interaction.ts @@ -0,0 +1,39 @@ +import { Event } from '../interactions.js'; + +import Flow = Event; + +export { Flow }; + +export enum Identifier { + Username = 'Username', + Email = 'Email', + Phone = 'Phone', + SocialId = 'SocialId', +} + +export enum Method { + Password = 'Password', + Passcode = 'Passcode', + Social = 'Social', +} + +export enum Action { + Submit = 'Submit', + Create = 'Create', +} + +export type ForgotPasswordLogKey = `${Flow.ForgotPassword}.${Exclude< + Identifier, + 'SocialId' +>}.${Method.Passcode}.${Action}`; + +type SignInRegisterFlow = Exclude; + +export type SignInRegisterLogKey = + | `${Flow.SignIn}.${Identifier.SocialId}.${Method.Social}.${Action.Submit}` + | `${SignInRegisterFlow}.${Exclude}.${Method.Password}.${Action.Submit}` + | `${SignInRegisterFlow}.${Exclude}.${Method.Passcode}.${Action}`; + +export type FlowLogKey = `${Flow}.${Action}`; + +export type LogKey = ForgotPasswordLogKey | SignInRegisterLogKey | FlowLogKey; diff --git a/packages/schemas/src/types/log/token.ts b/packages/schemas/src/types/log/token.ts new file mode 100644 index 000000000..24b87b9ef --- /dev/null +++ b/packages/schemas/src/types/log/token.ts @@ -0,0 +1,25 @@ +export enum Flow { + ExchangeTokenBy = 'ExchangeTokenBy', + RevokeToken = 'RevokeToken', +} + +/** + * Default grant type 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', +} + +export enum ExchangeByType { + Unknown = 'Unknown', + AuthorizationCode = 'AuthorizationCode', + RefreshToken = 'RefreshToken', + ClientCredentials = 'ClientCredentials', +} + +export type LogKey = `${Flow.ExchangeTokenBy}.${ExchangeByType}` | `${Flow.RevokeToken}`;