328 lines
8.9 KiB
Go
328 lines
8.9 KiB
Go
|
|
// 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 ""
|
||
|
|
}
|