Teammate self-onboarding (deployment-wide auth method) - #50
Conversation
A pending user (no password, no provider) adopts the provider + subject on their first OIDC login at the connector — claim_pending_oidc (guarded UPDATE WHERE pending) + a pending branch in _resolve_oidc_user that re-reads and requires the binding is ours (rejects a concurrent/no-op claim). A password user is never pending, so OIDC for that email is refused. Also lands the sibling claim_pending_password primitive for the password-deployment path.
New onboarding.py: a signed, time-boxed setup token (no new table) the admin copies per pending user; GET/POST /claim sets the password via the guarded claim_pending_password (single-use — a replay no-ops once the user is claimed). The token is unforgeable (purpose-marked HS256) and can't overwrite an already-claimed account. The admin roster shows a copy-able setup link for pending users, but only in a password deployment (no OIDC configured). Routes wired into mcp_http with the bearer public-skip; onboarding added to py-modules.
…iews The connector login renders only the deployment's configured method — provider buttons when OIDC is configured (no password surface), else the email/password form. README documents the deployment-wide model + the two onboarding paths; render_previews adds the setup pages + the roster setup link. (Admin login keeps its password break-glass fallback, unchanged.)
- Enforce the deployment-wide method server-side: /oauth/authorize refuses a (crafted) password POST when an OIDC provider is configured, not just by hiding the form. The admin's password break-glass stays on /admin/login. - Cap the claim password length; document that /claim hashes only after the token + pending checks and isn't in-process rate-limited (rely on the proxy). - README: note the OIDC-email trust assumption + the rate-limit gap. - Tidy: compute the login hidden fields only on the password branch. - Tests: password-POST refused in an OIDC deployment; OIDC-bound-then-password claim refused (the lock's other direction).
There was a problem hiding this comment.
Pull request overview
Implements deployment-wide teammate self-onboarding, where an admin can create pending users (email + name) and the deployment’s configured auth method determines how those users complete setup: first-login OIDC binding when OIDC is configured, or a signed setup-link flow for password deployments.
Changes:
- Added a password-deployment setup-link flow (
/claim) backed by a signed, time-boxed, purpose-marked token and a guarded “pending-only” password-claim update. - Added “pending teammate” first-login OIDC binding (race-safe guarded update + re-read verification) and enforced “only configured method” on connector login surfaces (including server-side refusal of password POSTs in OIDC deployments).
- Updated admin Users roster to display copyable setup links for pending users only in password deployments; added tests and preview fixtures/docs.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_onboarding.py | New tests covering setup token validation, /claim password setup, replay refusal, and admin roster link visibility. |
| tests/test_oidc.py | Tests ensuring connector login shows only configured method, password POST is refused in OIDC deployments, and pending OIDC binding works as intended. |
| render_previews.py | Updates HTML previews to include setup-link UI and /claim pages. |
| packages/agami-core/src/user_store.py | Adds guarded “pending-only” claim helpers for OIDC binding and password setting. |
| packages/agami-core/src/onboarding.py | New module implementing setup token mint/verify, /claim pages + handler, and public path declaration. |
| packages/agami-core/src/oauth_server.py | Adds pending-branch OIDC binding, hides password UI when OIDC configured, and refuses password POSTs server-side in OIDC deployments. |
| packages/agami-core/src/mcp_http.py | Mounts /claim routes and includes them in bearer-auth public-path skipping. |
| packages/agami-core/src/admin.py | Adds “Setup link” display for pending users (password deployments only) and generates per-user setup links. |
| packages/agami-core/README.md | Documents deployment-wide onboarding flows and operational trust/rate-limit notes. |
| packages/agami-core/pyproject.toml | Exposes the new onboarding module as a flat top-level module. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| This is a public surface (the teammate has no session yet), so it self-checks: a bad/expired/used | ||
| token, or an already-claimed user, yields a generic page — never a credential-overwrite of a claimed | ||
| account, and no email-enumeration (the token is opaque; we don't echo who it was for). |
Per review: a JWT payload is base64url-readable, so the token isn't 'opaque' — the no-enumeration property comes from the handler returning the same generic page regardless, not from the token hiding its sub. Doc-only.
|
Thanks @copilot — fixed in 3e2e07c. You're right that the JWT |
Summary
Completes teammate onboarding. An admin creates a teammate by email + name (a pending user who
can't yet sign in); this lets them finish setting up — following a deployment-wide auth method,
uniform for everyone, set by what's configured:
provider at the connector — the IdP verifies their email and binds the account on first login. No
link to share.
out-of-band; the teammate opens it and sets their own password. The link is a signed, time-boxed
token (no new table) and is single-use — it stops working once the account is set up.
Because a deployment offers one method, a user can never hold two credentials — so there's no per-user
choice and no lock-juggling. Login surfaces show only the configured method (the admin keeps a
password break-glass fallback on
/admin/login).Changes
user_store.py—claim_pending_oidc/claim_pending_password: guardedUPDATE … WHERE password_hash IS NULL AND oidc_provider IS NULL(race-safe, mirrors the ACE-006 bind).oauth_server.py—_resolve_oidc_usergains a pending branch (bind on first OIDC login, thenre-read and require the binding is ours);
login_body_htmlrenders only the configured method, and/oauth/authorizerefuses a password POST server-side in an OIDC deployment.onboarding.py(NEW) — the setup token (purpose-marked HS256, no new table) + the/claimsetuppage/handler. Unforgeable, single-use, never overwrites a claimed account; hashes only after the token
admin.py— the Users tab shows a copy-able setup link for pending users, only in apassword deployment (the page is session-gated).
mcp_http.py—/claimroutes + the bearer public-skip (boundary-matched). Docs + previews.Test plan
tests/test_onboarding.py— token round-trip + forged/expired/wrong-purpose rejection;/claimsets apassword for a pending user; single-use (replay refused, original password still works, attacker's
rejected); short/bad-token refused; can't overwrite a claimed/admin account; roster shows the setup
link for pending users only in a password deployment.
tests/test_oidc.py— pending user binds on first OIDC login; a password user is never a pending OIDCclaim; first-claim-locks both directions; connector login shows only the configured method; a
password POST is refused server-side in an OIDC deployment.
3 uncovered lines are defensive store-unavailable guards).
/code-review+/security-review(new public credential surface); findings addressed.Checklist
oidc.py, the existing single egress module)Spec: ACE-011