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:
commit
8ca25c8d5d
81 changed files with 1317 additions and 609 deletions
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -111,7 +111,8 @@
|
|||
"error",
|
||||
11
|
||||
],
|
||||
"default-case": "off"
|
||||
"default-case": "off",
|
||||
"import/extensions": "off"
|
||||
}
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
|
|
|
@ -8,7 +8,7 @@ const { jest } = import.meta;
|
|||
const middlewareList = [
|
||||
'error-handler',
|
||||
'i18next',
|
||||
'log',
|
||||
'audit-log',
|
||||
'oidc-error-handler',
|
||||
'slonik-error-handler',
|
||||
'spa-proxy',
|
||||
|
|
|
@ -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);
|
||||
|
|
210
packages/core/src/event-listeners/grant.test.ts
Normal file
210
packages/core/src/event-listeners/grant.test.ts
Normal 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,
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
90
packages/core/src/event-listeners/grant.ts
Normal file
90
packages/core/src/event-listeners/grant.ts
Normal 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]);
|
||||
};
|
24
packages/core/src/event-listeners/index.test.ts
Normal file
24
packages/core/src/event-listeners/index.test.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
|
||||
import { grantListener, grantRevocationListener } from './grant.js';
|
||||
import { addOidcEventListeners } from './index.js';
|
||||
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
describe('addOidcEventListeners', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add proper listeners', () => {
|
||||
const provider = createMockProvider();
|
||||
const addListener = jest.spyOn(provider, 'addListener');
|
||||
addOidcEventListeners(provider);
|
||||
expect(addListener).toHaveBeenCalledWith('grant.success', grantListener);
|
||||
expect(addListener).toHaveBeenCalledWith('grant.error', grantListener);
|
||||
expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevocationListener);
|
||||
expect(addListener).toHaveBeenCalledWith('interaction.started', interactionStartedListener);
|
||||
expect(addListener).toHaveBeenCalledWith('interaction.ended', interactionEndedListener);
|
||||
});
|
||||
});
|
16
packages/core/src/event-listeners/index.ts
Normal file
16
packages/core/src/event-listeners/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import { grantListener, grantRevocationListener } from './grant.js';
|
||||
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
|
||||
|
||||
/**
|
||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details Getting auth error with no details?}
|
||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md OIDC Provider events}
|
||||
*/
|
||||
export const addOidcEventListeners = (provider: Provider) => {
|
||||
provider.addListener('grant.success', grantListener);
|
||||
provider.addListener('grant.error', grantListener);
|
||||
provider.addListener('grant.revoked', grantRevocationListener);
|
||||
provider.addListener('interaction.started', interactionStartedListener);
|
||||
provider.addListener('interaction.ended', interactionEndedListener);
|
||||
};
|
76
packages/core/src/event-listeners/interaction.test.ts
Normal file
76
packages/core/src/event-listeners/interaction.test.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import type { LogKey } from '@logto/schemas';
|
||||
import type { PromptDetail } from 'oidc-provider';
|
||||
|
||||
import { createMockLogContext } from '#src/test-utils/koa-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'
|
||||
);
|
||||
});
|
||||
});
|
25
packages/core/src/event-listeners/interaction.ts
Normal file
25
packages/core/src/event-listeners/interaction.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { KoaContextWithOIDC, PromptDetail } from 'oidc-provider';
|
||||
|
||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
|
||||
import { extractInteractionContext } from './utils.js';
|
||||
|
||||
const interactionListener = (
|
||||
event: 'started' | 'ended',
|
||||
ctx: KoaContextWithOIDC & WithLogContext,
|
||||
prompt?: PromptDetail
|
||||
) => {
|
||||
const log = ctx.createLog(`Interaction.${event === 'started' ? 'Create' : 'End'}`);
|
||||
log.append({ ...extractInteractionContext(ctx), prompt });
|
||||
};
|
||||
|
||||
export const interactionStartedListener = (
|
||||
ctx: KoaContextWithOIDC & WithLogContext,
|
||||
prompt: PromptDetail
|
||||
) => {
|
||||
interactionListener('started', ctx, prompt);
|
||||
};
|
||||
|
||||
export const interactionEndedListener = (ctx: KoaContextWithOIDC & WithLogContext) => {
|
||||
interactionListener('ended', ctx);
|
||||
};
|
21
packages/core/src/event-listeners/utils.ts
Normal file
21
packages/core/src/event-listeners/utils.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { IRouterParamContext } from 'koa-router';
|
||||
import type { KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
import type { LogPayload } from '#src/middleware/koa-audit-log.js';
|
||||
|
||||
export const extractInteractionContext = (
|
||||
ctx: IRouterParamContext & KoaContextWithOIDC
|
||||
): LogPayload => {
|
||||
const {
|
||||
entities: { Account, Session, Client, Interaction },
|
||||
params,
|
||||
} = ctx.oidc;
|
||||
|
||||
return {
|
||||
applicationId: Client?.clientId,
|
||||
sessionId: Session?.jti,
|
||||
interactionId: Interaction?.jti,
|
||||
userId: Account?.accountId,
|
||||
params,
|
||||
};
|
||||
};
|
|
@ -1,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,
|
|
@ -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,
|
191
packages/core/src/middleware/koa-audit-log.test.ts
Normal file
191
packages/core/src/middleware/koa-audit-log.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
149
packages/core/src/middleware/koa-audit-log.ts
Normal file
149
packages/core/src/middleware/koa-audit-log.ts
Normal 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 },
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
25
packages/core/src/middleware/koa-log-session-legacy.ts
Normal file
25
packages/core/src/middleware/koa-log-session-legacy.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
`);
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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'));
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
) => {
|
||||
|
|
|
@ -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
|
||||
>;
|
||||
|
|
18
packages/core/src/test-utils/koa-audit-log.ts
Normal file
18
packages/core/src/test-utils/koa-audit-log.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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));
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
};
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
3
packages/core/src/utils/type.ts
Normal file
3
packages/core/src/utils/type.ts
Normal 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);
|
|
@ -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"
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -1,11 +1,3 @@
|
|||
import type { Response } from 'got';
|
||||
|
||||
export const extractCookie = (response: Response) => {
|
||||
const { headers } = response;
|
||||
|
||||
return headers['set-cookie']?.join('; ') ?? '';
|
||||
};
|
||||
|
||||
export const generateName = () => crypto.randomUUID();
|
||||
export const generateUsername = () => `usr_${crypto.randomUUID().replaceAll('-', '_')}`;
|
||||
export const generatePassword = () => `pwd_${crypto.randomUUID()}`;
|
||||
|
|
|
@ -11,9 +11,7 @@ export {
|
|||
* Commonly Used
|
||||
*/
|
||||
|
||||
// Cannot declare `z.object({}).catchall(z.unknown().optional())` to guard `{ [key: string]?: unknown }` (invalid type),
|
||||
// so do it another way to guard `{ [x: string]: unknown; } | {}`.
|
||||
export const arbitraryObjectGuard = z.union([z.object({}).catchall(z.unknown()), z.object({})]);
|
||||
export const arbitraryObjectGuard = z.record(z.unknown());
|
||||
|
||||
export type ArbitraryObject = z.infer<typeof arbitraryObjectGuard>;
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
52
packages/schemas/src/types/log/index.ts
Normal file
52
packages/schemas/src/types/log/index.ts
Normal 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(),
|
||||
});
|
79
packages/schemas/src/types/log/interaction.ts
Normal file
79
packages/schemas/src/types/log/interaction.ts
Normal 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}`;
|
25
packages/schemas/src/types/log/token.ts
Normal file
25
packages/schemas/src/types/log/token.ts
Normal 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}`;
|
Loading…
Add table
Reference in a new issue