Hive Hive
Sign in

feat(server): add auth.md agent registration

GitHub issue · Closed

Metadata
Source
tuist/tuist #10964
Updated
Jun 24, 2026
Domains
MCP
Details

Resolves N/A

Adds full auth.md support for Tuist’s hosted MCP server so agents can register and obtain user-scoped credentials through the WorkOS-compatible flows defined by the auth.md spec.

Note

The motivation behind this work is to guide agents through creating or claiming a Tuist account on behalf of the user, without requiring the agent to guess our signup, OAuth, or token flows. The server now advertises the supported auth.md contract and gives agents a structured path to get a user-scoped MCP credential after the user approves the registration.

Important

OpenAI is included in the built-in trusted provider list because it publishes a verifiable OIDC issuer and JWKS endpoint. Anthropic is intentionally not included yet because I could not verify a public Anthropic ID-JAG issuer and JWKS endpoint. Adding it without those exact values would make the default trust list either unsafe or non-functional. Self-hosted deployments can still add Anthropic through TUIST_AGENT_AUTH_TRUSTED_PROVIDERS_JSON or agent_auth.trusted_providers once Anthropic publishes the metadata to trust.

This includes:

  • GET /auth.md and discovery metadata for agent_auth, including registration, claim, and revocation endpoints.
  • User-claimed email-required registration with access token or API key issuance after OTP completion.
  • User-claimed anonymous start with immediate API key issuance and in-place claim upgrade.
  • Agent-verified ID-JAG registration with trusted-provider JWKS verification, replay protection, user matching/JIT provisioning, and logout-token revocation.
  • Append-only audit records, rate limiting, data export documentation, and MCP docs updates.

How to test locally

  • MIX_ENV=test mix ecto.reset
  • MIX_ENV=test mix ecto.migrate
  • MIX_ENV=test mix test test/tuist/accounts_test.exs test/tuist_web/controllers/agent_auth_controller_test.exs test/tuist_web/controllers/well_known_controller_test.exs test/tuist_web/rate_limit/agent_auth_test.exs test/tuist_web/controllers/mcp_controller_test.exs test/tuist_web/plugs/authentication_plug_test.exs
  • MIX_ENV=test mix test test/tuist/environment_test.exs test/tuist/accounts_test.exs test/tuist_web/controllers/agent_auth_controller_test.exs test/tuist_web/controllers/well_known_controller_test.exs
  • mix excellent_migrations.check_safety 2>&1 | rg "20260522|20260527|agent_registrations|agent_auth"
Comments
F
fortmarek May 27, 2026

Initial review findings:

  • [P1] Do not build emailed claim links from request-controlled origin
    server/lib/tuist_web/controllers/agent_auth_controller.ex:74 and :168 derive claim_view_url from RequestOrigin.from_conn(conn), which trusts X-Forwarded-Host / Host. A caller can start email auth for a victim, set the forwarded host to an attacker domain, receive the claim_token, and cause Tuist to email a claim-view link containing the secret claim_view_token to that attacker-controlled origin. If the user clicks it, the attacker can recover the OTP and complete the claim. Please use a canonical configured app URL, or validate the derived host against an allowlist before embedding secret tokens in outbound email links.

  • [P2] Return a 400 for invalid claim email instead of raising
    server/lib/tuist_web/controllers/agent_auth_controller.ex:168 does not handle {:error, :invalid_email} from Accounts.resend_agent_registration_claim/1. Missing or malformed email on /agent/auth/claim falls through the with else and raises instead of returning a client error. Add an invalid_email branch and a controller test for anonymous/email-required claim with bad email.

F
fortmarek May 27, 2026

[P3] Extract the auth.md workflow out of the main Accounts context

server/lib/tuist/accounts.ex grew by roughly 900 lines for this feature, and most of that code is a cohesive auth.md/agent-auth workflow rather than general account management. The added logic owns JWT/JWKS verification, trusted provider lookup, JTI replay protection, claim-token and OTP generation, email claim ceremony, audit-event insertion, credential issuance, and revocation. Keeping all of that inline makes an already broad context harder to scan and makes future changes to agent auth riskier than they need to be.

Consider moving the workflow into a focused module such as Tuist.Accounts.AgentAuth, with a public API along these lines: create_registration/1, resend_claim/1, complete_claim/1, get_claim_view/1, revoke_registrations/2, credential_revoked?/1, and scopes/0. If the project convention is that controllers talk only to context facades, Tuist.Accounts can keep thin delegators. That would preserve the boundary while letting the implementation and tests live closer to the agent-auth domain.

P
pepicrft May 27, 2026

Thanks @fortmarek — addressed all three. Pushed 0f90699e81 (P1 + P2) and 0d34885e6d (P3) on top of the branch.

[P1] Canonical app URL for emailed claim links and JWT audienceagent_auth_controller no longer uses RequestOrigin.from_conn/1 anywhere. There’s a single private canonical_origin/0 helper that delegates to Tuist.Environment.app_url/0, and every secret-bearing or trust-bearing site (the claim-view email URL, the JSON claim_url, and the audience passed to create_agent_registration/revoke_agent_registrations) flows through it. Caller-controlled Host / X-Forwarded-Host can no longer steer the claim email or the audience-binding.

[P2] invalid_email on /agent/auth/claim — added the missing {:error, :invalid_email} branch on the claim/2 action (400 + invalid_email error code), and made resend_claim/1 tolerate a nil email instead of raising (validate_optional_email/1) so omitting the field falls through to the stored registration email. New controller test "returns 400 invalid_email when the supplied email is malformed" covers the bad-email path.

[P3] Extract Tuist.Accounts.AgentAuth — moved the whole workflow (JWT/JWKS verification, JTI replay protection, claim ceremony, OTP, credential issuance, revocation, audit events) into Tuist.Accounts.AgentAuth. Tuist.Accounts keeps the public API via defdelegate lines (create_agent_registration, revoke_agent_registrations, agent_registration_credential_revoked?, resend_agent_registration_claim, complete_agent_registration_claim, get_agent_registration_claim_view, agent_registration_scopes), so the controller, Tuist.Authentication, and the existing test surface didn’t need to change. Diff: ~900 lines out of accounts.ex, into the new module.

Full suite passes (mix test: 4502 tests, 0 failures). mix credo and mix format clean on the touched files.

F
fortmarek May 27, 2026

[P2] Define and enforce SSO policy before issuing agent credentials

The new agent-auth flow appears to treat mailbox proof as sufficient for the user-claimed paths, and the ID-JAG path as sufficient for agent-verified registration, before issuing MCP credentials. That is weaker than Tuist’s normal SSO sign-in path for SSO-managed users/orgs: normal login goes through the configured IdP and records/uses the OAuth identity, while this flow can match or provision a user by email and issue credentials without satisfying the organization’s SSO policy.

For SSO-required orgs/domains, I think the PR needs an explicit policy decision and enforcement point before credential issuance. Either Tuist intentionally allows mailbox proof or trusted agent-provider assertions to mint MCP credentials for those users, or the claim page should require a normal authenticated Tuist session that satisfies SSO policy before /agent/auth/claim/complete can issue or upgrade credentials. The same consideration should apply to JIT provisioning: an email in an SSO-managed domain should probably not create a confirmed user and receive credentials through the email OTP path alone.

WorkOS auth.md separates these concepts: the agent-verified flow verifies a signed ID-JAG and does not go through the email OTP path, while the user-claimed flow is the email/OTP ceremony. WorkOS/AuthKit can also enforce SSO through org/domain authentication policies, so applications still need to wire agent registration into their normal org auth policy rather than treating auth.md as a bypass around it.

P
pepicrft May 27, 2026

Thanks @fortmarek — addressed in ae8b266652.

The auth.md flow now treats SSO policy as an explicit precondition for credential issuance, not something each path opts into. New surface:

  • Tuist.Accounts.sso_enforced_for_email?/1 — public predicate that returns true when an existing user belongs to any organization with sso_enforced: true, or — for an unknown email — when the email’s domain maps to an SSO-enforced Okta/OAuth2 organization. The JIT case is the important one: a fresh email in an SSO-managed domain no longer auto-provisions a confirmed user through the OTP path.
  • Tuist.Accounts.AgentAuth runs that check at every entry point that binds an email:
    • create_registration for email_verification (rejects before any insert)
    • create_registration for agent_provider ID-JAG (rejects after verifying the assertion but before issuing the credential / opening the transaction; JTI replay-protection still records, so the assertion can’t be retried)
    • resend_claim (covers the anonymous → real-email transition where the email first appears at claim time)
    • complete_claim (defense in depth, plus the anonymous-claim path where the policy decision happens late)
  • The controller maps :sso_required to HTTP 403 sso_required on /agent/auth, /agent/auth/claim, and /agent/auth/claim/complete. The agent-facing message routes the user back to the IdP: “Sign in through your identity provider and connect the MCP server from your authenticated Tuist session.”
  • auth.md doc grew a new ## SSO-enforced accounts section and added the error row to the table, so agents understand why the call failed and what to do next.

Tests cover: email-verification refused for SSO-enforced existing user, email-verification refused via SSO-enforced email domain (JIT case), ID-JAG agent-provider refused for SSO-enforced verified email, anonymous claim refused when the resend email is SSO-enforced, plus a controller test for the 403 response and unit tests for sso_enforced_for_email?/1 covering existing-user, domain-only, no-SSO, and malformed-input paths.

Worth flagging on the JIT-domain check: it matches :okta and :oauth2 orgs the same way Tuist’s existing sso_organization_for_email_domain does (sso_organization_id interpreted as domain / Okta tenant / https://domain). Google SSO doesn’t register a per-org domain identifier, so for Google-only SSO orgs an unknown-email JIT lookup wouldn’t match — once the user actually exists in Tuist, the existing-user path catches it.

TA
tuist-atlas[bot] May 28, 2026

This feature is now available in the xcresult-processor-image@0.9.0 release. Update to this version to use it.