// Package auth implements password fallback and OAuth/OIDC bearer-token // authentication for Kereru Forms. package auth import ( "context" "crypto/rand" "crypto/subtle" "errors" "fmt" "strings" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "kereru.forms/internal/config" ) const localIssuer = "kereru-forms" var ( // ErrInvalidCredentials is returned when a local password is incorrect. ErrInvalidCredentials = errors.New("invalid credentials") // ErrInvalidToken is returned when a bearer token cannot be verified. ErrInvalidToken = errors.New("invalid or expired token") // ErrLocalLoginDisabled is returned when password login is used with OIDC. ErrLocalLoginDisabled = errors.New("local password login is disabled") ) // User is the authenticated principal exposed to the API and frontend. type User struct { ID string `json:"id"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` Roles []string `json:"roles"` CanAdmin bool `json:"canAdmin"` CanView bool `json:"canView"` } // OIDCClientConfig is the browser-safe subset of OIDC configuration. type OIDCClientConfig struct { IssuerURL string `json:"issuerUrl"` AuthorizationEndpoint string `json:"authorizationEndpoint"` TokenEndpoint string `json:"tokenEndpoint"` ClientID string `json:"clientId"` Scopes []string `json:"scopes"` } // PublicConfig describes how the frontend should authenticate. type PublicConfig struct { Mode string `json:"mode"` RequireSubmissionAuth bool `json:"requireSubmissionAuth"` OIDC *OIDCClientConfig `json:"oidc,omitempty"` } // Service verifies bearer tokens and issues local password-mode tokens. type Service struct { cfg config.AuthConfig localSecret []byte localTTL time.Duration oidcProvider *oidc.Provider oidcVerifier *oidc.IDTokenVerifier } // New builds an authentication service. When an OIDC issuer is configured, // provider discovery is performed immediately so configuration errors fail // fast at startup. func New(ctx context.Context, cfg config.AuthConfig) (*Service, error) { service := &Service{ cfg: cfg, localTTL: time.Duration(cfg.LocalTokenTTLHours) * time.Hour, } if secret := strings.TrimSpace(cfg.LocalTokenSecret); secret != "" { service.localSecret = []byte(secret) } else { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("generate local token secret: %w", err) } service.localSecret = secret } if cfg.OIDCEnabled() { discoveryCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() provider, err := oidc.NewProvider(discoveryCtx, strings.TrimSpace(cfg.OIDC.IssuerURL)) if err != nil { return nil, fmt.Errorf("discover OIDC provider: %w", err) } service.oidcProvider = provider service.oidcVerifier = provider.Verifier(&oidc.Config{ ClientID: strings.TrimSpace(cfg.OIDC.ClientID), }) } return service, nil } // PublicConfig returns browser-safe authentication configuration. func (s *Service) PublicConfig() PublicConfig { out := PublicConfig{ Mode: "password", RequireSubmissionAuth: s.cfg.RequireSubmissionAuth, } if s.oidcProvider != nil { endpoint := s.oidcProvider.Endpoint() out.Mode = "oidc" out.OIDC = &OIDCClientConfig{ IssuerURL: s.cfg.OIDC.IssuerURL, AuthorizationEndpoint: endpoint.AuthURL, TokenEndpoint: endpoint.TokenURL, ClientID: s.cfg.OIDC.ClientID, Scopes: append([]string(nil), s.cfg.OIDC.Scopes...), } } return out } // LocalLoginEnabled reports whether password fallback is active. func (s *Service) LocalLoginEnabled() bool { return s.oidcProvider == nil } // RequiresSubmissionAuth reports whether filling in forms requires login. func (s *Service) RequiresSubmissionAuth() bool { return s.cfg.RequireSubmissionAuth } // Login validates the local password and returns a signed JWT and user. func (s *Service) Login(password string) (string, User, error) { if s.oidcProvider != nil { return "", User{}, ErrLocalLoginDisabled } if !passwordMatches(s.cfg.LocalPassword, password) { return "", User{}, ErrInvalidCredentials } roles := s.localRoles() user := s.user("local", "Local administrator", "", roles) token, err := s.issueLocalToken(user) if err != nil { return "", User{}, err } return token, user, nil } // Authenticate verifies a bearer token and returns its roles. func (s *Service) Authenticate(ctx context.Context, rawToken string) (User, error) { rawToken = strings.TrimSpace(rawToken) if rawToken == "" { return User{}, ErrInvalidToken } if s.oidcVerifier != nil { return s.authenticateOIDC(ctx, rawToken) } return s.authenticateLocal(rawToken) } func (s *Service) authenticateLocal(rawToken string) (User, error) { parsed, err := jwt.Parse(rawToken, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method %q", token.Method.Alg()) } return s.localSecret, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(localIssuer), jwt.WithExpirationRequired(), ) if err != nil || !parsed.Valid { return User{}, ErrInvalidToken } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return User{}, ErrInvalidToken } sub, _ := claims["sub"].(string) if strings.TrimSpace(sub) == "" { return User{}, ErrInvalidToken } name, _ := claims["name"].(string) email, _ := claims["email"].(string) roles := claimStrings(claims["roles"]) return s.user(sub, name, email, roles), nil } func (s *Service) authenticateOIDC(ctx context.Context, rawToken string) (User, error) { idToken, err := s.oidcVerifier.Verify(ctx, rawToken) if err != nil { return User{}, ErrInvalidToken } var claims map[string]any if err := idToken.Claims(&claims); err != nil { return User{}, ErrInvalidToken } sub := strings.TrimSpace(idToken.Subject) if sub == "" { sub, _ = claims["sub"].(string) } if strings.TrimSpace(sub) == "" { return User{}, ErrInvalidToken } name := firstString(claims, "name", "preferred_username") email, _ := claims["email"].(string) roles := claimValues(claims, s.cfg.OIDC.RoleClaim) return s.user(sub, name, email, roles), nil } func (s *Service) user(id, name, email string, roles []string) User { roles = normalizeRoles(roles) return User{ ID: id, Name: name, Email: email, Roles: roles, CanAdmin: containsRole(roles, s.cfg.OIDC.AdminRole), CanView: containsRole(roles, s.cfg.OIDC.ViewRole), } } func (s *Service) localRoles() []string { return normalizeRoles([]string{s.cfg.OIDC.AdminRole, s.cfg.OIDC.ViewRole}) } func (s *Service) issueLocalToken(user User) (string, error) { now := time.Now() claims := jwt.MapClaims{ "iss": localIssuer, "sub": user.ID, "name": user.Name, "roles": user.Roles, "iat": now.Unix(), "exp": now.Add(s.localTTL).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(s.localSecret) if err != nil { return "", fmt.Errorf("sign local token: %w", err) } return signed, nil } func passwordMatches(configured, supplied string) bool { if strings.HasPrefix(configured, "$2") { return bcrypt.CompareHashAndPassword([]byte(configured), []byte(supplied)) == nil } return subtle.ConstantTimeCompare([]byte(configured), []byte(supplied)) == 1 } func claimValues(claims map[string]any, path string) []string { var current any = claims for _, segment := range strings.Split(path, ".") { segment = strings.TrimSpace(segment) if segment == "" { return nil } next, ok := current.(map[string]any) if !ok { return nil } current, ok = next[segment] if !ok { return nil } } return claimStrings(current) } func claimStrings(value any) []string { switch typed := value.(type) { case string: return normalizeRoles([]string{typed}) case []string: return normalizeRoles(typed) case []any: roles := make([]string, 0, len(typed)) for _, item := range typed { if role, ok := item.(string); ok { roles = append(roles, role) } } return normalizeRoles(roles) default: return nil } } func normalizeRoles(roles []string) []string { seen := make(map[string]struct{}, len(roles)) out := make([]string, 0, len(roles)) for _, role := range roles { role = strings.TrimSpace(role) if role == "" { continue } if _, exists := seen[role]; exists { continue } seen[role] = struct{}{} out = append(out, role) } return out } func containsRole(roles []string, wanted string) bool { wanted = strings.TrimSpace(wanted) for _, role := range roles { if role == wanted { return true } } return false } func firstString(claims map[string]any, keys ...string) string { for _, key := range keys { if value, ok := claims[key].(string); ok && strings.TrimSpace(value) != "" { return value } } return "" }