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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user