-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add support for Basic Auth credentials in OAuth token endpoint #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
| } | ||
|
Comment on lines
+33
to
+44
|
||
|
|
||
| // Validate request | ||
| const parsed = tokenRequestSchema.safeParse(body); | ||
| if (!parsed.success) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
| }); | ||
|
Comment on lines
+203
to
+225
|
||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
|
||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| }); | ||
| }); | ||
| }); | ||
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
|
Comment on lines
+15
to
+16
|
||
| ); | ||
| 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to RFC 6749 Section 2.3.1, including client credentials in both the Authorization header and request body is not permitted. The current implementation silently prefers body credentials, but it should instead return an error if credentials are provided in both locations. This prevents ambiguous authentication attempts and follows the OAuth 2.0 specification more strictly.