Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/release-service/migrations/0001_oauth_custody.sql
Original file line number Diff line number Diff line change
@@ -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';
5 changes: 4 additions & 1 deletion apps/release-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions apps/release-service/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const errorEnvelopeSchema = {
export function generateApiSchema(routes: readonly RouteDefinition[] = ROUTES) {
const paths: Record<string, Record<string, unknown>> = {};
for (const route of routes) {
if (route.includeInApiSchema === false) continue;
const path = (paths[route.path] ??= {});
path[route.method.toLowerCase()] = {
operationId: route.operationId,
Expand Down
211 changes: 210 additions & 1 deletion apps/release-service/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand All @@ -8,13 +15,33 @@ export type ConfigurationBindings = Record<
| "ALLOWED_PUBLISHERS"
| "DEPLOYMENT_POLICY"
| "ENCRYPTION_KEYRING"
| "OAUTH_REDIRECT_URIS"
| "OAUTH_ASSERTION_KEYSET"
>,
string
>;

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<ServiceConfiguration>;
}

interface AllowAllPublishers {
mode: "all";
Expand All @@ -32,9 +59,20 @@ export interface ServiceConfiguration {
allowedOrigins: ReadonlySet<string>;
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[];

Expand All @@ -60,6 +98,126 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function hasExactKeys(record: Record<string, unknown>, 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<string>();
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<string> | null {
try {
const parsed: unknown = JSON.parse(value);
Expand Down Expand Up @@ -97,7 +255,7 @@ function parseAllowedPublishers(value: string): AllowedPublisherPolicy | null {
}
}

export function loadConfiguration(bindings: ConfigurationBindings): ServiceConfiguration {
async function parseConfiguration(bindings: ConfigurationBindings): Promise<ServiceConfiguration> {
const issues: string[] = [];
const publicOrigin = parseOrigin(bindings.PUBLIC_ORIGIN);
if (!publicOrigin) issues.push("PUBLIC_ORIGIN_INVALID");
Expand All @@ -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<object, ConfigurationCacheEntry> {
const target = globalThis as typeof globalThis & {
[CONFIGURATION_CACHE_SYMBOL]?: WeakMap<object, ConfigurationCacheEntry>;
};
return (target[CONFIGURATION_CACHE_SYMBOL] ??= new WeakMap());
}

export function loadConfiguration(bindings: ConfigurationBindings): Promise<ServiceConfiguration> {
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;
}
Loading
Loading