You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-auth SSO: every OIDC and SAML refusal answers one opaque error (token_not_verified / Invalid SAML response), so an admin cannot tell expiry, audience, issuer or key apart #30965
Filing gate: (b) a contract gap with a named consumer. The cloud#3666 dev measured it with offline mock OIDC and SAML IdPs driving a real licensed objectos-ee serve (report 6687166444 on objectstack-ai/cloud#3666; draft PR objectstack-ai/cloud#3673; repro in apps/objectos-ee/test/sso-mock-idp.e2e.test.ts). Platform gaps go to the platform (「平台的问题应该在平台处理」, objectstack#30765); the maintainer gave Enterprise work priority (「cloud 多了几个企业版的任务,优先处理」). Consumer: objectstack-ai/cloud#3666 (Enterprise SSO with Okta / Entra ID).
Measured (cloud pin 2ffdb649 on the serve; @better-auth/sso 1.7.12)
OIDC callback → error=invalid_provider&error_description=token_not_verified, identical for a wrong audience, an expired token, a foreign issuer and a retired signing key.
SAML ACS → error=saml_error&error_description=Invalid SAML response, identical for an expired assertion and an unknown signing key. The server logs only SAML response validation failed. A wrong audience is the only SAML refusal that names its cause.
Expected
A distinct, documented reason per refusal class: audience, expiry / clock skew, issuer, signing key unknown or retired, replay. It goes in the redirect's error code and in one structured server log line with the provider id. It must not leak token contents or say which accounts exist.
The closed error-code vocabulary is extended through its normal door (check:error-code-casing and docs error-code gates).
Acceptance
Tests: each refusal class yields its own code and log line, for OIDC and for SAML. The cloud#3666 it.todo cases flip to live.
Every OIDC and SAML refusal answers one opaque error, so an admin cannot tell audience, expiry, issuer or a retired key apart. Measured on a licensed serve with @better-auth/sso 1.7.12 (cloud#3666).
Raised to p1: new codes in the closed error-code vocabulary are a protocol addition (「协议新增定 p1」), on an Enterprise priority. Lane domain:server, followed by repo:cloud. pm:queue.
⚠️ The codes and the log line must not reveal token contents or whether an account exists, as the card says. A per-cause code is not a per-account oracle.
Triage: answer to domain:server#3 (6687686164) · 2026-11-06T15:17Z
⛔ Not a claim.
The audit above (14:59Z) already holds the grade: p1, raised as a protocol addition.
On the error-code widening: nothing beyond the PR's contract review is needed. New codes enter the closed vocabulary through its normal door: check:error-code-casing plus the docs error-code gates. The changeset level follows the spec lane's rules for a vocabulary addition. That is not a new gate, and not a maintainer decision.
It would need a decision card only if the codes or the log line were to say which account a token was for, or whether an account exists. The card already rules both out.
Serial wait, not a claim. Read 2026-11-07T04:31Z: #31048 has closed, but the open PR #31013 (the per-organization access policy, repo:cloud#3) changes plugin-auth/src/auth-manager-plugin-list.ts (where sso() is mounted), auth-manager-after-hook.ts and auth-plugin.ts. A fix here almost certainly touches the same files, so this card dispatches after #31013 merges. Re-read #31013's state before claiming.
Claim: PM loop round 1 · 2026-11-07T14:17Z
Session: session_017ihL2dFb2QdZEHEJ83cR2B
Account: marchtian
Branch: claude/project-thread-bqq14o
Worktree: objectstack-issue-30965 (the card thread's own checkout)
Domain: domain:server
Seat: domain:server#3
File surface: packages/plugins/plugin-auth/src/** (an SSO-refusal module + tests, the sso() mount), the error-code ledger / docs error-code page the gates name, .changeset/**; a packages/spec/** touch owes a full Fable review before ACCEPT (stop on breach; explain in the report)
Container & model: M, mode:thread (card thread cmsg_01VmuUhXU9SSUzqJYyXSmYSiXg46wP2iyXrx3QP2KhstCt of this account's project), model: TIER_DEFAULT — dispatch-gates --tier: no path-derived mandate, effort: session default — card-thread effort is set by the maintainer only; the seat's reading is high (M, measure-first with two stop points)
Clause-②: yes (widening) — new codes enter a closed error vocabulary; the dev confirms against the diff
Metadata-protocol: none
Responsibility: n/a — gap card (filing gate b, consumer cloud#3666), not a regression
Thread-read: 6702285648
Premise: holds — neither token_not_verified nor Invalid SAML response appears in packages/**; both come from @better-auth/sso 1.7.12 behind sso() in auth-manager-plugin-list.ts. Measure first: if the cause is readable only by patching the package, or the redirect code fits no vocabulary, the thread stops and reports
Serial constraints cleared: PR #31013 (repo:cloud#3, touching auth-manager-plugin-list.ts / auth-manager-after-hook.ts / auth-plugin.ts) MERGED 2026-11-07T13:45:15Z, the wait seat 1 named in 6702285648; #30967 and #30966 stay serial behind this card
The dispatch order is the card thread's opening message (files server-seat-3/orders/_standing.md + server-seat-3/orders/30965.md in this account's project); slot granted by the project coordinator.
Measure-first stop (order step 1 for SAML; order step 2 for the code slot). No code written, no PR. Reading tree: objectstack-ai/objectstack@af3451b0, installed @better-auth/sso 1.7.12 (dist/index.mjs), samlify 2.13.1, @better-auth/core 1.7.12.
{
"issue": 30965,
"status": "needs_decision",
"branch": "claude/project-thread-bqq14o",
"pr": null,
"session": "session_017ihL2dEm9wh1JnKi42YPDL",
"premise_still_valid": true,
"summary": "Premise holds: both opaque refusals are decided inside @better-auth/sso 1.7.12. OIDC: the cause IS readable without patching, but only through a side channel. handleOIDCCallback catches the jose error from validateOIDCIdToken, calls ctx.context.logger.error(error) with the error object, returns null, then redirects error=invalid_provider&error_description=token_not_verified (dist/index.mjs L3921-3927). betterAuth({ logger: { log } }) receives that object (core dist/env/logger.mjs createLogger: options.log(level, message, ...args)), so plugin-auth can classify it by the jose error code (ERR_JWT_EXPIRED, ERR_JWT_CLAIM_VALIDATION_FAILED with claim aud or iss, ERR_JWKS_NO_MATCHING_KEY, ERR_JWS_SIGNATURE_VERIFICATION_FAILED) if it scopes each request with its own AsyncLocalStorage and rewrites the redirect Location afterwards. plugin-auth passes no logger to betterAuth today, so this also moves every better-auth log line from console to the kernel logger. SAML: the cause is NOT readable without patching. processSAMLResponse wraps sp.parseLoginResponse in a bare catch with no binding (L2883-2893), logs the constant 'SAML response validation failed' and throws a constant APIError('Invalid SAML response'); the ACS endpoint turns that into error=saml_error (L4265-4280). samlify's own causes (FAILED_TO_VERIFY_SIGNATURE, ERR_UNMATCH_ISSUER, ERR_SUBJECT_UNCONFIRMED for NotBefore/NotOnOrAfter, ERR_EXPIRED_SESSION; samlify build/src/flow.js postFlow) never leave the package. samlify checks time inside parseLoginResponse before better-auth's own validateSAMLTimestamp runs, so expiry is always the opaque path. Already distinct today and out of this gap: SAML replay (replay_detected, L2983-2990), SAML audience/recipient (invalid_saml_response with the binding message, L2908-2928), InResponseTo failures (logged by name, L1516-1543).",
"tests": "none run: stop before the main edit, as the order directs. Measurement is source reading of the installed dist files named in summary, line numbers against the trees named above.",
"gates": "NOT MEASURED: all families, reason: no diff (stop before the main edit).",
"dogfood_battery": "n/a: no diff",
"mcp_calls": "0",
"api_writes": "1: this comment, through scripts/pm/post-stamped.mjs (relay). Reads only otherwise (REST GET on #30965 and PR #31013).",
"open_questions": [
{
"question": "Q1 (stop point 1, SAML): the SAML cause is discarded by a bare catch inside @better-auth/sso. How do we get it?",
"options": [
"A: upstream first. Open a better-auth issue/PR so processSAMLResponse keeps the samlify error (cause in the redirect or a hook), then bump the pin. Cleanest; timing is the vendor's.",
"B: diagnose in plugin-auth. On a SAML refusal, plugin-auth re-parses the posted SAMLResponse with samlify (new direct dependency) against the stored provider config, in samlify's own order (signature, issuer, time), only to LABEL a refusal that already happened, never to accept. createSP/createIdP are private in @better-auth/sso, so plugin-auth owns a second copy of the SP/IdP construction; drift is caught only by tests on each vendor bump. Size L.",
"C: pnpm patch @better-auth/sso. patchedDependencies applies only inside this workspace; cloud objectos-ee installs @objectstack/plugin-auth from npm and would not receive the patch, so it does not close cloud#3666. Not recommended.",
"D: split. Land OIDC now (it needs no patch), move SAML to a follow-up card behind option A, with B as the fallback if upstream stalls."
],
"recommendation": "D, with A for SAML. OIDC is readable today without a patch and covers half of the card; SAML through B means owning a second copy of the vendor's SAML validation setup, which is the parallel-implementation shape AGENTS.md route rule 1 warns about."
},
{
"question": "Q2 (stop point 2): which vocabulary carries the per-cause value?",
"options": [
"V1: keep error= unchanged (invalid_provider / saml_error); put a lowercase cause token in error_description (for example id_token_expired, id_token_audience_mismatch, id_token_issuer_mismatch, id_token_signing_key_unknown). This is the dialect @better-auth/sso already speaks in that slot (token_not_verified, id_token_subject_missing). The tokens live as one exported closed list in plugin-auth plus a docs line. No packages/spec change, no ADR change.",
"V2: add a new redirect parameter (for example error_code=SSO_ID_TOKEN_EXPIRED) carrying new ADR-0112 ledger members. That extends ADR-0112 beyond its scope (it governs error.code in a failing request's JSON body at the doors; a 302 Location query is not a door), so it needs an ADR-0112 amendment (Tier H) and a packages/spec ledger change (full Fable review).",
"V3: ledger members (SCREAMING) inside error_description. Mixes two dialects in one slot; not recommended."
],
"recommendation": "V1. RFC 6749 section 4.1.2.1 makes error a closed registry and error_description human-readable; better-auth already uses error_description for machine-readable lowercase tokens; ADR-0112 does not reach a redirect. The card's 'normal door' line (check:error-code-casing) assumed an ADR-0112 door that this surface does not have."
}
],
"security_findings": "none",
"out_of_scope_findings": [
"carrier: none · noted, not filed: plugin-auth passes no logger to betterAuth, so every better-auth log line (including the SAML/OIDC refusal lines) goes to console, not the kernel logger. Option D/V1 would change that as part of this card; listed so the decision sees it."
]
}
企业客户把 Okta / Entra ID 接到平台做单点登录时,任何一处配置错了(受众填错、令牌过期或时钟不准、签发方不对、签名证书换了)用户看到的都是同一句「登录失败」,服务器日志也只有一行「验证失败」。管理员无法自助定位,只能提工单;支持也只能让客户「把配置截图发来」。这张卡要让平台对每一类拒绝给出一个稳定、可查文档的原因,并在服务器留一条带供应商 id 的结构化日志。
AADSTS50006 "Signature verification failed…"、AADSTS50008 "SAML assertion is missing or misconfigured…"、AADSTS500133 "Assertion isn't within its valid time range."、AADSTS399284 invalid issuer、AADSTS50010 audience
learn.microsoft.com reference-error-codes
Okta
System Log,outcome.result eq "FAILURE" 过滤失败登录;具体 outcome.reason 文案本线程未能核到
田騏 decided at 2026-11-07T15:29:04Z on the project's decision card. The question was 「#30965 SSO 拒绝原因码,选哪条路?」 and they chose option 0, 「D + A + V1(推荐)」. Its stated consequence: 「OIDC 现在落,原因放 error_description 小写 token;SAML 另立卡推上游 PR,两周无进展再议 B。」 The analysis is 6708199006, and its section 7 is the execution plan.
Execution:
This card narrows to OIDC. plugin-auth passes its own logger to betterAuth. An AsyncLocalStorage scope ties each callback request to the jose error object that handleOIDCCallback logs, and the redirect Location is rewritten. error= is unchanged. error_description carries one token from a closed list exported by plugin-auth: id_token_expired, id_token_not_yet_valid, id_token_audience_mismatch, id_token_issuer_mismatch, id_token_signing_key_unknown, id_token_signature_invalid. An unclassified refusal keeps token_not_verified. Each refusal also writes one structured warn line (providerId, protocol, cause). It carries no token content and no account signal. The tokens get one docs page. There is no change to packages/spec or ADR-0112.
Premise the dev checks first:options.logger.log must receive the error object, not a string, on the installed @better-auth/core. If it does not, the dev stops and reports the fork.
Moving better-auth's log lines to the kernel logger maps their levels by AGENTS.md「Degradation log levels」.
SAML becomes a follow-up card that inherits this ruling and does not go back to the decision box. The seat drafts the better-auth upstream PR text. Submitting it needs 田騏's account or an authorization, because the upstream repository is outside this project's write scope. The card waits on that upstream merge. If the upstream PR has not moved in two weeks, option B goes back to the box once, with its preconditions stated. SAML tokens use the same list and docs page: assertion_expired, assertion_not_yet_valid, assertion_issuer_mismatch, assertion_signing_key_unknown, assertion_signature_invalid.
State: needs-user-decision → pm:dispatched. The claim 6708073906 stands and the same card thread (branch claude/project-thread-bqq14o) resumes. The card's carrier repo, scope and Clause-② change as follows: Clause-②: no replaces the claim's yes (widening), because V1 leaves the closed error-code vocabulary unchanged.
{
"issue": 30965,
"status": "done",
"branch": "claude/project-thread-bqq14o",
"pr": "https://github.com/objectstack-ai/objectstack/pull/31194",
"head": "b0a55dc3cee8b9b252f0d834b89ba2da585d4608",
"session": "session_017ihL2dEm9wh1JnKi42YPDL",
"premise_still_valid": true,
"premise_check": "Ruling premise holds: on installed @better-auth/core 1.7.12, a custom logger.log receives the jose error OBJECT as message (probe: level error, ctor JWTExpired, code ERR_JWT_EXPIRED, claim exp), and the real /sso/callback path delivers it (the six per-cause E2E cases pass only through that channel; ablation 2 removes it and they go red).",
"summary": "OIDC only, per ruling 6709033929 (D + A + V1). New sso-oidc-refusal-cause.ts: plugin-auth now passes betterAuth a logger sink; AuthManager.handleRequest wraps /sso/callback/:providerId in an AsyncLocalStorage scope (new host member runSsoOidcCallback). Inside it the sink holds an error line whose message is a jose error with a classified code/claim; when the vendor answers error=invalid_provider&error_description=token_not_verified, that description segment is replaced byte-exactly with the cause, one warn line {providerId, protocol:'oidc', cause} is written, and the held vendor line is dropped. Other answers release held lines unchanged. Closed list exported as SSO_OIDC_REFUSAL_CAUSES (id_token_expired, id_token_not_yet_valid, id_token_audience_mismatch, id_token_issuer_mismatch, id_token_signing_key_unknown, id_token_signature_invalid); unclassified keeps token_not_verified and still gets the warn line. Docs: sso.mdx section 'Why an OIDC sign-in was refused'. Changeset: plugin-auth patch.",
"tests": "sso-oidc-refusal-cause.test.ts 15/15 at b0a55dc3: real AuthManager + real sso() over the in-memory engine, /sign-in/sso -> /sso/callback, only the IdP token/JWKS endpoints stubbed, ID tokens signed locally (RS256): CONTROL sign-in, one case per cause, unclassified (azp) keeps token_not_verified, no-leak pin (token, payload segment, sub, email, name absent from Location and every logger channel; known vs unknown account give identical redirect and line), unit cases for classification by code/claim, path scope, byte-exact rewrite, held-line release. Full suite at 07fe9fcb after merging origin/main: Test Files 235 passed (235), Tests 4184 passed | 10 skipped. Ablations via scripts/ablation-replace.mjs, each restored (blob == HEAD, git diff HEAD empty): (1) side channel off -> 7 failed / 9 passed; (2) logger sink not passed to betterAuth -> 7 failed / 9 passed; (3) message rendering leaks error properties -> 2 failed / 14 passed. No build/dist leg: the tests import src through vitest.",
"gates": "At b0a55dc3: dispatch-gates --commands derived 95; all 95 run, each ':: exit 0'; --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). First-pass exit 3s fixed and rerun: check-plugin-teardown-shape --self-test (fetched fixture commit 621a4876), check:skill-examples and check:dual-build-cjs-loads (full pnpm build 119/119). pnpm --filter @objectstack/plugin-auth typecheck :: exit 0 (check:test-typecheck debt unchanged). npx eslint --no-inline-config packages/plugins/plugin-auth :: exit 0. CI check-runs: in_progress at report time.",
"dogfood_battery": "n/a: no ! in the PR title and Clause-② no",
"line_budget": "+~690 / -~10 across 8 files (two new files are most of it)",
"files_changed": [
"packages/plugins/plugin-auth/src/sso-oidc-refusal-cause.ts (new)",
"packages/plugins/plugin-auth/src/sso-oidc-refusal-cause.test.ts (new)",
"packages/plugins/plugin-auth/src/auth-manager.ts",
"packages/plugins/plugin-auth/src/auth-manager-request-routing.ts",
"packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts",
"packages/plugins/plugin-auth/src/index.ts",
"packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts",
"content/docs/permissions/sso.mdx",
".changeset/30965-sso-oidc-refusal-cause.md"
],
"deviations": [
"driver-fault-auth-log-carriers.test.ts edited (inside the declared plugin-auth/src surface): better-auth lines now reach the manager logger, so its carrier assertions read console + manager-logger lines tagged [better-auth]; assertions otherwise unchanged.",
"No docs-list pin: a pin reading sso.mdx from a plugin-auth test needs scripts/cross-package-test-inputs.mjs + turbo.json declarations (outside the file surface), so it was dropped; docs and export are kept equal by hand (PR Acceptance notes).",
"PR attribution line links THIS card thread, not the seat thread named in _standing.md: the harness's attribution instruction for this session requires its own thread link verbatim.",
"Clause-② no / Metadata-protocol none as the ruling restated (claim said yes (widening)); changeset patch, because kind none takes patch (AGENTS.md Post-Task step 3); the new export is a runtime constant, not an authorable key."
],
"mcp_calls": "1: subscribe_pr_activity (not a GitHub write tool). No mcp__github__* calls.",
"api_writes": "3 relay writes: comment 6708108101 (needs_decision report), pr_create #31194 (fleet-write run 42394118837), this comment. Plus git push of the branch.",
"open_questions": [],
"security_findings": "none",
"out_of_scope_findings": [
"carrier: seat (SAML follow-up card per ruling item 2) · noted, not filed: SAML cause still discarded inside @better-auth/sso processSAMLResponse (bare catch around parseLoginResponse)."
]
}
Activity
objectstack-fleet commentedon Nov 6, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: audit · p2 → p1 ·
domain:server· 2026-11-06T14:59Z⛔ Not a claim.
@better-auth/sso1.7.12 (cloud#3666).domain:server, followed byrepo:cloud.pm:queue.Generated by Claude Code
objectstack-fleet commentedon Nov 6, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: answer to
domain:server#3(6687686164) · 2026-11-06T15:17Z⛔ Not a claim.
check:error-code-casingplus the docs error-code gates. The changeset level follows the spec lane's rules for a vocabulary addition. That is not a new gate, and not a maintainer decision.Generated by Claude Code
objectstack-fleet commentedon Nov 7, 2026
domain:server#1 · sunhaolin · session_017xRBcTJTE7kSbcMoJQ4Txf
Serial wait, not a claim. Read 2026-11-07T04:31Z: #31048 has closed, but the open PR #31013 (the per-organization access policy,
repo:cloud#3) changesplugin-auth/src/auth-manager-plugin-list.ts(wheresso()is mounted),auth-manager-after-hook.tsandauth-plugin.ts. A fix here almost certainly touches the same files, so this card dispatches after #31013 merges. Re-read #31013's state before claiming.objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B
Claim: PM loop round 1 · 2026-11-07T14:17Z
Session:
session_017ihL2dFb2QdZEHEJ83cR2BAccount:
marchtianBranch:
claude/project-thread-bqq14oWorktree:
objectstack-issue-30965(the card thread's own checkout)Domain:
domain:serverSeat:
domain:server#3File surface:
packages/plugins/plugin-auth/src/**(an SSO-refusal module + tests, thesso()mount), the error-code ledger / docs error-code page the gates name,.changeset/**; apackages/spec/**touch owes a full Fable review before ACCEPT (stop on breach; explain in the report)Container & model:
M,mode:thread(card thread cmsg_01VmuUhXU9SSUzqJYyXSmYSiXg46wP2iyXrx3QP2KhstCt of this account's project),model: TIER_DEFAULT — dispatch-gates --tier: no path-derived mandate,effort: session default — card-thread effort is set by the maintainer only; the seat's reading is high (M, measure-first with two stop points)Clause-②: yes (widening) — new codes enter a closed error vocabulary; the dev confirms against the diff
Metadata-protocol: none
Responsibility: n/a — gap card (filing gate b, consumer cloud#3666), not a regression
Thread-read: 6702285648
Premise: holds — neither
token_not_verifiednorInvalid SAML responseappears inpackages/**; both come from@better-auth/sso1.7.12 behindsso()inauth-manager-plugin-list.ts. Measure first: if the cause is readable only by patching the package, or the redirect code fits no vocabulary, the thread stops and reportsSerial constraints cleared: PR #31013 (repo:cloud#3, touching
auth-manager-plugin-list.ts/auth-manager-after-hook.ts/auth-plugin.ts) MERGED 2026-11-07T13:45:15Z, the wait seat 1 named in 6702285648; #30967 and #30966 stay serial behind this cardThe dispatch order is the card thread's opening message (files
server-seat-3/orders/_standing.md+server-seat-3/orders/30965.mdin this account's project); slot granted by the project coordinator.objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2dEm9wh1JnKi42YPDL
os-dev-report
Measure-first stop (order step 1 for SAML; order step 2 for the code slot). No code written, no PR. Reading tree:
objectstack-ai/objectstack@af3451b0, installed@better-auth/sso1.7.12 (dist/index.mjs),samlify2.13.1,@better-auth/core1.7.12.{ "issue": 30965, "status": "needs_decision", "branch": "claude/project-thread-bqq14o", "pr": null, "session": "session_017ihL2dEm9wh1JnKi42YPDL", "premise_still_valid": true, "summary": "Premise holds: both opaque refusals are decided inside @better-auth/sso 1.7.12. OIDC: the cause IS readable without patching, but only through a side channel. handleOIDCCallback catches the jose error from validateOIDCIdToken, calls ctx.context.logger.error(error) with the error object, returns null, then redirects error=invalid_provider&error_description=token_not_verified (dist/index.mjs L3921-3927). betterAuth({ logger: { log } }) receives that object (core dist/env/logger.mjs createLogger: options.log(level, message, ...args)), so plugin-auth can classify it by the jose error code (ERR_JWT_EXPIRED, ERR_JWT_CLAIM_VALIDATION_FAILED with claim aud or iss, ERR_JWKS_NO_MATCHING_KEY, ERR_JWS_SIGNATURE_VERIFICATION_FAILED) if it scopes each request with its own AsyncLocalStorage and rewrites the redirect Location afterwards. plugin-auth passes no logger to betterAuth today, so this also moves every better-auth log line from console to the kernel logger. SAML: the cause is NOT readable without patching. processSAMLResponse wraps sp.parseLoginResponse in a bare catch with no binding (L2883-2893), logs the constant 'SAML response validation failed' and throws a constant APIError('Invalid SAML response'); the ACS endpoint turns that into error=saml_error (L4265-4280). samlify's own causes (FAILED_TO_VERIFY_SIGNATURE, ERR_UNMATCH_ISSUER, ERR_SUBJECT_UNCONFIRMED for NotBefore/NotOnOrAfter, ERR_EXPIRED_SESSION; samlify build/src/flow.js postFlow) never leave the package. samlify checks time inside parseLoginResponse before better-auth's own validateSAMLTimestamp runs, so expiry is always the opaque path. Already distinct today and out of this gap: SAML replay (replay_detected, L2983-2990), SAML audience/recipient (invalid_saml_response with the binding message, L2908-2928), InResponseTo failures (logged by name, L1516-1543).", "tests": "none run: stop before the main edit, as the order directs. Measurement is source reading of the installed dist files named in summary, line numbers against the trees named above.", "gates": "NOT MEASURED: all families, reason: no diff (stop before the main edit).", "dogfood_battery": "n/a: no diff", "mcp_calls": "0", "api_writes": "1: this comment, through scripts/pm/post-stamped.mjs (relay). Reads only otherwise (REST GET on #30965 and PR #31013).", "open_questions": [ { "question": "Q1 (stop point 1, SAML): the SAML cause is discarded by a bare catch inside @better-auth/sso. How do we get it?", "options": [ "A: upstream first. Open a better-auth issue/PR so processSAMLResponse keeps the samlify error (cause in the redirect or a hook), then bump the pin. Cleanest; timing is the vendor's.", "B: diagnose in plugin-auth. On a SAML refusal, plugin-auth re-parses the posted SAMLResponse with samlify (new direct dependency) against the stored provider config, in samlify's own order (signature, issuer, time), only to LABEL a refusal that already happened, never to accept. createSP/createIdP are private in @better-auth/sso, so plugin-auth owns a second copy of the SP/IdP construction; drift is caught only by tests on each vendor bump. Size L.", "C: pnpm patch @better-auth/sso. patchedDependencies applies only inside this workspace; cloud objectos-ee installs @objectstack/plugin-auth from npm and would not receive the patch, so it does not close cloud#3666. Not recommended.", "D: split. Land OIDC now (it needs no patch), move SAML to a follow-up card behind option A, with B as the fallback if upstream stalls." ], "recommendation": "D, with A for SAML. OIDC is readable today without a patch and covers half of the card; SAML through B means owning a second copy of the vendor's SAML validation setup, which is the parallel-implementation shape AGENTS.md route rule 1 warns about." }, { "question": "Q2 (stop point 2): which vocabulary carries the per-cause value?", "options": [ "V1: keep error= unchanged (invalid_provider / saml_error); put a lowercase cause token in error_description (for example id_token_expired, id_token_audience_mismatch, id_token_issuer_mismatch, id_token_signing_key_unknown). This is the dialect @better-auth/sso already speaks in that slot (token_not_verified, id_token_subject_missing). The tokens live as one exported closed list in plugin-auth plus a docs line. No packages/spec change, no ADR change.", "V2: add a new redirect parameter (for example error_code=SSO_ID_TOKEN_EXPIRED) carrying new ADR-0112 ledger members. That extends ADR-0112 beyond its scope (it governs error.code in a failing request's JSON body at the doors; a 302 Location query is not a door), so it needs an ADR-0112 amendment (Tier H) and a packages/spec ledger change (full Fable review).", "V3: ledger members (SCREAMING) inside error_description. Mixes two dialects in one slot; not recommended." ], "recommendation": "V1. RFC 6749 section 4.1.2.1 makes error a closed registry and error_description human-readable; better-auth already uses error_description for machine-readable lowercase tokens; ADR-0112 does not reach a redirect. The card's 'normal door' line (check:error-code-casing) assumed an ADR-0112 door that this surface does not have." } ], "security_findings": "none", "out_of_scope_findings": [ "carrier: none · noted, not filed: plugin-auth passes no logger to betterAuth, so every better-auth log line (including the SAML/OIDC refusal lines) goes to console, not the kernel logger. Option D/V1 would change that as part of this card; listed so the decision sees it." ] }Governing text: ADR-0112 D1/D4 (scope:
error.codeat the doors); AGENTS.md Route & surface ownership rule 1; RFC 6749 section 4.1.2.1.Dedupe words:
token_not_verified,Invalid SAML response,SAML response validation failed,parseLoginResponse.objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B
转入决定待做 · 2026-11-07T14:22Z
卡线程按派发令先做测量,两个停点都触发了,没有写代码,也没有开 PR。测量报告见 6708108101。本卡从
pm:dispatched转为needs-user-decision,退出活动队列。assignee 保持 marchtian,裁定后由本席位原线程继续做。待裁两问(选项和推荐都在 6708108101 里):
@better-auth/sso1.7.12 在processSAMLResponse里用不带变量的catch吞掉了 samlify 的错误,不改包就读不到原因。pnpm patch只对本仓库生效,到不了 cloud 从 npm 安装的 plugin-auth。OIDC 的原因可以通过 logger 侧通道读到,不需要打补丁。线程推荐 D:先落 OIDC,SAML 拆成独立卡走上游修复(A),上游没有进展再退到本地诊断(B)。error.code,管不到 302 跳转的查询参数。线程推荐 V1:error=保持不变,error_description里放小写的原因 token(这本来就是 better-auth 在这个位置的写法),由 plugin-auth 导出闭合清单,不动packages/spec和 ADR。Governing text: ADR-0112 D1/D4;AGENTS.md Route & surface ownership 规则 1;RFC 6749 §4.1.2.1。
完整的四维分析由决策线程补在本卡。#30967、#30966 仍排在本卡之后串行。
objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2eBbwbce6Wa8DyS1ov
决策分析 · #30965 SSO 拒绝按原因给码 · 2026-11-07T14:29Z
读取基线:
objectstack-ai/objectstack@af3451b0;@better-auth/sso1.7.12(仓内锁定)、1.7.18(npm latest)、1.8.0-beta.6 三个版本的dist/index.mjs本线程逐一下载核对。只读分析,无代码、无 PR。卡线程量测报告:6708108101;席位转入决定待做:6708125480。0. 一句话问题
企业客户把 Okta / Entra ID 接到平台做单点登录时,任何一处配置错了(受众填错、令牌过期或时钟不准、签发方不对、签名证书换了)用户看到的都是同一句「登录失败」,服务器日志也只有一行「验证失败」。管理员无法自助定位,只能提工单;支持也只能让客户「把配置截图发来」。这张卡要让平台对每一类拒绝给出一个稳定、可查文档的原因,并在服务器留一条带供应商 id 的结构化日志。
1. 现状事实(本线程复核)
@better-auth/sso内部,我们源码里没有。OIDC 路径:验证 id_token 的 jose 错误对象被ctx.context.logger.error(error)记一下就丢掉,然后统一跳转error=invalid_provider&error_description=token_not_verified。SAML 路径:sp.parseLoginResponse外面是一个不带变量的catch,samlify 的原因(签名、签发方、NotBefore/NotOnOrAfter)在包内被吞掉,统一抛Invalid SAML response。catch,所以选项 A 是「我们去推上游」,不是「升级即得」。但修法很小:ACS 端点已经会把APIError.body.code小写后放进error=,上游只需在那个catch里把 samlify 的错误码映射进APIError的code,改动约二三十行。better-auth 当前发版节奏是两三天一版(11 月 3、4、5 日各一版)。betterAuth({ logger })传自己的 logger,用 AsyncLocalStorage 把本次请求与 logger 收到的 jose 错误对象对上,再改写跳转的 Location。代价是 better-auth 全部日志从 console 转到内核 logger(这本身是 AGENTS.md「Degradation log levels」想要的方向,但要按级别映射)。invalid_saml_response+ 原文)、重放(replay_detected)、InResponseTo。分不清的只有签名、签发方、时间三类。?error=渲染成页内横幅(auth-manager-auth-instance.tsonAPIError.errorURL 注释),所以原因 token 一落到跳转参数,管理员在浏览器里就能看到。pnpm patch先例(patches/:tsup、tsx、radix),但都是构建工具;cloud 的 objectos-ee 从 npm 安装@objectstack/plugin-auth,仓内补丁到不了它,选项 C 对 cloud#3666 无效。Governing text: ADR-0112 D1/D4(
error.code只管失败请求 JSON 体,302 跳转的查询参数不是它的门);AGENTS.md「Route & surface ownership」规则 1(一条路一个主人,不做并行实现);AGENTS.md「Degradation log levels」;RFC 6749 §4.1.2.1(error闭合注册表、error_description人读文本)。ADR-0069 D6 只裁了「SAML 走@better-auth/sso评估」,未裁拒绝原因的呈现。2. 主流平台怎么做(SP 侧对管理员的失败诊断)
sso_saml_validation_errorserror+error_codes,管理中心 Sign-in logs 里「Sign-in error code」+「Failure reason」reference-error-codesoutcome.result eq "FAILURE"过滤失败登录;具体outcome.reason文案本线程未能核到system-log-query核实边界:Salesforce 与 Entra 两家为原文核对;Okta 只核到失败事件存在;Keycloak、Auth0 未核到,不作论据。
共同点:成熟平台都把 签名 / 签发方 / 受众 / 时间 / 密钥 当作互相独立、各有固定码的拒绝类,并且把它们同时放在两个地方——登录错误页(给当场的用户或管理员)和一条管理员可查的登录事件(给事后排查)。没有一家把原因塞进对外 API 的错误码总表;跳转参数与事件日志是两套并行词表。这和卡线程的 V1 + 结构化日志判断一致。
3. Q1:SAML 的原因怎么拿到
catch里把 samlify 错误码映射进APIError.code;合并后升 pincreateSP/createIdP私有构造)· 无先例pnpm patch @better-auth/sso业务含义直译:A = 找电梯厂商修故障显示屏;B = 自己在厂商显示屏旁再装一台;C = 只修自家楼里那台,客户楼里的不动;D = 先把能修的一半修好,另一半去找厂商。
B 还有一个「B-lite」变体:不引 samlify,只把 SAMLResponse 解码后读
Issuer与Conditions两个字段贴标签。它不复制签名校验,但会误诊——samlify 先验签名再看时间,一条既过期又签名坏的断言会被贴成「过期」。不推荐,列出供参考。4. Q2:原因码放在哪一层词表
error=不变(invalid_provider/saml_error);error_description从token_not_verified变成id_token_expired之类;plugin-auth 导出一张闭合清单 + 文档一页error=分支的行为不变packages/spec、不改 ADRerror_code=SSO_ID_TOKEN_EXPIRED,进ERROR_CODE_LEDGERerror_description写它的 token(如id_token_subject_missing),结果是两套并存业务含义直译:V1 = 快递单大类不变,小字从「异常」改成「地址不详 / 超时未取」;V2 = 为登录页的错误单独立一本和 API 共用的编码手册。
5. 四轴(从业务立场)
① 项目长远合理性(权重 ≥50%)。 两年后这个能力的样子:每个 SSO 供应商一张「登录事件」清单(时间、协议、结果、原因码),管理员在控制台按供应商筛查,错误页只是即时提示——Salesforce 的 Login History + SAML Validator、Keycloak 的 Events、Entra 的 Sign-in logs 都是这个形态。所以真正的长期契约是那条结构化日志(供应商 id、协议、原因码),跳转参数是近期载体。V1 不往 ADR-0112 台账里塞只在 302 里出现的成员,不扩大特例;D + A 让 SAML 诊断留在供应商那一层,我们只做映射,不养第二套 SAML 校验(B 是规则 1 点名的平行实现,每次升级都要重验)。V2 把 OAuth 跳转位纳入 API 错误码体系,是契约增生,而且压不住 better-auth 自己继续写
error_description的事实。② 实际业务拉动。 实测拉动:cloud#3666(Enterprise,Okta / Entra),两种协议都测了,
it.todo等着翻绿。要诚实说:Entra 的企业应用默认走 SAML,Okta 两者都常见,SAML 不是「次要的一半」。D 的代价就是 SAML 客户还要等上游一到两周;换来的是 OIDC 客户立刻可自助排查、且不新增永久依赖。③ 防 AI 犯错。 V1 的 token 是 plugin-auth 导出的闭合清单(
as const联合类型),每类拒绝一条测试、一条日志断言,外加消融测试(把映射折回单码,整组变红);logger 侧通道要对供应商升级「响亮失败」——jose 错误对象不再到达时测试必须红,不能静默退回token_not_verified。出错时谁看到什么:管理员看到带原因的横幅 + 一行带供应商 id 的日志;绝不出现令牌内容、断言 XML、NameID、邮箱,也不透露账号是否存在(同一原因码对已知与未知用户一致)。B 的第二份 SP/IdP 构造是 AI 最容易悄悄写歪的地方:两台检验台结论不一致时没有任何门会红。④ 创业阶段不扩散。 V1 零新 spec 面、零 ADR 修订;D 不新增 samlify 直接依赖;A 把 SAML 诊断的长期维护留在供应商。B 是一个 L 号、随每次供应商升级要重验的永久义务;V2 多一轮 Tier H 评审换来的只是口径整齐。
6. 推荐 + 置信缺口
只看①选 D + A(SAML)+ V1;②③④ 是否翻转:否(②把 SAML 的等待写成有期限的执行参数,不翻字母)。
不取理由各一句:B 养第二台检验台且会误诊;C 到不了 cloud;V2 为跳转参数改 ADR 并压不住上游方言;V3 混方言。
盲区:① 上游 PR 的时效由供应商决定,本线程只核了「还没修、改动小、发版快」;② logger 侧通道依赖 better-auth 把错误对象(不是字符串)交给
options.logger.log,卡线程按@better-auth/core1.7.12 读的,升到 1.7.18 要再核一次;③ 上游仓库不在本项目写入范围内,提 PR 需要田騏本人账号或把仓库开放给席位。7. 裁后执行(田騏只裁方向)
providerId、protocol、cause,级别按 AGENTS.md「Degradation log levels」(客户配置错误属 warn),禁令按卡面(无令牌内容、无账号信号)。id_token_expired、id_token_not_yet_valid、id_token_audience_mismatch、id_token_issuer_mismatch、id_token_signing_key_unknown、id_token_signature_invalid;未能归类的保留token_not_verified。Blocked-by:上游合并。设一个期限(建议两周),到期上游无进展再把 B 作为带前提的备选回箱一次。assertion_expired、assertion_not_yet_valid、assertion_issuer_mismatch、assertion_signing_key_unknown、assertion_signature_invalid。os-decision-facets
① 项目长远合理性:V1 + D 不扩大 ADR-0112 台账、不养第二套 SAML 校验;长期契约是结构化登录事件日志,跳转 token 只是近期载体
② 实际业务拉动:cloud#3666 Enterprise(Okta / Entra)实测,两种协议都撞上;SAML 半边等上游是 D 的真实代价
③ 防 AI 犯错:闭合导出清单 + 逐类测试 + 消融 + 侧通道失效即红;绝不泄露令牌内容与账号存在性;B 的平行校验是静默歪掉的温床
④ 创业阶段不扩散:零 spec/ADR 面、零新依赖;B 是永久 L 号义务,V2 是 Tier H 换口径
Prior rulings read: sso,saml,oidc,error_description,redirect,better-auth → 39 hits; ADR-0069 D6, ADR-0112 D1/D4; thread: 6708108101, 6708125480; repo: objectstack-ai/objectstack
objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B
Ruling: D + A + V1 · 2026-11-07T15:30Z
田騏 decided at 2026-11-07T15:29:04Z on the project's decision card. The question was 「#30965 SSO 拒绝原因码,选哪条路?」 and they chose option 0, 「D + A + V1(推荐)」. Its stated consequence: 「OIDC 现在落,原因放 error_description 小写 token;SAML 另立卡推上游 PR,两周无进展再议 B。」 The analysis is 6708199006, and its section 7 is the execution plan.
Execution:
loggertobetterAuth. An AsyncLocalStorage scope ties each callback request to the jose error object thathandleOIDCCallbacklogs, and the redirectLocationis rewritten.error=is unchanged.error_descriptioncarries one token from a closed list exported by plugin-auth:id_token_expired,id_token_not_yet_valid,id_token_audience_mismatch,id_token_issuer_mismatch,id_token_signing_key_unknown,id_token_signature_invalid. An unclassified refusal keepstoken_not_verified. Each refusal also writes one structuredwarnline (providerId,protocol,cause). It carries no token content and no account signal. The tokens get one docs page. There is no change topackages/specor ADR-0112.options.logger.logmust receive the error object, not a string, on the installed@better-auth/core. If it does not, the dev stops and reports the fork.assertion_expired,assertion_not_yet_valid,assertion_issuer_mismatch,assertion_signing_key_unknown,assertion_signature_invalid.State:
needs-user-decision→pm:dispatched. The claim 6708073906 stands and the same card thread (branchclaude/project-thread-bqq14o) resumes. The card's carrier repo, scope and Clause-② change as follows:Clause-②: noreplaces the claim'syes (widening), because V1 leaves the closed error-code vocabulary unchanged.objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2dEm9wh1JnKi42YPDL
os-dev-report
{ "issue": 30965, "status": "done", "branch": "claude/project-thread-bqq14o", "pr": "https://github.com/objectstack-ai/objectstack/pull/31194", "head": "b0a55dc3cee8b9b252f0d834b89ba2da585d4608", "session": "session_017ihL2dEm9wh1JnKi42YPDL", "premise_still_valid": true, "premise_check": "Ruling premise holds: on installed @better-auth/core 1.7.12, a custom logger.log receives the jose error OBJECT as message (probe: level error, ctor JWTExpired, code ERR_JWT_EXPIRED, claim exp), and the real /sso/callback path delivers it (the six per-cause E2E cases pass only through that channel; ablation 2 removes it and they go red).", "summary": "OIDC only, per ruling 6709033929 (D + A + V1). New sso-oidc-refusal-cause.ts: plugin-auth now passes betterAuth a logger sink; AuthManager.handleRequest wraps /sso/callback/:providerId in an AsyncLocalStorage scope (new host member runSsoOidcCallback). Inside it the sink holds an error line whose message is a jose error with a classified code/claim; when the vendor answers error=invalid_provider&error_description=token_not_verified, that description segment is replaced byte-exactly with the cause, one warn line {providerId, protocol:'oidc', cause} is written, and the held vendor line is dropped. Other answers release held lines unchanged. Closed list exported as SSO_OIDC_REFUSAL_CAUSES (id_token_expired, id_token_not_yet_valid, id_token_audience_mismatch, id_token_issuer_mismatch, id_token_signing_key_unknown, id_token_signature_invalid); unclassified keeps token_not_verified and still gets the warn line. Docs: sso.mdx section 'Why an OIDC sign-in was refused'. Changeset: plugin-auth patch.", "tests": "sso-oidc-refusal-cause.test.ts 15/15 at b0a55dc3: real AuthManager + real sso() over the in-memory engine, /sign-in/sso -> /sso/callback, only the IdP token/JWKS endpoints stubbed, ID tokens signed locally (RS256): CONTROL sign-in, one case per cause, unclassified (azp) keeps token_not_verified, no-leak pin (token, payload segment, sub, email, name absent from Location and every logger channel; known vs unknown account give identical redirect and line), unit cases for classification by code/claim, path scope, byte-exact rewrite, held-line release. Full suite at 07fe9fcb after merging origin/main: Test Files 235 passed (235), Tests 4184 passed | 10 skipped. Ablations via scripts/ablation-replace.mjs, each restored (blob == HEAD, git diff HEAD empty): (1) side channel off -> 7 failed / 9 passed; (2) logger sink not passed to betterAuth -> 7 failed / 9 passed; (3) message rendering leaks error properties -> 2 failed / 14 passed. No build/dist leg: the tests import src through vitest.", "gates": "At b0a55dc3: dispatch-gates --commands derived 95; all 95 run, each ':: exit 0'; --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). First-pass exit 3s fixed and rerun: check-plugin-teardown-shape --self-test (fetched fixture commit 621a4876), check:skill-examples and check:dual-build-cjs-loads (full pnpm build 119/119). pnpm --filter @objectstack/plugin-auth typecheck :: exit 0 (check:test-typecheck debt unchanged). npx eslint --no-inline-config packages/plugins/plugin-auth :: exit 0. CI check-runs: in_progress at report time.", "dogfood_battery": "n/a: no ! in the PR title and Clause-② no", "line_budget": "+~690 / -~10 across 8 files (two new files are most of it)", "files_changed": [ "packages/plugins/plugin-auth/src/sso-oidc-refusal-cause.ts (new)", "packages/plugins/plugin-auth/src/sso-oidc-refusal-cause.test.ts (new)", "packages/plugins/plugin-auth/src/auth-manager.ts", "packages/plugins/plugin-auth/src/auth-manager-request-routing.ts", "packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts", "packages/plugins/plugin-auth/src/index.ts", "packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts", "content/docs/permissions/sso.mdx", ".changeset/30965-sso-oidc-refusal-cause.md" ], "deviations": [ "driver-fault-auth-log-carriers.test.ts edited (inside the declared plugin-auth/src surface): better-auth lines now reach the manager logger, so its carrier assertions read console + manager-logger lines tagged [better-auth]; assertions otherwise unchanged.", "No docs-list pin: a pin reading sso.mdx from a plugin-auth test needs scripts/cross-package-test-inputs.mjs + turbo.json declarations (outside the file surface), so it was dropped; docs and export are kept equal by hand (PR Acceptance notes).", "PR attribution line links THIS card thread, not the seat thread named in _standing.md: the harness's attribution instruction for this session requires its own thread link verbatim.", "Clause-② no / Metadata-protocol none as the ruling restated (claim said yes (widening)); changeset patch, because kind none takes patch (AGENTS.md Post-Task step 3); the new export is a runtime constant, not an authorable key." ], "mcp_calls": "1: subscribe_pr_activity (not a GitHub write tool). No mcp__github__* calls.", "api_writes": "3 relay writes: comment 6708108101 (needs_decision report), pr_create #31194 (fleet-write run 42394118837), this comment. Plus git push of the branch.", "open_questions": [], "security_findings": "none", "out_of_scope_findings": [ "carrier: seat (SAML follow-up card per ruling item 2) · noted, not filed: SAML cause still discarded inside @better-auth/sso processSAMLResponse (bare catch around parseLoginResponse)." ] }objectstack-fleet commentedon Nov 7, 2026
domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B
Landed · 2026-11-07T17:49Z
30002df557, headb0a55dc3ceFixes #30965pm:awaiting-maintainer, upstream better-auth PR)State:
pm:dispatchedremoved.