Repository navigation
feat(auth): refresh-token rotation, session revocation, password fallback and device flow - #1871
Merged
github-actions[bot] merged 4 commits intoOct 21, 2026
Conversation
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
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:ServerSessionServicesession token, opaque, with a 30 min sliding TTL, now limited tosession.scopes;rt_…tokens that rotate on every use, stored only as SHA-256 digests.ServerSessionServicegains three things: per-sessionscopes,rotateSessionToken()(re-keys the access token while keeping the sessionid), andonRevoke()listeners. Every existing revocation path (logout, revoke-all, revoke-others, concurrent-session eviction, the legacy/sessions/revoke*routes inbackend/server.ts) therefore also revokes the session's refresh-token family.#1776: Refresh-token rotation
POST /api/v1/auth/oauth/tokenwithgrant_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.invalid_grant, an audit entry is written, andauth.refresh_token_reuse_detectedis published. A failure to publish the event never undoes the revocation.#1777: Session listing and remote revocation
GET /api/v1/auth/sessions(scopesessions: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 showscurrent,authMethod,clientId,scopes, coarse device/location, timestamps and both expiries. Token material is never included.DELETE /api/v1/auth/sessions/:idandPOST /sessions/revoke-others(scopesessions:write), plusPOST /logout. Revocation is immediate: the revoked device's next request gets401and its next refresh getsinvalid_grant. Another user's session id returns404, so ids can't be probed.revokeSessionnow also revokes sessions whose access token has expired (previously it only acted onactivesessions). 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:
PasswordCredentialServiceand two routes:POST /api/v1/auth/login/passwordreturns the same headers and body shape as a passkey login.PUT /api/v1/auth/password(scopeauth:manage); changing an existing password requires the current one.Hashing uses the repo's
argon2Kdf.tswith constant-time comparison. Unknown users are checked against a dummy hash, and an unknown user and a wrong password get the identical401. 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/verifyissues the session throughSessionTokenServiceand also returnsX-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_authorizationreturnsdevice_code,user_code(XXXX-XXXXfromBCDFGHJKLMNPQRSTVWXZ),verification_uri,verification_uri_complete,expires_in(600) andinterval(5).GET /oauth/device?user_code=…, then callsPOST /oauth/device/approve(optionally narrowingscope) orPOST /oauth/device/deny. All three needauth:manageand keep the CSRF check./oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codereturnsauthorization_pending,slow_down(the interval grows by 5 s),access_denied,expired_tokenorinvalid_grant, and returns tokens exactly once.*andauth:manageare never delegable;403 AUTH_INSUFFICIENT_SCOPEandWWW-Authenticate: Bearer error="insufficient_scope".DEVICE_FLOW_CLIENTS(defaultsubtrackr-cli) andDEVICE_FLOW_VERIFICATION_URI.docs/oauth-device-flow.md.Shared changes
createRequireAuth({ scope })middleware acceptsAuthorization: BearerorX-Session-Token, re-validates the session on every request, and follows RFC 6750 error semantics. It is reusable by resource routers.AUTH_*error codes with HTTP mappings inapiResponse.ts, matchingAuthErrorfactories, and a new event payload type.createApiServermountscreateSessionRouter()at/api/v1/auth. Only/api/v1/auth/oauth/tokenand/api/v1/auth/oauth/device_authorizationare 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/sessions-and-tokens.mdanddocs/oauth-device-flow.md, linked fromdocs/AUTHENTICATION.md.How it was tested
The 9 failures are all in
argon2Kdf.test.ts. They fail the same way onmain(RangeError: Invalid scrypt params: memory limit exceededfor itsFAST_PARAMS). Onmainthe same command gives2 failed, 9 passed suites / 10 failed, 158 passed tests. The tenth failure there isauthRouter.test.ts› "registers a passkey … and logs in with it", which broke when device binding (#1766) started includingaccept-languagein the fingerprint.fetch()sendsaccept-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:PasswordCredentialService.test.ts: hash-only storage, identical errors, policy, change-requires-current, lockout window.DeviceAuthorizationService.test.ts:sessionRouter.test.ts(real Express server over HTTP):401and its refresh isinvalid_grant; cross-user404; revoke-others and logout;403outside 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:
strict: true(a temporary tsconfig listing them). The only errors reported come from unchanged files the auth index pulls in:cmkConfigController.ts(missingkmsProviderexport),AccountRecoveryService.ts/LoginAnomalyService.ts(event types) andlogging.ts. I did not run the rootnpx tsc --noEmit, because AGENTS.md documents ~624 pre-existing errors and my local install lacked the Expo packages it needs.backend/anddocs/are in the root ESLintignorePatternsand.prettierignore, sonpm run lint/format:checkdon't cover them. I formatted the new files with the repo's Prettier config anyway, andeslint --no-ignoreon them reports 0 errors (onlyno-explicit-anywarnings in test helpers). Commits pass the huskylint-staged+commitlinthooks.createCsrfMiddleware(same skip list ascreateApiServer) plus both routers:device_authorization→ 200 without a CSRF token;/oauth/token→ 400invalid_grant(reached the handler);/oauth/device/approveand/login/passwordwithout a token → 403CSRF_TOKEN_MISMATCH.Environment notes:
pnpm install --frozen-lockfilefailed 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.expressandsupertestare imported by backend tests but are not inpackage.json/pnpm-lock.yaml, so the HTTP suites (existing and new) may not resolveexpressin CI until that is fixed separately.createApiServer()for two reasons, both outside this PR's changes:mainhas merge-conflict leftovers inbackend/server/createApiServer.ts(barefeat/stellar-wave-issues/mainlines), andbackend/services/shared/pagination.tsdeclaressigntwice.Pull Request Checklist
Quality Gates (All must pass before merge)
eslint --no-ignoreclean)tschas pre-existing errors)argon2Kdf.test.tsfailures, same asmain)Additional Requirements
Closes #1776
Closes #1777
Closes #1778
Closes #1779