feat(idp): POST /idp/refresh — token refresh for the programmatic flow (#587) - #591
Open
melvincarvalho wants to merge 1 commit into
Open
feat(idp): POST /idp/refresh — token refresh for the programmatic flow (#587)#591melvincarvalho wants to merge 1 commit into
melvincarvalho wants to merge 1 commit into
Conversation
The programmatic flow (POST /idp/credentials -> Bearer) is the only auth path a vanilla-JS app can realistically speak, and its 3600s tokens have no renewal story: mid-session every request starts 401ing. Found by plugin zero (Tideholm live on nostr.social) and inherited by bridge. POST /idp/refresh takes a still-valid, THIS-server-issued Bearer token (verified against our JWKS — not arbitrary credentials) and returns a fresh one for the same WebID. Clients refresh proactively (~80% of TTL) so an active session outlives the fixed TTL while an idle hour still ends it. An 'oat' (original auth time) claim, minted into credentials tokens and preserved across the chain, caps the whole chain absolutely (24h default, refreshMaxAge override) so a leaked token can't be renewed forever. Tests: fresh token authenticates; oat stable across a chain; chain cap enforced (invalid_grant); forged/foreign token refused; no-Bearer 401. docs/authentication.md updated.
There was a problem hiding this comment.
Pull request overview
Adds a programmatic token refresh capability to JSS’s IdP so browser-based apps using POST /idp/credentials (Bearer flow) can renew tokens proactively without re-sending user passwords mid-session, while preserving the existing short TTL posture.
Changes:
- Introduces
POST /idp/refresh, rate-limited like other/idp/*endpoints, issuing a new Bearer access token from a still-valid one. - Adds an
oat(original auth time) claim to credentials tokens and enforces an absolute refresh-chain cap viarefreshMaxAge(default 24h). - Updates docs and adds a dedicated test suite for refresh behavior and security constraints.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/idp-refresh.test.js | Adds tests covering refresh happy path, missing/invalid/foreign tokens, and refreshMaxAge behavior. |
| src/server.js | Wires refreshMaxAge from createServer() options into the IdP plugin. |
| src/idp/index.js | Registers the new POST /idp/refresh route with rate limiting and delegates to the handler. |
| src/idp/credentials.js | Adds oat to minted tokens and implements handleRefresh() logic and refreshMaxAge enforcement. |
| docs/authentication.md | Documents POST /idp/refresh, proactive refresh guidance, and refreshMaxAge. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+208
to
+213
| if (!payload.webid || !payload.sub) { | ||
| return reply.code(401).send({ | ||
| error: 'invalid_token', | ||
| error_description: 'Token lacks the webid/sub claims required to refresh', | ||
| }); | ||
| } |
Comment on lines
+202
to
+205
| return reply.code(401).send({ | ||
| error: 'invalid_token', | ||
| error_description: `Token is expired or not issued by this server: ${err.message}`, | ||
| }); |
Comment on lines
+162
to
+165
| // Absolute lifetime of a refresh chain: a token may be renewed only within | ||
| // this window of its ORIGINAL password grant (oat), so a leaked token can't | ||
| // be kept alive indefinitely. 24h; overridable via createServer or env. | ||
| const DEFAULT_REFRESH_MAX_AGE = 24 * 60 * 60; |
| }); | ||
| assert.ok(whoami.status < 400, `refreshed token should authenticate, got ${whoami.status}`); | ||
| }); | ||
|
|
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.
Closes #587. Found by plugin zero in production: Tideholm and bridge both use
POST /idp/credentials→ Bearer as their "sign in with your pod" flow, and the token dies at its fixed 3600s TTL with no way to renew short of re-sending the password — mid-session, every request starts 401ing.The endpoint
Same response shape as
/idp/credentials. Clients refresh proactively (e.g. at 80% of TTL): an active session outlives the fixed TTL, while an idle hour still ends it — the short-TTL security posture is preserved.Security
oat(original auth time) claim; refresh preserves it and refuses oncenow - oat ≥ refreshMaxAge(default 24h,createServer({ refreshMaxAge })to override). A leaked token can't be renewed forever. Tokens minted before this change lackoatand fall back toiat(conservative). Test drives a 0s cap →invalid_grant./idp/*endpoints.Acceptance
POST /idp/refreshissues a fresh token from a valid one (401 on expired/invalid/foreign)refreshMaxAge)/idp/credentials6 new tests in
test/idp-refresh.test.js; full suite 1022/1022. The Tideholm/bridge clients can now switch from the cookie bridge to proactive refresh. Refs #206.