Skip to content

Commit bc5bd71

Browse files
committed
feat(auth): update OAuth application handling and consent flow with improved parameter management
1 parent b084852 commit bc5bd71

5 files changed

Lines changed: 82 additions & 71 deletions

File tree

apps/account/src/hooks/useOAuthApplications.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export function useRegisterOAuthApplication() {
7171

7272
const register = useCallback(
7373
async (req: {
74-
client_name: string;
74+
name?: string;
75+
client_name?: string;
7576
redirect_uris: string[];
7677
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post';
7778
grant_types?: string[];
@@ -113,12 +114,12 @@ export function useDeleteOAuthApplication() {
113114
const [error, setError] = useState<Error | null>(null);
114115

115116
const remove = useCallback(
116-
async (id: string) => {
117+
async (clientId: string) => {
117118
if (!client?.oauth?.applications?.delete) throw new Error('Client not ready');
118119
setDeleting(true);
119120
setError(null);
120121
try {
121-
return await client.oauth.applications.delete(id);
122+
return await client.oauth.applications.delete(clientId);
122123
} catch (err) {
123124
setError(err as Error);
124125
throw err;
@@ -141,7 +142,7 @@ export function useOAuthConsent() {
141142
const [error, setError] = useState<Error | null>(null);
142143

143144
const submit = useCallback(
144-
async (req: { accept: boolean; consent_code?: string }) => {
145+
async (req: { accept: boolean; scope?: string; oauth_query?: string }) => {
145146
if (!client?.oauth?.consent) throw new Error('Client not ready');
146147
setSubmitting(true);
147148
setError(null);

apps/account/src/routes/account.oauth-applications.$clientId.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function OAuthApplicationDetailPage() {
7171
const handleDelete = async () => {
7272
if (!app) return;
7373
try {
74-
await remove(app.id);
74+
await remove(app.client_id);
7575
toast({ title: 'OAuth application deleted' });
7676
await reload();
7777
navigate({ to: '/account/oauth-applications' });

apps/account/src/routes/account.oauth-applications.index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function OAuthApplicationsListPage() {
3939
const handleDelete = async () => {
4040
if (!pendingDelete) return;
4141
try {
42-
await remove(pendingDelete.id);
42+
await remove(pendingDelete.client_id);
4343
toast({ title: 'OAuth application deleted' });
4444
setPendingDelete(null);
4545
await reload();

apps/account/src/routes/oauth.consent.tsx

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
/**
44
* /oauth/consent — OAuth/OIDC consent screen.
55
*
6-
* The better-auth `oidc-provider` plugin redirects unauthorized users here
7-
* with the following query parameters when an OAuth client requests
8-
* consent:
9-
* - consent_code — opaque token identifying the pending request
10-
* - client_id — the requesting application
11-
* - scope — space-separated requested scopes
6+
* The `@better-auth/oauth-provider` plugin redirects the user here when an
7+
* OAuth client requests consent. The full query string (including the
8+
* signed `sig`/`exp` carrier) is the canonical authorization request and
9+
* must be forwarded back to the consent endpoint as `oauth_query` so the
10+
* server can verify and re-issue an authorization code.
1211
*
1312
* After the user accepts or denies, we POST to `/api/v1/auth/oauth2/consent`
14-
* which redirects the user back to the client's redirect_uri.
13+
* which returns `{ redirect_uri }` pointing at the OAuth client's callback.
1514
*/
1615

17-
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
16+
import { createFileRoute, useNavigate } from '@tanstack/react-router';
1817
import { useEffect, useState } from 'react';
1918
import { Check, KeyRound, X } from 'lucide-react';
2019
import { useClient } from '@objectstack/client-react';
@@ -24,30 +23,31 @@ import { toast } from '@/hooks/use-toast';
2423
import { useSession } from '@/hooks/useSession';
2524
import { useOAuthConsent } from '@/hooks/useOAuthApplications';
2625

27-
interface ConsentSearch {
28-
consent_code?: string;
29-
client_id?: string;
30-
scope?: string;
31-
}
32-
3326
export const Route = createFileRoute('/oauth/consent')({
34-
validateSearch: (s: Record<string, unknown>): ConsentSearch => ({
35-
consent_code: typeof s.consent_code === 'string' ? s.consent_code : undefined,
36-
client_id: typeof s.client_id === 'string' ? s.client_id : undefined,
37-
scope: typeof s.scope === 'string' ? s.scope : undefined,
38-
}),
27+
// Accept arbitrary query params — the consent page receives the full
28+
// authorization-request query string and forwards it back as
29+
// `oauth_query` to the consent endpoint.
30+
validateSearch: (s: Record<string, unknown>) => s,
3931
component: OAuthConsentPage,
4032
});
4133

4234
function OAuthConsentPage() {
43-
const search = useSearch({ from: '/oauth/consent' });
4435
const navigate = useNavigate();
4536
const client = useClient() as any;
4637
const { user, loading: sessionLoading } = useSession();
4738
const { submit, submitting } = useOAuthConsent();
4839

4940
const [clientInfo, setClientInfo] = useState<{ name?: string; icon?: string } | null>(null);
5041

42+
// Read raw query directly so we can forward it verbatim. The router's
43+
// typed `useSearch` would coerce / re-serialize and risks breaking the
44+
// signature on `sig=`.
45+
const rawSearch = typeof window !== 'undefined' ? window.location.search : '';
46+
const oauthQuery = rawSearch.startsWith('?') ? rawSearch.slice(1) : rawSearch;
47+
const params = new URLSearchParams(oauthQuery);
48+
const clientId = params.get('client_id') ?? undefined;
49+
const scope = params.get('scope') ?? '';
50+
5151
// If unauthenticated, bounce to login with a return-to that brings the
5252
// user back here once signed in.
5353
useEffect(() => {
@@ -60,30 +60,27 @@ function OAuthConsentPage() {
6060

6161
// Best-effort lookup of the client app's display name + icon.
6262
useEffect(() => {
63-
if (!search.client_id || !client?.oauth?.applications?.get) return;
63+
if (!clientId || !client?.oauth?.applications?.getPublic) return;
6464
let cancelled = false;
65-
client.oauth.applications.get(search.client_id).then(
65+
client.oauth.applications.getPublic(clientId).then(
6666
(res: any) => {
6767
if (cancelled) return;
6868
const data = res?.data ?? res;
69-
setClientInfo({ name: data?.name, icon: data?.icon });
69+
setClientInfo({ name: data?.name ?? data?.client_name, icon: data?.icon ?? data?.logo_uri });
7070
},
7171
() => {},
7272
);
7373
return () => {
7474
cancelled = true;
7575
};
76-
}, [client, search.client_id]);
76+
}, [client, clientId]);
7777

78-
const scopes = (search.scope ?? '').split(/\s+/).filter(Boolean);
78+
const scopes = scope.split(/\s+/).filter(Boolean);
7979

8080
const handleDecision = async (accept: boolean) => {
8181
try {
82-
const res: any = await submit({
83-
accept,
84-
...(search.consent_code ? { consent_code: search.consent_code } : {}),
85-
});
86-
const redirect = res?.redirectURI ?? res?.redirect_uri ?? res?.url;
82+
const res: any = await submit({ accept, oauth_query: oauthQuery });
83+
const redirect = res?.redirect_uri ?? res?.redirectURI ?? res?.url;
8784
if (redirect) {
8885
window.location.href = redirect;
8986
return;
@@ -102,7 +99,7 @@ function OAuthConsentPage() {
10299
}
103100
};
104101

105-
const appName = clientInfo?.name ?? search.client_id ?? 'an application';
102+
const appName = clientInfo?.name ?? clientId ?? 'an application';
106103

107104
return (
108105
<div className="flex min-h-svh w-full flex-1 items-center justify-center bg-background px-4">

packages/client/src/index.ts

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,37 +1001,44 @@ export class ObjectStackClient {
10011001
};
10021002

10031003
/**
1004-
* OAuth / OpenID Connect Provider — admin endpoints exposed by better-auth's
1005-
* `oidc-provider` plugin (when enabled on the server). Lets users register
1006-
* their own OAuth client applications, list them, and revoke them.
1004+
* OAuth / OpenID Connect Provider — admin endpoints exposed by
1005+
* `@better-auth/oauth-provider` (when enabled on the server). Lets users
1006+
* register their own OAuth client applications, list them, and revoke them.
10071007
*
10081008
* All endpoints are mounted under the auth route, e.g. `/api/v1/auth/oauth2/*`.
10091009
*/
10101010
oauth = {
10111011
applications: {
10121012
/**
10131013
* Register a new OAuth client application.
1014-
* POST /api/v1/auth/oauth2/register
1014+
* POST /api/v1/auth/oauth2/create-client (authenticated)
10151015
*
10161016
* Returns the freshly-issued `client_id` and `client_secret`.
10171017
* The secret is only returned at creation time — store it securely.
10181018
*/
10191019
register: async (req: {
1020-
client_name: string;
1020+
client_name?: string;
1021+
name?: string;
10211022
redirect_uris: string[];
10221023
token_endpoint_auth_method?: 'none' | 'client_secret_basic' | 'client_secret_post';
10231024
grant_types?: string[];
10241025
response_types?: string[];
10251026
client_uri?: string;
10261027
logo_uri?: string;
10271028
scope?: string;
1029+
scopes?: string[];
10281030
contacts?: string[];
10291031
tos_uri?: string;
10301032
policy_uri?: string;
10311033
metadata?: Record<string, unknown>;
10321034
}) => {
10331035
const route = this.getRoute('auth');
1034-
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/register`, {
1036+
// The new oauth-provider package exposes `/oauth2/create-client`
1037+
// (authenticated dynamic registration). The legacy `/oauth2/register`
1038+
// endpoint is now disabled by default for security and only
1039+
// available when the server explicitly opts in via the
1040+
// `allowUnauthenticatedClientRegistration` option.
1041+
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/create-client`, {
10351042
method: 'POST',
10361043
body: JSON.stringify(req),
10371044
});
@@ -1040,54 +1047,57 @@ export class ObjectStackClient {
10401047

10411048
/**
10421049
* Get a single OAuth application by its `client_id`.
1043-
* GET /api/v1/auth/oauth2/client/:id
1050+
* GET /api/v1/auth/oauth2/get-client?client_id=...
10441051
*/
10451052
get: async (clientId: string) => {
10461053
const route = this.getRoute('auth');
10471054
const res = await this.fetch(
1048-
`${this.baseUrl}${route}/oauth2/client/${encodeURIComponent(clientId)}`,
1055+
`${this.baseUrl}${route}/oauth2/get-client?client_id=${encodeURIComponent(clientId)}`,
1056+
);
1057+
return res.json();
1058+
},
1059+
1060+
/**
1061+
* Get a single OAuth application's public fields (no auth required
1062+
* once the user has signed in). Used by the consent screen.
1063+
* GET /api/v1/auth/oauth2/public-client?client_id=...
1064+
*/
1065+
getPublic: async (clientId: string) => {
1066+
const route = this.getRoute('auth');
1067+
const res = await this.fetch(
1068+
`${this.baseUrl}${route}/oauth2/public-client?client_id=${encodeURIComponent(clientId)}`,
10491069
);
10501070
return res.json();
10511071
},
10521072

10531073
/**
10541074
* List OAuth applications visible to the current user.
10551075
*
1056-
* better-auth doesn't expose a list endpoint yet — we query the
1057-
* underlying `sys_oauth_application` table via the data API. In
1058-
* production deployments, row-level security on this system table
1059-
* should restrict rows to those owned by the current user; in
1060-
* single-project / local mode every authenticated user sees the
1061-
* full list.
1076+
* Uses `@better-auth/oauth-provider`'s `/oauth2/get-clients` endpoint
1077+
* which returns clients owned by the current user (and their
1078+
* organization, if applicable).
10621079
*/
10631080
list: async () => {
1064-
const route = this.getRoute('data');
1065-
const params = new URLSearchParams({ sort: '-created_at' });
1066-
const res = await this.fetch(
1067-
`${this.baseUrl}${route}/sys_oauth_application?${params.toString()}`,
1068-
);
1081+
const route = this.getRoute('auth');
1082+
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/get-clients`);
10691083
const data = await res.json();
1070-
const items =
1071-
data?.records ??
1072-
data?.items ??
1073-
data?.data?.records ??
1074-
data?.data?.items ??
1075-
[];
1084+
const items = Array.isArray(data) ? data : data?.clients ?? data?.data ?? [];
10761085
return { applications: items as Array<Record<string, any>> };
10771086
},
10781087

10791088
/**
1080-
* Delete an OAuth application by its row id (not client_id).
1089+
* Delete an OAuth application by its `client_id`.
1090+
* POST /api/v1/auth/oauth2/delete-client
10811091
*
10821092
* Tokens and consents referencing the client cascade-delete via the
10831093
* better-auth schema's `onDelete: cascade` foreign keys.
10841094
*/
1085-
delete: async (id: string) => {
1086-
const route = this.getRoute('data');
1087-
const res = await this.fetch(
1088-
`${this.baseUrl}${route}/sys_oauth_application/${encodeURIComponent(id)}`,
1089-
{ method: 'DELETE' },
1090-
);
1095+
delete: async (clientId: string) => {
1096+
const route = this.getRoute('auth');
1097+
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/delete-client`, {
1098+
method: 'POST',
1099+
body: JSON.stringify({ client_id: clientId }),
1100+
});
10911101
return res.json();
10921102
},
10931103
},
@@ -1096,9 +1106,12 @@ export class ObjectStackClient {
10961106
* Submit the user's decision to a pending consent request.
10971107
* POST /api/v1/auth/oauth2/consent
10981108
*
1099-
* Called by the consent screen after the user accepts or denies.
1109+
* Called by the consent screen after the user accepts or denies. The
1110+
* `oauth_query` is the raw query string of the consent page URL — it
1111+
* carries the signed authorization request that the consent endpoint
1112+
* verifies before issuing the authorization code.
11001113
*/
1101-
consent: async (req: { accept: boolean; consent_code?: string }) => {
1114+
consent: async (req: { accept: boolean; scope?: string; oauth_query?: string }) => {
11021115
const route = this.getRoute('auth');
11031116
const res = await this.fetch(`${this.baseUrl}${route}/oauth2/consent`, {
11041117
method: 'POST',

0 commit comments

Comments
 (0)