Unified · Split
| @@ -8,6 +8,8 @@ const appShellRewriteExclusions = [ · 7 unmodified lines | ||
| 8 | 8 | "sign-up(?:/|$)", |
| 9 | 9 | "sign-out(?:/|$)", |
| 10 | 10 | "callback(?:/|$)", |
| 11 | + "install(?:/|$)", | |
| 12 | + "install\\.sh(?:/|$)", | |
| 11 | 13 | "static-app-shell(?:/|$)", |
| 12 | 14 | ".*\\..*", |
| 13 | 15 | ].join("|"); |
| @@ -29,6 +31,15 @@ const nextConfig: NextConfig = { · 15 unmodified lines | ||
| 29 | 31 | }, |
| 30 | 32 | ]; |
| 31 | 33 | }, |
| 34 | + async redirects() { | |
| 35 | + return [ | |
| 36 | + { | |
| 37 | + source: "/install.sh", | |
| 38 | + destination: "/install", | |
| 39 | + permanent: false, | |
| 40 | + }, | |
| 41 | + ]; | |
| 42 | + }, | |
| 32 | 43 | }; |
| 33 | 44 | |
| 34 | 45 | export default nextConfig; |
| @@ -0,0 +1,7 @@ | ||
| 1 | +const DEFAULT_INSTALL_SCRIPT_URL = | |
| 2 | + "https://raw.githubusercontent.com/RhysSullivan/executor/main/executor/install"; | |
| 3 | + | |
| 4 | +export async function GET(): Promise<Response> { | |
| 5 | + const target = process.env.EXECUTOR_INSTALL_SCRIPT_URL ?? DEFAULT_INSTALL_SCRIPT_URL; | |
| 6 | + return Response.redirect(target, 302); | |
| 7 | +} | |
| @@ -548,6 +548,18 @@ export declare const internal: { · 547 unmodified lines | ||
| 548 | 548 | { sourceId: string; workspaceId: Id<"workspaces"> }, |
| 549 | 549 | any |
| 550 | 550 | >; |
| 551 | + getActiveAnonymousOauthSigningKey: FunctionReference< | |
| 552 | + "query", | |
| 553 | + "internal", | |
| 554 | + {}, | |
| 555 | + any | |
| 556 | + >; | |
| 557 | + getAnonymousOauthClient: FunctionReference< | |
| 558 | + "query", | |
| 559 | + "internal", | |
| 560 | + { clientId: string }, | |
| 561 | + any | |
| 562 | + >; | |
| 551 | 563 | getApproval: FunctionReference< |
| 552 | 564 | "query", |
| 553 | 565 | "internal", |
| @@ -639,6 +651,12 @@ export declare const internal: { · 85 unmodified lines | ||
| 639 | 651 | { taskId: string }, |
| 640 | 652 | any |
| 641 | 653 | >; |
| 654 | + registerAnonymousOauthClient: FunctionReference< | |
| 655 | + "mutation", | |
| 656 | + "internal", | |
| 657 | + { clientId: string; clientName?: string; redirectUris: Array<string> }, | |
| 658 | + any | |
| 659 | + >; | |
| 642 | 660 | resolveApproval: FunctionReference< |
| 643 | 661 | "mutation", |
| 644 | 662 | "internal", |
| @@ -661,6 +679,17 @@ export declare const internal: { · 16 unmodified lines | ||
| 661 | 679 | }, |
| 662 | 680 | any |
| 663 | 681 | >; |
| 682 | + storeAnonymousOauthSigningKey: FunctionReference< | |
| 683 | + "mutation", | |
| 684 | + "internal", | |
| 685 | + { | |
| 686 | + algorithm: string; | |
| 687 | + keyId: string; | |
| 688 | + privateKeyJwk: any; | |
| 689 | + publicKeyJwk: any; | |
| 690 | + }, | |
| 691 | + any | |
| 692 | + >; | |
| 664 | 693 | upsertAccessPolicy: FunctionReference< |
| 665 | 694 | "mutation", |
| 666 | 695 | "internal", |
| @@ -1133,3 +1133,115 @@ export const listTaskEvents = internalQuery({ · 1132 unmodified lines | ||
| 1133 | 1133 | return docs.map(mapTaskEvent); |
| 1134 | 1134 | }, |
| 1135 | 1135 | }); |
| 1136 | + | |
| 1137 | +// ── Anonymous OAuth Signing Keys ───────────────────────────────────────────── | |
| 1138 | + | |
| 1139 | +/** | |
| 1140 | + * Get the active anonymous OAuth signing key pair. | |
| 1141 | + * Returns null if no key has been generated yet. | |
| 1142 | + */ | |
| 1143 | +export const getActiveAnonymousOauthSigningKey = internalQuery({ | |
| 1144 | + args: {}, | |
| 1145 | + handler: async (ctx) => { | |
| 1146 | + const doc = await ctx.db | |
| 1147 | + .query("anonymousOauthSigningKeys") | |
| 1148 | + .withIndex("by_status", (q) => q.eq("status", "active")) | |
| 1149 | + .first(); | |
| 1150 | + | |
| 1151 | + if (!doc) return null; | |
| 1152 | + | |
| 1153 | + return { | |
| 1154 | + keyId: doc.keyId, | |
| 1155 | + algorithm: doc.algorithm, | |
| 1156 | + privateKeyJwk: doc.privateKeyJwk, | |
| 1157 | + publicKeyJwk: doc.publicKeyJwk, | |
| 1158 | + createdAt: doc.createdAt, | |
| 1159 | + }; | |
| 1160 | + }, | |
| 1161 | +}); | |
| 1162 | + | |
| 1163 | +/** | |
| 1164 | + * Store a new anonymous OAuth signing key pair, rotating any previously active key. | |
| 1165 | + */ | |
| 1166 | +export const storeAnonymousOauthSigningKey = internalMutation({ | |
| 1167 | + args: { | |
| 1168 | + keyId: v.string(), | |
| 1169 | + algorithm: v.string(), | |
| 1170 | + privateKeyJwk: v.any(), | |
| 1171 | + publicKeyJwk: v.any(), | |
| 1172 | + }, | |
| 1173 | + handler: async (ctx, args) => { | |
| 1174 | + const now = Date.now(); | |
| 1175 | + | |
| 1176 | + // Rotate any currently active keys | |
| 1177 | + const activeKeys = await ctx.db | |
| 1178 | + .query("anonymousOauthSigningKeys") | |
| 1179 | + .withIndex("by_status", (q) => q.eq("status", "active")) | |
| 1180 | + .collect(); | |
| 1181 | + | |
| 1182 | + for (const key of activeKeys) { | |
| 1183 | + await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now }); | |
| 1184 | + } | |
| 1185 | + | |
| 1186 | + // Insert the new active key | |
| 1187 | + await ctx.db.insert("anonymousOauthSigningKeys", { | |
| 1188 | + keyId: args.keyId, | |
| 1189 | + algorithm: args.algorithm, | |
| 1190 | + privateKeyJwk: args.privateKeyJwk, | |
| 1191 | + publicKeyJwk: args.publicKeyJwk, | |
| 1192 | + status: "active", | |
| 1193 | + createdAt: now, | |
| 1194 | + }); | |
| 1195 | + }, | |
| 1196 | +}); | |
| 1197 | + | |
| 1198 | +// ── Anonymous OAuth Client Registrations ──────────────────────────────────── | |
| 1199 | + | |
| 1200 | +/** | |
| 1201 | + * Register a new anonymous OAuth client (RFC 7591 dynamic client registration). | |
| 1202 | + */ | |
| 1203 | +export const registerAnonymousOauthClient = internalMutation({ | |
| 1204 | + args: { | |
| 1205 | + clientId: v.string(), | |
| 1206 | + clientName: v.optional(v.string()), | |
| 1207 | + redirectUris: v.array(v.string()), | |
| 1208 | + }, | |
| 1209 | + handler: async (ctx, args) => { | |
| 1210 | + const now = Date.now(); | |
| 1211 | + await ctx.db.insert("anonymousOauthClients", { | |
| 1212 | + clientId: args.clientId, | |
| 1213 | + clientName: args.clientName, | |
| 1214 | + redirectUris: args.redirectUris, | |
| 1215 | + createdAt: now, | |
| 1216 | + }); | |
| 1217 | + | |
| 1218 | + return { | |
| 1219 | + client_id: args.clientId, | |
| 1220 | + client_name: args.clientName, | |
| 1221 | + redirect_uris: args.redirectUris, | |
| 1222 | + created_at: now, | |
| 1223 | + }; | |
| 1224 | + }, | |
| 1225 | +}); | |
| 1226 | + | |
| 1227 | +/** | |
| 1228 | + * Look up an anonymous OAuth client by client_id. | |
| 1229 | + */ | |
| 1230 | +export const getAnonymousOauthClient = internalQuery({ | |
| 1231 | + args: { clientId: v.string() }, | |
| 1232 | + handler: async (ctx, args) => { | |
| 1233 | + const doc = await ctx.db | |
| 1234 | + .query("anonymousOauthClients") | |
| 1235 | + .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId)) | |
| 1236 | + .unique(); | |
| 1237 | + | |
| 1238 | + if (!doc) return null; | |
| 1239 | + | |
| 1240 | + return { | |
| 1241 | + client_id: doc.clientId, | |
| 1242 | + client_name: doc.clientName, | |
| 1243 | + redirect_uris: doc.redirectUris, | |
| 1244 | + created_at: doc.createdAt, | |
| 1245 | + }; | |
| 1246 | + }, | |
| 1247 | +}); | |
| @@ -313,4 +313,37 @@ export default defineSchema({ · 312 unmodified lines | ||
| 313 | 313 | .index("by_session_id", ["sessionId"]) |
| 314 | 314 | .index("by_workspace_actor", ["workspaceId", "actorId"]) |
| 315 | 315 | .index("by_account", ["accountId"]), |
| 316 | + | |
| 317 | + // ── Anonymous OAuth: Self-issued auth server for anonymous MCP clients ──── | |
| 318 | + | |
| 319 | + /** | |
| 320 | + * Signing key pair for the self-issued anonymous OAuth server. | |
| 321 | + * Persisted so tokens survive gateway restarts. | |
| 322 | + * Typically only one "active" row at a time. | |
| 323 | + */ | |
| 324 | + anonymousOauthSigningKeys: defineTable({ | |
| 325 | + keyId: v.string(), // kid claim: anon_key_<short-uuid> | |
| 326 | + algorithm: v.string(), // e.g. "RS256" | |
| 327 | + /** JWK-encoded private key (JSON object). */ | |
| 328 | + privateKeyJwk: v.any(), | |
| 329 | + /** JWK-encoded public key (JSON object). */ | |
| 330 | + publicKeyJwk: v.any(), | |
| 331 | + status: v.union(v.literal("active"), v.literal("rotated")), | |
| 332 | + createdAt: v.number(), | |
| 333 | + rotatedAt: v.optional(v.number()), | |
| 334 | + }) | |
| 335 | + .index("by_key_id", ["keyId"]) | |
| 336 | + .index("by_status", ["status"]), | |
| 337 | + | |
| 338 | + /** | |
| 339 | + * Dynamic client registrations (RFC 7591) for the anonymous OAuth flow. | |
| 340 | + * Persisted so MCP clients don't need to re-register after a gateway restart. | |
| 341 | + */ | |
| 342 | + anonymousOauthClients: defineTable({ | |
| 343 | + clientId: v.string(), // domain ID: anon_client_<uuid> | |
| 344 | + clientName: v.optional(v.string()), | |
| 345 | + redirectUris: v.array(v.string()), | |
| 346 | + createdAt: v.number(), | |
| 347 | + }) | |
| 348 | + .index("by_client_id", ["clientId"]), | |
| 316 | 349 | }); |
| @@ -5,7 +5,10 @@ APP="executor" · 4 unmodified lines | ||
| 5 | 5 | REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}" |
| 6 | 6 | INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}" |
| 7 | 7 | REQUESTED_VERSION="${VERSION:-}" |
| 8 | +BINARY_PATH="" | |
| 8 | 9 | NO_MODIFY_PATH="0" |
| 10 | +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" | |
| 11 | +LOCAL_BINARY_CANDIDATE="$SCRIPT_DIR/dist/executor" | |
| 9 | 12 | |
| 10 | 13 | RED='\033[0;31m' |
| 11 | 14 | MUTED='\033[0;2m' |
| @@ -20,6 +23,7 @@ Usage: · 8 unmodified lines | ||
| 20 | 23 | |
| 21 | 24 | Options: |
| 22 | 25 | -v, --version <version> Install specific release tag (with or without leading v) |
| 26 | + -b, --binary <path> Install from a local prebuilt binary | |
| 23 | 27 | --no-modify-path Do not append executor bin directory to shell rc |
| 24 | 28 | -h, --help Show this help |
| 25 | 29 | |
| @@ -43,6 +47,14 @@ while [[ $# -gt 0 ]]; do · 17 unmodified lines | ||
| 43 | 47 | fi |
| 44 | 48 | shift 2 |
| 45 | 49 | ;; |
| 50 | + -b|--binary) | |
| 51 | + BINARY_PATH="${2:-}" | |
| 52 | + if [[ -z "$BINARY_PATH" ]]; then | |
| 53 | + echo -e "${RED}Missing value for --binary${NC}" | |
| 54 | + exit 1 | |
| 55 | + fi | |
| 56 | + shift 2 | |
| 57 | + ;; | |
| 46 | 58 | --no-modify-path) |
| 47 | 59 | NO_MODIFY_PATH="1" |
| 48 | 60 | shift |
| @@ -80,30 +92,61 @@ archive_ext="tar.gz" · 31 unmodified lines | ||
| 80 | 92 | filename="${APP}-${os}-${arch}.${archive_ext}" |
| 81 | 93 | mkdir -p "$INSTALL_DIR" |
| 82 | 94 | |
| 83 | −if [[ -z "$REQUESTED_VERSION" ]]; then | |
| 84 | − url="https://github.com/${REPO}/releases/latest/download/${filename}" | |
| 85 | − version="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')" | |
| 86 | −else | |
| 87 | − version="${REQUESTED_VERSION#v}" | |
| 88 | − url="https://github.com/${REPO}/releases/download/v${version}/${filename}" | |
| 89 | −fi | |
| 95 | +install_local_binary() { | |
| 96 | + local src="$1" | |
| 97 | + if [[ ! -f "$src" ]]; then | |
| 98 | + echo -e "${RED}Local binary not found: ${src}${NC}" | |
| 99 | + exit 1 | |
| 100 | + fi | |
| 101 | + cp "$src" "$INSTALL_DIR/$APP" | |
| 102 | + chmod 755 "$INSTALL_DIR/$APP" | |
| 103 | +} | |
| 90 | 104 | |
| 91 | −tmp_dir="$(mktemp -d)" | |
| 92 | −trap 'rm -rf "$tmp_dir"' EXIT | |
| 105 | +if [[ -n "$BINARY_PATH" ]]; then | |
| 106 | + echo -e "${MUTED}Installing ${APP}${NC} from local binary: $BINARY_PATH" | |
| 107 | + install_local_binary "$BINARY_PATH" | |
| 108 | +else | |
| 109 | + if [[ -z "$REQUESTED_VERSION" ]]; then | |
| 110 | + url="https://github.com/${REPO}/releases/latest/download/${filename}" | |
| 111 | + release_json="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null || true)" | |
| 112 | + version="$(printf "%s" "$release_json" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')" | |
| 113 | + if [[ -z "$version" ]]; then | |
| 114 | + version="latest" | |
| 115 | + fi | |
| 116 | + else | |
| 117 | + version="${REQUESTED_VERSION#v}" | |
| 118 | + url="https://github.com/${REPO}/releases/download/v${version}/${filename}" | |
| 119 | + fi | |
| 93 | 120 | |
| 94 | −echo -e "${MUTED}Installing ${APP}${NC} ${version:-latest}" | |
| 95 | −curl -fsSL "$url" -o "$tmp_dir/$filename" | |
| 121 | + tmp_dir="$(mktemp -d)" | |
| 122 | + trap 'rm -rf "$tmp_dir"' EXIT | |
| 123 | + | |
| 124 | + echo -e "${MUTED}Installing ${APP}${NC} ${version:-latest}" | |
| 125 | + if ! curl -fsSL "$url" -o "$tmp_dir/$filename"; then | |
| 126 | + if [[ -f "$LOCAL_BINARY_CANDIDATE" ]]; then | |
| 127 | + echo -e "${MUTED}Release asset not found; using local binary at ${LOCAL_BINARY_CANDIDATE}${NC}" | |
| 128 | + install_local_binary "$LOCAL_BINARY_CANDIDATE" | |
| 129 | + else | |
| 130 | + echo -e "${RED}Could not download release asset: ${url}${NC}" | |
| 131 | + echo "" | |
| 132 | + echo "If you are developing locally, build and install from source:" | |
| 133 | + echo " bun run --cwd executor build:binary" | |
| 134 | + echo " bash executor/install --binary executor/dist/executor" | |
| 135 | + exit 1 | |
| 136 | + fi | |
| 137 | + else | |
| 138 | + tar -xzf "$tmp_dir/$filename" -C "$tmp_dir" | |
| 96 | 139 | |
| 97 | −tar -xzf "$tmp_dir/$filename" -C "$tmp_dir" | |
| 140 | + if [[ ! -f "$tmp_dir/$APP" ]]; then | |
| 141 | + echo -e "${RED}Archive did not contain expected binary '${APP}'${NC}" | |
| 142 | + exit 1 | |
| 143 | + fi | |
| 98 | 144 | |
| 99 | −if [[ ! -f "$tmp_dir/$APP" ]]; then | |
| 100 | − echo -e "${RED}Archive did not contain expected binary '${APP}'${NC}" | |
| 101 | − exit 1 | |
| 145 | + mv "$tmp_dir/$APP" "$INSTALL_DIR/$APP" | |
| 146 | + chmod 755 "$INSTALL_DIR/$APP" | |
| 147 | + fi | |
| 102 | 148 | fi |
| 103 | 149 | |
| 104 | −mv "$tmp_dir/$APP" "$INSTALL_DIR/$APP" | |
| 105 | −chmod 755 "$INSTALL_DIR/$APP" | |
| 106 | − | |
| 107 | 150 | if [[ "$NO_MODIFY_PATH" != "1" ]]; then |
| 108 | 151 | shell_name="$(basename "${SHELL:-}")" |
| 109 | 152 | case "$shell_name" in |
| @@ -18,6 +18,13 @@ · 17 unmodified lines | ||
| 18 | 18 | * (`anon_<uuid>`) which the gateway resolves via |
| 19 | 19 | * `bootstrapAnonymousSession` — identical to the existing anonymous flow |
| 20 | 20 | * but now behind a proper Bearer token. |
| 21 | + * | |
| 22 | + * Storage is pluggable via the `OAuthStorage` interface: | |
| 23 | + * - `InMemoryOAuthStorage` — ephemeral, for tests and single-process dev | |
| 24 | + * - `ConvexOAuthStorage` — persists keys & client registrations to Convex, | |
| 25 | + * so tokens survive gateway restarts | |
| 26 | + * | |
| 27 | + * Authorization codes are always in-memory (short-lived, single-use). | |
| 21 | 28 | */ |
| 22 | 29 | |
| 23 | 30 | import { |
| @@ -25,6 +32,7 @@ import { · 1 unmodified line | ||
| 25 | 32 | createLocalJWKSet, |
| 26 | 33 | exportJWK, |
| 27 | 34 | generateKeyPair, |
| 35 | + importJWK, | |
| 28 | 36 | jwtVerify, |
| 29 | 37 | type JWK, |
| 30 | 38 | type CryptoKey as JoseCryptoKey, |
| @@ -58,6 +66,73 @@ export interface AnonOAuthConfig { · 27 unmodified lines | ||
| 58 | 66 | accessTokenTtlSeconds?: number; |
| 59 | 67 | /** Authorization code TTL in seconds. Default 120 s. */ |
| 60 | 68 | codeExpirySeconds?: number; |
| 69 | + /** Pluggable storage backend for keys & client registrations. */ | |
| 70 | + storage?: OAuthStorage; | |
| 71 | +} | |
| 72 | + | |
| 73 | +// --------------------------------------------------------------------------- | |
| 74 | +// Storage interface | |
| 75 | +// --------------------------------------------------------------------------- | |
| 76 | + | |
| 77 | +export interface StoredSigningKey { | |
| 78 | + keyId: string; | |
| 79 | + algorithm: string; | |
| 80 | + privateKeyJwk: JWK; | |
| 81 | + publicKeyJwk: JWK; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export interface OAuthStorage { | |
| 85 | + /** | |
| 86 | + * Load the active signing key pair. | |
| 87 | + * Returns null if no key has been generated yet. | |
| 88 | + */ | |
| 89 | + getActiveSigningKey(): Promise<StoredSigningKey | null>; | |
| 90 | + | |
| 91 | + /** | |
| 92 | + * Store a new signing key pair (rotating any previous active key). | |
| 93 | + */ | |
| 94 | + storeSigningKey(key: StoredSigningKey): Promise<void>; | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * Register a new OAuth client. Returns the registration. | |
| 98 | + */ | |
| 99 | + registerClient(registration: AnonOAuthClientRegistration): Promise<AnonOAuthClientRegistration>; | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * Look up an OAuth client by client_id. Returns null if not found. | |
| 103 | + */ | |
| 104 | + getClient(clientId: string): Promise<AnonOAuthClientRegistration | null>; | |
| 105 | +} | |
| 106 | + | |
| 107 | +// --------------------------------------------------------------------------- | |
| 108 | +// In-memory storage (for tests and single-process dev) | |
| 109 | +// --------------------------------------------------------------------------- | |
| 110 | + | |
| 111 | +export class InMemoryOAuthStorage implements OAuthStorage { | |
| 112 | + private signingKey: StoredSigningKey | null = null; | |
| 113 | + private readonly clients = new Map<string, AnonOAuthClientRegistration>(); | |
| 114 | + | |
| 115 | + async getActiveSigningKey(): Promise<StoredSigningKey | null> { | |
| 116 | + return this.signingKey; | |
| 117 | + } | |
| 118 | + | |
| 119 | + async storeSigningKey(key: StoredSigningKey): Promise<void> { | |
| 120 | + this.signingKey = key; | |
| 121 | + } | |
| 122 | + | |
| 123 | + async registerClient(registration: AnonOAuthClientRegistration): Promise<AnonOAuthClientRegistration> { | |
| 124 | + this.clients.set(registration.client_id, registration); | |
| 125 | + return registration; | |
| 126 | + } | |
| 127 | + | |
| 128 | + async getClient(clientId: string): Promise<AnonOAuthClientRegistration | null> { | |
| 129 | + return this.clients.get(clientId) ?? null; | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** Test helper: number of registered clients. */ | |
| 133 | + get clientCount(): number { | |
| 134 | + return this.clients.size; | |
| 135 | + } | |
| 61 | 136 | } |
| 62 | 137 | |
| 63 | 138 | // --------------------------------------------------------------------------- |
| @@ -68,13 +143,12 @@ export class AnonymousOAuthServer { · 4 unmodified lines | ||
| 68 | 143 | private readonly issuer: string; |
| 69 | 144 | private readonly accessTokenTtlSeconds: number; |
| 70 | 145 | private readonly codeExpirySeconds: number; |
| 146 | + private readonly storage: OAuthStorage; | |
| 71 | 147 | |
| 72 | 148 | private privateKey!: JoseCryptoKey; |
| 73 | 149 | private publicJwk!: JWK; |
| 74 | 150 | private keyId!: string; |
| 75 | 151 | |
| 76 | − /** In-memory client registrations (ephemeral — survives process lifetime). */ | |
| 77 | − private readonly clients = new Map<string, AnonOAuthClientRegistration>(); | |
| 78 | 152 | /** In-memory authorization codes (short-lived, cleaned up on use). */ |
| 79 | 153 | private readonly codes = new Map<string, AuthorizationCode>(); |
| 80 | 154 | |
| @@ -82,25 +156,53 @@ export class AnonymousOAuthServer { · 1 unmodified line | ||
| 82 | 156 | this.issuer = config.issuer.replace(/\/+$/, ""); |
| 83 | 157 | this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60; |
| 84 | 158 | this.codeExpirySeconds = config.codeExpirySeconds ?? 120; |
| 159 | + this.storage = config.storage ?? new InMemoryOAuthStorage(); | |
| 85 | 160 | } |
| 86 | 161 | |
| 87 | 162 | // ------------------------------------------------------------------------- |
| 88 | 163 | // Initialization |
| 89 | 164 | // ------------------------------------------------------------------------- |
| 90 | 165 | |
| 91 | − /** Generate an ephemeral RSA key pair. Must be called once before use. */ | |
| 166 | + /** | |
| 167 | + * Initialize the OAuth server. | |
| 168 | + * | |
| 169 | + * Attempts to load an existing signing key from storage. If none exists, | |
| 170 | + * generates a new RSA key pair and persists it. Either way, the private | |
| 171 | + * key and public JWK are cached locally for fast in-process operations. | |
| 172 | + */ | |
| 92 | 173 | async init(): Promise<void> { |
| 174 | + const existing = await this.storage.getActiveSigningKey(); | |
| 175 | + | |
| 176 | + if (existing) { | |
| 177 | + // Import the persisted key pair into CryptoKey objects | |
| 178 | + this.keyId = existing.keyId; | |
| 179 | + this.publicJwk = { ...existing.publicKeyJwk, kid: this.keyId, use: "sig", alg: existing.algorithm }; | |
| 180 | + this.privateKey = await importJWK(existing.privateKeyJwk, existing.algorithm) as JoseCryptoKey; | |
| 181 | + return; | |
| 182 | + } | |
| 183 | + | |
| 184 | + // No existing key — generate a fresh pair and persist it | |
| 93 | 185 | const { privateKey, publicKey } = await generateKeyPair("RS256"); |
| 94 | 186 | this.privateKey = privateKey; |
| 95 | − this.keyId = `anon-${crypto.randomUUID().slice(0, 8)}`; | |
| 96 | − const jwk = await exportJWK(publicKey); | |
| 97 | − this.publicJwk = { ...jwk, kid: this.keyId, use: "sig", alg: "RS256" }; | |
| 187 | + this.keyId = `anon_key_${crypto.randomUUID().slice(0, 8)}`; | |
| 188 | + | |
| 189 | + const publicJwk = await exportJWK(publicKey); | |
| 190 | + this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" }; | |
| 191 | + | |
| 192 | + const privateKeyJwk = await exportJWK(privateKey); | |
| 193 | + | |
| 194 | + await this.storage.storeSigningKey({ | |
| 195 | + keyId: this.keyId, | |
| 196 | + algorithm: "RS256", | |
| 197 | + privateKeyJwk, | |
| 198 | + publicKeyJwk: publicJwk, | |
| 199 | + }); | |
| 98 | 200 | } |
| 99 | 201 | |
| 100 | − /** Import an existing key pair (for tests or persistence). */ | |
| 202 | + /** Import an existing key pair (for tests or manual persistence). */ | |
| 101 | 203 | async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> { |
| 102 | 204 | this.privateKey = privateKey; |
| 103 | − this.keyId = publicJwk.kid ?? `anon-${crypto.randomUUID().slice(0, 8)}`; | |
| 205 | + this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`; | |
| 104 | 206 | this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" }; |
| 105 | 207 | } |
| 106 | 208 | |
| @@ -135,10 +237,10 @@ export class AnonymousOAuthServer { · 28 unmodified lines | ||
| 135 | 237 | // RFC 7591 — Dynamic Client Registration |
| 136 | 238 | // ------------------------------------------------------------------------- |
| 137 | 239 | |
| 138 | − registerClient(body: { | |
| 240 | + async registerClient(body: { | |
| 139 | 241 | redirect_uris?: string[]; |
| 140 | 242 | client_name?: string; |
| 141 | − }): AnonOAuthClientRegistration { | |
| 243 | + }): Promise<AnonOAuthClientRegistration> { | |
| 142 | 244 | const redirectUris = body.redirect_uris; |
| 143 | 245 | if (!Array.isArray(redirectUris) || redirectUris.length === 0) { |
| 144 | 246 | throw new OAuthBadRequest("redirect_uris is required and must be non-empty"); |
| @@ -157,8 +259,8 @@ export class AnonymousOAuthServer { · 12 unmodified lines | ||
| 157 | 259 | redirect_uris: redirectUris, |
| 158 | 260 | created_at: Date.now(), |
| 159 | 261 | }; |
| 160 | − this.clients.set(clientId, registration); | |
| 161 | − return registration; | |
| 262 | + | |
| 263 | + return await this.storage.registerClient(registration); | |
| 162 | 264 | } |
| 163 | 265 | |
| 164 | 266 | // ------------------------------------------------------------------------- |
| @@ -170,7 +272,7 @@ export class AnonymousOAuthServer { · 5 unmodified lines | ||
| 170 | 272 | * user to authenticate — we auto-approve and redirect back immediately |
| 171 | 273 | * with an authorization code. |
| 172 | 274 | */ |
| 173 | − authorize(params: URLSearchParams): { redirectTo: string } { | |
| 275 | + async authorize(params: URLSearchParams): Promise<{ redirectTo: string }> { | |
| 174 | 276 | const responseType = params.get("response_type"); |
| 175 | 277 | if (responseType !== "code") { |
| 176 | 278 | throw new OAuthBadRequest("response_type must be 'code'"); |
| @@ -181,7 +283,7 @@ export class AnonymousOAuthServer { · 4 unmodified lines | ||
| 181 | 283 | throw new OAuthBadRequest("client_id is required"); |
| 182 | 284 | } |
| 183 | 285 | |
| 184 | − const client = this.clients.get(clientId); | |
| 286 | + const client = await this.storage.getClient(clientId); | |
| 185 | 287 | if (!client) { |
| 186 | 288 | throw new OAuthBadRequest("Unknown client_id"); |
| 187 | 289 | } |
| @@ -203,7 +305,7 @@ export class AnonymousOAuthServer { · 15 unmodified lines | ||
| 203 | 305 | // Generate anonymous identity |
| 204 | 306 | const actorId = `anon_${crypto.randomUUID()}`; |
| 205 | 307 | |
| 206 | − // Issue authorization code | |
| 308 | + // Issue authorization code (always in-memory — short-lived, single-use) | |
| 207 | 309 | const code = crypto.randomUUID(); |
| 208 | 310 | this.codes.set(code, { |
| 209 | 311 | code, |
| @@ -331,10 +433,6 @@ export class AnonymousOAuthServer { · 121 unmodified lines | ||
| 331 | 433 | return this.issuer; |
| 332 | 434 | } |
| 333 | 435 | |
| 334 | − getClientCount(): number { | |
| 335 | − return this.clients.size; | |
| 336 | − } | |
| 337 | − | |
| 338 | 436 | getCodeCount(): number { |
| 339 | 437 | return this.codes.size; |
| 340 | 438 | } |