0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

Merge pull request #2675 from logto-io/gao-refactor-log-types

refactor: log types 1
This commit is contained in:
Gao Sun 2022-12-19 22:57:03 +08:00 committed by GitHub
commit 8ca25c8d5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 1317 additions and 609 deletions

View file

@ -1,5 +1,5 @@
import type { LogDto } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import type { LogDto } from '@logto/schemas/lib/types/log-legacy.js';
import { conditional, conditionalString } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';

View file

@ -1,4 +1,5 @@
import type { LogDto, User } from '@logto/schemas';
import type { User } from '@logto/schemas';
import type { LogDto } from '@logto/schemas/lib/types/log-legacy.js';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';

View file

@ -111,7 +111,8 @@
"error",
11
],
"default-case": "off"
"default-case": "off",
"import/extensions": "off"
}
},
"prettier": "@silverhand/eslint-config/.prettierrc"

View file

@ -8,7 +8,7 @@ const { jest } = import.meta;
const middlewareList = [
'error-handler',
'i18next',
'log',
'audit-log',
'oidc-error-handler',
'slonik-error-handler',
'spa-proxy',

View file

@ -13,7 +13,6 @@ import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js';
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
import koaErrorHandler from '#src/middleware/koa-error-handler.js';
import koaI18next from '#src/middleware/koa-i18next.js';
import koaLog from '#src/middleware/koa-log.js';
import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js';
import koaRootProxy from '#src/middleware/koa-root-proxy.js';
import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
@ -32,13 +31,11 @@ const logListening = () => {
};
export default async function initApp(app: Koa): Promise<void> {
app.use(koaLogger());
app.use(koaErrorHandler());
app.use(koaOIDCErrorHandler());
app.use(koaSlonikErrorHandler());
app.use(koaConnectorErrorHandler());
app.use(koaLog());
app.use(koaLogger());
app.use(koaI18next());
const provider = await initOidc(app);

View file

@ -0,0 +1,210 @@
import type { LogKey } from '@logto/schemas';
import { LogResult, token } from '@logto/schemas';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { stringifyError } from '#src/utils/format.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { grantListener, grantRevocationListener } from './grant.js';
const { jest } = import.meta;
const userId = 'userIdValue';
const sessionId = 'sessionIdValue';
const applicationId = 'applicationIdValue';
const log = createMockLogContext();
const entities = {
Account: { accountId: userId },
Session: { jti: sessionId },
Client: { clientId: applicationId },
};
const baseCallArgs = { applicationId, sessionId, userId };
const testGrantListener = (
parameters: { grant_type: string } & Record<string, unknown>,
body: Record<string, string>,
expectLogKey: LogKey,
expectLogTokenTypes: token.TokenType[],
expectError?: Error
) => {
const ctx = {
...createContextWithRouteParameters(),
createLog: log.createLog,
oidc: { entities, params: parameters },
body,
};
// @ts-expect-error pass complex type check to mock ctx directly
grantListener(ctx, expectError);
expect(log.createLog).toHaveBeenCalledWith(expectLogKey);
expect(log.mockAppend).toHaveBeenCalledWith({
...baseCallArgs,
result: expectError && LogResult.Error,
tokenTypes: expectLogTokenTypes,
error: expectError && stringifyError(expectError),
params: parameters,
});
};
describe('grantSuccessListener', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should log type ExchangeTokenBy when grant type is authorization_code', () => {
testGrantListener(
{ grant_type: 'authorization_code', code: 'codeValue' },
{
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
},
'ExchangeTokenBy.AuthorizationCode',
[token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken]
);
});
it('should log type ExchangeTokenBy when grant type is refresh_code', () => {
testGrantListener(
{ grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' },
{
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
},
'ExchangeTokenBy.RefreshToken',
[token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken]
);
});
test('issued field should not contain "idToken" when there is no issued idToken', () => {
testGrantListener(
{ grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' },
{ access_token: 'newAccessTokenValue', refresh_token: 'newRefreshTokenValue' },
'ExchangeTokenBy.RefreshToken',
[token.TokenType.AccessToken, token.TokenType.RefreshToken]
);
});
it('should log type ExchangeTokenBy when grant type is client_credentials', () => {
testGrantListener(
{ grant_type: 'client_credentials' },
{ access_token: 'newAccessTokenValue' },
'ExchangeTokenBy.ClientCredentials',
[token.TokenType.AccessToken]
);
});
it('should log type ExchangeTokenBy when grant type is unknown', () => {
testGrantListener(
{ grant_type: 'foo' },
{ access_token: 'newAccessTokenValue' },
'ExchangeTokenBy.Unknown',
[token.TokenType.AccessToken]
);
});
});
describe('grantErrorListener', () => {
const errorMessage = 'error ocurred';
afterEach(() => {
jest.clearAllMocks();
});
it('should log type ExchangeTokenBy when error occurred', () => {
testGrantListener(
{ grant_type: 'authorization_code', code: 'codeValue' },
{
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
},
'ExchangeTokenBy.AuthorizationCode',
[token.TokenType.AccessToken, token.TokenType.RefreshToken, token.TokenType.IdToken],
new Error(errorMessage)
);
});
it('should log unknown grant when error occurred', () => {
testGrantListener(
{ grant_type: 'foo', code: 'codeValue' },
{ access_token: 'newAccessTokenValue' },
'ExchangeTokenBy.Unknown',
[token.TokenType.AccessToken],
new Error(errorMessage)
);
});
});
describe('grantRevocationListener', () => {
const grantId = 'grantIdValue';
const tokenValue = 'tokenValue';
const parameters = { token: tokenValue };
const client = { clientId: applicationId };
const accessToken = { accountId: userId };
const refreshToken = { accountId: userId };
afterEach(() => {
jest.clearAllMocks();
});
it('should log token types properly', () => {
const ctx = {
...createContextWithRouteParameters(),
createLog: log.createLog,
oidc: {
entities: { Client: client, AccessToken: accessToken },
params: parameters,
},
body: { client_id: applicationId, token: tokenValue },
};
// @ts-expect-error pass complex type check to mock ctx directly
grantRevocationListener(ctx, grantId);
expect(log.createLog).toHaveBeenCalledWith('RevokeToken');
expect(log.mockAppend).toHaveBeenCalledWith({
applicationId,
userId,
params: parameters,
grantId,
tokenTypes: [token.TokenType.AccessToken],
});
});
it('should log token types properly 2', () => {
const ctx = {
...createContextWithRouteParameters(),
createLog: log.createLog,
oidc: {
entities: {
Client: client,
AccessToken: accessToken,
RefreshToken: refreshToken,
DeviceCode: 'mock',
},
params: parameters,
},
body: { client_id: applicationId, token: tokenValue },
};
// @ts-expect-error pass complex type check to mock ctx directly
grantRevocationListener(ctx, grantId);
expect(log.createLog).toHaveBeenCalledWith('RevokeToken');
expect(log.mockAppend).toHaveBeenCalledWith({
applicationId,
userId,
params: parameters,
grantId,
tokenTypes: [
token.TokenType.AccessToken,
token.TokenType.RefreshToken,
token.TokenType.DeviceCode,
],
});
});
});

View file

@ -0,0 +1,90 @@
import { GrantType, LogResult, token } from '@logto/schemas';
import type { errors, KoaContextWithOIDC } from 'oidc-provider';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import { stringifyError } from '../utils/format.js';
import { isEnum } from '../utils/type.js';
import { extractInteractionContext } from './utils.js';
/**
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/lib/actions/token.js#L71 Success event emission}
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/lib/shared/error_handler.js OIDC Provider error handler}
*/
export const grantListener = (
ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody },
error?: errors.OIDCProviderError
) => {
const { params } = ctx.oidc;
const log = ctx.createLog(
`${token.Flow.ExchangeTokenBy}.${getExchangeByType(params?.grant_type)}`
);
const { access_token, refresh_token, id_token, scope } = ctx.body;
const tokenTypes = [
access_token && token.TokenType.AccessToken,
refresh_token && token.TokenType.RefreshToken,
id_token && token.TokenType.IdToken,
].filter(Boolean);
log.append({
...extractInteractionContext(ctx),
result: error && LogResult.Error,
tokenTypes,
scope,
error: error && stringifyError(error),
});
};
// The grant.revoked event is emitted at https://github.com/panva/node-oidc-provider/blob/v7.x/lib/helpers/revoke.js#L25
export const grantRevocationListener = (
ctx: KoaContextWithOIDC & WithLogContext,
grantId: string
) => {
const {
entities: { AccessToken, RefreshToken },
} = ctx.oidc;
// TODO: Check if this is needed or just use `Account?.accountId`
const userId = AccessToken?.accountId ?? RefreshToken?.accountId;
const tokenTypes = getRevocationTokenTypes(ctx.oidc);
const log = ctx.createLog('RevokeToken');
log.append({ ...extractInteractionContext(ctx), userId, grantId, tokenTypes });
};
/**
* @see {@link https://github.com/panva/node-oidc-provider/tree/v7.x/lib/actions/grants grants source code} for predefined grant implementations and types.
*/
type GrantBody = {
access_token?: string;
refresh_token?: string;
id_token?: string;
scope?: string; // AccessToken.scope
};
const grantTypeToExchangeByType: Record<GrantType, token.ExchangeByType> = {
[GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode,
[GrantType.RefreshToken]: token.ExchangeByType.RefreshToken,
[GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials,
};
const getExchangeByType = (grantType: unknown): token.ExchangeByType => {
if (!isEnum(Object.values(GrantType), grantType)) {
return token.ExchangeByType.Unknown;
}
return grantTypeToExchangeByType[grantType];
};
/**
* Note the revocation may revoke related tokens as well. In oidc-provider, it will revoke the whole Grant when revoking Refresh Token.
* So we don't assume the token type here.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc7009 OAuth 2.0 Token Revocation} for RFC reference.
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/lib/actions/revocation.js#L56 this function} for code reference.
**/
const getRevocationTokenTypes = (oidc: KoaContextWithOIDC['oidc']): token.TokenType[] => {
return Object.values(token.TokenType).filter((value) => oidc.entities[value]);
};

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-audit-log.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
const { jest } = import.meta;
const userId = 'userIdValue';
const sessionId = 'sessionIdValue';
const applicationId = 'applicationIdValue';
const log = createMockLogContext();
const entities = {
Account: { accountId: userId },
Session: { jti: sessionId },
Client: { clientId: applicationId },
};
const prompt: PromptDetail = {
name: 'login',
reasons: ['foo', 'bar'],
details: {
foo: 'bar',
},
};
const baseCallArgs = { applicationId, sessionId, userId };
const testInteractionListener = (
listener: typeof interactionStartedListener | typeof interactionEndedListener,
parameters: { grant_type: string } & Record<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,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<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...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<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...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<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...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<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
// Bypass middleware context type assert
addLogContext,

View file

@ -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 extends IRouterParamContext = IRouterParamContext> = ContextT &
LogContext;
/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */
export type WithLogContextLegacy<ContextT extends IRouterParamContext = IRouterParamContext> =
ContextT & LogContextLegacy;
type Logger = {
type?: LogType;
@ -77,11 +84,12 @@ const initLogger = (basePayload?: Readonly<BaseLogPayload>) => {
};
/* 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<StateT, WithLogContext<ContextT>, ResponseBodyT> {
>(): MiddlewareType<StateT, WithLogContextLegacy<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const {
ip,

View file

@ -0,0 +1,191 @@
import type { LogKey } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import i18next from 'i18next';
import RequestError from '#src/errors/RequestError/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithLogContext, LogPayload } from './koa-audit-log.js';
const { jest } = import.meta;
const nanoIdMock = 'mockId';
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
mockEsm('nanoid', () => ({
nanoid: () => nanoIdMock,
}));
const koaLog = await pickDefault(import('./koa-audit-log.js'));
describe('koaAuditLog middleware', () => {
const logKey: LogKey = 'Interaction.SignIn.Identifier.VerificationCode.Submit';
const mockPayload: LogPayload = {
userId: 'foo',
username: 'Bar',
};
const ip = '192.168.0.1';
const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36';
afterEach(() => {
jest.clearAllMocks();
});
it('should insert a success log when next() does not throw an error', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
};
ctx.request.ip = ip;
const additionalMockPayload: LogPayload = { foo: 'bar' };
const next = async () => {
const log = ctx.createLog(logKey);
log.append(mockPayload);
log.append(additionalMockPayload);
};
await koaLog()(ctx, next);
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
type: logKey,
payload: {
...mockPayload,
...additionalMockPayload,
key: logKey,
result: LogResult.Success,
ip,
userAgent,
},
});
});
it('should insert multiple success logs when needed', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
};
ctx.request.ip = ip;
const additionalMockPayload: LogPayload = { foo: 'bar' };
const next = async () => {
const log = ctx.createLog(logKey);
log.append(mockPayload);
log.append(additionalMockPayload);
const log2 = ctx.createLog(logKey);
log2.append(mockPayload);
};
await koaLog()(ctx, next);
const basePayload = {
...mockPayload,
key: logKey,
result: LogResult.Success,
ip,
userAgent,
};
expect(insertLog).toHaveBeenCalledWith({
id: nanoIdMock,
type: logKey,
payload: basePayload,
});
expect(insertLog).toHaveBeenCalledWith({
id: nanoIdMock,
type: logKey,
payload: {
...basePayload,
...additionalMockPayload,
},
});
});
it('should not log when there is no log type', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
};
ctx.request.ip = ip;
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function
const next = async () => {};
await koaLog()(ctx, next);
expect(insertLog).not.toBeCalled();
});
describe('should insert an error log with the error message when next() throws an error', () => {
it('should log with error message when next throws a normal Error', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
};
ctx.request.ip = ip;
const message = 'Normal error';
const error = new Error(message);
const next = async () => {
const log = ctx.createLog(logKey);
log.append(mockPayload);
throw error;
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
type: logKey,
payload: {
...mockPayload,
key: logKey,
result: LogResult.Error,
error: { message: `Error: ${message}` },
ip,
userAgent,
},
});
});
it('should update all logs with error result when next() throws a RequestError', async () => {
// @ts-expect-error for testing
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
};
ctx.request.ip = ip;
const message = 'Error message';
jest.spyOn(i18next, 't').mockReturnValueOnce(message); // Used in
const code = 'connector.general';
const data = { foo: 'bar', num: 123 };
const error = new RequestError(code, data);
const next = async () => {
const log = ctx.createLog(logKey);
log.append(mockPayload);
const log2 = ctx.createLog(logKey);
log2.append(mockPayload);
throw error;
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
expect(insertLog).toHaveBeenCalledTimes(2);
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
type: logKey,
payload: {
...mockPayload,
key: logKey,
result: LogResult.Error,
error: { message, code, data },
ip,
userAgent,
},
});
});
});
});

View file

@ -0,0 +1,149 @@
import type { LogContextPayload, LogKey } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import pick from 'lodash.pick';
import { nanoid } from 'nanoid';
import RequestError from '#src/errors/RequestError/index.js';
import { insertLog } from '#src/queries/log.js';
const removeUndefinedKeys = (object: Record<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,
};
}
/** Update payload by spreading `data` first, then spreading `this.payload`. */
prepend(data: Readonly<LogPayload>) {
this.payload = {
...removeUndefinedKeys(data),
...this.payload,
};
}
/** Update payload by spreading `this.payload` first, then spreading `data`. */
append(data: Readonly<LogPayload>) {
this.payload = {
...this.payload,
...removeUndefinedKeys(data),
};
}
}
export type LogPayload = Partial<LogContextPayload> & Record<string, unknown>;
export type LogContext = {
createLog: (key: LogKey) => LogEntry;
prependAllLogEntries: (payload: LogPayload) => void;
};
export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamContext> = ContextT &
LogContext;
/**
* The factory to create a new audit log middleware function.
* It will inject a `createLog` function the context to enable audit logging.
*
* #### Create a log entry
*
* You need to explicitly call `ctx.createLog()` to create a new {@link LogEntry} instance,
* which accepts a read-only parameter {@link LogKey} thus the log can be categorized and indexed in database.
*
* ```ts
* const log = ctx.createLog('Interaction.Create'); // Key is typed
* ```
*
* Note every time you call `ctx.createLog()`, it will create a new log entry instance for inserting. So multiple log entries may be inserted within one request.
*
* Remember to keep the log entry instance properly if you want to collect log data from multiple places.
*
* #### Log data
*
* To update log payload, call `log.append()`. It will use object spread operators to update payload (i.e. merge with one-level overwrite and shallow copy).
*
* ```ts
* log.append({ applicationId: 'foo' });
* ```
*
* This function can be called multiple times.
*
* #### Log context
*
* By default, before inserting the logs, it will extract the request context and prepend request IP and User Agent to every log entry:
*
* ```ts
* {
* ip: 'request-ip-addr',
* userAgent: 'request-user-agent',
* ...log.payload,
* }
* ```
*
* To add more common data to log entries, try to create another middleware function after this one, and call `ctx.prependAllLogEntries()`.
*
* @returns An audit log middleware function.
* @see {@link LogKey} for all available log keys, and {@link LogResult} for result enums.
* @see {@link LogContextPayload} for the basic type suggestion of log data.
*/
export default function koaAuditLog<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const entries: LogEntry[] = [];
ctx.createLog = (key: LogKey) => {
const entry = new LogEntry(key);
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
entries.push(entry);
return entry;
};
ctx.prependAllLogEntries = (payload) => {
for (const entry of entries) {
entry.prepend(payload);
}
};
try {
await next();
} catch (error: unknown) {
for (const entry of entries) {
entry.append({
result: LogResult.Error,
error:
error instanceof RequestError
? pick(error, 'message', 'code', 'data')
: { message: String(error) },
});
}
throw error;
} finally {
// Predefined context
const {
ip,
headers: { 'user-agent': userAgent },
} = ctx.request;
await Promise.all(
entries.map(async ({ payload }) => {
return insertLog({
id: nanoid(),
type: payload.key,
payload: { ip, userAgent, ...payload },
});
})
);
}
};
}

View file

@ -1,7 +1,7 @@
import { Provider } from 'oidc-provider';
import koaLogSession from '#src/middleware/koa-log-session.js';
import type { WithLogContext } from '#src/middleware/koa-log.js';
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
import koaLogSessionLegacy from '#src/middleware/koa-log-session-legacy.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
@ -9,7 +9,7 @@ const { jest } = import.meta;
const provider = new Provider('https://logto.test');
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
describe('koaLogSession', () => {
describe('koaLogSessionLegacy', () => {
const sessionId = 'sessionId';
const applicationId = 'applicationId';
const addLogContext = jest.fn();
@ -29,40 +29,40 @@ describe('koaLogSession', () => {
});
it('should get session info from the provider', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
addLogContext,
log,
};
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
expect(interactionDetails).toHaveBeenCalled();
});
it('should log session id and application id', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
addLogContext,
log,
};
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId });
});
it('should call next', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
addLogContext,
log,
};
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
expect(next).toHaveBeenCalled();
});
it('should not throw when interactionDetails throw error', async () => {
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
...createContextWithRouteParameters(),
addLogContext,
log,
@ -72,6 +72,6 @@ describe('koaLogSession', () => {
throw new Error('message');
});
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
});
});

View file

@ -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<StateT, ContextT, ResponseBodyT> {
return async (ctx, next) => {
await next();
try {
const {
jti,
params: { client_id },
} = await provider.interactionDetails(ctx.req, ctx.res);
ctx.addLogContext({ sessionId: jti, applicationId: String(client_id) });
} catch (error: unknown) {
console.error(`Failed to get oidc provider interaction`, error);
}
};
}

View file

@ -1,22 +0,0 @@
import type { MiddlewareType } from 'koa';
import type { Provider } from 'oidc-provider';
import type { WithLogContext } from '#src/middleware/koa-log.js';
export default function koaLogSession<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.addLogContext({ sessionId: jti, applicationId: String(client_id) });
} catch (error: unknown) {
console.error(`"${ctx.url}" failed to get oidc provider interaction`, error);
}
};
}

View file

@ -11,6 +11,8 @@ import { Provider, errors } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
import envSet from '#src/env-set/index.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import postgresAdapter from '#src/oidc/adapter.js';
import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js';
import { findApplicationById } from '#src/queries/application.js';
@ -18,7 +20,6 @@ import { findResourceByIndicator } from '#src/queries/resource.js';
import { findUserById } from '#src/queries/user.js';
import { routes } from '#src/routes/consts.js';
import assertThat from '#src/utils/assert-that.js';
import { addOidcEventListeners } from '#src/utils/oidc-provider-event-listener.js';
import { claimToUserKey, getUserClaims } from './scope.js';
@ -188,6 +189,9 @@ export default async function initOidc(app: Koa): Promise<Provider> {
addOidcEventListeners(oidc);
// Provide audit log context for event listeners
oidc.use(koaAuditLog());
app.use(mount('/oidc', oidc.app));
return oidc;

View file

@ -1,5 +1,5 @@
import type { CreateLog, Log, LogType } from '@logto/schemas';
import { Logs } from '@logto/schemas';
import type { CreateLog, Log } from '@logto/schemas';
import { token, Logs } from '@logto/schemas';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { sql } from 'slonik';
@ -50,10 +50,6 @@ export const findLogs = async (limit: number, offset: number, logCondition: LogC
export const findLogById = buildFindEntityById<CreateLog, Log>(Logs);
// The active user should exchange the tokens by the authorization code (i.e. sign-in)
// or exchange the access token, which will expire in 2 hours, by the refresh token.
const activeUserLogTypes: LogType[] = ['CodeExchangeToken', 'RefreshTokenExchangeToken'];
export const getDailyActiveUserCountsByTimeInterval = async (
startTimeExclusive: number,
endTimeInclusive: number
@ -63,7 +59,7 @@ export const getDailyActiveUserCountsByTimeInterval = async (
from ${table}
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000)
and ${fields.type} in (${sql.join(activeUserLogTypes, sql`, `)})
and ${fields.type} like ${`${token.Flow.ExchangeTokenBy}.%`}
and ${fields.payload}->>'result' = 'Success'
group by date(${fields.createdAt})
`);
@ -77,6 +73,6 @@ export const countActiveUsersByTimeInterval = async (
from ${table}
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000)
and ${fields.type} in (${sql.join(activeUserLogTypes, sql`, `)})
and ${fields.type} like ${`${token.Flow.ExchangeTokenBy}.%`}
and ${fields.payload}->>'result' = 'Success'
`);

View file

@ -4,8 +4,10 @@ import mount from 'koa-mount';
import Router from 'koa-router';
import type { Provider } from 'oidc-provider';
import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js';
import koaAuth from '../middleware/koa-auth.js';
import koaLogSession from '../middleware/koa-log-session.js';
import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js';
import adminUserRoutes from './admin-user.js';
import applicationRoutes from './application.js';
import authnRoutes from './authn.js';
@ -23,16 +25,15 @@ import settingRoutes from './setting.js';
import signInExperiencesRoutes from './sign-in-experience.js';
import statusRoutes from './status.js';
import swaggerRoutes from './swagger.js';
import type { AnonymousRouter, AuthedRouter } from './types.js';
import type { AnonymousRouter, AnonymousRouterLegacy, AuthedRouter } from './types.js';
import wellKnownRoutes from './well-known.js';
const createRouters = (provider: Provider) => {
const sessionRouter: AnonymousRouter = new Router();
sessionRouter.use(koaLogSession(provider));
const sessionRouter: AnonymousRouterLegacy = new Router();
sessionRouter.use(koaAuditLogLegacy(), koaLogSessionLegacy(provider));
sessionRoutes(sessionRouter, provider);
const interactionRouter: AnonymousRouter = new Router();
interactionRouter.use(koaLogSession(provider));
interactionRoutes(interactionRouter, provider);
const managementRouter: AuthedRouter = new Router();
@ -72,6 +73,7 @@ export default function initRouter(app: Koa, provider: Provider) {
const apisApp = new Koa();
for (const router of createRouters(provider)) {
// @ts-expect-error will remove once interaction refactor finished
apisApp.use(router.routes()).use(router.allowedMethods());
}

View file

@ -1,6 +1,7 @@
import { Event } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -51,10 +52,9 @@ jest.useFakeTimers().setSystemTime(now);
describe('submit action', () => {
const provider = createMockProvider();
const log = jest.fn();
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
log,
...createMockLogContext(),
interactionPayload: { event: Event.SignIn },
};
const profile = {

View file

@ -5,6 +5,8 @@ import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -76,7 +78,17 @@ const { getInteractionStorage } = mockEsm('./utils/interaction.js', () => ({
getInteractionStorage: jest.fn(),
}));
const log = jest.fn();
const { createLog, prependAllLogEntries } = createMockLogContext();
mockEsmDefault(
'#src/middleware/koa-audit-log.js',
// eslint-disable-next-line unicorn/consistent-function-scoping
(): typeof koaAuditLog => () => async (ctx, next) => {
ctx.createLog = createLog;
ctx.prependAllLogEntries = prependAllLogEntries;
return next();
}
);
const koaInteractionBodyGuard = await pickDefault(
import('./middleware/koa-interaction-body-guard.js')
@ -107,14 +119,6 @@ describe('session -> interactionRoutes', () => {
provider: createMockProvider(
jest.fn().mockResolvedValue({ params: {}, jti: 'jti', client_id: demoAppApplicationId })
),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = log;
return next();
},
],
});
afterEach(() => {
@ -244,7 +248,7 @@ describe('session -> interactionRoutes', () => {
};
const response = await sessionRequest.post(path).send(body);
expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', log);
expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', createLog);
expect(response.status).toEqual(204);
});
});

View file

@ -1,9 +1,11 @@
import type { LogtoErrorCode } from '@logto/phrases';
import { Event } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { assignInteractionResults } from '#src/libraries/session.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
@ -28,6 +30,24 @@ export default function interactionRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.use(koaAuditLog(), async (ctx, next) => {
await next();
// Prepend interaction context to log entries
try {
const {
jti,
params: { client_id },
} = await provider.interactionDetails(ctx.req, ctx.res);
ctx.prependAllLogEntries({
sessionId: jti,
applicationId: conditional(typeof client_id === 'string' && client_id),
});
} catch (error: unknown) {
console.error(`Failed to get oidc provider interaction details`, error);
}
});
router.put(
interactionPrefix,
koaInteractionBodyGuard(),
@ -117,7 +137,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
async (ctx, next) => {
// Check interaction session
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.log);
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.createLog);
ctx.status = 204;

View file

@ -11,6 +11,7 @@ import type { IRouterParamContext } from 'koa-router';
import type { z } from 'zod';
import type { SocialUserInfo } from '#src/connectors/types.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
import type {
@ -108,7 +109,9 @@ export type VerifiedInteractionResult =
| VerifiedSignInInteractionResult
| VerifiedForgotPasswordInteractionResult;
export type InteractionContext = WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>;
export type InteractionContext = WithGuardedIdentifierPayloadContext<
WithLogContext<IRouterParamContext & Context>
>;
export type UserIdentity =
| { username: string }

View file

@ -1,6 +1,8 @@
import { PasscodeType, Event } from '@logto/schemas';
import { mockEsmWithActual } from '@logto/shared/esm';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import type { SendPasscodePayload } from '../types/index.js';
const { jest } = import.meta;
@ -41,7 +43,7 @@ const sendPasscodeTestCase = [
];
describe('passcode-validation utils', () => {
const log = jest.fn();
const log = createMockLogContext();
afterEach(() => {
jest.clearAllMocks();
@ -50,7 +52,7 @@ describe('passcode-validation utils', () => {
it.each(sendPasscodeTestCase)(
'send passcode successfully',
async ({ payload, createPasscodeParams }) => {
await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log);
await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log.createLog);
expect(passcode.createPasscode).toBeCalledWith('jti', ...createPasscodeParams);
expect(passcode.sendPasscode).toBeCalled();
}

View file

@ -2,8 +2,7 @@ import type { Event } from '@logto/schemas';
import { PasscodeType } from '@logto/schemas';
import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js';
import type { LogContext } from '#src/middleware/koa-log.js';
import { getPasswordlessRelatedLogType } from '#src/routes/session/utils.js';
import type { LogContext } from '#src/middleware/koa-audit-log.js';
import type { SendPasscodePayload, PasscodeIdentifierPayload } from '../types/index.js';
@ -22,41 +21,31 @@ const getPasscodeTypeByEvent = (event: Event): PasscodeType => eventToPasscodeTy
export const sendPasscodeToIdentifier = async (
payload: SendPasscodePayload,
jti: string,
log: LogContext['log']
createLog: LogContext['createLog']
) => {
const { event, ...identifier } = payload;
const passcodeType = getPasscodeTypeByEvent(event);
const logType = getPasswordlessRelatedLogType(
passcodeType,
'email' in identifier ? 'email' : 'sms',
'send'
);
log(logType, identifier);
const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Create`);
log.append(identifier);
const passcode = await createPasscode(jti, passcodeType, identifier);
const { dbEntry } = await sendPasscode(passcode);
log(logType, { connectorId: dbEntry.id });
log.append({ connectorId: dbEntry.id });
};
export const verifyIdentifierByPasscode = async (
payload: PasscodeIdentifierPayload & { event: Event },
jti: string,
log: LogContext['log']
createLog: LogContext['createLog']
) => {
const { event, passcode, ...identifier } = payload;
const passcodeType = getPasscodeTypeByEvent(event);
const logType = getPasswordlessRelatedLogType(
passcodeType,
'email' in identifier ? 'email' : 'sms',
'verify'
);
log(logType, identifier);
// TODO: @Simeng maybe we should just log all interaction payload in every request?
const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`);
log.append(identifier);
await verifyPasscode(jti, passcodeType, passcode, identifier);
};

View file

@ -1,6 +1,8 @@
import { ConnectorType } from '@logto/connector-kit';
import { mockEsm } from '@logto/shared/esm';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
const { jest } = import.meta;
const { getUserInfoByAuthCode } = mockEsm('#src/libraries/social.js', () => ({
@ -18,13 +20,13 @@ mockEsm('#src/connectors.js', () => ({
}));
const { verifySocialIdentity } = await import('./social-verification.js');
const log = jest.fn();
const log = createMockLogContext();
describe('social-verification', () => {
it('verifySocialIdentity', async () => {
const connectorId = 'connector';
const connectorData = { authCode: 'code' };
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, log);
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, log.createLog);
expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData);
expect(userInfo).toEqual({ id: 'foo' });

View file

@ -1,10 +1,10 @@
import type { SocialConnectorPayload, LogType } from '@logto/schemas';
import type { SocialConnectorPayload } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { getLogtoConnectorById } from '#src/connectors/index.js';
import type { SocialUserInfo } from '#src/connectors/types.js';
import { getUserInfoByAuthCode } from '#src/libraries/social.js';
import type { LogContext } from '#src/middleware/koa-log.js';
import type { LogContext } from '#src/middleware/koa-audit-log.js';
import assertThat from '#src/utils/assert-that.js';
import type { SocialAuthorizationUrlPayload } from '../types/index.js';
@ -22,14 +22,14 @@ export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationU
export const verifySocialIdentity = async (
{ connectorId, connectorData }: SocialConnectorPayload,
log: LogContext['log']
createLog: LogContext['createLog']
): Promise<SocialUserInfo> => {
const logType: LogType = 'SignInSocial';
log(logType, { connectorId, connectorData });
const log = createLog('Interaction.SignIn.Identifier.Social.Submit');
log.append({ connectorId, connectorData });
const userInfo = await getUserInfoByAuthCode(connectorId, connectorData);
log(logType, userInfo);
log.append(userInfo);
return userInfo;
};

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -31,10 +32,10 @@ const identifierPayloadVerification = await pickDefault(
import('./identifier-payload-verification.js')
);
const log = jest.fn();
const logContext = createMockLogContext();
describe('identifier verification', () => {
const baseCtx = { ...createContextWithRouteParameters(), log };
const baseCtx = { ...createContextWithRouteParameters(), ...logContext };
afterEach(() => {
jest.clearAllMocks();
@ -152,7 +153,7 @@ describe('identifier verification', () => {
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
logContext.createLog
);
expect(result).toEqual({
@ -176,7 +177,7 @@ describe('identifier verification', () => {
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
logContext.createLog
);
expect(result).toEqual({
@ -198,7 +199,7 @@ describe('identifier verification', () => {
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(verifySocialIdentity).toBeCalledWith(identifier, log);
expect(verifySocialIdentity).toBeCalledWith(identifier, logContext.createLog);
expect(findUserByIdentifier).not.toBeCalled();
expect(result).toEqual({
@ -323,7 +324,7 @@ describe('identifier verification', () => {
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
logContext.createLog
);
expect(result).toEqual({

View file

@ -46,7 +46,7 @@ const verifyPasscodeIdentifier = async (
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log);
await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.createLog);
return 'email' in identifier
? { key: 'emailVerified', value: identifier.email }
@ -57,7 +57,7 @@ const verifySocialIdentifier = async (
identifier: SocialConnectorPayload,
ctx: InteractionContext
): Promise<SocialIdentifier> => {
const userInfo = await verifySocialIdentity(identifier, ctx.log);
const userInfo = await verifySocialIdentity(identifier, ctx.createLog);
return { key: 'social', connectorId: identifier.connectorId, userInfo };
};

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -25,7 +26,7 @@ const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('forgot password interaction profile verification', () => {
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const interaction = {
event: Event.ForgotPassword,

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -32,7 +33,7 @@ const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('Should throw when providing existing identifiers in profile', () => {
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'emailVerified', value: 'email' },

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -34,7 +35,7 @@ mockEsm('#src/connectors/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'emailVerified', value: 'email@logto.io' },

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -29,7 +30,7 @@ mockEsm('#src/connectors/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('profile protected identifier verification', () => {
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const interaction = { event: Event.SignIn, accountId: 'foo' };
const provider = createMockProvider();

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -26,6 +27,7 @@ describe('userAccountVerification', () => {
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
...createMockLogContext(),
interactionPayload: {
event: Event.SignIn,
},

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

@ -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: [

View file

@ -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<T extends AnonymousRouter>(router: T, provider: Provider) {
export default function continueRoutes<T extends AnonymousRouterLegacy>(
router: T,
provider: Provider
) {
router.post(
`${continueRoute}/password`,
koaGuard({

View file

@ -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: [

View file

@ -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<T extends AnonymousRouter>(
export default function forgotPasswordRoutes<T extends AnonymousRouterLegacy>(
router: T,
provider: Provider
) {

View file

@ -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: [

View file

@ -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<T extends AnonymousRouter>(router: T, provider: Provider) {
export default function sessionRoutes<T extends AnonymousRouterLegacy>(
router: T,
provider: Provider
) {
router.use(getRoutePrefix('sign-in'), koaGuardSessionAction(provider, 'sign-in'));
router.use(getRoutePrefix('register'), koaGuardSessionAction(provider, 'register'));

View file

@ -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 = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
export const smsSignInAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
@ -72,7 +72,7 @@ export const smsSignInAction = <StateT, ContextT extends WithLogContext, Respons
};
};
export const emailSignInAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
export const emailSignInAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
@ -117,7 +117,7 @@ export const emailSignInAction = <StateT, ContextT extends WithLogContext, Respo
};
};
export const smsRegisterAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
export const smsRegisterAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
@ -161,7 +161,7 @@ export const smsRegisterAction = <StateT, ContextT extends WithLogContext, Respo
};
};
export const emailRegisterAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
export const emailRegisterAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {

View file

@ -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: [

View file

@ -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<T extends AnonymousRouter>(router: T, provider: Provider) {
export default function passwordRoutes<T extends AnonymousRouterLegacy>(
router: T,
provider: Provider
) {
router.post(
`${signInRoute}/username`,
koaGuard({

View file

@ -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: [

View file

@ -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<T extends AnonymousRouter>(
export default function passwordlessRoutes<T extends AnonymousRouterLegacy>(
router: T,
provider: Provider
) {

View file

@ -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: [

View file

@ -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: [

View file

@ -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<T extends AnonymousRouter>(router: T, provider: Provider) {
export default function socialRoutes<T extends AnonymousRouterLegacy>(
router: T,
provider: Provider
) {
router.post(
`${signInRoute}`,
koaGuard({

View file

@ -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
) => {

View file

@ -1,9 +1,17 @@
import type { ExtendableContext } from 'koa';
import type Router from 'koa-router';
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
import type { WithLogContext } from '#src/middleware/koa-log.js';
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext & ExtendableContext>;
export type AuthedRouter = Router<unknown, WithAuthContext & WithLogContext & WithI18nContext>;
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;
export type AuthedRouter = Router<
unknown,
WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext
>;

View file

@ -0,0 +1,18 @@
import type { LogContext } from '#src/middleware/koa-audit-log.js';
import { LogEntry } from '#src/middleware/koa-audit-log.js';
const { jest } = import.meta;
class MockLogEntry extends LogEntry {
append = jest.fn();
}
export const createMockLogContext = (): LogContext & { mockAppend: jest.Mock } => {
const mockLogEntry = new MockLogEntry('Unknown');
return {
createLog: jest.fn(() => mockLogEntry),
prependAllLogEntries: jest.fn(),
mockAppend: mockLogEntry.append,
};
};

View file

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

View file

@ -1,293 +0,0 @@
import { LogResult, TokenType } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import {
addOidcEventListeners,
grantErrorListener,
grantRevokedListener,
grantSuccessListener,
} from '#src/utils/oidc-provider-event-listener.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const userId = 'userIdValue';
const sessionId = 'sessionIdValue';
const applicationId = 'applicationIdValue';
const addLogContext = jest.fn();
const log = jest.fn();
describe('addOidcEventListeners', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should add proper listeners', () => {
const provider = new Provider('https://logto.test');
const addListener = jest.spyOn(provider, 'addListener');
addOidcEventListeners(provider);
expect(addListener).toHaveBeenCalledWith('grant.success', grantSuccessListener);
expect(addListener).toHaveBeenCalledWith('grant.error', grantErrorListener);
expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevokedListener);
});
});
describe('grantSuccessListener', () => {
const entities = {
Account: { accountId: userId },
Grant: { jti: sessionId },
Client: { clientId: applicationId },
};
afterEach(() => {
jest.clearAllMocks();
});
it('should log type CodeExchangeToken when grant type is authorization_code', async () => {
const parameters = { grant_type: 'authorization_code', code: 'codeValue' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
scope: 'openid offline-access',
},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantSuccessListener(ctx);
expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId });
expect(log).toHaveBeenCalledWith('CodeExchangeToken', {
issued: Object.values(TokenType),
params: parameters,
scope: 'openid offline-access',
userId,
});
});
it('should log type RefreshTokenExchangeToken when grant type is refresh_code', async () => {
const parameters = { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
scope: 'openid offline-access',
},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantSuccessListener(ctx);
expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId });
expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', {
issued: Object.values(TokenType),
params: parameters,
scope: 'openid offline-access',
userId,
});
});
test('issued field should not contain "idToken" when there is no issued idToken', async () => {
const parameters = { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {
// There is no idToken here.
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
scope: 'offline-access',
},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantSuccessListener(ctx);
expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId });
expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', {
issued: [TokenType.AccessToken, TokenType.RefreshToken],
params: parameters,
scope: 'offline-access',
userId,
});
});
it('should not log when it found unexpected grant_type', async () => {
const parameters = { grant_type: 'client_credentials' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantSuccessListener(ctx);
expect(addLogContext).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
});
});
describe('grantErrorListener', () => {
const entities = { Client: { clientId: applicationId } };
const errorMessage = 'invalid grant';
afterEach(() => {
jest.clearAllMocks();
});
it('should log type CodeExchangeToken when grant type is authorization_code', async () => {
const parameters = { grant_type: 'authorization_code', code: 'codeValue' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
scope: 'openid offline-access',
},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantErrorListener(ctx, new Error(errorMessage));
expect(addLogContext).toHaveBeenCalledWith({ applicationId });
expect(log).toHaveBeenCalledWith('CodeExchangeToken', {
result: LogResult.Error,
error: `Error: ${errorMessage}`,
params: parameters,
});
});
it('should log type RefreshTokenExchangeToken when grant type is refresh_code', async () => {
const parameters = { grant_type: 'refresh_token', refresh_token: 'refreshTokenValue' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {
access_token: 'newAccessTokenValue',
refresh_token: 'newRefreshTokenValue',
id_token: 'newIdToken',
scope: 'openid offline-access',
},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantErrorListener(ctx, new Error(errorMessage));
expect(addLogContext).toHaveBeenCalledWith({ applicationId });
expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', {
result: LogResult.Error,
error: `Error: ${errorMessage}`,
params: parameters,
});
});
it('should not log when it found unexpected grant_type', async () => {
const parameters = { grant_type: 'client_credentials' };
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: { entities, params: parameters },
body: {},
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantErrorListener(ctx, new Error(errorMessage));
expect(addLogContext).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
});
});
describe('grantRevokedListener', () => {
const grantId = 'grantIdValue';
const token = 'tokenValue';
const parameters = { token };
const client = { clientId: applicationId };
const accessToken = { accountId: userId };
const refreshToken = { accountId: userId };
afterEach(() => {
jest.clearAllMocks();
});
it('should log token type AccessToken when the token is an access token', async () => {
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: {
entities: { Client: client, AccessToken: accessToken },
params: parameters,
},
body: { client_id: applicationId, token },
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantRevokedListener(ctx, grantId);
expect(addLogContext).toHaveBeenCalledWith({ applicationId });
expect(log).toHaveBeenCalledWith('RevokeToken', {
userId,
params: parameters,
grantId,
tokenType: TokenType.AccessToken,
});
});
it('should log token type RefreshToken when the token is a refresh code', async () => {
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: {
entities: { Client: client, RefreshToken: refreshToken },
params: parameters,
},
body: { client_id: applicationId, token },
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantRevokedListener(ctx, grantId);
expect(addLogContext).toHaveBeenCalledWith({ applicationId });
expect(log).toHaveBeenCalledWith('RevokeToken', {
userId,
params: parameters,
grantId,
tokenType: TokenType.RefreshToken,
});
});
it('should not log when the revoked token is neither access token nor refresh token', async () => {
const ctx = {
...createContextWithRouteParameters(),
addLogContext,
log,
oidc: {
entities: { Client: client },
params: parameters,
},
body: { client_id: applicationId, token },
};
// @ts-expect-error pass complex type check to mock ctx directly
await grantRevokedListener(ctx, grantId);
expect(addLogContext).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
});
});

View file

@ -1,132 +0,0 @@
import { GrantType, TokenType, LogResult } from '@logto/schemas';
import { notFalsy } from '@silverhand/essentials';
import type { errors, KoaContextWithOIDC, Provider } from 'oidc-provider';
import type { WithLogContext } from '#src/middleware/koa-log.js';
export const addOidcEventListeners = (provider: Provider) => {
/**
* OIDC provider listeners and events
* https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details
* https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md
*/
provider.addListener('grant.success', grantSuccessListener);
provider.addListener('grant.error', grantErrorListener);
provider.addListener('grant.revoked', grantRevokedListener);
};
/**
* See https://github.com/panva/node-oidc-provider/tree/main/lib/actions/grants
* - https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/actions/grants/authorization_code.js#L209
* - https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/actions/grants/refresh_token.js#L225
* -
*/
type GrantBody = {
access_token?: string;
refresh_token?: string;
id_token?: string;
scope?: string; // AccessToken.scope
};
const getLogType = (grantType: unknown) => {
const allowedGrantType = new Set<unknown>([GrantType.AuthorizationCode, GrantType.RefreshToken]);
// Only log token exchange by authorization code or refresh token.
if (!grantType || !allowedGrantType.has(grantType)) {
return;
}
return grantType === GrantType.AuthorizationCode
? 'CodeExchangeToken'
: 'RefreshTokenExchangeToken';
};
// The grant.success event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/actions/token.js#L71
export const grantSuccessListener = async (
ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody }
) => {
const {
oidc: {
entities: { Account: account, Grant: grant, Client: client },
params,
},
body,
} = ctx;
const logType = getLogType(params?.grant_type);
if (!logType) {
return;
}
ctx.addLogContext({
applicationId: client?.clientId,
sessionId: grant?.jti,
});
const { access_token, refresh_token, id_token, scope } = body;
const issued = [
access_token && TokenType.AccessToken,
refresh_token && TokenType.RefreshToken,
id_token && TokenType.IdToken,
].filter((value): value is TokenType => notFalsy(value));
ctx.log(logType, {
userId: account?.accountId,
params,
issued,
scope,
});
};
// The grant.error event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/initialize_app.js#L153
export const grantErrorListener = async (
ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody },
error: errors.OIDCProviderError
) => {
const {
oidc: {
entities: { Client: client },
params,
},
} = ctx;
const logType = getLogType(params?.grant_type);
if (!logType) {
return;
}
ctx.addLogContext({
applicationId: client?.clientId,
});
ctx.log(logType, {
result: LogResult.Error,
error: String(error),
params,
});
};
// OAuth 2.0 Token Revocation: https://datatracker.ietf.org/doc/html/rfc7009
// The grant.revoked event is emitted at https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/revoke.js#L25
export const grantRevokedListener = async (
ctx: KoaContextWithOIDC & WithLogContext,
grantId: string
) => {
const {
oidc: {
entities: { Client: client, AccessToken: accessToken, RefreshToken: refreshToken },
params,
},
} = ctx;
if (!refreshToken && !accessToken) {
// Only log token revocation of access token or refresh token.
return;
}
ctx.addLogContext({ applicationId: client?.clientId });
const userId = accessToken?.accountId ?? refreshToken?.accountId;
const tokenType = accessToken ? TokenType.AccessToken : TokenType.RefreshToken;
ctx.log('RevokeToken', { userId, params, grantId, tokenType });
};

View file

@ -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 = <T extends string>(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.

View file

@ -87,9 +87,9 @@ export const emptyMiddleware =
};
export const createContextWithRouteParameters = (
mockContestOptions?: Options<Record<string, unknown>>
mockContextOptions?: Options<Record<string, unknown>>
): Context & IRouterParamContext => {
const ctx = createMockContext(mockContestOptions);
const ctx = createMockContext(mockContextOptions);
return {
...ctx,

View file

@ -0,0 +1,3 @@
export const isEnum = <T extends string>(list: T[], value: unknown): value is T =>
// @ts-expect-error the easiest way to perform type checking for a string enum
list.includes(value);

View file

@ -12,10 +12,9 @@
"scripts": {
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build && pnpm test:api && pnpm test:ui && pnpm test:interaction",
"test": "pnpm build && pnpm test:api && pnpm test:ui",
"test:api": "pnpm test:only -i ./lib/tests/api",
"test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui",
"test:interaction": "pnpm test:only -i ./lib/tests/interaction",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"start": "pnpm test"

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

@ -1,12 +1,12 @@
import type { LogtoConfig } from '@logto/node';
import LogtoClient from '@logto/node';
import { demoAppApplicationId } from '@logto/schemas/lib/seeds/index.js';
import type { Optional } from '@silverhand/essentials';
import { assert } from '@silverhand/essentials';
import { got } from 'got';
import { consent } from '#src/api/index.js';
import { demoAppRedirectUri, logtoUrl } from '#src/constants.js';
import { extractCookie } from '#src/utils.js';
import { MemoryStorage } from './storage.js';
@ -17,7 +17,7 @@ export const defaultConfig = {
};
export default class MockClient {
public interactionCookie?: string;
public rawCookies: string[] = [];
private navigateUrl?: string;
private readonly storage: MemoryStorage;
@ -37,6 +37,27 @@ export default class MockClient {
);
}
// TODO: Rename to sessionCookies or something accurate
public get interactionCookie(): string {
return this.rawCookies.join('; ');
}
public get parsedCookies(): Map<string, Optional<string>> {
const map = new Map<string, Optional<string>>();
for (const cookie of this.rawCookies) {
for (const element of cookie.split(';')) {
const [key, value] = element.trim().split('=');
if (key) {
map.set(key, value);
}
}
}
return map;
}
public async initSession(callbackUri = demoAppRedirectUri) {
await this.logto.signIn(callbackUri);
@ -58,7 +79,7 @@ export default class MockClient {
);
// Get session cookie
this.interactionCookie = extractCookie(response);
this.rawCookies = response.headers['set-cookie'] ?? [];
assert(this.interactionCookie, new Error('Get cookie from authorization endpoint failed'));
}
@ -79,9 +100,10 @@ export default class MockClient {
new Error('Invoke auth before consent failed')
);
this.interactionCookie = extractCookie(authResponse);
this.rawCookies = authResponse.headers['set-cookie'] ?? [];
await this.consent();
const signInCallbackUri = await this.consent();
await this.logto.handleSignInCallback(signInCallbackUri);
}
public async getAccessToken(resource?: string) {
@ -93,7 +115,12 @@ export default class MockClient {
}
public async signOut(postSignOutRedirectUri?: string) {
return this.logto.signOut(postSignOutRedirectUri);
if (!this.navigateUrl) {
throw new Error('No navigate URL found for sign-out');
}
await this.logto.signOut(postSignOutRedirectUri);
await got(this.navigateUrl);
}
public async isAuthenticated() {
@ -105,7 +132,7 @@ export default class MockClient {
}
public assignCookie(cookie: string) {
this.interactionCookie = cookie;
this.rawCookies = cookie.split(';').map((value) => value.trim());
}
private readonly consent = async () => {
@ -130,6 +157,6 @@ export default class MockClient {
const signInCallbackUri = authCodeResponse.headers.location;
assert(signInCallbackUri, new Error('Get sign in callback uri failed'));
await this.logto.handleSignInCallback(signInCallbackUri);
return signInCallbackUri;
};
}

View file

@ -0,0 +1,57 @@
import { Event, interaction, SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { deleteUser } from '#src/api/admin-user.js';
import { putInteraction } from '#src/api/interaction.js';
import { getLogs } from '#src/api/logs.js';
import MockClient from '#src/client/index.js';
import { enableAllPasswordSignInMethods } from '../interaction/utils/sign-in-experience.js';
import { generateNewUserProfile } from '../interaction/utils/user.js';
describe('audit logs for interaction', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
});
it('should insert log after interaction started and ended', async () => {
const client = new MockClient();
await client.initSession();
const interactionId = client.parsedCookies.get('_interaction');
assert(interactionId, new Error('No interaction found in cookie'));
console.debug('Testing interaction', interactionId);
// Expect interaction create log
const createLogs = await getLogs(
new URLSearchParams({ logType: `${interaction.prefix}.${interaction.Action.Create}` })
);
expect(createLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy();
// Process interaction with minimum effort
const { username, password } = generateNewUserProfile({ username: true, password: true });
const response = await putInteraction(
{
event: Event.Register,
profile: { username, password },
},
client.interactionCookie
);
await client.processSession(response.redirectTo);
// Expect interaction end log
const endLogs = await getLogs(
new URLSearchParams({ logType: `${interaction.prefix}.${interaction.Action.End}` })
);
expect(endLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy();
// Clean up
const { sub: userId } = await client.getIdTokenClaims();
await client.signOut();
await deleteUser(userId);
});
});

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();
@ -19,9 +20,7 @@ describe('admin console logs', () => {
const logs = await getLogs();
const registerLog = logs.filter(
({ type, payload }) =>
type === 'RegisterUsernamePassword' &&
(payload as Record<string, unknown>).username === username
({ type, payload }) => type === 'RegisterUsernamePassword' && payload.username === username
);
expect(registerLog.length).toBeGreaterThan(0);

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

@ -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';

View file

@ -47,9 +47,9 @@ export const socialIdentityPayloadGuard = z.object({
});
export type SocialIdentityPayload = z.infer<typeof socialIdentityPayloadGuard>;
/**
* Interaction Payload Guard
*/
// Interaction Payload Guard
/** Interaction flow (main flow) types. */
export enum Event {
SignIn = 'SignIn',
Register = 'Register',

View file

@ -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<typeof baseLogPayloadGuard>;
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<typeof arbitraryLogPayloadGuard>;
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<typeof logPayloadsGuard>;
/** @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<typeof logTypeGuard>;
/** @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<Log, 'payload'> & {
payload: {
userId?: string;

View file

@ -0,0 +1,52 @@
import type { ZodType } from 'zod';
import { z } from 'zod';
import type * as interaction from './interaction.js';
import type * as token from './token.js';
export * as interaction from './interaction.js';
export * as token from './token.js';
/** Fallback for empty or unrecognized log keys. */
export const LogKeyUnknown = 'Unknown';
/**
* The union type of all available log keys.
* Note duplicate keys are allowed but should be avoided.
*
* @see {@link interaction.LogKey} for interaction log keys.
* @see {@link token.LogKey} for token log keys.
**/
export type LogKey = typeof LogKeyUnknown | interaction.LogKey | token.LogKey;
export enum LogResult {
Success = 'Success',
Error = 'Error',
}
/**
* The basic log context type. It's more about a type hint instead of forcing the log shape.
*
* Note when setting up a log function, the type of log key in function arguments should be `LogKey`.
* Here we use `string` to make it compatible with the Zod guard.
**/
export type LogContextPayload = {
key: string;
result: LogResult;
error?: Record<string, unknown> | string;
ip?: string;
userAgent?: string;
applicationId?: string;
sessionId?: string;
};
/** Type guard for {@link LogContextPayload} */
export const logContextGuard: ZodType<LogContextPayload> = 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(),
});

View file

@ -0,0 +1,79 @@
import type { Event } from '../interactions.js';
export type Prefix = 'Interaction';
export const prefix: Prefix = 'Interaction';
/** The interaction field to update. This is valid based on we only allow users update one field at a time. */
export enum Field {
Event = 'Event',
Identifier = 'Identifier',
Profile = 'Profile',
}
/** Method to verify the identifier */
export enum Method {
Password = 'Password',
VerificationCode = 'VerificationCode',
Social = 'Social',
}
export enum Action {
/** Create a new entity. (E.g. create an interaction, create a verification code) */
Create = 'Create',
/** Update an existing entity. (E.g. change interaction type) */
Update = 'Update',
/** Submit updated info to an entity, or submit to the system. (E.g. submit an interaction, submit a verification code to get verified) */
Submit = 'Submit',
/** Change an entity to the end state. (E.g. end an interaction) */
End = 'End',
}
/**
* The union type of all available log keys for interaction.
* The key MUST describe an {@link Action}.
*
* ### Keys breakdown
*
* ```ts
* `Interaction.${Action.Create | Action.End}`
* ```
*
* - Indicates an interaction is started or ended. Normally it is performed by OIDC Provider.
*
* ```ts
* `Interaction.${Event}.${Action.Update | Action.Submit}`
* ```
*
* Since {@link Event} is the primary identifier of interaction type, most of log keys include this info for better query experience.
* The only exception is the initial creation of an interaction, which has a key of `Interaction.Create`,
* since we cannot know the type at that time.
*
* - When {@link Action} is `Update`, it indicates the type of interaction is updating to {@link Event}.
* - When {@link Action} is `Submit`, it indicates the whole interaction is being submitted.
*
* ```ts
* `Interaction.${Event}.${Field.Profile}.${Action.Update}`
* ```
*
* - Indicates the profile of an interaction is being updated. It may add or remove profile data.
*
* ```ts
* `Interaction.${Event}.${Field.Identifier}.${Method}.${Action}`
* ```
*
* - Indicates an identifier method is being created or submitted to an interaction.
* - When {@link Method} is `VerificationCode`, {@link Action} can be `Create` (generate and send a code) or `Submit` (verify and submit to the identifiers);
* - Otherwise, {@link Action} is fixed to `Submit` (other methods can be verified on submitting).
*/
export type LogKey =
| `${Prefix}.${Action.Create | Action.End}`
| `${Prefix}.${Event}.${Action.Update | Action.Submit}`
| `${Prefix}.${Event}.${Field.Profile}.${Action.Update}`
| `${Prefix}.${Event}.${Field.Identifier}.${Method.VerificationCode}.${
| Action.Create
| Action.Submit}`
| `${Prefix}.${Event}.${Field.Identifier}.${Exclude<
Method,
Method.VerificationCode
>}.${Action.Submit}`;

View file

@ -0,0 +1,25 @@
/** The type of a token flow. */
export enum Flow {
ExchangeTokenBy = 'ExchangeTokenBy',
RevokeToken = 'RevokeToken',
}
/** Available grant token types extracted from [oidc-provider](https://github.com/panva/node-oidc-provider/blob/564b1095ee869c89381d63dfdb5875c99f870f5f/lib/helpers/revoke.js#L13). */
export enum TokenType {
AccessToken = 'AccessToken',
RefreshToken = 'RefreshToken',
IdToken = 'IdToken',
AuthorizationCode = 'AuthorizationCode',
DeviceCode = 'DeviceCode',
BackchannelAuthenticationRequest = 'BackchannelAuthenticationRequest',
}
/** The credential to request a grant. */
export enum ExchangeByType {
Unknown = 'Unknown',
AuthorizationCode = 'AuthorizationCode',
RefreshToken = 'RefreshToken',
ClientCredentials = 'ClientCredentials',
}
export type LogKey = `${Flow.ExchangeTokenBy}.${ExchangeByType}` | `${Flow.RevokeToken}`;