feat(oidc-provider): allow passing oauth consent code via query params (#3845)

* feat: pass oauth consent code via query params

* address cubic comments

* fix tests

* address comments
This commit is contained in:
Grant G
2025-08-07 00:03:05 -07:00
committed by Bereket Engida
parent dabc51a62a
commit 18f72643ef
5 changed files with 94 additions and 20 deletions

View File

@@ -264,17 +264,40 @@ export const auth = betterAuth({
})
```
The plugin will redirect the user to the specified path with a `client_id` and `scope` query parameter. You can use this information to display a custom consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization.
The plugin will redirect the user to the specified path with `consent_code`, `client_id` and `scope` query parameters. You can use this information to display a custom consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization.
<Endpoint path="/oauth2/consent" method="POST" />
```ts title="server.ts"
The consent endpoint supports two methods for passing the consent code:
**Method 1: URL Parameter**
```ts title="consent-page.ts"
// Get the consent code from the URL
const params = new URLSearchParams(window.location.search);
// Submit consent with the code in the request body
const consentCode = params.get('consent_code');
if (!consentCode) {
throw new Error('Consent code not found in URL parameters');
}
const res = await client.oauth2.consent({
accept: true, // or false to deny
consent_code: consentCode,
});
```
The `client_id` and other necessary information are stored in the browser cookie, so you don't need to pass them in the request. If they don't exist in the cookie, the consent method will return an error.
**Method 2: Cookie-Based**
```ts title="consent-page.ts"
// The consent code is automatically stored in a signed cookie
// Just submit the consent decision
const res = await client.oauth2.consent({
accept: true, // or false to deny
// consent_code not needed when using cookie-based flow
});
```
Both methods are fully supported. The URL parameter method works well with mobile apps and third-party contexts, while the cookie-based method provides a simpler implementation for web applications.
### Handling Login

View File

@@ -263,14 +263,19 @@ export async function authorize(
}
if (options?.consentPage) {
// Set cookie to support cookie-based consent flows
await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, {
maxAge: 600,
path: "/",
sameSite: "lax",
});
const consentURI = `${options.consentPage}?client_id=${
client.clientId
}&scope=${requestScope.join(" ")}`;
// Pass the consent code as a URL parameter to support URL-based consent flows
const urlParams = new URLSearchParams();
urlParams.set("consent_code", code);
urlParams.set("client_id", client.clientId);
urlParams.set("scope", requestScope.join(" "));
const consentURI = `${options.consentPage}?${urlParams.toString()}`;
return handleRedirect(consentURI);
}

View File

@@ -319,11 +319,36 @@ export const oidcProvider = (options: OIDCOptions) => {
method: "POST",
body: z.object({
accept: z.boolean(),
consent_code: z.string().optional(),
}),
use: [sessionMiddleware],
metadata: {
openapi: {
description: "Handle OAuth2 consent",
description:
"Handle OAuth2 consent. Supports both URL parameter-based flows (consent_code in body) and cookie-based flows (signed cookie).",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
accept: {
type: "boolean",
description:
"Whether the user accepts or denies the consent request",
},
consent_code: {
type: "string",
description:
"The consent code from the authorization request. Optional if using cookie-based flow.",
},
},
required: ["accept"],
},
},
},
},
responses: {
"200": {
description: "Consent processed successfully",
@@ -349,18 +374,31 @@ export const oidcProvider = (options: OIDCOptions) => {
},
},
async (ctx) => {
const storedCode = await ctx.getSignedCookie(
// Support both consent flow methods:
// 1. URL parameter-based: consent_code in request body (standard OAuth2 pattern)
// 2. Cookie-based: using signed cookie for stateful consent flows
let consentCode: string | null = ctx.body.consent_code || null;
if (!consentCode) {
// Check for cookie-based consent flow
consentCode = await ctx.getSignedCookie(
"oidc_consent_prompt",
ctx.context.secret,
);
if (!storedCode) {
}
if (!consentCode) {
throw new APIError("UNAUTHORIZED", {
error_description: "No consent prompt found",
error_description:
"consent_code is required (either in body or cookie)",
error: "invalid_request",
});
}
const verification =
await ctx.context.internalAdapter.findVerificationValue(storedCode);
await ctx.context.internalAdapter.findVerificationValue(
consentCode,
);
if (!verification) {
throw new APIError("UNAUTHORIZED", {
error_description: "Invalid code",
@@ -373,6 +411,12 @@ export const oidcProvider = (options: OIDCOptions) => {
error: "invalid_request",
});
}
// Clear the cookie
ctx.setCookie("oidc_consent_prompt", "", {
maxAge: 0,
});
const value = JSON.parse(verification.value) as CodeVerificationValue;
if (!value.requireConsent) {
throw new APIError("UNAUTHORIZED", {

View File

@@ -233,7 +233,9 @@ describe("oidc", async () => {
newHeaders.append("Cookie", headers.get("Cookie") || "");
},
});
expect(redirectURI).toContain("/oauth2/authorize?client_id=");
expect(redirectURI).toContain("/oauth2/authorize?");
expect(redirectURI).toContain("consent_code=");
expect(redirectURI).toContain("client_id=");
const res = await serverClient.oauth2.consent(
{
accept: true,

View File

@@ -52,14 +52,14 @@ export interface OIDCOptions {
*
* When the server redirects the user to the consent page, it will include the
* following query parameters:
* authorization code.
* - `consent_code` - The consent code to identify the authorization request.
* - `client_id` - The ID of the client.
* - `scope` - The requested scopes.
* - `code` - The authorization code.
*
* once the user consents, you need to call the `/oauth2/consent` endpoint
* with the code and `accept: true` to complete the authorization. Which will
* then return the client to the `redirect_uri` with the authorization code.
* Once the user consents, you need to call the `/oauth2/consent` endpoint
* with `accept: true` and optionally the `consent_code` (if using URL parameter flow)
* to complete the authorization. This will return the client to the `redirect_uri`
* with the authorization code.
*
* @example
* ```ts