From bb245adbb917dd066db2fe9cfbdbe102394e2c0e Mon Sep 17 00:00:00 2001 From: Wang Sijie Date: Wed, 20 Jul 2022 15:50:39 +0800 Subject: [PATCH] feat(core): refresh token rotation reuse interval (#1617) * feat(core): refresh token rotation reuse interval * refactor: apply suggestions from code review Co-authored-by: Gao Sun --- packages/core/src/env-set/oidc.ts | 7 ++++ .../core/src/queries/oidc-model-instance.ts | 34 ++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/core/src/env-set/oidc.ts b/packages/core/src/env-set/oidc.ts index fc868d32a..c84c04c47 100644 --- a/packages/core/src/env-set/oidc.ts +++ b/packages/core/src/env-set/oidc.ts @@ -112,12 +112,19 @@ const loadOidcValues = async (issuer: string) => { const cookieKeys = await readCookieKeys(); const privateKey = crypto.createPrivateKey(await readPrivateKey()); const publicKey = crypto.createPublicKey(privateKey); + /** + * This interval helps to avoid concurrency issues when exchanging the rotating refresh token multiple times within a given timeframe. + * During the leeway window (in seconds), the consumed refresh token will be considered as valid. + * This is useful for distributed apps and serverless apps like Next.js, in which there is no shared memory. + */ + const refreshTokenReuseInterval = getEnv('OIDC_REFRESH_TOKEN_REUSE_INTERVAL', '3'); return Object.freeze({ cookieKeys, privateKey, publicKey, issuer, + refreshTokenReuseInterval: Number(refreshTokenReuseInterval), defaultIdTokenTtl: 60 * 60, defaultRefreshTokenTtl: 14 * 24 * 60 * 60, }); diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index 46301842f..8dc6999c3 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -4,7 +4,8 @@ import { OidcModelInstancePayload, OidcModelInstances, } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; +import { conditional, Nullable } from '@silverhand/essentials'; +import dayjs from 'dayjs'; import { sql, ValueExpression } from 'slonik'; import { buildInsertInto } from '@/database/insert-into'; @@ -16,15 +17,32 @@ export type QueryResult = Pick; const { table, fields } = convertToIdentifiers(OidcModelInstances); -// eslint-disable-next-line @typescript-eslint/ban-types -const withConsumed = (data: T, consumedAt?: number | null): WithConsumed => ({ +const isConsumed = (modelName: string, consumedAt: Nullable): boolean => { + if (!consumedAt) { + return false; + } + + const { refreshTokenReuseInterval } = envSet.values.oidc; + + if (modelName !== 'RefreshToken' || !refreshTokenReuseInterval) { + return Boolean(consumedAt); + } + + return dayjs(consumedAt).add(refreshTokenReuseInterval, 'seconds').isBefore(dayjs()); +}; + +const withConsumed = ( + data: T, + modelName: string, + consumedAt: Nullable +): WithConsumed => ({ ...data, - ...(consumedAt ? { consumed: true } : undefined), + ...(isConsumed(modelName, consumedAt) ? { consumed: true } : undefined), }); // eslint-disable-next-line @typescript-eslint/ban-types -const convertResult = (result: QueryResult | null) => - conditional(result && withConsumed(result.payload, result.consumedAt)); +const convertResult = (result: QueryResult | null, modelName: string) => + conditional(result && withConsumed(result.payload, modelName, result.consumedAt)); export const upsertInstance = buildInsertInto(OidcModelInstances, { onConflict: { @@ -45,7 +63,7 @@ export const findPayloadById = async (modelName: string, id: string) => { and ${fields.id}=${id} `); - return convertResult(result); + return convertResult(result, modelName); }; export const findPayloadByPayloadField = async < @@ -61,7 +79,7 @@ export const findPayloadByPayloadField = async < and ${fields.payload}->>${field}=${value} `); - return convertResult(result); + return convertResult(result, modelName); }; export const consumeInstanceById = async (modelName: string, id: string) => {