From 569f552d796103cf5b33eb5c036b81d371f0f4d6 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 17 Aug 2026 12:23:08 -0700 Subject: [PATCH] Stop sending scope on the authorization code token request (#579) --- plugins/auth-oauth2/src/fetchAccessToken.ts | 8 +- .../tests/fetchAccessToken.test.ts | 93 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 plugins/auth-oauth2/tests/fetchAccessToken.test.ts diff --git a/plugins/auth-oauth2/src/fetchAccessToken.ts b/plugins/auth-oauth2/src/fetchAccessToken.ts index 270c2320..32e43b9c 100644 --- a/plugins/auth-oauth2/src/fetchAccessToken.ts +++ b/plugins/auth-oauth2/src/fetchAccessToken.ts @@ -31,7 +31,13 @@ export async function fetchAccessToken( ], }; - if (scope) httpRequest.body?.form.push({ name: "scope", value: scope }); + // RFC 6749 §4.1.3 doesn't define scope for the authorization code token + // request, so strict servers (OpenIddict) reject it outright. Scope belongs on + // the authorize request, which already sends it. Every other grant does define + // it: §4.3.2 password, §4.4.2 client credentials, §6 refresh. + if (scope && grantType !== "authorization_code") { + httpRequest.body?.form.push({ name: "scope", value: scope }); + } if (audience) httpRequest.body?.form.push({ name: "audience", value: audience }); if ("clientAssertion" in args) { diff --git a/plugins/auth-oauth2/tests/fetchAccessToken.test.ts b/plugins/auth-oauth2/tests/fetchAccessToken.test.ts new file mode 100644 index 00000000..5ee0206d --- /dev/null +++ b/plugins/auth-oauth2/tests/fetchAccessToken.test.ts @@ -0,0 +1,93 @@ +import type { HttpRequest } from "@yaakapp/api"; +import { describe, expect, test } from "vite-plus/test"; +import { fetchAccessToken } from "../src/fetchAccessToken"; + +/** + * Captures the request handed to ctx.httpRequest.send so tests can assert on the + * form body, and replies with a minimal successful token response. + */ +function createMockContext() { + const sent: Partial[] = []; + + const ctx = { + httpRequest: { + async send({ httpRequest }: { httpRequest: Partial }) { + sent.push(httpRequest); + return { + httpResponse: { status: 200, error: null }, + body: { + async text() { + return JSON.stringify({ access_token: "token-123" }); + }, + }, + }; + }, + }, + } as never; + + return { ctx, sent }; +} + +function formNames(httpRequest: Partial) { + return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name); +} + +function formValue(httpRequest: Partial, name: string) { + return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value; +} + +const baseArgs = { + clientId: "client-123", + accessTokenUrl: "https://auth.example.com/token", + scope: "openid profile", + audience: null, + clientSecret: "secret", + credentialsInBody: true, + params: [], +}; + +describe("fetchAccessToken scope handling", () => { + test("omits scope for the authorization code grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { + ...baseArgs, + grantType: "authorization_code", + params: [{ name: "code", value: "abc" }], + }); + + expect(formNames(sent[0]!)).not.toContain("scope"); + // The rest of the request is untouched + expect(formValue(sent[0]!, "grant_type")).toBe("authorization_code"); + expect(formValue(sent[0]!, "code")).toBe("abc"); + }); + + test("sends scope for the client credentials grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { ...baseArgs, grantType: "client_credentials" }); + + expect(formValue(sent[0]!, "scope")).toBe("openid profile"); + }); + + test("sends scope for the password grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { ...baseArgs, grantType: "password" }); + + expect(formValue(sent[0]!, "scope")).toBe("openid profile"); + }); + + test("still sends audience for the authorization code grant", async () => { + const { ctx, sent } = createMockContext(); + + await fetchAccessToken(ctx, { + ...baseArgs, + grantType: "authorization_code", + audience: "https://api.example.com", + }); + + expect(formValue(sent[0]!, "audience")).toBe("https://api.example.com"); + expect(formNames(sent[0]!)).not.toContain("scope"); + }); +});