Skip to content

fix(scim): unbreak provisioning + add Tier 1 handler tests (V2-273) - #27

Merged
Nic-dorman merged 1 commit into
masterfrom
nic/v2-273-scim-tier1
May 19, 2026
Merged

Nic-dorman merged 1 commit into
masterfrom
nic/v2-273-scim-tier1

Conversation

@Nic-dorman

Copy link
Copy Markdown
Member

Summary

Added 22 SCIM 2.0 handler integration tests exercising the real chi router via httptest. The tests immediately surfaced three latent production bugs that any real Okta or Azure AD integration would have hit on the first request — all fixed in the same PR.

The audit's optimistic "SCIM is fully implemented" framing was wrong: SCIM has never actually worked end-to-end. The existing token-service tests passed because they don't exercise the router.

Bugs fixed

1. Every SCIM request 404'd (mount prefix)

router.go mounted as r.Route(\"/scim/v2\", func(r){ r.Mount(\"/\", scimServer) }). chi.Mount does not rewrite r.URL.Path — it only adjusts the chi-internal RouteContext.RoutePath. The elimity-com/scim library does its own TrimPrefix(path, \"/v2\") internally and matches against /Users, /Groups. So the server saw /scim/v2/Users, couldn't strip /v2, and 404'd with {\"detail\":\"Specified endpoint does not exist.\"}.

Fix: wrap with http.StripPrefix(\"/scim/v2\", scimServer). The lib's own example uses the same pattern.

2. Group membership silently dropped (FK violation)

The SCIM handler called AddMember(group.ID, uid, 0) with 0 as addedBy. group_members.added_by is INTEGER REFERENCES users(id) (nullable) — no user has id=0, so the INSERT failed. The handler swallowed the error with _ =, so members were silently never added. PUT (atomic replace) surfaced a visible 500 because ReplaceMembers doesn't swallow.

Fix in internal/services/group.go: new nullableAddedBy(int64) sql.NullInt64 helper that maps 0 → SQL NULL. Used in both AddMember and ReplaceMembers. Public signatures unchanged; existing admin callers pass real user IDs and keep working.

3. Group Create returned stale externalId

After groupSvc.Create(), the handler called SetExternalID(group.ID, externalID) but then passed the pre-update group struct to groupToResource. The response always showed externalId: null even though the DB was correct — Okta would see "set externalId" succeed but every read would show null.

Fix: re-fetch via GetByID between the SetExternalID and the response.

Test coverage

Group Tests
Discovery (3) ServiceProviderConfig, Schemas, ResourceTypes return valid SCIM 2.0 payloads with application/scim+json
Auth (5) disabled-SCIM → 404, missing header, wrong prefix, invalid token, revoked token (revokes via real admin API)
Users (10) create, get, get-404, filter=userName eq (Azure AD existence-check shape), paginated list, PUT, PATCH active, PATCH Op: Replace (Okta capitalization quirk), DELETE (soft), full lifecycle
Groups (5) create-with-members, PATCH add+remove (incl. Azure members[value eq \"X\"] filtered path), atomic PUT replace, delete, list

All 22 pass green on SQLite. Will also run under the Postgres matrix added in V2-270.

Test plan

  • go test ./internal/handlers/ -run TestSCIM_ — all 22 pass
  • go test ./internal/handlers/ ./internal/services/ — no regressions
  • go build ./... clean
  • CI Postgres matrix legs go green

Out of scope (still TODO under V2-273)

  • Tier 1.5 — fixture replay from real IdP captures. Needs a Tier 2 run with mitmproxy first to capture real Okta/Azure AD PATCH payloads. The current Op: Replace test is a best-guess at the Okta quirk; fixtures from a real run would replace it with ground truth.
  • Tier 2 — manual rehearsal against a free Okta dev tenant + Azure AD trial via cloudflared tunnel --url http://localhost:8080. Documented as a pre-release ritual in the ticket.

🤖 Generated with Claude Code

Adds 22 handler integration tests exercising the SCIM 2.0 endpoints
through the real chi router via httptest. Tests immediately surfaced
three latent production bugs that any real Okta or Azure AD integration
would have hit on the first request — all fixed in the same commit.

Bugs found and fixed:

1. SCIM mount stripped the wrong prefix → every IdP call 404'd.
   chi.Mount does not rewrite r.URL.Path, but elimity-com/scim does its
   own TrimPrefix(path, "/v2") internally and matches "/Users" /
   "/Groups". Wrapped with http.StripPrefix("/scim/v2", scimServer).

2. Group membership silently dropped on every SCIM provisioning call.
   The SCIM handler passed addedBy=0 to AddMember / ReplaceMembers, but
   group_members.added_by is FK to users(id) (no user has id=0), so the
   INSERT failed and the handler swallowed the error with _ =. Added a
   nullableAddedBy() helper that maps 0 → SQL NULL.

3. Group Create returned stale externalId. The handler called
   SetExternalID after Create but then passed the pre-update struct to
   groupToResource, so the response always showed externalId: null.
   Re-fetch via GetByID between write and response.

Test coverage:
- Discovery (3): ServiceProviderConfig, Schemas, ResourceTypes
- Auth (5): disabled-SCIM 404, missing / wrong-prefix / invalid /
  revoked tokens (revoke via real admin API)
- Users (10): create, get, 404, filter=userName eq (Azure AD existence
  check shape), paginated list, PUT, PATCH active, PATCH "Op: Replace"
  (Okta capitalization quirk), delete, full lifecycle
- Groups (5): create-with-members, PATCH add+remove (incl. Azure
  members[value eq "X"] filtered path), atomic PUT replace, delete,
  list

Closes V2-273 Tier 1. Tier 1.5 (fixture replay from real IdP captures)
and Tier 2 (manual rehearsal via cloudflared tunnel) still TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Nic-dorman
Nic-dorman merged commit d97f2fd into master May 19, 2026
8 checks passed
@Nic-dorman
Nic-dorman deleted the nic/v2-273-scim-tier1 branch May 19, 2026 09:05
Nic-dorman added a commit that referenced this pull request May 19, 2026
…w, identity matching (V2-272 chunk A) (#31)

First of four PRs implementing the SSO login flow. V2-272 was ~60% done
on master (oidc_providers/identities tables + admin CRUD); this lands
the missing service layer that handles authorize → IdP → callback +
identity matching. Chunk B will mount HTTP handlers on top.

Deps
- github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification
- golang.org/x/oauth2 — code exchange with PKCE-S256

Schema (004_oidc_provisioning.sql for sqlite + postgres)
- oidc_providers.default_group_id (FK → groups, nullable)
- oidc_providers.auto_provision (bool, default false — off by default so
  a misconfigured provider can't silently grow the user table)
- Reflected on OIDCProvider struct + scanned in all SELECTs.

New service: internal/services/oidc_login.go
- BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL +
  the opaque encrypted-cookie value the caller must set on the response.
  Generates state (32 random bytes hex), nonce (same), PKCE verifier
  via oauth2.GenerateVerifier(), runs OIDC discovery, builds the
  authorize URL with S256 challenge.
- HandleCallback(cookieValue, queryState, code) → completes the
  exchange, validates the ID token (signature via JWKS, issuer,
  audience, nonce, exp), and resolves the identity. Returns a
  CallbackOutcome that distinguishes login vs linking flows.
- Identity matching tree (per V2-272 decisions):
    1. (provider_id, sub) hit → log in as that user
    2. users.external_id == sub → auto-link (SCIM correlation) + log in
    3. auto_provision=false → ErrOIDCNoAccount
    4. auto_provision=true + email already used → ErrOIDCEmailCollision
       (the explicit email-confusion guard — never auto-link by email)
    5. auto_provision=true + email free → create user via CreateFromSCIM
       + LinkIdentity + AddMember(default_group_id), log in
- LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the
  profile "connected accounts" flow. UnlinkIdentity refuses to leave
  the user with no login method (no password AND only one identity).

State cookie
- JSON payload {provider_id, state, nonce, code_verifier, redirect_url,
  link_to_user_id?, exp} AES-256-GCM encrypted via the existing
  internal/crypto package (reuses wallet_encryption_key — no separate
  secret to provision), then base64url-encoded for the cookie value.
- 10-minute TTL. Tampering and wrong-key decryption both fail closed
  via the GCM auth tag; both covered in tests.

OIDCProviderService.SetAutoProvision(id, enabled, groupID)
- Separate method so the admin UI toggle (Chunk C) doesn't need to
  re-supply every other field.

Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go
- Cookie round-trip, tampered cookie rejected, wrong-key rejected
- splitScopes (auto-prepends openid), splitName fallbacks
- LinkIdentity idempotent + rejects-different-user
- UnlinkIdentity: rejects-last, allows-with-password,
  allows-multiple-identities, rejects-foreign
- HandleCallback against an in-memory fake IdP (httptest + go-jose
  RSA signer with JWKS): existing-identity login, SCIM auto-link by
  external_id, no_account when auto_provision off, email-collision
  guard, state mismatch, expired cookie, nonce mismatch, linking flow
- TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped
  with a pointer to V2-273 (PR #27). The auto-provision path calls
  groupSvc.AddMember(..., 0) and AddMember on master writes 0
  directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via
  nullableAddedBy(); once it merges, drop the skip.

Chunks B/C/D
- B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} +
  link/unlink handlers
- C: LoginView.vue SSO buttons, profile connected accounts, admin
  auto-provision toggle
- D: Dex as CI service container, Playwright E2E

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nic-dorman added a commit that referenced this pull request May 19, 2026
… B) (#32)

* feat(sso): OIDC login service foundation — state cookie, callback flow, identity matching (V2-272 chunk A)

First of four PRs implementing the SSO login flow. V2-272 was ~60% done
on master (oidc_providers/identities tables + admin CRUD); this lands
the missing service layer that handles authorize → IdP → callback +
identity matching. Chunk B will mount HTTP handlers on top.

Deps
- github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification
- golang.org/x/oauth2 — code exchange with PKCE-S256

Schema (004_oidc_provisioning.sql for sqlite + postgres)
- oidc_providers.default_group_id (FK → groups, nullable)
- oidc_providers.auto_provision (bool, default false — off by default so
  a misconfigured provider can't silently grow the user table)
- Reflected on OIDCProvider struct + scanned in all SELECTs.

New service: internal/services/oidc_login.go
- BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL +
  the opaque encrypted-cookie value the caller must set on the response.
  Generates state (32 random bytes hex), nonce (same), PKCE verifier
  via oauth2.GenerateVerifier(), runs OIDC discovery, builds the
  authorize URL with S256 challenge.
- HandleCallback(cookieValue, queryState, code) → completes the
  exchange, validates the ID token (signature via JWKS, issuer,
  audience, nonce, exp), and resolves the identity. Returns a
  CallbackOutcome that distinguishes login vs linking flows.
- Identity matching tree (per V2-272 decisions):
    1. (provider_id, sub) hit → log in as that user
    2. users.external_id == sub → auto-link (SCIM correlation) + log in
    3. auto_provision=false → ErrOIDCNoAccount
    4. auto_provision=true + email already used → ErrOIDCEmailCollision
       (the explicit email-confusion guard — never auto-link by email)
    5. auto_provision=true + email free → create user via CreateFromSCIM
       + LinkIdentity + AddMember(default_group_id), log in
- LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the
  profile "connected accounts" flow. UnlinkIdentity refuses to leave
  the user with no login method (no password AND only one identity).

State cookie
- JSON payload {provider_id, state, nonce, code_verifier, redirect_url,
  link_to_user_id?, exp} AES-256-GCM encrypted via the existing
  internal/crypto package (reuses wallet_encryption_key — no separate
  secret to provision), then base64url-encoded for the cookie value.
- 10-minute TTL. Tampering and wrong-key decryption both fail closed
  via the GCM auth tag; both covered in tests.

OIDCProviderService.SetAutoProvision(id, enabled, groupID)
- Separate method so the admin UI toggle (Chunk C) doesn't need to
  re-supply every other field.

Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go
- Cookie round-trip, tampered cookie rejected, wrong-key rejected
- splitScopes (auto-prepends openid), splitName fallbacks
- LinkIdentity idempotent + rejects-different-user
- UnlinkIdentity: rejects-last, allows-with-password,
  allows-multiple-identities, rejects-foreign
- HandleCallback against an in-memory fake IdP (httptest + go-jose
  RSA signer with JWKS): existing-identity login, SCIM auto-link by
  external_id, no_account when auto_provision off, email-collision
  guard, state mismatch, expired cookie, nonce mismatch, linking flow
- TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped
  with a pointer to V2-273 (PR #27). The auto-provision path calls
  groupSvc.AddMember(..., 0) and AddMember on master writes 0
  directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via
  nullableAddedBy(); once it merges, drop the skip.

Chunks B/C/D
- B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} +
  link/unlink handlers
- C: LoginView.vue SSO buttons, profile connected accounts, admin
  auto-provision toggle
- D: Dex as CI service container, Playwright E2E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sso): OIDC authorize + callback + linking handlers (V2-272 chunk B)

Mounts the OIDC service from chunk A on the router and adds the public
auth + authenticated linking endpoints. Stack: this PR is on top of
nic/v2-272-sso-foundation (chunk A) — merge that first.

Public routes
- GET  /api/v2/auth/oidc/providers
    Lists enabled providers as {id, name, display_name}. Deliberately
    leaves out client_id and the encrypted secret — the login page just
    needs labels.
- GET  /api/v2/auth/oidc/authorize/{providerId}
    Calls BuildAuthorizeURL, sets the encrypted state cookie
    (HttpOnly + Secure + SameSite=Lax + Path=/api/v2/auth/oidc), and
    302's to the IdP. Cookie path is scoped so it doesn't leak to other
    routes.
- GET  /api/v2/auth/oidc/callback
    Clears the state cookie up-front (success OR failure), calls
    HandleCallback, issues a session JWT via the same path Login uses
    (jwt_expiry_hours setting, session cookie with the same flags), and
    302's to / on success or /login?error=<code> on failure. IdP-side
    errors (?error=access_denied etc.) pass through to /login.

Authenticated routes
- POST   /api/v2/auth/oidc/link/{providerId}
    Same flow as authorize but with LinkToUserID set to the current
    user. Returns {authorize_url} as JSON so the frontend can do
    window.location.href — POST→302 doesn't work cleanly with fetch.
- DELETE /api/v2/auth/oidc/identities/{identityId}
    Calls UnlinkIdentity. 409 + last_login_method code when removing
    would leave the user with no way to log in.
- GET    /api/v2/me/oidc/identities
    Returns the user's linked identities with provider_name resolved
    for the profile "connected accounts" view.

Admin route
- PUT /api/v2/admin/oidc/providers/{id}/auto-provision
    {auto_provision, default_group_id} — separate from the main Update
    endpoint so the UI toggle (chunk C) can flip it without re-supplying
    every other field. default_group_id=0 clears the group assignment.

Error code mapping (oidcErrorCode):
- ErrOIDCNoAccount       → no_account
- ErrOIDCEmailCollision  → email_exists
- state/nonce/cookie     → session_expired
- ErrOIDCMissingEmail    → missing_email
- ErrOIDCProviderDisabled→ provider_disabled
- everything else        → internal

oidcCallbackURL prefers cfg.BaseURL when set (production), falls back to
deriving scheme + host from the incoming request (dev / single-host).

Tests: 10 in internal/handlers/oidc_auth_test.go
- ListOIDCProviders only shows enabled, doesn't leak client_id
- Authorize sets HttpOnly+Lax cookie, 302's to IdP, URL includes PKCE
  S256 challenge
- Authorize → 404 for unknown provider
- Full round-trip: authorize → fake IdP /authorize → callback with
  pre-linked identity → session cookie set, 302 to /
- Callback with auto_provision=off + unknown sub → /login?error=no_account
- Callback with IdP ?error=access_denied → /login?error=access_denied
- Unlink requires auth (401)
- Unlink last-login-method returns 409 with last_login_method code
- /me/oidc/identities lists user's identities with provider_name resolved
- Admin set auto_provision persists

The handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that
auto-redirects /authorize back to the callback with code+state, so the
full authorize → IdP → callback chain runs in-process. Chunk D adds Dex
on top of this for browser-driven verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(swagger): regenerate docs to match handlers

Lint job on master enforces swagger freshness; the cumulative handler
changes on this branch hadn't been reflected. Generated with:

  swag init -g cmd/indelible/main.go -o docs/ --parseDependency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nic-dorman added a commit that referenced this pull request May 19, 2026
…n auto-provision UI (V2-272 chunk C) (#33)

* feat(sso): OIDC login service foundation — state cookie, callback flow, identity matching (V2-272 chunk A)

First of four PRs implementing the SSO login flow. V2-272 was ~60% done
on master (oidc_providers/identities tables + admin CRUD); this lands
the missing service layer that handles authorize → IdP → callback +
identity matching. Chunk B will mount HTTP handlers on top.

Deps
- github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification
- golang.org/x/oauth2 — code exchange with PKCE-S256

Schema (004_oidc_provisioning.sql for sqlite + postgres)
- oidc_providers.default_group_id (FK → groups, nullable)
- oidc_providers.auto_provision (bool, default false — off by default so
  a misconfigured provider can't silently grow the user table)
- Reflected on OIDCProvider struct + scanned in all SELECTs.

New service: internal/services/oidc_login.go
- BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL +
  the opaque encrypted-cookie value the caller must set on the response.
  Generates state (32 random bytes hex), nonce (same), PKCE verifier
  via oauth2.GenerateVerifier(), runs OIDC discovery, builds the
  authorize URL with S256 challenge.
- HandleCallback(cookieValue, queryState, code) → completes the
  exchange, validates the ID token (signature via JWKS, issuer,
  audience, nonce, exp), and resolves the identity. Returns a
  CallbackOutcome that distinguishes login vs linking flows.
- Identity matching tree (per V2-272 decisions):
    1. (provider_id, sub) hit → log in as that user
    2. users.external_id == sub → auto-link (SCIM correlation) + log in
    3. auto_provision=false → ErrOIDCNoAccount
    4. auto_provision=true + email already used → ErrOIDCEmailCollision
       (the explicit email-confusion guard — never auto-link by email)
    5. auto_provision=true + email free → create user via CreateFromSCIM
       + LinkIdentity + AddMember(default_group_id), log in
- LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the
  profile "connected accounts" flow. UnlinkIdentity refuses to leave
  the user with no login method (no password AND only one identity).

State cookie
- JSON payload {provider_id, state, nonce, code_verifier, redirect_url,
  link_to_user_id?, exp} AES-256-GCM encrypted via the existing
  internal/crypto package (reuses wallet_encryption_key — no separate
  secret to provision), then base64url-encoded for the cookie value.
- 10-minute TTL. Tampering and wrong-key decryption both fail closed
  via the GCM auth tag; both covered in tests.

OIDCProviderService.SetAutoProvision(id, enabled, groupID)
- Separate method so the admin UI toggle (Chunk C) doesn't need to
  re-supply every other field.

Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go
- Cookie round-trip, tampered cookie rejected, wrong-key rejected
- splitScopes (auto-prepends openid), splitName fallbacks
- LinkIdentity idempotent + rejects-different-user
- UnlinkIdentity: rejects-last, allows-with-password,
  allows-multiple-identities, rejects-foreign
- HandleCallback against an in-memory fake IdP (httptest + go-jose
  RSA signer with JWKS): existing-identity login, SCIM auto-link by
  external_id, no_account when auto_provision off, email-collision
  guard, state mismatch, expired cookie, nonce mismatch, linking flow
- TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped
  with a pointer to V2-273 (PR #27). The auto-provision path calls
  groupSvc.AddMember(..., 0) and AddMember on master writes 0
  directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via
  nullableAddedBy(); once it merges, drop the skip.

Chunks B/C/D
- B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} +
  link/unlink handlers
- C: LoginView.vue SSO buttons, profile connected accounts, admin
  auto-provision toggle
- D: Dex as CI service container, Playwright E2E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sso): OIDC authorize + callback + linking handlers (V2-272 chunk B)

Mounts the OIDC service from chunk A on the router and adds the public
auth + authenticated linking endpoints. Stack: this PR is on top of
nic/v2-272-sso-foundation (chunk A) — merge that first.

Public routes
- GET  /api/v2/auth/oidc/providers
    Lists enabled providers as {id, name, display_name}. Deliberately
    leaves out client_id and the encrypted secret — the login page just
    needs labels.
- GET  /api/v2/auth/oidc/authorize/{providerId}
    Calls BuildAuthorizeURL, sets the encrypted state cookie
    (HttpOnly + Secure + SameSite=Lax + Path=/api/v2/auth/oidc), and
    302's to the IdP. Cookie path is scoped so it doesn't leak to other
    routes.
- GET  /api/v2/auth/oidc/callback
    Clears the state cookie up-front (success OR failure), calls
    HandleCallback, issues a session JWT via the same path Login uses
    (jwt_expiry_hours setting, session cookie with the same flags), and
    302's to / on success or /login?error=<code> on failure. IdP-side
    errors (?error=access_denied etc.) pass through to /login.

Authenticated routes
- POST   /api/v2/auth/oidc/link/{providerId}
    Same flow as authorize but with LinkToUserID set to the current
    user. Returns {authorize_url} as JSON so the frontend can do
    window.location.href — POST→302 doesn't work cleanly with fetch.
- DELETE /api/v2/auth/oidc/identities/{identityId}
    Calls UnlinkIdentity. 409 + last_login_method code when removing
    would leave the user with no way to log in.
- GET    /api/v2/me/oidc/identities
    Returns the user's linked identities with provider_name resolved
    for the profile "connected accounts" view.

Admin route
- PUT /api/v2/admin/oidc/providers/{id}/auto-provision
    {auto_provision, default_group_id} — separate from the main Update
    endpoint so the UI toggle (chunk C) can flip it without re-supplying
    every other field. default_group_id=0 clears the group assignment.

Error code mapping (oidcErrorCode):
- ErrOIDCNoAccount       → no_account
- ErrOIDCEmailCollision  → email_exists
- state/nonce/cookie     → session_expired
- ErrOIDCMissingEmail    → missing_email
- ErrOIDCProviderDisabled→ provider_disabled
- everything else        → internal

oidcCallbackURL prefers cfg.BaseURL when set (production), falls back to
deriving scheme + host from the incoming request (dev / single-host).

Tests: 10 in internal/handlers/oidc_auth_test.go
- ListOIDCProviders only shows enabled, doesn't leak client_id
- Authorize sets HttpOnly+Lax cookie, 302's to IdP, URL includes PKCE
  S256 challenge
- Authorize → 404 for unknown provider
- Full round-trip: authorize → fake IdP /authorize → callback with
  pre-linked identity → session cookie set, 302 to /
- Callback with auto_provision=off + unknown sub → /login?error=no_account
- Callback with IdP ?error=access_denied → /login?error=access_denied
- Unlink requires auth (401)
- Unlink last-login-method returns 409 with last_login_method code
- /me/oidc/identities lists user's identities with provider_name resolved
- Admin set auto_provision persists

The handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that
auto-redirects /authorize back to the callback with code+state, so the
full authorize → IdP → callback chain runs in-process. Chunk D adds Dex
on top of this for browser-driven verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sso): login page SSO buttons + profile connected accounts + admin auto-provision UI (V2-272 chunk C)

Third of four PRs for V2-272. Stack: on top of chunk B
(nic/v2-272-sso-handlers).

LoginView.vue
- Fetches /api/v2/auth/oidc/providers on mount, renders
  "Sign in with X" buttons below the password form, separated by an
  "or" divider that only appears when at least one provider is enabled.
- Click → window.location.href = authorize URL (full-page navigation;
  fetch() would strand the state cookie).
- Reads ?error=<code> on mount and renders a human-friendly message
  for each of the error codes the callback emits: no_account,
  email_exists, session_expired, missing_email, provider_disabled,
  access_denied, internal. Fallback message includes the raw code so
  unknown errors don't disappear silently.

ProfileView.vue — new "Connected Accounts" card
- Lists OIDC identities from GET /me/oidc/identities with provider
  name, subject, and link date. Each row has an "Unlink" button.
- Unlink shows a native confirm() then DELETEs
  /auth/oidc/identities/{id}. The 409 last_login_method case gets a
  specific warn toast explaining the user needs to set a password or
  link another provider first.
- "Link another provider" section below the list, showing only
  providers the user doesn't already have linked (computed). Click →
  POSTs /auth/oidc/link/{id}, follows the returned authorize_url via
  full-page nav, completing the flow at /callback?...&link=1.
- Whole card is hidden when there's nothing linkable AND nothing
  linked, so password-only deployments don't see an empty section.

SettingsView.vue — admin auto-provision toggle
- Each OIDC provider in the SSO tab now renders editable controls:
    * ToggleSwitch for auto_provision
    * Select dropdown for default_group_id (with "None" sentinel = 0)
    * Save button that PUTs to
      /admin/oidc/providers/{id}/auto-provision and toasts on success
- Re-fetches on save failure so the UI reflects backend truth.
- Inline explanation calls out that email-collision is always blocked
  — never auto-link by email alone, regardless of toggle state.
- Groups fetched from /admin/groups on mount alongside providers.

Backend (admin_oidc.go) — response shape extension
- AdminListOIDCProviders now includes auto_provision (bool) and
  default_group_id (nullable int) in each provider row so the new UI
  has state to render. No new endpoint — the existing PUT
  .../{id}/auto-provision from chunk B handles the write side.

Test plan
- go test ./internal/handlers — green, no regressions
- npx vue-tsc --noEmit — clean

Chunk D
- Dex as CI service container running the full authorize flow through
  a real OIDC server
- Playwright E2E walks login button → Dex consent → callback → home

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(swagger): regenerate docs to match handlers

Lint job on master enforces swagger freshness; the cumulative handler
changes on this branch hadn't been reflected. Generated with:

  swag init -g cmd/indelible/main.go -o docs/ --parseDependency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nic-dorman added a commit that referenced this pull request May 19, 2026
… master (#40)

PR #27 (V2-273) merged the nullableAddedBy() fix that this test's t.Skip
was waiting on. The test passes locally with -count=1 after dropping the
skip; the original FK-violation path is gone.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant