Skip to content

feat(auth): link the Atlas credential automatically on first sign-in - #4

Open
binyangzhu000-sudo wants to merge 3 commits into
fix/codex-oauth-callback-timeout-20260817from
feat/credential-auto-link
Open

feat(auth): link the Atlas credential automatically on first sign-in#4
binyangzhu000-sudo wants to merge 3 commits into
fix/codex-oauth-callback-timeout-20260817from
feat/credential-auto-link

Conversation

@binyangzhu000-sudo

Copy link
Copy Markdown
Contributor

背景

上游身份登录解决了"你是谁",但首次登录仍然停在一个让用户手贴 Atlas API Key 的表单上。这一步其实后端能自己答:Atlas 后端在建号时就存了 provider 的 subject,所以一个已验签的身份足够定位到账号。

改了什么

配了 AUTH_CREDENTIAL_EXCHANGE_URL + AUTH_CREDENTIAL_EXCHANGE_TOKEN 之后,upstream 回调会拿刚验证过的身份向后端换取该用户的 key,加密进现有凭据库,然后直接完成登录。

凡是拿不到可用 key 就回落到手工表单(没有匹配账号、传输/鉴权失败、key 校验不通过)——这条链路是增强,绝不能让登录整体失败。

配套后端接口见 AtlasCloudTeam/kubedl#603(同样 issuer 无关,上游是 Google 还是自建 provider 都能用)。

几个容易写错的点

  • FederatedAccount 现在保留上游断言的原始 subject。它被折叠进的 sub 是 issuer+subject 的单向哈希,没法用来向后端指认身份。该字段是 optional,所以本次改动之前存下来的账号仍能通过 .strict() schema 解析。
  • 换来的 key 一样过只读余额校验,不因为来源可信就跳过。跳过的话,后端侧的错误会推迟到某次工具调用时才以一个莫名其妙的失败暴露出来。
  • 只配一半是启动报错,不是静默忽略。半配置的换取从外部看跟正常工作一模一样——所有人只是继续手贴 key,这种静默降级可能几个月都没人发现。
  • 审计事件只记结果linked / no_account / error),不记身份也不记 key,这样"悄悄退回手工模式"仍然能在日志里看出来。

验证

  • tsc 干净,npm test 81/81 通过(原有 76 + 新增 5)。
  • 新增测试覆盖:专用密钥头与请求体、404 视为"无匹配账号"、401/403/500/502 抛错而非静默降级、非 JSON 与缺字段响应被拒、拒绝跟随重定向(跟 discovery 同理:跟随等于允许把带密钥的请求转投别处)。
  • 既有 upstream 测试补了一条断言:原始上游 subject 必须被保留,且与派生 sub 不同。

兼容性

两个环境变量都不配时行为完全不变,首次登录照旧走手工粘贴。base 指向 fix/codex-oauth-callback-timeout-20260817,与 #3 保持一致(src/auth/ 目前只存在于该分支)。

Signing in with an upstream identity establishes who the user is, but the first
sign-in still ends on a form asking them to paste an Atlas API key. The backend
can answer that question itself: it already stores the provider subject it
received at signup, so a verified identity is enough to find the account.

When AUTH_CREDENTIAL_EXCHANGE_URL and AUTH_CREDENTIAL_EXCHANGE_TOKEN are set,
the upstream callback asks the backend for the key belonging to the identity it
just verified, encrypts it into the existing credential store, and completes the
login. Anything short of a usable key — no matching account, a transport or auth
failure, a key that fails verification — falls through to the manual form, since
this path is an enhancement and must not be able to break sign-in.

Notes on the parts that are easy to get wrong:

- FederatedAccount now retains the subject the upstream asserted. The `sub` it
  is folded into is a one-way hash of issuer and subject, so it cannot name the
  identity to the backend. The field is optional so accounts stored before this
  change still parse against the strict schema.
- An exchanged key is verified with the same read-only balance request as a
  pasted one. Trusting the source and skipping verification would let a
  backend-side mistake surface later as a puzzling tool failure.
- Setting only one of the two variables is a startup error, not a silent no-op.
  A half-configured exchange is indistinguishable from a working one from the
  outside: every user just keeps pasting keys.
- The audit event records the outcome only (linked / no_account / error), never
  the identity or the key, so a silent regression to manual entry is still
  visible in logs.

The exchange stays optional: with neither variable set the flow is unchanged.
The exchange call carries the secret that can mint a user API key, so
public hostnames still require TLS. In-cluster Service DNS
(*.svc.cluster.local) never leaves the cluster; forcing TLS there pushes
toward publishing the backend on a public ingress, which is the worse
trade.
…the callback

A consent prompt means oidc-provider found a live auth session, so the
upstream callback — previously the only place the credential was ensured —
never runs. If the stored credential was wiped or expired meanwhile (Redis
TTL, an ops cleanup), the flow minted tokens for an account the resource
server cannot serve: sign-in "succeeded", every MCP call then failed with
account_not_linked, and neither the exchange nor the manual-key fallback had
had a chance to run.

- The consent branch now ensures the credential first: reuse a stored one,
  else re-run the exchange (validated before storing, same as the callback),
  else render the manual-key form; when even the federated account record has
  aged out, re-run the upstream sign-in so the callback can rebuild it.
- The upstream callback, login-completion, and link-submit handlers accept a
  "consent" interaction as well as "login" — the consent path now legitimately
  resumes through them.
- The resource server answers a missing credential with 401 + WWW-Authenticate
  (error="invalid_token") instead of a bare 403: RFC 6750 clients only
  re-authorize on 401, and re-authorizing is now exactly what repairs the
  binding. This also makes the 90-day credential expiry self-healing instead
  of a hard stop.

Regression coverage: consent with wiped credential re-links via the exchange;
no matching Atlas account falls back to the manual form; exchange or
validation failures store nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant