diff --git a/.changeset/account-session-renewal.md b/.changeset/account-session-renewal.md new file mode 100644 index 0000000..7204e17 --- /dev/null +++ b/.changeset/account-session-renewal.md @@ -0,0 +1,17 @@ +--- +'@pdsjs/core': minor +--- + +Keep the account page signed in while it is in use, instead of asking for the +password on a fixed schedule. + +The session cookie carried a 12 hour expiry set at sign-in, and nothing moved +it. A console open all day was signed out mid-use, and a visit the next morning +always started with the password form. + +The cookie now lasts a week and any account page request renews it once a day of +its life is spent, so the session ends a week after the last visit rather than a +week after the sign-in. `PersonalDataServer.fetch` attaches the replacement, +beside the DPoP nonce it already refreshes for OAuth clients, so the pages, the +JSON API and the form posts all carry the session forward. A response that sets +the cookie itself, sign-in and sign-out, keeps its own value. diff --git a/docs/pds-decomposition.md b/docs/pds-decomposition.md index 95f9f22..c7b7d02 100644 --- a/docs/pds-decomposition.md +++ b/docs/pds-decomposition.md @@ -75,10 +75,10 @@ them on the other side of the seam, and as module functions taking `actorStorage` neither side carries them in a context. That left a 20-member context, all ports, config values, and closures over the class. -The factory exposes five functions inward: `readAccountSession`, -`readAccountUiState` and `accountAppResponse` for dispatch and the OAuth -consent flow, `accountApi` for the backup routes, and `listConnectedApps` for -its unit test, which builds the factory directly. +The factory exposes six functions inward: `readAccountSession`, +`renewAccountSession`, `readAccountUiState` and `accountAppResponse` for +dispatch and the OAuth consent flow, `accountApi` for the backup routes, and +`listConnectedApps` for its unit test, which builds the factory directly. ### The identity and migration residue, 580 lines, 17 members diff --git a/packages/core/src/handlers/account.js b/packages/core/src/handlers/account.js index d3af829..1c4825b 100644 --- a/packages/core/src/handlers/account.js +++ b/packages/core/src/handlers/account.js @@ -43,6 +43,7 @@ import { ACCOUNT_COOKIE, ACCOUNT_SESSION_TTL, accountCookie, + accountSessionDueForRenewal, } from '../session.js'; import { verifyAssertion, verifyRegistration } from '../webauthn.js'; @@ -130,6 +131,7 @@ function plausibleHost(value) { * @returns {{ * routes: import('../pds.js').Routes, * readAccountSession: (request: Request) => Promise, + * renewAccountSession: (request: Request) => Promise, * readAccountUiState: () => Promise, * accountAppResponse: (bootstrap?: Object|null, status?: number) => Response|null, * accountApi: (request: Request, url: URL, handler: (did: string, body: any) => Promise, options?: {write?: boolean}) => Promise, @@ -161,12 +163,12 @@ export function createAccountHandlers(ctx) { } = ctx; /** - * The DID of the account whose cookie session this request carries, or null - * when there is no valid session. + * The claims of the session cookie this request carries, or null when it + * carries none, or one this server did not sign. * @param {Request} request - * @returns {Promise} + * @returns {Promise} */ - async function readAccountSession(request) { + async function readAccountCookie(request) { const token = readCookie(request, ACCOUNT_COOKIE); if (!token) return null; try { @@ -174,12 +176,48 @@ export function createAccountHandlers(ctx) { // A session outlives nothing: if the PDS has since been initialized with // a different DID, its cookies are not this account's. const did = await getDid(); - return did && payload.sub === did ? did : null; + return did && payload.sub === did ? payload : null; } catch { return null; } } + /** + * The DID of the account whose cookie session this request carries, or null + * when there is no valid session. + * @param {Request} request + * @returns {Promise} + */ + async function readAccountSession(request) { + const payload = await readAccountCookie(request); + return payload ? /** @type {string} */ (payload.sub) : null; + } + + /** + * A replacement session cookie for a request whose own cookie has spent a + * day of its life. Null when the request carries no valid session, or one + * still fresh. `PersonalDataServer.fetch` sets it on the response, so an + * account page in use never reaches the expiry and asks for the password + * again. + * @param {Request} request + * @returns {Promise} + */ + async function renewAccountSession(request) { + const payload = await readAccountCookie(request); + const now = Math.floor(Date.now() / 1000); + if ( + typeof payload?.exp !== 'number' || + !accountSessionDueForRenewal(payload.exp, now) + ) + return null; + const token = await createAccountJwt( + /** @type {string} */ (payload.sub), + jwtSecret, + ACCOUNT_SESSION_TTL, + ); + return accountCookie(token, new URL(request.url).protocol === 'https:'); + } + /** * Display name and avatar from the account's own app.bsky.actor.profile * record. Read from this repo rather than looked up at a public AppView: @@ -2989,6 +3027,7 @@ export function createAccountHandlers(ctx) { }, }, readAccountSession, + renewAccountSession, readAccountUiState, accountAppResponse, accountApi, diff --git a/packages/core/src/pds.js b/packages/core/src/pds.js index d59b29a..cd43dc2 100644 --- a/packages/core/src/pds.js +++ b/packages/core/src/pds.js @@ -599,7 +599,10 @@ export class PersonalDataServer { return new Response(null, { headers: corsHeaders }); } - const response = await this.dispatch(request); + const response = await this.withAccountSession( + request, + await this.dispatch(request), + ); // RFC 9449 ยง8.2: hand every OAuth (DPoP) client a fresh nonce on each // response, so its cached nonce never goes stale between calls. Without @@ -757,6 +760,34 @@ export class PersonalDataServer { } } + /** + * Carry the account page session forward on any request that used it, so the + * cookie expires a week after the last visit rather than a week after the + * sign-in. A response that sets the cookie itself (sign-in, sign-out) chooses + * its own value. Requests to the rest of the server leave the cookie alone. + * @param {Request} request + * @param {Response} response + * @returns {Promise} + */ + async withAccountSession(request, response) { + const { pathname } = new URL(request.url); + if (pathname !== '/account' && !pathname.startsWith('/account/')) { + return response; + } + if (response.headers.has('Set-Cookie')) return response; + + const cookie = await this._account.renewAccountSession(request); + if (!cookie) return response; + + const headers = new Headers(response.headers); + headers.append('Set-Cookie', cookie); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + /** * Attach a fresh `DPoP-Nonce` to responses for OAuth (DPoP-authorized) * requests, so a spec-compliant client's cached nonce stays fresh from diff --git a/packages/core/src/session.js b/packages/core/src/session.js index 25abe18..51c748f 100644 --- a/packages/core/src/session.js +++ b/packages/core/src/session.js @@ -6,7 +6,15 @@ import { readJwtExp, readJwtId } from './auth.js'; // Cookie carrying the account page session, and how long it lives export const ACCOUNT_COOKIE = 'pdsjs_account'; -export const ACCOUNT_SESSION_TTL = 12 * 60 * 60; +export const ACCOUNT_SESSION_TTL = 7 * 24 * 60 * 60; + +// How much of a session cookie's life is spent before a request renews it. +// Every account page request carries the cookie forward, so a session ends +// ACCOUNT_SESSION_TTL after the last visit rather than after the sign-in. A +// smaller value reissues the cookie on nearly every request for an hour of +// extra life. The window stays at a week because this cookie cannot be revoked +// one session at a time. +const ACCOUNT_SESSION_RENEW_AFTER = 24 * 60 * 60; // A password/app-password refresh token lasts 90 days, matching the reference // PDS; the access token it mints stays short-lived. This is what a client holds @@ -310,6 +318,16 @@ export function createSessionRegistry(sharedStorage) { }; } +/** + * Whether a session cookie has spent enough of its life to reissue. + * @param {number} exp - The token's expiry, epoch seconds + * @param {number} now - epoch seconds + * @returns {boolean} + */ +export function accountSessionDueForRenewal(exp, now) { + return exp - now <= ACCOUNT_SESSION_TTL - ACCOUNT_SESSION_RENEW_AFTER; +} + /** * Serialize the account session cookie. An empty value expires it. * @param {string} value - Session token, or '' to clear the cookie diff --git a/test/account-page.test.js b/test/account-page.test.js index cc07981..faae603 100644 --- a/test/account-page.test.js +++ b/test/account-page.test.js @@ -1,5 +1,6 @@ // Account page tests - sign-in form, cookie session, account screen import { beforeEach, describe, expect, it } from 'vitest'; +import { createAccountJwt } from '../packages/core/src/auth.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; import { cborEncodeDagCbor } from '../packages/core/src/repo.js'; import { createSpaceBrowser } from '../packages/core/src/space-browser.js'; @@ -621,6 +622,62 @@ describe('Account pages', () => { expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); }); + it('leaves a fresh session cookie alone', async () => { + const cookie = await signedInCookie(pds); + const response = await pds.fetch( + new Request('https://pds.example.com/account', { + headers: { Cookie: cookie }, + }), + ); + expect(response.headers.get('set-cookie')).toBe(null); + }); + + it('reissues a session cookie that has spent a day of its life', async () => { + const spent = await createAccountJwt(DID, 'test-secret', 60); + const response = await pds.fetch( + new Request('https://pds.example.com/account', { + headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` }, + }), + ); + expect(response.status).toBe(200); + + const reissued = sessionCookie(response); + expect(reissued).not.toBe(''); + expect(reissued).not.toBe(spent); + expect(response.headers.get('set-cookie')).toContain('Max-Age=604800'); + + // The replacement opens the account screen on its own + const { html } = await getPage( + pds, + '/account', + `pdsjs_account=${encodeURIComponent(reissued)}`, + ); + expect(html).toContain(DID); + }); + + it('reissues nothing for a session cookie it did not sign', async () => { + const forged = await createAccountJwt(DID, 'other-secret', 60); + const response = await pds.fetch( + new Request('https://pds.example.com/account', { + headers: { Cookie: `pdsjs_account=${encodeURIComponent(forged)}` }, + }), + ); + expect(response.headers.get('set-cookie')).toBe(null); + expect(await response.text()).toContain('name="password"'); + }); + + it('signs out a part-spent session rather than reissuing it', async () => { + const spent = await createAccountJwt(DID, 'test-secret', 60); + const response = await pds.fetch( + new Request('https://pds.example.com/account/sign-out', { + method: 'POST', + headers: { Cookie: `pdsjs_account=${encodeURIComponent(spent)}` }, + }), + ); + expect(response.headers.getSetCookie()).toHaveLength(1); + expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); + }); + it('rejects cross-origin form posts', async () => { const response = await pds.fetch( new Request('https://pds.example.com/account/sign-in', {