0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor: multiple logs and oidc listeners

This commit is contained in:
Gao Sun 2022-12-17 19:05:28 +08:00
parent 42a305b6b2
commit b0e1c5368c
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
33 changed files with 416 additions and 317 deletions

View file

@ -2,15 +2,10 @@ 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,
grantListener,
grantRevocationListener,
} from '#src/utils/oidc-provider-event-listener.js';
import { stringifyError } from '#src/utils/format.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { stringifyError } from './format.js';
import { grantListener, grantRevocationListener } from './grant.js';
const { jest } = import.meta;
@ -20,30 +15,15 @@ const applicationId = 'applicationIdValue';
const log = createMockLogContext();
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);
});
});
const entities = {
Account: { accountId: userId },
Grant: { jti: sessionId },
Session: { jti: sessionId },
Client: { clientId: applicationId },
};
const baseCallArgs = { applicationId, sessionId, userId };
const testGrantListener = async (
const testGrantListener = (
parameters: { grant_type: string } & Record<string, unknown>,
body: Record<string, string>,
expectLogKey: LogKey,
@ -52,15 +32,15 @@ const testGrantListener = async (
) => {
const ctx = {
...createContextWithRouteParameters(),
log,
createLog: log.createLog,
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({
grantListener(ctx, expectError);
expect(log.createLog).toHaveBeenCalledWith(expectLogKey);
expect(log.mockAppend).toHaveBeenCalledWith({
...baseCallArgs,
result: expectError && LogResult.Error,
tokenTypes: expectLogTokenTypes,
@ -74,8 +54,8 @@ describe('grantSuccessListener', () => {
jest.clearAllMocks();
});
it('should log type ExchangeTokenBy when grant type is authorization_code', async () => {
await testGrantListener(
it('should log type ExchangeTokenBy when grant type is authorization_code', () => {
testGrantListener(
{ grant_type: 'authorization_code', code: 'codeValue' },
{
access_token: 'newAccessTokenValue',
@ -87,8 +67,8 @@ describe('grantSuccessListener', () => {
);
});
it('should log type ExchangeTokenBy when grant type is refresh_code', async () => {
await testGrantListener(
it('should log type ExchangeTokenBy when grant type is refresh_code', () => {
testGrantListener(
{ grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' },
{
access_token: 'newAccessTokenValue',
@ -100,8 +80,8 @@ describe('grantSuccessListener', () => {
);
});
test('issued field should not contain "idToken" when there is no issued idToken', async () => {
await testGrantListener(
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',
@ -109,8 +89,8 @@ describe('grantSuccessListener', () => {
);
});
it('should log type ExchangeTokenBy when grant type is client_credentials', async () => {
await testGrantListener(
it('should log type ExchangeTokenBy when grant type is client_credentials', () => {
testGrantListener(
{ grant_type: 'client_credentials' },
{ access_token: 'newAccessTokenValue' },
'ExchangeTokenBy.ClientCredentials',
@ -118,8 +98,8 @@ describe('grantSuccessListener', () => {
);
});
it('should log type ExchangeTokenBy when grant type is unknown', async () => {
await testGrantListener(
it('should log type ExchangeTokenBy when grant type is unknown', () => {
testGrantListener(
{ grant_type: 'foo' },
{ access_token: 'newAccessTokenValue' },
'ExchangeTokenBy.Unknown',
@ -135,8 +115,8 @@ describe('grantErrorListener', () => {
jest.clearAllMocks();
});
it('should log type ExchangeTokenBy when error occurred', async () => {
await testGrantListener(
it('should log type ExchangeTokenBy when error occurred', () => {
testGrantListener(
{ grant_type: 'authorization_code', code: 'codeValue' },
{
access_token: 'newAccessTokenValue',
@ -149,8 +129,8 @@ describe('grantErrorListener', () => {
);
});
it('should log unknown grant when error occurred', async () => {
await testGrantListener(
it('should log unknown grant when error occurred', () => {
testGrantListener(
{ grant_type: 'foo', code: 'codeValue' },
{ access_token: 'newAccessTokenValue' },
'ExchangeTokenBy.Unknown',
@ -173,10 +153,10 @@ describe('grantRevocationListener', () => {
jest.clearAllMocks();
});
it('should log token types properly', async () => {
it('should log token types properly', () => {
const ctx = {
...createContextWithRouteParameters(),
log,
createLog: log.createLog,
oidc: {
entities: { Client: client, AccessToken: accessToken },
params: parameters,
@ -185,9 +165,9 @@ describe('grantRevocationListener', () => {
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantRevocationListener(ctx, grantId);
expect(log.setKey).toHaveBeenCalledWith('RevokeToken');
expect(log).toHaveBeenCalledWith({
grantRevocationListener(ctx, grantId);
expect(log.createLog).toHaveBeenCalledWith('RevokeToken');
expect(log.mockAppend).toHaveBeenCalledWith({
applicationId,
userId,
params: parameters,
@ -196,10 +176,10 @@ describe('grantRevocationListener', () => {
});
});
it('should log token types properly 2', async () => {
it('should log token types properly 2', () => {
const ctx = {
...createContextWithRouteParameters(),
log,
createLog: log.createLog,
oidc: {
entities: {
Client: client,
@ -213,9 +193,9 @@ describe('grantRevocationListener', () => {
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantRevocationListener(ctx, grantId);
expect(log.setKey).toHaveBeenCalledWith('RevokeToken');
expect(log).toHaveBeenCalledWith({
grantRevocationListener(ctx, grantId);
expect(log.createLog).toHaveBeenCalledWith('RevokeToken');
expect(log.mockAppend).toHaveBeenCalledWith({
applicationId,
userId,
params: parameters,

View file

@ -1,35 +1,25 @@
import { GrantType, LogResult, token } from '@logto/schemas';
import type { errors, KoaContextWithOIDC, Provider } from 'oidc-provider';
import type { errors, KoaContextWithOIDC } from 'oidc-provider';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import { stringifyError } from './format.js';
import { isEnum } from './type.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);
};
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 = async (
export const grantListener = (
ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody },
error?: errors.OIDCProviderError
) => {
const {
entities: { Account: account, Grant: grant, Client: client },
params,
} = ctx.oidc;
const { params } = ctx.oidc;
ctx.log.setKey(`${token.Flow.ExchangeTokenBy}.${getExchangeByType(params?.grant_type)}`);
const log = ctx.createLog(
`${token.Flow.ExchangeTokenBy}.${getExchangeByType(params?.grant_type)}`
);
const { access_token, refresh_token, id_token, scope } = ctx.body;
const tokenTypes = [
@ -38,12 +28,9 @@ export const grantListener = async (
id_token && token.TokenType.IdToken,
].filter(Boolean);
ctx.log({
log.append({
...extractInteractionContext(ctx),
result: error && LogResult.Error,
applicationId: client?.clientId,
sessionId: grant?.jti,
userId: account?.accountId,
params,
tokenTypes,
scope,
error: error && stringifyError(error),
@ -51,20 +38,20 @@ export const grantListener = async (
};
// 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 = async (
export const grantRevocationListener = (
ctx: KoaContextWithOIDC & WithLogContext,
grantId: string
) => {
const {
entities: { Client: client, AccessToken, RefreshToken },
params,
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);
ctx.log.setKey('RevokeToken');
ctx.log({ userId, applicationId: client?.clientId, params, grantId, tokenTypes });
const log = ctx.createLog('RevokeToken');
log.append({ ...extractInteractionContext(ctx), userId, grantId, tokenTypes });
};
/**

View file

@ -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);
});
});

View file

@ -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);
};

View file

@ -0,0 +1,76 @@
import type { LogKey } from '@logto/schemas';
import type { PromptDetail } from 'oidc-provider';
import { createMockLogContext } from '#src/test-utils/koa-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<string, unknown>,
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'
);
});
});

View file

@ -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);
};

View file

@ -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,
};
};

View file

@ -1,73 +0,0 @@
import { Provider } from 'oidc-provider';
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;
const provider = new Provider('https://logto.test');
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
describe('koaAuditLogSession', () => {
const sessionId = 'sessionId';
const applicationId = 'applicationId';
const log = createMockLogContext();
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: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
log,
};
await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow();
expect(interactionDetails).toHaveBeenCalled();
});
it('should log session id and application id', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
log,
};
await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow();
expect(log).toHaveBeenCalledWith({ sessionId, applicationId });
});
it('should call next', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
log,
};
await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow();
expect(next).toHaveBeenCalled();
});
it('should not throw when interactionDetails throw error', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
log,
};
interactionDetails.mockImplementationOnce(() => {
throw new Error('message');
});
await expect(koaAuditLogSession(provider)(ctx, next)).resolves.not.toThrow();
});
});

View file

@ -1,22 +0,0 @@
import type { MiddlewareType } from 'koa';
import type { Provider } from 'oidc-provider';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
export default function koaAuditLogSession<StateT, ContextT extends WithLogContext, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
await next();
try {
const {
jti,
params: { client_id },
} = await provider.interactionDetails(ctx.req, ctx.res);
ctx.log({ sessionId: jti, applicationId: String(client_id) });
} catch (error: unknown) {
console.error(`Failed to get oidc provider interaction`, error);
}
};
}

View file

@ -12,9 +12,6 @@ const { jest } = import.meta;
const nanoIdMock = 'mockId';
// eslint-disable-next-line @silverhand/fp/no-mutating-assign
const log = Object.assign(jest.fn(), { setKey: jest.fn() });
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
@ -25,8 +22,9 @@ mockEsm('nanoid', () => ({
const koaLog = await pickDefault(import('./koa-audit-log.js'));
describe('koaLog middleware', () => {
const logKey: LogKey = 'SignIn.Username.VerificationCode.Submit';
// TODO: test with multiple logs
describe('koaAuditLog middleware', () => {
const logKey: LogKey = 'Interaction.SignIn.Identifier.VerificationCode.Submit';
const mockPayload: LogPayload = {
userId: 'foo',
username: 'Bar',
@ -41,17 +39,17 @@ describe('koaLog middleware', () => {
});
it('should insert a success log when next() does not throw an error', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...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);
const log = ctx.createLog(logKey);
log.append(mockPayload);
log.append(additionalMockPayload);
};
await koaLog()(ctx, next);
@ -69,33 +67,24 @@ describe('koaLog middleware', () => {
});
});
it('should insert a log with unknown key when there is no log type', async () => {
it('should not log when there is no log type', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...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,
},
});
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<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
log,
};
ctx.request.ip = ip;
@ -103,8 +92,8 @@ describe('koaLog middleware', () => {
const error = new Error(message);
const next = async () => {
ctx.log.setKey(logKey);
ctx.log(mockPayload);
const log = ctx.createLog(logKey);
log.append(mockPayload);
throw error;
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
@ -124,9 +113,9 @@ describe('koaLog middleware', () => {
});
it('should insert an error log with the error body when next() throws a RequestError', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
log,
};
ctx.request.ip = ip;
@ -137,8 +126,8 @@ describe('koaLog middleware', () => {
const error = new RequestError(code, data);
const next = async () => {
ctx.log.setKey(logKey);
ctx.log(mockPayload);
const log = ctx.createLog(logKey);
log.append(mockPayload);
throw error;
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);

View file

@ -1,5 +1,5 @@
import type { LogContextPayload, LogKey } from '@logto/schemas';
import { LogKeyUnknown, LogResult } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import pick from 'lodash.pick';
@ -11,15 +11,28 @@ import { insertLog } from '#src/queries/log.js';
const removeUndefinedKeys = (object: Record<string, unknown>) =>
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,
};
}
append(data: Readonly<LogPayload>) {
this.payload = {
...this.payload,
...removeUndefinedKeys(data),
};
}
}
export type LogPayload = Partial<LogContextPayload> & Record<string, unknown>;
export type LogFunction = {
(data: Readonly<LogPayload>): void;
setKey: (key: LogKey) => void;
};
export type LogContext = {
log: LogFunction;
createLog: (key: LogKey) => LogEntry;
};
export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamContext> = ContextT &
@ -29,7 +42,7 @@ export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamCo
* The factory to create a new audit log middleware function.
* It will inject a {@link LogFunction} property named `log` to the context to enable audit logging.
*
* ---
* #### Set log key
*
* You need to explicitly call `ctx.log.setKey()` to set a {@link LogKey} thus the log can be categorized and indexed in database:
*
@ -37,6 +50,10 @@ export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamCo
* ctx.log.setKey('SignIn.Submit'); // Key is typed
* ```
*
* If log key is {@link LogKeyUnknown} in the end, it will not be recorded to the persist storage.
*
* #### Log data
*
* To log data, call `ctx.log()`. It'll use object spread operators to update data (i.e. merge with one-level overwrite and shallow copy).
*
* ```ts
@ -60,61 +77,50 @@ export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamCo
* @see {@link LogContextPayload} for the basic type suggestion of log data.
* @returns An audit log middleware function.
*/
export default function koaAuditLog<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
export default function koaAuditLog<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
dumpLogContext?: (ctx: ContextT) => Promise<Record<string, unknown>> | Record<string, unknown>
): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const {
ip,
headers: { 'user-agent': userAgent },
} = ctx.request;
const entries: LogEntry[] = [];
// eslint-disable-next-line @silverhand/fp/no-let
let payload: LogContextPayload = {
key: LogKeyUnknown,
result: LogResult.Success,
ip,
userAgent,
ctx.createLog = (key: LogKey) => {
const entry = new LogEntry(key);
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
entries.push(entry);
return entry;
};
const log: LogFunction = Object.assign(
(data: Readonly<LogPayload>) => {
// 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) },
});
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 {
// TODO: If no `payload.key` found, should we trigger an alert or something?
await insertLog({
id: nanoid(),
type: payload.key,
payload,
});
// Predefined context
const {
ip,
headers: { 'user-agent': userAgent },
} = ctx.request;
const logContext = { ip, userAgent, ...(await dumpLogContext?.(ctx)) };
await Promise.all(
entries.map(async ({ payload }) => {
return insertLog({
id: nanoid(),
type: payload.key,
payload: { ...logContext, ...payload },
});
})
);
}
};
}

View file

@ -11,7 +11,7 @@ import { Provider, errors } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
import envSet from '#src/env-set/index.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import postgresAdapter from '#src/oidc/adapter.js';
import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js';
import { findApplicationById } from '#src/queries/application.js';
@ -19,7 +19,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';
@ -189,9 +188,6 @@ export default async function initOidc(app: Koa): Promise<Provider> {
addOidcEventListeners(oidc);
// Session audit logs
oidc.use(koaAuditLog());
app.use(mount('/oidc', oidc.app));
return oidc;

View file

@ -4,10 +4,10 @@ import mount from 'koa-mount';
import Router from 'koa-router';
import type { Provider } from 'oidc-provider';
import { extractInteractionContext } from '#src/event-listeners/utils.js';
import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaAuditLogSession from '../middleware/koa-audit-log-session.js';
import koaAuth from '../middleware/koa-auth.js';
import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js';
import adminUserRoutes from './admin-user.js';
@ -36,7 +36,7 @@ const createRouters = (provider: Provider) => {
sessionRoutes(sessionRouter, provider);
const interactionRouter: AnonymousRouter = new Router();
interactionRouter.use(koaAuditLog(), koaAuditLogSession(provider));
interactionRouter.use(koaAuditLog(extractInteractionContext));
interactionRoutes(interactionRouter, provider);
const managementRouter: AuthedRouter = new Router();

View file

@ -52,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();
}

View file

@ -1,5 +1,5 @@
import type { Event } from '@logto/schemas';
import { interaction, PasscodeType } from '@logto/schemas';
import { PasscodeType } from '@logto/schemas';
import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js';
import type { LogContext } from '#src/middleware/koa-audit-log.js';
@ -21,37 +21,29 @@ 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);
// TODO: @Simeng this can be refactored
const identifierType =
'email' in identifier ? interaction.Identifier.Email : interaction.Identifier.Phone;
log.setKey(`${event}.${identifierType}.VerificationCode.Create`);
log(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({ 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);
// TODO: @Simeng this can be refactored
const identifierType =
'email' in identifier ? interaction.Identifier.Email : interaction.Identifier.Phone;
log.setKey(`${event}.${identifierType}.VerificationCode.Submit`);
// TODO: @simeng append more log content?
const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`);
await verifyPasscode(jti, passcodeType, passcode, identifier);
};

View file

@ -26,7 +26,7 @@ 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' });

View file

@ -22,14 +22,14 @@ export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationU
export const verifySocialIdentity = async (
{ connectorId, connectorData }: SocialConnectorPayload,
log: LogContext['log']
createLog: LogContext['createLog']
): Promise<SocialUserInfo> => {
log.setKey('SignIn.SocialId.Social.Create');
log({ connectorId, connectorData });
const log = createLog('Interaction.SignIn.Identifier.Social.Submit');
log.append({ connectorId, connectorData });
const userInfo = await getUserInfoByAuthCode(connectorId, connectorData);
log(userInfo);
log.append(userInfo);
return userInfo;
};

View file

@ -24,6 +24,7 @@ export default function logRoutes<T extends AuthedRouter>(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 }),

View file

@ -1,11 +1,15 @@
import type Router from 'koa-router';
import type { KoaContextWithOIDC } from 'oidc-provider';
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';
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
export type AnonymousRouter = Router<
unknown,
WithLogContext & WithI18nContext & KoaContextWithOIDC
>;
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;

View file

@ -1,9 +1,13 @@
import type { LogKey } from '@logto/schemas';
import type { LogPayload } from '#src/middleware/koa-audit-log.js';
import { LogEntry } 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<void, [LogPayload]>(), { setKey: jest.fn<void, [LogKey]>() });
class MockLogEntry extends LogEntry {
append = jest.fn();
}
export const createMockLogContext = () => {
const mockLogEntry = new MockLogEntry('Unknown');
return { createLog: jest.fn(() => mockLogEntry), mockAppend: mockLogEntry.append };
};

View file

@ -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<Log[]>();
// eslint-disable-next-line unicorn/prevent-abbreviations
export const getLogs = (params?: URLSearchParams) =>
authedAdminApi.get('logs?' + conditionalString(params?.toString())).json<Log[]>();
export const getLog = (logId: string) => authedAdminApi.get(`logs/${logId}`).json<Log>();

View file

@ -6,7 +6,6 @@ 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 +16,7 @@ export const defaultConfig = {
};
export default class MockClient {
public interactionCookie?: string;
public rawCookies: string[] = [];
private navigateUrl?: string;
private readonly storage: MemoryStorage;
@ -37,6 +36,11 @@ export default class MockClient {
);
}
// TODO: Rename to sessionCookies or something accurate
public get interactionCookie(): string {
return this.rawCookies.join('; ');
}
public async initSession(callbackUri = demoAppRedirectUri) {
await this.logto.signIn(callbackUri);
@ -58,7 +62,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 +83,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 +98,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 +115,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 +140,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;
};
}

View file

@ -0,0 +1,65 @@
import { interaction } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { assert } from '@silverhand/essentials';
import { deleteUser } from '#src/api/admin-user.js';
import { getLogs } from '#src/api/logs.js';
import { registerUserWithUsernameAndPassword } from '#src/api/session.js';
import MockClient from '#src/client/index.js';
import { generatePassword, generateUsername } from '#src/utils.js';
const parseCookies = (cookies: string[]): Map<string, Optional<string>> => {
const map = new Map<string, Optional<string>>();
for (const cookie of cookies) {
for (const element of cookie.split(';')) {
const [key, value] = element.trim().split('=');
if (key) {
map.set(key, value);
}
}
}
return map;
};
// TODO: @Gao Use new interaction APIs
describe('audit logs for interaction', () => {
it('should insert log after interaction started and ended', async () => {
const client = new MockClient();
await client.initSession();
const cookies = parseCookies(client.rawCookies);
const interactionId = cookies.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 = generateUsername();
const password = generatePassword();
const response = await registerUserWithUsernameAndPassword(
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);
});
});

View file

@ -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();

View file

@ -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()}`;

View file

@ -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<typeof arbitraryObjectGuard>;

View file

@ -4,6 +4,8 @@ export { 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',
@ -25,6 +27,8 @@ export enum Action {
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',
}
/**
@ -39,10 +43,10 @@ export enum Action {
* ### Keys breakdown
*
* ```ts
* `Interaction.${Action.Create}`
* `Interaction.${Action.Create | Action.End}`
* ```
*
* - Indicates an interaction is being created. Normally it is performed by an OIDC auth request.
* - Indicates an interaction is started or ended. Normally it is performed by OIDC Provider.
*
* ```ts
* `Interaction.${Event}.${Action.Update | Action.Submit}`
@ -70,7 +74,7 @@ export enum Action {
* - Otherwise, {@link Action} is fixed to `Submit` (other methods can be verified on submitting).
*/
export type LogKey =
| `${Prefix}.${Action.Create}`
| `${Prefix}.${Action.Create | Action.End}`
| `${Prefix}.${Event}.${Action.Update | Action.Submit}`
| `${Prefix}.${Event}.${Field.Profile}.${Action.Update}`
| `${Prefix}.${Event}.${Field.Identifier}.${Method.VerificationCode}.${