Authsec prod mcp v2 - #32
Open
ritamAN77 wants to merge 35 commits into
Open
Conversation
callback url bug fixed
Adds /authsec/oauth/v2/* surface alongside the existing legacy flow. The v2 surface implements RFC 7591 DCR, RFC 8414 metadata, OIDC discovery, and per-Application IDP policy gating, rebound from dev's workspace model to prod's tenant model. Tables (9): mcp_oauth_clients + resource_server_tenant_index in master; resource_servers, resource_server_client_registrations, identity_providers, application_identity_provider_policies, auth_request_context, oauth_consent_grants in tenant. Endpoints: POST /oauth/v2/register (DCR), authorize/token proxied to Hydra with auth_request_context capture, introspect/jwks/revoke/ userinfo/logout, plus the two well-knowns and CanonicalIssuerOnly middleware. Tenant admin gets /authsec/applications and /authsec/identity-providers CRUD. mcp_oauth_clients <-> Hydra reconciler runs as a goroutine from cmd/main.go; AUTHSEC_DISABLE_HYDRA_RECONCILER_V2=true to keep it off during first rollout. Legacy /clientms/tenants/.../clients and /sdkmgr/playground/oauth surfaces are untouched. See docs/mcp_oauth_v2.md for TODOs explicitly not covered (deep RBAC on /token, auth_request_context consumption, per-tenant oidc_providers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the PHASE3-TODO from the initial backport. The /token handler now
binds each authorization_code exchange back to the row /authorize wrote.
Two recovery paths for context_id:
- preferred: parsed from state ("<context_id>~<rp_state>" prefix we stuff
at /authorize; RP echoes state to /token)
- fallback: most-recent unconsumed row for (tenant_id, client_id, redirect_uri)
when the RP drops state
Either way, ConsumeAuthRequestContext atomically marks the row consumed
(single UPDATE with consumed=false predicate, safe under replays) and the
controller then validates client_id, redirect_uri, resource, and scope
(subset) against the captured values. Mismatch aborts before reaching
Hydra. Refresh-token grants skip this check (no fresh /authorize behind
them).
Tenant resolution on /token uses the `resource` form param (RFC 8707).
RPs that omit resource are rejected with invalid_request rather than
falling through to a Hydra proxy with no tenant binding.
docs/mcp_oauth_v2.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tenant-scoped rotation endpoint for v2 Applications. Generates 32 bytes of crypto-random entropy, base64-url encodes, stores both the plaintext (introspection_secret) and a bcrypt hash (introspection_secret_hash) on the tenant-DB resource_servers row. Returns the plaintext once in the response body. Authenticated, requires tenant_id in JWT (matches the rest of the v2 admin surface). Application not found in the tenant returns 404. PHASE3-NOTE: plaintext is kept alongside the hash to match the dev branch's transition state. Long-term the plaintext column should be removed and callers required to capture the secret once at rotation time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kport Adds the four dev `applications` endpoints most commonly hit by the admin UI to the tenant-scoped backport. Lean implementations — none of them pull in the full dev RBAC stack (scope_resolver, role-options, drift events, mcp_tools), so behavior is intentionally narrower than dev. New table (tenant DB): application_access_policies — (enabled, default_role_id, assignment_*) minimal columns. No role-option validation; the default_role_id is persisted as-is. New model: ApplicationAccessPolicy. New service: ApplicationOnboardingService with GetAccessPolicy / UpdateAccessPolicy / GetAccessPolicySummary / CountRegisteredClients / ValidateResourceServer. ValidateResourceServer runs 4 checks: state, client count, access-policy enabled, public_base_url HEAD probe (8s). RSState constants added to models/resource_server.go (pending_scan, needs_setup, ready, scan_failed). Routes wired (all under /authsec/applications, with the same auth + tenant-validation middleware as the existing surface): POST /:id/validate — onboarding-style checks POST /:id/test — state + 0 tool counts POST /:id/launch — state=ready gate + RS metadata + conns GET /:id/access-policy — current policy PUT /:id/access-policy — upsert policy GET /:id/access — alias of GET /access-policy docs/mcp_oauth_v2.md updated with the explicit list of what's still NOT done and the per-endpoint lean-vs-full status. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two endpoints @authsec/sdk's runtime needs to enforce scope
matrix and publish its tool manifest:
GET /authsec/applications/:id/sdk-policy
PUT /authsec/applications/:id/sdk-manifest
Authentication is HTTP Basic with (application_id : introspection_secret).
Verified against introspection_secret_hash (bcrypt; preferred) or
introspection_secret (plaintext fallback). The id in the Basic username
MUST match the :id path param — guards against credential reuse across
Applications.
Mounted OUTSIDE the JWT auth group on authsec, so middleware doesn't
reject the request before we get a chance to verify the Basic creds.
New tenant-DB table mcp_tools (lean shape): id, tenant_id,
resource_server_id, name, title, description, input_schema (jsonb),
is_public, required_scopes (text[]), inventory_source ('sdk_manifest' |
'manual'), last_published_at. Unique on (resource_server_id, name).
Publish flow:
- Upserts mcp_tools rows for tools in the manifest.
- Deletes sdk_manifest rows missing from the manifest (manual rows
preserved).
- Bumps resource_servers.scan_generation so SDK clients refetch
sdk-policy on next TTL.
Policy fetch flow:
- Returns scopes_supported from the resource_servers row (admin-defined).
- Returns tool_policy[] from mcp_tools.
- policy_complete=true only when state=ready AND (tools OR scopes).
- Otherwise emits reason='needs_setup' / 'pending_scan' / etc., SDK
enforces deny-all per its contract.
PHASE3-NOTE: no drift events, no scope-grant role validation, no
auto-discovery. Dev branch has all three.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real curl-driven walkthrough, not smoke test. 12 phases: 0. Schema migrations (master + tenant) 1. POST /authsec/applications 2. Flip state=ready via SQL (no auto-scan on backport) 3. Rotate introspection secret 4. Optional: enable access policy 5. Discover OAuth via well-known 6. Start authsec-mcp-demo (npm run share) 7. DCR via /oauth/v2/register 8. Authorize -> code -> token 9. Introspect from MCP server perspective 10. Call tools (success + 403 insufficient_scope) 11. Refresh 12. Revoke Includes a troubleshooting table at the end covering the failure modes most likely to bite. Pairs with the matching .env.prod-mcp-v2 preset on the demo repo (github.com/authsec-ai/authsec-mcp-demo branch authsec-prod-mcp-v2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to mcp_v2_e2e_runbook.md. Walks through:
- One-time cloudflared install + tunnel registration + ~/.cloudflared
config file (which is NOT in the demo repo and must be created)
- DNS routing for mcp-dev.mcpauthz.com
- Daily 3-terminal run (backend / npm run share / curl)
- PowerShell equivalents of every bash command in the runbook,
including:
* Invoke-RestMethod for /applications, /rotate, /register, /token
* Basic auth header construction
* HttpListener-based one-shot callback catcher for /authorize
- Windows-specific troubleshooting (firewall on 9999, concurrently -k
signal handling, full Windows paths in cloudflared YAML, etc.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mcp_v2_full_port_plan.md — phased plan for porting the remaining 29 endpoints from dev's applications surface to prod-mcp-v2. 9 phases, 2-3 working days total estimate, honest cost tables per phase. Awaiting approval before any code lands. mcp_v2_curl_reference.md — comprehensive curl reference for every endpoint the backport hosts today. 6 sections: OAuth v2 surface, Applications admin, Identity providers, SDK-facing endpoints (Basic auth), MCP tool calls, and a final inventory of what's NOT yet on the backport. Drop-in commands that work as-is once $AUTHSEC, $JWT, $APP, $RSSECRET are set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the first 10 endpoints from mcp_v2_full_port_plan.md.
Phase 1 — admin reads (no schema changes):
GET /authsec/applications/:id/tools
GET /authsec/applications/:id/scopes
GET /authsec/applications/:id/scope-matrix
GET /authsec/applications/:id/setup
GET /authsec/applications/:id/sdk-manifest-status
GET /authsec/applications/:id/activation-preview
Phase 2 — activation state machine:
POST /authsec/applications/:id/activate (gated on setup checklist;
accepts {"force": true} override)
POST /authsec/applications/:id/rescan (bumps scan_generation so
SDKs refetch sdk-policy)
Phase 3 — connection admin:
POST /authsec/applications/:id/connections (admin prereg
of OAuth client,
returns one-time
client_secret)
DELETE /authsec/applications/:id/connections/:client_id (revoke; queues
Hydra delete via
reconciler)
New service: services/application_admin_service.go
- 5 reads built off existing tables (resource_servers + mcp_tools +
application_access_policies + resource_server_client_registrations)
- Activate gate: introspection-secret-rotated + tools-published +
scopes-defined + clients-registered. access_policy is surfaced as
a checklist item but NOT required for activation.
- PreregisterConnection mints a Hydra client + writes mcp_oauth_clients
(master) + resource_server_client_registrations (tenant) with
registration_type='prereg'. Defaults to client_secret_basic auth
(vs DCR's `none`).
- RevokeConnection sets status='revoked' and sync_status='pending_delete'
so the existing reconciler does the Hydra side.
No new schema in this batch — Phase 4 (drift events) and Phase 5 (scope
CRUD) introduce new tables in later sessions.
docs/mcp_v2_curl_reference.md updated with runnable curl for all 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements 4 more endpoints from mcp_v2_full_port_plan.md.
Phase 4 — drift events (admin "what changed since activation" banner):
GET /authsec/applications/:id/drift-events
POST /authsec/applications/:id/drift-events/:event_id/dismiss
New tenant tables:
- application_drift_events (id, application_id, event_type, payload,
occurred_at, occurred_by) with CHECK on event_type
- application_drift_event_dismissals (event_id, admin_user_id,
dismissed_at) — composite PK so each admin can dismiss each event once
Event types now emitted:
- secret_rotated (from RotateIntrospectionSecret)
- default_role_disabled (from UpdateAccessPolicy when enabled->disabled)
- connection_revoked (from RevokeConnection)
Future types reserved in CHECK constraint: tool_unmapped, scope_deleted.
DriftService.EmitEvent is best-effort:
- only emits when state=ready (pre-activation = setup, not drift)
- never blocks the originating mutation on emit failure
- logs errors via standard log package
emitDrift controller helper resolves occurredBy from the JWT and dispatches.
Phase 7 — consent grants (oauth_consent_grants table from migration 024):
GET /authsec/oauth/consent-grants
DELETE /authsec/oauth/consent-grants/:id
Query params:
application_id=<uuid> filter to specific Application
all=true admin-scope listing (no user_id filter)
include_revoked=true include revoked rows
admin=true (DELETE) skip user-ownership check
Cross-user revoke attempts return 404 to hide existence. Idempotent —
already-revoked returns 200.
Revoke side-effect: calls Hydra DELETE /admin/oauth2/auth/sessions/consent
to invalidate the upstream consent session so refresh-token issuance
fails immediately. Best-effort — logs but doesn't fail the DB revoke.
New helper hydraAdminRevokeConsentSession in services/hydra_service.go
(+ net/url import).
docs/mcp_v2_curl_reference.md updated: new sections for drift events
and consent grants, "NOT on backport yet" inventory trimmed by 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements 5 more endpoints from mcp_v2_full_port_plan.md.
Phase 5 — scope CRUD (oauth_scopes is now authoritative):
POST /authsec/applications/:id/scopes
PUT /authsec/applications/:id/scopes/:scope_id
DELETE /authsec/applications/:id/scopes/:scope_id
New tenant table oauth_scopes(id, tenant_id, application_id, scope_string,
display_name, description, risk_level, source) with CHECK constraints on
risk_level + source. Backfill INSERT at end of migration 028 pulls every
scope from existing resource_servers.scopes_supported arrays into rows
with source='application_create'. Idempotent — re-running skips dupes.
Phase 6 — tool ↔ scope mapping:
PUT /authsec/applications/:id/tool-scope-map (body: tool_id + required_scopes)
POST /authsec/applications/:id/tools/:tool_id/public (body: is_public)
Key semantics:
- oauth_scopes is authoritative; every scope write also updates
resource_servers.scopes_supported in lockstep within the same
transaction. SDK /sdk-policy continues to read the array column.
- scope_string is IMMUTABLE post-create. Hydra and clients hold scope
strings as opaque identifiers; renaming would break in-flight tokens.
UpdateScope accepts display_name / description / risk_level only.
- Scope delete cascades: strips the scope from scopes_supported, from
every mcp_tools.required_scopes via array_remove, then emits drift
events (scope_deleted + tool_unmapped per affected tool).
- Tool-scope-map writes validate every requested scope is registered
for the Application. Tools whose protection weakens (lost all scopes
OR flipped is_public=false→true) emit tool_unmapped drift events.
Phase 1 ListScopes handler refactored to read from oauth_scopes (vs the
legacy scopes_supported array). Response shape now matches dev's richer
view: full OAuthScope rows with display_name, description, risk_level.
CHECK constraints on oauth_scopes mirror models/agent_action.go's existing
RiskLevelLow/Medium/High/Critical constants — no duplicate definitions.
19 of 39 endpoints in the full-port plan now shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements 3 more endpoints from mcp_v2_full_port_plan.md. RBAC layer
begins: scopes from Phase 5 now have a way to be bundled into roles.
Bindings (the user→role join) come next session as Phase 8 part 2.
GET /authsec/applications/:id/roles
POST /authsec/applications/:id/roles
PUT /authsec/applications/:id/roles/:role_id/scope-grants
New tenant tables:
application_roles (id, tenant_id, application_id, name, description,
is_system, created_at, updated_at) with unique
(application_id, name)
application_role_scope_grants (id, tenant_id, role_id, scope_id,
created_at) joining application_roles
to oauth_scopes (Phase 5). CASCADE on
both FKs so deleting a role or a scope
cleans up grants atomically.
RoleService:
- List returns roles with hydrated GrantedScopes (single round trip,
JOIN onto oauth_scopes)
- Create supports optional scope_ids seed (validated in same tx)
- UpdateScopeGrants uses REPLACE semantics: caller passes the desired
complete set; service diffs against existing and inserts/deletes.
Empty list strips all grants.
- validateAndHydrateScopes verifies every passed scope_id belongs to
the SAME Application (defence against cross-application grants — a
scope from app A cannot be granted to a role on app B).
Backport scoping vs dev:
- Roles are strictly per-Application; no workspace-level role
inheritance, no cross-Application reuse.
- is_system marks backend-created roles; admins can edit but not
delete (will be enforced in a later phase when DELETE /roles/:id is
added).
- No drift events on role mutations yet — role changes affect bindings
(Phase 8 part 2) which will emit drift via the binding's emit path.
22 of 39 endpoints in the full-port plan now shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements 6 more endpoints from mcp_v2_full_port_plan.md. RBAC stack
completes: scopes (Phase 5) → roles (Phase 8 part 1) → bindings
(this) → users. The "who has access" side is now end-to-end.
GET /authsec/applications/:id/bindings
POST /authsec/applications/:id/bindings
DELETE /authsec/applications/:id/bindings/:binding_id
GET /authsec/applications/:id/eligible-users
GET /authsec/applications/:id/access/users
GET /authsec/applications/:id/users/:user_id/effective-access
New tenant table:
application_role_bindings (id, tenant_id, application_id, role_id,
user_id, granted_at, granted_by)
with UNIQUE (application_id, role_id, user_id) and CASCADE on all
three FKs (resource_servers, application_roles, users).
BindingService:
- ListBindings: single JOIN query hydrating user + role display data
- CreateBinding: validates the role belongs to this Application AND
the user exists in this tenant before insert. granted_by captured
from the calling admin's JWT.
- DeleteBinding: same scoping defence — returns 404 if the binding
belongs to a different Application even when the id is right.
- ListEligibleUsers: NOT IN subquery for users without bindings,
plus optional ?search= prefix match on email + name.
- ListAccessUsers: aggregated per-user view via array_agg(DISTINCT)
over bindings -> roles -> grants -> scopes.
- GetEffectiveAccess: full per-role + union-of-scopes resolver for
one user. Deduplicates scopes, sorts output for stable response.
The effective-access query is the load-bearing one — it's what the
admin UI calls to render "what does this user actually have access to?"
and is what any future runtime RBAC enforcement would consult. Computed
fresh on every read (no caching).
28 of 39 endpoints in the full-port plan now shipped. Only Phase 9
(governance views) remains.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the final 8 read-only endpoints from mcp_v2_full_port_plan.md.
No new schema — all read views composing existing tables (bindings,
roles, scope grants, scopes, tools, users).
GET /authsec/applications/:id/access-assignments
GET /authsec/applications/:id/access-change-previews
GET /authsec/applications/:id/access-simulations
GET /authsec/applications/:id/effective-access
GET /authsec/applications/:id/end-user-access-summary
GET /authsec/applications/:id/evidence-exports
GET /authsec/applications/:id/posture-summary
GET /authsec/applications/:id/tool-exposure
GovernanceService:
- ListAccessAssignments: hydrated bindings view; filterable by
user_id, role_id, granted_after, granted_before (all RFC3339).
- PreviewAccessChange: pure-read diff. Computes prior_roles,
next_roles, prior_scopes, next_scopes, added_scopes, removed_scopes
without touching the DB. Suited for "are you sure?" UI dialogs
before committing a binding mutation.
- SimulateAccess: "if user X had EXACTLY these roles..." Replaces
rather than diffs. Returns the simulated scope set + reachable
tool list (tools whose required_scopes intersect simulated scopes,
plus all public tools).
- GetApplicationEffectiveAccess: Application-wide effective-scope
view for every bound user. One JOIN, one row per user.
- EndUserAccessSummary: same data paged (page is 1-indexed,
limit defaults to 50, max 500).
- EvidenceExport: denormalized (user, role, scope) triples — CSV
spreadsheet-ready. Sorted stably (user email → role name → scope).
- GetPostureSummary: at-a-glance compliance snapshot. Counts roles,
scopes, tools (total + public + unmapped), bindings, users-bound,
users-with-no-bindings, orphan roles, undismissed drift events.
11 metrics in one read.
- GetToolExposure: per-tool list of reachable user emails. Public
tools = every active user. Non-public = users whose effective
scopes intersect required_scopes.
All effective-scope queries follow the same pattern: bindings → roles
→ scope_grants → scopes via LEFT JOINs with array_agg(DISTINCT) over
the scope_string column. Computed fresh on every read.
Phase 9 closes the full port plan. Every endpoint the deployed dev UI
fires at /authsec/applications/:id/* is now backed by prod-mcp-v2.
docs/mcp_v2_curl_reference.md inventory marked complete.
29 of 29 endpoints in the full-port plan now shipped (100%).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the long-standing PHASE3-TODO. Hydra-issued tokens carry their
original scope claim until expiry; this commit intersects that claim
with the user's current effective scopes on every introspect call.
Net effect: admin RBAC mutations (binding revoke, scope delete, role
edit) take effect on the next MCP tool call, not at token expiry.
Two changes to /authsec/oauth/v2/introspect:
1. Authentication (RFC 7662 §2.1)
The endpoint now REQUIRES HTTP Basic auth with
`<application_id>:<introspection_secret>`. Calls without it return
401 with WWW-Authenticate: Basic realm="introspect". The username
doubles as the RBAC context (which Application's bindings to
resolve against).
Credentials are verified via SDKPolicyService.AuthorizeFromBasic
(the same code path /sdk-policy and /sdk-manifest already use).
2. RBAC scope filtering
After Hydra responds with active=true, we:
- resolve sub -> users.id (sub IS users.id on this backport since
the consent flow writes oauth_consent_grants.user_id as sub)
- walk application_role_bindings -> application_roles ->
application_role_scope_grants -> oauth_scopes for that user on
THIS application
- intersect the resulting effective scope set with Hydra's claimed
scope and overwrite the response's `scope` field
- add `ext_authsec_scope_filtered: true` so SDK debug logs can
explain narrowed scopes
Special cases:
- sub doesn't parse as UUID (client_credentials, SPIRE workloads):
filter is SKIPPED. Non-user tokens pass through Hydra's scope.
- sub is a UUID but the user doesn't exist in this tenant: scope
filtered to EMPTY. Fail closed.
- Resolver error (DB hiccup): scope filtered to EMPTY. Fail closed.
Logged via standard log package for ops.
- active=false: response passes through unchanged.
New service method BindingService.EffectiveScopesForSubject(
tenantID, applicationID, subject) returns (scopes, isUserSubject, error).
Returns nil/false when sub isn't a UUID so the caller knows to skip.
Same resolver SQL as /users/:user_id/effective-access but returns just
the scope-string list — designed for the hot introspect path.
Behavior change worth flagging: any MCP server in production that was
relying on Hydra's original scope for non-security purposes will see
filtered (narrower) scopes after this lands. That's the correct
behavior; the dev branch's SDK already assumed it (scope-matrix TTL
was lowered to 30s in 4.4.2 specifically for this).
This closes the last PHASE3-TODO in code. Full RBAC enforcement on the
runtime hot path is now wired end-to-end:
scope -> oauth_scopes -> role_scope_grants -> roles -> bindings ->
users -> sub -> token scope intersection
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug found during e2e testing: GET /applications/:id/users/:user_id/effective-access
returned
{"error":"resolve effective access: sql: Scan error on column index 3,
name \"scope_strings\": unsupported Scan, storing driver.Value type
[]uint8 into type *[]string"}
Root cause: lib/pq returns Postgres text[] columns as []uint8 (raw bytes).
Only pq.StringArray implements sql.Scanner to decode it. A plain []string
struct field fails to scan, even with `gorm:"type:text[]"` (that tag is
schema-side metadata, not a runtime scan hint).
Five Raw().Scan() sites had the same shape and the same bug. All five
were on the RBAC effective-access hot path:
services/binding_service.go
- ListAccessUsers (row.RoleNames + row.ScopeStrings)
- GetEffectiveAccess (row.ScopeStrings)
services/governance_service.go
- ListAccessAssignments (row.ScopeStrings)
- GetApplicationEffectiveAccess (row.EffectiveScopes)
- EndUserAccessSummary (row.EffectiveScopes)
Fix per site: change struct field type from []string to pq.StringArray,
then convert via []string(r.Field) when copying into the response struct.
The public response shape is unchanged ([]string in JSON), only the
scan-time intermediate type changes.
This bug was load-bearing: the same SQL pattern powers the
/oauth/v2/introspect RBAC scope filter shipped in commit 2d9f8ae.
Without the fix, every introspect call would return 500 instead of a
filtered scope claim, so the new RBAC enforcement was effectively
breaking introspect rather than narrowing it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 9abd751. After the pq.StringArray fix, GET /effective-access + /access-assignments stopped erroring but returned scope_strings: null instead of the actual values. Postgres verified to return correct text[] {mcp_demo.read,mcp_demo.compute}; the data was being dropped between the driver and the response struct. Root cause: GORM's snake_case-to-PascalCase field-name mapper isn't always applied in Raw().Scan() against anonymous structs. The aliased column "scope_strings" never bound to the field ScopeStrings, leaving it at its zero value (nil for pq.StringArray, which json marshals as null). The Table().Select().Find() flavor in RoleService.List uses the mapper; the Raw() flavor here doesn't. Fix per site: add explicit `gorm:"column:..."` tags on every field of the row struct. The type:text[] hint stays on the array fields as a schema-side breadcrumb, but the column: tag is what actually drives the bind. Five row-struct sites patched: services/binding_service.go - ListAccessUsers (row) - GetEffectiveAccess (roleRow) services/governance_service.go - ListAccessAssignments (row) - GetApplicationEffectiveAccess (row) [replace_all] - EndUserAccessSummary (row) [replace_all, same shape] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CanonicalIssuerOnly middleware was bouncing /authsec/oauth/v2/* requests to config.HydraPublicURL — but that's Hydra's public host, which serves /oauth2/* and has no idea what /authsec/oauth/v2/register means. Result: every v2 OAuth request 308'd from prod.api.authsec.ai to oauth.prod.authsec.ai where it 404'd. The v2 surface was deployed but unreachable from any production host. Fix: new config.OAuthBaseURL field, fed by env AUTHSEC_OAUTH_BASE_URL. canonicalOAuthBaseURL() now reads OAuthBaseURL first, with NO fallback to HydraPublicURL (that was the bug — the fallback was always wrong). Empty OAuthBaseURL = middleware is a no-op (no redirects), and the well-known metadata uses a sentinel "...not-configured.invalid" issuer so ops can grep for unconfigured deploys. To use: set env AUTHSEC_OAUTH_BASE_URL to the public URL of THIS backend (the host that serves /authsec/oauth/v2/*), NOT Hydra. On single-host prod deployments this is the same as the admin API host: AUTHSEC_OAUTH_BASE_URL=https://prod.api.authsec.ai On multi-host deployments where v2 OAuth lives on a separate hostname: AUTHSEC_OAUTH_BASE_URL=https://auth.prod.authsec.ai Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-data
Session 1 of the 6-session port that brings the dev branch's Hydra login +
consent flow to prod-mcp-v2. Sessions 2-6 add custom-login completion,
OIDC, SAML, consent handler, and final wiring. Today: schema, Hydra
admin helpers, and the read endpoint.
Schema (tenant DB, migration 031):
ALTER TABLE auth_request_context ADD:
consent_completed BOOLEAN NOT NULL DEFAULT false — token-exchange gate
login_challenge TEXT — Hydra challenge token
consent_challenge TEXT — Hydra consent token
user_id UUID — set at login complete
auth_time TIMESTAMPTZ — set at login complete
+ partial indexes on (login_challenge), (consent_challenge).
Backfill: existing rows get consent_completed=false. Token exchanges
against those fail closed (correct — they're stale).
Model:
models.AuthRequestContext gains LoginChallenge/ConsentChallenge/UserID/
AuthTime/ConsentCompleted fields with proper gorm tags.
Service:
services/hydra_login_service.go — five thin wrappers over Hydra admin:
GetLoginRequest — GET /admin/oauth2/auth/requests/login
AcceptLoginRequest — PUT /admin/.../login/accept
RejectLoginRequest — PUT /admin/.../login/reject
GetConsentRequest — GET /admin/oauth2/auth/requests/consent
AcceptConsentRequest / RejectConsentRequest — same for consent
Each returns a typed HydraAcceptResponse with redirect_to so callers
can hand the URL to the browser. Subject MUST be the AuthSec users.id
(UUID string) for the introspect-time RBAC filter to work.
Controller:
controllers/platform/login_v2_controller.go — new file, public surface.
GET /authsec/oauth/v2/login/page-data?login_challenge=<challenge>:
1. Calls Hydra GET /requests/login to fetch metadata
2. Parses authsec_ctx from request_url -> context_id
3. Looks up auth_request_context by context_id, binds login_challenge
4. Resolves Application via the client's audience (resource_uri)
5. Lists tenant identity_providers, filtered by the Application's
IDP policy whitelist (default-allow when no policy rows)
6. Returns LoginPageDataResponse with submit-URLs pointing at the
Session 2-5 endpoints (not wired yet)
Skip-mode (Hydra has existing session) returns success=true skip=true
subject=<existing> — UI should POST to complete-local with that subject.
OAuth Authorize handler:
Adds authsec_ctx=<context_id> to the URL we redirect to Hydra. This
makes it round-trip through Hydra's request_url so the login page-data
handler can extract it and find our auth_request_context row.
The state-prefix carrier (<context_id>~<rp_state>) stays as a fallback
for Token's path (2).
Routes:
GET /authsec/oauth/v2/login/page-data — public, under CanonicalIssuerOnly.
Docs:
curl reference updated with the new endpoint's request + response shape.
Sessions remaining:
2: POST /login/complete-local (custom email+password)
3: GET/POST /consent (Hydra consent challenge handler + scope intersection)
4: POST /login/oidc/initiate + GET /login/oidc/callback (federated OIDC)
5: POST /login/saml/initiate + POST /login/saml/acs (federated SAML)
6: Final wiring + reject endpoint + docs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new public endpoints close the login-half of the dance for
email+password users:
POST /authsec/oauth/v2/login/complete-local
POST /authsec/oauth/v2/login/reject
CompleteCustomLogin flow:
1. Body: {login_challenge, email, password, remember?}
2. Resolve the auth_request_context by login_challenge — via
Hydra GetLoginRequest -> authsec_ctx -> resource_server_tenant_index
-> tenant DB -> context_id row.
3. Look up models.ExtendedUser in the resolved tenant DB by email +
provider IN ('custom','ad_sync','entra_id','scim'). Same filter
dev's /uflow/auth/enduser/login uses, so the contract matches.
4. user.CheckPassword(password) — bcrypt verify on the existing
password_hash column.
5. Hydra accept-login PUT /admin/oauth2/auth/requests/login/accept
with subject=user.id.String(), acr=pwd, context = email/name/
provider/auth_method/tenant_id/context_id metadata.
6. Stamp user_id + auth_time onto the auth_request_context row.
Best-effort — log on failure, don't roll back the Hydra accept
(the dance is committed at that point).
7. Return Hydra's redirect_to so the UI navigates the browser to
the consent step (or directly to the client redirect_uri if
Hydra has remembered consent).
Subject is the AuthSec users.id (UUID string), which is exactly what
the RBAC introspect filter (commit 2d9f8ae) expects. Federated users
hit a different path (Session 4); this endpoint is custom-login only.
remember=true asks Hydra to skip auth for 8h on subsequent /authorize
calls for the (client, subject) pair. UI surface is "Keep me signed in."
RejectLogin: just forwards to Hydra reject-login with error=access_denied
and a user-supplied reason string. Hydra returns a redirect_to that lands
the user back at the client's redirect_uri with ?error=access_denied so
the calling app can show "login cancelled."
Error handling:
- Bad creds / no user / inactive user: 401 with generic
"invalid credentials" — same message either way to avoid leaking
which is wrong.
- login_challenge expired / context consumed: 400.
- Hydra unavailable: 502.
- All errors are JSON, no HTML, no PII in error_description.
Curl reference doc updated with request + response shapes.
Sessions remaining:
3: GET/POST /authsec/oauth/v2/consent (consent handler + scope
intersection using the RBAC stack from Phases 5/6/8)
4: POST /login/oidc/initiate + GET /login/oidc/callback
5: POST /login/saml/initiate + POST /login/saml/acs
6: docs, polish, final wiring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ction The last big piece. With this commit, the custom-login OAuth dance works end-to-end against /authsec/oauth/v2/*: POST /authsec/oauth/v2/register (DCR — session 0) GET /authsec/oauth/v2/authorize (session 0, now sets authsec_ctx) GET /authsec/oauth/v2/login/page-data (session 1) POST /authsec/oauth/v2/login/complete-local (session 2) GET /authsec/oauth/v2/consent (THIS commit) POST /authsec/oauth/v2/consent/accept (THIS commit) POST /authsec/oauth/v2/consent/reject (THIS commit) POST /authsec/oauth/v2/token (session 0, unchanged) POST /authsec/oauth/v2/introspect (RBAC filter from commit 2d9f8ae) Two new service methods: BindingService.ResolveGrantableScopes: The 3-way intersection. Returns Grantable + Rejected{scope -> reason} where Grantable = (requested) ∩ (oauth_scopes for this Application) ∩ (user's effective via bindings → roles → grants). OIDC core scopes (openid/profile/email/address/phone/offline_access) pass through without RBAC check — they shape id_token claims, not access. ConsentGrantService.UpsertGrant / LookupActiveGrant: Persist + read oauth_consent_grants. Lookup is used by GET /consent to auto-approve when (user, client, application) already has a remembered grant covering every grantable scope. Upsert is called by POST /consent/accept when the user clicks "remember consent." Consent handler flow: GET /consent: 1. Hydra GetConsentRequest → consent metadata 2. Resolve auth_request_context via authsec_ctx → tenant_id, app 3. Bind consent_challenge to the row 4. ResolveGrantableScopes — 3-way intersection 5. If grantable=[], reject the consent (Hydra returns access_denied) 6. LookupActiveGrant — auto-approve if remembered covers grantable 7. Return ConsentPageDataResponse with grantable/rejected scopes POST /consent/accept: 1. Re-resolve grantable scopes (single source of truth) 2. Intersect user's chosen subset with grantable (UI can't escalate) 3. finalizeConsent: Hydra accept-consent with grant_scope + audience + session{access_token.ext.context_id, id_token.{email,name,...}} 4. Mark auth_request_context.consent_completed=true and scope=joined 5. If remember=true, UpsertGrant for next time 6. Return redirect_to POST /consent/reject: Hydra reject-consent with access_denied. Returns redirect_to. Critical wire: session.access_token.ext.context_id is what /oauth/v2/token introspects out of the freshly-minted token to find the auth_request_context row, validate consent_completed, and consume the row. Without this, token exchange fails closed. Auto-approve gate is strict: remembered grant must cover EVERY scope in the grantable set. If the user's scopes shrank (admin revoked a binding) or expanded (admin added one), the consent screen renders again. No silent escalation, no silent denials. Curl reference doc updated with all three endpoints + a complete end-to-end "after session 3" walkthrough. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…der polish
Session 4 (OIDC federated):
- migrations/tenant/032 extends oidc_states with application_id + login_challenge
- services/federated_login_service.go — InitiateOIDC + HandleOIDCCallback
- tenant-encoded state token "<tenant-uuid-hex>.<random>" lets the callback
pick the right tenant DB without a master-side index
- per-Application IDP whitelist gate matches /login/page-data
- PKCE S256 throughout; google gets access_type=offline+prompt=select_account
- resolveFederatedUser: identity-link match → email match → error.
No JIT user creation — ExtendedUser.ClientID is NOT NULL and we don't
have a clients.id from a federated context. Users register via
custom-login first.
- POST /login/oidc/initiate + GET /login/oidc/callback handlers
+ Hydra accept-login with acr=fed, auth_method=oidc_federated
Session 5 (SAML federated):
- POST /login/saml/initiate + POST /login/saml/acs handlers wired
- Service stubs return 501 until a SAML XML lib (e.g. crewjam/saml) is added
- Route + response shape matches what the real impl will emit so UI is stable
Session 6 (polish + docs):
- listIDPsForApplication now hydrates provider_name from oidc_providers
so the UI can render the right icon without a second lookup
- docs/mcp_v2_curl_reference.md gains a federated section under "After
Session 3", documenting both OIDC and SAML (the latter as 501-stub)
…nt_hydra_clients pattern) The federated OIDC service was Vault-first with env-var fallback, but the existing per-tenant trust pattern (see tenant_hydra_clients.hydra_client_secret) stores secrets in-row. Keep federated OIDC on the same boundary so a tenant can register/swap Google/GitHub/Microsoft creds with a single SQL INSERT — no Vault deployment required. - migrations/tenant/033 adds oidc_providers.client_secret TEXT NULL and drops the NOT NULL on client_secret_vault_path (now optional) - services/federated_login_service.go reads inline first, falls back to loadClientSecret (Vault → env) - models/oidc.go gains the ClientSecret field, marked json:"-" so it's never serialized back to admin APIs Master DB's oidc_providers (if/when added) remains the "AuthSec-as-Google- OAuth-client" platform-level concept; tenant DB is per-Application/tenant swap-in/out of upstream IDPs.
The schema doesn't have a redirect_uri column on oidc_providers — it was a dev-branch artifact ported without the actual column. The upstream callback URL is fixed per backend (computed from OAuthBaseURL) and not per-provider, so storing it on oidc_providers would be storing a constant. InitiateOIDC always uses in.CallbackURL (passed by the controller), which is the AuthSec backend's /login/oidc/callback. One source of truth.
…r_id
The earlier callback refused to JIT-create users because we couldn't satisfy
the NOT NULL on users.client_id from a federated context. The right answer
isn't to drop that constraint — it's to anchor federated users to the
resource_server's existing legacy_client_id (already populated at
Application-creation time) so the legacy uniqueness/audit invariants stay
intact.
Scoping change: pre-MCP, (tenant_id, client_id, email) identified a user.
With v2, each MCP is a discrete logical scope — the same Google account
logging into two different MCPs in the same tenant should produce two
distinct AuthSec users. Migration 034 adds resource_server_id to users +
oidc_user_identities (both nullable; legacy custom-login / AD / Entra
users stay NULL) plus partial unique-ish indexes scoped by
(tenant, resource_server, email) and (tenant, resource_server, provider, sub).
resolveOrJITFederatedUser:
1. (tenant, rs, provider, sub) on oidc_user_identities → return linked user
2. (tenant, rs, LOWER(email)) on users → create identity link, return
3. JIT: look up resource_servers.legacy_client_id → the clients row →
INSERT users with client_id + project_id from that row + the new
resource_server_id + provider='oidc' + email/name → identity link.
Errors out cleanly if the Application has no legacy_client_id (which
shouldn't happen post-prereg, but fail-closed is the right call).
The "register via custom-login first" error is now gone — first Google
login on a new (MCP, email) pair just works.
…nt_id is NULL resource_servers.legacy_client_id was assumed populated at Application creation but nothing actually writes it today. Pivot the anchor logic: honor legacy_client_id if set (future-proof), otherwise fall back to the tenant's first active clients row. The real per-MCP scope is carried by users.resource_server_id (migration 034); users.client_id is just legacy bookkeeping to satisfy the NOT NULL.
…ejects "") GORM serializes the zero-value Go string as "" which Postgres jsonb refuses. Set the default literal in both the email-match link and the JIT path.
The earlier commit stubbed SAML as 501 to avoid pulling in crewjam/saml.
Turns out the legacy /uflow/saml path already has battle-tested XML
parsing, RelayState encoding, AuthnRequest builder, and Status/entity_id
validation in internal/hydra/models. Reuse it.
Layering: services can't import internal/hydra/models (would cycle), so
the SAML orchestration lives in login_v2_controller.go. The service still
exposes ResolveSAMLProviderForApplication (whitelist + idp.config_ref
resolution) and ResolveOrJITFederatedUserBasic (per-MCP JIT path), which
the controller drives between the two legacy calls.
InitiateSAML:
→ validate (tenant_id, application_id, idp_id, whitelist) via service
→ load saml_providers config row
→ legacy.CreateSAMLRequest → (SAMLRequest, RelayState)
→ return JSON for the UI to POST to the IdP's SSO URL
CallbackSAML (POST /login/saml/acs):
→ legacy.ValidateSAMLResponse (decode + parse + status + entity_id)
→ look up v2 auth_request_context by login_challenge (legacy doesn't
track application_id; v2 does)
→ service.ResolveOrJITFederatedUserBasic — same per-MCP JIT path as OIDC
→ Hydra accept-login with acr=fed, auth_method=saml_federated
→ stamp user_id + auth_time on auth_request_context
MCP clients (Claude Desktop, Claude Code, Cursor, Cherry Studio) build the OAuth discovery URL from the issuer per RFC 8414: GET <issuer>/.well-known/oauth-authorization-server The protected-resource metadata correctly advertises issuer "https://prod.api.authsec.ai" (bare host) since that's where Hydra sits and where /authsec/oauth/v2/* lives, but the .well-known routes were only mounted under /authsec/oauth/v2/.well-known/* — clients hitting the bare root got nginx 403. Resulting flow on Claude Code: Status: failed Issue: HTTP 404: Invalid OAuth error response Fix: register two root-level routes that delegate to the existing v2 controller handlers. Skip CanonicalIssuerOnly middleware here — the metadata document is host-agnostic, only the OAuth endpoints inside it need canonical-host enforcement.
…audience is empty When a DCR'd client doesn't have an audience field set in Hydra (some clients drop it, older flows didn't always persist it), the v2 login page-data handler 400'd with "no resource bound to this client". This broke Claude Code's OAuth dance even though the original /authorize call correctly included ?resource=<uri> per RFC 8707. Fix: parse `resource` from loginReq.RequestURL (the original /authorize URL Hydra echoes back to us in the login challenge). The same value that's already in the auth_request_context row, just via a different extraction path. Same final lookup against resource_servers, same Application resolution.
adityaauthnull250401
force-pushed
the
main
branch
from
July 3, 2026 06:29
5772f65 to
62a33fa
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Changes
Testing
go test -short ./tests/unit/)go vet ./...cleanrun-integrationto this PR to trigger integration tests in CI (requires a live DB)Checklist