0
Fork 0
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:
IceHe.xyz 2022-05-19 11:24:26 +08:00 committed by GitHub
parent 4ba23085db
commit 3b048a80a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 171 additions and 2 deletions

View file

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

View file

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

View 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,
});
});
});

View 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,
});
};

View file

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

View file

@ -7,3 +7,8 @@ export type SnakeCaseOidcConfig = {
};
export type OidcConfig = KeysToCamelCase<SnakeCaseOidcConfig>;
export enum GrantType {
AuthorizationCode = 'authorization_code',
RefreshToken = 'refresh_token',
}