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>
This commit is contained in:
João Salavisa
2026-07-26 18:20:39 +01:00
committed by GitHub
parent a127e2dffd
commit b20047b3eb
28 changed files with 1259 additions and 8 deletions
@@ -17,6 +17,16 @@ func setSessionCookie(c *gin.Context, name, token string, ttl time.Duration) {
c.SetCookie(name, token, int(ttl.Seconds()), "/", "", requestIsTLS(c), true)
}
// SetSessionCookie writes the kandev session cookie with the same flags as the
// login/setup handlers. Exported for callers outside this package that mint a
// session and must set the cookie themselves — the plugin SSO login bridge
// (internal/backendapp) after an auth-capable plugin validates an external
// identity. Keeping this the single cookie-writing path means TLS/SameSite/
// HttpOnly flags never drift between the local and SSO login flows.
func SetSessionCookie(c *gin.Context, name, token string, ttl time.Duration) {
setSessionCookie(c, name, token, ttl)
}
func clearSessionCookie(c *gin.Context, name string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(name, "", -1, "/", "", requestIsTLS(c), true)
+179
View File
@@ -0,0 +1,179 @@
package auth
import (
"context"
"database/sql"
"errors"
"strings"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/auth/store"
usermodels "github.com/kandev/kandev/internal/user/models"
)
// Sentinel errors for the external (OIDC/SAML) login path.
var (
// ErrSSONotEnabled is returned when an external login is attempted while
// authentication is not fully enabled. SSO logs in against real accounts,
// so it only operates in ModeEnabled — never disabled or setup.
ErrSSONotEnabled = errors.New("sso login requires authentication to be enabled")
// ErrSSOInvalidAssertion is returned for a malformed external identity:
// an empty/local provider, an empty subject, or a new identity with no
// email to link or provision against.
ErrSSOInvalidAssertion = errors.New("invalid external identity assertion")
// ErrSSOAdminLinkForbidden is returned when an external login would
// auto-link to an existing admin account. External login must never
// authenticate an admin it didn't already have a deliberate identity link
// for — otherwise a plugin/IdP asserting an admin's (possibly unverified)
// email would be an admin account takeover.
ErrSSOAdminLinkForbidden = errors.New("external login cannot link to an admin account")
)
// ExternalIdentity is a validated identity assertion from an auth-capable
// plugin that has authenticated a user against an external IdP (OIDC/SAML).
// The plugin is trusted to have verified the token/signature and mapped IdP
// claims; kandev maps the assertion to a user and mints the session. Provider
// is a non-"local" provider key (e.g. "oidc:https://issuer.example"); Subject
// is the stable IdP subject claim. Email/DisplayName come from the IdP.
//
// SECURITY CONTRACT: Email is used to auto-link to (or provision) an account,
// so the plugin MUST only assert an email it has confirmed the IdP verified as
// owned by Subject. Asserting an unverified/user-settable email is an account
// takeover vector. As defense-in-depth kandev additionally refuses to auto-link
// to an admin account (ErrSSOAdminLinkForbidden), but it cannot itself tell a
// verified email from an unverified one — that guarantee is the plugin's.
type ExternalIdentity struct {
Provider string
Subject string
Email string
DisplayName string
}
// AuthenticateExternal establishes a browser session for an external identity
// asserted by an auth-capable plugin. Resolution order:
//
// 1. an existing (provider, subject) identity → its user;
// 2. else an active user with the asserted email → link a new identity to it
// (just-in-time account linking);
// 3. else just-in-time provision a new member user + identity.
//
// New users are always provisioned as members; external login never creates or
// elevates an admin (role changes stay with admin user management). A disabled
// account is rejected. The returned string is the raw session token to place
// in the kandev_session cookie — the caller (the plugin webhook relay) sets the
// cookie; the plugin never receives this token.
func (s *Service) AuthenticateExternal(ctx context.Context, ext ExternalIdentity, userAgent, ip string) (*usermodels.User, string, error) {
if s.Mode() != ModeEnabled {
return nil, "", ErrSSONotEnabled
}
provider := strings.TrimSpace(ext.Provider)
subject := strings.TrimSpace(ext.Subject)
if provider == "" || provider == store.ProviderLocal || subject == "" {
return nil, "", ErrSSOInvalidAssertion
}
email := normalizeEmail(ext.Email)
user, err := s.resolveExternalUser(ctx, provider, subject, email, ext.DisplayName)
if err != nil {
return nil, "", err
}
token, err := s.createSession(ctx, user.ID, userAgent, ip)
if err != nil {
return nil, "", err
}
if s.log != nil {
s.log.Info("external identity authenticated",
zap.String("provider", provider),
zap.String("user_id", user.ID))
}
return user, token, nil
}
// resolveExternalUser maps a validated external identity to a kandev user,
// linking or provisioning as needed. See AuthenticateExternal for the order.
func (s *Service) resolveExternalUser(ctx context.Context, provider, subject, email, displayName string) (*usermodels.User, error) {
identity, err := s.store.GetIdentityByProviderSubject(ctx, provider, subject)
switch {
case err == nil:
// Known external identity: log in as its (still-active) user.
return s.activeUser(ctx, identity.UserID)
case !errors.Is(err, store.ErrNotFound):
return nil, err
}
// Unknown identity. Link it to an existing active account by email when we
// can, otherwise just-in-time provision a new member account. A real store
// error (transient DB failure, context cancellation) must abort rather than
// be masked as "no such user" and fall through to provisioning — only a
// genuine no-rows miss (sql.ErrNoRows, what user store's scanUser returns)
// means the email is unused.
if email == "" {
return nil, ErrSSOInvalidAssertion
}
user, err := s.users.GetUserByEmail(ctx, email)
switch {
case err == nil:
if user.Status != usermodels.StatusActive {
return nil, ErrInvalidCredentials
}
// Defense-in-depth against takeover via an unverified/spoofed email
// claim: never auto-link to an admin. New users provision as members,
// so external login can only ever authenticate an admin through a link
// established deliberately while they were a member (then promoted) —
// never one minted from an email assertion here.
if user.Role == usermodels.RoleAdmin {
return nil, ErrSSOAdminLinkForbidden
}
if err := s.linkExternalIdentity(ctx, user.ID, provider, subject); err != nil {
return nil, err
}
return user, nil
case !errors.Is(err, sql.ErrNoRows):
return nil, err
}
return s.provisionExternalUser(ctx, provider, subject, email, displayName)
}
// linkExternalIdentity records a new external identity row for an existing
// user (JIT account linking). The (provider, subject) uniqueness guarantees a
// given IdP subject links to at most one user.
func (s *Service) linkExternalIdentity(ctx context.Context, userID, provider, subject string) error {
return s.store.CreateIdentity(ctx, &store.LoginIdentity{
UserID: userID,
Provider: provider,
Subject: subject,
})
}
// provisionExternalUser creates a new member account and its external identity
// for a first-time SSO visitor. New accounts are always members.
//
// If the identity insert fails — a transient error, or a lost race on
// UNIQUE(provider, subject) when two callbacks for the same subject arrive
// concurrently — the just-created user is rolled back so no account is left
// without a login identity (and its email is not permanently reserved by an
// unusable row). On a lost race the winning identity now exists, so we
// authenticate as its user instead of erroring. (These two stores don't share a
// transaction; compensating cleanup is the pragmatic equivalent here.)
func (s *Service) provisionExternalUser(ctx context.Context, provider, subject, email, displayName string) (*usermodels.User, error) {
user := &usermodels.User{
ID: uuid.New().String(),
Email: email,
DisplayName: displayName,
Role: usermodels.RoleMember,
Status: usermodels.StatusActive,
}
if err := s.users.CreateUser(ctx, user); err != nil {
return nil, err
}
if err := s.linkExternalIdentity(ctx, user.ID, provider, subject); err != nil {
_ = s.users.DeleteUser(ctx, user.ID)
if winner, lookupErr := s.store.GetIdentityByProviderSubject(ctx, provider, subject); lookupErr == nil {
return s.activeUser(ctx, winner.UserID)
}
return nil, err
}
return user, nil
}
@@ -0,0 +1,185 @@
package auth
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/auth/store"
usermodels "github.com/kandev/kandev/internal/user/models"
userstore "github.com/kandev/kandev/internal/user/store"
)
const testOIDCProvider = "oidc:https://issuer.example"
var errBoomSSO = errors.New("transient store failure")
// erroringUsers overrides GetUserByEmail to return a non-not-found error; every
// other AccountRepository method is left nil (this path never calls them).
type erroringUsers struct {
userstore.AccountRepository
err error
}
func (e erroringUsers) GetUserByEmail(context.Context, string) (*usermodels.User, error) {
return nil, e.err
}
func TestAuthenticateExternalRejectedWhenDisabled(t *testing.T) {
f := newServiceFixture(t, false)
ext := ExternalIdentity{Provider: testOIDCProvider, Subject: "sub-1", Email: "a@b.dev"}
if _, _, err := f.svc.AuthenticateExternal(context.Background(), ext, "", ""); !errors.Is(err, ErrSSONotEnabled) {
t.Fatalf("disabled mode: got %v, want ErrSSONotEnabled", err)
}
}
func TestAuthenticateExternalInvalidAssertions(t *testing.T) {
f := newServiceFixture(t, false)
setupEnabled(t, f)
ctx := context.Background()
cases := map[string]ExternalIdentity{
"empty provider": {Provider: "", Subject: "s", Email: "a@b.dev"},
"local provider": {Provider: "local", Subject: "s", Email: "a@b.dev"},
"empty subject": {Provider: testOIDCProvider, Subject: "", Email: "a@b.dev"},
"new without email": {Provider: testOIDCProvider, Subject: "s-noemail"},
}
for name, ext := range cases {
if _, _, err := f.svc.AuthenticateExternal(ctx, ext, "", ""); !errors.Is(err, ErrSSOInvalidAssertion) {
t.Fatalf("%s: got %v, want ErrSSOInvalidAssertion", name, err)
}
}
}
func TestAuthenticateExternalProvisionsMember(t *testing.T) {
f := newServiceFixture(t, false)
setupEnabled(t, f)
ctx := context.Background()
ext := ExternalIdentity{Provider: testOIDCProvider, Subject: "sub-new", Email: "New.User@Example.com", DisplayName: "New User"}
user, token, err := f.svc.AuthenticateExternal(ctx, ext, "ua", "1.2.3.4")
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if user.Email != "new.user@example.com" || user.Role != usermodels.RoleMember {
t.Fatalf("provisioned user = %+v, want normalized member", user)
}
// The minted session authenticates as a non-admin member.
identity, ok := f.svc.ResolveSessionToken(ctx, token)
if !ok || identity.UserID != user.ID || identity.IsAdmin() {
t.Fatalf("session identity = %+v ok=%v, want member %s", identity, ok, user.ID)
}
// A second login for the same (provider, subject) reuses the account —
// no duplicate provisioning.
again, _, err := f.svc.AuthenticateExternal(ctx, ext, "ua", "1.2.3.4")
if err != nil {
t.Fatalf("second authenticate: %v", err)
}
if again.ID != user.ID {
t.Fatalf("second login created a new user %s (first %s)", again.ID, user.ID)
}
}
func TestAuthenticateExternalRefusesAdminLink(t *testing.T) {
f := newServiceFixture(t, false)
setupEnabled(t, f) // creates admin@x.dev (admin)
ctx := context.Background()
// Asserting the admin's email must NOT auto-link / log in as the admin.
ext := ExternalIdentity{Provider: testOIDCProvider, Subject: "sub-admin", Email: "admin@x.dev"}
if _, _, err := f.svc.AuthenticateExternal(ctx, ext, "ua", "1.2.3.4"); !errors.Is(err, ErrSSOAdminLinkForbidden) {
t.Fatalf("admin auto-link: got %v, want ErrSSOAdminLinkForbidden", err)
}
// No identity row was created for the admin.
if _, err := f.svc.store.GetIdentityByProviderSubject(ctx, testOIDCProvider, "sub-admin"); err == nil {
t.Fatal("admin auto-link must not create an identity row")
}
}
func TestAuthenticateExternalLinksExistingMemberByEmail(t *testing.T) {
f := newServiceFixture(t, false)
setupEnabled(t, f)
ctx := context.Background()
// A local-password member adopting SSO is the intended JIT-linking case.
if _, err := f.svc.AdminCreateUser(ctx, "member@x.dev", "memberpass123", "Member", usermodels.RoleMember); err != nil {
t.Fatalf("create member: %v", err)
}
ext := ExternalIdentity{Provider: testOIDCProvider, Subject: "sub-mem", Email: "member@x.dev"}
user, token, err := f.svc.AuthenticateExternal(ctx, ext, "ua", "1.2.3.4")
if err != nil {
t.Fatalf("authenticate: %v", err)
}
identity, ok := f.svc.ResolveSessionToken(ctx, token)
if !ok || identity.UserID != user.ID || identity.IsAdmin() {
t.Fatalf("linked member session = %+v ok=%v, want non-admin member", identity, ok)
}
// The external identity now resolves directly (no email needed).
stored, err := f.svc.store.GetIdentityByProviderSubject(ctx, testOIDCProvider, "sub-mem")
if err != nil || stored.UserID != user.ID {
t.Fatalf("identity not linked: %+v err=%v", stored, err)
}
}
func TestProvisionExternalUserRollsBackOnIdentityConflict(t *testing.T) {
f := newServiceFixture(t, false)
setupEnabled(t, f)
ctx := context.Background()
// A "winner" already owns the (provider, subject) identity — simulating a
// concurrent callback that committed first.
winner, err := f.svc.AdminCreateUser(ctx, "winner@x.dev", "winnerpass123", "Winner", usermodels.RoleMember)
if err != nil {
t.Fatalf("create winner: %v", err)
}
if err := f.svc.store.CreateIdentity(ctx, &store.LoginIdentity{
UserID: winner.ID, Provider: testOIDCProvider, Subject: "shared-sub",
}); err != nil {
t.Fatalf("seed winner identity: %v", err)
}
// Provisioning the same subject with a different email must fail to link,
// roll back the new user, and resolve to the winner instead.
got, err := f.svc.provisionExternalUser(ctx, testOIDCProvider, "shared-sub", "loser@x.dev", "Loser")
if err != nil {
t.Fatalf("provision: %v", err)
}
if got.ID != winner.ID {
t.Fatalf("resolved user = %s, want winner %s", got.ID, winner.ID)
}
// The rolled-back account must not linger reserving loser@x.dev.
if _, err := f.svc.users.GetUserByEmail(ctx, "loser@x.dev"); err == nil {
t.Fatal("orphan user was not rolled back")
}
}
func TestResolveExternalUserPropagatesStoreError(t *testing.T) {
f := newServiceFixture(t, false)
// Empty auth store → unknown identity, so resolution reaches the
// link-by-email lookup; a transient error there must propagate, not be
// masked as "no such user" and fall through to provisioning.
f.svc.users = erroringUsers{err: errBoomSSO}
if _, err := f.svc.resolveExternalUser(context.Background(), testOIDCProvider, "sub-x", "x@y.dev", "X"); !errors.Is(err, errBoomSSO) {
t.Fatalf("got %v, want propagated store error", err)
}
}
func TestAuthenticateExternalRejectsDisabledUser(t *testing.T) {
f := newServiceFixture(t, false)
setupEnabled(t, f)
ctx := context.Background()
// Provision, then disable the account.
ext := ExternalIdentity{Provider: testOIDCProvider, Subject: "sub-dis", Email: "dis@x.dev"}
user, _, err := f.svc.AuthenticateExternal(ctx, ext, "", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
if _, err := f.svc.AdminSetRoleStatus(ctx, user.ID, usermodels.RoleMember, usermodels.StatusDisabled); err != nil {
t.Fatalf("disable: %v", err)
}
if _, _, err := f.svc.AuthenticateExternal(ctx, ext, "", ""); err == nil {
t.Fatal("expected disabled user to be rejected")
}
}
+14
View File
@@ -17,6 +17,20 @@ type StatePayload struct {
// Always true in disabled mode (synthetic single-user identity).
Authenticated bool `json:"authenticated"`
User *usermodels.User `json:"user,omitempty"`
// SSOProviders lists the external login buttons the pre-auth login page
// renders (OIDC/SAML, contributed by auth-capable plugins). Populated by
// the boot-payload assembler for anonymous visitors on an auth-enabled
// instance; empty otherwise.
SSOProviders []SSOProvider `json:"ssoProviders,omitempty"`
}
// SSOProvider is one external login option shown on the login screen. Clicking
// it navigates the browser to InitiateURL (the plugin's login-initiate webhook,
// which 302-redirects to the IdP).
type SSOProvider struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
InitiateURL string `json:"initiateUrl"`
}
// StateFor builds the StatePayload for a request identity (nil identity =
+15
View File
@@ -58,6 +58,21 @@ func (s *Store) GetLocalIdentity(ctx context.Context, userID string) (*LoginIden
return &identity, err
}
// GetIdentityByProviderSubject returns the identity for an external provider's
// subject claim (an OIDC issuer / SAML entity as provider, the IdP subject as
// subject). ErrNotFound when no row matches. The (provider, subject) pair is
// unique, so this resolves at most one identity.
func (s *Store) GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*LoginIdentity, error) {
var identity LoginIdentity
err := s.ro.GetContext(ctx, &identity, s.ro.Rebind(`
SELECT * FROM auth_identities WHERE provider = ? AND subject = ?
`), provider, subject)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &identity, err
}
// UpdatePasswordHash replaces the password hash of a user's local identity.
func (s *Store) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error {
result, err := s.db.ExecContext(ctx, s.db.Rebind(`
+26
View File
@@ -5,9 +5,11 @@ import (
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/auth"
authhttpapi "github.com/kandev/kandev/internal/auth/httpapi"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
@@ -16,6 +18,30 @@ import (
taskservice "github.com/kandev/kandev/internal/task/service"
)
// pluginSSOBridge adapts the auth service to plugins.AuthLoginBridge: it
// authenticates a plugin-asserted external identity (OIDC/SAML) and sets the
// kandev_session cookie, so an auth-capable plugin can complete SSO login
// without ever holding the raw session token. AuthenticateExternal enforces
// that auth is enabled, so this returns an error (surfaced as 403 by the
// plugin webhook relay) when it is not.
type pluginSSOBridge struct {
auth *auth.Service
}
func (b pluginSSOBridge) LoginExternal(c *gin.Context, provider, subject, email, displayName string) error {
_, token, err := b.auth.AuthenticateExternal(c.Request.Context(), auth.ExternalIdentity{
Provider: provider,
Subject: subject,
Email: email,
DisplayName: displayName,
}, c.Request.UserAgent(), c.ClientIP())
if err != nil {
return err
}
authhttpapi.SetSessionCookie(c, b.auth.CookieName(), token, b.auth.SessionTTL())
return nil
}
// provideAuthService wires the opt-in authentication service. Whether auth is
// enforced is read from cfg.Features.Auth (the `features.auth` runtime flag,
// already resolved at startup); credentials come from the auth store, accounts
+29 -1
View File
@@ -27,6 +27,32 @@ const (
quickChatSessionKindConfig = "config"
)
// ssoProvidersForBoot lists the plugin-contributed external-login options for
// the pre-auth login screen. Empty unless auth is enabled and the plugins
// feature is on with at least one active auth-capable plugin declaring
// auth_providers.
func ssoProvidersForBoot(p routeParams) []auth.SSOProvider {
if p.authSvc == nil || p.authSvc.Mode() == auth.ModeDisabled {
return nil
}
if !p.features.Plugins || p.services == nil || p.services.Plugins == nil {
return nil
}
providers := p.services.Plugins.SSOProviders()
if len(providers) == 0 {
return nil
}
out := make([]auth.SSOProvider, 0, len(providers))
for _, prov := range providers {
out = append(out, auth.SSOProvider{
ID: prov.ID,
DisplayName: prov.DisplayName,
InitiateURL: prov.InitiateURL,
})
}
return out
}
func bootInitialState(
ctx context.Context,
req *http.Request,
@@ -46,7 +72,9 @@ func bootInitialState(
if identity, ok := authn.IdentityFromContext(req.Context()); ok {
identityPtr = &identity
}
state["auth"] = p.authSvc.StateFor(ctx, identityPtr)
authState := p.authSvc.StateFor(ctx, identityPtr)
authState.SSOProviders = ssoProvidersForBoot(p)
state["auth"] = authState
if p.authSvc.Mode() != auth.ModeDisabled && identityPtr == nil {
return state
}
@@ -1059,6 +1059,12 @@ func registerSecondaryRoutes(
}
if p.features.Plugins && p.services.Plugins != nil {
if p.authSvc != nil {
// Lets an auth-capable plugin complete OIDC/SAML SSO: it asserts a
// validated external identity on its webhook response and the host
// mints + sets the session cookie (the plugin never sees the token).
p.services.Plugins.SetAuthLoginBridge(pluginSSOBridge{auth: p.authSvc})
}
plugins.RegisterRoutes(p.router, p.services.Plugins, p.services.Plugins.Deliverer(), p.log)
p.log.Debug("Registered Plugins handlers (HTTP)")
}
@@ -0,0 +1,71 @@
package plugins
import (
"strings"
"github.com/gin-gonic/gin"
)
// authLoginHeader is the reserved header an auth-capable plugin sets on its
// webhook response to assert a validated external identity (OIDC/SAML SSO).
// Its value is a JSON externalLoginAssertion. The host always consumes and
// removes it before relaying the response, so the plugin-controlled value can
// never reach the browser, and only interprets it when the plugin declares the
// `auth` capability.
const authLoginHeader = "X-Kandev-Auth-Login"
// externalLoginAssertion is the JSON payload carried by authLoginHeader: the
// identity a plugin validated against its IdP. Provider is a non-"local"
// provider key (e.g. an OIDC issuer or SAML entity ID); Subject is the stable
// IdP subject claim; Email/DisplayName come from the IdP for account linking
// and just-in-time provisioning.
type externalLoginAssertion struct {
Provider string `json:"provider"`
Subject string `json:"subject"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
// AuthLoginBridge establishes an authenticated kandev browser session for an
// external identity that an auth-capable plugin has validated against an
// external IdP (OIDC/SAML SSO). The host mints and sets the kandev_session
// cookie on the response; the plugin never receives the raw session token —
// its only channel is the reserved login directive header on its webhook
// response (see handlers.go). Implemented in internal/backendapp over
// internal/auth so this package need not depend on the auth service.
type AuthLoginBridge interface {
// LoginExternal maps the asserted external identity to a kandev user
// (linking or just-in-time provisioning as needed) and sets the session
// cookie on c. It returns an error when authentication is not enabled or
// the identity cannot be established.
LoginExternal(c *gin.Context, provider, subject, email, displayName string) error
}
// takeAuthLoginDirective removes the reserved SSO login directive header from a
// plugin webhook response's headers and returns its value. It strips *every*
// case variant (e.g. both `X-Kandev-Auth-Login` and `x-kandev-auth-login`,
// which are distinct Go map keys) so no variant can be relayed to the browser,
// regardless of whether the plugin holds the `auth` capability. If a plugin
// sends more than one variant, ambiguous is true and the caller must reject:
// map iteration order is nondeterministic, so we must never pick one arbitrarily
// and authenticate a possibly-different asserted identity.
func takeAuthLoginDirective(headers map[string]string) (raw string, present, ambiguous bool) {
var matched []string
for k := range headers {
if strings.EqualFold(k, authLoginHeader) {
matched = append(matched, k)
}
}
for _, k := range matched {
raw = headers[k]
delete(headers, k)
}
switch len(matched) {
case 0:
return "", false, false
case 1:
return raw, true, false
default:
return "", true, true
}
}
@@ -0,0 +1,238 @@
package plugins
import (
"errors"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/pkg/pluginsdk"
)
var errTestBridge = errors.New("bridge failed")
// fakeBridge records the LoginExternal call and optionally sets a session
// cookie / returns an error, standing in for the real auth-backed bridge.
type fakeBridge struct {
called bool
provider, subject, email, display string
err error
setCookie bool
}
func (b *fakeBridge) LoginExternal(c *gin.Context, provider, subject, email, displayName string) error {
b.called = true
b.provider, b.subject, b.email, b.display = provider, subject, email, displayName
if b.err != nil {
return b.err
}
if b.setCookie {
c.SetCookie("kandev_session", "minted-token", 3600, "/", "", false, true)
}
return nil
}
func authAssertionHeader() string {
return `{"provider":"oidc:iss","subject":"sub-1","email":"a@b.dev","display_name":"Alice"}`
}
func newTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest("POST", "/api/plugins/p/webhooks/cb", nil)
return c, rec
}
func authCapableRecord() *store.Record {
return &store.Record{Manifest: manifest.Manifest{
ID: "p",
Capabilities: manifest.Capabilities{Auth: true},
}}
}
func TestTakeAuthLoginDirective(t *testing.T) {
headers := map[string]string{
"Location": "/",
"x-kandev-auth-login": "payload", // case-insensitive match
}
raw, present, ambiguous := takeAuthLoginDirective(headers)
if !present || ambiguous || raw != "payload" {
t.Fatalf("directive = %q present=%v ambiguous=%v", raw, present, ambiguous)
}
if _, stillThere := headers["x-kandev-auth-login"]; stillThere {
t.Fatal("directive header was not stripped")
}
if _, present, _ := takeAuthLoginDirective(map[string]string{"Location": "/"}); present {
t.Fatal("no directive should report absent")
}
}
func TestTakeAuthLoginDirectiveMultipleVariantsAmbiguous(t *testing.T) {
// Two case variants are distinct Go map keys; picking one is nondeterministic
// and would leak/relay the other. Must be reported ambiguous with BOTH stripped.
headers := map[string]string{
"X-Kandev-Auth-Login": "a",
"x-kandev-auth-login": "b",
"Location": "/",
}
_, present, ambiguous := takeAuthLoginDirective(headers)
if !present || !ambiguous {
t.Fatalf("present=%v ambiguous=%v, want both true", present, ambiguous)
}
for k := range headers {
if k != "Location" {
t.Fatalf("directive variant %q not stripped", k)
}
}
}
func TestWriteWebhookResponseAuthLoginSuccess(t *testing.T) {
bridge := &fakeBridge{setCookie: true}
svc := &Service{}
svc.SetAuthLoginBridge(bridge)
ctrl := &Controller{svc: svc, log: testLogger(t)}
c, rec := newTestContext(t)
resp := &pluginsdk.WebhookResponse{
Status: 302,
Headers: map[string]string{
"Location": "/app",
authLoginHeader: authAssertionHeader(),
"Set-Cookie": "evil=1", // plugin-set cookies must be dropped
},
Body: []byte("redirecting"),
}
ctrl.writeWebhookResponse(c, authCapableRecord(), resp)
if rec.Code != 302 {
t.Fatalf("status = %d, want 302", rec.Code)
}
if !bridge.called || bridge.provider != "oidc:iss" || bridge.subject != "sub-1" ||
bridge.email != "a@b.dev" || bridge.display != "Alice" {
t.Fatalf("bridge not invoked with parsed assertion: %+v", bridge)
}
if rec.Header().Get("Location") != "/app" {
t.Fatalf("Location not relayed: %q", rec.Header().Get("Location"))
}
if rec.Header().Get(authLoginHeader) != "" {
t.Fatal("auth directive header leaked to the browser")
}
setCookies := strings.Join(rec.Header().Values("Set-Cookie"), "; ")
if !strings.Contains(setCookies, "kandev_session=minted-token") {
t.Fatalf("host session cookie missing: %q", setCookies)
}
if strings.Contains(setCookies, "evil=1") {
t.Fatalf("plugin-set cookie was relayed: %q", setCookies)
}
}
func TestWriteWebhookResponseAmbiguousDirectiveRejected(t *testing.T) {
bridge := &fakeBridge{}
svc := &Service{}
svc.SetAuthLoginBridge(bridge)
ctrl := &Controller{svc: svc, log: testLogger(t)}
c, rec := newTestContext(t)
resp := &pluginsdk.WebhookResponse{
Status: 302,
Headers: map[string]string{
"X-Kandev-Auth-Login": authAssertionHeader(),
"x-kandev-auth-login": `{"provider":"evil","subject":"x","email":"e@x.dev"}`,
},
}
ctrl.writeWebhookResponse(c, authCapableRecord(), resp)
if rec.Code != 403 {
t.Fatalf("status = %d, want 403 for ambiguous directive", rec.Code)
}
if bridge.called {
t.Fatal("bridge must not be called on an ambiguous directive")
}
if rec.Header().Get(authLoginHeader) != "" {
t.Fatal("a directive variant leaked to the browser")
}
}
func TestWriteWebhookResponseAuthLoginRequiresCapability(t *testing.T) {
bridge := &fakeBridge{}
svc := &Service{}
svc.SetAuthLoginBridge(bridge)
ctrl := &Controller{svc: svc, log: testLogger(t)}
c, rec := newTestContext(t)
record := &store.Record{Manifest: manifest.Manifest{ID: "p"}} // Capabilities.Auth == false
resp := &pluginsdk.WebhookResponse{
Status: 302,
Headers: map[string]string{authLoginHeader: authAssertionHeader()},
}
ctrl.writeWebhookResponse(c, record, resp)
if rec.Code != 403 {
t.Fatalf("status = %d, want 403", rec.Code)
}
if bridge.called {
t.Fatal("bridge must not be called without the auth capability")
}
}
func TestWriteWebhookResponseAuthLoginNoBridge(t *testing.T) {
ctrl := &Controller{svc: &Service{}, log: testLogger(t)} // no bridge wired (auth off)
c, rec := newTestContext(t)
resp := &pluginsdk.WebhookResponse{
Status: 302,
Headers: map[string]string{authLoginHeader: authAssertionHeader()},
}
ctrl.writeWebhookResponse(c, authCapableRecord(), resp)
if rec.Code != 403 {
t.Fatalf("status = %d, want 403 when auth is not enabled", rec.Code)
}
}
func TestWriteWebhookResponseBridgeError(t *testing.T) {
bridge := &fakeBridge{err: errTestBridge}
svc := &Service{}
svc.SetAuthLoginBridge(bridge)
ctrl := &Controller{svc: svc, log: testLogger(t)}
c, rec := newTestContext(t)
resp := &pluginsdk.WebhookResponse{
Status: 302,
Headers: map[string]string{authLoginHeader: authAssertionHeader()},
}
ctrl.writeWebhookResponse(c, authCapableRecord(), resp)
if rec.Code != 403 {
t.Fatalf("status = %d, want 403 on bridge error", rec.Code)
}
if !bridge.called {
t.Fatal("bridge should have been called")
}
// The raw bridge/store error must not be disclosed to the (public) caller.
if strings.Contains(rec.Body.String(), errTestBridge.Error()) {
t.Fatalf("raw error leaked to caller: %s", rec.Body.String())
}
}
func TestWriteWebhookResponseNoDirectiveRelayedVerbatim(t *testing.T) {
ctrl := &Controller{svc: &Service{}, log: testLogger(t)}
c, rec := newTestContext(t)
resp := &pluginsdk.WebhookResponse{
Status: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(`{"ok":true}`),
}
ctrl.writeWebhookResponse(c, authCapableRecord(), resp)
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/json" || rec.Body.String() != `{"ok":true}` {
t.Fatalf("plain webhook not relayed: code=%d ct=%q body=%q", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
}
}
+50 -4
View File
@@ -1,6 +1,7 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"io"
@@ -390,7 +391,7 @@ func (c *Controller) webhook(ctx *gin.Context) {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
writeWebhookResponse(ctx, resp)
c.writeWebhookResponse(ctx, record, resp)
}
// webhookStatusForResponse validates a plugin-supplied WebhookResponse.Status
@@ -410,9 +411,17 @@ func webhookStatusForResponse(status int32) (int, bool) {
// writeWebhookResponse turns a plugin's WebhookResponse into the outbound
// HTTP response: an out-of-range Status is rejected as 502 (see
// webhookStatusForResponse) before ever reaching ctx.Writer.WriteHeader;
// otherwise headers, status, and body are relayed verbatim.
func writeWebhookResponse(ctx *gin.Context, resp *pluginsdk.WebhookResponse) {
// webhookStatusForResponse) before ever reaching ctx.Writer.WriteHeader.
//
// Before relaying, it consumes the reserved SSO login directive (an
// auth-capable plugin asserting a validated external identity — OIDC/SAML)
// and, when accepted, mints and sets the session cookie host-side so the
// plugin never handles the raw token. Plugin-supplied Set-Cookie headers are
// dropped: a relayed webhook has no legitimate reason to set browser cookies,
// and dropping them stops a plugin from overwriting the session cookie the
// host just minted or fixating any other. Remaining headers, status, and body
// are relayed verbatim.
func (c *Controller) writeWebhookResponse(ctx *gin.Context, record *store.Record, resp *pluginsdk.WebhookResponse) {
status, ok := webhookStatusForResponse(resp.Status)
if !ok {
ctx.JSON(http.StatusBadGateway, gin.H{
@@ -420,13 +429,50 @@ func writeWebhookResponse(ctx *gin.Context, resp *pluginsdk.WebhookResponse) {
})
return
}
if raw, present, ambiguous := takeAuthLoginDirective(resp.Headers); present {
if ambiguous {
c.log.Warn("plugin sent multiple auth login directives", zap.String("plugin", record.ID))
ctx.JSON(http.StatusForbidden, gin.H{"error": "auth login rejected"})
return
}
if err := c.applyAuthLogin(ctx, record, raw); err != nil {
// The webhook endpoint is public; keep the response body generic so
// a raw store/auth error can't be disclosed to an anonymous caller.
// The real cause is logged for the operator.
c.log.Warn("plugin auth login rejected", zap.String("plugin", record.ID), zap.Error(err))
ctx.JSON(http.StatusForbidden, gin.H{"error": "auth login rejected"})
return
}
}
for k, v := range resp.Headers {
if http.CanonicalHeaderKey(k) == "Set-Cookie" {
continue
}
ctx.Writer.Header().Set(k, v)
}
ctx.Writer.WriteHeader(status)
_, _ = ctx.Writer.Write(resp.Body)
}
// applyAuthLogin establishes a browser session from a plugin's external-identity
// assertion. It requires the plugin to declare the `auth` capability and the
// SSO login bridge to be wired (auth enabled); a plugin that emits the directive
// without the capability is rejected rather than silently ignored.
func (c *Controller) applyAuthLogin(ctx *gin.Context, record *store.Record, raw string) error {
if !record.Capabilities.Auth {
return fmt.Errorf("plugin %q used the auth login directive without the 'auth' capability", record.ID)
}
bridge := c.svc.authLoginBridge()
if bridge == nil {
return errors.New("authentication is not enabled")
}
var a externalLoginAssertion
if err := json.Unmarshal([]byte(raw), &a); err != nil {
return errors.New("invalid auth login assertion")
}
return bridge.LoginExternal(ctx, a.Provider, a.Subject, a.Email, a.DisplayName)
}
// manifestDeclaresWebhookKey reports whether record's manifest declares a
// webhooks[] entry with the given key.
func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
@@ -480,7 +480,8 @@ func TestWriteWebhookResponse_OutOfRangePluginStatusReturns502(t *testing.T) {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
writeWebhookResponse(ctx, &pluginsdk.WebhookResponse{Status: 0, Body: []byte("boom")})
ctrl := &Controller{svc: &Service{}, log: testLogger(t)}
ctrl.writeWebhookResponse(ctx, &store.Record{}, &pluginsdk.WebhookResponse{Status: 0, Body: []byte("boom")})
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502, body=%s", rec.Code, rec.Body.String())
@@ -495,7 +496,8 @@ func TestWriteWebhookResponse_ValidStatusRelaysHeadersAndBody(t *testing.T) {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
writeWebhookResponse(ctx, &pluginsdk.WebhookResponse{
ctrl := &Controller{svc: &Service{}, log: testLogger(t)}
ctrl.writeWebhookResponse(ctx, &store.Record{}, &pluginsdk.WebhookResponse{
Status: 201,
Headers: map[string]string{"X-Plugin": "slack"},
Body: []byte(`{"ok":true}`),
@@ -39,6 +39,13 @@ type Manifest struct {
Webhooks []Webhook `yaml:"webhooks,omitempty" json:"webhooks,omitempty"`
// AuthProviders declares external login options (OIDC/SAML) this plugin
// contributes to the pre-auth login screen. Only meaningful alongside
// capabilities.auth. Each provider's Initiate names one of the plugin's
// webhook keys — the login button navigates to that webhook, which
// 302-redirects the browser to the IdP.
AuthProviders []AuthProvider `yaml:"auth_providers,omitempty" json:"auth_providers,omitempty"`
ConfigSchema map[string]any `yaml:"config_schema,omitempty" json:"config_schema,omitempty"`
UI UISection `yaml:"ui,omitempty" json:"ui,omitempty"`
@@ -75,6 +82,24 @@ type Capabilities struct {
// AgentInvoke gates Host.InvokeUtilityAgent (ADR 0048): a one-shot,
// non-interactive completion run by the operator-configured utility agent.
AgentInvoke bool `yaml:"agent_invoke,omitempty" json:"agent_invoke,omitempty"`
// Auth gates a plugin's ability to establish an authenticated kandev
// browser session for an external identity it has validated against an
// OIDC/SAML IdP. The plugin asserts the identity via the reserved login
// directive header on its webhook response; the host mints and sets the
// session cookie itself, so the plugin never receives the raw session
// token. This is the highest-privilege capability — a plugin that holds it
// can log a visitor in as any (or a new) user — so it should only be
// granted to plugins an operator explicitly trusts.
Auth bool `yaml:"auth,omitempty" json:"auth,omitempty"`
}
// AuthProvider is a login option a plugin contributes to the pre-auth login
// screen. ID is a stable provider key; DisplayName is the button label;
// Initiate is the plugin webhook key that starts the login redirect.
type AuthProvider struct {
ID string `yaml:"id" json:"id"`
DisplayName string `yaml:"display_name" json:"display_name"`
Initiate string `yaml:"initiate" json:"initiate"`
}
// Webhook is a proxied external webhook endpoint the plugin declares.
+25
View File
@@ -99,6 +99,13 @@ type Service struct {
utilityAgents utilityAgentSource
utilityRunner utilityRunner
// authLogin establishes an authenticated browser session for an external
// identity an auth-capable plugin asserts via its webhook response
// (OIDC/SAML SSO), wired via SetAuthLoginBridge. nil until backendapp
// wires it (only when the auth service exists); a nil bridge means the SSO
// login directive is rejected.
authLogin AuthLoginBridge
// kandevVersion is the currently running kandev build version, used to
// enforce a package's manifest.min_kandev_version at Install (see
// SetKandevVersion / checkMinKandevVersion). Empty (the default) means
@@ -250,6 +257,24 @@ func (s *Service) utilityAgentDeps() (utilityAgentSource, utilityRunner) {
return s.utilityAgents, s.utilityRunner
}
// SetAuthLoginBridge wires the SSO login bridge auth-capable plugins use to
// establish a browser session from a validated external identity (see
// auth_login.go). Called once during startup wiring, only when the auth
// service exists.
func (s *Service) SetAuthLoginBridge(b AuthLoginBridge) {
s.mu.Lock()
defer s.mu.Unlock()
s.authLogin = b
}
// authLoginBridge returns the wired SSO login bridge, or nil when auth is not
// available. Read under s.mu against the SetAuthLoginBridge write.
func (s *Service) authLoginBridge() AuthLoginBridge {
s.mu.Lock()
defer s.mu.Unlock()
return s.authLogin
}
// SetKandevVersion wires the currently running kandev build version,
// enabling Install to enforce a package's manifest.min_kandev_version
// (checkMinKandevVersion): a package requiring a newer kandev is rejected
+35
View File
@@ -0,0 +1,35 @@
package plugins
// SSOProvider is a login option surfaced on the pre-auth login screen,
// contributed by an active auth-capable plugin's manifest auth_providers.
// InitiateURL is the plugin webhook the login button navigates to, which
// 302-redirects the browser to the IdP.
type SSOProvider struct {
ID string
DisplayName string
InitiateURL string
}
// SSOProviders returns the external login options from every active,
// auth-capable plugin that declares auth_providers. A provider whose Initiate
// does not match one of the plugin's declared webhook keys is skipped (its
// button would 404), mirroring the webhook handler's own key check.
func (s *Service) SSOProviders() []SSOProvider {
var out []SSOProvider
for _, rec := range s.List() {
if rec.Status != StatusActive || !rec.Capabilities.Auth {
continue
}
for _, p := range rec.AuthProviders {
if p.ID == "" || p.Initiate == "" || !manifestDeclaresWebhookKey(rec, p.Initiate) {
continue
}
out = append(out, SSOProvider{
ID: p.ID,
DisplayName: p.DisplayName,
InitiateURL: "/api/plugins/" + rec.ID + "/webhooks/" + p.Initiate,
})
}
}
return out
}
+52
View File
@@ -0,0 +1,52 @@
package plugins
import (
"testing"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
)
func ssoRecord(id string, status string, auth bool, webhooks []string, providers []manifest.AuthProvider) *store.Record {
whs := make([]manifest.Webhook, 0, len(webhooks))
for _, k := range webhooks {
whs = append(whs, manifest.Webhook{Key: k})
}
return &store.Record{
Status: status,
Manifest: manifest.Manifest{
ID: id,
Capabilities: manifest.Capabilities{Auth: auth},
Webhooks: whs,
AuthProviders: providers,
},
}
}
func TestSSOProviders(t *testing.T) {
reg := NewRegistry()
// Valid: active, auth-capable, provider names a declared webhook.
reg.Add(ssoRecord("google", StatusActive, true, []string{"initiate", "callback"},
[]manifest.AuthProvider{{ID: "google", DisplayName: "Google", Initiate: "initiate"}}))
// Skipped: provider names an undeclared webhook (button would 404).
reg.Add(ssoRecord("broken", StatusActive, true, nil,
[]manifest.AuthProvider{{ID: "x", DisplayName: "X", Initiate: "missing"}}))
// Skipped: declares providers but lacks the auth capability.
reg.Add(ssoRecord("nocap", StatusActive, false, []string{"initiate"},
[]manifest.AuthProvider{{ID: "y", DisplayName: "Y", Initiate: "initiate"}}))
// Skipped: inactive plugin.
reg.Add(ssoRecord("inactive", store.StatusDisabled, true, []string{"initiate"},
[]manifest.AuthProvider{{ID: "z", DisplayName: "Z", Initiate: "initiate"}}))
svc := &Service{registry: reg}
got := svc.SSOProviders()
if len(got) != 1 {
t.Fatalf("SSOProviders() = %d providers, want 1: %+v", len(got), got)
}
p := got[0]
if p.ID != "google" || p.DisplayName != "Google" ||
p.InitiateURL != "/api/plugins/google/webhooks/initiate" {
t.Fatalf("unexpected provider: %+v", p)
}
}
@@ -147,6 +147,11 @@ func (r *sqliteRepository) ListUsers(ctx context.Context) ([]*models.User, error
return users, rows.Err()
}
func (r *sqliteRepository) DeleteUser(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM users WHERE id = ?`), id)
return err
}
func (r *sqliteRepository) CreateUser(ctx context.Context, user *models.User) error {
now := time.Now().UTC()
user.CreatedAt = now
@@ -26,6 +26,11 @@ type AccountRepository interface {
CreateUser(ctx context.Context, user *models.User) error
UpdateUserProfile(ctx context.Context, id, email, displayName, role string) (*models.User, error)
UpdateUserRoleStatus(ctx context.Context, id, role, status string) (*models.User, error)
// DeleteUser removes a user row by id. Used to roll back a just-created
// account when a follow-up step (e.g. linking its login identity) fails, so
// no account is left without a usable login. Deleting a missing id is not an
// error.
DeleteUser(ctx context.Context, id string) error
}
var _ AccountRepository = (*sqliteRepository)(nil)
+31
View File
@@ -7,12 +7,42 @@ import { Input } from "@kandev/ui/input";
import { IconLock } from "@tabler/icons-react";
import { ApiError } from "@/lib/api/client";
import { login } from "@/lib/api/domains/auth-api";
import { useAppStore } from "@/components/state-provider";
import type { SsoProvider } from "@/lib/state/slices/auth/types";
// LoginSsoButtons renders one "Continue with <provider>" button per
// plugin-contributed SSO provider, below a divider. Each button is a plain
// navigation to the plugin's login-initiate webhook.
function LoginSsoButtons({ providers }: { providers: SsoProvider[] }) {
if (providers.length === 0) return null;
return (
<div className="mt-4 flex flex-col gap-2" data-testid="login-sso">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
or continue with
<span className="h-px flex-1 bg-border" />
</div>
{providers.map((provider) => (
<Button
key={provider.id}
asChild
variant="outline"
className="cursor-pointer"
data-testid={`login-sso-${provider.id}`}
>
<a href={provider.initiateUrl}>Continue with {provider.displayName}</a>
</Button>
))}
</div>
);
}
export function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const ssoProviders = useAppStore((s) => s.auth.ssoProviders) ?? [];
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -90,6 +120,7 @@ export function LoginPage() {
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
<LoginSsoButtons providers={ssoProviders} />
</CardContent>
</Card>
</div>
@@ -0,0 +1,94 @@
import { expect } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { setupAdmin } from "../../helpers/auth";
/**
* Captures the pre-auth login page with a plugin-contributed "Continue with
* Google" SSO button into e2e/.auth-screenshots/. Fully real end-to-end: it
* installs the actual kandev-plugin-google-oidc package, so the button comes
* from the real boot payload (auth.ssoProviders, populated by the backend's
* plugins.SSOProviders() from the installed plugin's manifest).
*
* Runs in the `auth` project (backend restarted with auth required). Plugins
* are already enabled in the e2e profile.
*/
const SHOT_DIR = path.join(__dirname, "..", "..", ".auth-screenshots");
const PLUGIN_PKG = path.join(
process.env.HOME ?? "",
"kandev-plugins/kandev-plugin-google-oidc/kandev-plugin-google-oidc-0.1.0.tar.gz",
);
const PLUGIN_ID = "kandev-plugin-google-oidc";
test.describe.serial("sso login screenshot", () => {
const ADMIN = { email: "admin@demo.dev", password: "adminpass123", displayName: "Ada Admin" };
test.beforeAll(async ({ backend }) => {
fs.mkdirSync(SHOT_DIR, { recursive: true });
await backend.restart({ KANDEV_FEATURES_AUTH: "true" });
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("login page shows the Google SSO button", async ({ browser, backend }) => {
// Local capture only: needs the out-of-tree plugin package built via
// `make package-host` in kandev-plugin-google-oidc. Skip where it's absent
// (e.g. CI) rather than fail.
test.skip(!fs.existsSync(PLUGIN_PKG), `plugin package not found at ${PLUGIN_PKG}`);
// 1. Become admin (setup mode -> enabled). Install requires an admin.
const adminCtx = await browser.newContext({ baseURL: backend.frontendUrl });
await setupAdmin(adminCtx, backend.baseUrl, ADMIN);
// 2. Install the real Google OIDC plugin package as admin.
const pkg = fs.readFileSync(PLUGIN_PKG);
const install = await adminCtx.request.post(`${backend.baseUrl}/api/plugins/install`, {
multipart: {
package: { name: path.basename(PLUGIN_PKG), mimeType: "application/gzip", buffer: pkg },
},
});
expect(
install.ok(),
`install failed: ${install.status()} ${await install.text()}`,
).toBeTruthy();
// 3. Wait for the plugin subprocess to spawn and go active.
await expect
.poll(
async () => {
const res = await adminCtx.request.get(`${backend.baseUrl}/api/plugins/${PLUGIN_ID}`);
if (!res.ok()) return "missing";
return ((await res.json()) as { status?: string }).status ?? "unknown";
},
{ timeout: 20_000, intervals: [500] },
)
.toBe("active");
// 4. The anonymous boot payload now advertises the provider.
const stateRes = await adminCtx.request.get(`${backend.baseUrl}/api/v1/app-state?path=/`);
const boot = (await stateRes.json()) as {
initialState?: { auth?: { ssoProviders?: Array<{ id: string; displayName: string }> } };
};
const providers = boot.initialState?.auth?.ssoProviders ?? [];
expect(providers.map((p) => p.id)).toContain("google");
await adminCtx.close();
// 5. Fresh anonymous browser -> the login page renders the SSO button.
const ctx = await browser.newContext({
baseURL: backend.frontendUrl,
viewport: { width: 1280, height: 900 },
});
const page = await ctx.newPage();
await page.goto("/");
await expect(page.getByTestId("login-email")).toBeVisible({ timeout: 15_000 });
const ssoButton = page.getByTestId("login-sso-google");
await expect(ssoButton).toBeVisible();
await expect(ssoButton).toHaveText(/Continue with Google/);
await page.screenshot({ path: path.join(SHOT_DIR, "13-sso-login.png"), fullPage: true });
await ctx.close();
});
});
@@ -10,6 +10,7 @@ export const defaultAuthState: AuthSliceState = {
mode: "disabled",
authenticated: true,
user: null,
ssoProviders: [],
},
};
+12
View File
@@ -16,11 +16,23 @@ export type AuthUser = {
export type AuthMode = "disabled" | "setup" | "enabled";
// SsoProvider is one external-login button on the pre-auth login screen,
// contributed by an auth-capable plugin. Mirrors apps/backend/internal/auth
// state.go SSOProvider. Present only in the anonymous boot payload.
export type SsoProvider = {
id: string;
displayName: string;
initiateUrl: string;
};
export type AuthSliceState = {
auth: {
mode: AuthMode;
authenticated: boolean;
user: AuthUser | null;
// External-login options for the login screen. Optional: GET
// /api/v1/auth/me omits it, only the anonymous boot payload carries it.
ssoProviders?: SsoProvider[];
};
};
@@ -0,0 +1,100 @@
# 0050 — Plugins provide OIDC/SAML login via a capability-gated, host-minted session
- Status: accepted
- Date: 2026-07-26
- Area: backend, security, protocol
- Related: [0043 — Plugin host data API](0043-plugin-host-data-api.md),
[Opt-in authentication](2026-07-24-opt-in-authentication.md),
[docs/specs/auth/spec.md](../specs/auth/spec.md),
[docs/specs/plugins/spec.md](../specs/plugins/spec.md)
## 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.
+1
View File
@@ -57,6 +57,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0047 | [Plugins read conversation content via a capability-gated Host RPC](0047-plugin-host-conversation-reads.md) | accepted | backend, protocol | 2026-07-21 |
| 0048 | [Plugins invoke a settings-selectable utility agent](0048-plugin-host-utility-agent-invoke.md) | accepted | backend, frontend, protocol | 2026-07-21 |
| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | accepted (amended 2026-07-26) | backend, frontend, protocol | 2026-07-11 |
| 0050 | [Plugins provide OIDC/SAML login via a capability-gated, host-minted session](0050-plugin-external-auth-capability.md) | accepted | backend, security, protocol | 2026-07-26 |
| 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 |
| 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | accepted | workflow | 2026-07-23 |
| 2026-07-23-post-commit-hook-aware-verification | [Post-Commit Hook-Aware Verification](2026-07-23-post-commit-hook-aware-verification.md) | accepted | workflow | 2026-07-23 |
+16
View File
@@ -253,6 +253,22 @@ its resource in `capabilities.api_read` (e.g. `tasks`, `sessions`, `messages`,
Calling one without the declared capability returns gRPC `PermissionDenied`
with a message naming the missing capability — declare what you use.
**External login (`capabilities.auth`).** An auth-capable plugin can log a
visitor in against an external IdP (OIDC/SAML). Handle the IdP callback / SAML
ACS in a `webhook`, validate the token yourself, then set the reserved
`X-Kandev-Auth-Login` response header to a JSON object
`{"provider","subject","email","display_name"}`. Kandev maps it to a user
(link-by-email or just-in-time member provisioning), mints the session, and sets
the `kandev_session` cookie itself — your plugin never handles the raw token,
and any `Set-Cookie` you return is dropped. Requires authentication enabled;
emitting the header without `capabilities.auth` returns 403.
**You MUST only assert an `email` the IdP has verified as owned by `subject`.**
Kandev auto-links that email to (or provisions) an account, so an unverified or
user-settable email claim is an account-takeover vector. Kandev refuses to
auto-link to an admin account as defense-in-depth, but it cannot tell a verified
email from an unverified one — that is on your plugin. See ADR 0050.
**Writable data directory.** Kandev injects `KANDEV_PLUGIN_DATA_DIR` into
every spawned plugin subprocess — a per-plugin writable directory
(`~/.kandev/plugins/<id>/data`) for anything you'd rather keep on disk than
+8
View File
@@ -43,12 +43,18 @@ capabilities:
state: true
secrets: true
agent_invoke: true # gates Host.InvokeUtilityAgent
auth: true # gates external (OIDC/SAML) login — see ADR 0050
webhooks:
- key: "slack-events"
description: "Slack Events API webhook"
method: "POST" # informational only, not enforced
auth_providers: # login buttons (needs capabilities.auth)
- id: "google"
display_name: "Google"
initiate: "login-start" # names a webhook key above; the button navigates there
config_schema:
type: object
properties:
@@ -89,9 +95,11 @@ ui: # optional native frontend plugin
| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid enabled selection returns gRPC `FailedPrecondition`. See ADR 0048. |
| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. |
| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. |
| `webhooks[].description` | no | string | Free-form. |
| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. |
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
+7 -1
View File
@@ -93,7 +93,13 @@ the local single-user install with a login screen it never asked for.
kandev instances per user or use per-user OS accounts / sandboxed executors
(future work).
- No workspace sharing/membership — one owner per workspace.
- No OIDC/SSO yet (schema is ready).
- No first-party OIDC/SSO. The host supports **plugin-provided** external login
(an `auth`-capable plugin asserts a validated OIDC/SAML identity and the host
mints the session — see ADR 0050); the pre-auth login-page SSO buttons and
anonymous provider discovery (`auth.ssoProviders` in the boot payload) ship
here too. What remains is a published IdP plugin (e.g. Google OIDC, developed
in its own repo) and setup-mode first-admin bootstrap via SSO (SSO requires
enforced auth today).
- Office workspace-scoped HTTP routes (those carrying a `:wsId`) are
ownership-checked when auth is enabled; office run *subscriptions* are not
yet ownership-checked (run events carry no workspace context at the
+15
View File
@@ -109,6 +109,8 @@ capabilities:
api_write: ["tasks", "comments"] # Host data API writes; deferred, no effect yet
state: true
secrets: true
auth: true # establish a login session for an
# external (OIDC/SAML) identity — see ADR 0050
webhooks:
- key: "slack-events"
@@ -152,6 +154,19 @@ returns gRPC `PermissionDenied` with message `capability 'api_read:<resource>'
not declared`. `api_write` entries are accepted and stored but currently have no
effect (see "Write phase (deferred)").
**External login (`auth`).** An `auth`-capable plugin can log a visitor in
against an external IdP (OIDC/SAML): its webhook (the callback / SAML ACS)
validates the token, then asserts the identity to kandev via the reserved
`X-Kandev-Auth-Login` response header (`{provider, subject, email,
display_name}`). The host maps it to a user (link-by-email or just-in-time
member provisioning), mints the session, and sets the `kandev_session` cookie
itself — the plugin never receives the raw token. Requires auth enabled; new
users are members, and the host never creates an admin nor auto-links to an
existing admin account. The plugin **must** only assert IdP-verified emails (an
unverified email claim is an account-takeover vector the host cannot detect).
This is the highest-privilege capability; grant it only to trusted plugins.
See [ADR 0050](../../decisions/0050-plugin-external-auth-capability.md).
### Install pipeline
There is no manifest-paste registration step. A plugin is installed from a URL or an