From 758bf9eda279aa6afe064a7c8596d7f7bf8ae87d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:37:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(plugin-auth,platform-objects):=20close?= =?UTF-8?q?=20@better-auth/oauth-provider=201.7=20schema=20drift=20?= =?UTF-8?q?=E2=80=94=20restore=20platform=20SSO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The better-auth 1.7.0-rc.1 bump (4ab16f8c5 "pnpm upgrade") changed the oauth-provider model schemas, but the sys_oauth_* platform objects and the camelCase→snake_case fieldName mappings in auth-schema-config.ts were never updated. Token exchange then 500s with `table sys_oauth_access_token has no column named authorizationCodeId`, breaking platform SSO ("Continue with ObjectStack") end-to-end for every environment login — on SQLite AND Postgres control planes alike. - sys_oauth_access_token / sys_oauth_refresh_token: add authorization_code_id (indexed), resources, requested_user_info_claims, confirmation; access adds revoked; refresh adds rotated_at, rotation_replay_response, rotation_replay_expires_at. - sys_oauth_consent: add resources, requested_user_info_claims. - sys_oauth_application: add jwks, jwks_uri, backchannel_logout_uri, backchannel_logout_session_required, dpop_bound_access_tokens (1.7 DCR writes dpopBoundAccessTokens with a default — same crash class). - New objects for the three models 1.7 introduced: sys_oauth_resource, sys_oauth_client_resource (RFC 8707 resource indicators, used by the MCP OAuth track), sys_oauth_client_assertion (RFC 7523 jti replay prevention); registered in the auth manifest, SystemObjectName, i18n extract config, and buildOauthProviderPluginSchema(). - Mappings extended for every camelCase field; regenerated the platform-objects translation bundles (additive only). - NEW oauth-provider-schema-parity.test.ts: iterates the INSTALLED plugin's declared schema and fails when any writable column is missing from the platform objects — the next better-auth bump that adds fields breaks at test time, not in production. (This test is what surfaced the three brand-new 1.7 models above.) Companion changes: cloud registers the ≥1.7 social-flow callback format (/api/v1/auth/callback/) alongside the legacy one, and @object-ui/auth signs OIDC providers in via POST /sign-in/social. Co-Authored-By: Claude Fable 5 --- .changeset/oauth-provider-17-schema-drift.md | 26 +++ .../scripts/i18n-extract.config.ts | 6 + .../translations/bundle-ownership.test.ts | 3 +- .../apps/translations/en.objects.generated.ts | 177 ++++++++++++++++++ .../translations/es-ES.objects.generated.ts | 177 ++++++++++++++++++ .../translations/ja-JP.objects.generated.ts | 177 ++++++++++++++++++ .../translations/zh-CN.objects.generated.ts | 177 ++++++++++++++++++ .../platform-objects/src/identity/index.ts | 3 + .../identity/sys-oauth-access-token.object.ts | 32 ++++ .../identity/sys-oauth-application.object.ts | 36 ++++ .../sys-oauth-client-assertion.object.ts | 55 ++++++ .../sys-oauth-client-resource.object.ts | 79 ++++++++ .../src/identity/sys-oauth-consent.object.ts | 12 ++ .../sys-oauth-refresh-token.object.ts | 44 +++++ .../src/identity/sys-oauth-resource.object.ts | 145 ++++++++++++++ .../plugin-auth/src/auth-schema-config.ts | 168 ++++++++++++++--- packages/plugins/plugin-auth/src/manifest.ts | 6 + .../src/oauth-provider-schema-parity.test.ts | 84 +++++++++ .../spec/src/system/constants/system-names.ts | 6 + 19 files changed, 1387 insertions(+), 26 deletions(-) create mode 100644 .changeset/oauth-provider-17-schema-drift.md create mode 100644 packages/platform-objects/src/identity/sys-oauth-client-assertion.object.ts create mode 100644 packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts create mode 100644 packages/platform-objects/src/identity/sys-oauth-resource.object.ts create mode 100644 packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts diff --git a/.changeset/oauth-provider-17-schema-drift.md b/.changeset/oauth-provider-17-schema-drift.md new file mode 100644 index 0000000000..776e21c54b --- /dev/null +++ b/.changeset/oauth-provider-17-schema-drift.md @@ -0,0 +1,26 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/plugin-auth': patch +'@objectstack/spec': patch +--- + +Close the `@better-auth/oauth-provider` 1.7 schema drift that broke platform +SSO (token exchange 500: `table sys_oauth_access_token has no column named +authorizationCodeId`). + +- `sys_oauth_access_token` / `sys_oauth_refresh_token`: add + `authorization_code_id`, `resources`, `requested_user_info_claims`, + `confirmation` (+ access-token `revoked`; + refresh-token `rotated_at`, + `rotation_replay_response`, `rotation_replay_expires_at`). +- `sys_oauth_consent`: add `resources`, `requested_user_info_claims`. +- `sys_oauth_application`: add `jwks`, `jwks_uri`, `backchannel_logout_uri`, + `backchannel_logout_session_required`, `dpop_bound_access_tokens`. +- New platform objects for the three models 1.7 introduced: + `sys_oauth_resource`, `sys_oauth_client_resource`, + `sys_oauth_client_assertion` (RFC 8707 resource indicators + RFC 7523 + client-assertion replay prevention), registered in the auth manifest and + mapped in `buildOauthProviderPluginSchema()`. +- All camelCase→snake_case `fieldName` mappings extended accordingly, and a + new parity test (`oauth-provider-schema-parity.test.ts`) fails the build + whenever a future better-auth bump introduces model fields our objects or + mappings don't cover. diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index ffe25c4830..a909e590a1 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -47,6 +47,9 @@ import { SysOauthAccessToken, SysOauthRefreshToken, SysOauthConsent, + SysOauthResource, + SysOauthClientResource, + SysOauthClientAssertion, SysJwks, } from '../src/identity/index.js'; @@ -144,6 +147,9 @@ export default defineStack({ SysOauthAccessToken, SysOauthRefreshToken, SysOauthConsent, + SysOauthResource, + SysOauthClientResource, + SysOauthClientAssertion, SysJwks, // Security: RBAC moved to @objectstack/plugin-security, sharing to diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts index a920cea12e..a198fd3629 100644 --- a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -22,7 +22,8 @@ const OWNED_OBJECTS = new Set([ 'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit', 'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code', 'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token', - 'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_jwks', + 'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_oauth_resource', + 'sys_oauth_client_resource', 'sys_oauth_client_assertion', 'sys_jwks', // audit / messaging-adjacent (still owned here) 'sys_notification', 'sys_attachment', 'sys_email', 'sys_email_template', 'sys_saved_report', 'sys_report_schedule', 'sys_job', 'sys_job_run', 'sys_job_queue', diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 75e19805f8..1d3dfa909b 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1187,6 +1187,18 @@ export const enObjects: NonNullable = { label: "Subject Type", help: "OIDC subject type (e.g. public, pairwise)" }, + jwks: { + label: "JWKS", + help: "Client JSON Web Key Set (for private_key_jwt / signed-request verification)" + }, + jwks_uri: { + label: "JWKS URI", + help: "URL of the client JSON Web Key Set" + }, + dpop_bound_access_tokens: { + label: "DPoP-bound Access Tokens", + help: "Require access tokens issued to this client to be DPoP-bound (RFC 9449)" + }, disabled: { label: "Disabled" }, @@ -1198,6 +1210,14 @@ export const enObjects: NonNullable = { label: "Enable End Session", help: "Allow the client to call the OIDC end-session endpoint" }, + backchannel_logout_uri: { + label: "Back-channel Logout URI", + help: "OIDC back-channel logout endpoint of the client" + }, + backchannel_logout_session_required: { + label: "Back-channel Logout Session Required", + help: "Whether the back-channel logout token must include a sid claim" + }, software_id: { label: "Software ID" }, @@ -1312,6 +1332,18 @@ export const enObjects: NonNullable = { label: "Reference ID", help: "Caller-supplied correlation identifier" }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "Scopes", help: "JSON-serialized list of scopes granted to this token" @@ -1321,6 +1353,14 @@ export const enObjects: NonNullable = { }, created_at: { label: "Created At" + }, + revoked: { + label: "Revoked At", + help: "Timestamp at which this access token was revoked" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1352,6 +1392,18 @@ export const enObjects: NonNullable = { label: "Reference ID", help: "Caller-supplied correlation identifier" }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token chain originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "Scopes", help: "JSON-serialized list of scopes granted to this token" @@ -1366,9 +1418,25 @@ export const enObjects: NonNullable = { label: "Revoked At", help: "Timestamp at which this refresh token was revoked" }, + rotated_at: { + label: "Rotated At", + help: "Timestamp at which this token was rotated (superseded by a new row)" + }, + rotation_replay_response: { + label: "Rotation Replay Response", + help: "Cached token response replayed when the old token is re-presented within the reuse interval" + }, + rotation_replay_expires_at: { + label: "Rotation Replay Expires At", + help: "End of the post-rotation reuse interval during which the replay response is served" + }, auth_time: { label: "Auth Time", help: "When the user originally authenticated for this token chain" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1392,6 +1460,14 @@ export const enObjects: NonNullable = { label: "Reference ID", help: "Caller-supplied correlation identifier" }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators the consent covers" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims the user consented to expose" + }, scopes: { label: "Scopes", help: "JSON-serialized list of scopes the user consented to" @@ -1404,6 +1480,107 @@ export const enObjects: NonNullable = { } } }, + sys_oauth_resource: { + label: "OAuth Resource", + pluralLabel: "OAuth Resources", + description: "Registered OAuth protected resources (RFC 8707 resource indicators)", + fields: { + id: { + label: "ID" + }, + identifier: { + label: "Identifier", + help: "Resource indicator URI presented in the RFC 8707 resource parameter" + }, + name: { + label: "Name" + }, + access_token_ttl: { + label: "Access Token TTL", + help: "Access-token lifetime in seconds for this resource (overrides the server default)" + }, + refresh_token_ttl: { + label: "Refresh Token TTL", + help: "Refresh-token lifetime in seconds for this resource (overrides the server default)" + }, + signing_algorithm: { + label: "Signing Algorithm", + help: "JWS algorithm used to sign access tokens for this resource" + }, + signing_key_id: { + label: "Signing Key ID", + help: "Key id (kid) used to sign access tokens for this resource" + }, + allowed_scopes: { + label: "Allowed Scopes", + help: "JSON-serialized list of scopes clients may request for this resource" + }, + custom_claims: { + label: "Custom Claims", + help: "JSON object of extra claims stamped on access tokens for this resource" + }, + dpop_bound_access_tokens_required: { + label: "DPoP Required", + help: "Require access tokens for this resource to be DPoP-bound (RFC 9449)" + }, + disabled: { + label: "Disabled" + }, + policy_version: { + label: "Policy Version", + help: "Monotonic version of the resource token policy" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional resource metadata" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + } + } + }, + sys_oauth_client_resource: { + label: "OAuth Client Resource", + pluralLabel: "OAuth Client Resources", + description: "Grants allowing an OAuth client to request tokens for a protected resource", + fields: { + id: { + label: "ID" + }, + client_id: { + label: "Client ID", + help: "Foreign key to sys_oauth_application.client_id" + }, + resource_id: { + label: "Resource ID", + help: "Foreign key to sys_oauth_resource.identifier" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional grant metadata" + }, + created_at: { + label: "Created At" + } + } + }, + sys_oauth_client_assertion: { + label: "OAuth Client Assertion", + pluralLabel: "OAuth Client Assertions", + description: "Consumed OAuth client-assertion JTIs (RFC 7523 replay prevention)", + fields: { + id: { + label: "ID" + }, + expires_at: { + label: "Expires At", + help: "Assertion expiry — rows past this instant are safe to prune" + } + } + }, sys_jwks: { label: "JWKS Key", pluralLabel: "JWKS Keys", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 4a16e3b4a6..3c57a464cf 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1187,6 +1187,18 @@ export const esESObjects: NonNullable = { label: "Tipo de sujeto", help: "Tipo de sujeto OIDC (p. ej. public, pairwise)." }, + jwks: { + label: "JWKS", + help: "Client JSON Web Key Set (for private_key_jwt / signed-request verification)" + }, + jwks_uri: { + label: "JWKS URI", + help: "URL of the client JSON Web Key Set" + }, + dpop_bound_access_tokens: { + label: "DPoP-bound Access Tokens", + help: "Require access tokens issued to this client to be DPoP-bound (RFC 9449)" + }, disabled: { label: "Deshabilitado" }, @@ -1198,6 +1210,14 @@ export const esESObjects: NonNullable = { label: "Habilitar finalización de sesión", help: "Permitir que el cliente llame al endpoint OIDC de finalización de sesión." }, + backchannel_logout_uri: { + label: "Back-channel Logout URI", + help: "OIDC back-channel logout endpoint of the client" + }, + backchannel_logout_session_required: { + label: "Back-channel Logout Session Required", + help: "Whether the back-channel logout token must include a sid claim" + }, software_id: { label: "ID de software" }, @@ -1312,6 +1332,18 @@ export const esESObjects: NonNullable = { label: "ID de referencia", help: "Identificador de correlación proporcionado por quien llama." }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "Ámbitos", help: "Lista serializada en JSON de ámbitos concedidos a este token." @@ -1321,6 +1353,14 @@ export const esESObjects: NonNullable = { }, created_at: { label: "Creado el" + }, + revoked: { + label: "Revoked At", + help: "Timestamp at which this access token was revoked" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1352,6 +1392,18 @@ export const esESObjects: NonNullable = { label: "ID de referencia", help: "Identificador de correlación proporcionado por quien llama." }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token chain originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "Ámbitos", help: "Lista serializada en JSON de ámbitos concedidos a este token." @@ -1366,9 +1418,25 @@ export const esESObjects: NonNullable = { label: "Revocado el", help: "Marca temporal en la que se revocó este token de actualización." }, + rotated_at: { + label: "Rotated At", + help: "Timestamp at which this token was rotated (superseded by a new row)" + }, + rotation_replay_response: { + label: "Rotation Replay Response", + help: "Cached token response replayed when the old token is re-presented within the reuse interval" + }, + rotation_replay_expires_at: { + label: "Rotation Replay Expires At", + help: "End of the post-rotation reuse interval during which the replay response is served" + }, auth_time: { label: "Hora de autenticación", help: "Momento en que el usuario se autenticó originalmente para esta cadena de tokens." + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1392,6 +1460,14 @@ export const esESObjects: NonNullable = { label: "ID de referencia", help: "Identificador de correlación proporcionado por quien llama." }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators the consent covers" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims the user consented to expose" + }, scopes: { label: "Ámbitos", help: "Lista serializada en JSON de ámbitos a los que el usuario dio consentimiento." @@ -1404,6 +1480,107 @@ export const esESObjects: NonNullable = { } } }, + sys_oauth_resource: { + label: "OAuth Resource", + pluralLabel: "OAuth Resources", + description: "Registered OAuth protected resources (RFC 8707 resource indicators)", + fields: { + id: { + label: "ID" + }, + identifier: { + label: "Identifier", + help: "Resource indicator URI presented in the RFC 8707 resource parameter" + }, + name: { + label: "Name" + }, + access_token_ttl: { + label: "Access Token TTL", + help: "Access-token lifetime in seconds for this resource (overrides the server default)" + }, + refresh_token_ttl: { + label: "Refresh Token TTL", + help: "Refresh-token lifetime in seconds for this resource (overrides the server default)" + }, + signing_algorithm: { + label: "Signing Algorithm", + help: "JWS algorithm used to sign access tokens for this resource" + }, + signing_key_id: { + label: "Signing Key ID", + help: "Key id (kid) used to sign access tokens for this resource" + }, + allowed_scopes: { + label: "Allowed Scopes", + help: "JSON-serialized list of scopes clients may request for this resource" + }, + custom_claims: { + label: "Custom Claims", + help: "JSON object of extra claims stamped on access tokens for this resource" + }, + dpop_bound_access_tokens_required: { + label: "DPoP Required", + help: "Require access tokens for this resource to be DPoP-bound (RFC 9449)" + }, + disabled: { + label: "Disabled" + }, + policy_version: { + label: "Policy Version", + help: "Monotonic version of the resource token policy" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional resource metadata" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + } + } + }, + sys_oauth_client_resource: { + label: "OAuth Client Resource", + pluralLabel: "OAuth Client Resources", + description: "Grants allowing an OAuth client to request tokens for a protected resource", + fields: { + id: { + label: "ID" + }, + client_id: { + label: "Client ID", + help: "Foreign key to sys_oauth_application.client_id" + }, + resource_id: { + label: "Resource ID", + help: "Foreign key to sys_oauth_resource.identifier" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional grant metadata" + }, + created_at: { + label: "Created At" + } + } + }, + sys_oauth_client_assertion: { + label: "OAuth Client Assertion", + pluralLabel: "OAuth Client Assertions", + description: "Consumed OAuth client-assertion JTIs (RFC 7523 replay prevention)", + fields: { + id: { + label: "ID" + }, + expires_at: { + label: "Expires At", + help: "Assertion expiry — rows past this instant are safe to prune" + } + } + }, sys_jwks: { label: "Clave de firma (JWKS)", pluralLabel: "Claves de firma (JWKS)", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index f805f902c4..7eda8ade18 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1187,6 +1187,18 @@ export const jaJPObjects: NonNullable = { label: "サブジェクトタイプ", help: "OIDC サブジェクトタイプ(例: public、pairwise)" }, + jwks: { + label: "JWKS", + help: "Client JSON Web Key Set (for private_key_jwt / signed-request verification)" + }, + jwks_uri: { + label: "JWKS URI", + help: "URL of the client JSON Web Key Set" + }, + dpop_bound_access_tokens: { + label: "DPoP-bound Access Tokens", + help: "Require access tokens issued to this client to be DPoP-bound (RFC 9449)" + }, disabled: { label: "無効" }, @@ -1198,6 +1210,14 @@ export const jaJPObjects: NonNullable = { label: "エンドセッションを有効化", help: "クライアントが OIDC エンドセッションエンドポイントを呼び出せるようにします" }, + backchannel_logout_uri: { + label: "Back-channel Logout URI", + help: "OIDC back-channel logout endpoint of the client" + }, + backchannel_logout_session_required: { + label: "Back-channel Logout Session Required", + help: "Whether the back-channel logout token must include a sid claim" + }, software_id: { label: "ソフトウェア ID" }, @@ -1312,6 +1332,18 @@ export const jaJPObjects: NonNullable = { label: "参照 ID", help: "呼び出し元が指定した相関識別子" }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "スコープ", help: "このトークンに付与されたスコープの JSON シリアライズリスト" @@ -1321,6 +1353,14 @@ export const jaJPObjects: NonNullable = { }, created_at: { label: "作成日時" + }, + revoked: { + label: "Revoked At", + help: "Timestamp at which this access token was revoked" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1352,6 +1392,18 @@ export const jaJPObjects: NonNullable = { label: "参照 ID", help: "呼び出し元が指定した相関識別子" }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token chain originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "スコープ", help: "このトークンに付与されたスコープの JSON シリアライズリスト" @@ -1366,9 +1418,25 @@ export const jaJPObjects: NonNullable = { label: "失効日時", help: "このリフレッシュトークンが失効したタイムスタンプ" }, + rotated_at: { + label: "Rotated At", + help: "Timestamp at which this token was rotated (superseded by a new row)" + }, + rotation_replay_response: { + label: "Rotation Replay Response", + help: "Cached token response replayed when the old token is re-presented within the reuse interval" + }, + rotation_replay_expires_at: { + label: "Rotation Replay Expires At", + help: "End of the post-rotation reuse interval during which the replay response is served" + }, auth_time: { label: "認証日時", help: "このトークンチェーンのためにユーザーが最初に認証した日時" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1392,6 +1460,14 @@ export const jaJPObjects: NonNullable = { label: "参照 ID", help: "呼び出し元が指定した相関識別子" }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators the consent covers" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims the user consented to expose" + }, scopes: { label: "スコープ", help: "ユーザーが同意したスコープの JSON シリアライズリスト" @@ -1404,6 +1480,107 @@ export const jaJPObjects: NonNullable = { } } }, + sys_oauth_resource: { + label: "OAuth Resource", + pluralLabel: "OAuth Resources", + description: "Registered OAuth protected resources (RFC 8707 resource indicators)", + fields: { + id: { + label: "ID" + }, + identifier: { + label: "Identifier", + help: "Resource indicator URI presented in the RFC 8707 resource parameter" + }, + name: { + label: "Name" + }, + access_token_ttl: { + label: "Access Token TTL", + help: "Access-token lifetime in seconds for this resource (overrides the server default)" + }, + refresh_token_ttl: { + label: "Refresh Token TTL", + help: "Refresh-token lifetime in seconds for this resource (overrides the server default)" + }, + signing_algorithm: { + label: "Signing Algorithm", + help: "JWS algorithm used to sign access tokens for this resource" + }, + signing_key_id: { + label: "Signing Key ID", + help: "Key id (kid) used to sign access tokens for this resource" + }, + allowed_scopes: { + label: "Allowed Scopes", + help: "JSON-serialized list of scopes clients may request for this resource" + }, + custom_claims: { + label: "Custom Claims", + help: "JSON object of extra claims stamped on access tokens for this resource" + }, + dpop_bound_access_tokens_required: { + label: "DPoP Required", + help: "Require access tokens for this resource to be DPoP-bound (RFC 9449)" + }, + disabled: { + label: "Disabled" + }, + policy_version: { + label: "Policy Version", + help: "Monotonic version of the resource token policy" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional resource metadata" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + } + } + }, + sys_oauth_client_resource: { + label: "OAuth Client Resource", + pluralLabel: "OAuth Client Resources", + description: "Grants allowing an OAuth client to request tokens for a protected resource", + fields: { + id: { + label: "ID" + }, + client_id: { + label: "Client ID", + help: "Foreign key to sys_oauth_application.client_id" + }, + resource_id: { + label: "Resource ID", + help: "Foreign key to sys_oauth_resource.identifier" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional grant metadata" + }, + created_at: { + label: "Created At" + } + } + }, + sys_oauth_client_assertion: { + label: "OAuth Client Assertion", + pluralLabel: "OAuth Client Assertions", + description: "Consumed OAuth client-assertion JTIs (RFC 7523 replay prevention)", + fields: { + id: { + label: "ID" + }, + expires_at: { + label: "Expires At", + help: "Assertion expiry — rows past this instant are safe to prune" + } + } + }, sys_jwks: { label: "署名キー (JWKS)", pluralLabel: "署名キー (JWKS)", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 67764e9e16..b5851490c0 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1187,6 +1187,18 @@ export const zhCNObjects: NonNullable = { label: "主体类型", help: "OIDC 主体类型(例如 public、pairwise)" }, + jwks: { + label: "JWKS", + help: "Client JSON Web Key Set (for private_key_jwt / signed-request verification)" + }, + jwks_uri: { + label: "JWKS URI", + help: "URL of the client JSON Web Key Set" + }, + dpop_bound_access_tokens: { + label: "DPoP-bound Access Tokens", + help: "Require access tokens issued to this client to be DPoP-bound (RFC 9449)" + }, disabled: { label: "已禁用" }, @@ -1198,6 +1210,14 @@ export const zhCNObjects: NonNullable = { label: "启用 End Session", help: "允许客户端调用 OIDC end-session 端点" }, + backchannel_logout_uri: { + label: "Back-channel Logout URI", + help: "OIDC back-channel logout endpoint of the client" + }, + backchannel_logout_session_required: { + label: "Back-channel Logout Session Required", + help: "Whether the back-channel logout token must include a sid claim" + }, software_id: { label: "软件 ID" }, @@ -1312,6 +1332,18 @@ export const zhCNObjects: NonNullable = { label: "引用 ID", help: "调用方提供的关联标识符" }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "范围", help: "授予该令牌的 scopes 的 JSON 序列化列表" @@ -1321,6 +1353,14 @@ export const zhCNObjects: NonNullable = { }, created_at: { label: "创建时间" + }, + revoked: { + label: "Revoked At", + help: "Timestamp at which this access token was revoked" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1352,6 +1392,18 @@ export const zhCNObjects: NonNullable = { label: "引用 ID", help: "调用方提供的关联标识符" }, + authorization_code_id: { + label: "Authorization Code ID", + help: "ID of the authorization-code grant this token chain originates from" + }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators bound to this token" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims requested for the userinfo endpoint" + }, scopes: { label: "范围", help: "授予该令牌的 scopes 的 JSON 序列化列表" @@ -1366,9 +1418,25 @@ export const zhCNObjects: NonNullable = { label: "撤销时间", help: "该刷新令牌被撤销的时间戳" }, + rotated_at: { + label: "Rotated At", + help: "Timestamp at which this token was rotated (superseded by a new row)" + }, + rotation_replay_response: { + label: "Rotation Replay Response", + help: "Cached token response replayed when the old token is re-presented within the reuse interval" + }, + rotation_replay_expires_at: { + label: "Rotation Replay Expires At", + help: "End of the post-rotation reuse interval during which the replay response is served" + }, auth_time: { label: "认证时间", help: "该令牌链中用户最初完成认证的时间" + }, + confirmation: { + label: "Confirmation", + help: "JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key" } } }, @@ -1392,6 +1460,14 @@ export const zhCNObjects: NonNullable = { label: "引用 ID", help: "调用方提供的关联标识符" }, + resources: { + label: "Resources", + help: "JSON-serialized list of RFC 8707 resource indicators the consent covers" + }, + requested_user_info_claims: { + label: "Requested UserInfo Claims", + help: "JSON-serialized list of OIDC claims the user consented to expose" + }, scopes: { label: "范围", help: "用户同意的 scopes 的 JSON 序列化列表" @@ -1404,6 +1480,107 @@ export const zhCNObjects: NonNullable = { } } }, + sys_oauth_resource: { + label: "OAuth Resource", + pluralLabel: "OAuth Resources", + description: "Registered OAuth protected resources (RFC 8707 resource indicators)", + fields: { + id: { + label: "ID" + }, + identifier: { + label: "Identifier", + help: "Resource indicator URI presented in the RFC 8707 resource parameter" + }, + name: { + label: "Name" + }, + access_token_ttl: { + label: "Access Token TTL", + help: "Access-token lifetime in seconds for this resource (overrides the server default)" + }, + refresh_token_ttl: { + label: "Refresh Token TTL", + help: "Refresh-token lifetime in seconds for this resource (overrides the server default)" + }, + signing_algorithm: { + label: "Signing Algorithm", + help: "JWS algorithm used to sign access tokens for this resource" + }, + signing_key_id: { + label: "Signing Key ID", + help: "Key id (kid) used to sign access tokens for this resource" + }, + allowed_scopes: { + label: "Allowed Scopes", + help: "JSON-serialized list of scopes clients may request for this resource" + }, + custom_claims: { + label: "Custom Claims", + help: "JSON object of extra claims stamped on access tokens for this resource" + }, + dpop_bound_access_tokens_required: { + label: "DPoP Required", + help: "Require access tokens for this resource to be DPoP-bound (RFC 9449)" + }, + disabled: { + label: "Disabled" + }, + policy_version: { + label: "Policy Version", + help: "Monotonic version of the resource token policy" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional resource metadata" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + } + } + }, + sys_oauth_client_resource: { + label: "OAuth Client Resource", + pluralLabel: "OAuth Client Resources", + description: "Grants allowing an OAuth client to request tokens for a protected resource", + fields: { + id: { + label: "ID" + }, + client_id: { + label: "Client ID", + help: "Foreign key to sys_oauth_application.client_id" + }, + resource_id: { + label: "Resource ID", + help: "Foreign key to sys_oauth_resource.identifier" + }, + metadata: { + label: "Metadata", + help: "JSON object of additional grant metadata" + }, + created_at: { + label: "Created At" + } + } + }, + sys_oauth_client_assertion: { + label: "OAuth Client Assertion", + pluralLabel: "OAuth Client Assertions", + description: "Consumed OAuth client-assertion JTIs (RFC 7523 replay prevention)", + fields: { + id: { + label: "ID" + }, + expires_at: { + label: "Expires At", + help: "Assertion expiry — rows past this instant are safe to prune" + } + } + }, sys_jwks: { label: "签名密钥 (JWKS)", pluralLabel: "签名密钥 (JWKS)", diff --git a/packages/platform-objects/src/identity/index.ts b/packages/platform-objects/src/identity/index.ts index 765c952f76..7738089bbb 100644 --- a/packages/platform-objects/src/identity/index.ts +++ b/packages/platform-objects/src/identity/index.ts @@ -34,6 +34,9 @@ export { SysOauthApplication } from './sys-oauth-application.object.js'; export { SysOauthAccessToken } from './sys-oauth-access-token.object.js'; export { SysOauthRefreshToken } from './sys-oauth-refresh-token.object.js'; export { SysOauthConsent } from './sys-oauth-consent.object.js'; +export { SysOauthResource } from './sys-oauth-resource.object.js'; +export { SysOauthClientResource } from './sys-oauth-client-resource.object.js'; +export { SysOauthClientAssertion } from './sys-oauth-client-assertion.object.js'; export { SysJwks } from './sys-jwks.object.js'; // ── External SSO (relying-party, @better-auth/sso) ───────────────── diff --git a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts index 9d644f91e8..d2e0708adf 100644 --- a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts @@ -83,6 +83,25 @@ export const SysOauthAccessToken = ObjectSchema.create({ description: 'Caller-supplied correlation identifier', }), + authorization_code_id: Field.text({ + label: 'Authorization Code ID', + required: false, + maxLength: 255, + description: 'ID of the authorization-code grant this token originates from', + }), + + resources: Field.textarea({ + label: 'Resources', + required: false, + description: 'JSON-serialized list of RFC 8707 resource indicators bound to this token', + }), + + requested_user_info_claims: Field.textarea({ + label: 'Requested UserInfo Claims', + required: false, + description: 'JSON-serialized list of OIDC claims requested for the userinfo endpoint', + }), + scopes: Field.textarea({ label: 'Scopes', required: true, @@ -99,6 +118,18 @@ export const SysOauthAccessToken = ObjectSchema.create({ defaultValue: 'NOW()', readonly: true, }), + + revoked: Field.datetime({ + label: 'Revoked At', + required: false, + description: 'Timestamp at which this access token was revoked', + }), + + confirmation: Field.textarea({ + label: 'Confirmation', + required: false, + description: 'JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key', + }), }, indexes: [ @@ -107,6 +138,7 @@ export const SysOauthAccessToken = ObjectSchema.create({ { fields: ['session_id'] }, { fields: ['user_id'] }, { fields: ['refresh_id'] }, + { fields: ['authorization_code_id'] }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 69c464568c..883c816d8b 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -364,6 +364,28 @@ export const SysOauthApplication = ObjectSchema.create({ group: 'Credentials', }), + jwks: Field.textarea({ + label: 'JWKS', + required: false, + description: 'Client JSON Web Key Set (for private_key_jwt / signed-request verification)', + group: 'Credentials', + }), + + jwks_uri: Field.url({ + label: 'JWKS URI', + required: false, + description: 'URL of the client JSON Web Key Set', + group: 'Credentials', + }), + + dpop_bound_access_tokens: Field.boolean({ + label: 'DPoP-bound Access Tokens', + required: false, + defaultValue: false, + description: 'Require access tokens issued to this client to be DPoP-bound (RFC 9449)', + group: 'Credentials', + }), + // ── Behaviour flags ────────────────────────────────────────── disabled: Field.boolean({ label: 'Disabled', @@ -386,6 +408,20 @@ export const SysOauthApplication = ObjectSchema.create({ group: 'Behaviour', }), + backchannel_logout_uri: Field.url({ + label: 'Back-channel Logout URI', + required: false, + description: 'OIDC back-channel logout endpoint of the client', + group: 'Behaviour', + }), + + backchannel_logout_session_required: Field.boolean({ + label: 'Back-channel Logout Session Required', + required: false, + description: 'Whether the back-channel logout token must include a sid claim', + group: 'Behaviour', + }), + // ── Software statement (RFC 7591 §2.3) ─────────────────────── software_id: Field.text({ label: 'Software ID', diff --git a/packages/platform-objects/src/identity/sys-oauth-client-assertion.object.ts b/packages/platform-objects/src/identity/sys-oauth-client-assertion.object.ts new file mode 100644 index 0000000000..0bacc1c0e7 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-oauth-client-assertion.object.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_oauth_client_assertion — Consumed client-assertion JTI + * + * Backed by `@better-auth/oauth-provider`'s `oauthClientAssertion` model + * (better-auth ≥ 1.7). One row per consumed `private_key_jwt` / + * `client_secret_jwt` assertion `jti` — replay prevention for RFC 7523 + * client authentication. Rows are short-lived; expired ones can be pruned. + * + * @namespace sys + */ +export const SysOauthClientAssertion = ObjectSchema.create({ + name: 'sys_oauth_client_assertion', + label: 'OAuth Client Assertion', + pluralLabel: 'OAuth Client Assertions', + icon: 'shield-check', + isSystem: true, + managedBy: 'better-auth', + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema, + // but may add overlay row-level config. Use `no-overlay` if you need to + // forbid sys_metadata overlays entirely. + protection: { + lock: 'full', + reason: 'Identity table managed by better-auth — see ADR-0010.', + docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', + }, + description: 'Consumed OAuth client-assertion JTIs (RFC 7523 replay prevention)', + highlightFields: ['expires_at'], + + fields: { + id: Field.text({ + label: 'ID', + required: true, + readonly: true, + }), + + expires_at: Field.datetime({ + label: 'Expires At', + required: true, + description: 'Assertion expiry — rows past this instant are safe to prune', + }), + }, + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: false, + apiMethods: [], + trash: false, + mru: false, + }, +}); diff --git a/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts b/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts new file mode 100644 index 0000000000..c3e869fa08 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-oauth-client-resource.object.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_oauth_client_resource — Client ↔ protected-resource grant + * + * Backed by `@better-auth/oauth-provider`'s `oauthClientResource` model + * (better-auth ≥ 1.7). Join table allowing a registered client to request + * tokens for a registered resource (RFC 8707). A missing row means the + * client is not authorized for that audience. + * + * @namespace sys + */ +export const SysOauthClientResource = ObjectSchema.create({ + name: 'sys_oauth_client_resource', + label: 'OAuth Client Resource', + pluralLabel: 'OAuth Client Resources', + icon: 'link', + isSystem: true, + managedBy: 'better-auth', + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema, + // but may add overlay row-level config. Use `no-overlay` if you need to + // forbid sys_metadata overlays entirely. + protection: { + lock: 'full', + reason: 'Identity table managed by better-auth — see ADR-0010.', + docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', + }, + description: 'Grants allowing an OAuth client to request tokens for a protected resource', + highlightFields: ['client_id', 'resource_id'], + + fields: { + id: Field.text({ + label: 'ID', + required: true, + readonly: true, + }), + + client_id: Field.text({ + label: 'Client ID', + required: true, + description: 'Foreign key to sys_oauth_application.client_id', + }), + + resource_id: Field.text({ + label: 'Resource ID', + required: true, + maxLength: 1024, + description: 'Foreign key to sys_oauth_resource.identifier', + }), + + metadata: Field.textarea({ + label: 'Metadata', + required: false, + description: 'JSON object of additional grant metadata', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['client_id'] }, + { fields: ['resource_id'] }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: false, + apiMethods: [], + trash: false, + mru: false, + }, +}); diff --git a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts index 58439a7f86..ab8d786b40 100644 --- a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts @@ -60,6 +60,18 @@ export const SysOauthConsent = ObjectSchema.create({ description: 'Caller-supplied correlation identifier', }), + resources: Field.textarea({ + label: 'Resources', + required: false, + description: 'JSON-serialized list of RFC 8707 resource indicators the consent covers', + }), + + requested_user_info_claims: Field.textarea({ + label: 'Requested UserInfo Claims', + required: false, + description: 'JSON-serialized list of OIDC claims the user consented to expose', + }), + scopes: Field.textarea({ label: 'Scopes', required: true, diff --git a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts index d877bb1945..4e38f1d51f 100644 --- a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts @@ -76,6 +76,25 @@ export const SysOauthRefreshToken = ObjectSchema.create({ description: 'Caller-supplied correlation identifier', }), + authorization_code_id: Field.text({ + label: 'Authorization Code ID', + required: false, + maxLength: 255, + description: 'ID of the authorization-code grant this token chain originates from', + }), + + resources: Field.textarea({ + label: 'Resources', + required: false, + description: 'JSON-serialized list of RFC 8707 resource indicators bound to this token', + }), + + requested_user_info_claims: Field.textarea({ + label: 'Requested UserInfo Claims', + required: false, + description: 'JSON-serialized list of OIDC claims requested for the userinfo endpoint', + }), + scopes: Field.textarea({ label: 'Scopes', required: true, @@ -99,11 +118,35 @@ export const SysOauthRefreshToken = ObjectSchema.create({ description: 'Timestamp at which this refresh token was revoked', }), + rotated_at: Field.datetime({ + label: 'Rotated At', + required: false, + description: 'Timestamp at which this token was rotated (superseded by a new row)', + }), + + rotation_replay_response: Field.textarea({ + label: 'Rotation Replay Response', + required: false, + description: 'Cached token response replayed when the old token is re-presented within the reuse interval', + }), + + rotation_replay_expires_at: Field.datetime({ + label: 'Rotation Replay Expires At', + required: false, + description: 'End of the post-rotation reuse interval during which the replay response is served', + }), + auth_time: Field.datetime({ label: 'Auth Time', required: false, description: 'When the user originally authenticated for this token chain', }), + + confirmation: Field.textarea({ + label: 'Confirmation', + required: false, + description: 'JSON RFC 7800 cnf claim (e.g. DPoP key thumbprint) binding this token to a key', + }), }, indexes: [ @@ -111,6 +154,7 @@ export const SysOauthRefreshToken = ObjectSchema.create({ { fields: ['client_id'] }, { fields: ['session_id'] }, { fields: ['user_id'] }, + { fields: ['authorization_code_id'] }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-oauth-resource.object.ts b/packages/platform-objects/src/identity/sys-oauth-resource.object.ts new file mode 100644 index 0000000000..8626a8665a --- /dev/null +++ b/packages/platform-objects/src/identity/sys-oauth-resource.object.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_oauth_resource — Registered OAuth protected resource (RFC 8707) + * + * Backed by `@better-auth/oauth-provider`'s `oauthResource` model + * (better-auth ≥ 1.7). Each row registers a resource server (audience) + * that clients may request tokens for via the RFC 8707 `resource` + * parameter — e.g. the platform's own MCP endpoint. Carries the per- + * resource token policy (TTLs, signing, allowed scopes, DPoP requirement). + * + * @namespace sys + */ +export const SysOauthResource = ObjectSchema.create({ + name: 'sys_oauth_resource', + label: 'OAuth Resource', + pluralLabel: 'OAuth Resources', + icon: 'server', + isSystem: true, + managedBy: 'better-auth', + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema, + // but may add overlay row-level config. Use `no-overlay` if you need to + // forbid sys_metadata overlays entirely. + protection: { + lock: 'full', + reason: 'Identity table managed by better-auth — see ADR-0010.', + docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', + }, + description: 'Registered OAuth protected resources (RFC 8707 resource indicators)', + displayNameField: 'name', + nameField: 'name', + highlightFields: ['name', 'identifier', 'disabled'], + + fields: { + id: Field.text({ + label: 'ID', + required: true, + readonly: true, + }), + + identifier: Field.text({ + label: 'Identifier', + required: true, + maxLength: 1024, + description: 'Resource indicator URI presented in the RFC 8707 resource parameter', + }), + + name: Field.text({ + label: 'Name', + required: true, + maxLength: 255, + }), + + access_token_ttl: Field.number({ + label: 'Access Token TTL', + required: false, + description: 'Access-token lifetime in seconds for this resource (overrides the server default)', + }), + + refresh_token_ttl: Field.number({ + label: 'Refresh Token TTL', + required: false, + description: 'Refresh-token lifetime in seconds for this resource (overrides the server default)', + }), + + signing_algorithm: Field.text({ + label: 'Signing Algorithm', + required: false, + maxLength: 32, + description: 'JWS algorithm used to sign access tokens for this resource', + }), + + signing_key_id: Field.text({ + label: 'Signing Key ID', + required: false, + maxLength: 255, + description: 'Key id (kid) used to sign access tokens for this resource', + }), + + allowed_scopes: Field.textarea({ + label: 'Allowed Scopes', + required: false, + description: 'JSON-serialized list of scopes clients may request for this resource', + }), + + custom_claims: Field.textarea({ + label: 'Custom Claims', + required: false, + description: 'JSON object of extra claims stamped on access tokens for this resource', + }), + + dpop_bound_access_tokens_required: Field.boolean({ + label: 'DPoP Required', + required: false, + defaultValue: false, + description: 'Require access tokens for this resource to be DPoP-bound (RFC 9449)', + }), + + disabled: Field.boolean({ + label: 'Disabled', + required: false, + defaultValue: false, + }), + + policy_version: Field.number({ + label: 'Policy Version', + required: false, + defaultValue: 1, + description: 'Monotonic version of the resource token policy', + }), + + metadata: Field.textarea({ + label: 'Metadata', + required: false, + description: 'JSON object of additional resource metadata', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['identifier'], unique: true }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: false, + apiMethods: [], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index a3f51b1e1f..4afa191f92 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -374,6 +374,10 @@ export const AUTH_PHONE_NUMBER_USER_FIELDS = { * | softwareVersion | software_version | * | softwareStatement | software_statement | * | referenceId | reference_id | + * | jwksUri | jwks_uri | + * | backchannelLogoutUri | backchannel_logout_uri | + * | backchannelLogoutSessionRequired | backchannel_logout_session_required | + * | dpopBoundAccessTokens | dpop_bound_access_tokens | */ export const AUTH_OAUTH_CLIENT_SCHEMA = { modelName: SystemObjectName.OAUTH_APPLICATION, // 'sys_oauth_application' @@ -396,6 +400,10 @@ export const AUTH_OAUTH_CLIENT_SCHEMA = { softwareVersion: 'software_version', softwareStatement: 'software_statement', referenceId: 'reference_id', + jwksUri: 'jwks_uri', + backchannelLogoutUri: 'backchannel_logout_uri', + backchannelLogoutSessionRequired: 'backchannel_logout_session_required', + dpopBoundAccessTokens: 'dpop_bound_access_tokens', }, } as const; @@ -416,15 +424,21 @@ export const AUTH_OAUTH_APPLICATION_SCHEMA = AUTH_OAUTH_CLIENT_SCHEMA; * **separate** models. `oauthAccessToken` no longer carries a refresh token; * see {@link AUTH_OAUTH_REFRESH_TOKEN_SCHEMA} for the companion model. * - * | camelCase (better-auth) | snake_case (ObjectStack) | - * |:------------------------|:-------------------------| - * | clientId | client_id | - * | sessionId | session_id | - * | userId | user_id | - * | referenceId | reference_id | - * | refreshId | refresh_id | - * | expiresAt | expires_at | - * | createdAt | created_at | + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-----------------------------| + * | clientId | client_id | + * | sessionId | session_id | + * | userId | user_id | + * | referenceId | reference_id | + * | authorizationCodeId | authorization_code_id | + * | requestedUserInfoClaims | requested_user_info_claims | + * | refreshId | refresh_id | + * | expiresAt | expires_at | + * | createdAt | created_at | + * + * (`resources`, `revoked`, and `confirmation` are single-word columns — + * camelCase equals snake_case, so they need no remap; the columns still + * exist on `sys_oauth_access_token`.) */ export const AUTH_OAUTH_ACCESS_TOKEN_SCHEMA = { modelName: SystemObjectName.OAUTH_ACCESS_TOKEN, // 'sys_oauth_access_token' @@ -433,6 +447,8 @@ export const AUTH_OAUTH_ACCESS_TOKEN_SCHEMA = { sessionId: 'session_id', userId: 'user_id', referenceId: 'reference_id', + authorizationCodeId: 'authorization_code_id', + requestedUserInfoClaims: 'requested_user_info_claims', refreshId: 'refresh_id', expiresAt: 'expires_at', createdAt: 'created_at', @@ -450,15 +466,23 @@ export const AUTH_OAUTH_ACCESS_TOKEN_SCHEMA = { * issuing client. Each access token rotation produces a new refresh-token * row. * - * | camelCase (better-auth) | snake_case (ObjectStack) | - * |:------------------------|:-------------------------| - * | clientId | client_id | - * | sessionId | session_id | - * | userId | user_id | - * | referenceId | reference_id | - * | expiresAt | expires_at | - * | createdAt | created_at | - * | authTime | auth_time | + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:-------------------------|:-----------------------------| + * | clientId | client_id | + * | sessionId | session_id | + * | userId | user_id | + * | referenceId | reference_id | + * | authorizationCodeId | authorization_code_id | + * | requestedUserInfoClaims | requested_user_info_claims | + * | expiresAt | expires_at | + * | createdAt | created_at | + * | rotatedAt | rotated_at | + * | rotationReplayResponse | rotation_replay_response | + * | rotationReplayExpiresAt | rotation_replay_expires_at | + * | authTime | auth_time | + * + * (`resources`, `revoked`, and `confirmation` are single-word columns — + * camelCase equals snake_case, so they need no remap.) */ export const AUTH_OAUTH_REFRESH_TOKEN_SCHEMA = { modelName: SystemObjectName.OAUTH_REFRESH_TOKEN, // 'sys_oauth_refresh_token' @@ -467,8 +491,13 @@ export const AUTH_OAUTH_REFRESH_TOKEN_SCHEMA = { sessionId: 'session_id', userId: 'user_id', referenceId: 'reference_id', + authorizationCodeId: 'authorization_code_id', + requestedUserInfoClaims: 'requested_user_info_claims', expiresAt: 'expires_at', createdAt: 'created_at', + rotatedAt: 'rotated_at', + rotationReplayResponse: 'rotation_replay_response', + rotationReplayExpiresAt: 'rotation_replay_expires_at', authTime: 'auth_time', }, } as const; @@ -484,13 +513,16 @@ export const AUTH_OAUTH_REFRESH_TOKEN_SCHEMA = { * a row implies consent was given for the listed scopes. A new * `referenceId` column was added for client-supplied correlation. * - * | camelCase (better-auth) | snake_case (ObjectStack) | - * |:------------------------|:-------------------------| - * | clientId | client_id | - * | userId | user_id | - * | referenceId | reference_id | - * | createdAt | created_at | - * | updatedAt | updated_at | + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:---------------------------| + * | clientId | client_id | + * | userId | user_id | + * | referenceId | reference_id | + * | requestedUserInfoClaims | requested_user_info_claims | + * | createdAt | created_at | + * | updatedAt | updated_at | + * + * (`resources` is a single-word column — no remap needed.) */ export const AUTH_OAUTH_CONSENT_SCHEMA = { modelName: SystemObjectName.OAUTH_CONSENT, // 'sys_oauth_consent' @@ -498,11 +530,94 @@ export const AUTH_OAUTH_CONSENT_SCHEMA = { clientId: 'client_id', userId: 'user_id', referenceId: 'reference_id', + requestedUserInfoClaims: 'requested_user_info_claims', createdAt: 'created_at', updatedAt: 'updated_at', }, } as const; +// --------------------------------------------------------------------------- +// OAuth Provider plugin – oauthResource / oauthClientResource / +// oauthClientAssertion tables (new in @better-auth/oauth-provider 1.7) +// --------------------------------------------------------------------------- + +/** + * `@better-auth/oauth-provider` plugin `oauthResource` model mapping. + * + * Registry of protected resources (RFC 8707 resource indicators) plus the + * per-resource token policy. Used by the MCP OAuth track (`resource=` audience binding) and any future resource-server registrations. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------------|:------------------------------------| + * | accessTokenTtl | access_token_ttl | + * | refreshTokenTtl | refresh_token_ttl | + * | signingAlgorithm | signing_algorithm | + * | signingKeyId | signing_key_id | + * | allowedScopes | allowed_scopes | + * | customClaims | custom_claims | + * | dpopBoundAccessTokensRequired | dpop_bound_access_tokens_required | + * | policyVersion | policy_version | + * | createdAt | created_at | + * | updatedAt | updated_at | + * + * (`identifier`, `name`, `disabled`, and `metadata` are single-word columns — + * no remap needed.) + */ +export const AUTH_OAUTH_RESOURCE_SCHEMA = { + modelName: SystemObjectName.OAUTH_RESOURCE, // 'sys_oauth_resource' + fields: { + accessTokenTtl: 'access_token_ttl', + refreshTokenTtl: 'refresh_token_ttl', + signingAlgorithm: 'signing_algorithm', + signingKeyId: 'signing_key_id', + allowedScopes: 'allowed_scopes', + customClaims: 'custom_claims', + dpopBoundAccessTokensRequired: 'dpop_bound_access_tokens_required', + policyVersion: 'policy_version', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +} as const; + +/** + * `@better-auth/oauth-provider` plugin `oauthClientResource` model mapping. + * + * Join table granting a registered client access to a registered resource. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | clientId | client_id | + * | resourceId | resource_id | + * | createdAt | created_at | + */ +export const AUTH_OAUTH_CLIENT_RESOURCE_SCHEMA = { + modelName: SystemObjectName.OAUTH_CLIENT_RESOURCE, // 'sys_oauth_client_resource' + fields: { + clientId: 'client_id', + resourceId: 'resource_id', + createdAt: 'created_at', + }, +} as const; + +/** + * `@better-auth/oauth-provider` plugin `oauthClientAssertion` model mapping. + * + * Consumed `private_key_jwt` / `client_secret_jwt` assertion JTIs (RFC 7523 + * replay prevention). The row id IS the consumed jti; `expiresAt` bounds + * how long it must be remembered. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | expiresAt | expires_at | + */ +export const AUTH_OAUTH_CLIENT_ASSERTION_SCHEMA = { + modelName: SystemObjectName.OAUTH_CLIENT_ASSERTION, // 'sys_oauth_client_assertion' + fields: { + expiresAt: 'expires_at', + }, +} as const; + // --------------------------------------------------------------------------- // Device Authorization plugin – deviceCode table // --------------------------------------------------------------------------- @@ -671,6 +786,9 @@ export function buildOauthProviderPluginSchema() { oauthAccessToken: AUTH_OAUTH_ACCESS_TOKEN_SCHEMA, oauthRefreshToken: AUTH_OAUTH_REFRESH_TOKEN_SCHEMA, oauthConsent: AUTH_OAUTH_CONSENT_SCHEMA, + oauthResource: AUTH_OAUTH_RESOURCE_SCHEMA, + oauthClientResource: AUTH_OAUTH_CLIENT_RESOURCE_SCHEMA, + oauthClientAssertion: AUTH_OAUTH_CLIENT_ASSERTION_SCHEMA, }; } diff --git a/packages/plugins/plugin-auth/src/manifest.ts b/packages/plugins/plugin-auth/src/manifest.ts index 40017f65d6..62058ba5f7 100644 --- a/packages/plugins/plugin-auth/src/manifest.ts +++ b/packages/plugins/plugin-auth/src/manifest.ts @@ -17,8 +17,11 @@ import { SysJwks, SysOauthAccessToken, SysOauthApplication, + SysOauthClientAssertion, + SysOauthClientResource, SysOauthConsent, SysOauthRefreshToken, + SysOauthResource, SysOrganization, SysSession, SysSsoProvider, @@ -52,6 +55,9 @@ export const authIdentityObjects: any[] = [ SysOauthAccessToken, SysOauthRefreshToken, SysOauthConsent, + SysOauthResource, + SysOauthClientResource, + SysOauthClientAssertion, SysJwks, SysDeviceCode, SysSsoProvider, diff --git a/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts b/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts new file mode 100644 index 0000000000..506cce99b4 --- /dev/null +++ b/packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Drift gate: every column the INSTALLED `@better-auth/oauth-provider` + * version can write must exist on the corresponding `sys_oauth_*` platform + * object. + * + * Why this exists: the 1.7 upgrade added fields (e.g. `authorizationCodeId`) + * that were missing from both the snake_case mappings in + * `auth-schema-config.ts` AND the platform-object schemas. The result was a + * runtime 500 at the token endpoint — `table sys_oauth_access_token has no + * column named authorizationCodeId` — which broke platform SSO end-to-end + * (cloud#: every environment login). A better-auth bump that introduces new + * model fields must fail HERE, at test time, not in production. + * + * The check resolves each model field exactly like better-auth's adapter + * does: `field.fieldName ?? key` (mergeSchema applies our fields overrides + * as `fieldName`). The resolved column must be declared on the platform + * object. + */ + +import { describe, expect, it } from 'vitest'; +import { oauthProvider } from '@better-auth/oauth-provider'; +import { + SysOauthAccessToken, + SysOauthApplication, + SysOauthClientAssertion, + SysOauthClientResource, + SysOauthConsent, + SysOauthRefreshToken, + SysOauthResource, +} from '@objectstack/platform-objects'; +import { buildOauthProviderPluginSchema } from './auth-schema-config.js'; + +const PLATFORM_OBJECTS: Record }> = { + sys_oauth_application: SysOauthApplication as any, + sys_oauth_access_token: SysOauthAccessToken as any, + sys_oauth_refresh_token: SysOauthRefreshToken as any, + sys_oauth_consent: SysOauthConsent as any, + sys_oauth_resource: SysOauthResource as any, + sys_oauth_client_resource: SysOauthClientResource as any, + sys_oauth_client_assertion: SysOauthClientAssertion as any, +}; + +describe('oauth-provider plugin schema ↔ platform-objects parity', () => { + const plugin = oauthProvider({ + loginPage: '/login', + schema: buildOauthProviderPluginSchema() as any, + }); + const schema = (plugin as any).schema as Record< + string, + { modelName?: string; fields: Record } + >; + + it('declares the seven oauth models', () => { + expect(Object.keys(schema ?? {}).sort()).toEqual([ + 'oauthAccessToken', + 'oauthClient', + 'oauthClientAssertion', + 'oauthClientResource', + 'oauthConsent', + 'oauthRefreshToken', + 'oauthResource', + ]); + }); + + for (const [model, def] of Object.entries(schema ?? {})) { + it(`every ${model} column exists on ${def.modelName ?? model}`, () => { + const tableName = def.modelName ?? model; + const object = PLATFORM_OBJECTS[tableName]; + expect(object, `model "${model}" must map to a known sys_oauth_* object, got "${tableName}"`).toBeDefined(); + const declaredColumns = new Set(['id', ...Object.keys(object.fields ?? {})]); + const missing = Object.entries(def.fields) + .map(([key, field]) => field.fieldName ?? key) + .filter((column) => !declaredColumns.has(column)); + expect( + missing, + `columns better-auth can write to ${tableName} but the platform object does not declare — ` + + 'add the field(s) to packages/platform-objects/src/identity/ and, when camelCase ≠ snake_case, ' + + 'a fieldName mapping in auth-schema-config.ts', + ).toEqual([]); + }); + } +}); diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 616954484c..3d86fcbdd7 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -50,6 +50,12 @@ export const SystemObjectName = { OAUTH_REFRESH_TOKEN: 'sys_oauth_refresh_token', /** Authentication: recorded user consent for a given OAuth client + scopes */ OAUTH_CONSENT: 'sys_oauth_consent', + /** Authentication: registered OAuth protected resource (RFC 8707 resource indicator) */ + OAUTH_RESOURCE: 'sys_oauth_resource', + /** Authentication: client ↔ protected-resource grant (RFC 8707) */ + OAUTH_CLIENT_RESOURCE: 'sys_oauth_client_resource', + /** Authentication: consumed client-assertion JTI (RFC 7523 replay prevention) */ + OAUTH_CLIENT_ASSERTION: 'sys_oauth_client_assertion', /** Authentication: pending device-authorization (RFC 8628) request */ DEVICE_CODE: 'sys_device_code', /** Authentication: JWKS key pair used to sign OIDC ID tokens / JWT access tokens */ From 221eab6167ce3dec76ea803b10108bf73b6888f8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:47:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(dogfood):=20OIDC=20authorization-code?= =?UTF-8?q?=20flow=20e2e=20=E2=80=94=20real=20kernel,=20real=20token=20exc?= =?UTF-8?q?hange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives authorize → code → token → userinfo against a bootStack kernel with OS_OIDC_PROVIDER_ENABLED, seeding the client row exactly the way cloud's seedPlatformSsoClient does (hashed secret, skip_consent, client_secret_basic, ≥1.7 /api/v1/auth/callback/ redirect_uri). The token-exchange step is the hop that 500'd in production on the 1.7 schema drift; verified the test fails (2/3) when the authorization_code_id column is removed and passes (3/3) with the fix. Complements the static parity gate in plugin-auth. Co-Authored-By: Claude Fable 5 --- ...dc-authorization-code-flow.dogfood.test.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts diff --git a/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts b/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts new file mode 100644 index 0000000000..cd72849f7a --- /dev/null +++ b/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * OIDC authorization-code flow — end-to-end against a REAL kernel. + * + * Regression proof for the @better-auth/oauth-provider 1.7 schema drift that + * broke platform SSO: the token exchange 500'd with `table + * sys_oauth_access_token has no column named authorizationCodeId` because the + * 1.7 models gained fields the sys_oauth_* platform objects (and the + * snake_case mappings in plugin-auth's auth-schema-config.ts) did not carry. + * The static parity gate (plugin-auth's oauth-provider-schema-parity.test.ts) + * catches missing columns at schema level; THIS test proves the actual wiring + * — authorize → code → token — issues a token and lands the row, exactly the + * hop that died in production. + * + * The client row mirrors what the cloud control plane seeds for per-project + * platform SSO (`seedPlatformSsoClient`): confidential client, + * client_secret_basic, skip_consent, hashed secret (SHA-256 → base64url — the + * oauth-provider defaultHasher format). + */ + +import { createHash } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +// Must be on before the AuthPlugin builds its plugin list (kernel.use during +// bootStack) — this is the same switch cloud/objectstack dev deployments use. +process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; + +const CLIENT_ID = 'project_dogfood_env'; +const CLIENT_SECRET = 'dogfood-plaintext-secret'; +// better-auth ≥ 1.7 mounts genericOAuth RPs through the core social flow, so +// the env-side callback is /api/v1/auth/callback/ (the pre-1.7 form +// was /api/v1/auth/oauth2/callback/). +const REDIRECT_URI = 'https://env.example.com/api/v1/auth/callback/objectstack-cloud'; + +/** SHA-256 → base64url (no padding) — @better-auth/oauth-provider defaultHasher. */ +function hashSecret(plaintext: string): string { + return createHash('sha256').update(plaintext) + .digest('base64') + .replace(/=+$/, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +describe('OIDC authorization-code flow (oauth-provider 1.7)', () => { + let stack: VerifyStack; + let cookie: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + + // Seed the OAuth client the way cloud's seedPlatformSsoClient does. + const ql = await stack.kernel.getServiceAsync('objectql'); + const nowIso = new Date().toISOString(); + await ql.insert('sys_oauth_application', { + id: 'oauthc_dogfood_env', + name: 'Dogfood Project', + client_id: CLIENT_ID, + client_secret: hashSecret(CLIENT_SECRET), + type: 'web', + redirect_uris: JSON.stringify([REDIRECT_URI]), + grant_types: JSON.stringify(['authorization_code', 'refresh_token']), + response_types: JSON.stringify(['code']), + scopes: JSON.stringify(['openid', 'email', 'profile']), + token_endpoint_auth_method: 'client_secret_basic', + require_pkce: false, + skip_consent: true, + disabled: false, + subject_type: 'public', + created_at: nowIso, + updated_at: nowIso, + }, { context: { isSystem: true } }); + + // Browser-style session: sign in and carry the session COOKIE (the + // authorize endpoint is hit by a redirected browser, not a bearer client). + const res = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), + }); + expect(res.ok).toBe(true); + cookie = res.headers.getSetCookie?.().map((c: string) => c.split(';')[0]).join('; ') + ?? (res.headers.get('set-cookie') ?? '').split(';')[0]; + expect(cookie).toBeTruthy(); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + let code: string; + + it('authorize: skip_consent client gets an immediate code redirect', async () => { + const qs = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: 'openid email profile', + state: 'dogfood-state', + }); + const res = await stack.api(`/auth/oauth2/authorize?${qs}`, { + headers: { Cookie: cookie }, + redirect: 'manual', + }); + expect([302, 303]).toContain(res.status); + const location = res.headers.get('location') ?? ''; + expect(location.startsWith(REDIRECT_URI)).toBe(true); + const url = new URL(location); + expect(url.searchParams.get('state')).toBe('dogfood-state'); + expect(url.searchParams.get('error')).toBeNull(); + code = url.searchParams.get('code') ?? ''; + expect(code).toBeTruthy(); + }); + + it('token exchange succeeds and persists the access-token row (the 1.7-drift 500)', async () => { + const res = await stack.api('/auth/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + }).toString(), + }); + const body: any = await res.json(); + // Pre-fix this was a 500: `table sys_oauth_access_token has no column + // named authorizationCodeId`. + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.access_token).toBeTruthy(); + expect(body.id_token).toBeTruthy(); + + const ql = await stack.kernel.getServiceAsync('objectql'); + const rows = await ql.find('sys_oauth_access_token', { + filters: [['client_id', '=', CLIENT_ID]], + context: { isSystem: true }, + }); + const list = Array.isArray(rows) ? rows : (rows?.records ?? []); + expect(list.length).toBeGreaterThan(0); + // The drift column: better-auth writes authorizationCodeId on every + // code-grant issuance; the mapping must land it in snake_case. + expect(list[0].authorization_code_id).toBeTruthy(); + }); + + it('userinfo answers with the signed-in subject', async () => { + const tokenRes = await stack.api('/auth/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + // Single-use code was consumed above — mint a fresh one. + code: await (async () => { + const qs = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: 'openid email profile', + }); + const res = await stack.api(`/auth/oauth2/authorize?${qs}`, { + headers: { Cookie: cookie }, + redirect: 'manual', + }); + return new URL(res.headers.get('location') ?? '').searchParams.get('code') ?? ''; + })(), + redirect_uri: REDIRECT_URI, + }).toString(), + }); + const { access_token } = (await tokenRes.json()) as any; + expect(access_token).toBeTruthy(); + + const res = await stack.api('/auth/oauth2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` }, + }); + const info: any = await res.json(); + expect(res.status, JSON.stringify(info)).toBe(200); + expect(info.email).toBe('admin@objectos.ai'); + }); +});