Skip to content
Open
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
82 changes: 50 additions & 32 deletions packages/core/src/lib/actions/callback/oauth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,38 +92,56 @@ export async function handleOAuth(

let clientAuth: o.ClientAuth

switch (client.token_endpoint_auth_method) {
// TODO: in the next breaking major version have undefined be `client_secret_post`
case undefined:
case "client_secret_basic":
// TODO: in the next breaking major version use o.ClientSecretBasic() here
clientAuth = (_as, _client, _body, headers) => {
headers.set(
"authorization",
clientSecretBasic(provider.clientId, provider.clientSecret!)
)
}
break
case "client_secret_post":
clientAuth = o.ClientSecretPost(provider.clientSecret!)
break
case "client_secret_jwt":
clientAuth = o.ClientSecretJwt(provider.clientSecret!)
break
case "private_key_jwt":
clientAuth = o.PrivateKeyJwt(provider.token!.clientPrivateKey!, {
// TODO: review in the next breaking change
[o.modifyAssertion](_header, payload) {
payload.aud = [as.issuer, as.token_endpoint!]
},
})
break
case "none":
clientAuth = o.None()
break
default:
throw new Error("unsupported client authentication method")
}
// If a clientAssertionProvider is configured (and no clientSecret is set),
// use JWT bearer client assertion authentication instead of a client secret.
// See RFC 7523.
const useClientAssertion =
!provider.clientSecret &&
typeof provider.clientAssertionProvider === "function"

if (useClientAssertion) {
const assertionProvider = provider.clientAssertionProvider!
clientAuth = async (_as, _client, body, _headers) => {
body.set("client_id", provider.clientId)
body.set(
"client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
)
body.set("client_assertion", await assertionProvider())
}
} else
switch (client.token_endpoint_auth_method) {
// TODO: in the next breaking major version have undefined be `client_secret_post`
case undefined:
case "client_secret_basic":
// TODO: in the next breaking major version use o.ClientSecretBasic() here
clientAuth = (_as, _client, _body, headers) => {
headers.set(
"authorization",
clientSecretBasic(provider.clientId, provider.clientSecret!)
)
}
break
case "client_secret_post":
clientAuth = o.ClientSecretPost(provider.clientSecret!)
break
case "client_secret_jwt":
clientAuth = o.ClientSecretJwt(provider.clientSecret!)
break
case "private_key_jwt":
clientAuth = o.PrivateKeyJwt(provider.token!.clientPrivateKey!, {
// TODO: review in the next breaking change
[o.modifyAssertion](_header, payload) {
payload.aud = [as.issuer, as.token_endpoint!]
},
})
break
case "none":
clientAuth = o.None()
break
default:
throw new Error("unsupported client authentication method")
}

const resCookies: Cookie[] = []

Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/providers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,24 @@ export interface OAuth2Config<Profile>
checks?: Array<"pkce" | "state" | "none">
clientId?: string
clientSecret?: string
/**
* An async function that returns a client assertion (typically a signed JWT)
* to be used instead of a long-lived `clientSecret` when authenticating to the
* provider's token endpoint.
*
* When provided (and no `clientSecret` is set), the token exchange request
* will include the following parameters instead of a `client_secret`:
* - `client_assertion_type`: `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`
* - `client_assertion`: the string returned by this function
*
* This enables the use of JWT-based client authentication (for example, with
* Okta or Microsoft Entra ID) without having to store a long-lived secret.
*
* The caller is responsible for generating/signing the assertion.
*
* @see [RFC 7523 - JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants](https://www.rfc-editor.org/rfc/rfc7523)
*/
clientAssertionProvider?: () => Awaitable<string>
/**
* Pass overrides to the underlying OAuth library.
* See [`oauth4webapi` client](https://github.com/panva/oauth4webapi/blob/main/docs/interfaces/Client.md) for details.
Expand Down
151 changes: 151 additions & 0 deletions packages/core/test/actions/oauth-client-assertion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest"

import { customFetch } from "../../src/lib/symbols.js"
import { makeAuthRequest } from "../utils.js"
import type { OAuth2Config } from "../../src/providers/oauth.js"

/**
* These tests verify that when an OAuth2 provider is configured with a
* `clientAssertionProvider` (and no `clientSecret`), the token exchange
* request to the provider's token endpoint is made using
* RFC 7523 JWT-bearer client assertion parameters instead of a
* client secret.
*/
describe("OAuth2 clientAssertionProvider", () => {
function makeFetchMock() {
const calls: Array<{ url: string; init: RequestInit }> = []
const fetchMock: typeof fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url
calls.push({ url, init: init ?? {} })
// Return an error response so the callback flow short-circuits.
// We only care about what was sent on the token exchange request.
return new Response(JSON.stringify({ error: "stopped_by_test" }), {
status: 400,
headers: { "content-type": "application/json" },
})
}
return { calls, fetchMock }
}

function testProvider(
overrides: Partial<OAuth2Config<any>> = {}
): OAuth2Config<any> {
return {
id: "test-oauth",
name: "Test OAuth",
type: "oauth",
clientId: "test-client-id",
checks: ["none"],
authorization: "https://auth.example.com/authorize",
token: "https://auth.example.com/token",
userinfo: "https://auth.example.com/userinfo",
...overrides,
}
}

it("sends client_assertion params (and no client_secret) at the token endpoint", async () => {
const assertionProvider = vi
.fn<[], Promise<string>>()
.mockResolvedValue("fake.jwt.assertion")
const { calls, fetchMock } = makeFetchMock()

const provider = testProvider({
clientAssertionProvider: assertionProvider,
[customFetch]: fetchMock,
})

await makeAuthRequest({
action: "callback",
path: "/test-oauth",
query: { code: "auth-code" },
config: { providers: [provider] },
})

// The assertion provider should be invoked once per token exchange.
expect(assertionProvider).toHaveBeenCalledTimes(1)

const tokenCall = calls.find((c) =>
c.url.startsWith("https://auth.example.com/token")
)
expect(tokenCall).toBeDefined()

const body = new URLSearchParams(String(tokenCall!.init.body))
expect(body.get("client_id")).toBe("test-client-id")
expect(body.get("client_assertion_type")).toBe(
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
)
expect(body.get("client_assertion")).toBe("fake.jwt.assertion")
expect(body.get("client_secret")).toBeNull()

// No HTTP Basic `authorization` header should be present.
const headers = new Headers(tokenCall!.init.headers as HeadersInit)
expect(headers.get("authorization")).toBeNull()
})

it("falls back to clientSecret (HTTP Basic) when no clientAssertionProvider is configured", async () => {
const { calls, fetchMock } = makeFetchMock()

const provider = testProvider({
clientSecret: "super-secret",
[customFetch]: fetchMock,
})

await makeAuthRequest({
action: "callback",
path: "/test-oauth",
query: { code: "auth-code" },
config: { providers: [provider] },
})

const tokenCall = calls.find((c) =>
c.url.startsWith("https://auth.example.com/token")
)
expect(tokenCall).toBeDefined()

const headers = new Headers(tokenCall!.init.headers as HeadersInit)
expect(headers.get("authorization")).toMatch(/^Basic /)

const body = new URLSearchParams(String(tokenCall!.init.body))
expect(body.get("client_assertion")).toBeNull()
expect(body.get("client_assertion_type")).toBeNull()
})

it("prefers clientSecret over clientAssertionProvider when both are set", async () => {
const assertionProvider = vi
.fn<[], Promise<string>>()
.mockResolvedValue("fake.jwt.assertion")
const { calls, fetchMock } = makeFetchMock()

const provider = testProvider({
clientSecret: "super-secret",
clientAssertionProvider: assertionProvider,
[customFetch]: fetchMock,
})

await makeAuthRequest({
action: "callback",
path: "/test-oauth",
query: { code: "auth-code" },
config: { providers: [provider] },
})

expect(assertionProvider).not.toHaveBeenCalled()

const tokenCall = calls.find((c) =>
c.url.startsWith("https://auth.example.com/token")
)
expect(tokenCall).toBeDefined()

const body = new URLSearchParams(String(tokenCall!.init.body))
expect(body.get("client_assertion")).toBeNull()
expect(body.get("client_assertion_type")).toBeNull()

const headers = new Headers(tokenCall!.init.headers as HeadersInit)
expect(headers.get("authorization")).toMatch(/^Basic /)
})
})
Loading