Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ Response:

For DPoP-bound tokens (Solid-OIDC compliant), include a DPoP proof header.

#### Refreshing a token

`/idp/credentials` tokens expire after 3600s. To keep an active session
alive without re-sending the password, slide the token forward with a
**still-valid** token:

```bash
curl -X POST http://localhost:4443/idp/refresh \
-H "Authorization: Bearer YOUR_CURRENT_TOKEN"
```

Returns the same shape as `/idp/credentials` (a fresh `access_token`,
`expires_in: 3600`, same `webid`). Clients should refresh **proactively** —
e.g. at ~80% of the TTL — so a session lasts as long as the app is in use;
an idle hour still ends it, preserving the short-TTL security posture.

Only tokens this IdP issued can be refreshed (the token is verified against
the server's own signing keys), and only within an absolute cap measured
from the original password grant — a refresh chain maxes out at 24h by
default, so a leaked token can't be renewed forever. Override the cap with
`createServer({ refreshMaxAge: <seconds> })`. `401 invalid_grant` means the
chain has aged out and the user must sign in again.

### Passkey Authentication (v0.0.77+)

Enable passwordless login with WebAuthn/FIDO2:
Expand Down
105 changes: 105 additions & 0 deletions src/idp/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export async function handleCredentials(request, reply, issuer) {
webid: account.webId,
iat: now,
exp: now + expiresIn,
// Original auth time — the moment the password was actually presented.
// Preserved across /idp/refresh so a refresh chain can be capped
// absolutely (a stolen token can't be renewed forever). See #587.
oat: now,
jti: crypto.randomUUID(),
client_id: 'credentials_client',
scope: 'openid webid',
Expand Down Expand Up @@ -155,6 +159,107 @@ export async function handleCredentials(request, reply, issuer) {
return response;
}

// 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 on lines +162 to +165

/**
* Handle POST /idp/refresh (#587)
*
* Slides a still-valid IdP Bearer token forward: a client refreshes
* proactively (e.g. at 80% of TTL) so an active session outlives the fixed
* 3600s credential TTL, while an idle hour still ends it. Only tokens THIS
* IdP issued (verified against our JWKS) can be refreshed — not arbitrary
* credentials — and only within the absolute chain cap from the original
* grant.
*
* Auth: Authorization: Bearer <current, unexpired token>.
* Response: same shape as POST /idp/credentials.
*/
export async function handleRefresh(request, reply, issuer, options = {}) {
const maxAge = options.refreshMaxAge ?? DEFAULT_REFRESH_MAX_AGE;

const authz = request.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(authz);
if (!m) {
return reply.code(401).send({
error: 'invalid_token',
error_description: 'A valid Bearer token is required to refresh',
});
}
const token = m[1].trim();

// Verify against our own JWKS: refresh renews IdP-issued tokens only.
let payload;
try {
const jwks = await getJwks();
const keyStore = jose.createLocalJWKSet({
keys: jwks.keys.map(({ d, p, q, dp, dq, qi, ...pub }) => pub), // public halves only
});
({ payload } = await jose.jwtVerify(token, keyStore, { issuer }));
} catch (err) {
return reply.code(401).send({
error: 'invalid_token',
error_description: `Token is expired or not issued by this server: ${err.message}`,
});
Comment on lines +202 to +205
}

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 +208 to +213

// Absolute chain cap from the original grant. Tokens minted before #587
// have no `oat`; fall back to `iat` (conservative — caps from issuance).
const now = Math.floor(Date.now() / 1000);
const originalAuth = typeof payload.oat === 'number' ? payload.oat : payload.iat;
if (typeof originalAuth === 'number' && now - originalAuth >= maxAge) {
return reply.code(401).send({
error: 'invalid_grant',
error_description: 'Refresh chain has reached its maximum age; sign in again',
});
}

// Mint a fresh token for the same subject, preserving the original grant
// time so the cap is honored across the whole chain.
const expiresIn = 3600;
const jwks = await getJwks();
const signingKey = jwks.keys[0];
const signingAlg = signingKey.alg || 'ES256';
const privateKey = await jose.importJWK(signingKey, signingAlg);

const tokenPayload = {
iss: issuer,
sub: payload.sub,
aud: 'solid',
webid: payload.webid,
iat: now,
exp: now + expiresIn,
oat: originalAuth ?? now,
jti: crypto.randomUUID(),
client_id: 'credentials_client',
scope: payload.scope || 'openid webid',
};

const accessToken = await new jose.SignJWT(tokenPayload)
.setProtectedHeader({ alg: signingAlg, kid: signingKey.kid })
.sign(privateKey);

reply.header('Cache-Control', 'no-store');
reply.header('Pragma', 'no-cache');

return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: expiresIn,
webid: payload.webid,
id: payload.sub,
};
}

/**
* Validate a DPoP proof and return the JWK thumbprint
* @param {string} proof - The DPoP proof JWT
Expand Down
18 changes: 17 additions & 1 deletion src/idp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
handleChangePassword,
handleDeleteAccount,
handleAccountDeleteForm,
handleRefresh,
setNoCacheClickjackHeaders,
} from './credentials.js';
import { handleExportAccount } from './export.js';
Expand All @@ -51,7 +52,7 @@ import { landingPage, accountDeletePage } from './views.js';
* purposes. Defaults to 'unknown' inside the export handler.
*/
export async function idpPlugin(fastify, options) {
const { issuer, inviteOnly = false, singleUser = false, singleUserName = null, jssVersion } = options;
const { issuer, inviteOnly = false, singleUser = false, singleUserName = null, jssVersion, refreshMaxAge } = options;

if (!issuer) {
throw new Error('IdP requires issuer URL');
Expand Down Expand Up @@ -299,6 +300,21 @@ export async function idpPlugin(fastify, options) {
return handleChangePassword(request, reply);
});

// POST refresh - slide a still-valid Bearer token forward (#587), so an
// active session outlives the fixed 3600s TTL without re-sending the
// password, while an idle hour still ends it. Rate limited like the rest.
fastify.post('/idp/refresh', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request) => request.ip
}
}
}, async (request, reply) => {
return handleRefresh(request, reply, issuer, { refreshMaxAge });
});

// DELETE account - authenticated owner deletes their own account (#352).
// Single-user mode is rejected at the handler (deletion would leave the
// server with no IDP account until re-seed; CLI is the operator path).
Expand Down
1 change: 1 addition & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export function createServer(options = {}) {
} catch { /* keep 'unknown' */ }
fastify.register(idpPlugin, {
issuer: idpIssuer, inviteOnly, singleUser, singleUserName, jssVersion,
refreshMaxAge: options.refreshMaxAge, // #587: absolute /idp/refresh chain cap
});
}

Expand Down
162 changes: 162 additions & 0 deletions test/idp-refresh.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* POST /idp/refresh — slide a still-valid IdP Bearer token forward (#587).
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import * as jose from 'jose';
import { createServer } from '../src/server.js';
import fs from 'fs-extra';
import { createServer as createNetServer } from 'net';

const TEST_HOST = 'localhost';

function getAvailablePort() {
return new Promise((resolve, reject) => {
const srv = createNetServer();
srv.on('error', reject);
srv.listen(0, TEST_HOST, () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
});
}

async function createPod(baseUrl, name, email, password) {
const res = await fetch(`${baseUrl}/.pods`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const body = await res.json().catch(() => ({}));
assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
return body;
}

async function login(baseUrl, email, password) {
const res = await fetch(`${baseUrl}/idp/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const body = await res.json().catch(() => ({}));
assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
return body;
}

function refresh(baseUrl, token) {
return fetch(`${baseUrl}/idp/refresh`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
}

describe('POST /idp/refresh (#587)', () => {
let server;
let baseUrl;
let originalDataRoot;
const DATA_DIR = './test-data-idp-refresh';

before(async () => {
originalDataRoot = process.env.DATA_ROOT;
await fs.remove(DATA_DIR);
await fs.ensureDir(DATA_DIR);
const port = await getAvailablePort();
baseUrl = `http://${TEST_HOST}:${port}`;
server = createServer({
logger: false,
root: DATA_DIR,
idp: true,
idpIssuer: baseUrl,
forceCloseConnections: true,
});
await server.listen({ port, host: TEST_HOST });
});

after(async () => {
await server.close();
await fs.remove(DATA_DIR);
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
else process.env.DATA_ROOT = originalDataRoot;
});

it('rejects a request without a Bearer token (401)', async () => {
const res = await fetch(`${baseUrl}/idp/refresh`, { method: 'POST' });
assert.strictEqual(res.status, 401);
});

it('rejects a garbage token (401)', async () => {
const res = await refresh(baseUrl, 'not.a.jwt');
assert.strictEqual(res.status, 401);
});

it('issues a fresh token from a valid one, for the same WebID', async () => {
const id = `alice${Date.now()}`;
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
const first = await login(baseUrl, `${id}@example.com`, 'oldpassword123');

const res = await refresh(baseUrl, first.access_token);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.token_type, 'Bearer');
assert.strictEqual(body.expires_in, 3600);
assert.strictEqual(body.webid, first.webid);
assert.ok(body.access_token && body.access_token !== first.access_token, 'a new token is issued');

// The fresh token actually authenticates a protected request.
const whoami = await fetch(`${baseUrl}/${id}/`, {
headers: { Authorization: `Bearer ${body.access_token}` },
});
assert.ok(whoami.status < 400, `refreshed token should authenticate, got ${whoami.status}`);
});

it('preserves the original-auth-time (oat) claim across a refresh chain', async () => {
const id = `bob${Date.now()}`;
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
const first = await login(baseUrl, `${id}@example.com`, 'oldpassword123');
const firstOat = jose.decodeJwt(first.access_token).oat;
assert.strictEqual(typeof firstOat, 'number', 'credentials token carries oat');

const r1 = await (await refresh(baseUrl, first.access_token)).json();
const r2 = await (await refresh(baseUrl, r1.access_token)).json();
assert.strictEqual(jose.decodeJwt(r1.access_token).oat, firstOat);
assert.strictEqual(jose.decodeJwt(r2.access_token).oat, firstOat, 'oat is stable across the chain');
});

it('refuses to refresh once the chain exceeds refreshMaxAge', async () => {
// Fresh server with a 0s cap: any token is already past the absolute age.
const port = await getAvailablePort();
const capBase = `http://${TEST_HOST}:${port}`;
const capDir = './test-data-idp-refresh-cap';
await fs.remove(capDir);
const capServer = createServer({
logger: false, root: capDir, idp: true, idpIssuer: capBase,
refreshMaxAge: 0, forceCloseConnections: true,
});
await capServer.listen({ port, host: TEST_HOST });
try {
const id = `carol${Date.now()}`;
await createPod(capBase, id, `${id}@example.com`, 'oldpassword123');
const first = await login(capBase, `${id}@example.com`, 'oldpassword123');
const res = await refresh(capBase, first.access_token);
assert.strictEqual(res.status, 401);
const body = await res.json();
assert.strictEqual(body.error, 'invalid_grant');
} finally {
await capServer.close();
await fs.remove(capDir);
}
});

it('will not refresh a token this server did not issue (401)', async () => {
// A well-formed JWT signed by a stranger key must not be refreshable.
const { privateKey } = await jose.generateKeyPair('ES256');
const forged = await new jose.SignJWT({ webid: `${baseUrl}/eve/profile/card#me`, sub: 'eve' })
.setProtectedHeader({ alg: 'ES256' })
.setIssuer(baseUrl)
.setExpirationTime('1h')
.sign(privateKey);
const res = await refresh(baseUrl, forged);
assert.strictEqual(res.status, 401);
});
});