From 699882f4c3b1f4519fc18606601ecd36bc6c9596 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 22 Jan 2026 08:54:53 +0800 Subject: [PATCH] feat: add support for Basic Auth credentials in OAuth token endpoint --- app/api/oauth/token/route.ts | 14 ++++ e2e/oauth/client-credentials.spec.ts | 94 +++++++++++++++++++++++ lib/oauth/basic-auth.test.ts | 108 +++++++++++++++++++++++++++ lib/oauth/basic-auth.ts | 35 +++++++++ 4 files changed, 251 insertions(+) create mode 100644 lib/oauth/basic-auth.test.ts create mode 100644 lib/oauth/basic-auth.ts diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index ec1e826..2375756 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -14,6 +14,7 @@ import { signIdToken, generateRefreshToken, } from "@/lib/oauth/jwt"; +import { parseBasicAuth } from "@/lib/oauth/basic-auth"; import { tokenRequestSchema } from "@/lib/validations/oauth"; import { eq, and, isNull, or, gt } from "drizzle-orm"; @@ -29,6 +30,19 @@ export async function POST(request: NextRequest) { body[key] = value.toString(); }); + // Support client credentials from Authorization header (Basic auth) + const authHeader = request.headers.get("authorization"); + const basicAuth = parseBasicAuth(authHeader); + if (basicAuth) { + // Only use header values if not already in body + if (!body.client_id) { + body.client_id = basicAuth.clientId; + } + if (!body.client_secret) { + body.client_secret = basicAuth.clientSecret; + } + } + // Validate request const parsed = tokenRequestSchema.safeParse(body); if (!parsed.success) { diff --git a/e2e/oauth/client-credentials.spec.ts b/e2e/oauth/client-credentials.spec.ts index 5dc14ca..e407448 100644 --- a/e2e/oauth/client-credentials.spec.ts +++ b/e2e/oauth/client-credentials.spec.ts @@ -173,4 +173,98 @@ test.describe("OAuth Client Credentials Flow", () => { const config = await response.json(); expect(config.grant_types_supported).toContain("client_credentials"); }); + + test.describe("Basic Auth Header Support", () => { + test("should accept credentials via Authorization Basic header", async ({ + request, + }) => { + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( + "base64", + ); + + const response = await request.post("/api/oauth/token", { + headers: { + Authorization: `Basic ${credentials}`, + }, + form: { + grant_type: "client_credentials", + scope: "read:profile", + }, + }); + + expect(response.ok()).toBeTruthy(); + const tokens = await response.json(); + + expect(tokens.access_token).toBeTruthy(); + expect(tokens.token_type).toBe("Bearer"); + expect(tokens.scope).toBe("read:profile"); + }); + + test("should prefer body credentials over header credentials", async ({ + request, + }) => { + // Send wrong credentials in header but correct in body + const wrongCredentials = Buffer.from("wrong:wrong").toString("base64"); + + const response = await request.post("/api/oauth/token", { + headers: { + Authorization: `Basic ${wrongCredentials}`, + }, + form: { + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + scope: "read:profile", + }, + }); + + // Should succeed because body credentials take precedence + expect(response.ok()).toBeTruthy(); + const tokens = await response.json(); + expect(tokens.access_token).toBeTruthy(); + }); + + test("should reject invalid Basic auth credentials", async ({ + request, + }) => { + const wrongCredentials = Buffer.from( + `${clientId}:wrong-secret`, + ).toString("base64"); + + const response = await request.post("/api/oauth/token", { + headers: { + Authorization: `Basic ${wrongCredentials}`, + }, + form: { + grant_type: "client_credentials", + }, + }); + + expect(response.status()).toBe(401); + const error = await response.json(); + expect(error.error).toBe("invalid_client"); + }); + + test("should handle credentials with special characters via Basic auth", async ({ + request, + }) => { + // The clientSecret may contain special characters, test that it works + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( + "base64", + ); + + const response = await request.post("/api/oauth/token", { + headers: { + Authorization: `Basic ${credentials}`, + }, + form: { + grant_type: "client_credentials", + }, + }); + + expect(response.ok()).toBeTruthy(); + const tokens = await response.json(); + expect(tokens.access_token).toBeTruthy(); + }); + }); }); diff --git a/lib/oauth/basic-auth.test.ts b/lib/oauth/basic-auth.test.ts new file mode 100644 index 0000000..0ffa86b --- /dev/null +++ b/lib/oauth/basic-auth.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "bun:test"; +import { parseBasicAuth } from "./basic-auth"; + +describe("parseBasicAuth", () => { + it("should parse valid Basic auth header", () => { + // "client123:secret456" encoded in base64 + const encoded = Buffer.from("client123:secret456").toString("base64"); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toEqual({ + clientId: "client123", + clientSecret: "secret456", + }); + }); + + it("should handle empty client secret", () => { + // "client123:" encoded in base64 (empty secret) + const encoded = Buffer.from("client123:").toString("base64"); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toEqual({ + clientId: "client123", + clientSecret: "", + }); + }); + + it("should handle client secret with colons", () => { + // "client123:secret:with:colons" encoded in base64 + const encoded = Buffer.from("client123:secret:with:colons").toString( + "base64", + ); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toEqual({ + clientId: "client123", + clientSecret: "secret:with:colons", + }); + }); + + it("should return null for null header", () => { + const result = parseBasicAuth(null); + expect(result).toBeNull(); + }); + + it("should return null for non-Basic auth header", () => { + const result = parseBasicAuth("Bearer sometoken"); + expect(result).toBeNull(); + }); + + it("should return null for empty string", () => { + const result = parseBasicAuth(""); + expect(result).toBeNull(); + }); + + it("should return null for credentials without colon", () => { + const encoded = Buffer.from("justclientid").toString("base64"); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toBeNull(); + }); + + it("should return null for empty client id", () => { + // ":secretonly" encoded in base64 (empty clientId) + const encoded = Buffer.from(":secretonly").toString("base64"); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toBeNull(); + }); + + it("should handle special characters in credentials", () => { + const encoded = Buffer.from("client@example.com:p@$$w0rd!").toString( + "base64", + ); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toEqual({ + clientId: "client@example.com", + clientSecret: "p@$$w0rd!", + }); + }); + + it("should handle URL-encoded credentials", () => { + // Some clients URL-encode the credentials before base64 encoding + const encoded = Buffer.from( + "client%40example.com:secret%3Dvalue", + ).toString("base64"); + const header = `Basic ${encoded}`; + + const result = parseBasicAuth(header); + + expect(result).toEqual({ + clientId: "client%40example.com", + clientSecret: "secret%3Dvalue", + }); + }); +}); diff --git a/lib/oauth/basic-auth.ts b/lib/oauth/basic-auth.ts new file mode 100644 index 0000000..0a2ad51 --- /dev/null +++ b/lib/oauth/basic-auth.ts @@ -0,0 +1,35 @@ +/** + * Parse Basic authentication header and extract client credentials + * @param authHeader - The Authorization header value + * @returns Object with clientId and clientSecret, or null if invalid + */ +export function parseBasicAuth( + authHeader: string | null, +): { clientId: string; clientSecret: string } | null { + if (!authHeader?.startsWith("Basic ")) { + return null; + } + + try { + const base64Credentials = authHeader.slice(6); + const credentials = Buffer.from(base64Credentials, "base64").toString( + "utf-8", + ); + const colonIndex = credentials.indexOf(":"); + + if (colonIndex === -1) { + return null; + } + + const clientId = credentials.slice(0, colonIndex); + const clientSecret = credentials.slice(colonIndex + 1); + + if (!clientId) { + return null; + } + + return { clientId, clientSecret }; + } catch { + return null; + } +}