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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions docs/oauth-conformance.md
Original file line number Diff line number Diff line change
@@ -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://<vercel-returned-redirect>' \
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.
195 changes: 195 additions & 0 deletions src/app/oauth-conformance/oauth-client-conformance.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading
Loading