chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
A confidential client's OAuth session lasts as on the reference PDS
.changeset/confidential-client-session.mdnew+8−0
| @@ -0,0 +1,8 @@ | ||
--- | ||
'@pdsjs/core': patch | ||
--- | ||
| ||
An OAuth session of a confidential client lasts up to two years, and ends after | ||
three months without a refresh, as on the reference PDS. It ended two weeks | ||
after its first authorization, however often the client refreshed it. A public | ||
client's session still ends after two weeks. | ||
packages/core/src/handlers/oauth.js+17−6
| @@ -107,10 +107,14 @@ const CLIENT_ASSERTION_TYPE_JWT_BEARER = | ||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; | ||
const CLIENT_ASSERTION_MAX_AGE_S = 60; | ||
| ||
// How long an OAuth refresh token (and thus a logged-in app session) lives | ||
// before the user must re-authorize. Matches the atproto reference AS default | ||
// (SESSION_LIFETIME / REFRESH_LIFETIME = 2 weeks). | ||
const OAUTH_REFRESH_TOKEN_TTL_MS = 14 * 24 * 60 * 60 * 1000; | ||
const DAY_MS = 24 * 60 * 60 * 1000; | ||
| ||
// How long an OAuth session lasts before the user must re-authorize, from its | ||
// first authorization, and how long it lasts without a refresh. The values | ||
// match the atproto reference AS. A public client has only the first limit. | ||
const OAUTH_PUBLIC_SESSION_TTL_MS = 14 * DAY_MS; | ||
const OAUTH_CONFIDENTIAL_SESSION_TTL_MS = 2 * 365 * DAY_MS; | ||
const OAUTH_CONFIDENTIAL_IDLE_TTL_MS = 90 * DAY_MS; | ||
| ||
/** | ||
* The language-tagged variants of a permission-set text property, e.g. | ||
| @@ -1525,8 +1529,15 @@ export function createOAuthHandlers(ctx) { | ||
); | ||
if (clientAuthError) return clientAuthError; | ||
| ||
// Enforce the refresh-token lifetime (2 weeks, matching the reference AS). | ||
if (Date.now() - token.createdAt > OAUTH_REFRESH_TOKEN_TTL_MS) { | ||
// authenticateClient proved the key of a private_key_jwt client above. | ||
const now = Date.now(); | ||
const expired = | ||
refreshClientMetadata.token_endpoint_auth_method === 'private_key_jwt' | ||
? now - token.createdAt > OAUTH_CONFIDENTIAL_SESSION_TTL_MS || | ||
now - (token.updatedAt ?? token.createdAt) > | ||
OAUTH_CONFIDENTIAL_IDLE_TTL_MS | ||
: now - token.createdAt > OAUTH_PUBLIC_SESSION_TTL_MS; | ||
if (expired) { | ||
await sharedStorage.deleteOAuthToken(refresh_token); | ||
return Response.json( | ||
{ error: 'invalid_grant', error_description: 'Refresh token expired' }, | ||
test/oauth-endpoints.test.js+86−1
| @@ -1,7 +1,7 @@ | ||
// The OAuth authorization-server endpoints, driven through the full flow a | ||
// client walks: PAR, the consent page, approval, the code and refresh grants, | ||
// and revocation. A loopback client keeps client-metadata resolution local. | ||
import { describe, expect, it, vi } from 'vitest'; | ||
import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
import { | ||
base64UrlEncode, | ||
generateKeyPair, | ||
| @@ -1243,6 +1243,91 @@ describe('the refresh grant', () => { | ||
); | ||
expect(server.oauthTokens.has(refreshToken)).toBe(false); | ||
}); | ||
| ||
describe('of a confidential client', () => { | ||
const CONFIDENTIAL_ID = 'https://client.example/metadata.json'; | ||
const DAY = 24 * 60 * 60 * 1000; | ||
| ||
afterEach(() => vi.unstubAllGlobals()); | ||
| ||
async function refreshAged( | ||
/** @type {{createdDaysAgo: number, updatedDaysAgo: number}} */ age, | ||
) { | ||
const server = createServer(); | ||
const { client, refreshToken } = await establishSession(server); | ||
const row = server.oauthTokens.get(refreshToken); | ||
row.clientId = CONFIDENTIAL_ID; | ||
row.createdAt = Date.now() - age.createdDaysAgo * DAY; | ||
row.updatedAt = Date.now() - age.updatedDaysAgo * DAY; | ||
| ||
const pair = await crypto.subtle.generateKey( | ||
{ name: 'ECDSA', namedCurve: 'P-256' }, | ||
true, | ||
['sign', 'verify'], | ||
); | ||
const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey); | ||
const metadata = { | ||
client_id: CONFIDENTIAL_ID, | ||
token_endpoint_auth_method: 'private_key_jwt', | ||
jwks: { keys: [{ ...jwk, kid: 'k' }] }, | ||
}; | ||
vi.stubGlobal('fetch', async () => Response.json(metadata)); | ||
| ||
const encode = (/** @type {object} */ part) => | ||
base64UrlEncode(new TextEncoder().encode(JSON.stringify(part))); | ||
const seconds = Math.floor(Date.now() / 1000); | ||
const signed = `${encode({ alg: 'ES256', kid: 'k' })}.${encode({ | ||
iss: CONFIDENTIAL_ID, | ||
sub: CONFIDENTIAL_ID, | ||
aud: ORIGIN, | ||
jti: crypto.randomUUID(), | ||
iat: seconds, | ||
exp: seconds + 60, | ||
})}`; | ||
const signature = await crypto.subtle.sign( | ||
{ name: 'ECDSA', hash: 'SHA-256' }, | ||
pair.privateKey, | ||
new TextEncoder().encode(signed), | ||
); | ||
| ||
return token(server.handlers, client, { | ||
grant_type: 'refresh_token', | ||
refresh_token: refreshToken, | ||
client_id: CONFIDENTIAL_ID, | ||
client_assertion_type: | ||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', | ||
client_assertion: `${signed}.${base64UrlEncode(new Uint8Array(signature))}`, | ||
}); | ||
} | ||
| ||
it('refreshes past two weeks while it is used', async () => { | ||
const response = await refreshAged({ | ||
createdDaysAgo: 400, | ||
updatedDaysAgo: 89, | ||
}); | ||
expect(response.status).toBe(200); | ||
}); | ||
| ||
it('retires a session unused for three months', async () => { | ||
const response = await refreshAged({ | ||
createdDaysAgo: 100, | ||
updatedDaysAgo: 91, | ||
}); | ||
expect((await response.json()).error_description).toBe( | ||
'Refresh token expired', | ||
); | ||
}); | ||
| ||
it('retires a session past two years, however used', async () => { | ||
const response = await refreshAged({ | ||
createdDaysAgo: 731, | ||
updatedDaysAgo: 0, | ||
}); | ||
expect((await response.json()).error_description).toBe( | ||
'Refresh token expired', | ||
); | ||
}); | ||
}); | ||
}); | ||
| ||
describe('revocation', () => { | ||