Skip to content

feat(oauth): grant_type=refresh_token exchange at /oauth/token#153

Merged
EsTharian merged 3 commits intomainfrom
feat/oauth-refresh-token-grant
Apr 24, 2026
Merged

feat(oauth): grant_type=refresh_token exchange at /oauth/token#153
EsTharian merged 3 commits intomainfrom
feat/oauth-refresh-token-grant

Conversation

@EsTharian
Copy link
Copy Markdown
Member

Closes #151.

Summary

  • Adds grant_type=refresh_token dispatch at /oauth/token per RFC 6749 §6 with required rotation (revoke-old + mint-new) and token-family replay detection.
  • Schema: refresh_tokens.family_id uuid NOT NULL via migration 0002_add_refresh_tokens_family_id.sql (legacy rows backfilled with uuidv7() — one row = singleton family, correct for pre-rotation tokens). Repository gains findByTokenHashIncludingRevoked and atomic revokeFamily.
  • Removes /auth/refresh — grep confirmed no external callers, keeping two surfaces with diverging rotation semantics was the footgun the issue calls out.
  • Honours down-scoping; rejects upscoping with invalid_scope; cross-client refresh rejected with invalid_grant; public clients supported (bound by refresh-token ownership, no client_secret).

Caveats for reviewers

  • Merge-time migration collision: PR feat(oauth): user consent screen on /oauth/authorize #150 (consent screen) and the DCR PR also number their migration 0002. Pick one landing order; this one is the smallest schema delta and self-contained on the tokens table — suggest landing it at 0002 and renumbering the others after.
  • Ordering in handleRefreshToken is load-bearing: ownership check runs before replay detection so cross-client replay can't fan out revocation onto the owner's family; rotation-revoke runs before the new-token insert.
  • REFRESH_RATE_LIMIT / _WINDOW env vars are present but not yet wired — follow-up if a grant-specific rate limit is wanted.

Test plan

  • pnpm nx run-many -t test lint typecheck -p auth-server,infra-db — green
  • auth-server: 87 tests passing (10 new: happy-path rotation, replay family-revoke, cross-client reject, down-scope, upscope reject, public-client refresh, confidential-without-secret reject, unknown token, expired token, unauthorized_client)
  • Manual: full authcode → refresh → refresh chain against a dev instance
  • Manual: present a revoked refresh token, confirm the whole family is revoked

Introduces a `family_id` column on `refresh_tokens` plus a covering
index. Every rotation preserves the column so that replay of a
revoked token can be attributed to — and revoke — an entire family
atomically (OAuth 2.1 §4.3.1 / RFC 9700 §2.2.2).

Repository gains two siblings to the existing revoke API:
`findByTokenHashIncludingRevoked` (so replay is detectable at all)
and `revokeFamily` (revokes every non-revoked row sharing a family_id
in one statement, preserving the original revokedReason for audit).
Dispatches a third grant type at the OAuth token endpoint. New
`handleRefreshToken` sibling to authorization_code/client_credentials:

- Looks up the presented token regardless of revoked/expired state
  so replay is detectable.
- Rejects cross-client presentation (invalid_grant) before any
  family revocation fires, so replay on one client can never fan
  out revocation on another.
- Replay detection: a revoked-but-known token revokes its entire
  `family_id` (OAuth 2.1 §4.3.1, RFC 9700 §2.2.2) and the request
  fails invalid_grant.
- Rotation: the presented token is revoked as 'rotated' BEFORE the
  new token is persisted — no two tokens in the same chain link
  can be live at once.
- Down-scoping honoured (subset of original scopes); any scope
  outside the original set is rejected as invalid_scope.
- Public clients (`token_endpoint_auth_method: 'none'`) present
  only client_id and are bound by refresh-token ownership; a new
  `authenticateClientForRefresh` helper handles the split.

Audit logs carry `familyId` on success, and a security-event row is
written when a family is revoked on replay.

Authorization-code initial issuance and /auth/login now seed a new
family_id per token so rotations can inherit it cleanly.
… surface

`/auth/refresh` predated the OAuth-compliant token endpoint; it issued
refresh tokens that /oauth/token could not accept, leaving a bifurcated
refresh story. With `grant_type=refresh_token` landing at /oauth/token,
the legacy endpoint is redundant — and keeping two surfaces with
different rotation/ownership rules is a footgun.

Audit confirmed no external callers (only the route's own tests + MVP
docs mention the path). The now-unused `refreshSchema`/`refreshResponseSchema`
Zod shapes are also removed. `REFRESH_RATE_LIMIT` env var stays in place
for potential future use.
@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Apr 24, 2026

View your CI Pipeline Execution ↗ for commit b0164cd

Command Status Duration Result
nx affected -t lint test build ✅ Succeeded 3s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-24 08:42:44 UTC

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the OAuth 2.1 refresh_token grant type, introducing refresh token rotation and family-based replay detection. It adds a familyId to the refresh token schema and database, updates the token exchange endpoint to handle refresh grants for both public and confidential clients, and provides comprehensive test coverage for these security flows. Feedback includes addressing a high-severity race condition in the token rotation logic to ensure atomicity and simplifying redundant logic in the client authentication helper.

Comment on lines +489 to +702
async function handleRefreshToken(
ctx: HandlerContext<TokenExchangeRefreshBody>
): Promise<TokenExchangeResponse> {
const { fastify, request, client, body } = ctx;

if (!client.grantTypes.includes('refresh_token')) {
await fastify.repositories.auditLogs.create({
userId: null,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: { error: 'unauthorized_client', grantType: 'refresh_token' },
});
throw new UnauthorizedClientError();
}

const presentedHash = fastify.jwtUtils.hashRefreshToken(body.refresh_token);

// Fetch the row without any active-state filter so replay of a
// revoked token is detectable; downstream checks enforce liveness.
const storedToken =
await fastify.repositories.refreshTokens.findByTokenHashIncludingRevoked(presentedHash);

if (!storedToken) {
await fastify.repositories.auditLogs.create({
userId: null,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: { error: 'invalid_grant: refresh token not recognised' },
});
throw new InvalidGrantError('Invalid or expired refresh token');
}

// Ownership check — before any replay logic, before any revocation.
// Cross-client replay must never be able to fan out family revocation
// on another client's tokens.
if (storedToken.oauthClientId !== client.id) {
await fastify.repositories.auditLogs.create({
userId: storedToken.userId,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: {
error: 'invalid_grant: refresh token bound to different client',
presentingClientId: client.id,
ownerClientId: storedToken.oauthClientId,
},
});
throw new InvalidGrantError('Invalid or expired refresh token');
}

// Replay detection — a revoked token in the correct family MUST trigger
// family-wide revocation per RFC 9700 §2.2.2 / OAuth 2.1 §4.3.1.
if (storedToken.revoked) {
const familyRevokedCount = await fastify.repositories.refreshTokens.revokeFamily(
storedToken.familyId,
'replay_detected'
);
await fastify.repositories.auditLogs.create({
userId: storedToken.userId,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'security',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: {
error: 'invalid_grant: refresh token replay detected',
familyId: storedToken.familyId,
familyTokensRevoked: familyRevokedCount,
},
});
throw new InvalidGrantError('Invalid or expired refresh token');
}

if (storedToken.expiresAt <= Date.now()) {
await fastify.repositories.auditLogs.create({
userId: storedToken.userId,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: { error: 'invalid_grant: refresh token expired' },
});
throw new InvalidGrantError('Invalid or expired refresh token');
}

// Load the subject user; reject if missing or disabled (RFC 9700 §4.14).
const user = await fastify.repositories.users.findById(storedToken.userId);
if (!user) {
await fastify.repositories.auditLogs.create({
userId: storedToken.userId,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: { error: 'invalid_grant: user not found' },
});
throw new InvalidGrantError('Invalid or expired refresh token');
}
if (!user.enabled) {
await fastify.repositories.refreshTokens.revoke(storedToken.id, 'user_disabled');
await fastify.repositories.auditLogs.create({
userId: user.id,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: { error: 'invalid_grant: user_disabled' },
});
throw new InvalidGrantError('Invalid or expired refresh token');
}

// Down-scoping: request narrower scope or keep original set.
// Upscoping (any scope not in the original set) → invalid_scope.
let grantedScopes: string[];
if (body.scope !== undefined) {
const requested = body.scope.split(/\s+/).filter((s) => s.length > 0);
const extraneous = requested.filter((s) => !storedToken.scopes.includes(s));
if (extraneous.length > 0) {
await fastify.repositories.auditLogs.create({
userId: user.id,
oauthClientId: client.id,
event: 'oauth.token.exchange.failure',
eventType: 'token',
success: false,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: {
error: 'invalid_scope: upscoping rejected',
extraneous,
originalScopes: storedToken.scopes,
},
});
throw new InvalidScopeError(`${extraneous.join(' ')} not in original refresh token scope`);
}
grantedScopes = requested;
} else {
grantedScopes = storedToken.scopes;
}

// Rotate: revoke the presented token FIRST so a concurrent replay
// cannot also succeed. We then issue a new token carrying the same
// `familyId`. (The unique index on `token_hash` protects against the
// degenerate case of a collision.)
await fastify.repositories.refreshTokens.revoke(storedToken.id, 'rotated');

const { token: newRefreshToken, tokenHash: newRefreshTokenHash } =
fastify.jwtUtils.generateRefreshToken();
const refreshTokenExpiresAt = Date.now() + fastify.jwtUtils.getRefreshTokenLifespan() * 1000;

await fastify.repositories.refreshTokens.create({
userId: user.id,
oauthClientId: client.id,
tokenHash: newRefreshTokenHash,
familyId: storedToken.familyId,
previousTokenHash: presentedHash,
expiresAt: refreshTokenExpiresAt,
scopes: grantedScopes,
});

const scopeString = grantedScopes.length > 0 ? grantedScopes.join(' ') : undefined;

const accessToken = await fastify.jwtUtils.signAccessToken({
sub: user.id,
email: user.email,
email_verified: user.emailVerified,
clientId: client.clientId,
scope: scopeString,
aud: resolveAudience(client),
});

const accessTokenExpiresIn = fastify.jwtUtils.getAccessTokenLifespan();

await fastify.repositories.auditLogs.create({
userId: user.id,
oauthClientId: client.id,
event: 'oauth.token.exchange.success',
eventType: 'token',
success: true,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || null,
metadata: {
grantType: 'refresh_token',
familyId: storedToken.familyId,
rotatedFromTokenId: storedToken.id,
scope: scopeString,
},
});

return {
access_token: accessToken,
refresh_token: newRefreshToken,
expires_in: accessTokenExpiresIn,
token_type: 'Bearer' as const,
...(scopeString ? { scope: scopeString } : {}),
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The handleRefreshToken function is vulnerable to a race condition during token rotation. Multiple concurrent requests using the same refresh token can bypass the revoked check at line 552 because the check is based on a stale read from line 513. This could result in multiple valid refresh tokens being issued for the same family link, violating the security invariants of refresh token rotation.

To prevent this, you should wrap the entire flow (from the initial token lookup to the creation of the new token) in a database transaction. Additionally, the revocation of the current token (line 650) must be atomic and conditional, ensuring it only succeeds if the token is not already revoked. Following the organization's general rules, either use an atomic update with built-in checks in the WHERE clause or wrap the operations in a transaction.

References
  1. To prevent race conditions in read-then-update scenarios (e.g., checking and then using a token), either use an atomic update with built-in checks in the WHERE clause, or wrap the separate read and update operations in a database transaction.

Comment on lines +158 to +162
const hasBasic = /^Basic\s/i.test(request.headers.authorization ?? '');
if (hasBasic || bodyClientSecret) {
const creds = extractClientCredentials(request, bodyClientId, bodyClientSecret);
return authenticateClient(fastify, realmId, creds);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The hasBasic check and subsequent call to extractClientCredentials are somewhat redundant. extractClientCredentials already internally calls parseBasicAuthHeader and handles the logic for determining the authentication method. You could simplify this by directly calling extractClientCredentials and catching the error if no credentials (secret) are found, which would indicate a potential public client path.

@EsTharian EsTharian merged commit dd25488 into main Apr 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(oauth): grant_type=refresh_token exchange at /oauth/token (RFC 6749 §6)

1 participant