b20047b3eb
* 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>
61 lines
2.0 KiB
Go
61 lines
2.0 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
usermodels "github.com/kandev/kandev/internal/user/models"
|
|
)
|
|
|
|
// StatePayload is the auth snapshot shared by GET /api/v1/auth/me and the
|
|
// boot payload's initialState["auth"]. The frontend auth store slice hydrates
|
|
// from exactly this shape.
|
|
type StatePayload struct {
|
|
// Mode is "disabled" | "setup" | "enabled".
|
|
Mode string `json:"mode"`
|
|
// Authenticated is true when the request carried a valid session or PAT.
|
|
// 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 =
|
|
// unauthenticated visitor).
|
|
func (s *Service) StateFor(ctx context.Context, identity *authn.Identity) StatePayload {
|
|
payload := StatePayload{
|
|
Mode: string(s.Mode()),
|
|
}
|
|
if identity == nil {
|
|
return payload
|
|
}
|
|
payload.Authenticated = true
|
|
if identity.Synthetic {
|
|
// Disabled mode: report the implicit single user without exposing the
|
|
// placeholder email through the payload.
|
|
payload.User = &usermodels.User{
|
|
ID: identity.UserID,
|
|
Role: usermodels.RoleAdmin,
|
|
Status: usermodels.StatusActive,
|
|
}
|
|
return payload
|
|
}
|
|
if user, err := s.users.GetUser(ctx, identity.UserID); err == nil {
|
|
payload.User = user
|
|
}
|
|
return payload
|
|
}
|