Skip to content

feat(auth): refresh-token rotation, session revocation, password fallback and device flow - #1871

Merged
github-actions[bot] merged 4 commits into
Smartdevs17:mainfrom
Itodo-S:feat/auth-sessions-refresh-rotation-device-flow-1776-1779
Oct 21, 2026
Merged

github-actions[bot] merged 4 commits into
Smartdevs17:mainfrom
Itodo-S:feat/auth-sessions-refresh-rotation-device-flow-1776-1779

Conversation

@Itodo-S

@Itodo-S Itodo-S commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR builds one session model shared by all four issues. Every sign-in (passkey, password, OAuth device flow) creates its session through a new SessionTokenService. Each session gets:

  • an access token: the existing ServerSessionService session token, opaque, with a 30 min sliding TTL, now limited to session.scopes;
  • exactly one refresh-token family: rt_… tokens that rotate on every use, stored only as SHA-256 digests.

ServerSessionService gains three things: per-session scopes, rotateSessionToken() (re-keys the access token while keeping the session id), and onRevoke() listeners. Every existing revocation path (logout, revoke-all, revoke-others, concurrent-session eviction, the legacy /sessions/revoke* routes in backend/server.ts) therefore also revokes the session's refresh-token family.

#1776: Refresh-token rotation

  • POST /api/v1/auth/oauth/token with grant_type=refresh_token (form-encoded or JSON) returns a new access token and a new refresh token. The previous pair stops working immediately. The session keeps its id.
  • Reuse detection: each family remembers the digest of every refresh token it issued. Presenting one that was already rotated away revokes the whole family and its session, including the newest tokens. The request gets invalid_grant, an audit entry is written, and auth.refresh_token_reuse_detected is published. A failure to publish the event never undoes the revocation.
  • Refresh tokens expire after 7 days idle and 30 days absolute. A refresh from a different device fingerprint is refused without consuming the token (consistent with Add device binding for sessions #1766). A session whose access token idled out can be resumed; a revoked one cannot.
  • Families past their absolute lifetime are swept by the existing 5-minute session sweep.

#1777: Session listing and remote revocation

  • GET /api/v1/auth/sessions (scope sessions:read) lists the sessions that can still be used: either the access token is live or the refresh token can still mint one. Revoked and fully lapsed sessions are excluded. Each entry shows current, authMethod, clientId, scopes, coarse device/location, timestamps and both expiries. Token material is never included.
  • DELETE /api/v1/auth/sessions/:id and POST /sessions/revoke-others (scope sessions:write), plus POST /logout. Revocation is immediate: the revoked device's next request gets 401 and its next refresh gets invalid_grant. Another user's session id returns 404, so ids can't be probed.
  • revokeSession now also revokes sessions whose access token has expired (previously it only acted on active sessions). Without this, revoking an idle session would have left its refresh token usable.

#1778: WebAuthn/passkey login (builds on #1334)

Already present from #1334 (unchanged here): WebAuthn registration and login ceremonies (PasskeyService, webauthn.ts), the /api/v1/auth/passkeys/* routes, challenge, origin, RP ID, UP/UV and counter checks, and a session issued on login.

Added in this PR:

  • Password fallback: PasswordCredentialService and two routes:

    • POST /api/v1/auth/login/password returns the same headers and body shape as a passkey login.
    • PUT /api/v1/auth/password (scope auth:manage); changing an existing password requires the current one.

    Hashing uses the repo's argon2Kdf.ts with constant-time comparison. Unknown users are checked against a dummy hash, and an unknown user and a wrong password get the identical 401. Five failures within 15 min lock the account for 15 min (429 + Retry-After).

  • Passkey login is now wired into the new session model: /passkeys/login/verify issues the session through SessionTokenService and also returns X-Refresh-Token.

  • Passkey management now requires auth:manage, so device-flow tokens can't add or remove passkeys.

  • Docs: a "Fallback to password" section with a client example in docs/passkeys.md.

  • Not included: native passkey UI in the React Native app. It needs a native WebAuthn module that isn't a dependency yet, so the client side is documented rather than built.

#1779: OAuth device authorization flow (RFC 8628)

  • POST /api/v1/auth/oauth/device_authorization returns device_code, user_code (XXXX-XXXX from BCDFGHJKLMNPQRSTVWXZ), verification_uri, verification_uri_complete, expires_in (600) and interval (5).
  • The signed-in user reviews the request with GET /oauth/device?user_code=…, then calls POST /oauth/device/approve (optionally narrowing scope) or POST /oauth/device/deny. All three need auth:manage and keep the CSRF check.
  • Token polling on /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code returns authorization_pending, slow_down (the interval grows by 5 s), access_denied, expired_token or invalid_grant, and returns tokens exactly once.
  • Scopes are honoured and enforced:
    • requested scopes must be registered for the client;
    • the approver can only narrow them, and only grant scopes they hold;
    • * and auth:manage are never delegable;
    • the issued session is limited to the granted scopes, also across refreshes;
    • routes reject anything else with 403 AUTH_INSUFFICIENT_SCOPE and WWW-Authenticate: Bearer error="insufficient_scope".
  • Device and user codes are stored only as HMAC-SHA256 digests and are single-use. Wrong user codes are rate-limited per approver.
  • Clients are configured via DEVICE_FLOW_CLIENTS (default subtrackr-cli) and DEVICE_FLOW_VERIFICATION_URI.
  • Documented in docs/oauth-device-flow.md.

Shared changes

  • createRequireAuth({ scope }) middleware accepts Authorization: Bearer or X-Session-Token, re-validates the session on every request, and follows RFC 6750 error semantics. It is reusable by resource routers.
  • New AUTH_* error codes with HTTP mappings in apiResponse.ts, matching AuthError factories, and a new event payload type.
  • createApiServer mounts createSessionRouter() at /api/v1/auth. Only /api/v1/auth/oauth/token and /api/v1/auth/oauth/device_authorization are added to the CSRF skip list. They are used by non-browser clients and carry no ambient credentials (the refresh token or device code is in the body). Password login, approval and session routes keep CSRF.
  • Docs: new docs/sessions-and-tokens.md and docs/oauth-device-flow.md, linked from docs/AUTHENTICATION.md.

How it was tested

npx jest --config jest.backend.config.js backend/services/auth
# Test Suites: 1 failed, 14 passed, 15 total
# Tests:       9 failed, 212 passed, 221 total

The 9 failures are all in argon2Kdf.test.ts. They fail the same way on main (RangeError: Invalid scrypt params: memory limit exceeded for its FAST_PARAMS). On main the same command gives 2 failed, 9 passed suites / 10 failed, 158 passed tests. The tenth failure there is authRouter.test.ts › "registers a passkey … and logs in with it", which broke when device binding (#1766) started including accept-language in the fingerprint. fetch() sends accept-language: *, so the test's bootstrap session never matched. This PR adds that header to the test's bootstrap request, and it passes again.

New suites (53 tests, all passing):

  • SessionTokenService.test.ts:
    • rotation, revival after access-token expiry, scope preservation, device-binding refusal;
    • reuse detection: family and session revoked, newest tokens dead, other sessions untouched, event published, revocation kept when the event bus throws;
    • idle and absolute expiry, revocation through logout / revoke-all / concurrent-limit eviction / idle sessions;
    • listing accuracy, cross-user revocation refused, sweep.
  • PasswordCredentialService.test.ts: hash-only storage, identical errors, policy, change-requires-current, lockout window.
  • DeviceAuthorizationService.test.ts:
    • response shape, no plaintext codes stored, invalid client / scope, non-delegable scopes;
    • pending → slow_down → tokens once; deny → access_denied; expiry → expired_token; foreign or unknown codes;
    • single-use user codes, case-insensitive entry, narrowing but not widening scopes, approver-scope bound, brute-force limit, sweep.
  • sessionRouter.test.ts (real Express server over HTTP):
    • passkey failure → password fallback, then passkey login → refresh;
    • rotation and replay over HTTP;
    • list → remote revoke → the revoked device's next request is 401 and its refresh is invalid_grant; cross-user 404; revoke-others and logout;
    • full device flow with scope enforcement: 403 outside the grant, no passkey management, no chained approvals, refresh keeps the scope, remote revocation of the device session; plus deny, expiry and invalid client / scope.

Coverage of the new files: 94.8% statements, 83% branches.

Other checks:

  • Type check: the new and changed files type-check cleanly under strict: true (a temporary tsconfig listing them). The only errors reported come from unchanged files the auth index pulls in: cmkConfigController.ts (missing kmsProvider export), AccountRecoveryService.ts / LoginAnomalyService.ts (event types) and logging.ts. I did not run the root npx tsc --noEmit, because AGENTS.md documents ~624 pre-existing errors and my local install lacked the Expo packages it needs.
  • Lint / format: backend/ and docs/ are in the root ESLint ignorePatterns and .prettierignore, so npm run lint / format:check don't cover them. I formatted the new files with the repo's Prettier config anyway, and eslint --no-ignore on them reports 0 errors (only no-explicit-any warnings in test helpers). Commits pass the husky lint-staged + commitlint hooks.
  • CSRF wiring: checked with a small script mounting createCsrfMiddleware (same skip list as createApiServer) plus both routers:
    • device_authorization → 200 without a CSRF token;
    • /oauth/token → 400 invalid_grant (reached the handler);
    • /oauth/device/approve and /login/password without a token → 403 CSRF_TOKEN_MISMATCH.

Environment notes:

  • pnpm install --frozen-lockfile failed locally on a registry fetch during pnpm's supply-chain check, so the tests ran with the pinned versions of jest / ts-jest / typescript / express installed separately.
  • express and supertest are imported by backend tests but are not in package.json / pnpm-lock.yaml, so the HTTP suites (existing and new) may not resolve express in CI until that is fixed separately.
  • I couldn't smoke-test the full createApiServer() for two reasons, both outside this PR's changes: main has merge-conflict leftovers in backend/server/createApiServer.ts (bare feat/stellar-wave-issues / main lines), and backend/services/shared/pagination.ts declares sign twice.

Pull Request Checklist

Quality Gates (All must pass before merge)

  • Lint: Code passes ESLint and Prettier checks (backend/docs are excluded by repo config; new files formatted and eslint --no-ignore clean)
  • Type Check: TypeScript compilation succeeds (new/changed files clean in strict mode; full-project tsc has pre-existing errors)
  • Tests: All tests pass (all new tests pass; 9 pre-existing argon2Kdf.test.ts failures, same as main)
  • Build: Project builds successfully (not run locally)
  • Rust Format: no contract changes
  • Rust Clippy: no contract changes
  • Rust Tests: no contract changes
  • Rust Build: no contract changes

Additional Requirements

  • New code has appropriate TypeScript types
  • No hardcoded secrets or credentials
  • New features have corresponding tests
  • Documentation updated if needed

Closes #1776
Closes #1777
Closes #1778
Closes #1779

Every sign-in now issues a session plus a refresh-token family through
SessionTokenService. Redeeming the current refresh token rotates both the
refresh token and the session's access token; presenting a token that was
already rotated away revokes the whole family and its session and
publishes auth.refresh_token_reuse_detected.

ServerSessionService gains per-session scopes, in-place access-token
rotation (stable session id) and revocation listeners, so every revocation
path also kills the refresh-token family. Only SHA-256 digests of refresh
tokens are stored.

Refs Smartdevs17#1776
…ervices

PasswordCredentialService stores Argon2id-style hashes, verifies in
constant time against a dummy hash for unknown users, requires the current
password to change it and locks accounts after repeated failures.

DeviceAuthorizationService implements the device code grant: HMAC-hashed
single-use device and user codes, expiry, slow_down interval widening,
per-client allowed scopes, approver-bounded scope narrowing and user-code
brute-force limiting. `*` and auth:manage are never delegable.

Refs Smartdevs17#1778, Smartdevs17#1779
Mount createSessionRouter at /api/v1/auth: password login and password
management, the OAuth token endpoint (refresh_token and device_code
grants), the device authorization and approval endpoints, and session
listing, remote revocation, revoke-others and logout. Routes enforce
scopes through createRequireAuth (401 invalid_token / 403
insufficient_scope).

Passkey login now issues its session through SessionTokenService and
returns X-Refresh-Token; passkey management requires auth:manage. The
OAuth token and device authorization endpoints are exempt from the
cookie-based CSRF check because they carry no ambient credentials.

The existing passkey HTTP test now sends the accept-language header that
fetch() adds, which the device-binding fingerprint from Smartdevs17#1766 includes.

Refs Smartdevs17#1776, Smartdevs17#1777, Smartdevs17#1778, Smartdevs17#1779
@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@Itodo-S Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@github-actions
github-actions Bot merged commit 9b1fff6 into Smartdevs17:main Oct 21, 2026
8 of 24 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.

Add OAuth device authorization flow Add WebAuthn/passkey login Add session listing and remote revocation Add refresh-token rotation

1 participant