Files
kandev/apps/backend/internal/backendapp/auth.go
T
João Salavisa b20047b3eb feat(plugins): add auth capability for plugin-provided OIDC/SAML login (#1964)
* feat(plugins): add auth capability for plugin-provided OIDC/SAML login

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

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

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

* docs: exclude publish_review_findings_kandev from public-docs coverage

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

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

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

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

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

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

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

---------

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

140 lines
5.0 KiB
Go

package backendapp
import (
"context"
"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"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
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
// from the user store, and the setup-wizard ownership backfills from task +
// secrets. dbPool is retained for signature symmetry with the other providers.
func provideAuthService(
ctx context.Context,
cfg *config.Config,
_ *db.Pool,
repos *Repositories,
log *logger.Logger,
) (*auth.Service, error) {
backfills := []auth.BackfillFunc{
// Pre-auth workspaces (owner_id='') become the admin's at setup.
repos.Task.ClaimUnownedWorkspaces,
}
// Pre-auth secrets (user_id='') are claimed the same way. Interface
// assertion keeps SecretStore mocks free of the method.
if claimer, ok := repos.Secrets.(interface {
ClaimUnowned(context.Context, string) error
}); ok {
backfills = append(backfills, claimer.ClaimUnowned)
}
return auth.NewService(ctx, auth.Deps{
Cfg: cfg,
Store: repos.Auth,
Users: repos.UserAccounts,
Backfills: backfills,
Log: log,
})
}
// gatewayAuthPolicy assembles the WS gateway scoping hooks from the auth and
// task services. The workspace-owner resolver caches lookups briefly: it runs
// on every workspace-scoped broadcast.
func gatewayAuthPolicy(authSvc *auth.Service, taskSvc *taskservice.Service, taskRepo *sqliterepo.Repository) gateways.AuthPolicy {
return gateways.AuthPolicy{
Enforced: func() bool { return authSvc.Mode() != auth.ModeDisabled },
ResolveToken: authSvc.ResolveBearer,
Subscriptions: gateways.SubscriptionAccessPolicy{
Task: taskSvc.AuthorizeTaskAccess,
Session: taskSvc.AuthorizeSessionAccess,
},
WorkspaceOwner: newWorkspaceOwnerResolver(taskRepo),
// The user-shell actions name a task environment and treat task_id as
// optional, so the dispatch backstop needs an environment-keyed check.
ActionEnvironment: taskSvc.AuthorizeEnvironmentAccess,
}
}
// ownerCacheTTL bounds staleness of broadcast routing after an ownership
// change (only the setup wizard's claim changes owners in practice).
const ownerCacheTTL = 30 * time.Second
func newWorkspaceOwnerResolver(taskRepo *sqliterepo.Repository) gateways.WorkspaceOwnerResolver {
type cacheEntry struct {
owner string
cachedAt time.Time
}
var mu sync.Mutex
cache := map[string]cacheEntry{}
return func(ctx context.Context, workspaceID string) (string, error) {
mu.Lock()
if entry, ok := cache[workspaceID]; ok && time.Since(entry.cachedAt) < ownerCacheTTL {
mu.Unlock()
return entry.owner, nil
}
mu.Unlock()
workspace, err := taskRepo.GetWorkspace(ctx, workspaceID)
if err != nil {
return "", err
}
mu.Lock()
cache[workspaceID] = cacheEntry{owner: workspace.OwnerID, cachedAt: time.Now()}
mu.Unlock()
return workspace.OwnerID, nil
}
}
// warnIfExposedWithoutAuth surfaces the fail-closed nudge promised by
// ServerConfig.NonLoopbackBinds: a server reachable off-box without
// authentication gets a prominent startup warning.
func warnIfExposedWithoutAuth(cfg *config.Config, svc *auth.Service, log *logger.Logger) {
if svc == nil || svc.Mode() != auth.ModeDisabled {
return
}
binds, err := cfg.Server.NonLoopbackBinds()
if err != nil || len(binds) == 0 {
return
}
log.Warn("server is reachable on non-loopback interfaces WITHOUT authentication; "+
"enable the Authentication feature toggle (Settings > System > Feature Toggles) "+
"or set KANDEV_FEATURES_AUTH=true",
zap.Strings("binds", binds))
}