From a0eb1631fc17c752f3a9dfdb734ff7b85f75fb7a Mon Sep 17 00:00:00 2001 From: Dylan Vanmali Date: Wed, 18 Mar 2026 10:27:19 -0700 Subject: [PATCH] feat(oauth-provider): public client prelogin endpoint (#8214) --- docs/content/docs/plugins/oauth-provider.mdx | 27 ++++++++++++++++++ .../oauth-provider/src/middleware/index.ts | 17 +++++++++++ packages/oauth-provider/src/oauth.ts | 28 +++++++++---------- .../src/oauthClient/endpoints.test.ts | 25 ++++++++++++++++- .../src/oauthClient/endpoints.ts | 5 ++-- .../oauth-provider/src/oauthClient/index.ts | 28 +++++++++++++++++-- packages/oauth-provider/src/types/index.ts | 5 ++++ packages/oauth-provider/src/utils/index.ts | 16 +++++++++++ 8 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 packages/oauth-provider/src/middleware/index.ts diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 2b0e2b021b..44cd491d98 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -195,6 +195,33 @@ type getOAuthClientPublic = { ``` +#### Get Public Client Prelogin + +To obtain a public client prior to login, you must first enable the endpoint in your configuration: + +```ts title="auth.ts" +oauthProvider({ + allowPublicClientPrelogin: true, +}) +``` + +Then, the following endpoint will obtain public client information. + + +```ts +type getOAuthClientPublicPrelogin = { + /** + * The OAuth client's client_id + */ + client_id: string, + /** + * Valid oauth query parameters (Sent automatically when using the provided client) + */ + oauth_query: string +} +``` + + #### List Clients To obtain a list of clients owned by a specific user or organization, use the following endpoint: diff --git a/packages/oauth-provider/src/middleware/index.ts b/packages/oauth-provider/src/middleware/index.ts new file mode 100644 index 0000000000..eb25d59662 --- /dev/null +++ b/packages/oauth-provider/src/middleware/index.ts @@ -0,0 +1,17 @@ +import { APIError, createAuthMiddleware } from "better-auth/api"; +import type { OAuthOptions, Scope } from "../types"; +import { verifyOAuthQueryParams } from "../utils"; + +export const publicSessionMiddleware = (opts: OAuthOptions) => + createAuthMiddleware(async (ctx) => { + if (!opts.allowPublicClientPrelogin) { + throw new APIError("BAD_REQUEST"); + } + const query = ctx.body.oauth_query; + const isValid = await verifyOAuthQueryParams(query, ctx.context.secret); + if (!isValid) { + throw new APIError("UNAUTHORIZED", { + error: "invalid_signature", + }); + } + }); diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index 005b2f9ff6..bb38542539 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -9,7 +9,6 @@ import { sessionMiddleware, } from "better-auth/api"; import { parseSetCookieHeader } from "better-auth/cookies"; -import { constantTimeEqual, makeSignature } from "better-auth/crypto"; import { mergeSchema } from "better-auth/db"; import type { BetterAuthPlugin } from "better-auth/types"; import * as z from "zod"; @@ -28,7 +27,11 @@ import { tokenEndpoint } from "./token"; import type { OAuthOptions, Scope } from "./types"; import { SafeUrlSchema } from "./types/zod"; import { userInfoEndpoint } from "./userinfo"; -import { deleteFromPrompt, getJwtPlugin } from "./utils"; +import { + deleteFromPrompt, + getJwtPlugin, + verifyOAuthQueryParams, +} from "./utils"; declare module "@better-auth/core" { interface BetterAuthPluginRegistry { @@ -209,27 +212,20 @@ export const oauthProvider = >(options: O) => { handler: createAuthMiddleware(async (ctx) => { // Verify query signature const query = ctx.body.oauth_query; - let queryParams = new URLSearchParams(query); - const sig = queryParams.get("sig"); - const exp = Number(queryParams.get("exp")); - queryParams.delete("sig"); - queryParams = new URLSearchParams(queryParams); - const verifySig = await makeSignature( - queryParams.toString(), + const isValid = await verifyOAuthQueryParams( + query, ctx.context.secret, ); - if ( - !sig || - !constantTimeEqual(sig, verifySig) || - new Date(exp * 1000) < new Date() - ) { + if (!isValid) { throw new APIError("BAD_REQUEST", { error: "invalid_signature", }); } + const queryParams = new URLSearchParams(query); + queryParams.delete("sig"); queryParams.delete("exp"); await oAuthState.set({ - query: new URLSearchParams(queryParams).toString(), + query: queryParams.toString(), }); // If path starts oauth2 authorize (ie /sign-in/social, /sign-in/oauth2), add to additional data body @@ -1302,6 +1298,8 @@ export const oauthProvider = >(options: O) => { createOAuthClient: oauthClientEndpoints.createOAuthClient(opts), getOAuthClient: oauthClientEndpoints.getOAuthClient(opts), getOAuthClientPublic: oauthClientEndpoints.getOAuthClientPublic(opts), + getOAuthClientPublicPrelogin: + oauthClientEndpoints.getOAuthClientPublicPrelogin(opts), getOAuthClients: oauthClientEndpoints.getOAuthClients(opts), adminUpdateOAuthClient: oauthClientEndpoints.adminUpdateOAuthClient(opts), updateOAuthClient: oauthClientEndpoints.updateOAuthClient(opts), diff --git a/packages/oauth-provider/src/oauthClient/endpoints.test.ts b/packages/oauth-provider/src/oauthClient/endpoints.test.ts index 02abd452f2..f6af25fccf 100644 --- a/packages/oauth-provider/src/oauthClient/endpoints.test.ts +++ b/packages/oauth-provider/src/oauthClient/endpoints.test.ts @@ -1,4 +1,5 @@ import { createAuthClient } from "better-auth/client"; +import { makeSignature } from "better-auth/crypto"; import { jwt } from "better-auth/plugins/jwt"; import { getTestInstance } from "better-auth/test"; import { describe, expect, it } from "vitest"; @@ -11,7 +12,7 @@ describe("oauthClient", async () => { const baseUrl = "http://localhost:3000"; const rpBaseUrl = "http://localhost:5000"; const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`; - const { signInWithTestUser, customFetchImpl } = await getTestInstance({ + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ baseURL: baseUrl, plugins: [ oauthProvider({ @@ -21,6 +22,7 @@ describe("oauthClient", async () => { oauthAuthServerConfig: true, openidConfig: true, }, + allowPublicClientPrelogin: true, }), jwt(), ], @@ -103,6 +105,27 @@ describe("oauthClient", async () => { }); }); + it("should get public-only information about a client prelogin", async () => { + // Creates mock valid search params + const signedParams = new URLSearchParams({ + exp: `${Math.floor(Date.now() / 1000) + 60}`, + }); + const sig = await makeSignature( + signedParams.toString(), + (auth.options as unknown as { secret: string }).secret, + ); + signedParams.set("sig", sig); + + const client = await authClient.oauth2.publicClientPrelogin({ + client_id: oauthUiClient.client_id, + oauth_query: signedParams.toString(), + }); + expect(client.data).toMatchObject({ + client_id: oauthUiClient.client_id, + ...testUiClientInput, + }); + }); + it("should get user's clients", async () => { const clients = await authClient.oauth2.getClients(); expect(clients?.data?.length).toBe(3); diff --git a/packages/oauth-provider/src/oauthClient/endpoints.ts b/packages/oauth-provider/src/oauthClient/endpoints.ts index 27c3ca2bcb..c681280e77 100644 --- a/packages/oauth-provider/src/oauthClient/endpoints.ts +++ b/packages/oauth-provider/src/oauthClient/endpoints.ts @@ -53,10 +53,11 @@ export async function getClientEndpoint( * This is commonly used to display information on login flow pages. */ export async function getClientPublicEndpoint( - ctx: GenericEndpointContext & { query: { client_id: string } }, + ctx: GenericEndpointContext, opts: OAuthOptions, + clientId: string, ) { - const client = await getClient(ctx, opts, ctx.query.client_id); + const client = await getClient(ctx, opts, clientId); if (!client) { throw new APIError("NOT_FOUND", { error_description: "client not found", diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts index 8cbcfb6332..fcfd2c0ea0 100644 --- a/packages/oauth-provider/src/oauthClient/index.ts +++ b/packages/oauth-provider/src/oauthClient/index.ts @@ -1,5 +1,6 @@ import { createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import * as z from "zod"; +import { publicSessionMiddleware } from "../middleware"; import { createOAuthClientEndpoint } from "../register"; import type { OAuthOptions, Scope } from "../types"; import { SafeUrlSchema } from "../types/zod"; @@ -451,12 +452,35 @@ export const getOAuthClientPublic = (opts: OAuthOptions) => }), metadata: { openapi: { - description: "Gets publically available client fields", + description: "Gets publicly available client fields", }, }, }, async (ctx) => { - return getClientPublicEndpoint(ctx, opts); + const clientId = ctx.query.client_id; + return getClientPublicEndpoint(ctx, opts, clientId); + }, + ); + +export const getOAuthClientPublicPrelogin = (opts: OAuthOptions) => + createAuthEndpoint( + "/oauth2/public-client-prelogin", + { + method: "POST", + use: [publicSessionMiddleware(opts)], + body: z.object({ + client_id: z.string(), + oauth_query: z.string().optional(), + }), + metadata: { + openapi: { + description: "Gets publicly available client fields (prior to login)", + }, + }, + }, + async (ctx) => { + const clientId = ctx.body.client_id; + return getClientPublicEndpoint(ctx, opts, clientId); }, ); diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index 528cd4b669..3c6e5a7467 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -115,6 +115,11 @@ export interface OAuthOptions< scopeExpirations?: { [K in Scopes[number]]?: number | string | Date; }; + /** + * Allows /oauth2/public-client-prelogin endpoint to be + * requestable prior to login via a valid oauth_query. + */ + allowPublicClientPrelogin?: boolean; /** * Allow unauthenticated dynamic client registration. * diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts index 0dc5a61e0a..0311cc05de 100644 --- a/packages/oauth-provider/src/utils/index.ts +++ b/packages/oauth-provider/src/utils/index.ts @@ -62,6 +62,22 @@ export const getJwtPlugin = (ctx: AuthContext) => { const cachedTrustedClients = new TTLCache>(); +export async function verifyOAuthQueryParams( + oauth_query: string, + secret: string, +) { + const queryParams = new URLSearchParams(oauth_query); + const sig = queryParams.get("sig"); + const exp = Number(queryParams.get("exp")); + queryParams.delete("sig"); + const verifySig = await makeSignature(queryParams.toString(), secret); + return ( + !!sig && + constantTimeEqual(sig, verifySig) && + new Date(exp * 1000) >= new Date() + ); +} + /** * Get a client by ID, checking trusted clients first, then database */