From 1d17917fca6e2105a1a6410b49ef4b20b30305a5 Mon Sep 17 00:00:00 2001 From: Stavros Date: Thu, 13 Aug 2026 17:30:06 +0300 Subject: [PATCH] refactor: use crypto approach to store authorize status --- internal/controller/oidc_controller.go | 70 ++++++++++++----- internal/model/constants.go | 2 + internal/service/oidc_service.go | 104 +++++++++++++++++++------ 3 files changed, 133 insertions(+), 43 deletions(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 16ac7da8..3826aeef 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -245,18 +245,40 @@ func (controller *OIDCController) authorize(c *gin.Context) { } } - cookieId := strings.SplitN(client.ClientID, "-", 2)[0] - cookieName := fmt.Sprintf("%s-%s", controller.runtime.ScopeCookieName, cookieId) - scopeCookie, err := c.Cookie(cookieName) + checkSkipAuthorize := func() { + cookieId := client.ClientID[8:] + cookieName := fmt.Sprintf("%s-%s", controller.runtime.ScopeCookieName, cookieId) + scopeCookie, err := c.Cookie(cookieName) - if err == nil { - scopes := fmt.Sprintf("scopes=%s;", req.Scope) - if controller.oidc.VerifySignedValue(client.ClientSecret, []byte(scopes), scopeCookie) { - if values.OIDCPrompt != service.OIDCPromptLogin { - values.OIDCPrompt = service.OIDCPromptNone - } + if err != nil || userContext == nil || !userContext.Authenticated { + return + } + + kv, err := controller.oidc.DecryptSecureValue(client.ClientSecret, scopeCookie) + if err != nil { + controller.log.App.Warn().Err(err).Msg("Failed to decrypt scope cookie") + return + } + + scope, ok := kv["scope"] + if !ok { + controller.log.App.Warn().Str("cookieName", cookieName).Msg("Failed to get scopes from scope cookie") + return + } + + username, ok := kv["username"] + if !ok { + controller.log.App.Warn().Str("cookieName", cookieName).Msg("Failed to get username from scope cookie") + return + } + + if username == userContext.GetUsername() && + values.OIDCPrompt != service.OIDCPromptLogin && + scope == req.Scope { + values.OIDCPrompt = service.OIDCPromptNone } } + checkSkipAuthorize() queries, err := query.Values(values) @@ -386,21 +408,29 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) { } // Set a cookie for the consent screen (approved scopes) - cookieId := strings.SplitN(client.ClientID, "-", 2)[0] + cookieId := client.ClientID[8:] cookieName := fmt.Sprintf("%s-%s", controller.runtime.ScopeCookieName, cookieId) - scopes := fmt.Sprintf("scopes=%s;", authorizeReq.Scope) - cookie := &http.Cookie{ - Name: cookieName, - Value: controller.oidc.CreateSignedValue(client.ClientSecret, []byte(scopes)), - Path: "/", - Secure: controller.config.Auth.SecureCookie, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, + secureSignedValue, err := controller.oidc.CreateSecureValue(client.ClientSecret, map[string]string{ + "username": userContext.GetUsername(), + "scope": authorizeReq.Scope, + }) + + if err == nil { + cookie := &http.Cookie{ + Name: cookieName, + Value: secureSignedValue, + Path: "/", + Secure: controller.config.Auth.SecureCookie, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + + http.SetCookie(c.Writer, cookie) + } else { + controller.log.App.Warn().Err(err).Msg("Failed to create scope cookie") } - http.SetCookie(c.Writer, cookie) - q := cu.Query() q.Set("code", code) diff --git a/internal/model/constants.go b/internal/model/constants.go index 2dd8616b..f4ebc346 100644 --- a/internal/model/constants.go +++ b/internal/model/constants.go @@ -24,3 +24,5 @@ const OAuthSessionCookieName = "tinyauth-oauth" const OIDCScopeCookieName = "tinyauth-scope" const GracefulShutdownTimeout = 5 // seconds + +const HKDFSalt = "tinyauth-hkdf-salt-v1" diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index a82ce0a5..418ac574 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -3,7 +3,8 @@ package service import ( "context" "crypto" - "crypto/hmac" + "crypto/aes" + "crypto/cipher" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -13,6 +14,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "net/url" "os" "path/filepath" @@ -23,7 +25,6 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "github.com/steveiliop56/ding" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/repository" @@ -31,6 +32,7 @@ import ( "github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/pkg/cache" "go.uber.org/dig" + "golang.org/x/crypto/hkdf" ) var ( @@ -308,9 +310,6 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) { for id, client := range i.Config.OIDC.Clients { client.ID = id - if err := uuid.Validate(client.ClientID); err != nil { - return nil, fmt.Errorf("invalid client id: %w", err) - } if client.Name == "" { client.Name = utils.Capitalize(client.ID) } @@ -324,9 +323,6 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) { client.ClientSecret = secret } client.ClientSecretFile = "" - if len(client.ClientSecret) < 32 { - return nil, fmt.Errorf("client secret for client %s is too short, must be >= 32 chars", client.ClientID) - } clients[id] = client i.Log.App.Debug().Str("clientId", client.ClientID).Msg("Loaded OIDC client configuration") } @@ -928,7 +924,7 @@ func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) { service.caches.authorize.Delete(ticket) } -// TODO: support signed request objects in the future +// DecodeAuthorizeJWT TODO: support signed request objects in the future func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) { var claims jwt.MapClaims @@ -979,20 +975,82 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt { return parsedPromps } -func (service *OIDCService) CreateSignedValue(key string, data []byte) string { - // create the signature - h := hmac.New(sha256.New, []byte(key)) - h.Write(data) - sig := base64.URLEncoding.EncodeToString(h.Sum(nil)) - - // hash the data - hasher := sha256.New() - hasher.Write(data) - hash := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) - - return fmt.Sprintf("%s.%s", hash, sig) +func (service *OIDCService) deriveKey(key string, info string) ([]byte, error) { + kdf := hkdf.New(sha256.New, []byte(key), []byte(model.HKDFSalt), []byte(info)) + derived := make([]byte, 32) + if _, err := io.ReadFull(kdf, derived); err != nil { + return nil, fmt.Errorf("failed to derive key: %w", err) + } + return derived, nil } -func (service *OIDCService) VerifySignedValue(key string, data []byte, signedValue string) bool { - return service.CreateSignedValue(key, data) == signedValue +func (service *OIDCService) CreateSecureValue(key string, kv map[string]string) (string, error) { + aesKey, err := service.deriveKey(key, "oidc-scope-v1") + if err != nil { + return "", err + } + + c, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("failed to create aes cipher: %w", err) + } + + gcm, err := cipher.NewGCM(c) + if err != nil { + return "", fmt.Errorf("failed to create gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + plain, err := json.Marshal(kv) + if err != nil { + return "", fmt.Errorf("failed to marshal data: %w", err) + } + + sealed := gcm.Seal(nonce, nonce, plain, nil) + return base64.RawURLEncoding.EncodeToString(sealed), nil +} + +func (service *OIDCService) DecryptSecureValue(key string, encrypted string) (map[string]string, error) { + data, err := base64.RawURLEncoding.DecodeString(encrypted) + if err != nil { + return nil, fmt.Errorf("failed to decode value: %w", err) + } + + aesKey, err := service.deriveKey(key, "oidc-scope-v1") + if err != nil { + return nil, err + } + + c, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("failed to create aes cipher: %w", err) + } + + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, fmt.Errorf("failed to create gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt secure value: %w", err) + } + + kv := make(map[string]string) // make(), not var — nil map writes panic + if err := json.Unmarshal(plain, &kv); err != nil { + return nil, fmt.Errorf("failed to parse secure value: %w", err) + } + + return kv, nil }