From aa9b9108257f483e0a0463bced6ff2ee259f9440 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:35:31 +0000 Subject: [PATCH 1/4] Add Vercel OAuth conformance coverage --- README.md | 1 + docs/oauth-conformance.md | 61 +++++ .../oauth-client-conformance.ts | 195 ++++++++++++++ .../oauth-conformance/oauth-client-fixture.ts | 254 ++++++++++++++++++ .../oauth-conformance/vercel-connect.test.ts | 14 + 5 files changed, 525 insertions(+) create mode 100644 docs/oauth-conformance.md create mode 100644 src/app/oauth-conformance/oauth-client-conformance.ts create mode 100644 src/app/oauth-conformance/oauth-client-fixture.ts create mode 100644 src/app/oauth-conformance/vercel-connect.test.ts diff --git a/README.md b/README.md index 4dd621b..9ca210a 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,7 @@ We welcome contributions! Please see our contributing guidelines: - Add TypeScript types for new functions and components - Update documentation for any API changes - Ensure all tests pass before submitting +- Run the required [OAuth conformance suite](docs/oauth-conformance.md) when changing discovery, registration, authorization, token exchange, refresh, or scope enforcement ## 📄 License diff --git a/docs/oauth-conformance.md b/docs/oauth-conformance.md new file mode 100644 index 0000000..e125be0 --- /dev/null +++ b/docs/oauth-conformance.md @@ -0,0 +1,61 @@ +# OAuth conformance + +The required CI suite includes a Vercel Connect fixture for Kernel's hosted OAuth server. It models the Custom OAuth contract documented by Vercel rather than a separate Kernel-specific protocol. + +## Vercel Connect contract + +Vercel documents Custom OAuth connectors as follows: + +- the connector discovers authorization, token, registration, PKCE, scope, and grant metadata from the provider's server URL; +- Vercel Connect owns client registration, PKCE, state validation, the callback handshake, refresh-token storage, and refresh; +- user authorization uses the authorization-code flow; +- the connector configuration records its exact redirect URI, token-endpoint authentication method, PKCE requirement, challenge method, user scopes, and refresh-token support. + +Sources: + +- [Vercel Connect connectors](https://vercel.com/docs/connect/concepts/connectors) +- [Vercel Connect authentication](https://vercel.com/docs/connect/concepts/authentication) +- [Create a connector API](https://vercel.com/docs/rest-api/connect/create-a-connector) +- [`connectAuthProvider` implementation](https://github.com/vercel/vercel/blob/17d9ebaf8e9b335d550dea1a243743a74edc772e/packages/connect/src/mcp/connect-auth-provider.ts) + +The fixture uses a public client (`token_endpoint_auth_method=none`), authorization code plus refresh grants, `openid`, and S256 PKCE. Kernel preserves `state` exactly through its authorization redirect; Vercel Connect owns mismatch detection when handling its callback. + +## Covered behavior + +`src/app/oauth-conformance/vercel-connect.test.ts` verifies: + +- OAuth server discovery advertises registration, authorization-code exchange, refresh, and S256 PKCE; +- dynamic registration creates a public client without a secret; +- organization-wide and project-scoped authorization both complete; +- refresh rotation preserves the original organization or project boundary; +- token request fields cannot change the stored organization or scope; +- wrong PKCE verifiers fail before the provider exchange; +- redirect mismatches and invalid public-client authentication fail without persisting token context; +- OAuth state, redirect URI, and PKCE parameters survive the Kernel-to-Clerk redirect unchanged. + +CI runs the suite through the repository's required `bun test` check. + +## Run locally + +```bash +bun test src/app/oauth-conformance/vercel-connect.test.ts +``` + +The checked-in redirect uses the reserved `.test` domain. To replay the same suite with the redirect URI returned by a staging Vercel connector: + +```bash +VERCEL_CONNECT_REDIRECT_URI='https://' \ + bun test src/app/oauth-conformance/vercel-connect.test.ts +``` + +## Confirm a live Vercel connector + +Before treating Vercel Connect as a supported consumer: + +1. Create a staging Custom OAuth connector using Kernel's staging MCP server URL and Vercel Assisted Setup. +2. Record the connector response's `redirectUri`, `tokenEndpointAuthMethod`, `pkceRequired`, `codeChallengeMethod`, enabled user scopes, and refresh setting. +3. Compare those non-secret values with the fixture. Run the suite with `VERCEL_CONNECT_REDIRECT_URI` set to the returned URI. +4. Complete organization-wide and project-scoped grants and confirm harmless Kernel reads. +5. Refresh each grant and confirm the original scope remains enforced. + +Do not commit connector credentials, authorization codes, access tokens, refresh tokens, PKCE verifiers, state, or complete authorization URLs. diff --git a/src/app/oauth-conformance/oauth-client-conformance.ts b/src/app/oauth-conformance/oauth-client-conformance.ts new file mode 100644 index 0000000..7ed391c --- /dev/null +++ b/src/app/oauth-conformance/oauth-client-conformance.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test"; +import { NextRequest } from "next/server"; +import { + CODE_VERIFIER, + createFixture, + formRequest, + type OAuthClientConformanceContract, +} from "./oauth-client-fixture"; + +const { GET: authorizationServerMetadata } = await import( + "@/app/.well-known/oauth-authorization-server/route" +); +const { tokenRequest } = await import("@/app/token/route"); + +export function defineOAuthClientConformance( + contract: OAuthClientConformanceContract, +): void { + describe(`${contract.name} OAuth conformance`, () => { + test("discovers the documented authorization-code, refresh, and PKCE contract", async () => { + const response = await authorizationServerMetadata( + new NextRequest( + "https://auth.example.test/.well-known/oauth-authorization-server", + ), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + issuer: "https://auth.example.test", + authorization_endpoint: "https://auth.example.test/authorize", + token_endpoint: "https://auth.example.test/token", + registration_endpoint: "https://auth.example.test/register", + scopes_supported: ["openid"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: expect.arrayContaining(["none"]), + }); + }); + + for (const accessScope of ["organization", "project"] as const) { + test(`${accessScope} authorization and refresh preserve the selected boundary`, async () => { + const fixture = createFixture(contract); + const clientId = await fixture.registerClient(); + await fixture.authorize({ clientId, accessScope }); + const initial = await fixture.exchangeCode(clientId); + + const initialContext = fixture.refreshContexts.get( + initial.refreshToken, + ); + expect(initialContext).toMatchObject({ + clerk_user_id: "user_1", + clerk_org_id: "org_1", + access_scope: accessScope, + ...(accessScope === "project" ? { project_id: "project_1" } : {}), + }); + + const refreshResponse = await tokenRequest( + formRequest({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: initial.refreshToken, + redirect_uri: contract.redirectUri, + access_scope: + accessScope === "project" ? "organization" : "project", + project_id: "attempted-scope-escalation", + org_id: "attempted-org-switch", + }), + fixture.tokenDependencies, + ); + + expect(refreshResponse.status).toBe(200); + const refreshed = (await refreshResponse.json()) as { + refresh_token: string; + org_id: string; + access_scope: string; + project_id?: string; + }; + expect(refreshed).toMatchObject({ + org_id: "org_1", + access_scope: accessScope, + ...(accessScope === "project" ? { project_id: "project_1" } : {}), + }); + if (accessScope === "organization") { + expect(refreshed.project_id).toBeUndefined(); + } + expect(fixture.refreshContexts.has(initial.refreshToken)).toBe(false); + expect(fixture.refreshContexts.get(refreshed.refresh_token)).toEqual( + initialContext, + ); + expect(fixture.providerExchanges.at(-1)?.has("access_scope")).toBe( + false, + ); + expect(fixture.providerExchanges.at(-1)?.has("project_id")).toBe(false); + expect(fixture.providerExchanges.at(-1)?.has("org_id")).toBe(false); + }); + } + + test("rejects a wrong PKCE verifier before provider exchange", async () => { + const fixture = createFixture(contract); + const clientId = await fixture.registerClient(); + await fixture.authorize({ clientId, accessScope: "project" }); + + const response = await tokenRequest( + formRequest({ + grant_type: "authorization_code", + client_id: clientId, + code: "authorization-code", + code_verifier: "wrong-verifier", + redirect_uri: contract.redirectUri, + }), + fixture.tokenDependencies, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "invalid_grant" }); + expect(fixture.providerExchanges).toHaveLength(0); + expect(fixture.persisted).toHaveLength(0); + }); + + test("refresh rejects a different client identity", async () => { + const fixture = createFixture(contract); + const clientId = await fixture.registerClient(); + await fixture.authorize({ clientId, accessScope: "project" }); + const initial = await fixture.exchangeCode(clientId); + + const response = await tokenRequest( + formRequest({ + grant_type: "refresh_token", + client_id: "different-client", + refresh_token: initial.refreshToken, + redirect_uri: contract.redirectUri, + }), + fixture.tokenDependencies, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "invalid_grant" }); + expect(fixture.refreshContexts.has(initial.refreshToken)).toBe(true); + expect(fixture.refreshClientIds.get(initial.refreshToken)).toBe(clientId); + expect(fixture.persisted).toHaveLength(1); + }); + + test("rejects redirect mismatch and invalid public-client authentication", async () => { + const redirectFixture = createFixture(contract); + const clientId = await redirectFixture.registerClient(); + await redirectFixture.authorize({ + clientId, + accessScope: "organization", + }); + + const redirectResponse = await tokenRequest( + formRequest({ + grant_type: "authorization_code", + client_id: clientId, + code: "authorization-code", + code_verifier: CODE_VERIFIER, + redirect_uri: "https://attacker.example/callback", + }), + redirectFixture.tokenDependencies, + ); + expect(redirectResponse.status).toBe(400); + expect(await redirectResponse.json()).toMatchObject({ + error: "invalid_grant", + }); + expect(redirectFixture.persisted).toHaveLength(0); + + const authFixture = createFixture(contract); + const authClientId = await authFixture.registerClient(); + await authFixture.authorize({ + clientId: authClientId, + accessScope: "organization", + }); + const credentials = Buffer.from( + `${authClientId}:unexpected-secret`, + ).toString("base64"); + const authResponse = await tokenRequest( + formRequest( + { + grant_type: "authorization_code", + code: "authorization-code", + code_verifier: CODE_VERIFIER, + redirect_uri: contract.redirectUri, + }, + { Authorization: `Basic ${credentials}` }, + ), + authFixture.tokenDependencies, + ); + expect(authResponse.status).toBe(400); + expect(await authResponse.json()).toMatchObject({ + error: "invalid_grant", + }); + expect(authFixture.persisted).toHaveLength(0); + }); + }); +} diff --git a/src/app/oauth-conformance/oauth-client-fixture.ts b/src/app/oauth-conformance/oauth-client-fixture.ts new file mode 100644 index 0000000..4cc7784 --- /dev/null +++ b/src/app/oauth-conformance/oauth-client-fixture.ts @@ -0,0 +1,254 @@ +import { expect } from "bun:test"; +import { NextRequest } from "next/server"; +import type { AuthorizeDependencies } from "@/app/authorize/route"; +import type { RegisterDependencies } from "@/app/register/route"; +import type { TokenDependencies } from "@/app/token/route"; +import type { OAuthAuthorizationContext } from "@/lib/oauth-context"; + +process.env.KERNEL_CLI_PROD_CLIENT_ID ??= "cli_prod"; +process.env.KERNEL_CLI_STAGING_CLIENT_ID ??= "cli_staging"; +process.env.KERNEL_CLI_DEV_CLIENT_ID ??= "cli_dev"; +process.env.NEXT_PUBLIC_CLERK_DOMAIN ??= "clerk.example.test"; +process.env.CLERK_SECRET_KEY ??= "clerk-secret"; + +const { authorizeRequest } = await import("@/app/authorize/route"); +const { registerRequest } = await import("@/app/register/route"); +const { tokenRequest } = await import("@/app/token/route"); +const { deriveS256CodeChallenge } = await import("@/lib/oauth-context"); +const { resolveAuthorizationContext } = await import("@/lib/org-utils"); + +export interface OAuthClientConformanceContract { + name: string; + clientName: string; + redirectUri: string; + tokenEndpointAuthMethod: "none"; + grantTypes: readonly ["authorization_code", "refresh_token"]; + responseTypes: readonly ["code"]; + scope: "openid"; + codeChallengeMethod: "S256"; +} + +export const CODE_VERIFIER = "oauth-conformance-code-verifier"; +const CODE_CHALLENGE = deriveS256CodeChallenge(CODE_VERIFIER); + +interface TokenSet { + refreshToken: string; +} + +export function formRequest( + values: Record, + headers: Record = {}, +): NextRequest { + return new NextRequest("https://auth.example.test/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...headers, + }, + body: new URLSearchParams(values), + }); +} + +export function createFixture(contract: OAuthClientConformanceContract) { + const requestContexts = new Map(); + const refreshContexts = new Map(); + const refreshClientIds = new Map(); + const providerExchanges: URLSearchParams[] = []; + const persisted: Parameters[0][] = []; + let tokenSequence = 0; + + const registerDependencies: RegisterDependencies = { + createOAuthApplication: async (input) => { + expect(input).toEqual({ + name: contract.clientName, + redirectUris: [contract.redirectUri], + scopes: contract.scope, + public: true, + }); + return { + id: "oauth_app_conformance", + clientId: "conformance_client", + clientSecret: null, + }; + }, + }; + + const authorizeDependencies: AuthorizeDependencies = { + getAuth: async () => ({ + userId: "user_1", + orgId: "org_1", + getToken: async () => "clerk-session-token", + }), + setRequestContext: async ({ + clientId, + codeChallenge, + authorizationContext, + }) => { + requestContexts.set(`${clientId}:${codeChallenge}`, authorizationContext); + }, + setClientContext: async () => { + throw new Error(`${contract.name} must not use non-PKCE authorization`); + }, + requireProject: async ({ projectId }) => ({ + id: projectId, + name: "mason", + status: "active", + }), + }; + + const tokenDependencies: TokenDependencies = { + resolveContext: (input) => + resolveAuthorizationContext(input, { + getRequestContext: async ({ clientId, codeChallenge }) => + requestContexts.get(`${clientId}:${codeChallenge}`) ?? null, + getClientContext: async () => null, + getRefreshContext: async ({ refreshToken }) => + refreshContexts.get(refreshToken) ?? null, + }), + exchange: async (_input, init) => { + const params = new URLSearchParams(init?.body as URLSearchParams); + providerExchanges.push(params); + + if (params.get("redirect_uri") !== contract.redirectUri) { + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + if (params.get("client_secret")) { + return Response.json({ error: "invalid_client" }, { status: 401 }); + } + const presentedRefreshToken = params.get("refresh_token"); + if ( + presentedRefreshToken && + refreshClientIds.get(presentedRefreshToken) !== params.get("client_id") + ) { + return Response.json({ error: "invalid_client" }, { status: 401 }); + } + + tokenSequence += 1; + return Response.json({ + access_token: `provider-access-${tokenSequence}`, + id_token: `kernel-jwt-${tokenSequence}`, + refresh_token: `refresh-${tokenSequence}`, + expires_in: 3600, + token_type: "Bearer", + }); + }, + verify: async () => ({ sub: "user_1" }), + hasMembership: async () => true, + persistContexts: async (value) => { + persisted.push(value); + refreshContexts.set(value.newRefreshToken, value.authorizationContext); + const clientId = + value.consumedRequest?.clientId ?? + (value.oldRefreshToken + ? refreshClientIds.get(value.oldRefreshToken) + : undefined); + if (clientId) refreshClientIds.set(value.newRefreshToken, clientId); + if (value.oldRefreshToken) { + refreshContexts.delete(value.oldRefreshToken); + refreshClientIds.delete(value.oldRefreshToken); + } + if (value.consumedRequest) { + requestContexts.delete( + `${value.consumedRequest.clientId}:${value.consumedRequest.codeChallenge}`, + ); + } + }, + }; + + async function registerClient(): Promise { + const response = await registerRequest( + new NextRequest("https://auth.example.test/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: contract.clientName, + redirect_uris: [contract.redirectUri], + token_endpoint_auth_method: contract.tokenEndpointAuthMethod, + grant_types: contract.grantTypes, + response_types: contract.responseTypes, + scope: contract.scope, + }), + }), + registerDependencies, + ); + expect(response.status).toBe(200); + const registration = (await response.json()) as { + client_id: string; + client_secret?: string; + token_endpoint_auth_method: string; + grant_types: string[]; + redirect_uris: string[]; + }; + expect(registration).toMatchObject({ + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [contract.redirectUri], + }); + expect(registration.client_secret).toBeUndefined(); + return registration.client_id; + } + + async function authorize({ + clientId, + accessScope, + }: { + clientId: string; + accessScope: "organization" | "project"; + }): Promise { + const state = "oauth-conformance-state"; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: contract.redirectUri, + response_type: "code", + scope: contract.scope, + state, + code_challenge: CODE_CHALLENGE, + code_challenge_method: contract.codeChallengeMethod, + org_id: "org_1", + access_scope: accessScope, + ...(accessScope === "project" ? { project_id: "project_1" } : {}), + }); + const response = await authorizeRequest( + new NextRequest(`https://auth.example.test/authorize?${params}`), + authorizeDependencies, + ); + expect(response.status).toBe(307); + const providerUrl = new URL(response.headers.get("location")!); + expect(providerUrl.searchParams.get("state")).toBe(state); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + contract.redirectUri, + ); + expect(providerUrl.searchParams.get("code_challenge")).toBe(CODE_CHALLENGE); + expect(providerUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(providerUrl.searchParams.get("access_scope")).toBeNull(); + expect(providerUrl.searchParams.get("project_id")).toBeNull(); + } + + async function exchangeCode(clientId: string): Promise { + const response = await tokenRequest( + formRequest({ + grant_type: "authorization_code", + client_id: clientId, + code: "authorization-code", + code_verifier: CODE_VERIFIER, + redirect_uri: contract.redirectUri, + }), + tokenDependencies, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { refresh_token: string }; + return { refreshToken: body.refresh_token }; + } + + return { + requestContexts, + refreshContexts, + refreshClientIds, + providerExchanges, + persisted, + tokenDependencies, + registerClient, + authorize, + exchangeCode, + }; +} diff --git a/src/app/oauth-conformance/vercel-connect.test.ts b/src/app/oauth-conformance/vercel-connect.test.ts new file mode 100644 index 0000000..ec77b3d --- /dev/null +++ b/src/app/oauth-conformance/vercel-connect.test.ts @@ -0,0 +1,14 @@ +import { defineOAuthClientConformance } from "./oauth-client-conformance"; + +defineOAuthClientConformance({ + name: "Vercel Connect", + clientName: "Vercel Connect", + redirectUri: + process.env.VERCEL_CONNECT_REDIRECT_URI ?? + "https://connect.vercel.test/oauth/callback", + tokenEndpointAuthMethod: "none", + grantTypes: ["authorization_code", "refresh_token"], + responseTypes: ["code"], + scope: "openid", + codeChallengeMethod: "S256", +}); From 91a042ab4e2be5477fcfc6e4e6e865753a5e7adb Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:46:16 +0000 Subject: [PATCH 2/4] Support localhost conformance redirects --- src/app/oauth-conformance/oauth-client-fixture.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/oauth-conformance/oauth-client-fixture.ts b/src/app/oauth-conformance/oauth-client-fixture.ts index 4cc7784..375676d 100644 --- a/src/app/oauth-conformance/oauth-client-fixture.ts +++ b/src/app/oauth-conformance/oauth-client-fixture.ts @@ -4,6 +4,7 @@ import type { AuthorizeDependencies } from "@/app/authorize/route"; import type { RegisterDependencies } from "@/app/register/route"; import type { TokenDependencies } from "@/app/token/route"; import type { OAuthAuthorizationContext } from "@/lib/oauth-context"; +import { expandLocalhostUris, normalizeLocalhostUri } from "@/lib/auth-utils"; process.env.KERNEL_CLI_PROD_CLIENT_ID ??= "cli_prod"; process.env.KERNEL_CLI_STAGING_CLIENT_ID ??= "cli_staging"; @@ -61,7 +62,7 @@ export function createFixture(contract: OAuthClientConformanceContract) { createOAuthApplication: async (input) => { expect(input).toEqual({ name: contract.clientName, - redirectUris: [contract.redirectUri], + redirectUris: expandLocalhostUris([contract.redirectUri]), scopes: contract.scope, public: true, }); @@ -109,7 +110,10 @@ export function createFixture(contract: OAuthClientConformanceContract) { const params = new URLSearchParams(init?.body as URLSearchParams); providerExchanges.push(params); - if (params.get("redirect_uri") !== contract.redirectUri) { + if ( + params.get("redirect_uri") !== + normalizeLocalhostUri(contract.redirectUri) + ) { return Response.json({ error: "invalid_grant" }, { status: 400 }); } if (params.get("client_secret")) { From 06faca88f85709e6288ade5db7e6d0a733a2cc94 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:57:33 +0000 Subject: [PATCH 3/4] Tighten Vercel OAuth contract assertions --- .../oauth-conformance/oauth-client-fixture.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/app/oauth-conformance/oauth-client-fixture.ts b/src/app/oauth-conformance/oauth-client-fixture.ts index 375676d..4f01bf9 100644 --- a/src/app/oauth-conformance/oauth-client-fixture.ts +++ b/src/app/oauth-conformance/oauth-client-fixture.ts @@ -106,10 +106,30 @@ export function createFixture(contract: OAuthClientConformanceContract) { getRefreshContext: async ({ refreshToken }) => refreshContexts.get(refreshToken) ?? null, }), - exchange: async (_input, init) => { + exchange: async (input, init) => { + expect(init?.body).toBeInstanceOf(URLSearchParams); const params = new URLSearchParams(init?.body as URLSearchParams); providerExchanges.push(params); + expect(input.toString()).toBe("https://clerk.example.test/oauth/token"); + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("content-type")).toBe( + "application/x-www-form-urlencoded", + ); + expect(params.get("client_id")).toBeTruthy(); + expect(params.get("redirect_uri")).toBeTruthy(); + + if (params.get("grant_type") === "authorization_code") { + expect(params.get("code")).toBe("authorization-code"); + expect(params.get("code_verifier")).toBe(CODE_VERIFIER); + expect(params.has("refresh_token")).toBe(false); + } else { + expect(params.get("grant_type")).toBe("refresh_token"); + expect(params.get("refresh_token")).toBeTruthy(); + expect(params.has("code")).toBe(false); + expect(params.has("code_verifier")).toBe(false); + } + if ( params.get("redirect_uri") !== normalizeLocalhostUri(contract.redirectUri) @@ -181,13 +201,18 @@ export function createFixture(contract: OAuthClientConformanceContract) { client_secret?: string; token_endpoint_auth_method: string; grant_types: string[]; + response_types: string[]; redirect_uris: string[]; + scope: string; }; expect(registration).toMatchObject({ token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], redirect_uris: [contract.redirectUri], + scope: "openid", }); + expect(registration.client_id.length).toBeGreaterThan(0); expect(registration.client_secret).toBeUndefined(); return registration.client_id; } From 220822afb74a0ad637a68cf67ae7c4389dd77f47 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:56:43 +0000 Subject: [PATCH 4/4] Assert OAuth organization context stays internal --- src/app/oauth-conformance/oauth-client-fixture.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/oauth-conformance/oauth-client-fixture.ts b/src/app/oauth-conformance/oauth-client-fixture.ts index 4f01bf9..9395af2 100644 --- a/src/app/oauth-conformance/oauth-client-fixture.ts +++ b/src/app/oauth-conformance/oauth-client-fixture.ts @@ -249,6 +249,7 @@ export function createFixture(contract: OAuthClientConformanceContract) { ); expect(providerUrl.searchParams.get("code_challenge")).toBe(CODE_CHALLENGE); expect(providerUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(providerUrl.searchParams.get("org_id")).toBeNull(); expect(providerUrl.searchParams.get("access_scope")).toBeNull(); expect(providerUrl.searchParams.get("project_id")).toBeNull(); }