~ knotless Search/
↑↓ move↵ open⇧↵ all results

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

Merged chadtmiller.com asked to merge into main from chadtmiller.com/pds.js:oauth/confidential-session
.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', () => {