Unified · Split
| @@ -8,6 +8,8 @@ const appShellRewriteExclusions = [ · 7 unmodified lines | |||
| 8 | "sign-up(?:/|$)", | 8 | "sign-up(?:/|$)", |
| 9 | "sign-out(?:/|$)", | 9 | "sign-out(?:/|$)", |
| 10 | "callback(?:/|$)", | 10 | "callback(?:/|$)", |
| 11 | "install(?:/|$)", | ||
| 12 | "install\\.sh(?:/|$)", | ||
| 11 | "static-app-shell(?:/|$)", | 13 | "static-app-shell(?:/|$)", |
| 12 | ".*\\..*", | 14 | ".*\\..*", |
| 13 | ].join("|"); | 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 | export default nextConfig; | 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 | { sourceId: string; workspaceId: Id<"workspaces"> }, | 548 | { sourceId: string; workspaceId: Id<"workspaces"> }, |
| 549 | any | 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 | getApproval: FunctionReference< | 563 | getApproval: FunctionReference< |
| 552 | "query", | 564 | "query", |
| 553 | "internal", | 565 | "internal", |
| @@ -639,6 +651,12 @@ export declare const internal: { · 85 unmodified lines | |||
| 639 | { taskId: string }, | 651 | { taskId: string }, |
| 640 | any | 652 | any |
| 641 | >; | 653 | >; |
| 654 | registerAnonymousOauthClient: FunctionReference< | ||
| 655 | "mutation", | ||
| 656 | "internal", | ||
| 657 | { clientId: string; clientName?: string; redirectUris: Array<string> }, | ||
| 658 | any | ||
| 659 | >; | ||
| 642 | resolveApproval: FunctionReference< | 660 | resolveApproval: FunctionReference< |
| 643 | "mutation", | 661 | "mutation", |
| 644 | "internal", | 662 | "internal", |
| @@ -661,6 +679,17 @@ export declare const internal: { · 16 unmodified lines | |||
| 661 | }, | 679 | }, |
| 662 | any | 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 | upsertAccessPolicy: FunctionReference< | 693 | upsertAccessPolicy: FunctionReference< |
| 665 | "mutation", | 694 | "mutation", |
| 666 | "internal", | 695 | "internal", |
| @@ -1133,3 +1133,115 @@ export const listTaskEvents = internalQuery({ · 1132 unmodified lines | |||
| 1133 | return docs.map(mapTaskEvent); | 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 | .index("by_session_id", ["sessionId"]) | 313 | .index("by_session_id", ["sessionId"]) |
| 314 | .index("by_workspace_actor", ["workspaceId", "actorId"]) | 314 | .index("by_workspace_actor", ["workspaceId", "actorId"]) |
| 315 | .index("by_account", ["accountId"]), | 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 | REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}" | 5 | REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}" |
| 6 | INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}" | 6 | INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}" |
| 7 | REQUESTED_VERSION="${VERSION:-}" | 7 | REQUESTED_VERSION="${VERSION:-}" |
| 8 | BINARY_PATH="" | ||
| 8 | NO_MODIFY_PATH="0" | 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 | RED='\033[0;31m' | 13 | RED='\033[0;31m' |
| 11 | MUTED='\033[0;2m' | 14 | MUTED='\033[0;2m' |
| @@ -20,6 +23,7 @@ Usage: · 8 unmodified lines | |||
| 20 | 23 | ||
| 21 | Options: | 24 | Options: |
| 22 | -v, --version <version> Install specific release tag (with or without leading v) | 25 | -v, --version <version> Install specific release tag (with or without leading v) |
| 26 | -b, --binary <path> Install from a local prebuilt binary | ||
| 23 | --no-modify-path Do not append executor bin directory to shell rc | 27 | --no-modify-path Do not append executor bin directory to shell rc |
| 24 | -h, --help Show this help | 28 | -h, --help Show this help |
| 25 | 29 | ||
| @@ -43,6 +47,14 @@ while [[ $# -gt 0 ]]; do · 17 unmodified lines | |||
| 43 | fi | 47 | fi |
| 44 | shift 2 | 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 | --no-modify-path) | 58 | --no-modify-path) |
| 47 | NO_MODIFY_PATH="1" | 59 | NO_MODIFY_PATH="1" |
| 48 | shift | 60 | shift |
| @@ -80,30 +92,61 @@ archive_ext="tar.gz" · 31 unmodified lines | |||
| 80 | filename="${APP}-${os}-${arch}.${archive_ext}" | 92 | filename="${APP}-${os}-${arch}.${archive_ext}" |
| 81 | mkdir -p "$INSTALL_DIR" | 93 | mkdir -p "$INSTALL_DIR" |
| 82 | 94 | ||
| 83 | if [[ -z "$REQUESTED_VERSION" ]]; then | 95 | install_local_binary() { |
| 84 | url="https://github.com/${REPO}/releases/latest/download/${filename}" | 96 | local src="$1" |
| 85 | version="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')" | 97 | if [[ ! -f "$src" ]]; then |
| 86 | else | 98 | echo -e "${RED}Local binary not found: ${src}${NC}" |
| 87 | version="${REQUESTED_VERSION#v}" | 99 | exit 1 |
| 88 | url="https://github.com/${REPO}/releases/download/v${version}/${filename}" | 100 | fi |
| 89 | fi | 101 | cp "$src" "$INSTALL_DIR/$APP" |
| 102 | chmod 755 "$INSTALL_DIR/$APP" | ||
| 103 | } | ||
| 90 | 104 | ||
| 91 | tmp_dir="$(mktemp -d)" | 105 | if [[ -n "$BINARY_PATH" ]]; then |
| 92 | trap 'rm -rf "$tmp_dir"' EXIT | 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}" | 121 | tmp_dir="$(mktemp -d)" |
| 95 | curl -fsSL "$url" -o "$tmp_dir/$filename" | 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 | 145 | mv "$tmp_dir/$APP" "$INSTALL_DIR/$APP" |
| 100 | echo -e "${RED}Archive did not contain expected binary '${APP}'${NC}" | 146 | chmod 755 "$INSTALL_DIR/$APP" |
| 101 | exit 1 | 147 | fi |
| 102 | fi | 148 | fi |
| 103 | 149 | ||
| 104 | mv "$tmp_dir/$APP" "$INSTALL_DIR/$APP" | ||
| 105 | chmod 755 "$INSTALL_DIR/$APP" | ||
| 106 | |||
| 107 | if [[ "$NO_MODIFY_PATH" != "1" ]]; then | 150 | if [[ "$NO_MODIFY_PATH" != "1" ]]; then |
| 108 | shell_name="$(basename "${SHELL:-}")" | 151 | shell_name="$(basename "${SHELL:-}")" |
| 109 | case "$shell_name" in | 152 | case "$shell_name" in |
| @@ -18,6 +18,13 @@ · 17 unmodified lines | |||
| 18 | * (`anon_<uuid>`) which the gateway resolves via | 18 | * (`anon_<uuid>`) which the gateway resolves via |
| 19 | * `bootstrapAnonymousSession` — identical to the existing anonymous flow | 19 | * `bootstrapAnonymousSession` — identical to the existing anonymous flow |
| 20 | * but now behind a proper Bearer token. | 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 | import { | 30 | import { |
| @@ -25,6 +32,7 @@ import { · 1 unmodified line | |||
| 25 | createLocalJWKSet, | 32 | createLocalJWKSet, |
| 26 | exportJWK, | 33 | exportJWK, |
| 27 | generateKeyPair, | 34 | generateKeyPair, |
| 35 | importJWK, | ||
| 28 | jwtVerify, | 36 | jwtVerify, |
| 29 | type JWK, | 37 | type JWK, |
| 30 | type CryptoKey as JoseCryptoKey, | 38 | type CryptoKey as JoseCryptoKey, |
| @@ -58,6 +66,73 @@ export interface AnonOAuthConfig { · 27 unmodified lines | |||
| 58 | accessTokenTtlSeconds?: number; | 66 | accessTokenTtlSeconds?: number; |
| 59 | /** Authorization code TTL in seconds. Default 120 s. */ | 67 | /** Authorization code TTL in seconds. Default 120 s. */ |
| 60 | codeExpirySeconds?: number; | 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 | private readonly issuer: string; | 143 | private readonly issuer: string; |
| 69 | private readonly accessTokenTtlSeconds: number; | 144 | private readonly accessTokenTtlSeconds: number; |
| 70 | private readonly codeExpirySeconds: number; | 145 | private readonly codeExpirySeconds: number; |
| 146 | private readonly storage: OAuthStorage; | ||
| 71 | 147 | ||
| 72 | private privateKey!: JoseCryptoKey; | 148 | private privateKey!: JoseCryptoKey; |
| 73 | private publicJwk!: JWK; | 149 | private publicJwk!: JWK; |
| 74 | private keyId!: string; | 150 | private keyId!: string; |
| 75 | 151 | ||
| 76 | /** In-memory client registrations (ephemeral — survives process lifetime). */ | ||
| 77 | private readonly clients = new Map<string, AnonOAuthClientRegistration>(); | ||
| 78 | /** In-memory authorization codes (short-lived, cleaned up on use). */ | 152 | /** In-memory authorization codes (short-lived, cleaned up on use). */ |
| 79 | private readonly codes = new Map<string, AuthorizationCode>(); | 153 | private readonly codes = new Map<string, AuthorizationCode>(); |
| 80 | 154 | ||
| @@ -82,25 +156,53 @@ export class AnonymousOAuthServer { · 1 unmodified line | |||
| 82 | this.issuer = config.issuer.replace(/\/+$/, ""); | 156 | this.issuer = config.issuer.replace(/\/+$/, ""); |
| 83 | this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60; | 157 | this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60; |
| 84 | this.codeExpirySeconds = config.codeExpirySeconds ?? 120; | 158 | this.codeExpirySeconds = config.codeExpirySeconds ?? 120; |
| 159 | this.storage = config.storage ?? new InMemoryOAuthStorage(); | ||
| 85 | } | 160 | } |
| 86 | 161 | ||
| 87 | // ------------------------------------------------------------------------- | 162 | // ------------------------------------------------------------------------- |
| 88 | // Initialization | 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 | async init(): Promise<void> { | 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 | const { privateKey, publicKey } = await generateKeyPair("RS256"); | 185 | const { privateKey, publicKey } = await generateKeyPair("RS256"); |
| 94 | this.privateKey = privateKey; | 186 | this.privateKey = privateKey; |
| 95 | this.keyId = `anon-${crypto.randomUUID().slice(0, 8)}`; | 187 | this.keyId = `anon_key_${crypto.randomUUID().slice(0, 8)}`; |
| 96 | const jwk = await exportJWK(publicKey); | 188 | |
| 97 | this.publicJwk = { ...jwk, kid: this.keyId, use: "sig", alg: "RS256" }; | 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 | async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> { | 203 | async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> { |
| 102 | this.privateKey = privateKey; | 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 | this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" }; | 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 | // RFC 7591 — Dynamic Client Registration | 237 | // RFC 7591 — Dynamic Client Registration |
| 136 | // ------------------------------------------------------------------------- | 238 | // ------------------------------------------------------------------------- |
| 137 | 239 | ||
| 138 | registerClient(body: { | 240 | async registerClient(body: { |
| 139 | redirect_uris?: string[]; | 241 | redirect_uris?: string[]; |
| 140 | client_name?: string; | 242 | client_name?: string; |
| 141 | }): AnonOAuthClientRegistration { | 243 | }): Promise<AnonOAuthClientRegistration> { |
| 142 | const redirectUris = body.redirect_uris; | 244 | const redirectUris = body.redirect_uris; |
| 143 | if (!Array.isArray(redirectUris) || redirectUris.length === 0) { | 245 | if (!Array.isArray(redirectUris) || redirectUris.length === 0) { |
| 144 | throw new OAuthBadRequest("redirect_uris is required and must be non-empty"); | 246 | throw new OAuthBadRequest("redirect_uris is required and must be non-empty"); |
| @@ -157,8 +259,8 @@ export class AnonymousOAuthServer { · 12 unmodified lines | |||
| 157 | redirect_uris: redirectUris, | 259 | redirect_uris: redirectUris, |
| 158 | created_at: Date.now(), | 260 | created_at: Date.now(), |
| 159 | }; | 261 | }; |
| 160 | this.clients.set(clientId, registration); | 262 | |
| 161 | return registration; | 263 | return await this.storage.registerClient(registration); |
| 162 | } | 264 | } |
| 163 | 265 | ||
| 164 | // ------------------------------------------------------------------------- | 266 | // ------------------------------------------------------------------------- |
| @@ -170,7 +272,7 @@ export class AnonymousOAuthServer { · 5 unmodified lines | |||
| 170 | * user to authenticate — we auto-approve and redirect back immediately | 272 | * user to authenticate — we auto-approve and redirect back immediately |
| 171 | * with an authorization code. | 273 | * with an authorization code. |
| 172 | */ | 274 | */ |
| 173 | authorize(params: URLSearchParams): { redirectTo: string } { | 275 | async authorize(params: URLSearchParams): Promise<{ redirectTo: string }> { |
| 174 | const responseType = params.get("response_type"); | 276 | const responseType = params.get("response_type"); |
| 175 | if (responseType !== "code") { | 277 | if (responseType !== "code") { |
| 176 | throw new OAuthBadRequest("response_type must be 'code'"); | 278 | throw new OAuthBadRequest("response_type must be 'code'"); |
| @@ -181,7 +283,7 @@ export class AnonymousOAuthServer { · 4 unmodified lines | |||
| 181 | throw new OAuthBadRequest("client_id is required"); | 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 | if (!client) { | 287 | if (!client) { |
| 186 | throw new OAuthBadRequest("Unknown client_id"); | 288 | throw new OAuthBadRequest("Unknown client_id"); |
| 187 | } | 289 | } |
| @@ -203,7 +305,7 @@ export class AnonymousOAuthServer { · 15 unmodified lines | |||
| 203 | // Generate anonymous identity | 305 | // Generate anonymous identity |
| 204 | const actorId = `anon_${crypto.randomUUID()}`; | 306 | const actorId = `anon_${crypto.randomUUID()}`; |
| 205 | 307 | ||
| 206 | // Issue authorization code | 308 | // Issue authorization code (always in-memory — short-lived, single-use) |
| 207 | const code = crypto.randomUUID(); | 309 | const code = crypto.randomUUID(); |
| 208 | this.codes.set(code, { | 310 | this.codes.set(code, { |
| 209 | code, | 311 | code, |
| @@ -331,10 +433,6 @@ export class AnonymousOAuthServer { · 121 unmodified lines | |||
| 331 | return this.issuer; | 433 | return this.issuer; |
| 332 | } | 434 | } |
| 333 | 435 | ||
| 334 | getClientCount(): number { | ||
| 335 | return this.clients.size; | ||
| 336 | } | ||
| 337 | |||
| 338 | getCodeCount(): number { | 436 | getCodeCount(): number { |
| 339 | return this.codes.size; | 437 | return this.codes.size; |
| 340 | } | 438 | } |