diff --git a/apps/backend/internal/auth/httpapi/cookie.go b/apps/backend/internal/auth/httpapi/cookie.go index 03ae257dae..c4f355de82 100644 --- a/apps/backend/internal/auth/httpapi/cookie.go +++ b/apps/backend/internal/auth/httpapi/cookie.go @@ -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) diff --git a/apps/backend/internal/auth/service_sso.go b/apps/backend/internal/auth/service_sso.go new file mode 100644 index 0000000000..3c98639e46 --- /dev/null +++ b/apps/backend/internal/auth/service_sso.go @@ -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 +} diff --git a/apps/backend/internal/auth/service_sso_test.go b/apps/backend/internal/auth/service_sso_test.go new file mode 100644 index 0000000000..1b249f7511 --- /dev/null +++ b/apps/backend/internal/auth/service_sso_test.go @@ -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") + } +} diff --git a/apps/backend/internal/auth/state.go b/apps/backend/internal/auth/state.go index 505535b2ee..9774018a4d 100644 --- a/apps/backend/internal/auth/state.go +++ b/apps/backend/internal/auth/state.go @@ -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 = diff --git a/apps/backend/internal/auth/store/store.go b/apps/backend/internal/auth/store/store.go index 3a5dc0e1ad..bb7f191fef 100644 --- a/apps/backend/internal/auth/store/store.go +++ b/apps/backend/internal/auth/store/store.go @@ -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(` diff --git a/apps/backend/internal/backendapp/auth.go b/apps/backend/internal/backendapp/auth.go index a1307a24a1..9208002b45 100644 --- a/apps/backend/internal/backendapp/auth.go +++ b/apps/backend/internal/backendapp/auth.go @@ -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 diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 2683af7fd4..f6d1737881 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -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 } diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 59beee5966..b464aec24a 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -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)") } diff --git a/apps/backend/internal/plugins/auth_login.go b/apps/backend/internal/plugins/auth_login.go new file mode 100644 index 0000000000..0574de2fdf --- /dev/null +++ b/apps/backend/internal/plugins/auth_login.go @@ -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 + } +} diff --git a/apps/backend/internal/plugins/auth_login_test.go b/apps/backend/internal/plugins/auth_login_test.go new file mode 100644 index 0000000000..cb59abd4e3 --- /dev/null +++ b/apps/backend/internal/plugins/auth_login_test.go @@ -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()) + } +} diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 70f915346f..c7c5a2940b 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -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 { diff --git a/apps/backend/internal/plugins/handlers_test.go b/apps/backend/internal/plugins/handlers_test.go index cdd49cb2de..0d61c13eed 100644 --- a/apps/backend/internal/plugins/handlers_test.go +++ b/apps/backend/internal/plugins/handlers_test.go @@ -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}`), diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 628f5d9c30..c53b85642d 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -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. diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 5f6d796e5d..5e8a58efcf 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -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 diff --git a/apps/backend/internal/plugins/sso.go b/apps/backend/internal/plugins/sso.go new file mode 100644 index 0000000000..e2c990ad3f --- /dev/null +++ b/apps/backend/internal/plugins/sso.go @@ -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 +} diff --git a/apps/backend/internal/plugins/sso_test.go b/apps/backend/internal/plugins/sso_test.go new file mode 100644 index 0000000000..b9f1ca0279 --- /dev/null +++ b/apps/backend/internal/plugins/sso_test.go @@ -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) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 119fb711a9..dda42c2d2d 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 diff --git a/apps/backend/internal/user/store/store.go b/apps/backend/internal/user/store/store.go index af6162f0b6..de6baab7af 100644 --- a/apps/backend/internal/user/store/store.go +++ b/apps/backend/internal/user/store/store.go @@ -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) diff --git a/apps/web/app/auth/login-page.tsx b/apps/web/app/auth/login-page.tsx index 7b4d4f97eb..e8b0ccd4ed 100644 --- a/apps/web/app/auth/login-page.tsx +++ b/apps/web/app/auth/login-page.tsx @@ -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 " 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 ( +
+
+ + or continue with + +
+ {providers.map((provider) => ( + + ))} +
+ ); +} export function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(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"} + diff --git a/apps/web/e2e/tests/auth/sso-login-screenshot.spec.ts b/apps/web/e2e/tests/auth/sso-login-screenshot.spec.ts new file mode 100644 index 0000000000..ec8f5f49f3 --- /dev/null +++ b/apps/web/e2e/tests/auth/sso-login-screenshot.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/lib/state/slices/auth/auth-slice.ts b/apps/web/lib/state/slices/auth/auth-slice.ts index 2aabc61535..51d75373f6 100644 --- a/apps/web/lib/state/slices/auth/auth-slice.ts +++ b/apps/web/lib/state/slices/auth/auth-slice.ts @@ -10,6 +10,7 @@ export const defaultAuthState: AuthSliceState = { mode: "disabled", authenticated: true, user: null, + ssoProviders: [], }, }; diff --git a/apps/web/lib/state/slices/auth/types.ts b/apps/web/lib/state/slices/auth/types.ts index 5ebd46ffa7..abe92d547d 100644 --- a/apps/web/lib/state/slices/auth/types.ts +++ b/apps/web/lib/state/slices/auth/types.ts @@ -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[]; }; }; diff --git a/docs/decisions/0050-plugin-external-auth-capability.md b/docs/decisions/0050-plugin-external-auth-capability.md new file mode 100644 index 0000000000..9d76d5a19b --- /dev/null +++ b/docs/decisions/0050-plugin-external-auth-capability.md @@ -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. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index fadb8a4494..90bdb4d3bf 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -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 | diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 7d019af3ec..f8c766738b 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -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//data`) for anything you'd rather keep on disk than diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index 998b227d0e..a182b91e3a 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -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 > ``** (`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 `` tags on load. | diff --git a/docs/specs/auth/spec.md b/docs/specs/auth/spec.md index 9c26af4d18..c67a33cc4e 100644 --- a/docs/specs/auth/spec.md +++ b/docs/specs/auth/spec.md @@ -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 diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 440d88f5cd..360283e367 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -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:' 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