Skip to content

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

Closed

Description

@objectstack-fleet

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

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).

维护者速读

企业客户接 SSO 时,任何一处配置错误(受众不对、令牌过期、签名证书换了、签发方不对)在平台上都显示成同一句含糊的错误,服务器日志也只有一行「验证失败」。管理员没法自己排查,只能找支持。需要平台按原因给出可操作的错误码和日志。

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.

Suggested-grade: p2 · Enterprise priority (maintainer).

Dedupe (2026-11-06): token_not_verified → 0; Invalid SAML response → 1 (#6015, the triage seat post). None.

Ruled: 6709033929 · letter D+A+V1 · 2026-11-07T15:29Z

Scope (ruling 6709033929): OIDC only; SAML follow-up is #31168.

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    repo:cloudCard routed whole to the cloud PM: needs cloud code, a producer or a fact (cloud owns it to closure)
    domain:serverDomain lane — seat card indexed by label:pm:seat
    priority:p1High: required for production / M2
    and removed on Nov 6, 2026
  2. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: audit · p2 → p1 · domain:server · 2026-11-06T14:59Z

    ⛔ Not a claim.

    • 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.

    Generated by Claude Code

  3. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    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.

    Generated by Claude Code

  4. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    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) 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.

  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 7, 2026
  6. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B

    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.

  7. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    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/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."
      ]
    }

    Governing text: ADR-0112 D1/D4 (scope: error.code at 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.

  8. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Nov 7, 2026
  9. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B

    转入决定待做 · 2026-11-07T14:22Z

    卡线程按派发令先做测量,两个停点都触发了,没有写代码,也没有开 PR。测量报告见 6708108101。本卡从 pm:dispatched 转为 needs-user-decision,退出活动队列。assignee 保持 marchtian,裁定后由本席位原线程继续做。

    待裁两问(选项和推荐都在 6708108101 里):

    • Q1 SAML 的拒绝原因怎么拿到: @better-auth/sso 1.7.12 在 processSAMLResponse 里用不带变量的 catch 吞掉了 samlify 的错误,不改包就读不到原因。pnpm patch 只对本仓库生效,到不了 cloud 从 npm 安装的 plugin-auth。OIDC 的原因可以通过 logger 侧通道读到,不需要打补丁。线程推荐 D:先落 OIDC,SAML 拆成独立卡走上游修复(A),上游没有进展再退到本地诊断(B)。
    • Q2 原因码放在哪一层词表: ADR-0112 只管失败请求 JSON 体里的 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 仍排在本卡之后串行。

  10. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2eBbwbce6Wa8DyS1ov

    决策分析 · #30965 SSO 拒绝按原因给码 · 2026-11-07T14:29Z

    读取基线:objectstack-ai/objectstack@af3451b0;@better-auth/sso 1.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。
    • 上游尚未修:1.7.18(11 月 5 日发布)与 1.8.0-beta.6 同一位置仍是裸 catch,所以选项 A 是「我们去推上游」,不是「升级即得」。但修法很小:ACS 端点已经会把 APIError.body.code 小写后放进 error=,上游只需在那个 catch 里把 samlify 的错误码映射进 APIError 的 code,改动约二三十行。better-auth 当前发版节奏是两三天一版(11 月 3、4、5 日各一版)。
    • OIDC 的原因今天就能读到,不用改包:plugin-auth 给 betterAuth({ logger }) 传自己的 logger,用 AsyncLocalStorage 把本次请求与 logger 收到的 jose 错误对象对上,再改写跳转的 Location。代价是 better-auth 全部日志从 console 转到内核 logger(这本身是 AGENTS.md「Degradation log levels」想要的方向,但要按级别映射)。
    • 今天 SAML 已经分得清的拒绝:受众 / recipient(invalid_saml_response + 原文)、重放(replay_detected)、InResponseTo。分不清的只有签名、签发方、时间三类。
    • 控制台登录页会把 ?error= 渲染成页内横幅(auth-manager-auth-instance.ts onAPIError.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 侧对管理员的失败诊断)

    平台 管理员在哪看到原因 区分的拒绝类(原文标签) 来源
    Salesforce(作 SP) Login History 每条失败登录一个标签;SAML Assertion Validator 逐项给结果 "Signature Invalid"、"Audience Invalid"、"Assertion Expired"、"Issuer Mismatched"、"Recipient Mismatched"、"Replay Detected"、"Subject Confirmation Error";验证器逐项:Conditions、Timestamps、Issuer、Audience、Recipient、Signature… help.salesforce.com sso_saml_validation_errors
    Microsoft Entra ID 令牌错误响应里 error + error_codes,管理中心 Sign-in logs 里「Sign-in error code」+「Failure reason」 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 文案本线程未能核到 (未核实) developer.okta.com system-log-query
    Keycloak / Auth0 文档抓取失败,未核实 — —

    核实边界:Salesforce 与 Entra 两家为原文核对;Okta 只核到失败事件存在;Keycloak、Auth0 未核到,不作论据。

    共同点:成熟平台都把 签名 / 签发方 / 受众 / 时间 / 密钥 当作互相独立、各有固定码的拒绝类,并且把它们同时放在两个地方——登录错误页(给当场的用户或管理员)和一条管理员可查的登录事件(给事后排查)。没有一家把原因塞进对外 API 的错误码总表;跳转参数与事件日志是两套并行词表。这和卡线程的 V1 + 结构化日志判断一致。

    3. Q1:SAML 的原因怎么拿到

    选项 做什么 客户可感知的后果 工作量
    A 推上游 给 better-auth 提 PR:catch 里把 samlify 错误码映射进 APIError.code;合并后升 pin 修好前 SAML 仍是一句「Invalid SAML response」;修好后无需 plugin-auth 再做复杂工作 上游 PR ≈ 30 行(外部仓,需田騏本人或开放仓库给席位)+ 升 pin 1 PR(S)+ 映射与日志 1 PR(S)。无仓内先例
    B 本地再诊断 plugin-auth 直接依赖 samlify,拒绝后用存的 provider 配置按同一顺序重跑一次,只为贴标签 立即分得清;但等于自建一台和供应商平行的检验台,升级后两台可能给出不同结论(标签说签名错、实际是时间错) server 车道 · 1 PR · L(复制 createSP/createIdP 私有构造)· 无先例
    C 仓内补丁 pnpm patch @better-auth/sso 只有本仓库的 dogfood 受益,cloud 从 npm 装的 plugin-auth 不受益 → 客户问题不解决 S,但不解题
    D 拆分 OIDC 现在落(不用改包),SAML 另立后续卡走 A,上游停滞再退 B OIDC 客户立刻可自助排查;SAML 客户等上游一到两周 OIDC:server 车道 · 1 PR · M(logger 侧通道 + ALS + Location 改写 + 闭合清单 + 测试)· 无先例

    业务含义直译:A = 找电梯厂商修故障显示屏;B = 自己在厂商显示屏旁再装一台;C = 只修自家楼里那台,客户楼里的不动;D = 先把能修的一半修好,另一半去找厂商。

    B 还有一个「B-lite」变体:不引 samlify,只把 SAMLResponse 解码后读 Issuer 与 Conditions 两个字段贴标签。它不复制签名校验,但会误诊——samlify 先验签名再看时间,一条既过期又签名坏的断言会被贴成「过期」。不推荐,列出供参考。

    4. Q2:原因码放在哪一层词表

    选项 做什么 客户可感知的后果 工作量
    V1 error_description 放小写原因 token error= 不变(invalid_provider / saml_error);error_description 从 token_not_verified 变成 id_token_expired 之类;plugin-auth 导出一张闭合清单 + 文档一页 登录页横幅直接显示原因;接 cloud 的测试断言 token;现有消费者按 error= 分支的行为不变 随 Q1 的 PR 落,不碰 packages/spec、不改 ADR
    V2 新参数 + ADR-0112 台账 加 error_code=SSO_ID_TOKEN_EXPIRED,进 ERROR_CODE_LEDGER 平台「一套错误码」口径更整齐;但 better-auth 自己仍会往 error_description 写它的 token(如 id_token_subject_missing),结果是两套并存 ADR-0112 修订(Tier H)+ spec 台账 + 全量 Fable 评审 + 文档生成门;比 V1 多 1 个 PR 与一轮评审
    V3 台账成员直接写进 error_description 大写码混进 OAuth 小写位 同一位置两种方言 不推荐

    业务含义直译: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/core 1.7.12 读的,升到 1.7.18 要再核一次;③ 上游仓库不在本项目写入范围内,提 PR 需要田騏本人账号或把仓库开放给席位。

    7. 裁后执行(田騏只裁方向)

    1. 本卡收窄为 OIDC + 原因码清单 + 日志契约;server 席 3 原线程继续。日志一条:providerId、protocol、cause,级别按 AGENTS.md「Degradation log levels」(客户配置错误属 warn),禁令按卡面(无令牌内容、无账号信号)。
    2. OIDC 原因 token(V1,闭合清单,文档一页):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。
    3. SAML 另立后续卡(继承本裁决,不再回箱):席位起草 better-auth 上游 PR 文本;提交需田騏账号或授权;Blocked-by: 上游合并。设一个期限(建议两周),到期上游无进展再把 B 作为带前提的备选回箱一次。
    4. SAML 后续卡落地时的 token 与 OIDC 同一清单同一文档:assertion_expired、assertion_not_yet_valid、assertion_issuer_mismatch、assertion_signing_key_unknown、assertion_signature_invalid。
    5. plugin-auth SSO: expose the operator knobs Enterprise SSO needs — IdP-initiated sign-in, IdP group to role / permission set, and refusing unknown users instead of JIT #30967、plugin-auth SSO: a SAML account_not_linked refusal redirects to the success callbackURL and ignores errorCallbackURL (OIDC honours it) #30966 继续串行排在本卡之后。

    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

  11. removed
    repo:cloudCard routed whole to the cloud PM: needs cloud code, a producer or a fact (cloud owns it to closure)
    on Nov 7, 2026
  12. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    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:

    1. 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」.
    2. 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.
    3. plugin-auth SSO: expose the operator knobs Enterprise SSO needs — IdP-initiated sign-in, IdP group to role / permission set, and refusing unknown users instead of JIT #30967 and plugin-auth SSO: a SAML account_not_linked refusal redirects to the success callbackURL and ignores errorCallbackURL (OIDC honours it) #30966 stay serial behind this card.

    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.

  13. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Nov 7, 2026
  14. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    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)."
      ]
    }
  15. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B

    Landed · 2026-11-07T17:49Z

    reading value
    PR #31194 MERGED 2026-11-07T17:48:52Z through the merge queue as 30002df557, head b0a55dc3ce
    this card closed by that PR's Fixes #30965
    scope OIDC half of ruling 6709033929 (D + A + V1); the SAML half is #31168 (pm:awaiting-maintainer, upstream better-auth PR)

    State: pm:dispatched removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-setpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions