mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
feat(core,schemas): log token exchange success (#809)
This commit is contained in:
parent
4ba23085db
commit
3b048a80a3
6 changed files with 171 additions and 2 deletions
|
@ -1,4 +1,4 @@
|
|||
import { CreateApplication } from '@logto/schemas';
|
||||
import { CreateApplication, GrantType } from '@logto/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { AdapterFactory, AllClientMetadata } from 'oidc-provider';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
@ -28,7 +28,7 @@ export default function postgresAdapter(modelName: string): ReturnType<AdapterFa
|
|||
client_id,
|
||||
client_name,
|
||||
application_type: getApplicationTypeString(type),
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
grant_types: Object.values(GrantType),
|
||||
token_endpoint_auth_method: 'none',
|
||||
...snakecaseKeys(oidcClientMetadata),
|
||||
...customClientMetadata, // OIDC Provider won't camelcase custom parameter keys
|
||||
|
|
|
@ -12,6 +12,7 @@ import { isOriginAllowed, validateCustomClientMetadata } from '@/oidc/utils';
|
|||
import { findResourceByIndicator } from '@/queries/resource';
|
||||
import { findUserById } from '@/queries/user';
|
||||
import { routes } from '@/routes/consts';
|
||||
import { grantSuccessListener } from '@/utils/oidc-provider-event-listener';
|
||||
|
||||
export default async function initOidc(app: Koa): Promise<Provider> {
|
||||
const { issuer, privateKey, defaultIdTokenTtl, defaultRefreshTokenTtl } = envSet.values.oidc;
|
||||
|
@ -116,6 +117,9 @@ export default async function initOidc(app: Koa): Promise<Provider> {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
oidc.on('grant.success', grantSuccessListener);
|
||||
|
||||
app.use(mount('/oidc', oidc.app));
|
||||
|
||||
return oidc;
|
||||
|
|
98
packages/core/src/utils/oidc-provider-event-listener.test.ts
Normal file
98
packages/core/src/utils/oidc-provider-event-listener.test.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { grantSuccessListener } from '@/utils/oidc-provider-event-listener';
|
||||
import { createContextWithRouteParameters } from '@/utils/test-utils';
|
||||
|
||||
describe('grantSuccessListener', () => {
|
||||
const userId = 'userIdValue';
|
||||
const sessionId = 'sessionIdValue';
|
||||
const applicationId = 'applicationIdValue';
|
||||
const entities = {
|
||||
Account: { accountId: userId },
|
||||
Grant: { jti: sessionId },
|
||||
Client: { clientId: applicationId },
|
||||
};
|
||||
|
||||
const addLogContext = jest.fn();
|
||||
const log = jest.fn();
|
||||
|
||||
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: ['accessToken', 'refreshToken', 'idToken'],
|
||||
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: ['accessToken', 'refreshToken', 'idToken'],
|
||||
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: ['accessToken', 'refreshToken'],
|
||||
params: parameters,
|
||||
scope: 'offline-access',
|
||||
userId,
|
||||
});
|
||||
});
|
||||
});
|
51
packages/core/src/utils/oidc-provider-event-listener.ts
Normal file
51
packages/core/src/utils/oidc-provider-event-listener.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { GrantType, IssuedTokenType, LogType } from '@logto/schemas';
|
||||
import { notFalsy } from '@silverhand/essentials';
|
||||
import { KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
import { WithLogContext } from '@/middleware/koa-log';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - ……
|
||||
*/
|
||||
interface GrantBody {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export const grantSuccessListener = async (
|
||||
ctx: KoaContextWithOIDC & WithLogContext & { body: GrantBody }
|
||||
) => {
|
||||
const {
|
||||
oidc: {
|
||||
entities: { Account: account, Grant: grant, Client: client },
|
||||
params,
|
||||
},
|
||||
body,
|
||||
} = ctx;
|
||||
ctx.addLogContext({
|
||||
applicationId: client?.clientId,
|
||||
sessionId: grant?.jti,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token, id_token, scope } = body;
|
||||
const issued: IssuedTokenType[] = [
|
||||
access_token && 'accessToken',
|
||||
refresh_token && 'refreshToken',
|
||||
id_token && 'idToken',
|
||||
].filter((value): value is IssuedTokenType => notFalsy(value));
|
||||
|
||||
const grantType = params?.grant_type;
|
||||
const type: LogType =
|
||||
grantType === GrantType.AuthorizationCode ? 'CodeExchangeToken' : 'RefreshTokenExchangeToken';
|
||||
ctx.log(type, {
|
||||
userId: account?.accountId,
|
||||
params,
|
||||
issued,
|
||||
scope,
|
||||
});
|
||||
};
|
|
@ -100,6 +100,15 @@ interface SignInSocialLogPayload extends SignInSocialBindLogPayload {
|
|||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export type IssuedTokenType = 'accessToken' | 'refreshToken' | 'idToken';
|
||||
|
||||
interface ExchangeTokenLogPayload extends ArbitraryLogPayload {
|
||||
userId?: string;
|
||||
params?: Record<string, unknown>;
|
||||
issued?: IssuedTokenType[];
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export type LogPayloads = {
|
||||
RegisterUsernamePassword: RegisterUsernamePasswordLogPayload;
|
||||
RegisterEmailSendPasscode: RegisterEmailSendPasscodeLogPayload;
|
||||
|
@ -115,6 +124,8 @@ export type LogPayloads = {
|
|||
SignInSms: SignInSmsLogPayload;
|
||||
SignInSocialBind: SignInSocialBindLogPayload;
|
||||
SignInSocial: SignInSocialLogPayload;
|
||||
CodeExchangeToken: ExchangeTokenLogPayload;
|
||||
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
|
||||
};
|
||||
|
||||
export type LogType = keyof LogPayloads;
|
||||
|
|
|
@ -7,3 +7,8 @@ export type SnakeCaseOidcConfig = {
|
|||
};
|
||||
|
||||
export type OidcConfig = KeysToCamelCase<SnakeCaseOidcConfig>;
|
||||
|
||||
export enum GrantType {
|
||||
AuthorizationCode = 'authorization_code',
|
||||
RefreshToken = 'refresh_token',
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue