Skip to content

Commit 30002df

Browse files
fix(plugin-auth): a refused enterprise OIDC sign-in names its cause (#31194)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiXg46wP2iyXrx3QP2KhstCt)_ Fixes #30965 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:企业 OIDC 单点登录被拒时,跳转里的 `error_description` 从一律的 `token_not_verified` 改成具体原因(过期、未生效、受众不对、签发方不对、签名密钥未知、签名无效),服务器同时写一行 warn 日志(providerId、协议、原因)。 - 为什么改:Okta / Entra ID 接入时任何配置错误都显示同一句,管理员无法自查(cloud#3666 实测)。按裁定 D + A + V1 只做 OIDC,SAML 等上游。 - 风险与回滚:better-auth 自己的日志改走 AuthManager 的 logger(级别不变),没配 logger 时仍走控制台;`error=` 不变。回滚即还原本 PR,无数据迁移。 - 先看:`sso-oidc-refusal-cause.ts`(分类与改写,风险在 `runSsoOidcCallbackScope`)、`auth-manager-request-routing.ts`(作用域挂载点)、`sso-oidc-refusal-cause.test.ts`(端到端用例)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/sso-oidc-refusal-cause.test.ts`,预期 15 个用例全过。 - 需要你做的:无,席位落地。 ## What changes `@better-auth/sso` 1.7.12 answers every refused ID token on the OIDC callback with one redirect, `error=invalid_provider&error_description=token_not_verified`. The `jose` error that names the cause reaches only the vendor's logger (`ctx.context.logger.error(error)` in `handleOIDCCallback`). Per the ruling (D + A + V1, comment 6709033929), this PR reads it there and does OIDC only: - **`sso-oidc-refusal-cause.ts`** (new). `createBetterAuthLogSink` is the `logger` plugin-auth now hands `betterAuth`. `runSsoOidcCallbackScope` wraps one `/sso/callback/:providerId` request in an `AsyncLocalStorage` scope. Inside that scope the sink holds back an `error` line whose message is a `jose` error with a classified `code` (and `claim`), and records the cause. When the vendor answers the opaque redirect, the scope does three things: it replaces the `error_description` segment with the cause and leaves every other byte and header as they were, it writes one `warn` line (`providerId`, `protocol: 'oidc'`, `cause`), and it drops the held vendor line, which the warn line replaces. Any other answer releases the held lines unchanged. - **Closed list**, exported as `SSO_OIDC_REFUSAL_CAUSES`: `id_token_expired` (`ERR_JWT_EXPIRED`), `id_token_not_yet_valid` (`ERR_JWT_CLAIM_VALIDATION_FAILED` + `nbf`), `id_token_audience_mismatch` (+ `aud`), `id_token_issuer_mismatch` (+ `iss`), `id_token_signing_key_unknown` (`ERR_JWKS_NO_MATCHING_KEY`), `id_token_signature_invalid` (`ERR_JWS_SIGNATURE_VERIFICATION_FAILED`). Anything else keeps `token_not_verified` and still gets the warn line. Classification never reads message text. - **Wiring**: `auth-manager-auth-instance.ts` passes the sink; `auth-manager-request-routing.ts` opens the scope for the per-provider callback path through a new `AuthManager.runSsoOidcCallback` host member. The shared `/sso/callback` endpoint is not scoped: it is reached only when `sso({ redirectURI })` is set, and plugin-auth does not set it. - **How existing better-auth log lines move**: they now reach the `AuthManager` logger instead of the console, at the level better-auth chose (`error` stays `error`, `warn` stays `warn`; no regrading under the "Degradation log levels" rule, because the vendor already graded them). `info`/`debug` stay unpublished (the vendor's default level `warn` is unchanged). With no logger configured they go to the console as before. A message that is an error object is rendered as its name and message, as the vendor's console logger rendered it, so a `jose` claim error's `payload` (the token's claims) never reaches a line. Further arguments pass through unchanged. `driver-fault-auth-log-carriers.test.ts` now reads the library's carriers from both the console and the manager logger, and its assertions are otherwise unchanged. - **Docs**: `content/docs/permissions/sso.mdx` gains "Why an OIDC sign-in was refused", with one row per cause and what to check. - **Changeset**: `@objectstack/plugin-auth` patch. `Metadata-protocol: none` takes `patch` (AGENTS.md Post-Task Checklist step 3). The new export is a runtime constant, not an authorable key. Not here: SAML (the vendor discards that cause inside a bare `catch`, and it goes to a follow-up card upstream-first), `packages/spec`, ADR-0112. The values are lowercase `error_description` tokens in the vendor's dialect for that slot, not ledger codes (ruling V1). ## Tests `sso-oidc-refusal-cause.test.ts` drives a real `AuthManager` with the real `sso()` plugin through `/sign-in/sso` → `/sso/callback/:providerId`. Only the IdP's token and JWKS endpoints are stubbed. The ID tokens are signed in the test with generated RS256 keys, so every refusal comes from the vendor's real verifier. - CONTROL: a valid token signs in (302 to `callbackURL`, session cookie, no refusal line). - One case per cause: the redirect carries `error=invalid_provider` and the cause, there is exactly one warn line `{ providerId, protocol: 'oidc', cause }`, and no vendor `error` line. - Unclassified (`azp` naming another client): keeps `token_not_verified`, writes the warn line, and the vendor's diagnosis still reaches the log. - No-leak: neither the Location nor any logger channel carries the token, its payload segment, the subject, the email or the name. A known account and an unknown one get identical redirects and lines. - Unit: classification by code and claim only, the path scope, a byte-exact rewrite with headers kept, held lines released on other answers, and forwarding outside a scope. <details><summary>Ablations (each restored, blob == HEAD, `git diff HEAD` empty, via scripts/ablation-replace.mjs)</summary> Run on the 16-case file before the docs-table pin was dropped (heads b73a45f for 1 and 2, 07fe9fc for 3); the per-cause and no-leak cases are unchanged since. 1. Side channel off (`const cause = undefined` in the sink): 7 failed / 9 passed (all six per-cause E2E cases and the rewrite unit case). 2. Sink not handed to `betterAuth` (the `logger:` line deleted): 7 failed / 9 passed (six per-cause cases plus the unclassified case). This is the shape a vendor bump that stops passing the error object would take. 3. Error-message rendering leaks the error's own properties (`JSON.stringify({ ...message })`): 2 failed / 14 passed, including the held-line no-leak pin. A first version that also reshaped the vendor's further arguments was measured against `driver-fault-auth-log-carriers.test.ts` (the error object's `cause` stopped surviving). The arguments now pass through as written. </details> <details><summary>Local runs (final head b0a55dc)</summary> - `pnpm --filter @objectstack/plugin-auth test` at 07fe9fc (after merging origin/main): Test Files 235 passed (235), Tests 4184 passed, 10 skipped. Since then only a comment move in `auth-manager-request-routing.ts` and the removal of one test case; the new file reruns 15/15 at b0a55dc. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (includes `check:test-typecheck`, debt unchanged). - `npx eslint --no-inline-config packages/plugins/plugin-auth`: exit 0. - Derived gates at b0a55dc (`node scripts/pm/dispatch-gates.mjs --commands`): 95 derived, 95 run, every one `:: exit 0`; `--ran` reconciliation: 95 run, 0 NOT-MEASURED, 0 UNRUN. Three gates first answered exit 3 for missing prerequisites (`check-plugin-teardown-shape --self-test` on a shallow clone, `check:skill-examples` and `check:dual-build-cjs-loads` without a full build); fixed by fetching the fixture commit and a full `pnpm build` (119/119), then rerun green. - A first gate pass flagged `check:cross-package-test-inputs`: a docs-table pin read `content/docs/permissions/sso.mdx` from the plugin-auth test. That pin was dropped rather than widening the package's declared inputs. </details> ## Acceptance notes - The cloud#3666 `it.todo` cases for OIDC (wrong audience, expired token, foreign issuer, retired key) can go live against this behaviour; the cloud repo is not edited here. The SAML todos stay todo until the follow-up card lands. - The mechanism depends on the vendor handing its logger the `jose` error object. Ablation 2 shows the per-cause tests go red if that stops, so a bump cannot silently fall back. - The docs table and `SSO_OIDC_REFUSAL_CAUSES` are kept equal by hand: a pin would read outside the package, which `check:cross-package-test-inputs` requires declaring in `scripts/cross-package-test-inputs.mjs` and `turbo.json`, outside this card's file surface. ## Verification Human Minimum: 1 n/a (no spec), 2 yes, 3 yes, 4 yes, 5 yes, 6 n/a, 7 yes, 8 yes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2dEm9wh1JnKi42YPDL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d57be87 commit 30002df

9 files changed

Lines changed: 766 additions & 7 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a refused enterprise OIDC sign-in names its cause
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The SSO callback (`/sso/callback/:providerId`) used to answer every refused ID token with the same redirect, `error=invalid_provider&error_description=token_not_verified`, so an administrator could not tell a wrong audience from an expired token, a foreign issuer or a retired signing key. `error_description` now names the cause: `id_token_expired`, `id_token_not_yet_valid`, `id_token_audience_mismatch`, `id_token_issuer_mismatch`, `id_token_signing_key_unknown` or `id_token_signature_invalid`. A refusal outside those classes keeps `token_not_verified`. `error=` is unchanged. The server writes one `warn` line per refusal with `providerId`, `protocol: 'oidc'` and `cause`, and no token content or account signal. The list is exported as `SSO_OIDC_REFUSAL_CAUSES`.
11+
12+
better-auth's own log lines now go to the `AuthManager` logger at the level better-auth chose (`error` stays `error`, `warn` stays `warn`) instead of the console. With no logger configured they still go to the console. A message that is an error object is logged as its name and message, as better-auth's console logger printed it; the vendor's further arguments pass through unchanged. The vendor's `error` line for a classified ID-token refusal is replaced by the `warn` line above.
13+
14+
SAML refusals are unchanged: `@better-auth/sso` does not expose their cause.

‎content/docs/permissions/sso.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,27 @@ caller gets **401 `UNAUTHENTICATED`** and a signed-in non-platform-admin **403
288288
cannot use these routes to probe which features an environment has enabled.
289289
</Callout>
290290

291+
### Why an OIDC sign-in was refused
292+
293+
When the identity provider's ID token is not accepted, the callback redirects to
294+
your `errorCallbackURL` (or `callbackURL`) with `error=invalid_provider`, and
295+
`error_description` names the cause. The server also writes one `warn` log line
296+
with the `providerId`, `protocol: 'oidc'` and the same `cause`. Neither carries
297+
any token content, and the answer does not depend on whether the account exists.
298+
299+
| `error_description` | What to check |
300+
| :--- | :--- |
301+
| `id_token_expired` | The token's `exp` is in the past. Check the clocks of this server and the IdP. |
302+
| `id_token_not_yet_valid` | The token's `nbf` is in the future. Check the clocks of this server and the IdP. |
303+
| `id_token_audience_mismatch` | The token's `aud` is not this provider's `clientId`. Check the client ID registered here against the IdP application. |
304+
| `id_token_issuer_mismatch` | The token's `iss` is not this provider's `issuer`. Check the issuer URL, including any trailing path or slash. |
305+
| `id_token_signing_key_unknown` | No key in the IdP's JWKS matches the token's key ID, usually after the IdP rotated or retired its signing key. Check the `jwksEndpoint`. |
306+
| `id_token_signature_invalid` | The JWKS key does not verify the token's signature. Check that the `jwksEndpoint` belongs to the same IdP as the `issuer`. |
307+
| `token_not_verified` | Any other refusal of the ID token. The server log carries the detail. |
308+
309+
The list is exported as `SSO_OIDC_REFUSAL_CAUSES` from `@objectstack/plugin-auth`.
310+
SAML refusals do not name their cause yet.
311+
291312
---
292313

293314
## Enterprise SSO (SAML 2.0)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { PLATFORM_IDP_PROVIDER_ID, refuseImplicitAccountLink } from './implicit-
7171
import { isPlaceholderEmail } from './placeholder-email.js';
7272
import { logAfterResponseSendFailure, sendAfterResponseOnPublicDoor } from './run-after-response.js';
7373
import { isVisitorResendInEndpointContext } from './visitor-verification-resend.js';
74+
import { createBetterAuthLogSink } from './sso-oidc-refusal-cause.js';
7475

7576
/** The path better-auth declares for the email sign-up endpoint. */
7677
const SIGN_UP_EMAIL_PATH = '/sign-up/email';
@@ -206,6 +207,12 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
206207
// restart), while auth emails call `getAppName()` on every send.
207208
appName: this.getAppName(),
208209

210+
// better-auth's own log lines go to this manager's logger, level for level,
211+
// instead of the console. The sink is also where an enterprise OIDC
212+
// callback's refused ID token is classified, because the vendor hands that
213+
// error object to its logger and nowhere else (`sso-oidc-refusal-cause.ts`).
214+
logger: createBetterAuthLogSink(() => this.config.logger),
215+
209216
// Database adapter configuration
210217
database: await this.createDatabaseConfig(),
211218

‎packages/plugins/plugin-auth/src/auth-manager-request-routing.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { clientAddressStateEntries } from './request-client-address.js';
6767
import { SESSION_ERASURE_PATHS } from './session-tombstone.js';
6868
import { envelopeVendorAdminRefusal } from './vendor-admin-refusal-envelope.js';
6969
import { OAUTH_REVOKE_PATH } from './mcp-token-revocation.js';
70+
import { ssoOidcCallbackProviderId } from './sso-oidc-refusal-cause.js';
7071

7172
/**
7273
* The `AuthManager` members the functions below read — every one of them, and
@@ -85,10 +86,11 @@ export interface RequestRoutingHost {
8586
resolveClientAddress: AuthManager['resolveClientAddress'];
8687
runSubjectErasureAtomically: AuthManager['runSubjectErasureAtomically'];
8788
answerOAuthTokenRevocation: AuthManager['answerOAuthTokenRevocation'];
89+
runSsoOidcCallback: AuthManager['runSsoOidcCallback'];
8890
}
8991

9092
/** The body of `AuthManager#handleRequest`; the method's docblock there is its contract. */
91-
export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation'>, request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
93+
export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation' | 'runSsoOidcCallback'>, request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
9294
// ADR-0069 D7 — the client address this request's sign-in audit stamps,
9395
// established ONCE, here, by the same trust rule the D5 allow-list judges
9496
// with (`resolveClientAddress`). Read before any rewrite below, and carried
@@ -210,6 +212,10 @@ export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEn
210212
// longer sign in. Nothing tells the operator, and there is no way back.
211213
const endpointPath = this.betterAuthEndpointPath(request);
212214

215+
// An enterprise OIDC callback runs inside the refusal scope that puts the
216+
// cause of a refused ID token on the redirect (`sso-oidc-refusal-cause.ts`).
217+
const ssoOidcProviderId = ssoOidcCallbackProviderId(endpointPath);
218+
213219
// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
214220
// `scimRequestScope`, which is what makes the adapter's `transaction`
215221
// config open a REAL engine transaction around the vendor's provisioning
@@ -230,7 +236,9 @@ export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEn
230236
const { scimRequestScope } = await import('./scim-connection-service.js');
231237
return scimRequestScope.run({ scim: true }, runHandler);
232238
}
233-
: runHandler;
239+
: ssoOidcProviderId !== undefined
240+
? (): Promise<Response> => this.runSsoOidcCallback(ssoOidcProviderId, runHandler)
241+
: runHandler;
234242
// [objectstack-ai/cloud#3606, ruling A] `/oauth2/revoke` is answered a
235243
// second time, AFTER the provider: the provider authenticates the client
236244
// and answers first, unchanged, and the MCP access token it cannot revoke

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ import { resolvePasswordHasher } from './auth-manager-password-hasher.js';
250250
// after-hook (cut 12) and before-hook (cut 13) are now imported there, by the
251251
// two arrows that moved with it.
252252
import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-instance.js';
253+
import { runSsoOidcCallbackScope } from './sso-oidc-refusal-cause.js';
253254
// [#25224 cut 16] The body of `buildPluginList`; the method of the same name
254255
// below hands it the members it reads (`PluginListHost`). The organization
255256
// plugin's factory (cut 15) is now imported there, by the arrow that moved
@@ -3938,16 +3939,26 @@ export class AuthManager {
39383939
*/
39393940
handleRequest(request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
39403941
// [#25224 cut 10] The body moved to `auth-manager-request-routing.ts` (see `RequestRoutingHost`).
3941-
const host: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation'> = {
3942+
const host: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation' | 'runSsoOidcCallback'> = {
39423943
betterAuthEndpointPath: (...a) => this.betterAuthEndpointPath(...a),
39433944
getOrCreateAuth: (...a) => this.getOrCreateAuth(...a),
39443945
resolveClientAddress: (...a) => this.resolveClientAddress(...a),
39453946
runSubjectErasureAtomically: (...a) => this.runSubjectErasureAtomically(...a),
39463947
answerOAuthTokenRevocation: (...a) => this.answerOAuthTokenRevocation(...a),
3948+
runSsoOidcCallback: (...a) => this.runSsoOidcCallback(...a),
39473949
};
39483950
return handleRequest.call(host, request, transport);
39493951
}
39503952

3953+
/**
3954+
* Run one enterprise OIDC callback (`/sso/callback/:providerId`) so its
3955+
* refusal names its cause: the redirect's `error_description` and one `warn`
3956+
* line on this manager's logger (`sso-oidc-refusal-cause.ts`).
3957+
*/
3958+
private runSsoOidcCallback(providerId: string, run: () => Promise<Response>): Promise<Response> {
3959+
return runSsoOidcCallbackScope(providerId, this.config.logger, run);
3960+
}
3961+
39513962
/**
39523963
* [#16025] The `basePath` string this manager hands better-auth: the
39533964
* configured value VERBATIM, or the shipped default when nothing is

‎packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
* this package's adapter or its logger wiring was changed for it, because a
1515
* per-consumer patch would leave the class open for the next logger.
1616
*
17+
* The library's lines reach the manager's logger, through the sink plugin-auth
18+
* hands `betterAuth` (`sso-oidc-refusal-cause.ts`), and the console only when
19+
* no logger is configured. So "the library's carriers" below are both: the
20+
* console, and the manager-logger lines the sink tags `[better-auth]`. The sink
21+
* passes the vendor's arguments through as written, so the error object is
22+
* the same carrier it was on the console.
23+
*
1724
* ## How a driver error is forced, honestly
1825
*
1926
* A UNIQUE index is added to `sys_user.name` after the schema is synced, and a
@@ -350,17 +357,23 @@ for (const cell of CELLS) {
350357
expect(engineWarns).toContain('Update operation failed');
351358
});
352359

360+
/** Every line the auth library wrote: the console, and its lines on the manager logger. */
361+
const libraryLines = (): RecordedCall[] => [
362+
...captured.console,
363+
...captured.manager.calls.filter((c) => String(c.args[0]).startsWith('[better-auth]')),
364+
];
365+
353366
it("the library's error line and its server-error line carry no bound value", () => {
354-
const errorLines = captured.console.filter((l) => l.level === 'error');
367+
const errorLines = libraryLines().filter((l) => l.level === 'error');
355368
expect(errorLines.map((l) => format(...l.args)).some((t) => t.includes('Failed to create user'))).toBe(true);
356369
expect(errorLines.map((l) => format(...l.args)).some((t) => t.includes('DuplicateRecordError'))).toBe(true);
357-
for (const line of captured.console) {
358-
expect(renderCall(line.args).includes(S), `console.${line.level} carries the caller's value`).toBe(false);
370+
for (const line of libraryLines()) {
371+
expect(renderCall(line.args).includes(S), `${line.level} ${String(line.args[0])} carries the caller's value`).toBe(false);
359372
}
360373
});
361374

362375
it("the error object's properties carry no bound value, while its class, code and diagnostic survive", () => {
363-
const errors = captured.console
376+
const errors = libraryLines()
364377
.flatMap((l) => l.args)
365378
.filter((a): a is Error & Record<string, unknown> => a instanceof Error);
366379
for (const error of errors) {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ export * from './otp-send-guard.js';
9393
export * from './rate-limit-storage.js';
9494
export * from './secondary-storage.js';
9595
export * from './register-sso-provider.js';
96+
// The closed list of causes an enterprise OIDC callback names when it refuses
97+
// the IdP's ID token (`error_description` on the redirect, and the log line).
98+
export {
99+
SSO_OIDC_REFUSAL_CAUSES,
100+
SSO_OIDC_UNCLASSIFIED_REFUSAL,
101+
type SsoOidcRefusalCause,
102+
} from './sso-oidc-refusal-cause.js';
96103
export * from './send-verification-email.js';
97104
export * from './objectql-adapter.js';
98105
// [#7732] ADR-0069 D4's revoke-audit trail. Exported alongside the adapter it

0 commit comments

Comments
 (0)