Skip to content
Merged
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
14 changes: 14 additions & 0 deletions app/api/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 +37 to +43
Copy link

Copilot AI Jan 22, 2026

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.

Suggested change
// 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;
}
// RFC 6749 Section 2.3.1: client credentials MUST NOT be included
// in more than one location (e.g., Authorization header and body).
if (body.client_id !== undefined || body.client_secret !== undefined) {
return NextResponse.json(
{
error: "invalid_request",
error_description:
"Client credentials must not be provided in multiple locations",
},
{ status: 400 },
);
}
body.client_id = basicAuth.clientId;
body.client_secret = basicAuth.clientSecret;

Copilot uses AI. Check for mistakes.
}
Comment on lines +33 to +44
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency with the token endpoint, consider also supporting Basic Auth credentials in the revoke endpoint (app/api/oauth/revoke/route.ts). The OAuth 2.0 Token Revocation spec (RFC 7009) allows the same client authentication methods as the token endpoint, so clients expecting Basic Auth support would expect it to work for revocation as well.

Copilot uses AI. Check for mistakes.

// Validate request
const parsed = tokenRequestSchema.safeParse(body);
if (!parsed.success) {
Expand Down
94 changes: 94 additions & 0 deletions e2e/oauth/client-credentials.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test validates behavior that conflicts with RFC 6749 Section 2.3.1. According to the OAuth 2.0 specification, a client MUST NOT use more than one authentication method in each request. Rather than allowing body credentials to take precedence, the endpoint should return an error when credentials are provided in both the Authorization header and request body. This test should be updated to expect a 400 or 401 error response instead of success.

Copilot uses AI. Check for mistakes.

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();
});
});
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing e2e test case for malformed Basic auth headers. Consider adding tests for edge cases such as: 1) Authorization header with "Basic" but no space or credentials, 2) Authorization header with invalid base64 encoding, 3) Authorization header with "Basic" followed by empty string. These cases help ensure robust error handling in production.

Copilot uses AI. Check for mistakes.
});
108 changes: 108 additions & 0 deletions lib/oauth/basic-auth.test.ts
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",
});
});
});
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test case for invalid base64 encoding. The function should handle cases where the Authorization header contains "Basic" followed by invalid base64 characters (e.g., "Basic !!!invalid!!!"). This would help ensure the error handling in the try-catch block is properly tested.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test case for very long credentials that could test buffer limits. Consider adding a test with extremely long clientId or clientSecret values to ensure the function handles them appropriately without performance degradation or memory issues.

Copilot uses AI. Check for mistakes.
35 changes: 35 additions & 0 deletions lib/oauth/basic-auth.ts
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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Buffer.from() operation on untrusted base64 input could potentially cause issues with malformed input. While the try-catch block handles this, consider adding explicit validation of the base64 string format before decoding to fail fast on obviously malformed input. Additionally, consider adding a maximum length check on the base64Credentials string to prevent potential DoS attacks with extremely large authorization headers.

Copilot uses AI. Check for mistakes.
);
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;
}
}
Loading