From 8cc560dfeb7d0a1401b74ec275d07d78145eae4d Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Fri, 28 Aug 2026 23:16:34 -0700 Subject: [PATCH] feat(oauth): share browser client across apps --- packages/git-ui/package.json | 1 + packages/git-ui/src/lib/dpop.js | 187 ------- packages/git-ui/src/lib/oauth.js | 514 ++------------------ packages/oauth-client/README.md | 26 + packages/oauth-client/package.json | 24 + packages/oauth-client/src/dpop.js | 219 +++++++++ packages/oauth-client/src/index.js | 13 + packages/oauth-client/src/oauth.js | 501 +++++++++++++++++++ packages/oauth-client/test/oauth.test.js | 127 +++++ packages/spaces-ui/package.json | 1 + packages/spaces-ui/src/app.jsx | 2 +- packages/spaces-ui/src/lib/credential.js | 8 +- packages/spaces-ui/src/lib/dpop.js | 208 -------- packages/spaces-ui/src/lib/oauth.js | 447 ++--------------- packages/spaces-ui/src/pages/credential.jsx | 2 +- packages/spaces-ui/src/pages/sign-in.jsx | 4 +- packages/spaces-ui/test/dpop.test.js | 4 +- pnpm-lock.yaml | 8 + 18 files changed, 988 insertions(+), 1308 deletions(-) delete mode 100644 packages/git-ui/src/lib/dpop.js create mode 100644 packages/oauth-client/README.md create mode 100644 packages/oauth-client/package.json create mode 100644 packages/oauth-client/src/dpop.js create mode 100644 packages/oauth-client/src/index.js create mode 100644 packages/oauth-client/src/oauth.js create mode 100644 packages/oauth-client/test/oauth.test.js delete mode 100644 packages/spaces-ui/src/lib/dpop.js diff --git a/packages/git-ui/package.json b/packages/git-ui/package.json index f297271..a58befd 100644 --- a/packages/git-ui/package.json +++ b/packages/git-ui/package.json @@ -16,6 +16,7 @@ "@pdsjs/git-ci": "workspace:*", "@pdsjs/npm": "workspace:*", "@pdsjs/oci": "workspace:*", + "@pdsjs/oauth-client": "workspace:*", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.32", "class-variance-authority": "^0.7.1", diff --git a/packages/git-ui/src/lib/dpop.js b/packages/git-ui/src/lib/dpop.js deleted file mode 100644 index 890dc92..0000000 --- a/packages/git-ui/src/lib/dpop.js +++ /dev/null @@ -1,187 +0,0 @@ -// DPoP (RFC 9449) for a page with no backend. -// -// The OAuth access token this app holds is bound by the authorization server -// to the key pair here, so the token is useless to anybody who copies it out -// of this browser: a request only counts when it carries a fresh proof signed -// by the private key. -// -// The private key is generated non-extractable and kept in IndexedDB, which is -// the only store that holds a CryptoKey as a key rather than as bytes. Nothing -// in this document can read it, export it, or send it anywhere. -// -// The same module serves @pdsjs/spaces-ui; each app bundles its own copy the -// way the atomic components are copied, because neither app imports the other. - -const DB_NAME = 'pdsjs-git-ui'; -const STORE = 'keys'; -const KEY_ID = 'dpop'; - -/** - * @returns {Promise} - */ -function openDb() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, 1); - request.onupgradeneeded = () => { - request.result.createObjectStore(STORE); - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} - -/** - * @template T - * @param {IDBTransactionMode} mode - * @param {(store: IDBObjectStore) => IDBRequest} run - * @returns {Promise} - */ -async function withStore(mode, run) { - const db = await openDb(); - try { - return await new Promise((resolve, reject) => { - const request = run(db.transaction(STORE, mode).objectStore(STORE)); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } finally { - db.close(); - } -} - -/** - * The key pair this browser signs proofs with, generating one on first use. - * @returns {Promise} - */ -export async function dpopKeyPair() { - const stored = /** @type {CryptoKeyPair|undefined} */ ( - await withStore('readonly', (store) => store.get(KEY_ID)) - ); - if (stored?.privateKey) return stored; - - const pair = await crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'P-256' }, - false, - ['sign', 'verify'], - ); - await withStore('readwrite', (store) => store.put(pair, KEY_ID)); - return pair; -} - -/** Forget the key, so the next sign-in cannot be linked to the last one. */ -export async function forgetDpopKey() { - await withStore('readwrite', (store) => store.delete(KEY_ID)); -} - -/** - * @param {Uint8Array} bytes - * @returns {string} - */ -export function base64Url(bytes) { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -/** - * @param {string} text - * @returns {Promise} base64url of the SHA-256 digest - */ -export async function sha256Base64Url(text) { - const digest = await crypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(text), - ); - return base64Url(new Uint8Array(digest)); -} - -/** - * @param {CryptoKey} publicKey - * @returns {Promise<{crv: string, kty: string, x: string, y: string}>} - */ -async function publicJwk(publicKey) { - const jwk = await crypto.subtle.exportKey('jwk', publicKey); - return { - crv: String(jwk.crv), - kty: String(jwk.kty), - x: String(jwk.x), - y: String(jwk.y), - }; -} - -/** - * @param {object} value - * @returns {string} - */ -function encodeSegment(value) { - return base64Url(new TextEncoder().encode(JSON.stringify(value))); -} - -/** - * Mint one proof. `htu` carries neither query nor fragment, which is what the - * RFC asks for and what every server this talks to normalizes to anyway. - * - * @param {Object} opts - * @param {CryptoKeyPair} opts.keyPair - * @param {string} opts.method - * @param {string} opts.url - * @param {string} [opts.nonce] - the server's most recent DPoP-Nonce - * @param {string} [opts.token] - the access token to bind to - * @returns {Promise} - */ -export async function dpopProof({ keyPair, method, url, nonce, token }) { - const htu = new URL(url); - htu.search = ''; - htu.hash = ''; - - const header = { - typ: 'dpop+jwt', - alg: 'ES256', - jwk: await publicJwk(keyPair.publicKey), - }; - /** @type {Record} */ - const payload = { - jti: crypto.randomUUID(), - htm: method.toUpperCase(), - htu: htu.toString(), - iat: Math.floor(Date.now() / 1000), - }; - if (nonce) payload.nonce = nonce; - if (token) payload.ath = await sha256Base64Url(token); - - const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`; - const signature = await crypto.subtle.sign( - { name: 'ECDSA', hash: 'SHA-256' }, - keyPair.privateKey, - new TextEncoder().encode(signingInput), - ); - return `${signingInput}.${base64Url(new Uint8Array(signature))}`; -} - -/** - * The most recent DPoP-Nonce each origin handed back. A server rotates its - * nonce whenever it likes, and the only correct next value is the one it sent - * last, so this is read per request rather than cached with a lifetime. - * @type {Map} - */ -const nonces = new Map(); - -/** - * @param {string} url - * @returns {string|undefined} - */ -export function nonceFor(url) { - return nonces.get(new URL(url).origin); -} - -/** - * @param {string} url - * @param {Response} response - */ -export function rememberNonce(url, response) { - const nonce = response.headers.get('dpop-nonce'); - if (nonce) nonces.set(new URL(url).origin, nonce); -} diff --git a/packages/git-ui/src/lib/oauth.js b/packages/git-ui/src/lib/oauth.js index f0ed99c..6bf6ed6 100644 --- a/packages/git-ui/src/lib/oauth.js +++ b/packages/git-ui/src/lib/oauth.js @@ -1,320 +1,38 @@ -// The atproto OAuth client, in the page. -// -// A public client with no backend: PKCE for the code, DPoP for the token, and -// a client_id that is a URL serving this app's own metadata document. The -// deploy writes that document beside the page, so the authorization server -// reads the app's registration from the same site the visitor is looking at. -// -// The visitor signs in to their own PDS, wherever it is. This site's account -// is the repository owner; the reviewer is anybody, and their review record -// is written to their server, not this one. -// -// The scope names the collections a participant writes, and the repository -// record itself, which merging updates. Merging is the one act on somebody -// else's work, so the server accepts it only from the owner's own token; the -// scope grants the page nothing over another account's repositories, only -// the owner's own, and it is narrower than the account password session it -// replaces, which is the whole account. - -import { - dpopKeyPair, - dpopProof, - forgetDpopKey, - nonceFor, - rememberNonce, - sha256Base64Url, -} from '#/lib/dpop.js'; +import { createDpopClient, createOAuthClient } from '@pdsjs/oauth-client'; import { resolveIdentity } from '#/lib/identity.js'; import { SCOPE } from '#/lib/scope.js'; -export { SCOPE }; - -const SESSION_KEY = 'git-ui:session'; -const PENDING_KEY = 'git-ui:pending'; - -/** - * @typedef {Object} Session - * @property {string} did - * @property {string} handle - * @property {string} pds - * @property {string} [clientId] - the exact id the tokens were issued to - * @property {string} issuer - the authorization server - * @property {string} tokenEndpoint - * @property {string|null} revocationEndpoint - * @property {string} accessToken - * @property {string} refreshToken - * @property {number} expiresAt - epoch milliseconds - * @property {string} scope - */ - -/** Where the authorization server sends the visitor back. */ -export function redirectUri() { - return `${window.location.origin}/callback`; -} - -/** - * This app's registration. - * - * On a real origin that is a URL the server fetches. On loopback there is - * nowhere to host one, so the registration states itself in the client_id's - * own query parameters, which is what the atproto profile defines for a - * client running on the user's device. - * - * The loopback id therefore embeds the scope, and the scope grows as the app - * does. A session's tokens were issued to the exact id its sign-in used, and - * the server refuses a refresh from any other, so a session carries its own - * id and never recomputes it from a scope that may since have changed. - * - * @param {string} [scope] - the scope the id embeds; the current one when - * starting a fresh sign-in - * @returns {string} - */ -export function clientId(scope = SCOPE) { - const { origin, hostname } = window.location; - if (hostname === 'localhost' || hostname === '127.0.0.1') { - const params = new URLSearchParams(); - params.set('redirect_uri', redirectUri()); - params.set('scope', scope); - return `http://localhost?${params}`; - } - return `${origin}/client-metadata.json`; -} - -/** - * @returns {Session|null} - */ -export function currentSession() { - const raw = localStorage.getItem(SESSION_KEY); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -/** @param {Session} session */ -function storeSession(session) { - localStorage.setItem(SESSION_KEY, JSON.stringify(session)); -} - -/** - * A random URL-safe string, used for both the state and the PKCE verifier. - * @returns {string} - */ -function randomToken() { - const bytes = crypto.getRandomValues(new Uint8Array(32)); - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); -} - -/** An authorization server's refusal, with the OAuth error code it named. */ -class OAuthError extends Error { - /** - * @param {string} message - * @param {string} [code] - */ - constructor(message, code) { - super(message); - this.code = code; - } -} - -/** - * POST a form to an endpoint with a DPoP proof, retrying once when the server - * answers that it wants its nonce. The first request to any authorization - * server draws that answer, because the client has no nonce to send yet. - * - * @param {string} url - * @param {Record} form - * @returns {Promise} - */ -async function postWithDpop(url, form) { - const keyPair = await dpopKeyPair(); - - for (let attempt = 0; attempt < 2; attempt += 1) { - const proof = await dpopProof({ - keyPair, - method: 'POST', - url, - nonce: nonceFor(url), - }); - const response = await fetch(url, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - dpop: proof, - }, - body: new URLSearchParams(form), - }); - rememberNonce(url, response); - - if (response.ok) return response.json(); - - const body = await response.json().catch(() => ({})); - if (body?.error === 'use_dpop_nonce' && attempt === 0) continue; - throw new OAuthError( - body?.error_description || body?.error || `${url} ${response.status}`, - body?.error, - ); - } - throw new Error('The server kept asking for a new DPoP nonce.'); -} - -/** - * The authorization server a PDS delegates to, and its endpoints. - * @param {string} pds - * @returns {Promise<{issuer: string, parEndpoint: string, authorizeEndpoint: string, tokenEndpoint: string, revocationEndpoint: string|null}>} - */ -async function discoverAuthServer(pds) { - const resource = await fetch(`${pds}/.well-known/oauth-protected-resource`); - if (!resource.ok) { - throw new Error('That PDS does not publish OAuth metadata.'); - } - const authServers = (await resource.json()).authorization_servers || []; - if (authServers.length === 0) { - throw new Error('That PDS names no authorization server.'); - } - const issuer = String(authServers[0]).replace(/\/$/, ''); - - const metadataUrl = `${issuer}/.well-known/oauth-authorization-server`; - const metadata = await fetch(metadataUrl); - if (!metadata.ok) { - throw new Error('The authorization server publishes no metadata.'); - } - const doc = await metadata.json(); - if (doc.issuer !== issuer) { - throw new Error( - 'The authorization server metadata names a different issuer.', - ); - } - if (!doc.pushed_authorization_request_endpoint) { - throw new Error( - 'The authorization server does not accept pushed authorization requests.', - ); - } - return { - issuer, - parEndpoint: doc.pushed_authorization_request_endpoint, - authorizeEndpoint: doc.authorization_endpoint, - tokenEndpoint: doc.token_endpoint, - revocationEndpoint: doc.revocation_endpoint || null, - }; -} - -/** - * Push the authorization request and send the browser to the consent screen. - * This function does not return: it navigates away. - * - * @param {string} input - a handle or DID - * @param {string} [returnTo] - where the callback sends the visitor after; - * the front page when absent - * @returns {Promise} - */ -export async function beginSignIn(input, returnTo) { - const identity = await resolveIdentity(input); - const server = await discoverAuthServer(identity.pds); - - const verifier = randomToken(); - const state = randomToken(); - const challenge = await sha256Base64Url(verifier); - - const { request_uri } = await postWithDpop(server.parEndpoint, { - client_id: clientId(), - response_type: 'code', - redirect_uri: redirectUri(), - scope: SCOPE, - state, - code_challenge: challenge, - code_challenge_method: 'S256', - login_hint: identity.handle, - }); - - sessionStorage.setItem( - PENDING_KEY, - JSON.stringify({ - state, - verifier, - clientId: clientId(), - ...(returnTo ? { returnTo } : {}), - did: identity.did, - handle: identity.handle, - pds: identity.pds, - ...server, - }), - ); - - const authorize = new URL(server.authorizeEndpoint); - authorize.searchParams.set('client_id', clientId()); - authorize.searchParams.set('request_uri', request_uri); - window.location.assign(authorize.toString()); - // The navigation above ends this page, so nothing after it runs. - return await new Promise(() => {}); -} - -/** - * Exchange the code the authorization server sent back for a session. - * @param {URLSearchParams} params - the callback's query string - * @returns {Promise<{session: Session, returnTo: string}>} - */ -export async function completeSignIn(params) { - const error = params.get('error'); - if (error) { - throw new Error(params.get('error_description') || error); - } - const code = params.get('code'); - const state = params.get('state'); - if (!code || !state) throw new Error('That callback carried no code.'); - - const raw = sessionStorage.getItem(PENDING_KEY); - if (!raw) throw new Error('No sign-in was in progress in this tab.'); - const pending = JSON.parse(raw); - sessionStorage.removeItem(PENDING_KEY); - - if (pending.state !== state) { - throw new Error('That callback answers a different sign-in.'); - } - // The issuer identifies which server answered, and a mismatch is the mix-up - // attack the parameter exists to stop. - const issuer = params.get('iss'); - if (issuer && issuer.replace(/\/$/, '') !== pending.issuer) { - throw new Error( - 'That callback came from a different authorization server.', - ); - } +const dpop = createDpopClient({ databaseName: 'pdsjs-git-ui' }); +const client = createOAuthClient({ + dpop, + resolveIdentity, + scope: SCOPE, + storagePrefix: 'git-ui', +}); - const token = await postWithDpop(pending.tokenEndpoint, { - client_id: pending.clientId ?? clientId(), - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri(), - code_verifier: pending.verifier, - }); +/** @typedef {import('@pdsjs/oauth-client/oauth').OAuthSession} Session */ - if (token.sub !== pending.did) { - throw new Error('The server issued a token for a different account.'); - } - - /** @type {Session} */ - const session = { - did: pending.did, - handle: pending.handle, - pds: pending.pds, - clientId: pending.clientId ?? clientId(), - issuer: pending.issuer, - tokenEndpoint: pending.tokenEndpoint, - revocationEndpoint: pending.revocationEndpoint, - accessToken: token.access_token, - refreshToken: token.refresh_token, - expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000, - scope: token.scope || SCOPE, - }; - storeSession(session); - return { session, returnTo: pending.returnTo ?? '/' }; -} +export { SCOPE }; +export const authedFetch = client.authedFetch; +export const beginSignIn = client.beginSignIn; +export const clearRenewal = client.clearRenewal; +export const clientId = client.clientId; +export const completeSignIn = client.completeSignIn; +export const currentSession = client.currentSession; +export const onSessionEnded = client.onSessionEnded; +export const renewGrant = client.renewGrant; +export const redirectUri = client.redirectUri; +export const signOut = client.signOut; +export const dpopKeyPair = dpop.dpopKeyPair; +export const dpopProof = dpop.dpopProof; +export const dpopThumbprint = dpop.dpopThumbprint; +export const forgetDpopKey = dpop.forgetDpopKey; +export const nonceFor = dpop.nonceFor; +export const rememberNonce = dpop.rememberNonce; +export const sha256Base64Url = dpop.sha256Base64Url; +export const thumbprint = dpop.thumbprint; /** - * Whether the granted scope lets this session publish a review. Read from - * what the server granted rather than from what the sign-in asked for. * @param {Session|null} session * @returns {boolean} */ @@ -323,9 +41,6 @@ export function canReview(session) { } /** - * Whether the session's grant covers everything the app now asks for. The - * app's scope grows as it does, and a session granted before a growth lacks - * the new piece, whichever piece that is. * @param {Session|null} session * @returns {boolean} */ @@ -334,176 +49,3 @@ export function grantCurrent(session) { const granted = (session.scope || '').split(/\s+/); return SCOPE.split(/\s+/).every((piece) => granted.includes(piece)); } - -/** One renewal attempt per tab, so a narrower grant cannot loop the page. */ -const RENEW_KEY = 'git-ui:renewed'; - -/** - * Send a session granted before the app grew back through consent, and - * return to the page the visitor was on. No message and nothing to click: - * the consent screen itself is the ask, and it names exactly what changed. - * @param {Session} session - * @param {string} returnTo - */ -export function renewGrant(session, returnTo) { - if (sessionStorage.getItem(RENEW_KEY)) return; - sessionStorage.setItem(RENEW_KEY, '1'); - // The DID rather than the handle: a DID resolves anywhere, while a - // handle may need a lookup the browser cannot make. - beginSignIn(session.did, returnTo).catch(() => { - // The flag guards against a renewal that completes and comes back - // short. One that never left the page keeps no claim on the tab, so - // the next load tries again. - sessionStorage.removeItem(RENEW_KEY); - }); -} - -/** A finished sign-in is the renewal succeeding, whoever started it. */ -export function clearRenewal() { - sessionStorage.removeItem(RENEW_KEY); -} - -/** - * Drop the session, telling the server about it where the server offers a - * way. The DPoP key goes too: it is what binds this browser to the tokens, - * and a fresh one makes the next sign-in unlinkable to the last. - * @param {Session|null} session - */ -export async function signOut(session) { - if (session?.revocationEndpoint) { - try { - await fetch(session.revocationEndpoint, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - token: session.refreshToken, - client_id: session.clientId ?? clientId(), - }), - }); - } catch { - // Signing out of this browser does not depend on the server hearing it. - } - } - localStorage.removeItem(SESSION_KEY); - await forgetDpopKey(); -} - -// A token is replaced this long before it expires, so a request never races -// its own credential. -const REFRESH_MARGIN_MS = 30_000; - -/** @type {Promise|null} */ -let refreshing = null; - -/** - * A session with an access token good for the next request, refreshing it - * when it is not. Concurrent callers share one refresh: the server - * invalidates a refresh token as it spends it, so a second spend of the same - * one fails. - * - * @param {Session} session - * @returns {Promise} - */ -async function fresh(session) { - if (session.expiresAt - REFRESH_MARGIN_MS > Date.now()) return session; - refreshing ??= (async () => { - try { - const token = await postWithDpop(session.tokenEndpoint, { - client_id: session.clientId ?? clientId(), - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }).catch((err) => { - // invalid_grant is the server refusing this session itself, not the - // request: the tokens never work again, so the session ends here - // rather than showing the refusal on whichever form asked. - if (err instanceof OAuthError && err.code === 'invalid_grant') { - throw sessionExpired(); - } - throw err; - }); - const next = { - ...session, - accessToken: token.access_token, - refreshToken: token.refresh_token || session.refreshToken, - expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000, - }; - storeSession(next); - return next; - } finally { - refreshing = null; - } - })(); - return refreshing; -} - -/** @type {(() => void)|null} */ -let sessionEndedCallback = null; - -/** - * Register what to do when the server refuses this session. The app holds the - * session in React state, and a token the server has stopped accepting is one - * only the app can take off the screen. - * @param {() => void} callback - */ -export function onSessionEnded(callback) { - sessionEndedCallback = callback; -} - -/** - * The session is over, whatever the page still shows. - * @returns {Error} - */ -function sessionExpired() { - localStorage.removeItem(SESSION_KEY); - sessionEndedCallback?.(); - return new Error('That session is no longer valid. Sign in again.'); -} - -/** - * Call an endpoint as the signed-in account. - * - * Two retries, each for one thing the server can ask for rather than an - * error: a DPoP nonce it wants the proof to carry, and an access token that - * has gone stale earlier than its stated lifetime. - * - * @param {Session} session - * @param {string} url - * @param {RequestInit} [init] - * @returns {Promise} - */ -export async function authedFetch(session, url, init = {}) { - let active = await fresh(session); - let refreshed = false; - - for (let attempt = 0; attempt < 3; attempt += 1) { - const keyPair = await dpopKeyPair(); - const proof = await dpopProof({ - keyPair, - method: init.method || 'GET', - url, - nonce: nonceFor(url), - token: active.accessToken, - }); - const response = await fetch(url, { - ...init, - headers: { - ...(init.headers || {}), - authorization: `DPoP ${active.accessToken}`, - dpop: proof, - }, - }); - rememberNonce(url, response); - - if (response.status !== 401) return response; - - const challenge = response.headers.get('www-authenticate') || ''; - if (challenge.includes('use_dpop_nonce')) continue; - if (!refreshed) { - refreshed = true; - active = await fresh({ ...active, expiresAt: 0 }); - continue; - } - throw sessionExpired(); - } - throw sessionExpired(); -} diff --git a/packages/oauth-client/README.md b/packages/oauth-client/README.md new file mode 100644 index 0000000..4adc92c --- /dev/null +++ b/packages/oauth-client/README.md @@ -0,0 +1,26 @@ +# @pdsjs/oauth-client + +Browser OAuth for public atproto clients. It implements authorization code with +PKCE, pushed authorization requests, DPoP-bound tokens, refresh, revocation and +session storage. + +Create one client per application. Supply the application's identity resolver, +requested scope and storage prefix. The prefix lets an application preserve its +existing session keys when it moves to this package. + +```js +import { createDpopClient, createOAuthClient } from '@pdsjs/oauth-client'; + +const dpop = createDpopClient({ databaseName: 'my-app-oauth' }); +const oauth = createOAuthClient({ + dpop, + resolveIdentity, + scope: 'atproto', + storagePrefix: 'my-app', +}); + +await oauth.beginSignIn('alice.example'); +``` + +The `dpop` export provides the same key and proof for credentials other than +OAuth tokens. Its key stays non-extractable in IndexedDB. diff --git a/packages/oauth-client/package.json b/packages/oauth-client/package.json new file mode 100644 index 0000000..5e0cea5 --- /dev/null +++ b/packages/oauth-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@pdsjs/oauth-client", + "version": "0.1.0", + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js", + "./dpop": "./src/dpop.js", + "./oauth": "./src/oauth.js" + }, + "description": "Browser OAuth and DPoP client for atproto public clients", + "files": [ + "src" + ], + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://tangled.org/chadtmiller.com/pds.js", + "directory": "packages/oauth-client" + } +} diff --git a/packages/oauth-client/src/dpop.js b/packages/oauth-client/src/dpop.js new file mode 100644 index 0000000..179690c --- /dev/null +++ b/packages/oauth-client/src/dpop.js @@ -0,0 +1,219 @@ +/** + * Browser DPoP primitives for OAuth and other credentials bound to one key. + * The key remains non-extractable in IndexedDB. + */ + +/** + * @typedef {Object} DpopClient + * @property {() => Promise} dpopKeyPair + * @property {() => Promise} forgetDpopKey + * @property {(bytes: Uint8Array) => string} base64Url + * @property {(text: string) => Promise} sha256Base64Url + * @property {(jwk: {crv: string, kty: string, x: string, y: string}) => Promise} thumbprint + * @property {() => Promise} dpopThumbprint + * @property {(opts: {keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}) => Promise} dpopProof + * @property {(url: string) => string|undefined} nonceFor + * @property {(url: string, response: Response) => void} rememberNonce + */ + +/** + * @param {Object} [options] + * @param {string} [options.databaseName] + * @returns {DpopClient} + */ +export function createDpopClient({ databaseName = 'pdsjs-oauth-client' } = {}) { + const STORE = 'keys'; + const KEY_ID = 'dpop'; + + /** + * @returns {Promise} + */ + function openDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + /** + * @template T + * @param {IDBTransactionMode} mode + * @param {(store: IDBObjectStore) => IDBRequest} run + * @returns {Promise} + */ + async function withStore(mode, run) { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const request = run(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } + } + + /** + * @returns {Promise} + */ + async function dpopKeyPair() { + const stored = /** @type {CryptoKeyPair|undefined} */ ( + await withStore('readonly', (store) => store.get(KEY_ID)) + ); + if (stored?.privateKey) return stored; + + const pair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign', 'verify'], + ); + await withStore('readwrite', (store) => store.put(pair, KEY_ID)); + return pair; + } + + async function forgetDpopKey() { + await withStore('readwrite', (store) => store.delete(KEY_ID)); + } + + /** + * @param {Uint8Array} bytes + * @returns {string} + */ + function base64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + + /** + * @param {string} text + * @returns {Promise} + */ + async function sha256Base64Url(text) { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(text), + ); + return base64Url(new Uint8Array(digest)); + } + + /** + * @param {CryptoKey} publicKey + * @returns {Promise<{crv: string, kty: string, x: string, y: string}>} + */ + async function publicJwk(publicKey) { + const jwk = await crypto.subtle.exportKey('jwk', publicKey); + return { + crv: String(jwk.crv), + kty: String(jwk.kty), + x: String(jwk.x), + y: String(jwk.y), + }; + } + + /** + * @param {{crv: string, kty: string, x: string, y: string}} jwk + * @returns {Promise} + */ + function thumbprint(jwk) { + return sha256Base64Url( + JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }), + ); + } + + async function dpopThumbprint() { + const pair = await dpopKeyPair(); + return thumbprint(await publicJwk(pair.publicKey)); + } + + /** + * @param {unknown} value + * @returns {string} + */ + function encodeSegment(value) { + return base64Url(new TextEncoder().encode(JSON.stringify(value))); + } + + /** + * @param {{keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}} opts + * @returns {Promise} + */ + async function dpopProof({ keyPair, method, url, nonce, token }) { + const htu = new URL(url); + htu.search = ''; + htu.hash = ''; + + const header = { + typ: 'dpop+jwt', + alg: 'ES256', + jwk: await publicJwk(keyPair.publicKey), + }; + /** @type {Record} */ + const payload = { + jti: crypto.randomUUID(), + htm: method.toUpperCase(), + htu: htu.toString(), + iat: Math.floor(Date.now() / 1000), + }; + if (nonce) payload.nonce = nonce; + if (token) payload.ath = await sha256Base64Url(token); + + const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`; + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.privateKey, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${base64Url(new Uint8Array(signature))}`; + } + + /** @type {Map} */ + const nonces = new Map(); + + /** @param {string} url */ + function nonceFor(url) { + return nonces.get(new URL(url).origin); + } + + /** + * @param {string} url + * @param {Response} response + */ + function rememberNonce(url, response) { + const nonce = response.headers.get('dpop-nonce'); + if (nonce) nonces.set(new URL(url).origin, nonce); + } + + return { + dpopKeyPair, + forgetDpopKey, + base64Url, + sha256Base64Url, + thumbprint, + dpopThumbprint, + dpopProof, + nonceFor, + rememberNonce, + }; +} + +const defaultDpop = createDpopClient(); +export const { + dpopKeyPair, + forgetDpopKey, + base64Url, + sha256Base64Url, + thumbprint, + dpopThumbprint, + dpopProof, + nonceFor, + rememberNonce, +} = defaultDpop; diff --git a/packages/oauth-client/src/index.js b/packages/oauth-client/src/index.js new file mode 100644 index 0000000..b204b00 --- /dev/null +++ b/packages/oauth-client/src/index.js @@ -0,0 +1,13 @@ +export { + base64Url, + createDpopClient, + dpopKeyPair, + dpopProof, + dpopThumbprint, + forgetDpopKey, + nonceFor, + rememberNonce, + sha256Base64Url, + thumbprint, +} from './dpop.js'; +export { createOAuthClient, OAuthError } from './oauth.js'; diff --git a/packages/oauth-client/src/oauth.js b/packages/oauth-client/src/oauth.js new file mode 100644 index 0000000..ee4d826 --- /dev/null +++ b/packages/oauth-client/src/oauth.js @@ -0,0 +1,501 @@ +/** + * Authorization-code OAuth for an atproto browser client. + * + * The client is public: PKCE protects the code exchange, and DPoP binds the + * access and refresh tokens to a non-extractable browser key. + */ + +import { createDpopClient } from './dpop.js'; + +const REFRESH_MARGIN_MS = 30_000; + +/** + * @typedef {Object} OAuthSession + * @property {string} did + * @property {string} handle + * @property {string} pds + * @property {string} [clientId] + * @property {string} issuer + * @property {string} tokenEndpoint + * @property {string|null} revocationEndpoint + * @property {string} accessToken + * @property {string} refreshToken + * @property {number} expiresAt + * @property {string} scope + */ + +/** + * @typedef {Object} OAuthClientOptions + * @property {(input: string) => Promise<{did: string, handle: string, pds: string}>} resolveIdentity + * @property {string} scope + * @property {string} storagePrefix + * @property {import('./dpop.js').DpopClient} [dpop] + * @property {boolean} [requireProtectedResourceMetadata] + */ + +/** + * @typedef {Object} OAuthClient + * @property {(scope?: string) => string} clientId + * @property {() => string} redirectUri + * @property {() => OAuthSession|null} currentSession + * @property {(input: string, options?: {scope?: string, returnTo?: string}) => Promise} beginSignIn + * @property {(params: URLSearchParams) => Promise<{session: OAuthSession, returnTo: string}>} completeSignIn + * @property {(session: OAuthSession|null) => Promise} signOut + * @property {(callback: () => void) => void} onSessionEnded + * @property {(session: OAuthSession, returnTo: string) => void} renewGrant + * @property {() => void} clearRenewal + * @property {(session: OAuthSession, url: string, init?: RequestInit) => Promise} authedFetch + * @property {import('./dpop.js').DpopClient} dpop + */ + +/** An authorization-server refusal, with the OAuth error code it named. */ +export class OAuthError extends Error { + /** + * @param {string} message + * @param {string} [code] + */ + constructor(message, code = 'oauth_error') { + super(message); + this.code = code; + } +} + +/** + * @param {unknown} value + * @returns {string|undefined} + */ +function stringValue(value) { + return typeof value === 'string' ? value : undefined; +} + +/** + * @param {Response} response + * @returns {Promise>} + */ +async function jsonObject(response) { + const value = await response.json().catch(() => ({})); + return value && typeof value === 'object' && !Array.isArray(value) + ? /** @type {Record} */ (value) + : {}; +} + +/** + * @param {OAuthClientOptions} options + * @returns {OAuthClient} + */ +export function createOAuthClient({ + resolveIdentity, + scope: defaultScope, + storagePrefix, + dpop = createDpopClient({ databaseName: `${storagePrefix}-oauth` }), + requireProtectedResourceMetadata = false, +}) { + const SESSION_KEY = `${storagePrefix}:session`; + const PENDING_KEY = `${storagePrefix}:pending`; + const RENEW_KEY = `${storagePrefix}:renewed`; + + /** @typedef {{state: string, verifier: string, clientId: string, scope: string, did: string, handle: string, pds: string, issuer: string, tokenEndpoint: string, authorizeEndpoint: string, revocationEndpoint: string|null, returnTo?: string}} PendingSignIn */ + + /** + * @param {string} scope + * @returns {string} + */ + function clientId(scope = defaultScope) { + const { origin, hostname } = window.location; + if (hostname === 'localhost' || hostname === '127.0.0.1') { + const params = new URLSearchParams(); + params.set('redirect_uri', redirectUri()); + params.set('scope', scope); + return `http://localhost?${params}`; + } + return `${origin}/client-metadata.json`; + } + + function redirectUri() { + return `${window.location.origin}/callback`; + } + + /** + * @returns {OAuthSession|null} + */ + function currentSession() { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + } + + /** @param {OAuthSession} session */ + function storeSession(session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); + } + + function randomToken() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); + } + + /** + * @param {string} url + * @param {Record} form + * @returns {Promise>} + */ + async function postWithDpop(url, form) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const proof = await dpop.dpopProof({ + keyPair: await dpop.dpopKeyPair(), + method: 'POST', + url, + nonce: dpop.nonceFor(url), + }); + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + dpop: proof, + }, + body: new URLSearchParams(form), + }); + dpop.rememberNonce(url, response); + + if (response.ok) return jsonObject(response); + + const body = await jsonObject(response); + const code = stringValue(body.error); + const freshNonce = response.headers.get('dpop-nonce'); + if (code === 'use_dpop_nonce' && freshNonce && attempt === 0) continue; + throw new OAuthError( + stringValue(body.error_description) || + code || + `${url} ${response.status}`, + code, + ); + } + throw new OAuthError('The server kept asking for a new DPoP nonce.'); + } + + /** + * @param {string} pds + * @returns {Promise<{issuer: string, parEndpoint: string, authorizeEndpoint: string, tokenEndpoint: string, revocationEndpoint: string|null}>} + */ + async function discoverAuthServer(pds) { + const base = pds.replace(/\/+$/, ''); + let issuer = base; + let resource; + try { + resource = await fetch(`${base}/.well-known/oauth-protected-resource`); + } catch { + if (requireProtectedResourceMetadata) { + throw new OAuthError('That PDS does not publish OAuth metadata.'); + } + resource = null; + } + if (resource?.ok) { + const body = await jsonObject(resource); + const namedIssuer = Array.isArray(body.authorization_servers) + ? stringValue(body.authorization_servers[0]) + : undefined; + if (namedIssuer) issuer = namedIssuer.replace(/\/+$/, ''); + else if (requireProtectedResourceMetadata) { + throw new OAuthError('That PDS names no authorization server.'); + } + } else if (requireProtectedResourceMetadata) { + throw new OAuthError('That PDS does not publish OAuth metadata.'); + } + + const response = await fetch( + `${issuer}/.well-known/oauth-authorization-server`, + ); + if (!response.ok) { + throw new OAuthError( + 'The authorization server publishes no metadata.', + 'invalid_server_metadata', + ); + } + const doc = await jsonObject(response); + const metadataIssuer = stringValue(doc.issuer); + const parEndpoint = stringValue(doc.pushed_authorization_request_endpoint); + const authorizeEndpoint = stringValue(doc.authorization_endpoint); + const tokenEndpoint = stringValue(doc.token_endpoint); + if (metadataIssuer && metadataIssuer.replace(/\/+$/, '') !== issuer) { + throw new OAuthError( + 'The authorization server metadata names a different issuer.', + 'invalid_server_metadata', + ); + } + if (!parEndpoint || !authorizeEndpoint || !tokenEndpoint) { + throw new OAuthError( + 'The authorization server does not publish the required endpoints.', + 'invalid_server_metadata', + ); + } + return { + issuer, + parEndpoint, + authorizeEndpoint, + tokenEndpoint, + revocationEndpoint: stringValue(doc.revocation_endpoint) || null, + }; + } + + /** + * @param {string} input + * @param {{scope?: string, returnTo?: string}} [options] + * @returns {Promise} + */ + async function beginSignIn(input, { scope = defaultScope, returnTo } = {}) { + const identity = await resolveIdentity(input); + const server = await discoverAuthServer(identity.pds); + const verifier = randomToken(); + const state = randomToken(); + const challenge = await dpop.sha256Base64Url(verifier); + const id = clientId(scope); + + const par = await postWithDpop(server.parEndpoint, { + client_id: id, + response_type: 'code', + redirect_uri: redirectUri(), + scope, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + login_hint: identity.handle, + }); + const requestUri = stringValue(par.request_uri); + if (!requestUri) + throw new OAuthError('The authorization server returned no request URI.'); + + sessionStorage.setItem( + PENDING_KEY, + JSON.stringify({ + state, + verifier, + clientId: id, + scope, + did: identity.did, + handle: identity.handle, + pds: identity.pds, + ...server, + ...(returnTo ? { returnTo } : {}), + }), + ); + + const authorize = new URL(server.authorizeEndpoint); + authorize.searchParams.set('client_id', id); + authorize.searchParams.set('request_uri', requestUri); + window.location.assign(authorize.toString()); + return await new Promise(() => {}); + } + + /** + * @param {URLSearchParams} params + * @returns {Promise<{session: OAuthSession, returnTo: string}>} + */ + async function completeSignIn(params) { + const error = params.get('error'); + if (error) + throw new OAuthError(params.get('error_description') || error, error); + const code = params.get('code'); + const state = params.get('state'); + if (!code || !state) throw new OAuthError('That callback carried no code.'); + + const raw = sessionStorage.getItem(PENDING_KEY); + if (!raw) throw new OAuthError('No sign-in was in progress in this tab.'); + /** @type {PendingSignIn} */ + const pending = JSON.parse(raw); + sessionStorage.removeItem(PENDING_KEY); + if (pending.state !== state) { + throw new OAuthError('That callback answers a different sign-in.'); + } + const issuer = params.get('iss'); + if (issuer && issuer.replace(/\/+$/, '') !== pending.issuer) { + throw new OAuthError( + 'That callback came from a different authorization server.', + ); + } + + const id = pending.clientId || clientId(pending.scope); + const token = await postWithDpop(pending.tokenEndpoint, { + client_id: id, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri(), + code_verifier: pending.verifier, + }); + if (stringValue(token.sub) !== pending.did) { + throw new OAuthError( + 'The server issued a token for a different account.', + ); + } + const accessToken = stringValue(token.access_token); + const refreshToken = stringValue(token.refresh_token); + if (!accessToken || !refreshToken) { + throw new OAuthError('The token response did not contain a token pair.'); + } + const session = { + did: pending.did, + handle: pending.handle, + pds: pending.pds, + clientId: id, + issuer: pending.issuer, + tokenEndpoint: pending.tokenEndpoint, + revocationEndpoint: pending.revocationEndpoint, + accessToken, + refreshToken, + expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000, + scope: stringValue(token.scope) || pending.scope, + }; + storeSession(session); + return { session, returnTo: pending.returnTo || '/' }; + } + + /** @type {Promise|null} */ + let refreshing = null; + /** @type {(() => void)|null} */ + let sessionEndedCallback = null; + + function sessionExpired() { + localStorage.removeItem(SESSION_KEY); + sessionEndedCallback?.(); + return new OAuthError( + 'That session is no longer valid. Sign in again.', + 'session_expired', + ); + } + + /** @param {OAuthSession} session */ + async function fresh(session) { + if (session.expiresAt - REFRESH_MARGIN_MS > Date.now()) return session; + refreshing ??= (async () => { + try { + const token = await postWithDpop(session.tokenEndpoint, { + client_id: session.clientId || clientId(session.scope), + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + }).catch((error) => { + if (error instanceof OAuthError && error.code === 'invalid_grant') { + throw sessionExpired(); + } + throw error; + }); + const accessToken = stringValue(token.access_token); + if (!accessToken) + throw new OAuthError( + 'The refresh response contained no access token.', + ); + const next = { + ...session, + accessToken, + refreshToken: + stringValue(token.refresh_token) || session.refreshToken, + expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000, + scope: stringValue(token.scope) || session.scope, + }; + storeSession(next); + return next; + } finally { + refreshing = null; + } + })(); + return refreshing; + } + + /** @param {OAuthSession|null} session */ + async function signOut(session) { + if (session?.revocationEndpoint) { + try { + await fetch(session.revocationEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + token: session.refreshToken, + client_id: session.clientId || clientId(session.scope), + }), + }); + } catch { + // Local sign-out does not depend on the server hearing the revocation. + } + } + localStorage.removeItem(SESSION_KEY); + await dpop.forgetDpopKey(); + } + + /** @param {() => void} callback */ + function onSessionEnded(callback) { + sessionEndedCallback = callback; + } + + /** + * @param {OAuthSession} session + * @param {string} returnTo + */ + function renewGrant(session, returnTo) { + if (sessionStorage.getItem(RENEW_KEY)) return; + sessionStorage.setItem(RENEW_KEY, '1'); + beginSignIn(session.did, { returnTo }).catch(() => { + sessionStorage.removeItem(RENEW_KEY); + }); + } + + function clearRenewal() { + sessionStorage.removeItem(RENEW_KEY); + } + + /** + * @param {OAuthSession} session + * @param {string} url + * @param {RequestInit} [init] + * @returns {Promise} + */ + async function authedFetch(session, url, init = {}) { + let active = await fresh(session); + let refreshed = false; + for (let attempt = 0; attempt < 3; attempt += 1) { + const proof = await dpop.dpopProof({ + keyPair: await dpop.dpopKeyPair(), + method: init.method || 'GET', + url, + nonce: dpop.nonceFor(url), + token: active.accessToken, + }); + const response = await fetch(url, { + ...init, + headers: { + ...(init.headers || {}), + authorization: `DPoP ${active.accessToken}`, + dpop: proof, + }, + }); + dpop.rememberNonce(url, response); + if (response.status !== 401) return response; + const challenge = response.headers.get('www-authenticate') || ''; + if (challenge.includes('use_dpop_nonce')) continue; + if (!refreshed) { + refreshed = true; + active = await fresh({ ...active, expiresAt: 0 }); + continue; + } + throw sessionExpired(); + } + throw sessionExpired(); + } + + return { + clientId, + redirectUri, + currentSession, + beginSignIn, + completeSignIn, + signOut, + onSessionEnded, + renewGrant, + clearRenewal, + authedFetch, + dpop, + }; +} diff --git a/packages/oauth-client/test/oauth.test.js b/packages/oauth-client/test/oauth.test.js new file mode 100644 index 0000000..4722c11 --- /dev/null +++ b/packages/oauth-client/test/oauth.test.js @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createOAuthClient } from '../src/index.js'; + +class StorageMock { + values = new Map(); + + /** @param {string} key */ + getItem(key) { + return this.values.get(key) ?? null; + } + + /** @param {string} key @param {string} value */ + setItem(key, value) { + this.values.set(key, String(value)); + } + + /** @param {string} key */ + removeItem(key) { + this.values.delete(key); + } +} + +/** @type {ReturnType} */ +let fetchMock; + +/** @returns {import('../src/oauth.js').OAuthClient} */ +function client() { + const dpop = { + dpopKeyPair: vi.fn(), + forgetDpopKey: vi.fn(async () => {}), + sha256Base64Url: vi.fn(async () => 'challenge'), + dpopProof: vi.fn(async () => 'proof'), + nonceFor: vi.fn(() => undefined), + rememberNonce: vi.fn(), + }; + return createOAuthClient({ + dpop: /** @type {any} */ (dpop), + resolveIdentity: async () => ({ + did: 'did:plc:alice', + handle: 'alice.example', + pds: 'https://pds.example', + }), + scope: 'atproto', + storagePrefix: 'oauth-client-test', + }); +} + +beforeEach(() => { + vi.stubGlobal('window', { + location: { + origin: 'http://localhost:5173', + hostname: 'localhost', + assign: vi.fn(() => { + throw new Error('navigation'); + }), + }, + }); + vi.stubGlobal('localStorage', new StorageMock()); + vi.stubGlobal('sessionStorage', new StorageMock()); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); +}); + +describe('createOAuthClient', () => { + it('creates a loopback client id from the requested scope', () => { + const oauth = client(); + expect(oauth.redirectUri()).toBe('http://localhost:5173/callback'); + expect(oauth.clientId('atproto read')).toBe( + 'http://localhost?redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcallback&scope=atproto+read', + ); + }); + + it('isolates and reads its configured session storage', () => { + const oauth = client(); + localStorage.setItem( + 'oauth-client-test:session', + JSON.stringify({ did: 'did:plc:alice' }), + ); + localStorage.setItem( + 'other-app:session', + JSON.stringify({ did: 'did:plc:other' }), + ); + expect(oauth.currentSession()).toEqual({ did: 'did:plc:alice' }); + }); + + it('stores the exact client id and scope used by a sign-in', async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ authorization_servers: ['https://auth.example'] }), + { headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + issuer: 'https://auth.example', + pushed_authorization_request_endpoint: + 'https://auth.example/oauth/par', + authorization_endpoint: 'https://auth.example/oauth/authorize', + token_endpoint: 'https://auth.example/oauth/token', + }), + { headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ request_uri: 'request-1' }), { + headers: { 'content-type': 'application/json' }, + }), + ); + + const oauth = client(); + await expect( + oauth.beginSignIn('alice.example', { + scope: 'atproto read', + returnTo: '/records', + }), + ).rejects.toThrow('navigation'); + + const pending = JSON.parse( + sessionStorage.getItem('oauth-client-test:pending') || '', + ); + expect(pending.scope).toBe('atproto read'); + expect(pending.clientId).toBe(oauth.clientId('atproto read')); + expect(pending.returnTo).toBe('/records'); + }); +}); diff --git a/packages/spaces-ui/package.json b/packages/spaces-ui/package.json index fd13969..fb24809 100644 --- a/packages/spaces-ui/package.json +++ b/packages/spaces-ui/package.json @@ -12,6 +12,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@pdsjs/core": "workspace:*", + "@pdsjs/oauth-client": "workspace:*", "@pdsjs/spaces": "workspace:*", "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", diff --git a/packages/spaces-ui/src/app.jsx b/packages/spaces-ui/src/app.jsx index dbf0846..851689c 100644 --- a/packages/spaces-ui/src/app.jsx +++ b/packages/spaces-ui/src/app.jsx @@ -38,7 +38,7 @@ function CallbackPage({ navigate }) { // and this effect must not re-run, so it depends on nothing a render makes // anew. completeSignIn(new URLSearchParams(window.location.search)) - .then((session) => { + .then(({ session }) => { setSession(session); navigate('/'); }) diff --git a/packages/spaces-ui/src/lib/credential.js b/packages/spaces-ui/src/lib/credential.js index 12a1250..d48639d 100644 --- a/packages/spaces-ui/src/lib/credential.js +++ b/packages/spaces-ui/src/lib/credential.js @@ -12,16 +12,16 @@ // copy of it taken out of this browser cannot be presented anywhere. import { parseSpaceUri } from '@pdsjs/spaces/uri'; +import { pdsEndpoint, resolveDid } from '#/lib/identity.js'; +import { expiryOf } from '#/lib/jwt.js'; import { + authedFetch, dpopKeyPair, dpopProof, dpopThumbprint, nonceFor, rememberNonce, -} from '#/lib/dpop.js'; -import { pdsEndpoint, resolveDid } from '#/lib/identity.js'; -import { expiryOf } from '#/lib/jwt.js'; -import { authedFetch } from '#/lib/oauth.js'; +} from '#/lib/oauth.js'; /** PDS origin by DID, for every account whose repo this page has opened. */ const hosts = new Map(); diff --git a/packages/spaces-ui/src/lib/dpop.js b/packages/spaces-ui/src/lib/dpop.js deleted file mode 100644 index 0920f32..0000000 --- a/packages/spaces-ui/src/lib/dpop.js +++ /dev/null @@ -1,208 +0,0 @@ -// DPoP (RFC 9449) for a page with no backend. -// -// One key pair serves both credentials this app holds: the OAuth access token, -// which the authorization server binds to it, and the space credential, which a -// space authority binds to the same thumbprint. Both are therefore useless to -// anybody who copies them out of this browser, because a request only counts -// when it carries a fresh proof signed by the private key. -// -// The private key is generated non-extractable and kept in IndexedDB, which is -// the only store that holds a CryptoKey as a key rather than as bytes. Nothing -// in this document can read it, export it, or send it anywhere. - -const DB_NAME = 'pdsjs-spaces-ui'; -const STORE = 'keys'; -const KEY_ID = 'dpop'; - -/** - * @returns {Promise} - */ -function openDb() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, 1); - request.onupgradeneeded = () => { - request.result.createObjectStore(STORE); - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} - -/** - * @template T - * @param {IDBTransactionMode} mode - * @param {(store: IDBObjectStore) => IDBRequest} run - * @returns {Promise} - */ -async function withStore(mode, run) { - const db = await openDb(); - try { - return await new Promise((resolve, reject) => { - const request = run(db.transaction(STORE, mode).objectStore(STORE)); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } finally { - db.close(); - } -} - -/** - * The key pair this browser signs proofs with, generating one on first use. - * @returns {Promise} - */ -export async function dpopKeyPair() { - const stored = /** @type {CryptoKeyPair|undefined} */ ( - await withStore('readonly', (store) => store.get(KEY_ID)) - ); - if (stored?.privateKey) return stored; - - const pair = await crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'P-256' }, - false, - ['sign', 'verify'], - ); - await withStore('readwrite', (store) => store.put(pair, KEY_ID)); - return pair; -} - -/** Forget the key, so the next sign-in cannot be linked to the last one. */ -export async function forgetDpopKey() { - await withStore('readwrite', (store) => store.delete(KEY_ID)); -} - -/** - * @param {Uint8Array} bytes - * @returns {string} - */ -export function base64Url(bytes) { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -/** - * @param {string} text - * @returns {Promise} base64url of the SHA-256 digest - */ -export async function sha256Base64Url(text) { - const digest = await crypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(text), - ); - return base64Url(new Uint8Array(digest)); -} - -/** - * The public half, in the exact member order a JWK thumbprint is computed over. - * @param {CryptoKey} publicKey - * @returns {Promise<{crv: string, kty: string, x: string, y: string}>} - */ -async function publicJwk(publicKey) { - const jwk = await crypto.subtle.exportKey('jwk', publicKey); - return { - crv: String(jwk.crv), - kty: String(jwk.kty), - x: String(jwk.x), - y: String(jwk.y), - }; -} - -/** - * The RFC 7638 thumbprint of an EC public JWK: a hash of the four members the - * RFC names, sorted, and nothing else the key carries. - * @param {{crv: string, kty: string, x: string, y: string}} jwk - * @returns {Promise} - */ -export function thumbprint(jwk) { - return sha256Base64Url( - JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }), - ); -} - -/** - * The thumbprint of the key this browser signs with, which is what a space - * authority binds a credential to. - * @returns {Promise} - */ -export async function dpopThumbprint() { - const pair = await dpopKeyPair(); - return thumbprint(await publicJwk(pair.publicKey)); -} - -/** - * @param {object} value - * @returns {string} - */ -function encodeSegment(value) { - return base64Url(new TextEncoder().encode(JSON.stringify(value))); -} - -/** - * Mint one proof. `htu` carries neither query nor fragment, which is what the - * RFC asks for and what every server this talks to normalizes to anyway. - * - * @param {Object} opts - * @param {CryptoKeyPair} opts.keyPair - * @param {string} opts.method - * @param {string} opts.url - * @param {string} [opts.nonce] - the server's most recent DPoP-Nonce - * @param {string} [opts.token] - the access token or credential to bind to - * @returns {Promise} - */ -export async function dpopProof({ keyPair, method, url, nonce, token }) { - const htu = new URL(url); - htu.search = ''; - htu.hash = ''; - - const header = { - typ: 'dpop+jwt', - alg: 'ES256', - jwk: await publicJwk(keyPair.publicKey), - }; - /** @type {Record} */ - const payload = { - jti: crypto.randomUUID(), - htm: method.toUpperCase(), - htu: htu.toString(), - iat: Math.floor(Date.now() / 1000), - }; - if (nonce) payload.nonce = nonce; - if (token) payload.ath = await sha256Base64Url(token); - - const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`; - const signature = await crypto.subtle.sign( - { name: 'ECDSA', hash: 'SHA-256' }, - keyPair.privateKey, - new TextEncoder().encode(signingInput), - ); - return `${signingInput}.${base64Url(new Uint8Array(signature))}`; -} - -/** - * The most recent DPoP-Nonce each origin handed back. A server rotates its - * nonce whenever it likes, and the only correct next value is the one it sent - * last, so this is read per request rather than cached with a lifetime. - * @type {Map} - */ -const nonces = new Map(); - -/** - * @param {string} url - * @returns {string|undefined} - */ -export function nonceFor(url) { - return nonces.get(new URL(url).origin); -} - -/** - * @param {string} url - * @param {Response} response - */ -export function rememberNonce(url, response) { - const nonce = response.headers.get('dpop-nonce'); - if (nonce) nonces.set(new URL(url).origin, nonce); -} diff --git a/packages/spaces-ui/src/lib/oauth.js b/packages/spaces-ui/src/lib/oauth.js index 6cca5d2..8f6f1a2 100644 --- a/packages/spaces-ui/src/lib/oauth.js +++ b/packages/spaces-ui/src/lib/oauth.js @@ -1,428 +1,41 @@ -// The atproto OAuth client, in the page. -// -// A public client with no backend: PKCE for the code, DPoP for the token, and -// a client_id that is a URL serving this app's own metadata document. The -// deploy writes that document beside the page, so the authorization server -// reads the app's registration from the same site the user is looking at. -// -// The whole flow is here rather than in a library because this app talks to one -// authorization server at a time and needs nothing else the libraries carry. - -import { - dpopKeyPair, - dpopProof, - forgetDpopKey, - nonceFor, - rememberNonce, - sha256Base64Url, -} from '#/lib/dpop.js'; +import { createDpopClient, createOAuthClient } from '@pdsjs/oauth-client'; import { resolveIdentity } from '#/lib/identity.js'; import { SCOPES } from '#/lib/scopes.js'; -const SESSION_KEY = 'pdsjs-spaces-ui:session'; -const PENDING_KEY = 'pdsjs-spaces-ui:pending'; - -/** - * @typedef {Object} Session - * @property {string} did - * @property {string} handle - * @property {string} pds - * @property {string} issuer - the authorization server - * @property {string} tokenEndpoint - * @property {string|null} revocationEndpoint - * @property {string} accessToken - * @property {string} refreshToken - * @property {number} expiresAt - epoch milliseconds - * @property {string} scope - */ - -/** Where the authorization server sends the user back. */ -export function redirectUri() { - return `${window.location.origin}/callback`; -} - -/** - * This app's registration. - * - * On a real origin that is a URL the server fetches. On loopback there is - * nowhere to host one, so the registration states itself in the client_id's own - * query parameters, which is what the atproto profile defines for a client - * running on the user's device. - * - * @param {string} scope - * @returns {string} - */ -export function clientId(scope) { - const { origin, hostname } = window.location; - if (hostname === 'localhost' || hostname === '127.0.0.1') { - const params = new URLSearchParams(); - params.set('redirect_uri', redirectUri()); - params.set('scope', scope); - return `http://localhost?${params}`; - } - return `${origin}/client-metadata.json`; -} - -/** - * @returns {Session|null} - */ -export function currentSession() { - const raw = localStorage.getItem(SESSION_KEY); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -/** @param {Session} session */ -function storeSession(session) { - localStorage.setItem(SESSION_KEY, JSON.stringify(session)); -} - -/** - * A random URL-safe string, used for both the state and the PKCE verifier. - * @returns {string} - */ -function randomToken() { - const bytes = crypto.getRandomValues(new Uint8Array(32)); - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * POST a form to an endpoint with a DPoP proof, retrying once when the server - * answers that it wants its nonce. The first request to any authorization - * server draws that answer, because the client has no nonce to send yet. - * - * @param {string} url - * @param {Record} form - * @returns {Promise} - */ -async function postWithDpop(url, form) { - const keyPair = await dpopKeyPair(); - - for (let attempt = 0; attempt < 2; attempt += 1) { - const proof = await dpopProof({ - keyPair, - method: 'POST', - url, - nonce: nonceFor(url), - }); - const response = await fetch(url, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - dpop: proof, - }, - body: new URLSearchParams(form), - }); - rememberNonce(url, response); - - if (response.ok) return response.json(); - - const body = await response.json().catch(() => ({})); - if (body?.error === 'use_dpop_nonce' && attempt === 0) continue; - throw new Error( - body?.error_description || body?.error || `${url} ${response.status}`, - ); - } - throw new Error('The server kept asking for a new DPoP nonce.'); -} - -/** - * The authorization server a PDS delegates to, and its endpoints. - * @param {string} pds - * @returns {Promise<{issuer: string, parEndpoint: string, authorizeEndpoint: string, tokenEndpoint: string, revocationEndpoint: string|null}>} - */ -async function discoverAuthServer(pds) { - const resource = await fetch(`${pds}/.well-known/oauth-protected-resource`); - if (!resource.ok) { - throw new Error('That PDS does not publish OAuth metadata.'); - } - const authServers = (await resource.json()).authorization_servers || []; - if (authServers.length === 0) { - throw new Error('That PDS names no authorization server.'); - } - const issuer = String(authServers[0]).replace(/\/$/, ''); - - const metadataUrl = `${issuer}/.well-known/oauth-authorization-server`; - const metadata = await fetch(metadataUrl); - if (!metadata.ok) { - throw new Error('The authorization server publishes no metadata.'); - } - const doc = await metadata.json(); - if (doc.issuer !== issuer) { - throw new Error( - 'The authorization server metadata names a different issuer.', - ); - } - if (!doc.pushed_authorization_request_endpoint) { - throw new Error( - 'The authorization server does not accept pushed authorization requests.', - ); - } - return { - issuer, - parEndpoint: doc.pushed_authorization_request_endpoint, - authorizeEndpoint: doc.authorization_endpoint, - tokenEndpoint: doc.token_endpoint, - revocationEndpoint: doc.revocation_endpoint || null, - }; -} - -/** - * Push the authorization request and send the browser to the consent screen. - * This function does not return: it navigates away. - * - * @param {string} input - a handle or DID - * @param {'read'|'write'} access - * @returns {Promise} - */ -export async function beginSignIn(input, access) { - const scope = SCOPES[access]; - const identity = await resolveIdentity(input); - const server = await discoverAuthServer(identity.pds); - - const verifier = randomToken(); - const state = randomToken(); - const challenge = await sha256Base64Url(verifier); - - const { request_uri } = await postWithDpop(server.parEndpoint, { - client_id: clientId(scope), - response_type: 'code', - redirect_uri: redirectUri(), - scope, - state, - code_challenge: challenge, - code_challenge_method: 'S256', - login_hint: identity.handle, - }); - - sessionStorage.setItem( - PENDING_KEY, - JSON.stringify({ - state, - verifier, - scope, - did: identity.did, - handle: identity.handle, - pds: identity.pds, - ...server, - }), - ); - - const authorize = new URL(server.authorizeEndpoint); - authorize.searchParams.set('client_id', clientId(scope)); - authorize.searchParams.set('request_uri', request_uri); - window.location.assign(authorize.toString()); - // The navigation above ends this page, so nothing after it runs. - return await new Promise(() => {}); -} +const dpop = createDpopClient({ databaseName: 'pdsjs-spaces-ui' }); +const client = createOAuthClient({ + dpop, + requireProtectedResourceMetadata: true, + resolveIdentity, + scope: SCOPES.read, + storagePrefix: 'pdsjs-spaces-ui', +}); + +/** @typedef {import('@pdsjs/oauth-client/oauth').OAuthSession} Session */ + +export const authedFetch = client.authedFetch; +export const beginSignIn = client.beginSignIn; +export const clearRenewal = client.clearRenewal; +export const clientId = client.clientId; +export const completeSignIn = client.completeSignIn; +export const currentSession = client.currentSession; +export const onSessionEnded = client.onSessionEnded; +export const renewGrant = client.renewGrant; +export const redirectUri = client.redirectUri; +export const signOut = client.signOut; +export const dpopKeyPair = dpop.dpopKeyPair; +export const dpopProof = dpop.dpopProof; +export const dpopThumbprint = dpop.dpopThumbprint; +export const forgetDpopKey = dpop.forgetDpopKey; +export const nonceFor = dpop.nonceFor; +export const rememberNonce = dpop.rememberNonce; +export const sha256Base64Url = dpop.sha256Base64Url; +export const thumbprint = dpop.thumbprint; /** - * Exchange the code the authorization server sent back for a session. - * @param {URLSearchParams} params - the callback's query string - * @returns {Promise} - */ -export async function completeSignIn(params) { - const error = params.get('error'); - if (error) { - throw new Error(params.get('error_description') || error); - } - const code = params.get('code'); - const state = params.get('state'); - if (!code || !state) throw new Error('That callback carried no code.'); - - const raw = sessionStorage.getItem(PENDING_KEY); - if (!raw) throw new Error('No sign-in was in progress in this tab.'); - const pending = JSON.parse(raw); - sessionStorage.removeItem(PENDING_KEY); - - if (pending.state !== state) { - throw new Error('That callback answers a different sign-in.'); - } - // The issuer identifies which server answered, and a mismatch is the mix-up - // attack the parameter exists to stop. - const issuer = params.get('iss'); - if (issuer && issuer.replace(/\/$/, '') !== pending.issuer) { - throw new Error( - 'That callback came from a different authorization server.', - ); - } - - const token = await postWithDpop(pending.tokenEndpoint, { - client_id: clientId(pending.scope), - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri(), - code_verifier: pending.verifier, - }); - - if (token.sub !== pending.did) { - throw new Error('The server issued a token for a different account.'); - } - - /** @type {Session} */ - const session = { - did: pending.did, - handle: pending.handle, - pds: pending.pds, - issuer: pending.issuer, - tokenEndpoint: pending.tokenEndpoint, - revocationEndpoint: pending.revocationEndpoint, - accessToken: token.access_token, - refreshToken: token.refresh_token, - expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000, - scope: token.scope || pending.scope, - }; - storeSession(session); - return session; -} - -/** - * Whether the granted scope lets this session change anything. Read from what - * the server granted rather than from what the sign-in asked for, so an editor - * is offered only where a write would be accepted. * @param {Session|null} session * @returns {boolean} */ export function canWrite(session) { return (session?.scope || '').includes('action=create'); } - -/** - * Drop the session, telling the server about it where the server offers a way. - * The DPoP key goes too: it is what binds this browser to the tokens, and a - * fresh one makes the next sign-in unlinkable to the last. - * @param {Session|null} session - */ -export async function signOut(session) { - if (session?.revocationEndpoint) { - try { - await fetch(session.revocationEndpoint, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - token: session.refreshToken, - client_id: clientId(session.scope), - }), - }); - } catch { - // Signing out of this browser does not depend on the server hearing it. - } - } - localStorage.removeItem(SESSION_KEY); - await forgetDpopKey(); -} - -// A token is replaced this long before it expires, so a request never races -// its own credential. -const REFRESH_MARGIN_MS = 30_000; - -/** @type {Promise|null} */ -let refreshing = null; - -/** - * A session with an access token good for the next request, refreshing it when - * it is not. Concurrent callers share one refresh: the server invalidates a - * refresh token as it spends it, so a second spend of the same one fails. - * - * @param {Session} session - * @returns {Promise} - */ -async function fresh(session) { - if (session.expiresAt - REFRESH_MARGIN_MS > Date.now()) return session; - refreshing ??= (async () => { - try { - const token = await postWithDpop(session.tokenEndpoint, { - client_id: clientId(session.scope), - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }); - const next = { - ...session, - accessToken: token.access_token, - refreshToken: token.refresh_token || session.refreshToken, - expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000, - }; - storeSession(next); - return next; - } finally { - refreshing = null; - } - })(); - return refreshing; -} - -/** @type {(() => void)|null} */ -let sessionEndedCallback = null; - -/** - * Register what to do when the server refuses this session. The app holds the - * session in React state, and a token the server has stopped accepting is one - * only the app can take off the screen. - * @param {() => void} callback - */ -export function onSessionEnded(callback) { - sessionEndedCallback = callback; -} - -/** - * The session is over, whatever the page still shows. - * @returns {Error} - */ -function sessionExpired() { - localStorage.removeItem(SESSION_KEY); - sessionEndedCallback?.(); - return new Error('That session is no longer valid. Sign in again.'); -} - -/** - * Call an endpoint as the signed-in account. - * - * Two retries, each for one thing the server can ask for rather than an error: - * a DPoP nonce it wants the proof to carry, and an access token that has gone - * stale earlier than its stated lifetime. - * - * @param {Session} session - * @param {string} url - * @param {RequestInit} [init] - * @returns {Promise} - */ -export async function authedFetch(session, url, init = {}) { - let active = await fresh(session); - let refreshed = false; - - for (let attempt = 0; attempt < 3; attempt += 1) { - const keyPair = await dpopKeyPair(); - const proof = await dpopProof({ - keyPair, - method: init.method || 'GET', - url, - nonce: nonceFor(url), - token: active.accessToken, - }); - const response = await fetch(url, { - ...init, - headers: { - ...(init.headers || {}), - authorization: `DPoP ${active.accessToken}`, - dpop: proof, - }, - }); - rememberNonce(url, response); - - if (response.status !== 401) return response; - - const challenge = response.headers.get('www-authenticate') || ''; - if (challenge.includes('use_dpop_nonce')) continue; - if (!refreshed) { - refreshed = true; - active = await fresh({ ...active, expiresAt: 0 }); - continue; - } - throw sessionExpired(); - } - throw sessionExpired(); -} diff --git a/packages/spaces-ui/src/pages/credential.jsx b/packages/spaces-ui/src/pages/credential.jsx index 665bb3c..681ba46 100644 --- a/packages/spaces-ui/src/pages/credential.jsx +++ b/packages/spaces-ui/src/pages/credential.jsx @@ -13,10 +13,10 @@ import { heldCredential, requestDelegation, } from '#/lib/credential.js'; -import { dpopThumbprint } from '#/lib/dpop.js'; import { until } from '#/lib/format.js'; import { decodeJwt } from '#/lib/jwt.js'; import { href } from '#/lib/navigation.jsx'; +import { dpopThumbprint } from '#/lib/oauth.js'; import { useSession } from '#/lib/session.jsx'; /** diff --git a/packages/spaces-ui/src/pages/sign-in.jsx b/packages/spaces-ui/src/pages/sign-in.jsx index d5aa5a5..8629de7 100644 --- a/packages/spaces-ui/src/pages/sign-in.jsx +++ b/packages/spaces-ui/src/pages/sign-in.jsx @@ -65,14 +65,14 @@ export function SignInPage() { setError(null); setBusy(true); try { - await beginSignIn(identifier, access); + await beginSignIn(identifier, { scope: SCOPES[access] }); } catch (err) { // The origin publishes its own account's DID, which resolves wherever the // handle beside it does not. A handle on a domain with no public DNS is // the case: a local stack, or an account mid-handle-change. if (host && identifier.trim().toLowerCase() === host.handle) { try { - await beginSignIn(host.did, access); + await beginSignIn(host.did, { scope: SCOPES[access] }); return; } catch { // The DID failed too, so the first message is the useful one. diff --git a/packages/spaces-ui/test/dpop.test.js b/packages/spaces-ui/test/dpop.test.js index 9b2ccda..a5bc9ac 100644 --- a/packages/spaces-ui/test/dpop.test.js +++ b/packages/spaces-ui/test/dpop.test.js @@ -3,14 +3,14 @@ import { computeJwkThumbprint } from '@pdsjs/core/crypto'; import { parseDpopProof } from '@pdsjs/core/oauth'; -import { describe, expect, it } from 'vitest'; import { base64Url, dpopProof, nonceFor, rememberNonce, thumbprint, -} from '#/lib/dpop.js'; +} from '@pdsjs/oauth-client/dpop'; +import { describe, expect, it } from 'vitest'; /** @returns {Promise} */ function keyPair() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c673b2..c1a0f05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@pdsjs/npm': specifier: workspace:* version: link:../npm + '@pdsjs/oauth-client': + specifier: workspace:* + version: link:../oauth-client '@pdsjs/oci': specifier: workspace:* version: link:../oci @@ -424,6 +427,8 @@ importers: specifier: workspace:* version: link:../core + packages/oauth-client: {} + packages/oci: dependencies: '@pdsjs/core': @@ -465,6 +470,9 @@ importers: '@pdsjs/core': specifier: workspace:* version: link:../core + '@pdsjs/oauth-client': + specifier: workspace:* + version: link:../oauth-client '@pdsjs/spaces': specifier: workspace:* version: link:../spaces -- 2.51.2