Skip to content

Authsec prod mcp v2 - #32

Open
ritamAN77 wants to merge 35 commits into
mainfrom
authsec-prod-mcp-v2
Open

Authsec prod mcp v2#32
ritamAN77 wants to merge 35 commits into
mainfrom
authsec-prod-mcp-v2

Conversation

@ritamAN77

Copy link
Copy Markdown
Contributor

Description

Changes

Testing

  • Unit tests pass (go test -short ./tests/unit/)
  • go vet ./... clean
  • Manual testing (describe below)
  • Add label run-integration to this PR to trigger integration tests in CI (requires a live DB)

Checklist

  • Code follows the project's style guidelines
  • Self-reviewed the diff
  • No secrets or credentials committed
  • Updated documentation if needed
  • Added/updated tests for new behaviour

adityaauthnull250401 and others added 30 commits May 12, 2026 09:36
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.
ritam77 added 5 commits June 3, 2026 12:08
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants