From 23bfab4a3a9aa37b8251be53b772a458a15c88ea Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:10:19 -0700 Subject: [PATCH] Fix migrated OAuth authorization URLs --- apps/cloud/scripts/migrate-v1-v2.ts | 191 ++++++++++++++++-- apps/local/src/db/v1-v2-migration.test.ts | 76 +++++++ apps/local/src/db/v1-v2-migration.ts | 17 +- .../core/sdk/src/migration-oauth-metadata.ts | 112 ++++++++++ packages/core/sdk/src/migration-spec.test.ts | 6 + packages/core/sdk/src/migration-spec.ts | 29 ++- 6 files changed, 411 insertions(+), 20 deletions(-) create mode 100644 packages/core/sdk/src/migration-oauth-metadata.ts diff --git a/apps/cloud/scripts/migrate-v1-v2.ts b/apps/cloud/scripts/migrate-v1-v2.ts index 21f11b03e..14be49105 100644 --- a/apps/cloud/scripts/migrate-v1-v2.ts +++ b/apps/cloud/scripts/migrate-v1-v2.ts @@ -12,6 +12,14 @@ * op run --env-file=apps/cloud/.env.production -- \ * bun apps/cloud/scripts/migrate-v1-v2.ts --apply --confirm-v1-v2-cutover * + * Repair already-migrated OAuth authorization URLs from archived v1 metadata: + * + * op run --env-file=apps/cloud/.env.production -- \ + * bun apps/cloud/scripts/migrate-v1-v2.ts --repair-oauth-authorization-urls + * + * op run --env-file=apps/cloud/.env.production -- \ + * bun apps/cloud/scripts/migrate-v1-v2.ts --repair-oauth-authorization-urls --apply --confirm-oauth-authorization-url-repair + * * Apply copies WorkOS Vault values into deterministic v2 item ids first, then * runs the structural Postgres transaction: archive v1 executor tables as * `v1_*`, create the v2 executor tables, and upsert the planned v2 rows. @@ -33,14 +41,18 @@ import { migrateOpenApiSourceConfig, migrateV1PluginStorageRuntimeRow, migrateV1ToolAnnotations, + migrationOAuthAuthorizationUrlFor as authorizationUrlFor, + migrationOAuthClientPlanKey as oauthClientPlanKey, migrationSourceKey, parseScope, planMigration, + resolveMigrationOAuthAuthorizationUrls, vaultV1LegacyObjectName, vaultV1ObjectName, vaultV2ObjectName, type MigratedSourceConfig, type MigrationInput, + type MigrationOAuthMetadataFetch, type MigrationOwner, type MigrationPlan, type OwnerKeys, @@ -132,11 +144,29 @@ export interface CloudMigrationResult { }; } +export interface CloudOAuthAuthorizationUrlRepairRow { + readonly tenant: string; + readonly owner: MigrationOwner; + readonly subject: string; + readonly slug: string; + readonly currentAuthorizationUrl: string; + readonly repairedAuthorizationUrl: string; + readonly metadataUrl: string; +} + +export interface CloudOAuthAuthorizationUrlRepairResult { + readonly applied: boolean; + readonly checked: number; + readonly changed: readonly CloudOAuthAuthorizationUrlRepairRow[]; +} + export interface CloudMigrationOptions { readonly sql: Pg; readonly apply: boolean; readonly confirmApply?: boolean; readonly objectPrefix?: string; + readonly oauthMetadataFetch?: MigrationOAuthMetadataFetch; + readonly oauthMetadataTimeoutMs?: number; readonly workosCredentials?: { readonly apiKey: string; readonly clientId: string; @@ -146,8 +176,22 @@ export interface CloudMigrationOptions { readonly now?: Date; } +export interface CloudOAuthAuthorizationUrlRepairOptions { + readonly sql: Pg; + readonly apply: boolean; + readonly confirmApply?: boolean; + readonly oauthMetadataFetch?: MigrationOAuthMetadataFetch; + readonly oauthMetadataTimeoutMs?: number; + readonly log?: (message: string) => void; + readonly now?: Date; +} + const APPLY = process.argv.includes("--apply"); const CONFIRM_APPLY = process.argv.includes("--confirm-v1-v2-cutover"); +const REPAIR_OAUTH_AUTHORIZATION_URLS = process.argv.includes("--repair-oauth-authorization-urls"); +const CONFIRM_REPAIR_OAUTH_AUTHORIZATION_URLS = process.argv.includes( + "--confirm-oauth-authorization-url-repair", +); const WORKOS_VAULT_PROVIDER = "workos-vault"; const WORKOS_VAULT_METADATA_PLUGIN_ID = "workos-vault"; const WORKOS_VAULT_METADATA_COLLECTION = "metadata"; @@ -509,9 +553,6 @@ const readV1Snapshot = async ( const ownerSubject = (owner: MigrationOwner, subject: string): string => owner === "org" ? "" : subject; -const oauthClientPlanKey = (client: MigrationPlan["oauthClients"][number]): string => - `${client.ownerKeys.tenant}\0${client.ownerKeys.owner}\0${client.ownerKeys.subject}\0${client.slug}`; - const secretRefKey = (scopeId: string, secretId: string): string => `${scopeId}\0${secretId}`; const secretNameByRef = (input: MigrationInput): ReadonlyMap => @@ -681,6 +722,13 @@ const clientIdFor = ( values: ReadonlyMap, ): string => client.clientId || values.get(oauthClientPlanKey(client)) || ""; +const oauthClientStorageKey = (input: { + readonly tenant: string; + readonly owner: string; + readonly subject: string; + readonly slug: string; +}): string => `${input.tenant}\0${input.owner}\0${input.subject}\0${input.slug}`; + const jsonValue = (sql: Pg, value: unknown): unknown => (value == null ? null : sql.json(value)); const requiredJsonValue = (sql: Pg, value: unknown): unknown => sql.json(value ?? {}); @@ -902,6 +950,7 @@ const insertPlan = async ( snapshot: CloudV1Snapshot, plan: MigrationPlan, secretCopy: WorkosSecretCopyResult, + oauthAuthorizationUrls: ReadonlyMap, now: Date, ): Promise => { const connectionTargets = plan.connections.map((connection) => ({ @@ -933,7 +982,7 @@ const insertPlan = async ( for (const clientRow of plan.oauthClients) { await sql` insert into oauth_client (slug, authorization_url, token_url, grant, client_id, client_secret_item_id, resource, created_at, row_id, tenant, owner, subject) - values (${clientRow.slug}, ${clientRow.authorizationUrl}, ${clientRow.tokenUrl}, ${clientRow.grant}, ${clientIdFor(clientRow, secretCopy.oauthClientIdValues)}, ${clientRow.clientSecretItemId}, ${clientRow.resource}, ${now}, ${createId()}, ${clientRow.ownerKeys.tenant}, ${clientRow.ownerKeys.owner}, ${ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject)}) + values (${clientRow.slug}, ${authorizationUrlFor(clientRow, oauthAuthorizationUrls)}, ${clientRow.tokenUrl}, ${clientRow.grant}, ${clientIdFor(clientRow, secretCopy.oauthClientIdValues)}, ${clientRow.clientSecretItemId}, ${clientRow.resource}, ${now}, ${createId()}, ${clientRow.ownerKeys.tenant}, ${clientRow.ownerKeys.owner}, ${ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject)}) on conflict (tenant, owner, subject, slug) do update set authorization_url = excluded.authorization_url, token_url = excluded.token_url, @@ -1088,12 +1137,13 @@ const applyStructuralMigration = async ( snapshot: CloudV1Snapshot, plan: MigrationPlan, secretCopy: WorkosSecretCopyResult, + oauthAuthorizationUrls: ReadonlyMap, now: Date, ): Promise => { await sql.begin(async (tx) => { await archiveV1Tables(tx as Pg); await createV2Schema(tx as Pg); - await insertPlan(tx as Pg, snapshot, plan, secretCopy, now); + await insertPlan(tx as Pg, snapshot, plan, secretCopy, oauthAuthorizationUrls, now); }); }; @@ -1121,6 +1171,94 @@ const printReport = ( for (const warning of r.warnings) log(` - ${warning}`); }; +const readCurrentOAuthAuthorizationUrls = async (sql: Pg): Promise> => { + const rows = await sql< + { + readonly tenant: string; + readonly owner: string; + readonly subject: string; + readonly slug: string; + readonly authorization_url: string; + }[] + >` + select tenant, owner, subject, slug, authorization_url + from oauth_client + `; + return new Map(rows.map((row) => [oauthClientStorageKey(row), row.authorization_url])); +}; + +export const repairCloudOAuthAuthorizationUrls = async ( + options: CloudOAuthAuthorizationUrlRepairOptions, +): Promise => { + const log = options.log ?? console.log; + const now = options.now ?? new Date(); + const snapshot = await readV1Snapshot(options.sql, now, false, log); + log("plan: building OAuth authorization URL repair plan"); + const plan = planMigration(snapshot.input); + const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { + fetch: options.oauthMetadataFetch ?? fetch, + timeoutMs: options.oauthMetadataTimeoutMs, + }); + const currentAuthorizationUrls = await readCurrentOAuthAuthorizationUrls(options.sql); + + const changed: CloudOAuthAuthorizationUrlRepairRow[] = []; + let checked = 0; + for (const clientRow of plan.oauthClients) { + const metadataUrl = clientRow.authorizationServerMetadataUrl?.trim(); + if (!metadataUrl) continue; + checked++; + + const currentAuthorizationUrl = currentAuthorizationUrls.get(oauthClientPlanKey(clientRow)); + if (currentAuthorizationUrl == null) { + log( + ` - missing current oauth_client row for ${clientRow.ownerKeys.tenant}/${clientRow.slug}`, + ); + continue; + } + + const repairedAuthorizationUrl = authorizationUrlFor(clientRow, oauthAuthorizationUrls); + if (currentAuthorizationUrl === repairedAuthorizationUrl) continue; + changed.push({ + tenant: clientRow.ownerKeys.tenant, + owner: clientRow.ownerKeys.owner, + subject: ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject), + slug: clientRow.slug, + currentAuthorizationUrl, + repairedAuthorizationUrl, + metadataUrl, + }); + } + + log(`oauth repair: ${checked} metadata-backed client(s) checked`); + log(`oauth repair: ${changed.length} client(s) need authorization_url update`); + for (const row of changed) { + log( + ` - ${row.tenant}/${row.owner}/${row.subject || ""}/${row.slug}: ${row.currentAuthorizationUrl} -> ${row.repairedAuthorizationUrl}`, + ); + } + + if (!options.apply) return { applied: false, checked, changed }; + if (!options.confirmApply) { + throw new Error("Refusing OAuth authorization URL repair without confirmation flag."); + } + + await options.sql.begin(async (tx) => { + for (const row of changed) { + await (tx as Pg)` + update oauth_client + set authorization_url = ${row.repairedAuthorizationUrl} + where tenant = ${row.tenant} + and owner = ${row.owner} + and subject = ${row.subject} + and slug = ${row.slug} + `; + } + }); + log("oauth repair: complete"); + + return { applied: true, checked, changed }; +}; + export const runCloudV1V2Migration = async ( options: CloudMigrationOptions, ): Promise => { @@ -1164,7 +1302,22 @@ export const runCloudV1V2Migration = async ( ); } - await applyStructuralMigration(options.sql, snapshot, plan, secretCopy, now); + const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { + fetch: options.oauthMetadataFetch ?? fetch, + timeoutMs: options.oauthMetadataTimeoutMs, + }); + if (oauthAuthorizationUrls.size > 0) { + log(`oauth metadata: resolved ${oauthAuthorizationUrls.size} authorization URL(s)`); + } + + await applyStructuralMigration( + options.sql, + snapshot, + plan, + secretCopy, + oauthAuthorizationUrls, + now, + ); log("apply: complete"); return { applied: true, @@ -1189,15 +1342,23 @@ const main = async (): Promise => { databaseSsl === "disable" || databaseSsl === "false" || databaseSsl === "0" ? false : "require"; const sql = postgres(databaseUrl, { max: 1, prepare: false, ssl }) as Pg; try { - await runCloudV1V2Migration({ - sql, - apply: APPLY, - confirmApply: CONFIRM_APPLY, - workosCredentials: { - apiKey: process.env.WORKOS_API_KEY ?? "", - clientId: process.env.WORKOS_CLIENT_ID ?? "", - }, - }); + if (REPAIR_OAUTH_AUTHORIZATION_URLS) { + await repairCloudOAuthAuthorizationUrls({ + sql, + apply: APPLY, + confirmApply: CONFIRM_REPAIR_OAUTH_AUTHORIZATION_URLS, + }); + } else { + await runCloudV1V2Migration({ + sql, + apply: APPLY, + confirmApply: CONFIRM_APPLY, + workosCredentials: { + apiKey: process.env.WORKOS_API_KEY ?? "", + clientId: process.env.WORKOS_CLIENT_ID ?? "", + }, + }); + } } finally { await sql.end(); } diff --git a/apps/local/src/db/v1-v2-migration.test.ts b/apps/local/src/db/v1-v2-migration.test.ts index 87013e717..7ac701de4 100644 --- a/apps/local/src/db/v1-v2-migration.test.ts +++ b/apps/local/src/db/v1-v2-migration.test.ts @@ -39,6 +39,7 @@ const seedV1Db = async ( readonly includeMcpToolBinding?: boolean; readonly jsonBlobs?: boolean; readonly oauthConnectionProvider?: string; + readonly oauthProviderStateOverrides?: Record; } = {}, ) => { const client = await openLocalLibsql(dbPath); @@ -541,6 +542,7 @@ const seedV1Db = async ( tokenEndpoint: "https://resolve.dealcloud.com/oauth/token", resource: "https://api.dealcloud.com", scopes: ["data"], + ...(options.oauthProviderStateOverrides ?? {}), }), ], ); @@ -886,4 +888,78 @@ describe("local v1 -> v2 migration", () => { expect(auth[clientSecretItemId]).toBe("dealcloud-secret"); expect(auth["dealcloud-client-id"]).toBeUndefined(); }); + + it("resolves v1 OAuth authorization-server metadata URLs before writing oauth_client rows", async () => { + const scopeId = "executor-workspace-abcd1234"; + const tenantId = "executor-workspace-abcd1234"; + const metadataUrl = + "https://mcp.pscale.dev/.well-known/oauth-authorization-server/mcp/planetscale"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + await seedV1Db(dbPath, scopeId, { + includeSecretBackedOauth: true, + oauthConnectionProvider: "oauth2", + oauthProviderStateOverrides: { + kind: "dynamic-dcr", + authorizationServerUrl: "https://mcp.pscale.dev/mcp/planetscale", + authorizationServerMetadataUrl: metadataUrl, + resource: "https://mcp.pscale.dev/mcp/planetscale", + }, + }); + + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify({ + [scopeId]: { + "dealcloud-access": "old-access-token", + "dealcloud-client-id": "dealcloud-client", + "dealcloud-client-secret": "dealcloud-secret", + }, + }), + ); + + const seenMetadataUrls: string[] = []; + const oauthMetadataFetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + seenMetadataUrls.push(String(input)); + return new Response( + JSON.stringify({ + issuer: "https://api.planetscale.com", + authorization_endpoint: "https://app.planetscale.com/oauth/authorize", + token_endpoint: "https://auth.planetscale.com/oauth/token", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId, + oauthMetadataFetch, + }); + + expect(result.migrated).toBe(true); + expect(seenMetadataUrls).toEqual([metadataUrl]); + + const client = await openLocalLibsql(dbPath); + const oauthClients = await client.execute( + "SELECT slug, grant, authorization_url, resource FROM oauth_client", + ); + expect(oauthClients.rows).toEqual([ + { + slug: "dealcloud", + grant: "authorization_code", + authorization_url: "https://app.planetscale.com/oauth/authorize", + resource: "https://mcp.pscale.dev/mcp/planetscale", + }, + ]); + client.close(); + }); }); diff --git a/apps/local/src/db/v1-v2-migration.ts b/apps/local/src/db/v1-v2-migration.ts index 1def520ad..24ddd14d5 100644 --- a/apps/local/src/db/v1-v2-migration.ts +++ b/apps/local/src/db/v1-v2-migration.ts @@ -17,11 +17,15 @@ import { migrateOpenApiSourceConfig, migrateV1PluginStorageRuntimeRow, migrateV1ToolAnnotations, + migrationOAuthAuthorizationUrlFor as authorizationUrlFor, + migrationOAuthClientPlanKey as oauthClientPlanKey, migrationSourceKey, parseScope, planMigration, + resolveMigrationOAuthAuthorizationUrls, type MigratedSourceConfig, type MigrationInput, + type MigrationOAuthMetadataFetch, type MigrationOwner, type MigrationPlan, type OwnerKeys, @@ -92,6 +96,8 @@ export interface LocalV1V2MigrationOptions { readonly tables: FumaTables; readonly namespace: string; readonly tenantId: string; + readonly oauthMetadataFetch?: MigrationOAuthMetadataFetch; + readonly oauthMetadataTimeoutMs?: number; } const FILE_PROVIDER = "file"; @@ -469,9 +475,6 @@ const timestamp = (): number => Date.now(); const ownerSubject = (owner: MigrationOwner, subject: string): string => owner === "org" ? "" : subject; -const oauthClientPlanKey = (client: MigrationPlan["oauthClients"][number]): string => - `${client.ownerKeys.tenant}\0${client.ownerKeys.owner}\0${client.ownerKeys.subject}\0${client.slug}`; - const clientIdFor = ( client: MigrationPlan["oauthClients"][number], values: ReadonlyMap, @@ -507,6 +510,7 @@ const insertPlan = async ( plan: MigrationPlan, idOverrides: ReadonlyMap, oauthClientIdValues: ReadonlyMap, + oauthAuthorizationUrls: ReadonlyMap, tenantId: string, ): Promise => { const now = timestamp(); @@ -552,7 +556,7 @@ const insertPlan = async ( "INSERT INTO oauth_client (slug, authorization_url, token_url, grant, client_id, client_secret_item_id, resource, created_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ clientRow.slug, - clientRow.authorizationUrl, + authorizationUrlFor(clientRow, oauthAuthorizationUrls), clientRow.tokenUrl, clientRow.grant, clientIdFor(clientRow, oauthClientIdValues), @@ -743,6 +747,10 @@ export const migrateLocalV1ToV2IfNeeded = async ( const snapshot = await readV1Snapshot(reader, options.tenantId); const plan = planMigration(snapshot.input); const secretValues = await collectSecretValues(plan); + const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { + fetch: options.oauthMetadataFetch ?? fetch, + timeoutMs: options.oauthMetadataTimeoutMs, + }); const backupPath = backupPathFor(options.sqlitePath); reader.close(); @@ -761,6 +769,7 @@ export const migrateLocalV1ToV2IfNeeded = async ( plan, secretValues.idOverrides, secretValues.oauthClientIdValues, + oauthAuthorizationUrls, options.tenantId, ); await target.close(); diff --git a/packages/core/sdk/src/migration-oauth-metadata.ts b/packages/core/sdk/src/migration-oauth-metadata.ts new file mode 100644 index 000000000..3a112dccd --- /dev/null +++ b/packages/core/sdk/src/migration-oauth-metadata.ts @@ -0,0 +1,112 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: v1 migration resolves archived OAuth metadata before committing migrated rows */ + +import { Schema } from "effect"; + +import type { MigrationPlan } from "./migration-spec"; + +const OAuthAuthorizationServerMetadata = Schema.Struct({ + authorization_endpoint: Schema.String, +}); +const decodeOAuthAuthorizationServerMetadata = Schema.decodeUnknownSync( + OAuthAuthorizationServerMetadata, +); +const DEFAULT_OAUTH_METADATA_TIMEOUT_MS = 20_000; + +export type MigrationOAuthMetadataFetch = ( + input: string, + init: { + readonly headers: Readonly>; + readonly signal: AbortSignal; + }, +) => Promise<{ + readonly ok: boolean; + readonly status: number; + readonly json: () => Promise; +}>; + +export interface ResolveMigrationOAuthAuthorizationUrlsOptions { + readonly fetch?: MigrationOAuthMetadataFetch; + readonly timeoutMs?: number; +} + +export const migrationOAuthClientPlanKey = ( + client: MigrationPlan["oauthClients"][number], +): string => + `${client.ownerKeys.tenant}\0${client.ownerKeys.owner}\0${client.ownerKeys.subject}\0${client.slug}`; + +const validateMigrationOAuthUrl = (value: string, label: string): string => { + const trimmed = value.trim(); + const url = new URL(trimmed); + const loopbackHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]"); + if (url.protocol !== "https:" && !loopbackHttp) { + throw new Error(`${label} must use https: or loopback http: ${trimmed}`); + } + return trimmed; +}; + +const fetchOAuthAuthorizationEndpoint = async ( + metadataUrl: string, + fetchImpl: MigrationOAuthMetadataFetch, + timeoutMs: number, +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(metadataUrl, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `OAuth authorization-server metadata ${metadataUrl} returned HTTP ${response.status}`, + ); + } + const metadata = decodeOAuthAuthorizationServerMetadata(await response.json()); + return validateMigrationOAuthUrl(metadata.authorization_endpoint, "authorization_endpoint"); + } finally { + clearTimeout(timeout); + } +}; + +export const resolveMigrationOAuthAuthorizationUrls = async ( + plan: MigrationPlan, + options: ResolveMigrationOAuthAuthorizationUrlsOptions = {}, +): Promise> => { + const clientsWithMetadata = plan.oauthClients.filter((client) => + client.authorizationServerMetadataUrl?.trim(), + ); + if (clientsWithMetadata.length === 0) return new Map(); + + const fetchImpl = options.fetch; + if (!fetchImpl) { + throw new Error("OAuth metadata resolution requires an injected fetch implementation."); + } + const timeoutMs = options.timeoutMs ?? DEFAULT_OAUTH_METADATA_TIMEOUT_MS; + const endpointByMetadataUrl = new Map>(); + const resolved = new Map(); + + for (const client of clientsWithMetadata) { + const metadataUrl = validateMigrationOAuthUrl( + client.authorizationServerMetadataUrl ?? "", + "authorizationServerMetadataUrl", + ); + let endpoint = endpointByMetadataUrl.get(metadataUrl); + if (!endpoint) { + endpoint = fetchOAuthAuthorizationEndpoint(metadataUrl, fetchImpl, timeoutMs); + endpointByMetadataUrl.set(metadataUrl, endpoint); + } + resolved.set(migrationOAuthClientPlanKey(client), await endpoint); + } + + return resolved; +}; + +export const migrationOAuthAuthorizationUrlFor = ( + client: MigrationPlan["oauthClients"][number], + resolvedUrls: ReadonlyMap, +): string => resolvedUrls.get(migrationOAuthClientPlanKey(client)) ?? client.authorizationUrl; diff --git a/packages/core/sdk/src/migration-spec.test.ts b/packages/core/sdk/src/migration-spec.test.ts index 2243e62f2..021bed98c 100644 --- a/packages/core/sdk/src/migration-spec.test.ts +++ b/packages/core/sdk/src/migration-spec.test.ts @@ -851,6 +851,8 @@ describe("planMigration (the weave)", () => { clientSecretSecretId: "linear-client-secret", tokenEndpoint: "https://mcp.linear.app/token", authorizationServerUrl: "https://mcp.linear.app/authorize", + authorizationServerMetadataUrl: + "https://mcp.linear.app/.well-known/oauth-authorization-server", resource: "https://mcp.linear.app", scopes: ["read", "write"], }, @@ -949,6 +951,10 @@ describe("planMigration (the weave)", () => { // OAuth client: one, with its secret re-keyed. expect(plan.oauthClients).toHaveLength(1); expect(plan.oauthClients[0]?.clientId).toBe("cid-linear"); + expect(plan.oauthClients[0]?.authorizationUrl).toBe("https://mcp.linear.app/authorize"); + expect(plan.oauthClients[0]?.authorizationServerMetadataUrl).toBe( + "https://mcp.linear.app/.well-known/oauth-authorization-server", + ); expect(plan.oauthClients[0]?.resource).toBe("https://mcp.linear.app"); expect(plan.oauthClients[0]?.clientSecretItemId).toBe( migratedItemId("user-org:user_U:org_X", "linear-client-secret"), diff --git a/packages/core/sdk/src/migration-spec.ts b/packages/core/sdk/src/migration-spec.ts index c6f04a65a..6c7a4a110 100644 --- a/packages/core/sdk/src/migration-spec.ts +++ b/packages/core/sdk/src/migration-spec.ts @@ -11,6 +11,14 @@ import { createHash } from "node:crypto"; +export { + migrationOAuthAuthorizationUrlFor, + migrationOAuthClientPlanKey, + resolveMigrationOAuthAuthorizationUrls, + type MigrationOAuthMetadataFetch, + type ResolveMigrationOAuthAuthorizationUrlsOptions, +} from "./migration-oauth-metadata"; + export type MigrationOwner = "org" | "user"; /** v2 owner partition for a migrated row. `subject` is "" (ORG_SUBJECT) for org. */ @@ -709,6 +717,7 @@ export interface PlannedOAuthClientInput { readonly clientIdSecretRef?: SecretReadRef | null; readonly tokenUrl: string; readonly authorizationUrl: string; + readonly authorizationServerMetadataUrl?: string | null; readonly grant: MigrationGrant; readonly resource: string | null; readonly clientSecretRef: string | null; @@ -1025,7 +1034,12 @@ export interface V1ProviderState { readonly clientSecretSecretId?: string; readonly clientSecretSecretScopeId?: string | null; readonly tokenEndpoint?: string; + readonly authorizationEndpoint?: string; readonly authorizationServerUrl?: string; + readonly authorizationServerMetadataUrl?: string; + readonly authorizationServerMetadata?: { + readonly authorization_endpoint?: string; + } | null; readonly issuerUrl?: string; readonly resource?: string | null; readonly scopes?: readonly string[]; @@ -1196,6 +1210,18 @@ const scopesFromProviderState = (ps: V1ProviderState | null): readonly string[] return []; }; +const nonEmptyString = (value: string | null | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +}; + +const authorizationUrlFromProviderState = (ps: V1ProviderState | null): string => + nonEmptyString(ps?.authorizationEndpoint) ?? + nonEmptyString(ps?.authorizationServerMetadata?.authorization_endpoint) ?? + nonEmptyString(ps?.authorizationServerUrl) ?? + nonEmptyString(ps?.issuerUrl) ?? + ""; + export const planMigration = (input: MigrationInput): MigrationPlan => { const warnings: string[] = []; const secretOps: SecretOp[] = []; @@ -1359,7 +1385,8 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { clientId: ps?.clientId ?? "", clientIdSecretRef, tokenUrl: ps?.tokenEndpoint ?? "", - authorizationUrl: ps?.authorizationServerUrl ?? ps?.issuerUrl ?? "", + authorizationUrl: authorizationUrlFromProviderState(ps), + authorizationServerMetadataUrl: nonEmptyString(ps?.authorizationServerMetadataUrl), grant, resource: ps?.resource ?? null, clientSecretRef: ps?.clientSecretSecretId ?? null,