Skip to content

Commit fe71931

Browse files
os-zhuangCopilot
andcommitted
feat(plugin-auth): add bridge route to enable/disable OAuth applications
better-auth 1.6.11's stock admin endpoint /admin/oauth2/update-client strips `disabled` from its Zod body schema, so the field cannot be toggled from the HTTP surface even though the column exists and the runtime honours it everywhere (introspect, token, authorize, public-client lookup). Close the gap with a small extension under /api/v1/auth/*: POST /api/v1/auth/admin/oauth2/toggle-disabled The route validates body, requires an admin session (role synthesised by the customSession plugin), and writes through ObjectQL so the sys_oauth_application table — better-auth's mapped `oauthClient` — is updated. All OAuth-application mutations remain auth-routed; no generic data-layer bypass is exposed. Wires two sysadmin row actions on sys_oauth_application: - enable_oauth_application (visible when record.disabled) When upstream adds `disabled` to adminUpdateOAuthClient's schema, this bridge can be deleted and the actions retargeted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8db158a commit fe71931

3 files changed

Lines changed: 168 additions & 15 deletions

File tree

packages/platform-objects/src/identity/sys-oauth-application.object.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,60 @@ export const SysOauthApplication = ObjectSchema.create({
3030
compactLayout: ['name', 'client_id', 'type', 'disabled'],
3131

3232
// Custom actions — all OAuth-application mutations are routed through
33-
// better-auth's `@better-auth/oauth-provider` endpoints rather than the
34-
// generic data layer, so server-side validation, secret hashing, and
35-
// audit hooks all run. The generic `delete` API method is intentionally
36-
// dropped from `apiMethods` below (see `enable.apiMethods`) so the only
37-
// delete path is the better-auth wrapper below.
33+
// better-auth's `@better-auth/oauth-provider` endpoints (and a thin
34+
// ObjectStack-added auth route for the enable/disable toggle) rather
35+
// than the generic data layer, so server-side validation, secret
36+
// hashing, and audit hooks all run. The generic `delete` API method
37+
// is intentionally dropped from `apiMethods` below so the only delete
38+
// path is the better-auth wrapper.
3839
//
39-
// Upstream gap (better-auth 1.6.11): the `/admin/oauth2/update-client`
40-
// endpoint does NOT accept the `disabled` flag in its `update` body
41-
// schema, and no dedicated enable/disable endpoint exists. Until that
42-
// ships upstream, sysadmins cannot toggle `sys_oauth_application.disabled`
43-
// through the UI — `delete` is the only kill-switch. See
44-
// https://github.com/better-auth/better-auth — track the
45-
// `@better-auth/oauth-provider` plugin's adminUpdateOAuthClient schema.
40+
// Upstream gap (better-auth 1.6.11): the stock `/admin/oauth2/update-client`
41+
// endpoint's Zod body schema does NOT accept the `disabled` flag, even
42+
// though the column exists and the runtime honours it. We bridge the
43+
// gap with `POST /api/v1/auth/admin/oauth2/toggle-disabled`, registered
44+
// by plugin-auth, which writes through better-auth's own adapter under
45+
// the auth namespace (no generic data-layer bypass). When upstream
46+
// ships `disabled` support, retarget the enable/disable actions and
47+
// delete the bridge route.
4648
actions: [
49+
{
50+
name: 'disable_oauth_application',
51+
label: 'Disable OAuth Application',
52+
icon: 'pause-circle',
53+
variant: 'secondary',
54+
mode: 'custom',
55+
locations: ['list_item', 'record_header'],
56+
type: 'api',
57+
method: 'POST',
58+
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
59+
confirmText: 'Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.',
60+
successMessage: 'OAuth application disabled',
61+
refreshAfter: true,
62+
visible: '!record.disabled',
63+
bodyExtra: { disabled: true },
64+
params: [
65+
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
66+
],
67+
},
68+
{
69+
name: 'enable_oauth_application',
70+
label: 'Enable OAuth Application',
71+
icon: 'play-circle',
72+
variant: 'primary',
73+
mode: 'custom',
74+
locations: ['list_item', 'record_header'],
75+
type: 'api',
76+
method: 'POST',
77+
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
78+
confirmText: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.',
79+
successMessage: 'OAuth application enabled',
80+
refreshAfter: true,
81+
visible: 'record.disabled',
82+
bodyExtra: { disabled: false },
83+
params: [
84+
{ name: 'client_id', field: 'client_id', defaultFromRow: true, required: true },
85+
],
86+
},
4787
{
4888
name: 'rotate_client_secret',
4989
label: 'Rotate Client Secret',

packages/platform-objects/src/platform-objects.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,17 +159,43 @@ describe('@objectstack/platform-objects', () => {
159159

160160
it('SysOauthApplication routes all mutations through better-auth, not the data layer', () => {
161161
const actions = SysOauthApplication.actions ?? [];
162+
const names = actions.map((a) => a.name).sort();
163+
expect(names).toEqual([
164+
'delete_oauth_application',
165+
'disable_oauth_application',
166+
'enable_oauth_application',
167+
'rotate_client_secret',
168+
]);
169+
162170
const rotate = actions.find((a) => a.name === 'rotate_client_secret');
163171
const del = actions.find((a) => a.name === 'delete_oauth_application');
172+
const disable = actions.find((a) => a.name === 'disable_oauth_application');
173+
const enable = actions.find((a) => a.name === 'enable_oauth_application');
174+
164175
expect(rotate?.target).toBe('/api/v1/auth/oauth2/client/rotate-secret');
165176
expect(rotate?.method).toBe('POST');
166177
expect((rotate?.params ?? []).map((p) => p.field)).toEqual(['client_id']);
178+
167179
expect(del?.target).toBe('/api/v1/auth/oauth2/delete-client');
168180
expect(del?.method).toBe('POST');
169181
expect(del?.mode).toBe('delete');
170-
// Generic CRUD must NOT expose delete — that path is reserved for
171-
// the better-auth-backed action above so OAuth-specific cleanup
172-
// (token revocation, consent invalidation) always runs.
182+
183+
// Enable/disable both hit the ObjectStack-added bridge route on
184+
// /api/v1/auth (since better-auth 1.6.11's stock admin endpoint
185+
// does not accept `disabled` in its update schema). They differ
186+
// only in the static `disabled` body field and the visibility
187+
// predicate, so exactly one is active at any time.
188+
expect(disable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled');
189+
expect(disable?.bodyExtra).toEqual({ disabled: true });
190+
expect((disable?.visible as any)?.source).toBe('!record.disabled');
191+
expect(enable?.target).toBe('/api/v1/auth/admin/oauth2/toggle-disabled');
192+
expect(enable?.bodyExtra).toEqual({ disabled: false });
193+
expect((enable?.visible as any)?.source).toBe('record.disabled');
194+
195+
// Generic CRUD must NOT expose mutating methods — all writes are
196+
// reserved for better-auth wrappers above so OAuth-specific
197+
// invariants (token revocation on delete, consent invalidation,
198+
// disabled checks at runtime) always run.
173199
expect(SysOauthApplication.enable?.apiMethods).not.toContain('delete');
174200
expect(SysOauthApplication.enable?.apiMethods).not.toContain('update');
175201
expect(SysOauthApplication.enable?.apiMethods).not.toContain('create');

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,93 @@ export class AuthPlugin implements Plugin {
433433
}
434434
});
435435

436+
// ────────────────────────────────────────────────────────────────────
437+
// OAuth admin: toggle the `disabled` flag on a registered OAuth client.
438+
//
439+
// Why this lives here (and not as a plain data-layer UPDATE on
440+
// sys_oauth_application): better-auth 1.6.11's stock admin update
441+
// endpoint (`/admin/oauth2/update-client`) does NOT accept `disabled`
442+
// in its Zod body schema, so the field gets silently stripped before
443+
// it reaches `updateClientEndpoint`. The column exists, the runtime
444+
// honours it everywhere (introspect, token, authorize, public-client
445+
// lookup), but no client-facing API can flip it.
446+
//
447+
// We close the gap by writing through better-auth's own adapter under
448+
// the `/api/v1/auth/*` namespace so all OAuth-application mutations
449+
// remain auth-routed (no generic data-layer bypass for the `oauth_client`
450+
// model). When upstream adds `disabled` to `adminUpdateOAuthClient`'s
451+
// schema, this route can be deleted and the sys_oauth_application
452+
// action retargeted to the stock endpoint.
453+
//
454+
// Upstream tracking: https://github.com/better-auth/better-auth
455+
rawApp.post(`${basePath}/admin/oauth2/toggle-disabled`, async (c: any) => {
456+
try {
457+
let body: any = {};
458+
try { body = await c.req.json(); } catch { body = {}; }
459+
const clientId: unknown = body?.client_id;
460+
const disabled: unknown = body?.disabled;
461+
if (typeof clientId !== 'string' || clientId.length === 0) {
462+
return c.json({ success: false, error: { code: 'invalid_request', message: 'client_id is required' } }, 400);
463+
}
464+
if (typeof disabled !== 'boolean') {
465+
return c.json({ success: false, error: { code: 'invalid_request', message: 'disabled must be a boolean' } }, 400);
466+
}
467+
468+
const authApi = await this.authManager!.getApi();
469+
const session = await authApi.getSession({ headers: c.req.raw.headers });
470+
if (!session?.user?.id) {
471+
return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401);
472+
}
473+
// The customSession plugin synthesizes `user.role = 'admin'` for
474+
// platform admins (admin_full_access permission set) and active-org
475+
// owners/admins; anyone else is denied.
476+
if ((session.user as any).role !== 'admin') {
477+
return c.json({ success: false, error: { code: 'forbidden', message: 'Admin role required' } }, 403);
478+
}
479+
480+
// Write through the same ObjectQL data engine that better-auth's
481+
// adapter uses. We target the snake_case table name (`sys_oauth_application`,
482+
// mapped from better-auth's internal `oauthClient` model via
483+
// `auth-schema-config.ts`) because `$context.adapter`'s model-lookup
484+
// helper does not see plugin-provided model names from outside
485+
// better-auth's own endpoint invocation context. This is the same
486+
// physical row the better-auth runtime reads at introspect / token
487+
// / authorize time, so the toggle is fully honoured.
488+
const dataEngine: any = this.authManager!.getDataEngine();
489+
if (!dataEngine) {
490+
return c.json({ success: false, error: { code: 'unavailable', message: 'Data engine unavailable' } }, 503);
491+
}
492+
493+
const existing = await dataEngine.findOne('sys_oauth_application', {
494+
where: { client_id: clientId },
495+
});
496+
if (!existing) {
497+
return c.json({ success: false, error: { code: 'not_found', message: 'OAuth client not found' } }, 404);
498+
}
499+
500+
const updated = await dataEngine.update('sys_oauth_application', {
501+
id: existing.id,
502+
disabled,
503+
updated_at: new Date(Math.floor(Date.now() / 1000) * 1000),
504+
});
505+
if (!updated) {
506+
return c.json({ success: false, error: { code: 'internal', message: 'Unable to update OAuth client' } }, 500);
507+
}
508+
509+
return c.json({
510+
success: true,
511+
data: {
512+
client_id: clientId,
513+
disabled,
514+
},
515+
});
516+
} catch (error) {
517+
const err = error instanceof Error ? error : new Error(String(error));
518+
ctx.logger.error('[AuthPlugin] toggle-disabled failed', err);
519+
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
520+
}
521+
});
522+
436523
// Register wildcard route to forward all auth requests to better-auth.
437524
// better-auth is configured with basePath matching our route prefix, so we
438525
// forward the original request directly — no path rewriting needed.

0 commit comments

Comments
 (0)