Skip to content

feat(idp): POST /idp/refresh — token refresh for the programmatic flow (#587) - #591

Open
melvincarvalho wants to merge 1 commit into
gh-pagesfrom
plugin-idp-refresh-587
Open

feat(idp): POST /idp/refresh — token refresh for the programmatic flow (#587)#591
melvincarvalho wants to merge 1 commit into
gh-pagesfrom
plugin-idp-refresh-587

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

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

POST /idp/refresh
Authorization: Bearer <current, unexpired token>

→ { access_token, token_type: "Bearer", expires_in: 3600, webid }

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

  • Only this server's own tokens refresh. The presented token is verified against the IdP's JWKS (public halves), not merely accepted as "some valid credential" — a NIP-98 signature or a foreign JWT can't be turned into an IdP session. Test covers a well-formed JWT signed by a stranger key → 401.
  • Absolute chain cap. Credentials tokens now carry an oat (original auth time) claim; refresh preserves it and refuses once now - oat ≥ refreshMaxAge (default 24h, createServer({ refreshMaxAge }) to override). A leaked token can't be renewed forever. Tokens minted before this change lack oat and fall back to iat (conservative). Test drives a 0s cap → invalid_grant.
  • Rate limited (10/min/IP) consistent with the other /idp/* endpoints.

Acceptance

  • POST /idp/refresh issues a fresh token from a valid one (401 on expired/invalid/foreign)
  • Optional absolute chain cap, documented (refreshMaxAge)
  • Rate limit consistent with /idp/credentials
  • docs/authentication.md updated

6 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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via refreshMaxAge (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 thread src/idp/credentials.js
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 thread src/idp/credentials.js
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 thread src/idp/credentials.js
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;
Comment thread test/idp-refresh.test.js
});
assert.ok(whoami.status < 400, `refreshed token should authenticate, got ${whoami.status}`);
});

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.

idp: token refresh for the programmatic flow — POST /idp/refresh

2 participants