- Create internal/config package with unified config structs and validation - Abstract viper dependency behind config.Loader interface for better testability - Replace manual config parsing and type assertions with type-safe loading - Consolidate AuthConfig, SiteConfig, and DiscoveryConfig into single package - Add comprehensive validation with clear error messages - Remove ~200 lines of duplicate config handling code - Maintain backward compatibility with existing config files
465 lines
13 KiB
Go
465 lines
13 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/insertr/insertr/internal/config"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// UserInfo represents authenticated user information
|
|
type UserInfo struct {
|
|
ID string `json:"sub"`
|
|
Email string `json:"email,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Provider string `json:"iss,omitempty"`
|
|
}
|
|
|
|
// Type aliases for backward compatibility
|
|
type AuthConfig = config.AuthConfig
|
|
type OAuthConfig = config.OAuthConfig
|
|
type OIDCConfig = config.OIDCConfig
|
|
|
|
// AuthService handles authentication operations
|
|
type AuthService struct {
|
|
config *AuthConfig
|
|
provider *oidc.Provider
|
|
oauth2 *oauth2.Config
|
|
}
|
|
|
|
// NewAuthService creates a new authentication service
|
|
func NewAuthService(config *AuthConfig) (*AuthService, error) {
|
|
service := &AuthService{config: config}
|
|
|
|
// Initialize OIDC provider if configured
|
|
if config.Provider == "authentik" && config.OIDC != nil {
|
|
if err := service.initOIDC(); err != nil {
|
|
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
|
|
}
|
|
}
|
|
|
|
return service, nil
|
|
}
|
|
|
|
// initOIDC initializes the OIDC provider and OAuth2 config
|
|
func (a *AuthService) initOIDC() error {
|
|
ctx := context.Background()
|
|
|
|
// Create OIDC provider
|
|
provider, err := oidc.NewProvider(ctx, a.config.OIDC.Endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create OIDC provider: %w", err)
|
|
}
|
|
a.provider = provider
|
|
|
|
// Default scopes if none specified
|
|
scopes := a.config.OIDC.Scopes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
|
|
}
|
|
|
|
// Create OAuth2 config
|
|
a.oauth2 = &oauth2.Config{
|
|
ClientID: a.config.OIDC.ClientID,
|
|
ClientSecret: a.config.OIDC.ClientSecret,
|
|
RedirectURL: a.config.OIDC.RedirectURL,
|
|
Endpoint: provider.Endpoint(),
|
|
Scopes: scopes,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExtractUserFromRequest extracts user information from HTTP request
|
|
func (a *AuthService) ExtractUserFromRequest(r *http.Request) (*UserInfo, error) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return &UserInfo{ID: "anonymous"}, nil
|
|
}
|
|
|
|
// Parse Bearer token
|
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
|
return nil, fmt.Errorf("invalid authorization header format")
|
|
}
|
|
|
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
|
|
// Handle mock tokens in development mode
|
|
if a.config.DevMode && strings.HasPrefix(token, "mock-") {
|
|
return a.parseMockToken(token), nil
|
|
}
|
|
|
|
// Parse real JWT token
|
|
return a.parseJWT(token)
|
|
}
|
|
|
|
// parseMockToken handles mock development tokens
|
|
func (a *AuthService) parseMockToken(token string) *UserInfo {
|
|
// Mock token format: mock-{user}-{timestamp}-{random}
|
|
parts := strings.Split(token, "-")
|
|
if len(parts) >= 2 {
|
|
return &UserInfo{
|
|
ID: parts[1], // user part
|
|
Email: fmt.Sprintf("%s@localhost", parts[1]),
|
|
Name: strings.ToTitle(parts[1][:1]) + parts[1][1:],
|
|
Provider: "insertr-dev",
|
|
}
|
|
}
|
|
|
|
return &UserInfo{ID: "anonymous"}
|
|
}
|
|
|
|
// parseJWT parses and validates a real JWT token
|
|
func (a *AuthService) parseJWT(tokenString string) (*UserInfo, error) {
|
|
// Use OIDC verification if available
|
|
if a.config.Provider == "authentik" && a.provider != nil {
|
|
return a.parseOIDCToken(tokenString)
|
|
}
|
|
|
|
// Fallback to HMAC verification for other tokens
|
|
return a.parseHMACToken(tokenString)
|
|
}
|
|
|
|
// parseOIDCToken parses and validates an OIDC token using the provider's keys
|
|
func (a *AuthService) parseOIDCToken(tokenString string) (*UserInfo, error) {
|
|
ctx := context.Background()
|
|
|
|
// Create verifier
|
|
verifier := a.provider.Verifier(&oidc.Config{ClientID: a.config.OIDC.ClientID})
|
|
|
|
// Verify the token
|
|
idToken, err := verifier.Verify(ctx, tokenString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to verify OIDC token: %w", err)
|
|
}
|
|
|
|
// Extract claims
|
|
var claims struct {
|
|
Sub string `json:"sub"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
Name string `json:"name"`
|
|
PreferredName string `json:"preferred_username"`
|
|
Groups []string `json:"groups"`
|
|
}
|
|
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
return nil, fmt.Errorf("failed to extract claims: %w", err)
|
|
}
|
|
|
|
// Build user info
|
|
userInfo := &UserInfo{
|
|
ID: claims.Sub,
|
|
Email: claims.Email,
|
|
Name: claims.Name,
|
|
Provider: idToken.Issuer,
|
|
}
|
|
|
|
// Use preferred_username if name is empty
|
|
if userInfo.Name == "" {
|
|
userInfo.Name = claims.PreferredName
|
|
}
|
|
|
|
return userInfo, nil
|
|
}
|
|
|
|
// parseHMACToken parses and validates a JWT token using HMAC signing
|
|
func (a *AuthService) parseHMACToken(tokenString string) (*UserInfo, error) {
|
|
// Parse the token
|
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
// Validate signing method
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return []byte(a.config.JWTSecret), nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
|
}
|
|
|
|
// Extract claims
|
|
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
|
userInfo := &UserInfo{}
|
|
|
|
// Extract standard JWT claims
|
|
if sub, ok := claims["sub"].(string); ok {
|
|
userInfo.ID = sub
|
|
}
|
|
if email, ok := claims["email"].(string); ok {
|
|
userInfo.Email = email
|
|
}
|
|
if name, ok := claims["name"].(string); ok {
|
|
userInfo.Name = name
|
|
}
|
|
if iss, ok := claims["iss"].(string); ok {
|
|
userInfo.Provider = iss
|
|
}
|
|
|
|
// Fallback to alternative claim names
|
|
if userInfo.ID == "" {
|
|
if userID, ok := claims["user_id"].(string); ok {
|
|
userInfo.ID = userID
|
|
}
|
|
}
|
|
|
|
// Default to anonymous if no user ID found
|
|
if userInfo.ID == "" {
|
|
userInfo.ID = "anonymous"
|
|
}
|
|
|
|
return userInfo, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid JWT claims")
|
|
}
|
|
|
|
// CreateMockJWT creates a mock JWT token for development/testing
|
|
func (a *AuthService) CreateMockJWT(userID, email, name string) (string, error) {
|
|
if !a.config.DevMode {
|
|
return "", fmt.Errorf("mock JWT creation only allowed in development mode")
|
|
}
|
|
|
|
// Create the claims
|
|
claims := jwt.MapClaims{
|
|
"sub": userID,
|
|
"email": email,
|
|
"name": name,
|
|
"iss": "insertr-dev",
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
|
}
|
|
|
|
// Create token
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
// Sign and get the complete encoded token as a string
|
|
tokenString, err := token.SignedString([]byte(a.config.JWTSecret))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
|
}
|
|
|
|
return tokenString, nil
|
|
}
|
|
|
|
// ValidateToken validates a JWT token without extracting user info
|
|
func (a *AuthService) ValidateToken(tokenString string) error {
|
|
_, err := a.parseJWT(tokenString)
|
|
return err
|
|
}
|
|
|
|
// generateCodeVerifier generates a cryptographically secure code verifier for PKCE
|
|
func generateCodeVerifier() (string, error) {
|
|
data := make([]byte, 32)
|
|
if _, err := rand.Read(data); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(data), nil
|
|
}
|
|
|
|
// generateCodeChallenge generates a code challenge from the verifier using SHA256
|
|
func generateCodeChallenge(verifier string) string {
|
|
hash := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
|
}
|
|
|
|
// generateState generates a random state parameter for OAuth2
|
|
func generateState() string {
|
|
data := make([]byte, 16)
|
|
rand.Read(data)
|
|
return base64.RawURLEncoding.EncodeToString(data)
|
|
}
|
|
|
|
// RefreshToken creates a new token with extended expiration
|
|
func (a *AuthService) RefreshToken(tokenString string) (string, error) {
|
|
userInfo, err := a.parseJWT(tokenString)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot refresh invalid token: %w", err)
|
|
}
|
|
|
|
// Create new token with same user info but extended expiration
|
|
claims := jwt.MapClaims{
|
|
"sub": userInfo.ID,
|
|
"email": userInfo.Email,
|
|
"name": userInfo.Name,
|
|
"iss": userInfo.Provider,
|
|
"iat": time.Now().Unix(),
|
|
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString([]byte(a.config.JWTSecret))
|
|
}
|
|
|
|
// IsAuthenticated checks if the request has valid authentication
|
|
func (a *AuthService) IsAuthenticated(r *http.Request) bool {
|
|
userInfo, err := a.ExtractUserFromRequest(r)
|
|
return err == nil && userInfo.ID != "anonymous"
|
|
}
|
|
|
|
// RequireAuth middleware that requires authentication
|
|
func (a *AuthService) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userInfo, err := a.ExtractUserFromRequest(r)
|
|
if err != nil || userInfo.ID == "anonymous" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Add user info to request context for use by handlers
|
|
ctx := r.Context()
|
|
ctx = ContextWithUser(ctx, userInfo)
|
|
r = r.WithContext(ctx)
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// OAuth flow handlers (to be implemented when adding real OAuth)
|
|
|
|
// HandleOAuthLogin initiates OAuth flow
|
|
func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
// Handle mock authentication in dev mode
|
|
if a.config.DevMode && a.config.Provider == "mock" {
|
|
response := map[string]interface{}{
|
|
"message": "Mock OAuth login",
|
|
"redirect_url": "/auth/callback?code=mock_code&state=mock_state",
|
|
"dev_mode": true,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
// Handle Authentik OIDC flow
|
|
if a.config.Provider == "authentik" && a.oauth2 != nil {
|
|
// Generate PKCE parameters
|
|
codeVerifier, err := generateCodeVerifier()
|
|
if err != nil {
|
|
http.Error(w, "Failed to generate code verifier", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
codeChallenge := generateCodeChallenge(codeVerifier)
|
|
state := generateState()
|
|
|
|
// Store PKCE parameters in session/cookie (simplified for now)
|
|
// In production, you'd store this in a secure session store
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "code_verifier",
|
|
Value: codeVerifier,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: !a.config.DevMode,
|
|
SameSite: http.SameSiteStrictMode,
|
|
MaxAge: 600, // 10 minutes
|
|
})
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "oauth_state",
|
|
Value: state,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: !a.config.DevMode,
|
|
SameSite: http.SameSiteStrictMode,
|
|
MaxAge: 600, // 10 minutes
|
|
})
|
|
|
|
// Build authorization URL with PKCE
|
|
authURL := a.oauth2.AuthCodeURL(state,
|
|
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
|
)
|
|
|
|
response := map[string]interface{}{
|
|
"redirect_url": authURL,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Authentication provider not configured", http.StatusBadRequest)
|
|
}
|
|
|
|
// HandleOAuthCallback handles OAuth provider callback
|
|
func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
|
code := r.URL.Query().Get("code")
|
|
state := r.URL.Query().Get("state")
|
|
|
|
// Handle mock authentication in dev mode
|
|
if a.config.DevMode && a.config.Provider == "mock" && code != "" {
|
|
mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User")
|
|
if err != nil {
|
|
http.Error(w, "Failed to create mock token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"token": mockToken,
|
|
"dev_mode": true,
|
|
"message": "Mock OAuth callback successful",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
// Handle Authentik OIDC callback
|
|
if a.config.Provider == "authentik" && a.oauth2 != nil {
|
|
// Validate state parameter
|
|
stateCookie, err := r.Cookie("oauth_state")
|
|
if err != nil || stateCookie.Value != state {
|
|
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get code verifier from cookie
|
|
verifierCookie, err := r.Cookie("code_verifier")
|
|
if err != nil {
|
|
http.Error(w, "Missing code verifier", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Exchange code for token with PKCE
|
|
ctx := context.Background()
|
|
token, err := a.oauth2.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("code_verifier", verifierCookie.Value),
|
|
)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Extract ID token
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
http.Error(w, "No ID token in response", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Clear cookies
|
|
http.SetCookie(w, &http.Cookie{Name: "oauth_state", MaxAge: -1, Path: "/"})
|
|
http.SetCookie(w, &http.Cookie{Name: "code_verifier", MaxAge: -1, Path: "/"})
|
|
|
|
response := map[string]interface{}{
|
|
"token": rawIDToken,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Authentication provider not configured", http.StatusBadRequest)
|
|
}
|