Skip to content

Teammate self-onboarding (deployment-wide auth method) - #50

Merged
ashwin-agami merged 5 commits into
mainfrom
ACE-011-teammate-self-onboarding
Jun 27, 2026
Merged

ashwin-agami merged 5 commits into
mainfrom
ACE-011-teammate-self-onboarding

Conversation

@ashwin-agami

Copy link
Copy Markdown
Contributor

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:

  • OIDC deployment (a Google/Microsoft client is configured): a pending teammate signs in with that
    provider at the connector — the IdP verifies their email and binds the account on first login. No
    link to share.
  • Password deployment (no OIDC): the admin copies a setup link from the Users tab and shares it
    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.pyclaim_pending_oidc / claim_pending_password: guarded
    UPDATE … WHERE password_hash IS NULL AND oidc_provider IS NULL (race-safe, mirrors the ACE-006 bind).
  • oauth_server.py_resolve_oidc_user gains a pending branch (bind on first OIDC login, then
    re-read and require the binding is ours); login_body_html renders only the configured method, and
    /oauth/authorize refuses a password POST server-side in an OIDC deployment.
  • onboarding.py (NEW) — the setup token (purpose-marked HS256, no new table) + the /claim setup
    page/handler. Unforgeable, single-use, never overwrites a claimed account; hashes only after the token
    • pending checks.
  • admin.py — the Users tab shows a copy-able setup link for pending users, only in a
    password deployment (the page is session-gated).
  • mcp_http.py/claim routes + the bearer public-skip (boundary-matched). Docs + previews.

Test plan

  • tests/test_onboarding.py — token round-trip + forged/expired/wrong-purpose rejection; /claim sets a
    password 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 OIDC
    claim; first-claim-locks both directions; connector login shows only the configured method; a
    password POST is refused server-side in an OIDC deployment.
  • Gate green: ruff + ruff-format + full pytest (890) + gitleaks; patch coverage 100% (onboarding 96% — the
    3 uncovered lines are defensive store-unavailable guards).
  • Reviewed with /code-review + /security-review (new public credential surface); findings addressed.

Checklist

  • Tests added and passing; gate green
  • No real customer names / data / credentials — neutral placeholders only
  • No new network egress (OIDC reuses oidc.py, the existing single egress module)
  • Flat access preserved (no role/permission column)
  • Manual end-to-end smoke behind a Cloudflare tunnel — OIDC bind + the setup-link password path

Spec: ACE-011

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).
Copilot AI review requested due to automatic review settings June 26, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread packages/agami-core/src/onboarding.py Outdated
Comment on lines +10 to +12
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.
@ashwin-agami

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — fixed in 3e2e07c. You're right that the JWT sub is base64url-readable; the docstring now says the token is signed (unforgeable) rather than "opaque", and clarifies the no-enumeration property comes from the handler returning the same generic page regardless of token/user state — not from the token hiding who it's for. Doc-only; no behavior change.

@ashwin-agami
ashwin-agami merged commit fa7e836 into main Jun 27, 2026
6 checks passed
@ashwin-agami
ashwin-agami deleted the ACE-011-teammate-self-onboarding branch June 27, 2026 03:48
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants