Skip to content

feat: organization selection for browser OAuth logins via x-org-id (ADR-0009) - #183

Merged
yuanwen-tian merged 3 commits into
mainfrom
claude/oauth-login-org-selection-8522fc
Aug 7, 2026
Merged

feat: organization selection for browser OAuth logins via x-org-id (ADR-0009)#183
yuanwen-tian merged 3 commits into
mainfrom
claude/oauth-login-org-selection-8522fc

Conversation

@yuanwen-tian

Copy link
Copy Markdown
Contributor

Summary

Browser OAuth logins were organization-blind: the CLI requested no organization at the token grant, so multi-org accounts always landed on the platform-side default (migratedOrgId) with no way to choose. This adds organization selection and switching, carried as the x-org-id header the platform authz already honors and membership-verifies for user-scoped tokens.

  • Login: requests urn:logto:scope:organizations and discovers memberships via Logto userinfo. One subtlety: the API access token is audience-bound (RFC 8707) and userinfo rejects it, so discovery spends one resource-less refresh to mint the opaque token userinfo accepts — persisting the rotated refresh token under the existing cross-process lock, leaving the API token untouched.
  • Selection: one membership selects itself silently; several prompt on a terminal (arrow-key picker with a typed fallback, same widget as the login-method menu) or take --org <id-or-name>. Discovery failure never fails a login — the platform default applies with a remediation note in both the human text and the JSON payload.
  • New commands: ade auth org list (live memberships, selected one marked) and ade auth org switch <org> — no browser round-trip, since the refresh token is org-agnostic. ade auth login --org X on an already-authenticated target switches in place, keeping login's guarantee semantics.
  • Wire: the stored selection rides x-org-id on every parse/extract request and survives token refreshes. API keys send no header — they are already organization-bound.
  • Docs: ADR-0009 records the header-vs-organization-token trade-off and the flip condition; CONTEXT.md glossary, README, SKILL.md, and the ade help credentials topic updated.

Why the header, not Logto organization tokens

Logto supports org-bound tokens (organization_id at the token grant, membership enforced at mint, org in a signed claim). That is the better end state, but platform authz does not read the claim yet, so it would need a server change to ship. The header needs none: checkAuthHeader routes JWT-shaped bearers through verifyLogtoAccessToken (JWKS signature, iss, aud, typ: at+jwt), then resolves the org from x-org-id via fetchOrgContextForUser, which hard-verifies membership (403 otherwise) and gates on that org's subscription and credit balance.

The two approaches share all the CLI work and differ only in transport, so this does not paint us into a corner. ADR-0009 records the flip condition: when authz reads the organization_id claim, swap the header for the grant parameter — nothing user-visible changes.

Live verification (dev)

Same user, same OIDC token, only the header changed:

Selected org Result
ctplkf4kvkjs 403 "Organization subscription has expired" — that org's subscription gate fired
hpblrpj3suw4 parsed, 1.1 credits (parse-01kzd2cwqt9m5wsjr24rfz2j8b)
cxwau16ra4zh parsed, 1.1 credits (parse-01kzd2d8zkx84g37xpmhgmebdv)

Two fresh, uncached parses billed to two different organizations. Also exercised live: scope acceptance by dev Logto, userinfo discovery (3 real memberships), the ambiguous-name guard (two identically-named orgs), switch by id, login --org preselect, and the non-interactive deferral note.

Notes for reviewers

  • The platform's shadow authz service (apps/authz in vision-agent-ui, currently compare-only) ignores x-org-id. No runtime effect today, but every org-header request will show as a comparison divergence, and it must be ported before that service becomes the enforcer. Flagged in ADR-0009 — worth a heads-up to the platform team.
  • Job-item identity is environment + invocation, not organization: re-running the same invocation after an org switch joins the recorded run instead of re-billing under the new org. Arguably correct (no double billing), but it means the cache can serve a run attributed to the previous org. Documented in SKILL.md; possible follow-up decision.
  • An API key stores and sends no organization, and the CLI cannot report which org a key acts in — the platform has no introspection route for M2M credentials. The docs now say "the organization it was created in"; a platform endpoint would be needed to display it.

Test plan

  • 722 offline tests pass (34 new/updated: discovery, the picker, --org matching and the ambiguity guard, guarantee switch, header on submit and poll, refresh preservation, api-key isolation).
  • ruff check and uvx ty check src clean; docs/reference/help.json regenerated.
  • Live dev run as above.

🤖 Generated with Claude Code

yuanwen-tian and others added 2 commits August 7, 2026 10:31
Browser OAuth logins now act in a chosen Logto organization instead of
silently falling to the account's platform-side default:

- login requests urn:logto:scope:organizations and discovers memberships
  from userinfo (one resource-less refresh mints the opaque token
  userinfo accepts; the rotated refresh token is persisted under the
  refresh lock, the API access token untouched)
- one membership selects itself; several prompt on a terminal or take
  --org (id or name); discovery failing never fails a login — the
  platform default applies until a selection is made
- the selection is stored on the OAuth session and rides x-org-id on
  every API request, where the platform verifies membership per request
- new: ade auth org list / ade auth org switch (no browser round-trip);
  login --org on an already-authenticated target switches in place
- status shows the selection; API keys send no header (already
  organization-bound)

ADR-0009 records the header-vs-organization-token trade-off and the
flip condition for moving to signed organization_id claims later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answers the api-key question in the docs themselves: a key acts in the
organization it was created in — no selection stored, none accepted —
and OAuth sessions bill to the selected organization. SKILL.md warns
agents that re-running an invocation after an org switch joins the
recorded run instead of re-billing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds OAuth organization discovery, selection, switching, and x-org-id request routing.

Changes:

  • Adds organization-aware OAuth login and management commands.
  • Sends stored organization IDs through the gateway.
  • Updates tests, help, documentation, and ADR-0009.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/ade_cli/auth.py Implements organization selection and commands.
src/ade_cli/oauth.py Discovers memberships and persists selections.
src/ade_cli/credentials.py Exposes the active organization ID.
src/ade_cli/gateway.py Adds the organization request header.
src/ade_cli/parse.py Passes organization context to requests.
src/ade_cli/extract.py Passes organization context to requests.
src/ade_cli/help.py Documents commands and result shapes.
tests/test_auth_oauth.py Tests organization-aware authentication.
tests/test_help.py Updates help command expectations.
tests/test_telemetry.py Registers new command paths.
README.md Documents organization workflows.
SKILL.md Adds agent-facing organization guidance.
CONTEXT.md Defines organization-selection concepts.
docs/adr/0009-org-selection-rides-x-org-id.md Records the transport decision.
docs/reference/help.json Regenerates command reference data.
login-stderr.log Empty file with no functional change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/ade_cli/oauth.py
Comment thread src/ade_cli/oauth.py Outdated
Comment thread src/ade_cli/help.py
Comment thread src/ade_cli/auth.py
…ale selections

Addresses review feedback on #183:

- _post_token normalizes a 200 whose body is not a JSON object into
  _TokenEndpointError, so a malformed answer can no longer escape as a
  decode error. This is what the ADR-0009 guarantee needed (discovery
  failure never costs a login) and it hardens the login exchange and
  refresh at the same time — both already translate that error.
- userinfo decoding and the organizations claim are validated the same
  way, raising OrgDiscoveryError instead of AttributeError/JSONDecodeError.
- the already-authenticated login path publishes organization (null
  included), matching the shape help.json advertises for auth login.
- org list detects a selection that is no longer a membership: the
  header keeps going out and the platform rejects it, so the listing
  says so instead of implying the platform default.
- new ade auth org clear makes that remediation real — deliberately
  offline, since a stale selection is exactly when listing may fail.

Also removes login-stderr.log, committed by accident, and ignores *.log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yuanwen-tian

Copy link
Copy Markdown
Contributor Author

Review follow-ups pushed in d80aec4 — all four Copilot comments were valid and are fixed, each with tests verified load-bearing (reverting the fix fails them).

Response-shape hardening. _post_token now normalizes a 200 whose body is not a JSON object into _TokenEndpointError instead of letting a decode error escape. Fixing it one level down rather than at the discovery call site means the login exchange and the refresh path get the same protection — both already translate that error into their own failure. Userinfo decoding got the same treatment, plus a check on the organizations claim itself: a present-but-not-a-list value would have iterated a string character by character.

Contract parity. The already-authenticated auth login path now publishes organization (null included), matching what help.json advertises. It stays a true no-op — reports the stored selection, no discovery.

Stale selections. org list detects a selection that is no longer a membership and says so, instead of reporting a platform default that is not what requests actually carry. That needed a real way out, since switch only accepts live memberships — so this adds ade auth org clear, deliberately offline, because a stale selection is exactly when listing may fail.

Also removed login-stderr.log, which I committed by accident while running the live dev test, and added *.log to .gitignore. Good catch by the reviewer — thanks.

Suite is 740 passing (18 new tests); ruff and ty clean; help.json regenerated for the new command.

@yuanwen-tian
yuanwen-tian merged commit dc8f3ba into main Aug 7, 2026
1 check passed
@yuanwen-tian
yuanwen-tian deleted the claude/oauth-login-org-selection-8522fc branch August 7, 2026 03:57
yuanwen-tian added a commit that referenced this pull request Aug 7, 2026
Bumps `pyproject.toml` and `uv.lock` to 1.0.5 (minimal two-line diff;
the lockfile edit is validated by `uv sync --locked`, full suite green,
`ade version` reports 1.0.5).

## ⚠️ Merge order

**Merge #183 first, then this PR** — v1.0.5 is intended to include:

- #183 — feat: organization selection for browser OAuth logins via
`x-org-id` (ADR-0009)

Once #183 is in, cut the release via **Actions → Release → "Run
workflow"** on `main`. The pipeline runs the live integration suite
(macOS + Windows, against production) before tagging `v1.0.5`.

## Release note candidate

Browser (OAuth) sign-in now acts in a chosen organization: a single
membership is selected automatically, several prompt at login (or take
`--org`), and `ade auth org list` / `ade auth org switch` manage the
selection afterwards without a re-login. Requests — and credits — go to
the selected organization. API-key logins are unaffected; a key already
acts in the organization it was created in.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants