first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:01:36 +02:00
commit 035ac8ddb5
61 changed files with 6600 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"net/http"
"strings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"github.com/gin-gonic/gin"
)
const principalContextKey = "principal"
func RequireAuth(verifier *Verifier, repo db.Repository) gin.HandlerFunc {
return func(c *gin.Context) {
if verifier == nil || !verifier.Enabled() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
return
}
header := c.GetHeader("Authorization")
tokenString, ok := strings.CutPrefix(header, "Bearer ")
if !ok || strings.TrimSpace(tokenString) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing_bearer_token"})
return
}
claims, err := verifier.Verify(strings.TrimSpace(tokenString))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
subject, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
if name == "" {
name, _ = claims["display_name"].(string)
}
role, _ := claims["role"].(string)
if role == "" {
role = "authenticated"
}
if strings.TrimSpace(subject) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"})
return
}
if repo != nil {
if err := repo.EnsureUserIdentity(c.Request.Context(), subject, email, name); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "identity_sync_failed"})
return
}
}
c.Set(principalContextKey, domain.Principal{
Subject: subject,
Email: email,
Name: name,
Role: role,
})
c.Next()
}
}
func PrincipalFromContext(c *gin.Context) domain.Principal {
value, ok := c.Get(principalContextKey)
if !ok {
return domain.Principal{}
}
principal, _ := value.(domain.Principal)
return principal
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Verifier struct {
jwks keyfunc.Keyfunc
expectedIssuer string
enabled bool
cancel context.CancelFunc
}
func NewVerifier(neonAuthURL string) (*Verifier, error) {
trimmed := strings.TrimSpace(neonAuthURL)
if trimmed == "" {
return &Verifier{enabled: false}, nil
}
parsed, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("parse neon auth url: %w", err)
}
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
ctx, cancel := context.WithCancel(context.Background())
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
cancel()
return nil, fmt.Errorf("create jwks: %w", err)
}
return &Verifier{
jwks: jwks,
expectedIssuer: expectedIssuer,
enabled: true,
cancel: cancel,
}, nil
}
func (v *Verifier) Enabled() bool {
return v.enabled
}
func (v *Verifier) Close() {
if v.cancel != nil {
v.cancel()
}
}
func (v *Verifier) Verify(tokenString string) (jwt.MapClaims, error) {
if !v.enabled {
return nil, errors.New("neon auth verifier is disabled")
}
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
jwt.WithIssuer(v.expectedIssuer),
jwt.WithValidMethods([]string{"EdDSA"}),
jwt.WithAudience(v.expectedIssuer),
jwt.WithLeeway(15*time.Second),
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}