Skip to content

feat(auth): [AGI-6072] log in at the router on router tenants and pick the org - #170

Draft
whiskeylotus wants to merge 6 commits into
mainfrom
feat/agi-6072-router-login-org-picker
Draft

whiskeylotus wants to merge 6 commits into
mainfrom
feat/agi-6072-router-login-org-picker

Conversation

@whiskeylotus

@whiskeylotus whiskeylotus commented Oct 29, 2026 •

Copy link
Copy Markdown

Claude

nullify auth login dead-ends on router tenants (AGI-6072). The CLI runs login on the tenant's own Cognito pool, whose GitHub callback is not on the router's shared App, so GitHub refuses the redirect. A router token also covers every org the user has, and with no org named, multi-org requests fail with ErrNoTenantID.

Login:

  • Login at the auth host. Before signing in, the CLI asks the tenant GET /auth/cli/config where its login runs, then runs session, redeem and refresh there. A 404 means a stack that predates the endpoint, which keeps its own login. Any other failure, or an answer naming neither the tenant nor a known router (api.nullify.ai, api.router.dev.nullify.ai), fails closed.
  • Credentials per auth host. Tokens are stored under the auth host. ~/.nullify/hosts.json binds each API host to its auth host. Old credential files keyed by the tenant keep working.
  • Refresh keeps the org. Refresh no longer saves the server's default org (the lowest id) over the stored scope.
  • No redirect replay. Auth calls do not follow redirects. Both client transports send the bearer only on an https request to their own API host that the caller made; redirect hops and other hosts go out without it.

Org picker:

  • One org per command, never the lowest id. Commands run as the first of --org (name or id), NULLIFY_ORG, NULLIFY_GITHUB_OWNER_ID, the pick saved by nullify org use if the login still covers it, the git remote's owner if it names one org, or the only org. Otherwise a terminal prompts and remembers the answer, and anything else fails with the choices. The query_parameters old logins saved are never a pick.
  • Org list. GET /admin/installations on the router (or a legacy tenant), kept to installations whose nullifyBaseUrl is this host. Orgs not routed yet and orgs on other hosts are reported apart. An API token names no org unless asked, so the server takes the token's own.
  • MCP. nullify mcp serve starts on auth alone. Every tool takes an optional org argument, and list_organizations lists them. A call uses its argument, then serve --org, then the same chain, and returns the choices if still ambiguous. Resource reads follow the same rule. The scope rides in the request context, so concurrent calls for different orgs cannot cross.
  • Visibility. auth status, auth config and whoami show the effective host, auth host and org. New nullify org list / nullify org use. README covers all of it.
flowchart LR
  CLI["nullify auth login --host acme"] -->|"GET /auth/cli/config"| T["api.acme.nullify.ai"]
  T -->|"auth_host=api.nullify.ai"| R["router: session, redeem, refresh"]
  T -->|"404 (older stack)"| S["tenant: session, redeem, refresh"]
  R --> C["credentials under the router; hosts.json: acme -> router"]
Loading

Companion auth side: https://github.com/Nullify-Platform/nullify/pull/17026.

Evidence:

  • curl https://api.radiant.dev.nullify.ai/auth/cli/config and the dev router both answer 404 page not found today, the legacy path above.
  • Not yet run against an environment: the plan's dev-radiant and legacy-stack checks are open before-merge items.

Pre-push review: independent four-reviewer pass plus judge on each wave.
CI: wave 1 checkpoint green at 97f81cb (https://github.com/Nullify-Platform/cli/actions/runs/40957439860).

plan: router-cli-login-public-cli

🤖 Generated with Claude Code

whiskeylotus and others added 2 commits October 29, 2026 17:04
Router tenants run CLI login on the router, not on their own pool. Login now
asks the tenant (GET /auth/cli/config) where to sign in and runs session,
redeem and refresh there. A 404 is a stack that predates the endpoint and
keeps its own login; any other failure, or an answer that is neither the
tenant nor a known router, fails closed. Auth calls no longer follow
redirects, so a 307 cannot replay a redemption secret elsewhere.

Credentials are stored per auth host, with a hosts.json binding each API
host to its auth host; old credential files keyed by the tenant keep
working. Refresh no longer saves the server's default org over the stored
scope.

Plan-Step: 1/5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both client transports set the Authorization header on every request they
carried, redirect hops included, so a 3xx from the API sent the user's token
to wherever it pointed. The bearer now goes only on an https request to the
client's own API host that the caller made; redirects, other hosts, other
ports and plain http go out without it. A 401 from such a request no longer
triggers a forced refresh.

Plan-Step: 2/5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@whiskeylotus whiskeylotus added the minor Minor version updates (features) label Oct 29, 2026
whiskeylotus and others added 4 commits October 29, 2026 17:34
A router login covers every org the user has on a host, and with none
named, multi-org requests fail with ErrNoTenantID. Old logins also saved
the server's default org (the lowest id) as if the user had chosen it.

Commands now run as the first of: --org (name or id), NULLIFY_ORG,
NULLIFY_GITHUB_OWNER_ID, the pick saved by `nullify org use` if the login
still covers it, the git remote's owner if it names one org, the only org.
Otherwise a terminal prompts and remembers the answer, and anything else
fails with the choices. The saved query_parameters are never a pick.

The org list is the router's (or a legacy tenant's) /admin/installations,
kept to installations whose nullifyBaseUrl is this host; orgs not routed
yet and orgs on other hosts are reported apart. An API token names no org
unless asked, so the server takes the token's own. `auth status`,
`auth config` and `whoami` show the effective host, auth host and org.

Also from the wave 1 review: store read errors are surfaced instead of
"not authenticated", `auth switch` drops hosts whose login is gone, and a
router login removes the tenant's previous login.

Plan-Step: 3/5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`nullify mcp serve` resolved one org at startup and failed outright on a
multi-org login. It now starts on auth alone. Every tool takes an
optional org argument and list_organizations lists the choices; a call
uses its org argument, then serve --org, then the CLI's chain, and returns
the choices when that is still ambiguous. Resource reads follow the same
rule. The scope rides in the request context and is set on each request
by a transport, so concurrent calls for different orgs cannot cross.

Plan-Step: 4/5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan-Step: 5/5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MCP refuses an org argument that is blank or not a name or id instead of
  running the call as the default org; a numeric id is taken as that id.
- The org list retries once with a force-refreshed token when the router
  rejects the stored one, so a revoked or skewed token no longer breaks
  every MCP call until its stored expiry.
- An ambiguous --org settled at the prompt no longer replaces the
  remembered pick, and API-token commands no longer read hosts.json.
- `org list` reports a NULLIFY_GITHUB_OWNER_ID scope, and has help text.
- README: NULLIFY_ORG, API-token org rules, and the real host order.
- Tests for serve --org as the MCP default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Minor version updates (features)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant