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:
parent
42a305b6b2
commit
b0e1c5368c
33 changed files with 416 additions and 317 deletions
|
@ -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,
|
|
@ -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 });
|
||||
};
|
||||
|
||||
/**
|
24
packages/core/src/event-listeners/index.test.ts
Normal file
24
packages/core/src/event-listeners/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
16
packages/core/src/event-listeners/index.ts
Normal file
16
packages/core/src/event-listeners/index.ts
Normal 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);
|
||||
};
|
76
packages/core/src/event-listeners/interaction.test.ts
Normal file
76
packages/core/src/event-listeners/interaction.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
25
packages/core/src/event-listeners/interaction.ts
Normal file
25
packages/core/src/event-listeners/interaction.ts
Normal 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);
|
||||
};
|
21
packages/core/src/event-listeners/utils.ts
Normal file
21
packages/core/src/event-listeners/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
@ -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()}`;
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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}.${
|
||||
|
|
Loading…
Add table
Reference in a new issue