Skip to content

Commit b084852

Browse files
committed
feat(auth): add JWKS model and integrate JWT plugin for key management
1 parent 6b07dbd commit b084852

8 files changed

Lines changed: 158 additions & 0 deletions

File tree

packages/platform-objects/src/identity/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ export { SysOauthApplication } from './sys-oauth-application.object.js';
3030
export { SysOauthAccessToken } from './sys-oauth-access-token.object.js';
3131
export { SysOauthRefreshToken } from './sys-oauth-refresh-token.object.js';
3232
export { SysOauthConsent } from './sys-oauth-consent.object.js';
33+
export { SysJwks } from './sys-jwks.object.js';
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_jwks — JWKS (JSON Web Key Set) key pair store
7+
*
8+
* Backed by better-auth's `jwt` plugin. Each row is a single asymmetric
9+
* key pair used to sign and verify JWTs (id_tokens, JWT access tokens)
10+
* issued by this ObjectStack server when it acts as an OAuth/OIDC IdP.
11+
*
12+
* The plugin rotates keys automatically — older rows are kept until
13+
* `expires_at` so existing tokens can still be verified.
14+
*
15+
* @namespace sys
16+
*/
17+
export const SysJwks = ObjectSchema.create({
18+
name: 'sys_jwks',
19+
label: 'JWKS Key',
20+
pluralLabel: 'JWKS Keys',
21+
icon: 'key',
22+
isSystem: true,
23+
description: 'Asymmetric key pairs used to sign and verify issued JWTs',
24+
compactLayout: ['id', 'created_at', 'expires_at'],
25+
26+
fields: {
27+
id: Field.text({
28+
label: 'Key ID',
29+
required: true,
30+
readonly: true,
31+
description: 'JWK `kid` value',
32+
}),
33+
34+
public_key: Field.textarea({
35+
label: 'Public Key',
36+
required: true,
37+
description: 'JSON-serialized JWK public key',
38+
}),
39+
40+
private_key: Field.textarea({
41+
label: 'Private Key',
42+
required: true,
43+
description: 'JSON-serialized JWK private key (encrypted at rest)',
44+
}),
45+
46+
created_at: Field.datetime({
47+
label: 'Created At',
48+
required: true,
49+
defaultValue: 'NOW()',
50+
readonly: true,
51+
}),
52+
53+
expires_at: Field.datetime({
54+
label: 'Expires At',
55+
required: false,
56+
description: 'When the key may no longer be used to verify tokens',
57+
}),
58+
},
59+
60+
enable: {
61+
trackHistory: false,
62+
searchable: false,
63+
apiEnabled: false,
64+
apiMethods: [],
65+
trash: false,
66+
mru: false,
67+
},
68+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
buildTwoFactorPluginSchema,
2222
buildOauthProviderPluginSchema,
2323
buildDeviceAuthorizationPluginSchema,
24+
buildJwtPluginSchema,
2425
} from './auth-schema-config.js';
2526

2627
/**
@@ -303,6 +304,13 @@ export class AuthManager {
303304
// models — see `buildOauthProviderPluginSchema()` for the snake_case
304305
// mappings to ObjectStack's `sys_oauth_*` tables.
305306
if (pluginConfig?.oidcProvider) {
307+
// The new @better-auth/oauth-provider package requires the `jwt`
308+
// plugin (used to sign id_tokens / JWT access tokens). Register it
309+
// automatically — it is otherwise an internal implementation detail
310+
// and forcing every consumer to opt in would be poor DX.
311+
const { jwt } = await import('better-auth/plugins');
312+
plugins.push(jwt({ schema: buildJwtPluginSchema() }));
313+
306314
const { oauthProvider } = await import('@better-auth/oauth-provider');
307315
const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, '');
308316
plugins.push(oauthProvider({

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,44 @@ export class AuthPlugin implements Plugin {
317317
}
318318
});
319319

320+
// OIDC / OAuth 2.0 Authorization Server Metadata (RFC 8414) and
321+
// OpenID Connect Discovery 1.0 require the well-known documents to be
322+
// served from the **root** of the issuer URL — not under our auth
323+
// basePath. `@better-auth/oauth-provider` ships dedicated helpers for
324+
// this case (`oauthProviderAuthServerMetadata` /
325+
// `oauthProviderOpenIdConfigMetadata`) which we mount here so external
326+
// OIDC clients can discover the IdP at the canonical paths.
327+
if (this.options.plugins?.oidcProvider) {
328+
void this.registerOidcDiscoveryRoutes(rawApp, ctx).catch((error) => {
329+
ctx.logger.error('Failed to register OIDC discovery routes', error as Error);
330+
});
331+
}
332+
320333
ctx.logger.info(`Auth routes registered: All requests under ${basePath}/* forwarded to better-auth`);
321334
}
335+
336+
/**
337+
* Mount the OIDC / OAuth 2.0 well-known discovery documents at the root
338+
* URL. Required by RFC 8414 §3 and OpenID Connect Discovery 1.0 §4 — the
339+
* documents must live at `/.well-known/{oauth-authorization-server,openid-configuration}`
340+
* relative to the issuer, not under the auth basePath.
341+
*/
342+
private async registerOidcDiscoveryRoutes(rawApp: any, ctx: PluginContext): Promise<void> {
343+
const auth = await this.authManager!.getAuthInstance();
344+
const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import(
345+
'@better-auth/oauth-provider'
346+
);
347+
348+
const authServerHandler = oauthProviderAuthServerMetadata(auth as any);
349+
const openidConfigHandler = oauthProviderOpenIdConfigMetadata(auth as any);
350+
351+
rawApp.get('/.well-known/oauth-authorization-server', (c: any) => authServerHandler(c.req.raw));
352+
rawApp.get('/.well-known/openid-configuration', (c: any) => openidConfigHandler(c.req.raw));
353+
354+
ctx.logger.info(
355+
'OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}',
356+
);
357+
}
322358
}
323359

324360

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,45 @@ export function buildOrganizationPluginSchema() {
532532
};
533533
}
534534

535+
// ---------------------------------------------------------------------------
536+
// JWT plugin – jwks table
537+
// ---------------------------------------------------------------------------
538+
539+
/**
540+
* better-auth `jwt` plugin `jwks` model mapping.
541+
*
542+
* The JWT plugin maintains a small set of rotating asymmetric key pairs
543+
* used to sign and verify issued JWTs (id_tokens for OIDC, JWT access
544+
* tokens). It is required by the `@better-auth/oauth-provider` plugin.
545+
*
546+
* | camelCase (better-auth) | snake_case (ObjectStack) |
547+
* |:------------------------|:-------------------------|
548+
* | publicKey | public_key |
549+
* | privateKey | private_key |
550+
* | createdAt | created_at |
551+
* | expiresAt | expires_at |
552+
*/
553+
export const AUTH_JWKS_SCHEMA = {
554+
modelName: SystemObjectName.JWKS, // 'sys_jwks'
555+
fields: {
556+
publicKey: 'public_key',
557+
privateKey: 'private_key',
558+
createdAt: 'created_at',
559+
expiresAt: 'expires_at',
560+
},
561+
} as const;
562+
563+
/**
564+
* Builds the `schema` option for better-auth's `jwt()` plugin.
565+
*
566+
* @returns An object suitable for `jwt({ schema: … })`
567+
*/
568+
export function buildJwtPluginSchema() {
569+
return {
570+
jwks: AUTH_JWKS_SCHEMA,
571+
};
572+
}
573+
535574
// ---------------------------------------------------------------------------
536575
// Helper: build OAuth provider plugin schema option
537576
// ---------------------------------------------------------------------------

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
SysDeviceCode,
1515
SysInvitation,
1616
SysMember,
17+
SysJwks,
1718
SysOauthAccessToken,
1819
SysOauthApplication,
1920
SysOauthConsent,
@@ -49,6 +50,7 @@ export const authIdentityObjects: any[] = [
4950
SysOauthAccessToken,
5051
SysOauthRefreshToken,
5152
SysOauthConsent,
53+
SysJwks,
5254
SysDeviceCode,
5355
];
5456

packages/spec/src/system/constants/system-names.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe('SystemObjectName', () => {
2626
expect(SystemObjectName.OAUTH_ACCESS_TOKEN).toBe('sys_oauth_access_token');
2727
expect(SystemObjectName.OAUTH_REFRESH_TOKEN).toBe('sys_oauth_refresh_token');
2828
expect(SystemObjectName.OAUTH_CONSENT).toBe('sys_oauth_consent');
29+
expect(SystemObjectName.DEVICE_CODE).toBe('sys_device_code');
30+
expect(SystemObjectName.JWKS).toBe('sys_jwks');
2931
expect(SystemObjectName.USER_PREFERENCE).toBe('sys_user_preference');
3032
expect(SystemObjectName.ROLE).toBe('sys_role');
3133
expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set');

packages/spec/src/system/constants/system-names.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export const SystemObjectName = {
5252
OAUTH_CONSENT: 'sys_oauth_consent',
5353
/** Authentication: pending device-authorization (RFC 8628) request */
5454
DEVICE_CODE: 'sys_device_code',
55+
/** Authentication: JWKS key pair used to sign OIDC ID tokens / JWT access tokens */
56+
JWKS: 'sys_jwks',
5557
/** Authentication: user preferences (theme, locale, etc.) */
5658
USER_PREFERENCE: 'sys_user_preference',
5759
/** Security: role definition for RBAC */

0 commit comments

Comments
 (0)