mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(core): grantRevokedListener for logging revocation of access and refresh token (#900)
* feat(core): grantRevokedListener for logging access and refresh token revocation * chore(core): improve description of grantRevokedListener test cases * refactor(core): extract addOidcEventListeners
This commit is contained in:
parent
9589aeafec
commit
e5196fc31d
4 changed files with 181 additions and 23 deletions
|
@ -12,7 +12,7 @@ import { isOriginAllowed, validateCustomClientMetadata } from '@/oidc/utils';
|
|||
import { findResourceByIndicator } from '@/queries/resource';
|
||||
import { findUserById } from '@/queries/user';
|
||||
import { routes } from '@/routes/consts';
|
||||
import { grantErrorListener, grantSuccessListener } from '@/utils/oidc-provider-event-listener';
|
||||
import { addOidcEventListeners } from '@/utils/oidc-provider-event-listener';
|
||||
|
||||
export default async function initOidc(app: Koa): Promise<Provider> {
|
||||
const { issuer, cookieKeys, privateKey, defaultIdTokenTtl, defaultRefreshTokenTtl } =
|
||||
|
@ -116,8 +116,7 @@ export default async function initOidc(app: Koa): Promise<Provider> {
|
|||
},
|
||||
});
|
||||
|
||||
oidc.on('grant.success', grantSuccessListener);
|
||||
oidc.on('grant.error', grantErrorListener);
|
||||
addOidcEventListeners(oidc);
|
||||
|
||||
app.use(mount('/oidc', oidc.app));
|
||||
|
||||
|
|
|
@ -1,15 +1,49 @@
|
|||
import { LogResult } from '@logto/schemas';
|
||||
import { LogResult, TokenType } from '@logto/schemas';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { grantErrorListener, grantSuccessListener } from '@/utils/oidc-provider-event-listener';
|
||||
import {
|
||||
addOidcEventListeners,
|
||||
grantErrorListener,
|
||||
grantRevokedListener,
|
||||
grantSuccessListener,
|
||||
} from '@/utils/oidc-provider-event-listener';
|
||||
import { createContextWithRouteParameters } from '@/utils/test-utils';
|
||||
|
||||
const userId = 'userIdValue';
|
||||
const sessionId = 'sessionIdValue';
|
||||
const applicationId = 'applicationIdValue';
|
||||
|
||||
const addLogContext = jest.fn();
|
||||
const log = jest.fn();
|
||||
const addListener = jest.fn();
|
||||
|
||||
jest.mock('oidc-provider', () => ({ Provider: jest.fn(() => ({ addListener })) }));
|
||||
|
||||
describe('addOidcEventListeners', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add grantSuccessListener', () => {
|
||||
const provider = new Provider('');
|
||||
addOidcEventListeners(provider);
|
||||
expect(addListener).toHaveBeenCalledWith('grant.success', grantSuccessListener);
|
||||
});
|
||||
|
||||
it('should add grantErrorListener', () => {
|
||||
const provider = new Provider('');
|
||||
addOidcEventListeners(provider);
|
||||
expect(addListener).toHaveBeenCalledWith('grant.error', grantErrorListener);
|
||||
});
|
||||
|
||||
it('should add grantRevokedListener', () => {
|
||||
const provider = new Provider('');
|
||||
addOidcEventListeners(provider);
|
||||
expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevokedListener);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grantSuccessListener', () => {
|
||||
const userId = 'userIdValue';
|
||||
const sessionId = 'sessionIdValue';
|
||||
const applicationId = 'applicationIdValue';
|
||||
const entities = {
|
||||
Account: { accountId: userId },
|
||||
Grant: { jti: sessionId },
|
||||
|
@ -39,7 +73,7 @@ describe('grantSuccessListener', () => {
|
|||
await grantSuccessListener(ctx);
|
||||
expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId });
|
||||
expect(log).toHaveBeenCalledWith('CodeExchangeToken', {
|
||||
issued: ['accessToken', 'refreshToken', 'idToken'],
|
||||
issued: Object.values(TokenType),
|
||||
params: parameters,
|
||||
scope: 'openid offline-access',
|
||||
userId,
|
||||
|
@ -65,7 +99,7 @@ describe('grantSuccessListener', () => {
|
|||
await grantSuccessListener(ctx);
|
||||
expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId });
|
||||
expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', {
|
||||
issued: ['accessToken', 'refreshToken', 'idToken'],
|
||||
issued: Object.values(TokenType),
|
||||
params: parameters,
|
||||
scope: 'openid offline-access',
|
||||
userId,
|
||||
|
@ -91,7 +125,7 @@ describe('grantSuccessListener', () => {
|
|||
await grantSuccessListener(ctx);
|
||||
expect(addLogContext).toHaveBeenCalledWith({ applicationId, sessionId });
|
||||
expect(log).toHaveBeenCalledWith('RefreshTokenExchangeToken', {
|
||||
issued: ['accessToken', 'refreshToken'],
|
||||
issued: [TokenType.AccessToken, TokenType.RefreshToken],
|
||||
params: parameters,
|
||||
scope: 'offline-access',
|
||||
userId,
|
||||
|
@ -116,7 +150,6 @@ describe('grantSuccessListener', () => {
|
|||
});
|
||||
|
||||
describe('grantErrorListener', () => {
|
||||
const applicationId = 'applicationIdValue';
|
||||
const entities = { Client: { clientId: applicationId } };
|
||||
const errorMessage = 'invalid grant';
|
||||
|
||||
|
@ -190,3 +223,81 @@ describe('grantErrorListener', () => {
|
|||
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,9 +1,20 @@
|
|||
import { GrantType, IssuedTokenType, LogResult } from '@logto/schemas';
|
||||
import { GrantType, TokenType, LogResult } from '@logto/schemas';
|
||||
import { notFalsy } from '@silverhand/essentials';
|
||||
import { errors, KoaContextWithOIDC } from 'oidc-provider';
|
||||
import { errors, KoaContextWithOIDC, Provider } from 'oidc-provider';
|
||||
|
||||
import { WithLogContext } from '@/middleware/koa-log';
|
||||
|
||||
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
|
||||
|
@ -22,8 +33,7 @@ const getLogType = (grantType: unknown) => {
|
|||
!grantType ||
|
||||
![GrantType.AuthorizationCode, GrantType.RefreshToken].includes(grantType as GrantType)
|
||||
) {
|
||||
console.error('Unexpected grant_type:', grantType);
|
||||
|
||||
// Only log token exchange by authorization code or refresh token.
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -32,6 +42,7 @@ const getLogType = (grantType: unknown) => {
|
|||
: '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 }
|
||||
) => {
|
||||
|
@ -55,11 +66,11 @@ export const grantSuccessListener = async (
|
|||
});
|
||||
|
||||
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 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,
|
||||
|
@ -69,6 +80,7 @@ export const grantSuccessListener = async (
|
|||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
|
@ -95,3 +107,27 @@ export const grantErrorListener = async (
|
|||
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 });
|
||||
};
|
||||
|
|
|
@ -100,15 +100,26 @@ interface SignInSocialLogPayload extends SignInSocialBindLogPayload {
|
|||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export type IssuedTokenType = 'accessToken' | 'refreshToken' | 'idToken';
|
||||
export enum TokenType {
|
||||
AccessToken = 'AccessToken',
|
||||
RefreshToken = 'RefreshToken',
|
||||
IdToken = 'IdToken',
|
||||
}
|
||||
|
||||
interface ExchangeTokenLogPayload extends ArbitraryLogPayload {
|
||||
userId?: string;
|
||||
params?: Record<string, unknown>;
|
||||
issued?: IssuedTokenType[];
|
||||
issued?: TokenType[];
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface RevokeTokenLogPayload extends ArbitraryLogPayload {
|
||||
userId?: string;
|
||||
params?: Record<string, unknown>;
|
||||
grantId?: string;
|
||||
tokenType?: TokenType;
|
||||
}
|
||||
|
||||
export type LogPayloads = {
|
||||
RegisterUsernamePassword: RegisterUsernamePasswordLogPayload;
|
||||
RegisterEmailSendPasscode: RegisterEmailSendPasscodeLogPayload;
|
||||
|
@ -126,6 +137,7 @@ export type LogPayloads = {
|
|||
SignInSocial: SignInSocialLogPayload;
|
||||
CodeExchangeToken: ExchangeTokenLogPayload;
|
||||
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
|
||||
RevokeToken: RevokeTokenLogPayload;
|
||||
};
|
||||
|
||||
export type LogType = keyof LogPayloads;
|
||||
|
|
Loading…
Add table
Reference in a new issue