diff --git a/apps/release-service/migrations/0001_oauth_custody.sql b/apps/release-service/migrations/0001_oauth_custody.sql new file mode 100644 index 0000000000..c872685f08 --- /dev/null +++ b/apps/release-service/migrations/0001_oauth_custody.sql @@ -0,0 +1,76 @@ +CREATE TABLE publisher_accounts ( + did TEXT PRIMARY KEY, + handle TEXT, + pds_url TEXT, + pds_resolved_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE oauth_transactions ( + id TEXT PRIMARY KEY, + state_hash TEXT NOT NULL UNIQUE, + purpose TEXT NOT NULL CHECK (purpose IN ('console_login', 'approver_identity', 'release_delegation')), + expected_did TEXT, + client_key_id TEXT NOT NULL, + encrypted_state TEXT NOT NULL, + encryption_key_version INTEGER NOT NULL, + redirect_target TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_oauth_transactions_purpose_expiry + ON oauth_transactions(purpose, expires_at); +CREATE INDEX idx_oauth_transactions_expected_did + ON oauth_transactions(expected_did, purpose); + +CREATE TABLE console_sessions ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + publisher_did TEXT NOT NULL, + encrypted_csrf_secret TEXT NOT NULL, + encryption_key_version INTEGER NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + FOREIGN KEY (publisher_did) REFERENCES publisher_accounts(did) ON DELETE CASCADE +); + +CREATE INDEX idx_console_sessions_owner_expiry + ON console_sessions(publisher_did, expires_at); + +CREATE TABLE delegations ( + id TEXT PRIMARY KEY, + publisher_did TEXT NOT NULL, + release_nsid TEXT NOT NULL, + encrypted_session TEXT, + encryption_key_version INTEGER, + client_key_id TEXT NOT NULL, + scope TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'refreshing', 'reauthorization_required', 'revoked')), + state_version INTEGER NOT NULL DEFAULT 1 CHECK (state_version >= 1), + lease_owner TEXT, + lease_expires_at TEXT, + last_refreshed_at TEXT, + refresh_before TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + FOREIGN KEY (publisher_did) REFERENCES publisher_accounts(did) ON DELETE CASCADE, + CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)), + CHECK ((lease_owner IS NULL) = (lease_expires_at IS NULL)), + CHECK (encrypted_session IS NOT NULL OR status = 'revoked') +); + +CREATE UNIQUE INDEX idx_delegations_active_grant + ON delegations(publisher_did, release_nsid) + WHERE revoked_at IS NULL; +CREATE INDEX idx_delegations_owner_status + ON delegations(publisher_did, status); +CREATE INDEX idx_delegations_refresh_before + ON delegations(refresh_before) + WHERE status = 'active' AND refresh_before IS NOT NULL; +CREATE INDEX idx_delegations_lease_expiry + ON delegations(lease_expires_at) + WHERE status = 'refreshing'; diff --git a/apps/release-service/package.json b/apps/release-service/package.json index e27b5b632c..e20f1f5710 100644 --- a/apps/release-service/package.json +++ b/apps/release-service/package.json @@ -23,6 +23,9 @@ "wrangler": "catalog:" }, "dependencies": { - "jose": "^6.1.3" + "@atcute/oauth-node-client": "2.0.1", + "@emdash-cms/registry-lexicons": "workspace:*", + "jose": "^6.1.3", + "ulidx": "^2.4.1" } } diff --git a/apps/release-service/src/api/schema.ts b/apps/release-service/src/api/schema.ts index 9d3ac3731b..687df38a9b 100644 --- a/apps/release-service/src/api/schema.ts +++ b/apps/release-service/src/api/schema.ts @@ -29,6 +29,7 @@ const errorEnvelopeSchema = { export function generateApiSchema(routes: readonly RouteDefinition[] = ROUTES) { const paths: Record> = {}; for (const route of routes) { + if (route.includeInApiSchema === false) continue; const path = (paths[route.path] ??= {}); path[route.method.toLowerCase()] = { operationId: route.operationId, diff --git a/apps/release-service/src/config.ts b/apps/release-service/src/config.ts index f3f867e048..6acbd06dac 100644 --- a/apps/release-service/src/config.ts +++ b/apps/release-service/src/config.ts @@ -1,3 +1,10 @@ +import { + Keyset, + type ClientAssertionPrivateJwk, + type ConfidentialClientMetadata, +} from "@atcute/oauth-node-client"; +import { getDelegatedReleasePermission } from "@emdash-cms/registry-lexicons"; + import { createEnvelopeEncryption, type EnvelopeEncryption } from "./crypto/encryption.js"; export type ConfigurationBindings = Record< @@ -8,6 +15,8 @@ export type ConfigurationBindings = Record< | "ALLOWED_PUBLISHERS" | "DEPLOYMENT_POLICY" | "ENCRYPTION_KEYRING" + | "OAUTH_REDIRECT_URIS" + | "OAUTH_ASSERTION_KEYSET" >, string >; @@ -15,6 +24,24 @@ export type ConfigurationBindings = Record< export type DeploymentPolicy = "hosted" | "self-hosted"; const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; +const MAX_ASSERTION_KEYSET_CHARS = 64 * 1024; +const MAX_ASSERTION_KEYS = 8; +const CONFIGURATION_CACHE_SYMBOL = Symbol.for("@emdash-cms/release-service/configuration-cache"); +const CONFIGURATION_BINDING_KEYS = [ + "PUBLIC_ORIGIN", + "ALLOWED_ORIGINS", + "ALLOWED_PUBLISHERS", + "DEPLOYMENT_POLICY", + "ENCRYPTION_KEYRING", + "OAUTH_REDIRECT_URIS", + "OAUTH_ASSERTION_KEYSET", +] as const satisfies readonly (keyof ConfigurationBindings)[]; + +interface ConfigurationCacheEntry { + snapshot: readonly string[]; + promise: Promise; +} interface AllowAllPublishers { mode: "all"; @@ -32,9 +59,20 @@ export interface ServiceConfiguration { allowedOrigins: ReadonlySet; deploymentPolicy: DeploymentPolicy; encryption: EnvelopeEncryption; + oauth: OAuthConfiguration; isPublisherAllowed(did: string): boolean; } +export interface OAuthConfiguration { + clientMetadata: ConfidentialClientMetadata & { client_uri: string }; + releaseNsid: string; + releaseScope: string; + activeAssertionKeyId: string; + assertionKeys: readonly ClientAssertionPrivateJwk[]; + keyset: Keyset; + hasAssertionKey(keyId: string): boolean; +} + export class ConfigurationError extends Error { readonly issues: readonly string[]; @@ -60,6 +98,126 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function hasExactKeys(record: Record, expected: readonly string[]): boolean { + const keys = Object.keys(record); + return keys.length === expected.length && keys.every((key) => expected.includes(key)); +} + +function isBase64UrlBytes(value: unknown, byteLength: number): value is string { + if (typeof value !== "string" || !BASE64URL_PATTERN.test(value) || value.length % 4 === 1) { + return false; + } + try { + const binary = atob( + value + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(value.length + ((4 - (value.length % 4)) % 4), "="), + ); + return binary.length === byteLength; + } catch { + return false; + } +} + +function parseRedirectUris(value: string, publicOrigin: string): readonly [string] | null { + try { + const parsed: unknown = JSON.parse(value); + const expected = `${publicOrigin}/oauth/callback`; + return Array.isArray(parsed) && parsed.length === 1 && parsed[0] === expected + ? [expected] + : null; + } catch { + return null; + } +} + +async function parseAssertionKeyset(value: string): Promise<{ + active: string; + keys: readonly ClientAssertionPrivateJwk[]; + keyset: Keyset; +} | null> { + try { + if (value.length === 0 || value.length > MAX_ASSERTION_KEYSET_CHARS) return null; + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed) || !hasExactKeys(parsed, ["active", "keys"])) return null; + if ( + typeof parsed["active"] !== "string" || + !Array.isArray(parsed["keys"]) || + parsed["keys"].length === 0 || + parsed["keys"].length > MAX_ASSERTION_KEYS + ) { + return null; + } + const keys: ClientAssertionPrivateJwk[] = []; + const keyIds = new Set(); + for (const entry of parsed["keys"]) { + if ( + !isRecord(entry) || + !hasExactKeys(entry, ["kty", "crv", "x", "y", "d", "kid", "alg", "use"]) || + entry["kty"] !== "EC" || + entry["crv"] !== "P-256" || + entry["alg"] !== "ES256" || + entry["use"] !== "sig" || + typeof entry["kid"] !== "string" || + entry["kid"].length === 0 || + entry["kid"].length > 128 || + keyIds.has(entry["kid"]) || + !isBase64UrlBytes(entry["x"], 32) || + !isBase64UrlBytes(entry["y"], 32) || + !isBase64UrlBytes(entry["d"], 32) + ) { + return null; + } + const key: ClientAssertionPrivateJwk = { + kty: "EC", + crv: "P-256", + x: entry["x"], + y: entry["y"], + d: entry["d"], + kid: entry["kid"], + alg: "ES256", + use: "sig", + }; + const algorithm = { name: "ECDSA", namedCurve: "P-256" }; + const privateKey = await crypto.subtle.importKey("jwk", key, algorithm, false, ["sign"]); + const publicKey = await crypto.subtle.importKey( + "jwk", + { kty: key.kty, crv: key.crv, x: key.x, y: key.y }, + algorithm, + false, + ["verify"], + ); + const challenge = new TextEncoder().encode("emdash-oauth-assertion-key-validation"); + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + challenge, + ); + if ( + !(await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + signature, + challenge, + )) + ) { + return null; + } + keys.push(key); + keyIds.add(key.kid); + } + if (keys.length === 0 || !keyIds.has(parsed["active"])) return null; + keys.sort( + (left, right) => + Number(right.kid === parsed["active"]) - Number(left.kid === parsed["active"]), + ); + return { active: parsed["active"], keys, keyset: new Keyset(keys) }; + } catch { + return null; + } +} + function parseAllowedOrigins(value: string): ReadonlySet | null { try { const parsed: unknown = JSON.parse(value); @@ -97,7 +255,7 @@ function parseAllowedPublishers(value: string): AllowedPublisherPolicy | null { } } -export function loadConfiguration(bindings: ConfigurationBindings): ServiceConfiguration { +async function parseConfiguration(bindings: ConfigurationBindings): Promise { const issues: string[] = []; const publicOrigin = parseOrigin(bindings.PUBLIC_ORIGIN); if (!publicOrigin) issues.push("PUBLIC_ORIGIN_INVALID"); @@ -120,21 +278,72 @@ export function loadConfiguration(bindings: ConfigurationBindings): ServiceConfi } catch { issues.push("ENCRYPTION_KEYRING_INVALID"); } + const redirectUris = publicOrigin + ? parseRedirectUris(bindings.OAUTH_REDIRECT_URIS, publicOrigin) + : null; + if (!redirectUris) issues.push("OAUTH_REDIRECT_URIS_INVALID"); + const assertionKeyset = await parseAssertionKeyset(bindings.OAUTH_ASSERTION_KEYSET); + if (!assertionKeyset) issues.push("OAUTH_ASSERTION_KEYSET_INVALID"); if ( !publicOrigin || !allowedOrigins || !publisherPolicy || !deploymentPolicy || !encryption || + !redirectUris || + !assertionKeyset || issues.length > 0 ) { throw new ConfigurationError(issues); } + const permission = getDelegatedReleasePermission(); + const clientMetadata: OAuthConfiguration["clientMetadata"] = { + client_id: `${publicOrigin}/.well-known/atproto-client-metadata.json`, + client_name: "EmDash delegated release service", + client_uri: publicOrigin, + application_type: "web", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + redirect_uris: [...redirectUris], + scope: permission.scope, + jwks_uri: `${publicOrigin}/oauth/jwks.json`, + dpop_bound_access_tokens: true, + token_endpoint_auth_method: "private_key_jwt", + token_endpoint_auth_signing_alg: "ES256", + }; return { publicOrigin, allowedOrigins, deploymentPolicy, encryption, + oauth: { + clientMetadata, + releaseNsid: permission.collection, + releaseScope: permission.scope, + activeAssertionKeyId: assertionKeyset.active, + assertionKeys: assertionKeyset.keys, + keyset: assertionKeyset.keyset, + hasAssertionKey: (keyId) => assertionKeyset.keys.some((key) => key.kid === keyId), + }, isPublisherAllowed: (did) => publisherPolicy.mode === "all" || publisherPolicy.dids.has(did), }; } + +function getConfigurationCache(): WeakMap { + const target = globalThis as typeof globalThis & { + [CONFIGURATION_CACHE_SYMBOL]?: WeakMap; + }; + return (target[CONFIGURATION_CACHE_SYMBOL] ??= new WeakMap()); +} + +export function loadConfiguration(bindings: ConfigurationBindings): Promise { + const snapshot = CONFIGURATION_BINDING_KEYS.map((key) => bindings[key]); + const cache = getConfigurationCache(); + const cached = cache.get(bindings); + if (cached?.snapshot.every((value, index) => value === snapshot[index])) { + return cached.promise; + } + const promise = parseConfiguration(bindings); + cache.set(bindings, { snapshot, promise }); + return promise; +} diff --git a/apps/release-service/src/crypto/encryption.ts b/apps/release-service/src/crypto/encryption.ts index bd4740e27e..cd63a89e04 100644 --- a/apps/release-service/src/crypto/encryption.ts +++ b/apps/release-service/src/crypto/encryption.ts @@ -18,12 +18,16 @@ const OWNED_PURPOSES: ReadonlySet = new Set([ "email-address", "webhook-destination", "webhook-secret", + "csrf-secret", ]); const UNOWNED_PURPOSES: ReadonlySet = new Set([ "confidential-client-private-key", ]); const OPTIONAL_OWNER_PURPOSES: ReadonlySet = new Set([ "oauth-transaction", + "oauth-console-transaction", + "oauth-approver-transaction", + "oauth-delegation-transaction", ]); export type OwnedEncryptionPurpose = @@ -31,9 +35,14 @@ export type OwnedEncryptionPurpose = | "dpop-private-key" | "email-address" | "webhook-destination" - | "webhook-secret"; - -export type OptionalOwnerEncryptionPurpose = "oauth-transaction"; + | "webhook-secret" + | "csrf-secret"; + +export type OptionalOwnerEncryptionPurpose = + | "oauth-transaction" + | "oauth-console-transaction" + | "oauth-approver-transaction" + | "oauth-delegation-transaction"; export type UnownedEncryptionPurpose = "confidential-client-private-key"; diff --git a/apps/release-service/src/index.ts b/apps/release-service/src/index.ts index 859d1b1071..2ecc161027 100644 --- a/apps/release-service/src/index.ts +++ b/apps/release-service/src/index.ts @@ -20,7 +20,7 @@ export async function handleRequest( ): Promise { const requestId = getRequestId(request); try { - const configuration = loadConfiguration(bindings); + const configuration = await loadConfiguration(bindings); const url = new URL(request.url); const route = routes.find( (candidate) => candidate.path === url.pathname && candidate.method === request.method, diff --git a/apps/release-service/src/oauth/metadata.ts b/apps/release-service/src/oauth/metadata.ts new file mode 100644 index 0000000000..1a5783bf6b --- /dev/null +++ b/apps/release-service/src/oauth/metadata.ts @@ -0,0 +1,45 @@ +import type { OAuthConfiguration } from "../config.js"; + +interface PublicAssertionJwk { + kty: "EC"; + crv: "P-256"; + x: string; + y: string; + kid: string; + alg: "ES256"; + use: "sig"; +} + +export function getClientMetadata(configuration: OAuthConfiguration) { + return configuration.clientMetadata; +} + +export function getPublicJwks(configuration: OAuthConfiguration): { + keys: readonly PublicAssertionJwk[]; +} { + return { + keys: configuration.assertionKeys.map((key) => { + if (key.kty !== "EC" || key.crv !== "P-256" || key.alg !== "ES256") { + throw new Error("Invalid configured assertion key"); + } + return { + kty: "EC", + crv: "P-256", + x: key.x, + y: key.y, + kid: key.kid, + alg: "ES256", + use: "sig", + }; + }), + }; +} + +export function publicOAuthJson(value: unknown): Response { + return Response.json(value, { + headers: { + "cache-control": "public, max-age=300", + "content-type": "application/json; charset=utf-8", + }, + }); +} diff --git a/apps/release-service/src/oauth/store.ts b/apps/release-service/src/oauth/store.ts new file mode 100644 index 0000000000..bc42f537e7 --- /dev/null +++ b/apps/release-service/src/oauth/store.ts @@ -0,0 +1,901 @@ +import { + MemoryStore, + type OAuthClientStores, + type Store, + type StoredSession, + type StoredState, +} from "@atcute/oauth-node-client"; +import { ulid } from "ulidx"; + +import type { OAuthConfiguration } from "../config.js"; +import type { + EncryptionContext, + EnvelopeEncryption, + OptionalOwnerEncryptionPurpose, +} from "../crypto/encryption.js"; + +const DID_PATTERN = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/; +const BASE64_PADDING_PATTERN = /=+$/; +const MAX_DELEGATION_LEASE_MS = 5 * 60_000; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +type Did = `did:${string}:${string}`; + +export type OAuthPurpose = "console_login" | "approver_identity" | "release_delegation"; +export type OAuthCustodyErrorCode = + | "OAUTH_CLIENT_KEY_UNAVAILABLE" + | "OAUTH_CLIENT_AUTH_INVALID" + | "OAUTH_SCOPE_INVALID" + | "OAUTH_IDENTITY_MISMATCH" + | "OAUTH_STATE_INVALID" + | "OAUTH_SESSION_INVALID" + | "OAUTH_REDIRECT_INVALID" + | "OAUTH_DELEGATION_CAS_REQUIRED"; + +const ERROR_MESSAGES: Record = { + OAUTH_CLIENT_KEY_UNAVAILABLE: "OAuth client key is unavailable", + OAUTH_CLIENT_AUTH_INVALID: "OAuth client authentication is invalid", + OAUTH_SCOPE_INVALID: "OAuth scope is invalid", + OAUTH_IDENTITY_MISMATCH: "OAuth identity does not match", + OAUTH_STATE_INVALID: "OAuth state is invalid", + OAUTH_SESSION_INVALID: "OAuth session is invalid", + OAUTH_REDIRECT_INVALID: "OAuth redirect is invalid", + OAUTH_DELEGATION_CAS_REQUIRED: "OAuth delegation requires a compare-and-set update", +}; + +export class OAuthCustodyError extends Error { + readonly code: OAuthCustodyErrorCode; + readonly reauthorizationRequired: boolean; + + constructor(code: OAuthCustodyErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = "OAuthCustodyError"; + this.code = code; + this.reauthorizationRequired = code === "OAUTH_CLIENT_KEY_UNAVAILABLE"; + } +} + +export type OAuthStoreOptions = + | { purpose: "console_login"; expectedDid: Did | null; redirectTarget: string } + | { purpose: "approver_identity"; expectedDid: Did; redirectTarget: string } + | { purpose: "release_delegation"; expectedDid: Did; redirectTarget: string }; + +export interface OAuthUserState { + redirectTarget: string; +} + +type PublisherPdsUpdate = + | { pdsUrl?: never; pdsResolvedAt?: never } + | { pdsUrl: string; pdsResolvedAt: Date } + | { pdsUrl: null; pdsResolvedAt?: Date | null } + | { pdsUrl?: string | null; pdsResolvedAt: null }; + +interface TransactionRow { + id: string; + expected_did: string | null; + client_key_id: string; + encrypted_state: string; + redirect_target: string; + expires_at: string; +} + +interface DelegationRow { + id: string; + publisher_did: string; + release_nsid: string; + encrypted_session: string | null; + client_key_id: string; + scope: string; + status: "active" | "refreshing" | "reauthorization_required" | "revoked"; + state_version: number; + lease_owner: string | null; + lease_expires_at: string | null; + refresh_before: string | null; +} + +export interface Delegation extends DelegationRow {} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isDid(value: unknown): value is Did { + return typeof value === "string" && DID_PATTERN.test(value); +} + +function getClientKeyId(value: { authMethod: StoredState["authMethod"] }): string { + if ( + value.authMethod.method !== "private_key_jwt" || + typeof value.authMethod.kid !== "string" || + value.authMethod.kid.length === 0 + ) { + throw new OAuthCustodyError("OAUTH_CLIENT_AUTH_INVALID"); + } + return value.authMethod.kid; +} + +function assertDpopKey(value: unknown): asserts value is StoredSession["dpopKey"] { + if ( + !isRecord(value) || + value["kty"] !== "EC" || + value["crv"] !== "P-256" || + value["alg"] !== "ES256" || + typeof value["x"] !== "string" || + typeof value["y"] !== "string" || + typeof value["d"] !== "string" + ) { + throw new OAuthCustodyError("OAUTH_SESSION_INVALID"); + } +} + +function parseStoredState(value: string): StoredState { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new OAuthCustodyError("OAUTH_STATE_INVALID"); + } + if ( + !isRecord(parsed) || + !isRecord(parsed["authMethod"]) || + parsed["authMethod"]["method"] !== "private_key_jwt" || + typeof parsed["authMethod"]["kid"] !== "string" || + typeof parsed["pkceVerifier"] !== "string" || + typeof parsed["issuer"] !== "string" || + typeof parsed["redirectUri"] !== "string" || + (typeof parsed["sub"] !== "undefined" && !isDid(parsed["sub"])) || + typeof parsed["expiresAt"] !== "number" || + !Number.isFinite(parsed["expiresAt"]) + ) { + throw new OAuthCustodyError("OAUTH_STATE_INVALID"); + } + try { + assertDpopKey(parsed["dpopKey"]); + } catch { + throw new OAuthCustodyError("OAUTH_STATE_INVALID"); + } + return { + dpopKey: parsed["dpopKey"], + authMethod: { method: "private_key_jwt", kid: parsed["authMethod"]["kid"] }, + pkceVerifier: parsed["pkceVerifier"], + issuer: parsed["issuer"], + redirectUri: parsed["redirectUri"], + ...(parsed["sub"] ? { sub: parsed["sub"] } : {}), + ...("userState" in parsed ? { userState: parsed["userState"] } : {}), + expiresAt: parsed["expiresAt"], + }; +} + +function parseStoredSession(value: string): StoredSession { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new OAuthCustodyError("OAUTH_SESSION_INVALID"); + } + if ( + !isRecord(parsed) || + !isRecord(parsed["authMethod"]) || + parsed["authMethod"]["method"] !== "private_key_jwt" || + typeof parsed["authMethod"]["kid"] !== "string" || + !isRecord(parsed["tokenSet"]) || + typeof parsed["tokenSet"]["iss"] !== "string" || + !isDid(parsed["tokenSet"]["sub"]) || + typeof parsed["tokenSet"]["aud"] !== "string" || + typeof parsed["tokenSet"]["scope"] !== "string" || + typeof parsed["tokenSet"]["access_token"] !== "string" || + (typeof parsed["tokenSet"]["refresh_token"] !== "undefined" && + typeof parsed["tokenSet"]["refresh_token"] !== "string") || + parsed["tokenSet"]["token_type"] !== "DPoP" || + (typeof parsed["tokenSet"]["expires_at"] !== "undefined" && + typeof parsed["tokenSet"]["expires_at"] !== "number") + ) { + throw new OAuthCustodyError("OAUTH_SESSION_INVALID"); + } + assertDpopKey(parsed["dpopKey"]); + return { + dpopKey: parsed["dpopKey"], + authMethod: { method: "private_key_jwt", kid: parsed["authMethod"]["kid"] }, + tokenSet: { + iss: parsed["tokenSet"]["iss"], + sub: parsed["tokenSet"]["sub"], + aud: parsed["tokenSet"]["aud"], + scope: parsed["tokenSet"]["scope"], + access_token: parsed["tokenSet"]["access_token"], + ...(parsed["tokenSet"]["refresh_token"] + ? { refresh_token: parsed["tokenSet"]["refresh_token"] } + : {}), + ...(typeof parsed["tokenSet"]["expires_at"] === "number" + ? { expires_at: parsed["tokenSet"]["expires_at"] } + : {}), + token_type: "DPoP", + }, + }; +} + +async function hashOpaque(value: string): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))); + let binary = ""; + for (const byte of digest) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(BASE64_PADDING_PATTERN, ""); +} + +function transactionEncryptionPurpose(purpose: OAuthPurpose): OptionalOwnerEncryptionPurpose { + switch (purpose) { + case "console_login": + return "oauth-console-transaction"; + case "approver_identity": + return "oauth-approver-transaction"; + case "release_delegation": + return "oauth-delegation-transaction"; + } +} + +function canonicalizeRedirectTarget(value: string, publicOrigin: string): string { + if (typeof value !== "string") throw new OAuthCustodyError("OAUTH_REDIRECT_INVALID"); + let hasControlCharacter = false; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || codeUnit === 0x7f) { + hasControlCharacter = true; + break; + } + } + if ( + !value.startsWith("/") || + value.startsWith("//") || + value.includes("\\") || + hasControlCharacter + ) { + throw new OAuthCustodyError("OAUTH_REDIRECT_INVALID"); + } + try { + const url = new URL(value, publicOrigin); + if (url.origin !== publicOrigin) throw new OAuthCustodyError("OAUTH_REDIRECT_INVALID"); + return `${url.pathname}${url.search}${url.hash}`; + } catch (error) { + if (error instanceof OAuthCustodyError) throw error; + throw new OAuthCustodyError("OAUTH_REDIRECT_INVALID"); + } +} + +function parseOAuthUserState( + value: unknown, + expectedRedirectTarget: string, + publicOrigin: string, +): OAuthUserState { + if ( + !isRecord(value) || + Object.keys(value).length !== 1 || + typeof value["redirectTarget"] !== "string" || + canonicalizeRedirectTarget(value["redirectTarget"], publicOrigin) !== expectedRedirectTarget + ) { + throw new OAuthCustodyError("OAUTH_REDIRECT_INVALID"); + } + return { redirectTarget: expectedRedirectTarget }; +} + +export class OAuthCustodyRepository { + readonly #db: D1Database; + readonly #encryption: EnvelopeEncryption; + readonly #oauth: OAuthConfiguration; + + constructor(db: D1Database, encryption: EnvelopeEncryption, oauth: OAuthConfiguration) { + this.#db = db; + this.#encryption = encryption; + this.#oauth = oauth; + } + + async upsertPublisher( + input: { + did: string; + handle?: string | null; + now?: Date; + } & PublisherPdsUpdate, + ): Promise { + if (!isDid(input.did)) throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); + const now = (input.now ?? new Date()).toISOString(); + const hasHandle = input.handle !== undefined; + const hasPdsUpdate = input.pdsUrl !== undefined || input.pdsResolvedAt !== undefined; + let pdsUrl: string | null = null; + let pdsResolvedAt: string | null = null; + if (hasPdsUpdate && input.pdsUrl !== null && input.pdsResolvedAt !== null) { + if ( + typeof input.pdsUrl !== "string" || + !(input.pdsResolvedAt instanceof Date) || + !Number.isFinite(input.pdsResolvedAt.getTime()) + ) { + throw new TypeError("PDS URL and resolution time must be set together"); + } + pdsUrl = input.pdsUrl; + pdsResolvedAt = input.pdsResolvedAt.toISOString(); + } + await this.#db + .prepare( + `INSERT INTO publisher_accounts ( + did, handle, pds_url, pds_resolved_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(did) DO UPDATE SET + handle = CASE WHEN ? THEN excluded.handle ELSE publisher_accounts.handle END, + pds_url = CASE WHEN ? THEN excluded.pds_url ELSE publisher_accounts.pds_url END, + pds_resolved_at = CASE WHEN ? THEN excluded.pds_resolved_at ELSE publisher_accounts.pds_resolved_at END, + updated_at = excluded.updated_at`, + ) + .bind( + input.did, + input.handle ?? null, + pdsUrl, + pdsResolvedAt, + now, + now, + hasHandle ? 1 : 0, + hasPdsUpdate ? 1 : 0, + hasPdsUpdate ? 1 : 0, + ) + .run(); + } + + async putTransaction(rawState: string, state: StoredState, options: OAuthStoreOptions) { + const publicOrigin = this.#oauth.clientMetadata.client_uri; + const redirectTarget = canonicalizeRedirectTarget(options.redirectTarget, publicOrigin); + const userState = parseOAuthUserState(state.userState, redirectTarget, publicOrigin); + const keyId = getClientKeyId(state); + this.assertClientKeyAvailable(keyId); + this.assertSeparateDpopKey(state.dpopKey); + if (options.expectedDid && options.expectedDid !== state.sub) { + throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); + } + if (!this.#oauth.clientMetadata.redirect_uris.includes(state.redirectUri)) { + throw new OAuthCustodyError("OAUTH_REDIRECT_INVALID"); + } + const id = ulid(); + const context: EncryptionContext = { + purpose: transactionEncryptionPurpose(options.purpose), + table: "oauth_transactions", + primaryKey: id, + ownerDid: options.expectedDid, + }; + const encrypted = await this.#encryption.encrypt( + encoder.encode(JSON.stringify({ ...state, userState })), + context, + ); + await this.#db + .prepare( + `INSERT INTO oauth_transactions ( + id, state_hash, purpose, expected_did, client_key_id, encrypted_state, + encryption_key_version, redirect_target, expires_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + await hashOpaque(rawState), + options.purpose, + options.expectedDid, + keyId, + encrypted.envelope, + encrypted.keyVersion, + redirectTarget, + new Date(state.expiresAt).toISOString(), + new Date().toISOString(), + ) + .run(); + } + + async getTransaction( + rawState: string, + options: OAuthStoreOptions, + ): Promise { + const publicOrigin = this.#oauth.clientMetadata.client_uri; + const redirectTarget = canonicalizeRedirectTarget(options.redirectTarget, publicOrigin); + // atcute deletes state after reading it, so consume it here atomically to close callback races. + const row = await this.#db + .prepare( + `DELETE FROM oauth_transactions + WHERE state_hash = ? AND purpose = ? AND expected_did IS ? AND redirect_target = ? + RETURNING id, expected_did, client_key_id, encrypted_state, redirect_target, expires_at`, + ) + .bind(await hashOpaque(rawState), options.purpose, options.expectedDid, redirectTarget) + .first(); + if (!row) return undefined; + if (row.expires_at <= new Date().toISOString()) { + return undefined; + } + this.assertClientKeyAvailable(row.client_key_id); + const plaintext = await this.#encryption.decrypt(row.encrypted_state, { + purpose: transactionEncryptionPurpose(options.purpose), + table: "oauth_transactions", + primaryKey: row.id, + ownerDid: row.expected_did, + }); + const state = parseStoredState(decoder.decode(plaintext)); + if (getClientKeyId(state) !== row.client_key_id) { + throw new OAuthCustodyError("OAUTH_STATE_INVALID"); + } + return { + ...state, + userState: parseOAuthUserState(state.userState, row.redirect_target, publicOrigin), + }; + } + + async deleteTransaction(rawState: string, options: OAuthStoreOptions): Promise { + const redirectTarget = canonicalizeRedirectTarget( + options.redirectTarget, + this.#oauth.clientMetadata.client_uri, + ); + await this.#db + .prepare( + "DELETE FROM oauth_transactions WHERE state_hash = ? AND purpose = ? AND expected_did IS ? AND redirect_target = ?", + ) + .bind(await hashOpaque(rawState), options.purpose, options.expectedDid, redirectTarget) + .run(); + } + + async clearTransactions(options: OAuthStoreOptions): Promise { + const redirectTarget = canonicalizeRedirectTarget( + options.redirectTarget, + this.#oauth.clientMetadata.client_uri, + ); + await this.#db + .prepare( + "DELETE FROM oauth_transactions WHERE purpose = ? AND expected_did IS ? AND redirect_target = ?", + ) + .bind(options.purpose, options.expectedDid, redirectTarget) + .run(); + } + + async createConsoleSession(input: { + publisherDid: string; + token: string; + csrfSecret: string; + expiresAt: Date; + now?: Date; + }) { + const id = ulid(); + const encrypted = await this.#encryption.encrypt(encoder.encode(input.csrfSecret), { + purpose: "csrf-secret", + table: "console_sessions", + primaryKey: id, + ownerDid: input.publisherDid, + }); + const now = (input.now ?? new Date()).toISOString(); + await this.#db + .prepare( + `INSERT INTO console_sessions ( + id, token_hash, publisher_did, encrypted_csrf_secret, encryption_key_version, + expires_at, created_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + await hashOpaque(input.token), + input.publisherDid, + encrypted.envelope, + encrypted.keyVersion, + input.expiresAt.toISOString(), + now, + now, + ) + .run(); + return { id }; + } + + async getConsoleSession(token: string, publisherDid: string) { + const row = await this.#db + .prepare( + `SELECT id, publisher_did, encrypted_csrf_secret, expires_at + FROM console_sessions WHERE token_hash = ? AND publisher_did = ?`, + ) + .bind(await hashOpaque(token), publisherDid) + .first<{ + id: string; + publisher_did: string; + encrypted_csrf_secret: string; + expires_at: string; + }>(); + if (!row) return undefined; + if (row.expires_at <= new Date().toISOString()) { + await this.#db.prepare("DELETE FROM console_sessions WHERE id = ?").bind(row.id).run(); + return undefined; + } + const csrfSecret = decoder.decode( + await this.#encryption.decrypt(row.encrypted_csrf_secret, { + purpose: "csrf-secret", + table: "console_sessions", + primaryKey: row.id, + ownerDid: row.publisher_did, + }), + ); + return { id: row.id, publisherDid: row.publisher_did, csrfSecret }; + } + + async putDelegation(publisherDid: `did:${string}:${string}`, session: StoredSession) { + this.validateDelegationSession(publisherDid, session); + const existing = await this.getDelegationByPublisher(publisherDid); + if (existing && existing.status !== "reauthorization_required") { + throw new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED"); + } + const id = existing?.id ?? ulid(); + const encrypted = await this.encryptSession(id, publisherDid, session); + const now = new Date().toISOString(); + const refreshBefore = session.tokenSet.expires_at + ? new Date(session.tokenSet.expires_at).toISOString() + : null; + if (existing) { + const result = await this.#db + .prepare( + `UPDATE delegations SET + encrypted_session = ?, encryption_key_version = ?, client_key_id = ?, scope = ?, + status = 'active', refresh_before = ?, lease_owner = NULL, lease_expires_at = NULL, + state_version = state_version + 1, updated_at = ? + WHERE id = ? AND publisher_did = ? AND release_nsid = ? + AND status = 'reauthorization_required' AND revoked_at IS NULL`, + ) + .bind( + encrypted.envelope, + encrypted.keyVersion, + getClientKeyId(session), + this.#oauth.releaseScope, + refreshBefore, + now, + id, + publisherDid, + this.#oauth.releaseNsid, + ) + .run(); + if (result.meta.changes !== 1) { + throw new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED"); + } + return id; + } + const result = await this.#db + .prepare( + `INSERT INTO delegations ( + id, publisher_did, release_nsid, encrypted_session, encryption_key_version, + client_key_id, scope, status, refresh_before, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) + ON CONFLICT(publisher_did, release_nsid) WHERE revoked_at IS NULL DO NOTHING`, + ) + .bind( + id, + publisherDid, + this.#oauth.releaseNsid, + encrypted.envelope, + encrypted.keyVersion, + getClientKeyId(session), + this.#oauth.releaseScope, + refreshBefore, + now, + now, + ) + .run(); + if (result.meta.changes !== 1) { + throw new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED"); + } + return id; + } + + async getDelegationByPublisher(publisherDid: string): Promise { + return ( + (await this.#db + .prepare( + `SELECT id, publisher_did, release_nsid, encrypted_session, client_key_id, + scope, status, state_version, lease_owner, lease_expires_at, refresh_before + FROM delegations + WHERE publisher_did = ? AND release_nsid = ? AND revoked_at IS NULL`, + ) + .bind(publisherDid, this.#oauth.releaseNsid) + .first()) ?? undefined + ); + } + + async getDelegation(id: string, publisherDid: string): Promise { + return ( + (await this.#db + .prepare( + `SELECT id, publisher_did, release_nsid, encrypted_session, client_key_id, + scope, status, state_version, lease_owner, lease_expires_at, refresh_before + FROM delegations WHERE id = ? AND publisher_did = ?`, + ) + .bind(id, publisherDid) + .first()) ?? undefined + ); + } + + async getDelegationSession(publisherDid: `did:${string}:${string}`) { + const row = await this.#db + .prepare( + `SELECT id, publisher_did, release_nsid, encrypted_session, client_key_id, + scope, status, state_version, lease_owner, lease_expires_at, refresh_before + FROM delegations + WHERE publisher_did = ? AND release_nsid = ? AND status = 'active' + AND lease_owner IS NULL AND lease_expires_at IS NULL AND revoked_at IS NULL`, + ) + .bind(publisherDid, this.#oauth.releaseNsid) + .first(); + if (!row?.encrypted_session) return undefined; + if (!this.#oauth.hasAssertionKey(row.client_key_id)) { + const transitioned = await this.transitionMissingClientKeyCas(row, null); + if (transitioned) throw new OAuthCustodyError("OAUTH_CLIENT_KEY_UNAVAILABLE"); + return undefined; + } + return this.decryptDelegationSession(row, publisherDid); + } + + async getDelegationSessionForRefresh(input: { + id: string; + publisherDid: Did; + expectedVersion: number; + leaseOwner: string; + }): Promise { + const now = new Date().toISOString(); + const row = await this.#db + .prepare( + `SELECT id, publisher_did, release_nsid, encrypted_session, client_key_id, + scope, status, state_version, lease_owner, lease_expires_at, refresh_before + FROM delegations + WHERE id = ? AND publisher_did = ? AND release_nsid = ? AND state_version = ? + AND status = 'refreshing' AND lease_owner = ? AND lease_expires_at > ? + AND revoked_at IS NULL`, + ) + .bind( + input.id, + input.publisherDid, + this.#oauth.releaseNsid, + input.expectedVersion, + input.leaseOwner, + now, + ) + .first(); + if (!row?.encrypted_session) return undefined; + if (!this.#oauth.hasAssertionKey(row.client_key_id)) { + const transitioned = await this.transitionMissingClientKeyCas(row, input.leaseOwner); + if (transitioned) throw new OAuthCustodyError("OAUTH_CLIENT_KEY_UNAVAILABLE"); + return undefined; + } + return this.decryptDelegationSession(row, input.publisherDid); + } + + async decryptDelegationSession(row: DelegationRow, publisherDid: Did): Promise { + if (!row.encrypted_session) throw new OAuthCustodyError("OAUTH_SESSION_INVALID"); + const plaintext = await this.#encryption.decrypt(row.encrypted_session, { + purpose: "oauth-session", + table: "delegations", + primaryKey: row.id, + ownerDid: publisherDid, + }); + const session = parseStoredSession(decoder.decode(plaintext)); + if (getClientKeyId(session) !== row.client_key_id) { + throw new OAuthCustodyError("OAUTH_SESSION_INVALID"); + } + this.validateDelegationSession(publisherDid, session); + return session; + } + + async transitionMissingClientKeyCas( + row: DelegationRow, + leaseOwner: string | null, + ): Promise { + const now = new Date().toISOString(); + const statement = leaseOwner + ? this.#db + .prepare( + `UPDATE delegations SET + status = 'reauthorization_required', lease_owner = NULL, lease_expires_at = NULL, + state_version = state_version + 1, updated_at = ? + WHERE id = ? AND publisher_did = ? AND state_version = ? + AND status = 'refreshing' AND lease_owner = ? AND lease_expires_at > ? + AND revoked_at IS NULL`, + ) + .bind(now, row.id, row.publisher_did, row.state_version, leaseOwner, now) + : this.#db + .prepare( + `UPDATE delegations SET + status = 'reauthorization_required', lease_owner = NULL, lease_expires_at = NULL, + state_version = state_version + 1, updated_at = ? + WHERE id = ? AND publisher_did = ? AND state_version = ? + AND status = 'active' AND lease_owner IS NULL AND lease_expires_at IS NULL + AND revoked_at IS NULL`, + ) + .bind(now, row.id, row.publisher_did, row.state_version); + const result = await statement.run(); + return result.meta.changes === 1; + } + + async revokeDelegation(publisherDid: string): Promise { + const now = new Date().toISOString(); + await this.#db + .prepare( + `UPDATE delegations SET + status = 'revoked', encrypted_session = NULL, encryption_key_version = NULL, + lease_owner = NULL, lease_expires_at = NULL, revoked_at = ?, updated_at = ?, + state_version = state_version + 1 + WHERE publisher_did = ? AND release_nsid = ? AND revoked_at IS NULL`, + ) + .bind(now, now, publisherDid, this.#oauth.releaseNsid) + .run(); + } + + async claimDelegationLeaseCas(input: { + id: string; + publisherDid: string; + expectedVersion: number; + leaseOwner: string; + leaseExpiresAt: Date; + }): Promise { + const nowMs = Date.now(); + const leaseExpiresAtMs = input.leaseExpiresAt.getTime(); + if ( + !Number.isFinite(leaseExpiresAtMs) || + leaseExpiresAtMs <= nowMs || + leaseExpiresAtMs - nowMs > MAX_DELEGATION_LEASE_MS + ) { + return false; + } + const now = new Date(nowMs).toISOString(); + const result = await this.#db + .prepare( + `UPDATE delegations SET + status = 'refreshing', lease_owner = ?, lease_expires_at = ?, + state_version = state_version + 1, updated_at = ? + WHERE id = ? AND publisher_did = ? AND release_nsid = ? AND state_version = ? + AND revoked_at IS NULL AND ( + (status = 'active' AND lease_owner IS NULL AND lease_expires_at IS NULL) + OR (status = 'refreshing' AND lease_owner IS NOT NULL + AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?) + )`, + ) + .bind( + input.leaseOwner, + new Date(leaseExpiresAtMs).toISOString(), + now, + input.id, + input.publisherDid, + this.#oauth.releaseNsid, + input.expectedVersion, + now, + ) + .run(); + return result.meta.changes === 1; + } + + async storeDelegationSessionCas(input: { + id: string; + publisherDid: `did:${string}:${string}`; + expectedVersion: number; + leaseOwner: string; + session: StoredSession; + refreshBefore: Date; + }): Promise { + this.validateDelegationSession(input.publisherDid, input.session); + const encrypted = await this.encryptSession(input.id, input.publisherDid, input.session); + const now = new Date().toISOString(); + const result = await this.#db + .prepare( + `UPDATE delegations SET + encrypted_session = ?, encryption_key_version = ?, client_key_id = ?, + status = 'active', lease_owner = NULL, lease_expires_at = NULL, + last_refreshed_at = ?, refresh_before = ?, updated_at = ?, + state_version = state_version + 1 + WHERE id = ? AND publisher_did = ? AND release_nsid = ? AND state_version = ? + AND status = 'refreshing' AND lease_owner = ? AND lease_expires_at > ? + AND revoked_at IS NULL`, + ) + .bind( + encrypted.envelope, + encrypted.keyVersion, + getClientKeyId(input.session), + now, + input.refreshBefore.toISOString(), + now, + input.id, + input.publisherDid, + this.#oauth.releaseNsid, + input.expectedVersion, + input.leaseOwner, + now, + ) + .run(); + return result.meta.changes === 1; + } + + assertClientKeyAvailable(keyId: string): void { + if (!this.#oauth.hasAssertionKey(keyId)) { + throw new OAuthCustodyError("OAUTH_CLIENT_KEY_UNAVAILABLE"); + } + } + + validateDelegationSession(publisherDid: `did:${string}:${string}`, session: StoredSession): void { + this.validateSession(publisherDid, session, this.#oauth.releaseScope); + } + + validateIdentitySession( + did: `did:${string}:${string}`, + expectedDid: `did:${string}:${string}` | null, + session: StoredSession, + ): void { + if (expectedDid && did !== expectedDid) { + throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); + } + this.validateSession(did, session, "atproto"); + } + + validateSession( + did: `did:${string}:${string}`, + session: StoredSession, + expectedScope: string, + ): void { + const keyId = getClientKeyId(session); + this.assertClientKeyAvailable(keyId); + if (session.tokenSet.sub !== did) { + throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); + } + if (session.tokenSet.scope !== expectedScope) { + throw new OAuthCustodyError("OAUTH_SCOPE_INVALID"); + } + assertDpopKey(session.dpopKey); + this.assertSeparateDpopKey(session.dpopKey); + } + + assertSeparateDpopKey(dpopKey: StoredSession["dpopKey"]): void { + if ( + dpopKey.kty === "EC" && + this.#oauth.assertionKeys.some( + (key) => key.kty === "EC" && key.x === dpopKey.x && key.y === dpopKey.y, + ) + ) { + throw new OAuthCustodyError("OAUTH_SESSION_INVALID"); + } + } + + async encryptSession( + id: string, + publisherDid: `did:${string}:${string}`, + session: StoredSession, + ) { + return this.#encryption.encrypt(encoder.encode(JSON.stringify(session)), { + purpose: "oauth-session", + table: "delegations", + primaryKey: id, + ownerDid: publisherDid, + }); + } +} + +export function createOAuthStores( + repository: OAuthCustodyRepository, + options: OAuthStoreOptions, +): OAuthClientStores { + if ( + (options.purpose !== "console_login" && !isDid(options.expectedDid)) || + (options.expectedDid !== null && !isDid(options.expectedDid)) + ) { + throw new OAuthCustodyError("OAUTH_IDENTITY_MISMATCH"); + } + const states: Store = { + get: (key) => repository.getTransaction(key, options), + set: (key, value) => repository.putTransaction(key, value, options), + delete: (key) => repository.deleteTransaction(key, options), + clear: () => repository.clearTransactions(options), + }; + if (options.purpose !== "release_delegation") { + const memory = new MemoryStore<`did:${string}:${string}`, StoredSession>(); + const sessions: Store<`did:${string}:${string}`, StoredSession> = { + get: (did) => memory.get(did), + set: (did, session) => { + repository.validateIdentitySession(did, options.expectedDid, session); + memory.set(did, session); + }, + delete: (did) => memory.delete(did), + clear: () => memory.clear(), + }; + return { states, sessions }; + } + const sessions: Store<`did:${string}:${string}`, StoredSession> = { + get: (did) => repository.getDelegationSession(did), + set: (did, session) => repository.putDelegation(did, session).then(() => undefined), + delete: (did) => repository.revokeDelegation(did), + clear: () => Promise.reject(new OAuthCustodyError("OAUTH_DELEGATION_CAS_REQUIRED")), + }; + return { states, sessions }; +} diff --git a/apps/release-service/src/routes.ts b/apps/release-service/src/routes.ts index 138951bc3d..79ef29b850 100644 --- a/apps/release-service/src/routes.ts +++ b/apps/release-service/src/routes.ts @@ -1,5 +1,6 @@ import { apiSuccess } from "./api/response.js"; import type { ServiceConfiguration } from "./config.js"; +import { getClientMetadata, getPublicJwks, publicOAuthJson } from "./oauth/metadata.js"; export type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -8,6 +9,7 @@ export interface RouteDefinition { path: string; operationId: string; summary: string; + includeInApiSchema?: boolean; successStatus: number; successDataSchema: Readonly>; handler( @@ -18,6 +20,28 @@ export interface RouteDefinition { } export const ROUTES = Object.freeze([ + { + method: "GET", + path: "/.well-known/atproto-client-metadata.json", + operationId: "getAtprotoClientMetadata", + summary: "Get atproto OAuth client metadata", + includeInApiSchema: false, + successStatus: 200, + successDataSchema: { type: "object" }, + handler: (_request, _requestId, configuration) => + publicOAuthJson(getClientMetadata(configuration.oauth)), + }, + { + method: "GET", + path: "/oauth/jwks.json", + operationId: "getAtprotoClientJwks", + summary: "Get atproto OAuth client assertion keys", + includeInApiSchema: false, + successStatus: 200, + successDataSchema: { type: "object" }, + handler: (_request, _requestId, configuration) => + publicOAuthJson(getPublicJwks(configuration.oauth)), + }, { method: "GET", path: "/health", diff --git a/apps/release-service/test/api-foundation.test.ts b/apps/release-service/test/api-foundation.test.ts index 52d55bb2a7..fb35711dd5 100644 --- a/apps/release-service/test/api-foundation.test.ts +++ b/apps/release-service/test/api-foundation.test.ts @@ -14,6 +14,7 @@ import { } from "../src/api/security.js"; import { loadConfiguration, type ConfigurationBindings } from "../src/config.js"; import { ROUTES } from "../src/routes.js"; +import { TEST_ASSERTION_KEYSET } from "./fixtures/oauth.js"; const BINDINGS = { PUBLIC_ORIGIN: "https://release.example.com", @@ -22,9 +23,11 @@ const BINDINGS = { DEPLOYMENT_POLICY: "hosted", ENCRYPTION_KEYRING: '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}', + OAUTH_REDIRECT_URIS: '["https://release.example.com/oauth/callback"]', + OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, } satisfies ConfigurationBindings; -const config = loadConfiguration(BINDINGS); +const config = await loadConfiguration(BINDINGS); describe("request IDs and errors", () => { it("accepts only bounded, header-safe inbound request IDs", () => { @@ -68,8 +71,10 @@ describe("configuration", () => { { ...BINDINGS, ALLOWED_PUBLISHERS: '{"mode":"allowlist","dids":["not-a-did"]}' }, { ...BINDINGS, DEPLOYMENT_POLICY: "preview" }, { ...BINDINGS, ENCRYPTION_KEYRING: "not-json" }, - ])("fails closed for invalid deployment configuration", (bindings) => { - expect(() => loadConfiguration(bindings)).toThrowError("Invalid release-service configuration"); + ])("fails closed for invalid deployment configuration", async (bindings) => { + await expect(loadConfiguration(bindings)).rejects.toThrowError( + "Invalid release-service configuration", + ); }); }); @@ -250,10 +255,11 @@ describe("API schema", () => { }); it("preserves multiple methods registered for the same path", () => { + const healthRoute = ROUTES.find((route) => route.path === "/health")!; const schema = generateApiSchema([ ...ROUTES, { - ...ROUTES[0], + ...healthRoute, method: "POST", operationId: "postHealthTest", }, diff --git a/apps/release-service/test/fixtures/oauth.ts b/apps/release-service/test/fixtures/oauth.ts new file mode 100644 index 0000000000..f95d43cada --- /dev/null +++ b/apps/release-service/test/fixtures/oauth.ts @@ -0,0 +1,41 @@ +import type { ConfigurationBindings } from "../../src/config.js"; + +export const ASSERTION_KEY_1 = { + kty: "EC", + x: "ltusUjVlZKJd0aB08R9ofpA618lL6Bh5Vklz1BnItBQ", + y: "SOhTX8HsvUgesPwUhB1jF-YIyoqv-3rU3a2awb-pvrU", + crv: "P-256", + d: "F_epxvQa-byikHSElS85WQYumK5MplPRSrqOo-Q3U5w", + kid: "assertion-2026-01", + alg: "ES256", + use: "sig", +} as const; + +export const ASSERTION_KEY_2 = { + kty: "EC", + x: "3MPONnVYNjZG1cYlDyrabO4Y4Raqpq4bbhxWuVDMMrg", + y: "dkRyxzxRco-qe5SIgmgS6N66GFx-cSLzkUCHvua3KbE", + crv: "P-256", + d: "EG0ysjQnY6YhBfYdwfzV4FmBIsQr99XOLLEA-c9F-rE", + kid: "assertion-2026-02", + alg: "ES256", + use: "sig", +} as const; + +export const TEST_ASSERTION_KEYSET = JSON.stringify({ + active: ASSERTION_KEY_2.kid, + keys: [ASSERTION_KEY_1, ASSERTION_KEY_2], +}); + +export const TEST_ENCRYPTION_KEYRING = + '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}'; + +export const TEST_BINDINGS = { + PUBLIC_ORIGIN: "https://release.example.invalid", + ALLOWED_ORIGINS: '["https://release.example.invalid"]', + ALLOWED_PUBLISHERS: '{"mode":"all"}', + DEPLOYMENT_POLICY: "hosted", + ENCRYPTION_KEYRING: TEST_ENCRYPTION_KEYRING, + OAUTH_REDIRECT_URIS: '["https://release.example.invalid/oauth/callback"]', + OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, +} satisfies ConfigurationBindings; diff --git a/apps/release-service/test/oauth-custody.test.ts b/apps/release-service/test/oauth-custody.test.ts new file mode 100644 index 0000000000..20d9a6685d --- /dev/null +++ b/apps/release-service/test/oauth-custody.test.ts @@ -0,0 +1,940 @@ +import { OAuthClient, type StoredSession, type StoredState } from "@atcute/oauth-node-client"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; + +import { loadConfiguration } from "../src/config.js"; +import { + OAuthCustodyError, + OAuthCustodyRepository, + createOAuthStores, + type OAuthStoreOptions, +} from "../src/oauth/store.js"; +import { ASSERTION_KEY_1, ASSERTION_KEY_2, TEST_BINDINGS } from "./fixtures/oauth.js"; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} + +const testEnv = env as unknown as TestEnv; +const DID = "did:plc:publisher" as const; +const OTHER_DID = "did:plc:other" as const; +const DPOP_KEY = { + kty: "EC", + x: "DKas1cwlMQB8YyJdRR_vvenYaiPOG_m49pW7T5xo2Nk", + y: "Q7Nbp2qHt66StC3qX4Lv82BYysuTtzwJ9UON04KywYo", + crv: "P-256", + d: "cVdiepgRpynyyhZIV1wEY4P7nr3kVSGn70uP6ng1QUw", + alg: "ES256", +} as const; + +function storedState( + redirectTarget = "/delegations", + expiresAt = Date.now() + 10 * 60_000, +): StoredState { + return { + dpopKey: DPOP_KEY, + authMethod: { method: "private_key_jwt", kid: ASSERTION_KEY_2.kid }, + pkceVerifier: "pkce-secret", + issuer: "https://pds.example.com", + redirectUri: TEST_BINDINGS.PUBLIC_ORIGIN + "/oauth/callback", + sub: DID, + userState: { redirectTarget }, + expiresAt, + }; +} + +function storedSession(accessToken = "access-secret"): StoredSession { + return { + dpopKey: DPOP_KEY, + authMethod: { method: "private_key_jwt", kid: ASSERTION_KEY_2.kid }, + tokenSet: { + iss: "https://pds.example.com", + sub: DID, + aud: "https://pds.example.com", + scope: "atproto repo:com.emdashcms.experimental.package.release?action=create", + access_token: accessToken, + refresh_token: "refresh-secret", + token_type: "DPoP", + expires_at: Date.now() + 60_000, + }, + }; +} + +function identitySession(accessToken: string): StoredSession { + return { + ...storedSession(accessToken), + tokenSet: { ...storedSession(accessToken).tokenSet, scope: "atproto" }, + }; +} + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); +}); + +async function createRepository(bindings = TEST_BINDINGS) { + const configuration = await loadConfiguration(bindings); + return { + configuration, + repository: new OAuthCustodyRepository( + testEnv.DB, + configuration.encryption, + configuration.oauth, + ), + }; +} + +describe("OAuth custody D1 repository", () => { + it("round trips authorization state while storing only a hash and ciphertext", async () => { + const { repository } = await createRepository(); + await repository.upsertPublisher({ did: DID }); + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + const state = storedState(); + + await stores.states.set("opaque-state-token", state); + const row = await testEnv.DB.prepare( + "SELECT state_hash, encrypted_state FROM oauth_transactions WHERE purpose = ? AND expected_did = ?", + ) + .bind("release_delegation", DID) + .first<{ state_hash: string; encrypted_state: string }>(); + expect(row?.state_hash).not.toBe("opaque-state-token"); + expect(row?.encrypted_state).not.toContain("pkce-secret"); + expect(row?.encrypted_state).not.toContain("refresh-secret"); + expect(await stores.states.get("opaque-state-token")).toEqual(state); + + await stores.states.delete("opaque-state-token"); + expect(await stores.states.get("opaque-state-token")).toBeUndefined(); + }); + + it("atomically consumes authorization state and binds its redirect target", async () => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "console_login", + expectedDid: DID, + redirectTarget: "/console", + }); + await expect( + stores.states.set("mismatched-state", storedState("/other")), + ).rejects.toMatchObject({ code: "OAUTH_REDIRECT_INVALID" }); + await stores.states.set("single-use-state", storedState("/console")); + const wrongRedirectStores = createOAuthStores(repository, { + purpose: "console_login", + expectedDid: DID, + redirectTarget: "/other", + }); + expect(await wrongRedirectStores.states.get("single-use-state")).toBeUndefined(); + + const results = await Promise.all([ + stores.states.get("single-use-state"), + stores.states.get("single-use-state"), + ]); + expect(results.filter((result) => result !== undefined)).toHaveLength(1); + }); + + it.each([ + "//attacker.example", + "/\\attacker.example", + "/safe\r\nLocation: evil", + "https://evil.example", + ])("rejects unsafe redirect target %s", async (redirectTarget) => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "console_login", + expectedDid: null, + redirectTarget, + }); + await expect( + stores.states.set(`unsafe-${redirectTarget}`, storedState(redirectTarget)), + ).rejects.toMatchObject({ code: "OAUTH_REDIRECT_INVALID" }); + }); + + it("canonicalizes the bound redirect target against the public origin", async () => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "console_login", + expectedDid: null, + redirectTarget: "/console/../delegations?tab=oauth#active", + }); + await stores.states.set( + "canonical-redirect", + storedState("/console/../delegations?tab=oauth#active"), + ); + expect((await stores.states.get("canonical-redirect"))?.userState).toEqual({ + redirectTarget: "/delegations?tab=oauth#active", + }); + }); + + it("isolates identical identities and state material by OAuth purpose", async () => { + const { repository } = await createRepository(); + const consoleStores = createOAuthStores(repository, { + purpose: "console_login", + expectedDid: DID, + redirectTarget: "/", + }); + const approverStores = createOAuthStores(repository, { + purpose: "approver_identity", + expectedDid: DID, + redirectTarget: "/approve", + }); + await consoleStores.states.set("console-state", storedState("/")); + await approverStores.states.set("approver-state", storedState("/approve")); + await expect( + approverStores.states.set("console-state", storedState("/approve")), + ).rejects.toThrow(); + + expect(await consoleStores.states.get("approver-state")).toBeUndefined(); + expect(await approverStores.states.get("console-state")).toBeUndefined(); + + await consoleStores.sessions.set(DID, identitySession("console-token")); + await approverStores.sessions.set(DID, identitySession("approver-token")); + expect((await consoleStores.sessions.get(DID))?.tokenSet.access_token).toBe("console-token"); + expect((await approverStores.sessions.get(DID))?.tokenSet.access_token).toBe("approver-token"); + await consoleStores.sessions.delete(DID); + expect(await consoleStores.sessions.get(DID)).toBeUndefined(); + expect(await approverStores.sessions.get(DID)).toBeDefined(); + const durableSessions = await testEnv.DB.prepare( + "SELECT COUNT(*) AS count FROM delegations WHERE publisher_did = ?", + ) + .bind(DID) + .first<{ count: number }>(); + expect(durableSessions?.count).toBe(0); + }); + + it("restricts transient identity sessions to the expected DID and atproto-only scope", async () => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "approver_identity", + expectedDid: DID, + redirectTarget: "/approve", + }); + + expect(() => stores.sessions.set(DID, storedSession())).toThrowError( + expect.objectContaining({ + code: "OAUTH_SCOPE_INVALID", + }), + ); + expect(() => stores.sessions.set(OTHER_DID, identitySession("other"))).toThrowError( + expect.objectContaining({ code: "OAUTH_IDENTITY_MISMATCH" }), + ); + expect(() => + stores.sessions.set(DID, { + ...identitySession("wrong-sub"), + tokenSet: { ...identitySession("wrong-sub").tokenSet, sub: OTHER_DID }, + }), + ).toThrowError(expect.objectContaining({ code: "OAUTH_IDENTITY_MISMATCH" })); + }); + + it("cryptographically binds authorization ciphertext to its logical purpose", async () => { + const { repository } = await createRepository(); + const consoleStores = createOAuthStores(repository, { + purpose: "console_login", + expectedDid: DID, + redirectTarget: "/", + }); + await consoleStores.states.set("purpose-bound-state", storedState("/")); + const row = await testEnv.DB.prepare( + "SELECT id FROM oauth_transactions WHERE purpose = ? AND expected_did = ? ORDER BY created_at DESC LIMIT 1", + ) + .bind("console_login", DID) + .first<{ id: string }>(); + await testEnv.DB.prepare("UPDATE oauth_transactions SET purpose = ? WHERE id = ?") + .bind("approver_identity", row!.id) + .run(); + const approverStores = createOAuthStores(repository, { + purpose: "approver_identity", + expectedDid: DID, + redirectTarget: "/", + }); + await expect(approverStores.states.get("purpose-bound-state")).rejects.toMatchObject({ + code: "DECRYPTION_FAILED", + }); + }); + + it("returns a stable typed reauthorization failure when an assertion key disappeared", async () => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + await stores.states.set("rotated-state", storedState()); + + const oldOnlyBindings = { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [ASSERTION_KEY_1], + }), + }; + const { repository: rotatedRepository } = await createRepository(oldOnlyBindings); + const rotatedStores = createOAuthStores(rotatedRepository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + + await expect(rotatedStores.states.get("rotated-state")).rejects.toMatchObject({ + code: "OAUTH_CLIENT_KEY_UNAVAILABLE", + reauthorizationRequired: true, + }); + }); + + it("deletes expired authorization state on read", async () => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "approver_identity", + expectedDid: DID, + redirectTarget: "/approve", + }); + await stores.states.set("expired-state", storedState("/approve", Date.now() - 1)); + expect(await stores.states.get("expired-state")).toBeUndefined(); + const row = await testEnv.DB.prepare( + "SELECT id FROM oauth_transactions WHERE purpose = ? AND expected_did = ? AND expires_at <= ?", + ) + .bind("approver_identity", DID, new Date().toISOString()) + .first(); + expect(row).toBeNull(); + }); + + it("hashes console tokens and encrypts CSRF secrets with ownership and expiry", async () => { + const { repository } = await createRepository(); + await repository.upsertPublisher({ did: OTHER_DID }); + const session = await repository.createConsoleSession({ + publisherDid: OTHER_DID, + token: "browser-session-secret", + csrfSecret: "csrf-secret", + expiresAt: new Date(Date.now() + 60_000), + }); + expect(await repository.getConsoleSession("browser-session-secret", OTHER_DID)).toMatchObject({ + id: session.id, + publisherDid: OTHER_DID, + csrfSecret: "csrf-secret", + }); + expect(await repository.getConsoleSession("browser-session-secret", DID)).toBeUndefined(); + + const row = await testEnv.DB.prepare( + "SELECT token_hash, encrypted_csrf_secret FROM console_sessions WHERE id = ?", + ) + .bind(session.id) + .first<{ token_hash: string; encrypted_csrf_secret: string }>(); + expect(row?.token_hash).not.toBe("browser-session-secret"); + expect(row?.encrypted_csrf_secret).not.toContain("csrf-secret"); + + await repository.createConsoleSession({ + publisherDid: OTHER_DID, + token: "expired-browser-session", + csrfSecret: "expired-csrf", + expiresAt: new Date(Date.now() - 1), + }); + expect( + await repository.getConsoleSession("expired-browser-session", OTHER_DID), + ).toBeUndefined(); + await expect( + repository.createConsoleSession({ + publisherDid: OTHER_DID, + token: "browser-session-secret", + csrfSecret: "another-csrf", + expiresAt: new Date(Date.now() + 60_000), + }), + ).rejects.toThrow(); + }); + + it("preserves omitted publisher cache fields and updates or clears the PDS tuple atomically", async () => { + const { repository } = await createRepository(); + const resolvedAt = new Date("2026-07-01T12:00:00.000Z"); + await repository.upsertPublisher({ + did: DID, + handle: "publisher.example", + pdsUrl: "https://pds.example", + pdsResolvedAt: resolvedAt, + }); + await repository.upsertPublisher({ did: DID }); + let row = await testEnv.DB.prepare( + "SELECT handle, pds_url, pds_resolved_at FROM publisher_accounts WHERE did = ?", + ) + .bind(DID) + .first<{ handle: string | null; pds_url: string | null; pds_resolved_at: string | null }>(); + expect(row).toEqual({ + handle: "publisher.example", + pds_url: "https://pds.example", + pds_resolved_at: resolvedAt.toISOString(), + }); + + await expect( + repository.upsertPublisher({ + did: DID, + pdsUrl: "https://replacement-pds.example", + } as Parameters[0]), + ).rejects.toThrow(TypeError); + await repository.upsertPublisher({ did: DID, handle: "new.example", pdsUrl: null }); + row = await testEnv.DB.prepare( + "SELECT handle, pds_url, pds_resolved_at FROM publisher_accounts WHERE did = ?", + ) + .bind(DID) + .first<{ handle: string | null; pds_url: string | null; pds_resolved_at: string | null }>(); + expect(row).toEqual({ + handle: "new.example", + pds_url: null, + pds_resolved_at: null, + }); + + await repository.upsertPublisher({ + did: DID, + pdsUrl: "https://replacement-pds.example", + pdsResolvedAt: resolvedAt, + }); + await repository.upsertPublisher({ did: DID, pdsResolvedAt: null }); + row = await testEnv.DB.prepare( + "SELECT handle, pds_url, pds_resolved_at FROM publisher_accounts WHERE did = ?", + ) + .bind(DID) + .first<{ handle: string | null; pds_url: string | null; pds_resolved_at: string | null }>(); + expect(row).toEqual({ + handle: "new.example", + pds_url: null, + pds_resolved_at: null, + }); + }); + + it("persists only the durable release session and keeps DPoP material encrypted", async () => { + const { repository } = await createRepository(); + await repository.upsertPublisher({ did: DID }); + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + const session = storedSession(); + await stores.sessions.set(DID, session); + expect(await stores.sessions.get(DID)).toEqual(session); + const oldOnlyBindings = { + ...TEST_BINDINGS, + PUBLIC_ORIGIN: "https://release.example.com", + ALLOWED_ORIGINS: '["https://release.example.com"]', + OAUTH_REDIRECT_URIS: '["https://release.example.com/oauth/callback"]', + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [ASSERTION_KEY_1], + }), + }; + const { repository: rotatedRepository } = await createRepository(oldOnlyBindings); + const rotatedStores = createOAuthStores(rotatedRepository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + const client = new OAuthClient({ + metadata: (await loadConfiguration(oldOnlyBindings)).oauth.clientMetadata, + keyset: (await loadConfiguration(oldOnlyBindings)).oauth.keyset, + actorResolver: {} as never, + stores: rotatedStores, + }); + await expect(client.restore(DID, { refresh: false })).rejects.toThrow(); + + const row = await testEnv.DB.prepare( + "SELECT id, encrypted_session, client_key_id, status, state_version FROM delegations WHERE publisher_did = ?", + ) + .bind(DID) + .first<{ + id: string; + encrypted_session: string; + client_key_id: string; + status: string; + state_version: number; + }>(); + expect(row).toMatchObject({ + client_key_id: ASSERTION_KEY_2.kid, + status: "reauthorization_required", + state_version: 2, + }); + expect(row?.encrypted_session).not.toContain("refresh-secret"); + expect(row?.encrypted_session).not.toContain(DPOP_KEY.d); + + expect(await repository.getDelegation(row!.id, OTHER_DID)).toBeUndefined(); + await stores.sessions.delete(DID); + const revoked = await testEnv.DB.prepare( + "SELECT status, encrypted_session, revoked_at FROM delegations WHERE id = ?", + ) + .bind(row!.id) + .first<{ status: string; encrypted_session: string | null; revoked_at: string | null }>(); + expect(revoked).toMatchObject({ status: "revoked", encrypted_session: null }); + expect(revoked?.revoked_at).not.toBeNull(); + }); + + it("enforces one non-revoked grant and unique opaque credentials", async () => { + const { configuration, repository } = await createRepository(); + await repository.upsertPublisher({ did: "did:plc:constraints" }); + const now = new Date().toISOString(); + const scope = configuration.oauth.releaseScope; + const statement = (id: string) => + testEnv.DB.prepare( + `INSERT INTO delegations ( + id, publisher_did, release_nsid, encrypted_session, encryption_key_version, + client_key_id, scope, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`, + ).bind( + id, + "did:plc:constraints", + configuration.oauth.releaseNsid, + "encrypted", + 1, + ASSERTION_KEY_2.kid, + scope, + now, + now, + ); + await statement("01J00000000000000000000001").run(); + await expect(statement("01J00000000000000000000002").run()).rejects.toThrow(); + }); + + it("normalizes concurrent first-delegation conflicts", async () => { + const publisherDid = "did:plc:concurrent-delegation" as const; + const { repository } = await createRepository(); + await repository.upsertPublisher({ did: publisherDid }); + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/delegations", + }); + const session = { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, sub: publisherDid }, + }; + + const results = await Promise.allSettled( + Array.from({ length: 8 }, async () => stores.sessions.set(publisherDid, session)), + ); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.filter((result) => result.status === "rejected"); + expect(rejected).toHaveLength(7); + for (const result of rejected) { + expect(result.reason).toMatchObject({ code: "OAUTH_DELEGATION_CAS_REQUIRED" }); + } + }); + + it("replaces a delegation after reauthorization is required", async () => { + const { repository } = await createRepository(); + await repository.upsertPublisher({ did: DID }); + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + await stores.sessions.set(DID, storedSession()); + const original = await repository.getDelegationByPublisher(DID); + + const key1OnlyBindings = { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [ASSERTION_KEY_1], + }), + }; + const { repository: rotatedRepository } = await createRepository(key1OnlyBindings); + const rotatedStores = createOAuthStores(rotatedRepository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + await expect(rotatedStores.sessions.get(DID)).rejects.toMatchObject({ + code: "OAUTH_CLIENT_KEY_UNAVAILABLE", + }); + + const reauthorized = { + ...storedSession("reauthorized-access"), + authMethod: { method: "private_key_jwt" as const, kid: ASSERTION_KEY_1.kid }, + }; + await rotatedStores.sessions.set(DID, reauthorized); + + const replacement = await rotatedRepository.getDelegationByPublisher(DID); + expect(replacement).toMatchObject({ + id: original!.id, + client_key_id: ASSERTION_KEY_1.kid, + status: "active", + state_version: 3, + }); + expect(await rotatedStores.sessions.get(DID)).toEqual(reauthorized); + }); + + it("provides owner-bound delegation CAS and lease persistence for refresh coordination", async () => { + const { repository } = await createRepository(); + await repository.upsertPublisher({ did: "did:plc:lease" }); + const leaseDid = "did:plc:lease" as const; + const session = { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, sub: leaseDid }, + }; + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: leaseDid, + redirectTarget: "/delegations", + }); + await stores.sessions.set(leaseDid, session); + const delegation = await repository.getDelegationByPublisher(leaseDid); + const leaseExpiresAt = new Date(Date.now() + 30_000); + + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid: OTHER_DID, + expectedVersion: 1, + leaseOwner: "worker-a", + leaseExpiresAt, + }), + ).toBe(false); + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 1, + leaseOwner: "worker-a", + leaseExpiresAt, + }), + ).toBe(true); + expect(await stores.sessions.get(leaseDid)).toBeUndefined(); + expect( + await repository.getDelegationSessionForRefresh({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 1, + leaseOwner: "worker-a", + }), + ).toBeUndefined(); + expect( + await repository.getDelegationSessionForRefresh({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 2, + leaseOwner: "wrong-worker", + }), + ).toBeUndefined(); + expect( + await repository.getDelegationSessionForRefresh({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 2, + leaseOwner: "worker-a", + }), + ).toEqual(session); + const rotated = { ...session, tokenSet: { ...session.tokenSet, access_token: "rotated" } }; + expect( + await repository.storeDelegationSessionCas({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 2, + leaseOwner: "wrong-worker", + session: rotated, + refreshBefore: new Date(Date.now() + 45_000), + }), + ).toBe(false); + expect( + await repository.storeDelegationSessionCas({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 2, + leaseOwner: "worker-a", + session: rotated, + refreshBefore: new Date(Date.now() + 45_000), + }), + ).toBe(true); + expect((await stores.sessions.get(leaseDid))?.tokenSet.access_token).toBe("rotated"); + expect( + await repository.getDelegationSessionForRefresh({ + id: delegation!.id, + publisherDid: leaseDid, + expectedVersion: 2, + leaseOwner: "worker-a", + }), + ).toBeUndefined(); + }); + + it("does not let jobs for another release namespace claim or persist a delegation", async () => { + const { repository } = await createRepository(); + const publisherDid = "did:plc:legacy-namespace" as const; + await repository.upsertPublisher({ did: publisherDid }); + const session = { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, sub: publisherDid }, + }; + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/delegations", + }); + await stores.sessions.set(publisherDid, session); + const delegation = await repository.getDelegationByPublisher(publisherDid); + await testEnv.DB.prepare("UPDATE delegations SET release_nsid = ? WHERE id = ?") + .bind("com.example.legacy.release", delegation!.id) + .run(); + + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 1, + leaseOwner: "stale-worker", + leaseExpiresAt: new Date(Date.now() + 30_000), + }), + ).toBe(false); + await testEnv.DB.prepare( + `UPDATE delegations SET status = 'refreshing', state_version = 2, + lease_owner = ?, lease_expires_at = ? WHERE id = ?`, + ) + .bind("stale-worker", new Date(Date.now() + 30_000).toISOString(), delegation!.id) + .run(); + const rotated = { ...session, tokenSet: { ...session.tokenSet, access_token: "rotated" } }; + expect( + await repository.storeDelegationSessionCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 2, + leaseOwner: "stale-worker", + session: rotated, + refreshBefore: new Date(Date.now() + 45_000), + }), + ).toBe(false); + const row = await testEnv.DB.prepare( + "SELECT release_nsid, status, state_version FROM delegations WHERE id = ?", + ) + .bind(delegation!.id) + .first<{ release_nsid: string; status: string; state_version: number }>(); + expect(row).toEqual({ + release_nsid: "com.example.legacy.release", + status: "refreshing", + state_version: 2, + }); + }); + + it("rejects expired and excessively long delegation leases without changing the row", async () => { + const { repository } = await createRepository(); + const publisherDid = "did:plc:expired-lease" as const; + await repository.upsertPublisher({ did: publisherDid }); + const session = { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, sub: publisherDid }, + }; + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/delegations", + }); + await stores.sessions.set(publisherDid, session); + const delegation = await repository.getDelegationByPublisher(publisherDid); + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 1, + leaseOwner: "expired-worker", + leaseExpiresAt: new Date(Date.now() - 1), + }), + ).toBe(false); + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 1, + leaseOwner: "long-worker", + leaseExpiresAt: new Date(Date.now() + 5 * 60_000 + 1_000), + }), + ).toBe(false); + const row = await testEnv.DB.prepare( + "SELECT status, state_version, lease_owner, lease_expires_at FROM delegations WHERE id = ?", + ) + .bind(delegation!.id) + .first<{ + status: string; + state_version: number; + lease_owner: string | null; + lease_expires_at: string | null; + }>(); + expect(row).toEqual({ + status: "active", + state_version: 1, + lease_owner: null, + lease_expires_at: null, + }); + expect(await stores.sessions.get(publisherDid)).toEqual(session); + }); + + it("reclaims an expired delegation refresh lease", async () => { + const { repository } = await createRepository(); + const publisherDid = "did:plc:stale-refresh-lease" as const; + await repository.upsertPublisher({ did: publisherDid }); + const session = { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, sub: publisherDid }, + }; + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/delegations", + }); + await stores.sessions.set(publisherDid, session); + const delegation = await repository.getDelegationByPublisher(publisherDid); + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 1, + leaseOwner: "stale-worker", + leaseExpiresAt: new Date(Date.now() + 30_000), + }), + ).toBe(true); + await testEnv.DB.prepare("UPDATE delegations SET lease_expires_at = ? WHERE id = ?") + .bind(new Date(Date.now() - 1).toISOString(), delegation!.id) + .run(); + + expect( + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 2, + leaseOwner: "recovery-worker", + leaseExpiresAt: new Date(Date.now() + 30_000), + }), + ).toBe(true); + const recovered = await repository.getDelegationByPublisher(publisherDid); + expect(recovered).toMatchObject({ + status: "refreshing", + state_version: 3, + lease_owner: "recovery-worker", + }); + expect( + await repository.storeDelegationSessionCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 2, + leaseOwner: "stale-worker", + session, + refreshBefore: new Date(Date.now() + 45_000), + }), + ).toBe(false); + }); + + it("transitions a leased delegation when its client key is unavailable", async () => { + const { repository } = await createRepository(); + const publisherDid = "did:plc:missing-refresh-key" as const; + await repository.upsertPublisher({ did: publisherDid }); + const session = { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, sub: publisherDid }, + }; + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: publisherDid, + redirectTarget: "/delegations", + }); + await stores.sessions.set(publisherDid, session); + const delegation = await repository.getDelegationByPublisher(publisherDid); + await repository.claimDelegationLeaseCas({ + id: delegation!.id, + publisherDid, + expectedVersion: 1, + leaseOwner: "refresh-worker", + leaseExpiresAt: new Date(Date.now() + 30_000), + }); + const { repository: rotatedRepository } = await createRepository({ + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [ASSERTION_KEY_1], + }), + }); + + await expect( + rotatedRepository.getDelegationSessionForRefresh({ + id: delegation!.id, + publisherDid, + expectedVersion: 2, + leaseOwner: "refresh-worker", + }), + ).rejects.toMatchObject({ code: "OAUTH_CLIENT_KEY_UNAVAILABLE" }); + const row = await testEnv.DB.prepare( + "SELECT status, state_version, lease_owner, lease_expires_at FROM delegations WHERE id = ?", + ) + .bind(delegation!.id) + .first<{ + status: string; + state_version: number; + lease_owner: string | null; + lease_expires_at: string | null; + }>(); + expect(row).toEqual({ + status: "reauthorization_required", + state_version: 3, + lease_owner: null, + lease_expires_at: null, + }); + expect( + await rotatedRepository.getDelegationSessionForRefresh({ + id: delegation!.id, + publisherDid, + expectedVersion: 2, + leaseOwner: "refresh-worker", + }), + ).toBeUndefined(); + }); + + it("rejects nullable identity-purpose options at runtime", async () => { + const { repository } = await createRepository(); + expect(() => + createOAuthStores(repository, { + purpose: "approver_identity", + expectedDid: null, + redirectTarget: "/approve", + } as unknown as OAuthStoreOptions), + ).toThrowError(expect.objectContaining({ code: "OAUTH_IDENTITY_MISMATCH" })); + }); + + it("rejects public-client auth and mismatched exact release scopes", async () => { + const { repository } = await createRepository(); + const stores = createOAuthStores(repository, { + purpose: "release_delegation", + expectedDid: DID, + redirectTarget: "/delegations", + }); + await expect( + stores.sessions.set(DID, { + ...storedSession(), + authMethod: { method: "none" }, + }), + ).rejects.toBeInstanceOf(OAuthCustodyError); + await expect( + stores.sessions.set(DID, { + ...storedSession(), + tokenSet: { ...storedSession().tokenSet, scope: "atproto transition:generic" }, + }), + ).rejects.toMatchObject({ code: "OAUTH_SCOPE_INVALID" }); + await expect( + stores.sessions.set(DID, { + ...storedSession(), + dpopKey: ASSERTION_KEY_2, + }), + ).rejects.toMatchObject({ code: "OAUTH_SESSION_INVALID" }); + }); +}); + +describe("OAuth store option types", () => { + it("permits nullable expected DIDs only for console login", () => { + expectTypeOf<{ + purpose: "console_login"; + expectedDid: null; + redirectTarget: "/"; + }>().toMatchTypeOf(); + expectTypeOf<{ + purpose: "approver_identity"; + expectedDid: null; + redirectTarget: "/"; + }>().not.toMatchTypeOf(); + expectTypeOf<{ + purpose: "release_delegation"; + expectedDid: null; + redirectTarget: "/"; + }>().not.toMatchTypeOf(); + }); +}); diff --git a/apps/release-service/test/oauth-metadata.test.ts b/apps/release-service/test/oauth-metadata.test.ts new file mode 100644 index 0000000000..0e4e634491 --- /dev/null +++ b/apps/release-service/test/oauth-metadata.test.ts @@ -0,0 +1,119 @@ +import { getDelegatedReleasePermission } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import { ConfigurationError, loadConfiguration } from "../src/config.js"; +import { getClientMetadata, getPublicJwks } from "../src/oauth/metadata.js"; +import { ASSERTION_KEY_1, ASSERTION_KEY_2, TEST_BINDINGS } from "./fixtures/oauth.js"; + +describe("confidential OAuth metadata", () => { + it("shares validated configuration work for the same bindings object", async () => { + const [first, second] = await Promise.all([ + loadConfiguration(TEST_BINDINGS), + loadConfiguration(TEST_BINDINGS), + ]); + expect(first).toBe(second); + }); + + it("derives stable metadata and the exact create-only release scope", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const metadata = getClientMetadata(configuration.oauth); + const permission = getDelegatedReleasePermission(); + + expect(metadata).toEqual({ + client_id: "https://release.example.invalid/.well-known/atproto-client-metadata.json", + client_name: "EmDash delegated release service", + client_uri: "https://release.example.invalid", + application_type: "web", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + redirect_uris: ["https://release.example.invalid/oauth/callback"], + scope: `atproto repo:${permission.collection}?action=create`, + jwks_uri: "https://release.example.invalid/oauth/jwks.json", + dpop_bound_access_tokens: true, + token_endpoint_auth_method: "private_key_jwt", + token_endpoint_auth_signing_alg: "ES256", + }); + expect(configuration.oauth.releaseNsid).toBe(permission.collection); + }); + + it("publishes overlapping public assertion keys with the active key first", async () => { + const configuration = await loadConfiguration(TEST_BINDINGS); + const jwks = getPublicJwks(configuration.oauth); + + expect(jwks.keys.map((key) => key.kid)).toEqual(["assertion-2026-02", "assertion-2026-01"]); + expect(JSON.stringify(jwks)).not.toContain('"d"'); + expect(configuration.oauth.assertionKeys[0]).not.toEqual( + expect.objectContaining({ kid: "dpop" }), + ); + }); + + it.each([ + ["origin path", { ...TEST_BINDINGS, PUBLIC_ORIGIN: "https://release.example.invalid/path" }], + [ + "redirect origin", + { ...TEST_BINDINGS, OAUTH_REDIRECT_URIS: '["https://other.example/oauth/callback"]' }, + ], + [ + "redirect path", + { ...TEST_BINDINGS, OAUTH_REDIRECT_URIS: '["https://release.example.invalid/callback"]' }, + ], + ["empty redirects", { ...TEST_BINDINGS, OAUTH_REDIRECT_URIS: "[]" }], + ["malformed keyset", { ...TEST_BINDINGS, OAUTH_ASSERTION_KEYSET: "not-json" }], + [ + "oversized encoded keyset", + { ...TEST_BINDINGS, OAUTH_ASSERTION_KEYSET: " ".repeat(64 * 1024 + 1) }, + ], + [ + "too many assertion keys", + { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: "assertion-0", + keys: Array.from({ length: 9 }, (_, index) => ({ + ...ASSERTION_KEY_1, + kid: `assertion-${index}`, + })), + }), + }, + ], + [ + "missing active key", + { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ active: "missing", keys: [ASSERTION_KEY_1] }), + }, + ], + [ + "public-only configured key", + { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [{ ...ASSERTION_KEY_1, d: undefined }], + }), + }, + ], + [ + "wrong algorithm", + { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [{ ...ASSERTION_KEY_1, alg: "ES384" }], + }), + }, + ], + [ + "mismatched assertion public and private key material", + { + ...TEST_BINDINGS, + OAUTH_ASSERTION_KEYSET: JSON.stringify({ + active: ASSERTION_KEY_1.kid, + keys: [{ ...ASSERTION_KEY_1, x: ASSERTION_KEY_2.x, y: ASSERTION_KEY_2.y }], + }), + }, + ], + ])("fails closed for invalid %s configuration", async (_name, bindings) => { + await expect(loadConfiguration(bindings)).rejects.toBeInstanceOf(ConfigurationError); + }); +}); diff --git a/apps/release-service/test/worker.test.ts b/apps/release-service/test/worker.test.ts index a275e0b792..5c66514fbf 100644 --- a/apps/release-service/test/worker.test.ts +++ b/apps/release-service/test/worker.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ConfigurationBindings } from "../src/config.js"; import { failInactiveSchedule, handleRequest, retryUnsupportedQueue } from "../src/index.js"; import { ROUTES, type RouteDefinition } from "../src/routes.js"; +import { TEST_ASSERTION_KEYSET } from "./fixtures/oauth.js"; const BLOCKED_PATHS = [ "/v1/release-intents", @@ -46,6 +47,8 @@ describe("release-service worker", () => { DEPLOYMENT_POLICY: "hosted", ENCRYPTION_KEYRING: '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}', + OAUTH_REDIRECT_URIS: '["https://test/oauth/callback"]', + OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, } satisfies ConfigurationBindings; const response = await handleRequest(new Request("https://test/health"), bindings); expect(response.status).toBe(503); @@ -64,8 +67,31 @@ describe("release-service worker", () => { expect(result).toEqual({ healthy: 1 }); }); - it("registers only the health route", () => { - expect(ROUTES.map(({ method, path }) => `${method} ${path}`)).toEqual(["GET /health"]); + it("serves stable public-only OAuth metadata and overlapping JWKS", async () => { + const metadataResponse = await SELF.fetch( + "https://untrusted-host.invalid/.well-known/atproto-client-metadata.json", + ); + expect(metadataResponse.status).toBe(200); + expect(metadataResponse.headers.get("cache-control")).toBe("public, max-age=300"); + expect(await metadataResponse.json()).toMatchObject({ + client_id: "https://release.example.invalid/.well-known/atproto-client-metadata.json", + redirect_uris: ["https://release.example.invalid/oauth/callback"], + jwks_uri: "https://release.example.invalid/oauth/jwks.json", + }); + + const jwksResponse = await SELF.fetch("https://release.example.invalid/oauth/jwks.json"); + const jwksText = await jwksResponse.text(); + expect(jwksResponse.status).toBe(200); + expect(JSON.parse(jwksText).keys).toHaveLength(2); + expect(jwksText).not.toContain('"d"'); + }); + + it("registers only public OAuth discovery and health routes", () => { + expect(ROUTES.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /.well-known/atproto-client-metadata.json", + "GET /oauth/jwks.json", + "GET /health", + ]); }); it("catches async route failures without leaking them to clients", async () => { diff --git a/apps/release-service/vitest.config.ts b/apps/release-service/vitest.config.ts index 49f7781b9b..113e89f7e6 100644 --- a/apps/release-service/vitest.config.ts +++ b/apps/release-service/vitest.config.ts @@ -1,14 +1,19 @@ import { fileURLToPath } from "node:url"; -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; +import { TEST_ASSERTION_KEYSET } from "./test/fixtures/oauth.js"; + const TEST_ENCRYPTION_KEYRING = '{"current":1,"keys":[{"version":1,"key":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"}]}'; process.env["ENCRYPTION_KEYRING"] ??= TEST_ENCRYPTION_KEYRING; +process.env["OAUTH_ASSERTION_KEYSET"] ??= TEST_ASSERTION_KEYSET; const verifierScriptPath = fileURLToPath( new URL("./test/fixtures/release-verifier.js", import.meta.url), ); +const migrationsPath = fileURLToPath(new URL("./migrations", import.meta.url)); +const migrations = await readD1Migrations(migrationsPath); export default defineConfig({ plugins: [ @@ -23,11 +28,14 @@ export default defineConfig({ }, ], bindings: { + TEST_MIGRATIONS: migrations, PUBLIC_ORIGIN: "https://release.example.invalid", ALLOWED_ORIGINS: '["https://release.example.invalid"]', ALLOWED_PUBLISHERS: '{"mode":"all"}', DEPLOYMENT_POLICY: "hosted", ENCRYPTION_KEYRING: TEST_ENCRYPTION_KEYRING, + OAUTH_REDIRECT_URIS: '["https://release.example.invalid/oauth/callback"]', + OAUTH_ASSERTION_KEYSET: TEST_ASSERTION_KEYSET, }, }, }), diff --git a/apps/release-service/vitest.node.config.ts b/apps/release-service/vitest.node.config.ts index ba77ab2aba..cf56326918 100644 --- a/apps/release-service/vitest.node.config.ts +++ b/apps/release-service/vitest.node.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/encryption.test.ts"], + include: ["test/encryption.test.ts", "test/oauth-metadata.test.ts"], }, }); diff --git a/apps/release-service/worker-configuration.d.ts b/apps/release-service/worker-configuration.d.ts index 9bd40a64a0..72c5667da6 100644 --- a/apps/release-service/worker-configuration.d.ts +++ b/apps/release-service/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=wrangler.jsonc --config=../release-verifier/wrangler.jsonc` (hash: f16c80582f5d22ad180e2539412d622e) +// Generated by Wrangler by running `wrangler types --config=wrangler.jsonc --config=../release-verifier/wrangler.jsonc` (hash: b073fd522e48557982a702d20b1f3d23) // Runtime types generated with workerd@1.20260611.1 2026-05-14 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -10,7 +10,9 @@ interface __BaseEnv_Env { ALLOWED_ORIGINS: "[]"; ALLOWED_PUBLISHERS: "{\"mode\":\"allowlist\",\"dids\":[]}"; DEPLOYMENT_POLICY: ""; + OAUTH_REDIRECT_URIS: "[]"; ENCRYPTION_KEYRING: string; + OAUTH_ASSERTION_KEYSET: string; RELEASE_VERIFIER: Service; } declare namespace Cloudflare { @@ -24,7 +26,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/apps/release-service/wrangler.jsonc b/apps/release-service/wrangler.jsonc index b4c1257c41..f9cfef3078 100644 --- a/apps/release-service/wrangler.jsonc +++ b/apps/release-service/wrangler.jsonc @@ -62,9 +62,10 @@ "ALLOWED_ORIGINS": "[]", "ALLOWED_PUBLISHERS": "{\"mode\":\"allowlist\",\"dids\":[]}", "DEPLOYMENT_POLICY": "", + "OAUTH_REDIRECT_URIS": "[]", }, "secrets": { - "required": ["ENCRYPTION_KEYRING"], + "required": ["ENCRYPTION_KEYRING", "OAUTH_ASSERTION_KEYSET"], }, "observability": { "enabled": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f99ec4284c..967ac2d1a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,9 +417,18 @@ importers: apps/release-service: dependencies: + '@atcute/oauth-node-client': + specifier: 2.0.1 + version: 2.0.1(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../../packages/registry-lexicons jose: specifier: ^6.1.3 version: 6.1.3 + ulidx: + specifier: ^2.4.1 + version: 2.4.1 devDependencies: '@cloudflare/vite-plugin': specifier: 'catalog:' @@ -1115,7 +1124,7 @@ importers: dependencies: '@atcute/identity-resolver': specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) + version: 2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/lexicons': specifier: 'catalog:' version: 2.0.0 @@ -1418,10 +1427,10 @@ importers: dependencies: '@atcute/identity-resolver': specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) + version: 2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) '@atcute/oauth-node-client': specifier: 'catalog:' - version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) + version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) '@emdash-cms/auth': specifier: workspace:* version: link:../auth @@ -1923,7 +1932,7 @@ importers: version: 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/identity-resolver': specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) + version: 2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/lexicons': specifier: 'catalog:' version: 2.0.0 @@ -1932,7 +1941,7 @@ importers: version: 1.2.0 '@atcute/oauth-node-client': specifier: 'catalog:' - version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) + version: 2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@clack/prompts': specifier: ^1.4.0 version: 1.4.0 @@ -3031,6 +3040,11 @@ packages: peerDependencies: '@atcute/lexicons': ^2.0.0 + '@atcute/client@5.1.1': + resolution: {integrity: sha512-cn5/Zi/qo37WtQG6gzIC7JPs0RDzX9Z4eaceX45SpKgLZoc3fCFDJcE7C8xsbxBNfjry2T6PmUxWA8obebZsEQ==} + peerDependencies: + '@atcute/lexicons': ^2.0.0 + '@atcute/crypto@2.4.1': resolution: {integrity: sha512-tJ3Pi/XYcAsABKtqSlSOTKfO5YiQ4XdqlTuPS8HiRZSezOPcXBFFzAFWpSIJPURbVPFQL3LLrrK0Ea24wl5qeQ==} @@ -3058,6 +3072,11 @@ packages: peerDependencies: '@atcute/lexicons': ^2.0.0 + '@atcute/identity@2.0.1': + resolution: {integrity: sha512-FEURUvl30SyyWWikkvm+MLz0Snuf0OF10L/qxRhWjj6qDB5Ib+XWhiBuwidjvhCkrCepTUNLbj4TlUm/gHaUig==} + peerDependencies: + '@atcute/lexicons': ^2.0.0 + '@atcute/jetstream@2.0.0': resolution: {integrity: sha512-mlFxoQSi5PwF/8NfIdML7C/tH3sN2vZWUP1y4/oBlivVFD93hERaJWKnb3L7NII0iksbqvfs3FW0ylFw6VuAXg==} peerDependencies: @@ -3081,6 +3100,9 @@ packages: '@atcute/lexicons@2.0.0': resolution: {integrity: sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==} + '@atcute/lexicons@2.0.2': + resolution: {integrity: sha512-ATBADJAy4KQ76NB86BjgYKrRdbDRUo76Cbqna4WIfQAgN105Rcy972MiNKs+BSmcOOM3WakilgTm0CXD4RC0iA==} + '@atcute/mst@1.0.0': resolution: {integrity: sha512-pMce2efib+dmKtnGnIvJZitVncJkpr3AmhyfgfYllni8KzsaDGsJmuGavSVpuojAhQe+6jYwHFtpm/beiiH4uw==} @@ -3093,21 +3115,39 @@ packages: '@atcute/multibase@1.2.0': resolution: {integrity: sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ==} + '@atcute/multibase@1.2.4': + resolution: {integrity: sha512-WeX12hvFZEim6C+cyv7Eqd93w6DzubNWQGmTFBghjsEuXvMe4HbBCYvsti0OUnbA5qLBPlsTyssQUJeLlHCzIw==} + '@atcute/oauth-crypto@1.0.0': resolution: {integrity: sha512-2UC1msk4PyUArk/5Pl8zgtz1T8O+LZdFfB8ENLHjQVYitpqzGj2ZpDJaWZvCF3Y8lly4KoeUHLpFPDzbP+3u+g==} + '@atcute/oauth-crypto@1.0.1': + resolution: {integrity: sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ==} + '@atcute/oauth-keyset@0.1.1': resolution: {integrity: sha512-BpaaXSuMawxILhWTOR0YIpKzFSA0MQC1W5Hn0HGE+giTqYFAKcdf0oA+2RZG9ZLVIzfO2txBsTeMpxB5qL6lEQ==} + '@atcute/oauth-keyset@0.1.2': + resolution: {integrity: sha512-2CUazWBWRjSEoYZDaLS4zEPgCOcr9LgnMhPr1pzr9I+DytRnoXmrdbRs4xBVg4iUdXtdzU48DsMaGi6bHXaFcg==} + '@atcute/oauth-node-client@2.0.0': resolution: {integrity: sha512-1+q7eszInPXR/aTmYyyhW6RD+rnNTpHXF3vgum3mmCt44oSCTeKuzCBrR538LORpspgNwhopotvKJ1AN5UD4kQ==} peerDependencies: '@atcute/identity-resolver': ^2.0.0 '@atcute/lexicons': ^2.0.0 + '@atcute/oauth-node-client@2.0.1': + resolution: {integrity: sha512-MgqN9Rnt5nuUH5zriETtbQrwcx/tWaELKaFVQtqggMtRI4k4IP5+ZbnG40tiJjbZfSclapA2kwOnT14kO0nAxg==} + peerDependencies: + '@atcute/identity-resolver': ^2.0.0 + '@atcute/lexicons': ^2.0.0 + '@atcute/oauth-types@1.0.0': resolution: {integrity: sha512-YOpjLU8H5PG6oKfgau+dx7rSmGsLxIA36MeGL7BDeopcyq80RqPSBAzOasEEsmbMRJ/nTsMRJhnmGkp3RCa/Zw==} + '@atcute/oauth-types@1.0.1': + resolution: {integrity: sha512-9uFP/nK0PgPDsaNbLArzrCHvTZO1k04cSEX5jZ75W2Jh7Jxbh/CSTenSrad3CZXuX6y6hDquEtl6H/IqLtTmHw==} + '@atcute/repo@0.1.4': resolution: {integrity: sha512-uzbGJkE+1A8UFviosJrtw7HW87u8nCCH1V3yOQ79FPrRhS67EvEHF6GTg4aMkP21ze/pRtttJ1k9pFfDmyTlTg==} @@ -3121,18 +3161,24 @@ packages: '@atcute/uint8array@1.1.1': resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} + '@atcute/uint8array@1.1.4': + resolution: {integrity: sha512-rSW5AFVCIN4ooH7vEZB+J60+uWjn5fRBQAQL58qHLiDm8+xDPmHfEU5GfYOJuuD+7UBj8KKiQugIzohFn8/xPw==} + '@atcute/util-fetch@1.0.5': resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==} '@atcute/util-fetch@2.0.0': resolution: {integrity: sha512-v+4aFQ/tuBqTV+URDJaFgm3mASWdglKXiPaGutJ1bs7QtQKmPZeesPY5MzW/a+MtI8GWCEJk8X9wOfalPOFSlg==} - '@atcute/util-text@1.2.0': - resolution: {integrity: sha512-b8WSh+Z7K601eUFFmTFj8QPKDO8Ic0VDDj63sdKzpkm+ySQKsYT5nXekViGqFVKbyKj1V5FyvZvgXad6/aI4QQ==} + '@atcute/util-fetch@2.0.1': + resolution: {integrity: sha512-ugWTOLemA8OxSOj7c8q6ncRmBGFDHSwwE1YinO+PCtaw6WLQFGBfHn+yikQ0e3wTK2t4IPjQ5PxZcRXm961ZVA==} '@atcute/util-text@1.3.1': resolution: {integrity: sha512-MRgJXkx67znuBXuoAYCJkBZyd3OApL7zZlNf5kXhuoCXcdiu1nblRDycYTADSkym4epBSQWxh26kmI9sewaq6A==} + '@atcute/util-text@1.3.3': + resolution: {integrity: sha512-WhedTmg/msFhrdwXw9RjnNcDl8Vmisxl4+Vzyf5k3+8Gj5TKQg72dLSDtBNmNLd61RbHjgfQRBgE0ez6q/jciw==} + '@atcute/varint@2.0.0': resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==} @@ -9773,14 +9819,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9790,6 +9828,11 @@ packages: resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} engines: {node: ^18 || >=20} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -12753,6 +12796,13 @@ snapshots: transitivePeerDependencies: - typescript + '@atcute/client@5.1.1(@atcute/lexicons@2.0.2)(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/lexicons': 2.0.2 + transitivePeerDependencies: + - typescript + '@atcute/crypto@2.4.1': dependencies: '@atcute/multibase': 1.2.0 @@ -12779,21 +12829,39 @@ snapshots: '@atcute/util-fetch': 1.0.5 '@badrap/valita': 0.4.6 - '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': + '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': dependencies: - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/lexicons': 2.0.0 - '@atcute/util-fetch': 2.0.0(typescript@6.0.0-beta) - valibot: 1.4.0(typescript@6.0.0-beta) + '@atcute/util-fetch': 2.0.1(typescript@6.0.3) + valibot: 1.4.1(typescript@6.0.3) transitivePeerDependencies: - typescript - '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': + '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': dependencies: - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) + '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) '@atcute/lexicons': 2.0.0 - '@atcute/util-fetch': 2.0.0(typescript@6.0.3) - valibot: 1.4.0(typescript@6.0.3) + '@atcute/util-fetch': 2.0.1(typescript@6.0.0-beta) + valibot: 1.4.1(typescript@6.0.0-beta) + transitivePeerDependencies: + - typescript + + '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3) + '@atcute/lexicons': 2.0.0 + '@atcute/util-fetch': 2.0.1(typescript@6.0.3) + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/lexicons': 2.0.2 + '@atcute/util-fetch': 2.0.1(typescript@6.0.3) + valibot: 1.4.1(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -12816,6 +12884,27 @@ snapshots: transitivePeerDependencies: - typescript + '@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': + dependencies: + '@atcute/lexicons': 2.0.0 + valibot: 1.4.1(typescript@6.0.0-beta) + transitivePeerDependencies: + - typescript + + '@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3)': + dependencies: + '@atcute/lexicons': 2.0.0 + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3)': + dependencies: + '@atcute/lexicons': 2.0.2 + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + '@atcute/jetstream@2.0.0(@atcute/lexicons@2.0.0)(react@19.2.4)(typescript@6.0.3)': dependencies: '@atcute/lexicons': 2.0.0 @@ -12846,7 +12935,7 @@ snapshots: '@atcute/identity': 1.1.4 '@atcute/lexicons': 1.3.0 '@atcute/uint8array': 1.1.1 - '@atcute/util-text': 1.2.0 + '@atcute/util-text': 1.3.1 '@badrap/valita': 0.4.6 '@atcute/lexicon-resolver@0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.4))(@atcute/identity@1.1.4)': @@ -12863,7 +12952,7 @@ snapshots: '@atcute/lexicons@1.3.0': dependencies: '@atcute/uint8array': 1.1.1 - '@atcute/util-text': 1.2.0 + '@atcute/util-text': 1.3.1 '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 @@ -12874,6 +12963,13 @@ snapshots: '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 + '@atcute/lexicons@2.0.2': + dependencies: + '@atcute/uint8array': 1.1.4 + '@atcute/util-text': 1.3.3 + '@standard-schema/spec': 1.1.0 + esm-env: 1.2.2 + '@atcute/mst@1.0.0': dependencies: '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) @@ -12890,6 +12986,10 @@ snapshots: dependencies: '@atcute/uint8array': 1.1.1 + '@atcute/multibase@1.2.4': + dependencies: + '@atcute/uint8array': 1.1.4 + '@atcute/oauth-crypto@1.0.0(typescript@6.0.0-beta)': dependencies: '@atcute/multibase': 1.2.0 @@ -12908,6 +13008,15 @@ snapshots: transitivePeerDependencies: - typescript + '@atcute/oauth-crypto@1.0.1(typescript@6.0.3)': + dependencies: + '@atcute/multibase': 1.2.4 + '@atcute/uint8array': 1.1.4 + nanoid: 5.1.16 + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + '@atcute/oauth-keyset@0.1.1(typescript@6.0.0-beta)': dependencies: '@atcute/oauth-crypto': 1.0.0(typescript@6.0.0-beta) @@ -12920,11 +13029,17 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/oauth-node-client@2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': + '@atcute/oauth-keyset@0.1.2(typescript@6.0.3)': + dependencies: + '@atcute/oauth-crypto': 1.0.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/oauth-node-client@2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta)': dependencies: '@atcute/client': 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) - '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) + '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta))(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) '@atcute/lexicons': 2.0.0 '@atcute/oauth-crypto': 1.0.0(typescript@6.0.0-beta) '@atcute/oauth-keyset': 0.1.1(typescript@6.0.0-beta) @@ -12935,11 +13050,11 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/oauth-node-client@2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': + '@atcute/oauth-node-client@2.0.0(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3)': dependencies: '@atcute/client': 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) - '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)(typescript@6.0.3) '@atcute/lexicons': 2.0.0 '@atcute/oauth-crypto': 1.0.0(typescript@6.0.3) '@atcute/oauth-keyset': 0.1.1(typescript@6.0.3) @@ -12950,6 +13065,21 @@ snapshots: transitivePeerDependencies: - typescript + '@atcute/oauth-node-client@2.0.1(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3)': + dependencies: + '@atcute/client': 5.1.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/lexicons': 2.0.2 + '@atcute/oauth-crypto': 1.0.1(typescript@6.0.3) + '@atcute/oauth-keyset': 0.1.2(typescript@6.0.3) + '@atcute/oauth-types': 1.0.1(typescript@6.0.3) + '@atcute/util-fetch': 2.0.1(typescript@6.0.3) + nanoid: 5.1.16 + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + '@atcute/oauth-types@1.0.0(typescript@6.0.0-beta)': dependencies: '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.0-beta) @@ -12968,6 +13098,15 @@ snapshots: transitivePeerDependencies: - typescript + '@atcute/oauth-types@1.0.1(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/lexicons': 2.0.2 + '@atcute/oauth-keyset': 0.1.2(typescript@6.0.3) + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + '@atcute/repo@0.1.4': dependencies: '@atcute/car': 5.1.1 @@ -12990,6 +13129,8 @@ snapshots: '@atcute/uint8array@1.1.1': {} + '@atcute/uint8array@1.1.4': {} + '@atcute/util-fetch@1.0.5': dependencies: '@badrap/valita': 0.4.6 @@ -13006,14 +13147,26 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/util-text@1.2.0': + '@atcute/util-fetch@2.0.1(typescript@6.0.0-beta)': dependencies: - unicode-segmenter: 0.14.5 + valibot: 1.4.1(typescript@6.0.0-beta) + transitivePeerDependencies: + - typescript + + '@atcute/util-fetch@2.0.1(typescript@6.0.3)': + dependencies: + valibot: 1.4.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript '@atcute/util-text@1.3.1': dependencies: unicode-segmenter: 0.14.5 + '@atcute/util-text@1.3.3': + dependencies: + unicode-segmenter: 0.14.5 + '@atcute/varint@2.0.0': {} '@atcute/xrpc-server-cloudflare@2.0.0(@atcute/xrpc-server@2.0.0(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.0)(typescript@6.0.3))': @@ -20668,14 +20821,12 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.11: {} - - nanoid@3.3.12: {} - nanoid@3.3.15: {} nanoid@5.1.11: {} + nanoid@5.1.16: {} + napi-build-utils@2.0.0: {} negotiator@1.0.0: {} @@ -21171,13 +21322,13 @@ snapshots: postcss@8.5.14: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.15: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -21189,7 +21340,7 @@ snapshots: postcss@8.5.6: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1