Skip to content

Address P0/P1 code-quality findings + auto-refresh on token expiry#80

Merged
reverentgeek merged 9 commits into
mainfrom
dn-p0-p1-fixes-refresh
May 6, 2026
Merged

Address P0/P1 code-quality findings + auto-refresh on token expiry#80
reverentgeek merged 9 commits into
mainfrom
dn-p0-p1-fixes-refresh

Conversation

@reverentgeek
Copy link
Copy Markdown
Collaborator

Summary

Addresses the P0 and P1 findings from the auth/api/app code-quality review and adds automatic access-token refresh in the relying party.

apps/auth

  • CSRF protection on all interaction POST routes via csrf-csrf (double-submit, scoped per interaction UID); hidden _csrf input added to login/confirm/cancel forms.
  • Login rate limit via express-rate-limit (default 10 attempts / 15 min / IP, configurable through LOGIN_RATE_LIMIT_* env vars).
  • Replaced the five eslint-disabled anys in apps/auth/src/index.ts with a typed local oidc-provider module declaration and a typed parseTokenBody helper.
  • Demo credentials block in views/interaction.ejs is now gated to non-production.

apps/api

  • New requireScope middleware enforces accounts:read on the FDX accounts router.
  • Account ownership (IDOR) fix: every account row carries customerId; routes resolve req.user.sub and 404 on not-owned (no existence leak); list endpoint filters to the authenticated user.
  • getCurrentCustomer(userId) derives from the JWT subject — customer-123 hardcode removed. Demo fixtures rebound to user_123 to match the auth sub.
  • Standardized FDX error shape ({ code, message, debugMessage? }) via utils/errors.ts; new asyncHandler wrapper; routes typed as AuthenticatedRequest.

apps/app

  • All cookies now signed (signed: true + req.signedCookies).
  • /debug/tokens masks tokens to first/last 4 chars and 404s in production.
  • /token view receives a validated absolute jwksUrl instead of rendering raw OP_ISSUER.
  • OIDC nonce generated, persisted in the signed oidc cookie, and validated on callback.
  • RP-initiated logout against the OP's end_session_endpoint (with id_token_hint, state, post_logout_redirect_uri); local cookies cleared up front.
  • Auto-refresh on expired access token: new getValidAccessToken(req, res) decodes exp with a 30s skew buffer, refreshes via client.refreshTokenGrant when expired, and atomically writes new tokens to the signed cookie before returning the access token. /api-call is proactive + reactive (one 401-retry-after-refresh fallback).

Test plan

  • pnpm lint passes
  • pnpm build passes for all four workspace projects
  • Auto-refresh expiry-detection logic verified against fresh / stale / near-expiry / malformed / no-exp JWTs
  • End-to-end smoke: log in via app, force access-token expiry (short TTL or system-clock advance), hit /api-call, confirm cookies rotate and call returns 200
  • Login flow still completes with CSRF tokens injected (interaction.ejs)
  • Login rate limit returns 429 after configured threshold
  • FDX endpoints return 403 with insufficient_scope when token lacks accounts:read
  • FDX endpoints return 404 when an authenticated user requests another user's account
  • RP-initiated logout redirects through the OP's end_session_endpoint and lands back at app
  • /debug/tokens returns 404 with NODE_ENV=production

🤖 Generated with Claude Code

reverentgeek and others added 9 commits May 5, 2026 11:13
Introduce small per-app helpers that the route refactors depend on:

- utils/errors.ts: FDX-compliant error builder + curated set of
  reusable error responses ({ code, message, debugMessage? }).
- utils/asyncHandler.ts: wraps async route handlers so rejected
  promises propagate to Express's error handler.
- utils/auth.ts: AuthenticatedRequest type, getSubject helper, and
  requireScope middleware for selectively enforcing OAuth scopes
  per-route. Augments Express's Request via global declaration so
  req.user is typed everywhere without per-route casts.
…okup

getCurrentCustomer now requires the authenticated user's id (the JWT
sub claim) instead of always returning the first record. This removes
the cross-customer data leak where any authenticated user resolved to
"customer-123" regardless of identity.

The first customer fixture is updated so its customerId matches the
demo user's sub ("user_123" - see apps/auth/src/index.ts USERS map),
allowing the demo flow to continue working end-to-end after the lookup
becomes identity-driven.
Add a customerId field to every account record and update the
repository so account access is always scoped by the authenticated
user. getAccounts now requires a customerId and filters to that
customer; getAccountForCustomer pairs a lookup with an ownership
check and treats not-owned identically to not-found so we don't leak
the existence of other customers' accounts.

All demo accounts are assigned to the demo user ("user_123") so the
existing happy-path flow keeps working after the route handlers move
to the ownership-aware repository helpers.
P0: protect all interaction POST routes (login/confirm/cancel) with the
maintained `csrf-csrf` double-submit cookie middleware and embed a hidden
CSRF token in every interaction form. The CSRF session is bound to the
interaction UID so each login flow gets a uniquely-scoped token.

P1: rate-limit POST /interaction/:uid/login at 10 attempts per 15 minutes
per IP via `express-rate-limit`. The limiter renders a friendly EJS error
in production and exposes `LOGIN_RATE_LIMIT_*` env vars for tuning.

P1: replace the five eslint-disabled `any` annotations in apps/auth with
narrow types from a new minimal `oidc-provider` module declaration, and
swap the response interception body/chunk over to `unknown` with a typed
parser.

P1: hide the development credential block in views/interaction.ejs unless
NODE_ENV !== 'production' (exposed via `app.locals.isProduction`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Route handlers now derive the caller from req.user.sub and reject
account access where the caller does not own the account, fixing the
IDOR on every /accounts/:accountId, /contact, /statements,
/statements/:statementId, /transactions, /payment-networks, and
/asset-transfer-networks endpoint. The list endpoint is filtered to
the authenticated user.

Also:
- Wrap every async handler in asyncHandler so rejected promises
  propagate to Express's error middleware instead of falling through
  silently.
- Replace the ad-hoc { error, details } and mixed { code, error }
  shapes with the centralized FDX { code, message, debugMessage? }
  helpers so all routes return a consistent error contract.
- Type handlers as AuthenticatedRequest instead of bare Request.
Apply requireScope("accounts:read") in front of the accounts router
so the JWT's scope claim is checked once, centrally, before any
account/data route runs. /customers/current is left without the
extra scope guard because resolving the authenticated user only
needs the openid scope (already implicit in any authenticated
session).

Also small cleanups:
- Pull AuthenticatedRequest in from utils/auth so the JWT middleware
  uses the canonical type and Express Request is augmented globally.
- Rewrite the 404 handler to return the FDX error shape.
- Add the missing NextFunction parameter to the global error handler
  so Express recognizes its 4-arg signature.
P0 fixes:
* Sign httpOnly cookies (`signed: true`) and read tokens via
  `req.signedCookies`. Cookie-parser was already initialized with
  COOKIE_SECRET; the cookies just weren't being signed.
* Mask access/refresh/id tokens to first/last 4 chars on /debug/tokens
  and gate the route behind `NODE_ENV !== 'production'`. Decoded payload
  is still shown for development debugging.
* Harden the JWKS link in the token inspector: pass an absolute, validated
  URL from the route handler instead of rendering `process.env.OP_ISSUER`
  directly into an `href`. Add `rel="noopener noreferrer"`.

P1 fixes:
* Generate an OIDC `nonce` in /login, persist it alongside state and PKCE
  verifier in the signed `oidc` cookie, and validate it via
  `expectedNonce` on `authorizationCodeGrant` in /callback.
* Replace the local-only logout TODO with proper RP-initiated logout:
  redirect to the auth server's `end_session_endpoint` with
  `id_token_hint`, `client_id`, `state`, and `post_logout_redirect_uri`.
  Local cookies are still cleared up front so the local session is gone
  even if the auth server is unreachable.

Auto-refresh feature:
* New `getValidAccessToken(req, res)` helper:
  - reads tokens from the signed cookie,
  - decodes the access token (`exp` claim) with a 30s clock-skew buffer,
  - if expired, calls openid-client's `refreshTokenGrant` with the
    resource indicator,
  - atomically writes the new token set back to the signed cookie before
    returning the new access token.
* /api-call uses the helper proactively, then falls back to one
  refresh-and-retry when the API returns 401 despite a non-expired token
  (covers clock skew, server-side revocation, etc.).

Verified by:
* `pnpm --filter @apps/app build` and `npx eslint apps/app` both pass.
* Standalone test script for the expiry detection logic confirms
  fresh / stale / within-skew / malformed / no-exp tokens all classify
  correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@reverentgeek reverentgeek self-assigned this May 6, 2026
@reverentgeek reverentgeek marked this pull request as ready for review May 6, 2026 19:46
@reverentgeek reverentgeek merged commit 995b949 into main May 6, 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.

1 participant