0
Fork 0
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:
IceHe.xyz 2022-05-20 13:54:05 +08:00 committed by GitHub
parent 9589aeafec
commit e5196fc31d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 181 additions and 23 deletions

View file

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

View file

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

View file

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

View file

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