kereru-forms/backend/internal/auth/auth.go

328 lines
8.9 KiB
Go
Raw Normal View History

2026-10-23 19:36:02 +00:00
// 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 ""
}