Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/oauth-client-auth-method.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": minor
---

**OAuth clients can now persist `clientAuth: "basic"` to use `client_secret_basic` for authorization-code exchange, refresh, and client-credentials token requests; existing clients continue to use `client_secret_post`**
3 changes: 3 additions & 0 deletions packages/core/sdk/src/core-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ export const coreTables = defineTables({
token_url: textColumn("token_url"),
grant: textColumn("grant"),
client_id: textColumn("client_id"),
// Token-endpoint authentication for confidential clients. Null in old
// databases means the historical default, client_secret_post ("body").
client_auth: nullableTextColumn("client_auth"),
// The client secret is NOT stored inline — it's a provider `item_id` that
// resolves to the value via the default writable credential provider
// (WorkOS Vault on cloud, the local store on desktop). Null for public /
Expand Down
13 changes: 13 additions & 0 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,11 @@ import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } fr
import { buildToolTypeScriptPreview } from "./schema-types";
import { collectReferencedDefinitions } from "./schema-refs";
import {
DEFAULT_CLIENT_AUTH_METHOD,
refreshAccessToken,
exchangeClientCredentials,
shouldRefreshToken,
type ClientAuthMethod,
type OAuthEndpointUrlPolicy,
} from "./oauth-helpers";
import { connectionIdentifier } from "./connection-name-identifier";
Expand Down Expand Up @@ -1839,6 +1841,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
? ((yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id)))) ??
"")
: "";
const clientAuth: ClientAuthMethod =
clientRow.client_auth == null
? DEFAULT_CLIENT_AUTH_METHOD
: clientRow.client_auth === "body" || clientRow.client_auth === "basic"
? clientRow.client_auth
: yield* new StorageError({
message: `OAuth client "${row.oauth_client}" has an unknown client auth method: ${String(clientRow.client_auth)}`,
cause: undefined,
});
// Re-request the scopes this connection was GRANTED (RFC 6749 §6: a
// refresh must not exceed the originally-granted scope). Empty → omit
// the param, which the AS treats as "same scopes as granted".
Expand All @@ -1864,6 +1875,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
tokenUrl,
clientId: String(clientRow.client_id),
clientSecret,
clientAuth,
scopes: grantedScopes,
resource: clientRow.resource ? String(clientRow.resource) : undefined,
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
Expand Down Expand Up @@ -1894,6 +1906,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
tokenUrl,
clientId: String(clientRow.client_id),
clientSecret,
clientAuth,
refreshToken,
scopes: grantedScopes,
// RFC 8707: keep the re-minted token bound to the same resource
Expand Down
6 changes: 6 additions & 0 deletions packages/core/sdk/src/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
OAuthState,
type Owner,
} from "./ids";
import type { ClientAuthMethod } from "./oauth-helpers";

/* The v2 OAuth surface contracts. OAuth is a credential mechanism, not an
* integration type. A client is a registered app; running its flow mints a
Expand Down Expand Up @@ -56,6 +57,10 @@ export interface OAuthClient {
readonly tokenUrl: string;
readonly grant: OAuthGrant;
readonly clientId: string;
/** How a confidential client authenticates at the token endpoint. Defaults
* to `"body"` (`client_secret_post`) for compatibility. Public clients
* without a secret ignore this setting. */
readonly clientAuth?: ClientAuthMethod;
/** The literal client secret. Stored out-of-band in the credential provider
* (vault item id), never inline. Empty string for public / PKCE clients. */
readonly clientSecret: string;
Expand Down Expand Up @@ -101,6 +106,7 @@ export interface OAuthClientSummary {
readonly tokenUrl: string;
readonly resource?: string | null;
readonly clientId: string;
readonly clientAuth?: ClientAuthMethod;
readonly origin: OAuthClientOrigin;
}

Expand Down
17 changes: 17 additions & 0 deletions packages/core/sdk/src/oauth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ describe("oauth.start / oauth.complete", () => {
grant: "client_credentials",
clientId: "test-client",
clientSecret: "test-secret",
clientAuth: "basic",
});

const started = yield* executor.oauth.start({
Expand All @@ -626,6 +627,13 @@ describe("oauth.start / oauth.complete", () => {
template: TEMPLATE,
});
expect(started.status).toBe("connected");
const tokenRequest = (yield* server.requests).find(
(request) => request.path === "/token" && request.body.includes("client_credentials"),
);
expect(tokenRequest?.headers.authorization).toBe(
`Basic ${Buffer.from("test%2Dclient:test%2Dsecret").toString("base64")}`,
);
expect(tokenRequest?.body).not.toContain("client_secret=");
}),
),
);
Expand Down Expand Up @@ -765,6 +773,7 @@ describe("oauth token refresh in resolveConnectionValue", () => {
grant: "authorization_code",
clientId: "test-client",
clientSecret: "test-secret",
clientAuth: "basic",
resource: server.mcpResourceUrl,
});

Expand Down Expand Up @@ -811,9 +820,17 @@ describe("oauth token refresh in resolveConnectionValue", () => {
expect(refreshedToken.token).not.toBe(firstToken.token);
expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true);
const requests = yield* server.requests;
const expectedAuthorization = `Basic ${Buffer.from("test%2Dclient:test%2Dsecret").toString("base64")}`;
const exchangeRequest = requests.find(
(r) => r.path === "/token" && r.body.includes("grant_type=authorization_code"),
);
const refreshRequest = requests.find(
(r) => r.path === "/token" && r.method === "POST" && r.body.includes("refresh_token"),
);
expect(exchangeRequest?.headers.authorization).toBe(expectedAuthorization);
expect(exchangeRequest?.body).not.toContain("client_secret=");
expect(refreshRequest?.headers.authorization).toBe(expectedAuthorization);
expect(refreshRequest?.body).not.toContain("client_secret=");
expect(refreshRequest?.body).toContain(
`resource=${encodeURIComponent(server.mcpResourceUrl)}`,
);
Expand Down
13 changes: 12 additions & 1 deletion packages/core/sdk/src/oauth-list-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("oauth.listClients", () => {
it.effect("returns owner-visible clients as summaries without the secret", () =>
Effect.scoped(
Effect.gen(function* () {
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });

yield* executor.oauth.createClient({
owner: "org",
Expand All @@ -60,6 +60,7 @@ describe("oauth.listClients", () => {
grant: "authorization_code",
clientId: "org-client-id",
clientSecret: "org-super-secret",
clientAuth: "basic",
});
yield* executor.oauth.createClient({
owner: "user",
Expand All @@ -70,6 +71,14 @@ describe("oauth.listClients", () => {
clientId: "user-client-id",
clientSecret: "user-super-secret",
});
// Rows written before client_auth existed read as the historical body
// default rather than becoming unusable after an upgrade.
yield* Effect.promise(() =>
config.db.updateMany("oauth_client", {
where: (b) => b("slug", "=", String(USER_CLIENT)),
set: { client_auth: null },
}),
);

const clients = yield* executor.oauth.listClients();

Expand All @@ -91,13 +100,15 @@ describe("oauth.listClients", () => {
tokenUrl: "https://acme.test/token",
resource: null,
clientId: "org-client-id",
clientAuth: "basic",
// Manual apps carry a nullable recorded-intent integration; a client
// created outside any integration dialog stamps null.
origin: { kind: "manual", integration: null },
});
expect(user!.owner).toBe("user");
expect(user!.grant).toBe("client_credentials");
expect(user!.clientId).toBe("user-client-id");
expect(user!.clientAuth).toBe("body");

// The secret is NEVER projected onto a summary.
for (const client of clients) {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ import {
exchangeClientCredentials,
isLoopbackHttpUrl,
rebindTokenEndpointHostToCallbackDomain,
DEFAULT_CLIENT_AUTH_METHOD,
type ClientAuthMethod,
type OAuth2TokenResponse,
type OAuthEndpointUrlPolicy,
} from "./oauth-helpers";
Expand Down Expand Up @@ -300,6 +302,9 @@ const clientOwnerFromPayload = (payload: unknown): Owner | null => {
const parseGrant = (grant: unknown): OAuthGrant | null =>
grant === "client_credentials" || grant === "authorization_code" ? grant : null;

const parseClientAuth = (clientAuth: unknown): ClientAuthMethod | null =>
clientAuth == null || clientAuth === "body" ? "body" : clientAuth === "basic" ? "basic" : null;

const canonicalDcrIssuer = (
issuer: string | null | undefined,
registrationEndpoint: string,
Expand Down Expand Up @@ -398,6 +403,7 @@ interface LoadedOAuthClient {
readonly tokenUrl: string;
readonly grant: OAuthGrant;
readonly clientId: string;
readonly clientAuth: ClientAuthMethod;
/** Resolved literal secret (read from the provider via the stored item id). */
readonly clientSecret: string;
readonly resource: string | null;
Expand Down Expand Up @@ -642,6 +648,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
token_url: input.tokenUrl,
grant: input.grant,
client_id: input.clientId,
client_auth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
client_secret_item_id: clientSecretItemIdValue,
resource: input.resource ?? null,
origin_kind: input.origin?.kind ?? "manual",
Expand Down Expand Up @@ -959,6 +966,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
Effect.flatMap((rows) =>
Effect.forEach(rows, (row) => {
const grant = parseGrant(row.grant);
const clientAuth = parseClientAuth(row.client_auth);
// EXPLICIT — a row with an unknown grant is corrupt; surface it
// loudly rather than silently displaying it as authorization_code.
if (grant === null) {
Expand All @@ -969,6 +977,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
}),
);
}
if (clientAuth === null) {
return Effect.fail(
new StorageError({
message: `oauth_client ${String(row.slug)} has an unknown client auth method: ${String(row.client_auth)}`,
cause: undefined,
}),
);
}
return Effect.succeed({
owner: String(row.owner) as Owner,
slug: OAuthClientSlug.make(String(row.slug)),
Expand All @@ -977,6 +993,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
tokenUrl: String(row.token_url),
resource: row.resource == null ? null : String(row.resource),
clientId: String(row.client_id),
clientAuth,
origin: parseOAuthClientOrigin(row),
} satisfies OAuthClientSummary);
}),
Expand All @@ -1000,6 +1017,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
Effect.flatMap((row) => {
if (!row) return Effect.succeed(null);
const grant = parseGrant(row.grant);
const clientAuth = parseClientAuth(row.client_auth);
// EXPLICIT — this row drives the token exchange. An unknown grant is a
// corrupt row; fail loudly rather than guessing authorization_code and
// running the wrong flow.
Expand All @@ -1011,6 +1029,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
}),
);
}
if (clientAuth === null) {
return Effect.fail(
new StorageError({
message: `oauth_client ${String(slug)} has an unknown client auth method: ${String(row.client_auth)}`,
cause: undefined,
}),
);
}
// `client_secret_item_id` is null for DCR-minted / public PKCE clients;
// the token exchange treats a missing secret as "public client, omit
// client_secret" (see pickClientAuth). A confidential client persisted
Expand All @@ -1031,6 +1057,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
tokenUrl: String(row.token_url),
grant,
clientId: String(row.client_id),
clientAuth,
clientSecret,
resource: row.resource == null ? null : String(row.resource),
} satisfies LoadedOAuthClient;
Expand Down Expand Up @@ -1131,6 +1158,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
tokenUrl: client.tokenUrl,
clientId: client.clientId,
clientSecret: client.clientSecret,
clientAuth: client.clientAuth,
scopes: requestedScopes,
resource: client.resource ?? undefined,
endpointUrlPolicy: deps.endpointUrlPolicy,
Expand Down Expand Up @@ -1332,6 +1360,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
tokenUrl,
clientId: client.clientId,
clientSecret: client.clientSecret,
clientAuth: client.clientAuth,
redirectUrl: session.redirectUrl,
codeVerifier: session.pkceVerifier,
code: input.code,
Expand Down
6 changes: 4 additions & 2 deletions packages/core/sdk/src/testing/oauth-test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ const decodeBasicAuthorization = (
const separator = decoded.indexOf(":");
if (separator < 0) return null;
return {
username: decoded.slice(0, separator),
password: decoded.slice(separator + 1),
// RFC 6749 §2.3.1 applies application/x-www-form-urlencoded encoding to
// each credential before constructing the Basic value.
username: new URLSearchParams(`value=${decoded.slice(0, separator)}`).get("value") ?? "",
password: new URLSearchParams(`value=${decoded.slice(separator + 1)}`).get("value") ?? "",
};
};

Expand Down