Files
kandev/docs/decisions/0050-plugin-external-auth-capability.md
T
João Salavisa b20047b3eb feat(plugins): add auth capability for plugin-provided OIDC/SAML login (#1964)
* feat(plugins): add auth capability for plugin-provided OIDC/SAML login

Plugins can now authenticate a visitor against an external IdP (OIDC/SAML)
and contribute a login button to the pre-auth screen:

- new `auth` manifest capability + a capability-gated X-Kandev-Auth-Login
  webhook-response directive; the host mints the kandev_session (ADR 0050),
  the plugin never receives the raw token. Plugin-set Set-Cookie is dropped.
- auth.Service.AuthenticateExternal: existing-identity / link-by-email / JIT
  member provisioning; refuses to auto-link to an admin; verified-email is
  the plugin's contract.
- auth_providers manifest block + plugins.SSOProviders() populate the
  anonymous boot payload (auth.ssoProviders); the login page renders a
  "Continue with <provider>" button per provider.
- ADR 0050 + plugin-manifest / plugin-authoring / auth spec docs.

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

* docs: exclude publish_review_findings_kandev from public-docs coverage

The native code-review agent (#1957) added the publish_review_findings_kandev
MCP tool but did not update docs/public/coverage.json; that PR did not touch
docs/public so the path-filtered validator never ran. It's an internal review
reporting tool, not a user-facing feature, so record it as an exclusion.

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

* fix(plugins,auth): address review on SSO directive and JIT provisioning

- takeAuthLoginDirective strips every case variant of X-Kandev-Auth-Login and
  rejects (403) when a plugin sends more than one, so no variant leaks to the
  browser and login never uses a nondeterministically-picked assertion.
- provisionExternalUser rolls back the just-created user when the identity
  insert fails (transient error or a lost UNIQUE(provider,subject) race) and
  resolves to the winning identity, so no account is left without a login and
  no email is reserved by an unusable row. Adds userstore.DeleteUser.
- auth spec: mark the login-page SSO buttons + provider discovery as landed.

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

* docs: drop redundant coverage exclusion; main now covers publish_review_findings_kandev

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

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 18:20:39 +01:00

5.8 KiB

0050 — Plugins provide OIDC/SAML login via a capability-gated, host-minted session

Context

Kandev's opt-in auth (ADR 2026-07-24) ships a provider-based identity schema — auth_identities(provider, subject) with UNIQUE(provider, subject) — and says "OIDC/SSO can be added by inserting rows with a new provider … no migration required." What it did not provide was a way for a plugin to do that insertion and establish a session, so an external IdP (OIDC/SAML) could be delivered as an out-of-tree plugin rather than baked into the host.

The plugin capability model (ADR 0043) is a set of capability-gated Host gRPC RPCs the plugin calls, plus host→plugin events and a proxied inbound webhook (/api/plugins/{id}/webhooks/{key}, already on the auth public allowlist). None of these can establish an authenticated browser session.

The architectural constraint that shaped this decision: the kandev_session cookie must be set on the HTTP response to the browser. A Host gRPC RPC runs on the go-plugin broker channel, not the browser's HTTP connection, so it cannot set that cookie. The only plugin→browser HTTP path is the webhook response. Therefore session establishment must happen on the webhook response path, regardless of whether a new RPC exists.

Decision

Add an auth plugin capability and a capability-gated login directive on the plugin webhook response. An auth-capable plugin, after validating an IdP token/assertion in its webhook handler (the OIDC callback / SAML ACS), returns a reserved header X-Kandev-Auth-Login whose value is a JSON assertion {provider, subject, email, display_name}. The host webhook relay:

  1. Always consumes and strips the directive header, so a plugin-controlled value can never reach the browser.
  2. Interprets it only when the plugin declares capabilities.auth — a plugin that emits the directive without the capability is rejected (403), not silently ignored.
  3. Calls auth.Service.AuthenticateExternal, which maps the assertion to a user (existing identity → link-by-email → JIT-provision a member), mints a session, and the host sets the kandev_session cookie itself. The email-linking step is trust-sensitive: the plugin MUST only assert an email the IdP verified as owned by the subject, otherwise a spoofed email claim is an account takeover. As defense-in-depth the host refuses to auto-link to an admin account (ErrSSOAdminLinkForbidden) — new users are members and an admin can only be reached through a link deliberately established while they were a member — but it cannot itself distinguish a verified email from an unverified one; that guarantee is the plugin's.
  4. Drops any plugin-supplied Set-Cookie header on the webhook relay, so a plugin cannot overwrite the minted session cookie or fixate any other.

The plugin never receives the raw session token. Its authority is limited to asserting who the user is; the host owns minting, cookie flags, and the session lifecycle. AuthenticateExternal only operates in ModeEnabled, never creates or elevates an admin, and rejects disabled accounts. Sessions are the existing DB-backed opaque cookies, so per-user scoping, revocation, and user-disable apply to SSO sessions unchanged.

We deliberately did not add a Host.AuthenticateExternalIdentity RPC that returns the token to the plugin: it cannot set the browser cookie anyway, and handing the raw session token to the plugin is strictly weaker.

Consequences

  • OIDC/SAML ships as an out-of-tree plugin using only the existing webhook transport; no proto/gRPC contract change, no schema migration.
  • The auth capability is the highest-privilege capability in the system — a plugin holding it can log a visitor in as any (or a new) user. It must only be granted to operator-trusted plugins; treat it like admin install.
  • Dropping plugin Set-Cookie on the webhook relay is a small behavior change for existing webhook plugins (they had no legitimate reason to set cookies).
  • Login-screen discovery (landed). A plugin declares login buttons via a manifest auth_providers block ({id, display_name, initiate}, initiate naming one of its webhook keys). plugins.Service.SSOProviders() aggregates those from active, auth-capable plugins into initiate URLs, and the anonymous /api/v1/app-state boot payload carries them as auth.ssoProviders; the pre-auth login page renders one button per provider. No pre-auth plugin JS runs — the button is a plain navigation to the plugin's initiate webhook.
  • Not yet built: setup-mode bootstrap of the first admin via SSO is out of scope (SSO requires ModeEnabled); the OIDC/SAML plugin implementations live in their own repos (e.g. kandev-plugin-google-oidc).

Alternatives considered

  • A Host RPC that returns the session token to the plugin. Rejected: the RPC channel cannot set the browser cookie, so the plugin would still set it via the webhook response — the same path — while additionally holding the raw token. More coupling, weaker security, and a proto change for no gain.
  • A plugin-registered HTTP route / middleware seam. Rejected: a far larger surface (arbitrary route registration, request interception) than SSO needs; the existing public-allowlisted webhook already receives the callback.
  • First-party OIDC/SAML in the host. Still possible later, but the point of this work is to prove the capability path so IdP integrations live and ship independently of the monorepo.