Skip to content

feat(oauth): user consent screen on /oauth/authorize#154

Merged
EsTharian merged 3 commits intomainfrom
feat/oauth-consent-screen
Apr 24, 2026
Merged

feat(oauth): user consent screen on /oauth/authorize#154
EsTharian merged 3 commits intomainfrom
feat/oauth-consent-screen

Conversation

@EsTharian
Copy link
Copy Markdown
Member

Closes #150.

Summary

  • Session-cookie user auth on /oauth/authorize (__Host-qauth_session, HttpOnly, SameSite=Lax, Secure, HMAC-SHA256) alongside the existing Bearer path.
  • Consent UI between auth and code issuance. Shows client name, audience, requested scopes, dynamic-client badge for recently-registered clients, Allow/Deny + "Allow forever". Deny redirects with error=access_denied and preserved state (RFC 6749 §4.1.2.1).
  • New table oauth_consents(user_id, oauth_client_id, realm_id, scopes[], granted_at, revoked_at) with partial unique index on active rows. "Allow forever" upserts and unions scopes; subsequent authorize calls with a subset skip the screen.
  • New column oauth_clients.dynamic_registered_at (nullable bigint) powering the dynamic-client badge + forced re-consent window (DYNAMIC_CLIENT_BADGE_DAYS).
  • Revocation: JSON /consents list/delete with ownership checks; minimal developer-portal /consents page.

Caveats for reviewers

  • Merge-time migration collision: three parallel PRs number their migration 0002. Suggest landing order #151 (0002 tokens) → this (0003) → DCR (0004); renumber this migration + snapshot + journal entry on rebase.
  • CSRF (helpers/session-cookie.ts + routes/ui/consent.ts): double-submit token rotated per GET, stored in Redis session payload, timing-safe compare, burned on POST. Failure → BadRequestError('invalid_csrf_token').
  • Cookie attributes: __Host- prefix enforces Secure + root path; env-gated for local dev.
  • Open-redirect safety on return_to in routes/ui/login.ts (relative-path-only isSafeReturnTo).
  • oauth_clients.dynamic_registered_at is the column DCR (feat(oauth): Dynamic Client Registration (RFC 7591), open mode #149) will populate; currently always null.
  • Bearer-token path on /oauth/authorize retained for back-compat; should be removed once first-party callers migrate (comment in code).

Scope cuts (follow-ups)

  • No /ui/logout endpoint yet — trivial follow-up via clearSessionCookie.
  • Consent TTL is indefinite (per spec); no time-based re-prompt beyond the dynamic-client window.
  • /ui/consent HTML is utility-grade inline CSS; no @qauth-labs/ui yet.
  • Dev portal /consents page is bare-bones (no auth redirect, no pagination).
  • No end-to-end Fastify .inject() test — route handlers covered directly.

Test plan

  • pnpm nx test auth-server — 123 tests passing (13 new: session cookies, HTML escaping, consent logic, CSRF, allow/deny, authorize integration, consents JSON API)
  • pnpm nx lint across affected projects; pnpm nx build developer-portal
  • Manual: full authcode flow via browser — login → consent → code
  • Manual: deny path returns error=access_denied with preserved state
  • Manual: second authorize with subset scopes skips consent; superset re-prompts
  • Manual: revoke a consent in developer portal, verify next authorize re-prompts

Introduces storage for user consent records (one active row per (user, client)
pair, revocation preserved for audit) and a dynamic_registered_at column on
oauth_clients consumed by the upcoming consent UI to badge newly registered
clients. Required groundwork for the /oauth/authorize consent screen.
Adds the browser-driven authentication and consent flow required by issue
#150 before dynamic client registration can ship:

- Session cookie: __Host-qauth_session carries a signed session id
  (HMAC-SHA256) bound to a Redis-backed payload. HttpOnly, SameSite=Lax,
  Secure configurable for local dev. Timing-safe verification.
- Login UI: GET/POST /ui/login renders a server-side form that sets the
  session cookie and redirects to a validated, same-origin return_to path
  (open-redirect-safe). Reuses the JSON login's password verification and
  audit log plumbing.
- Consent UI: GET/POST /ui/consent renders the consent screen with
  client name, optional homepage_uri, scope descriptions, audience, a
  dynamic-client phishing-defense badge, "Allow forever" toggle, and
  timing-safe double-submit CSRF token rotated per render.
- Authorize route: now accepts either the session cookie or the legacy
  Bearer token. Missing both → redirect to /ui/login with return_to;
  cookie + no matching consent → redirect to /ui/consent; cached consent
  that covers the requested scopes → issue code directly.
- Scope-subset check and dynamic-client badge logic live in a dedicated
  helper with unit tests.

Out of scope for this commit: revocation UI, DPoP, 2FA, audit-trail
surfacing. Dynamic client registration will populate dynamicRegisteredAt
in a sibling branch.
Adds the consent-management side of issue #150: users can see every
active grant and revoke any of them.

- Auth-server: GET /consents returns the authenticated user's active
  consents joined to client name/id; DELETE /consents/:id revokes one
  after an ownership check so a user cannot take back someone else's
  grant. Both endpoints require the __Host-qauth_session cookie.
- Developer portal: a minimal /consents page lists each row (name,
  client_id, scopes, granted timestamp) with a single Revoke button.
  Uses credentials:'include' so the signed session cookie reaches the
  auth-server regardless of deployment origin.

Scope intentionally kept minimal per issue guidance (list + revoke, no
UX polish).
@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Apr 24, 2026

View your CI Pipeline Execution ↗ for commit b553605

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

☁️ Nx Cloud last updated this comment at 2026-04-24 08:43:14 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 introduces a browser-based session and consent flow for OAuth 2.1, including server-rendered login and consent pages, a revocation API, and a management UI in the developer portal. Feedback focuses on security and efficiency: a potential open-redirect bypass in the return URL validation was identified, along with brittle query string parsing in the authorization route. Additionally, the database repository for consents contains a race condition in its upsert logic, and the consent listing and revocation endpoints could be optimized to avoid N+1 queries and inefficient ownership checks.

// Disallow absolute URLs, protocol-relative, or anything that doesn't
// start with a single `/`. This keeps us off the open-redirector list.
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
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 isSafeReturnTo check is vulnerable to an open redirect bypass in some browsers if the path starts with /\. For example, /\\example.com might be interpreted as //example.com by the browser, leading to a redirect to an external domain. It is safer to explicitly check for both // and /\.

Suggested change
if (value.startsWith('//')) return false;
if (value.startsWith('//') || value.startsWith('/\\')) return false;

client.id
);
if (!canSkipConsent(existingConsent, client, scopes)) {
return reply.redirect(`/ui/consent${request.url.slice(request.url.indexOf('?'))}`, 302);
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

Extracting the query string using request.url.slice(request.url.indexOf('?')) is brittle. If the URL does not contain a ?, indexOf returns -1, and slice(-1) will return the last character of the path (e.g., /oauth/authorize becomes e), resulting in an invalid redirect to /ui/consente. While validation usually ensures parameters are present, the code should handle the absence of a query string gracefully.

          const queryIndex = request.url.indexOf('?');
          const queryString = queryIndex !== -1 ? request.url.slice(queryIndex) : '';
          return reply.redirect('/ui/consent' + queryString, 302);

Comment on lines +68 to +78
const [existing] = await invoker
.select()
.from(oauthConsents)
.where(
and(
eq(oauthConsents.userId, userId),
eq(oauthConsents.oauthClientId, oauthClientId),
isNull(oauthConsents.revokedAt)
)
)
.limit(1);
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

This implementation of upsertGrant performs a read followed by a conditional update or insert, which is susceptible to race conditions. If two concurrent requests attempt to grant consent for the same user and client, one may fail with a unique constraint violation or result in inconsistent state. Per the general rules, this should be handled using an atomic operation (e.g., PostgreSQL's ON CONFLICT) or wrapped in a database transaction with appropriate locking (e.g., FOR UPDATE).

References
  1. To prevent race conditions in read-then-update scenarios, 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 +55 to +66
const consents = await Promise.all(
rows.map(async (row) => {
const client = await fastify.repositories.oauthClients.findById(row.oauthClientId);
return {
id: row.id,
clientId: client?.clientId ?? 'unknown',
clientName: client?.name ?? 'Unknown application',
scopes: row.scopes,
grantedAt: row.grantedAt,
};
})
);
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

This loop performs an N+1 query by calling findById for each consent row. While the number of consents per user is likely small, it is better to optimize this by joining the oauth_clients table in the repository or using a single query to fetch all relevant clients in bulk.

Comment on lines +93 to +97
const rows = await fastify.repositories.oauthConsents.listActiveForUser(session.userId);
const owned = rows.find((r) => r.id === id);
if (!owned) {
throw new NotFoundError('OAuthConsent', id);
}
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

Fetching all active consents for a user to check ownership of a single ID is inefficient, especially as the number of grants grows. It is better to perform the ownership check directly in the revoke query by including userId in the WHERE clause (e.g., WHERE id = :id AND user_id = :userId).

@EsTharian EsTharian merged commit 74d8805 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): user consent screen on /oauth/authorize

1 participant