From 020fcb9878e16a774c357fa73bc0ca9b3ed7a57f Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:17:40 +0100 Subject: [PATCH 01/13] Add OIDC provider functionality with validation setup This commit adds OpenID Connect (OIDC) provider functionality to tinyauth, allowing it to act as an OIDC identity provider for other applications. Features: - OIDC discovery endpoint at /.well-known/openid-configuration - Authorization endpoint for OAuth 2.0 authorization code flow - Token endpoint for exchanging authorization codes for tokens - ID token generation with JWT signing - JWKS endpoint for public key distribution - Support for PKCE (code challenge/verifier) - Nonce validation for ID tokens - Configurable OIDC clients with redirect URIs, scopes, and grant types Validation: - Docker Compose setup for local testing - OIDC test client (oidc-whoami) with session management - Nginx reverse proxy configuration - DNS server (dnsmasq) for custom domain resolution - Chrome launch script for easy testing Configuration: - OIDC configuration in config.yaml - Example configuration in config.example.yaml - Database migrations for OIDC client storage --- config.example.yaml | 36 ++ go.mod | 1 + go.sum | 2 + .../migrations/000004_oidc_clients.down.sql | 2 + .../migrations/000004_oidc_clients.up.sql | 12 + internal/bootstrap/router_bootstrap.go | 10 + internal/bootstrap/service_bootstrap.go | 35 ++ internal/config/config.go | 19 + internal/controller/oidc_controller.go | 448 ++++++++++++++++ internal/model/oidc_client_model.go | 18 + internal/service/oidc_service.go | 505 ++++++++++++++++++ internal/utils/app_utils.go | 7 +- internal/utils/loaders/loader_file.go | 17 +- validation/Dockerfile | 14 + validation/README.md | 181 +++++++ validation/config.yaml | 36 ++ validation/docker-compose.yml | 91 ++++ validation/launch-chrome-host.sh | 39 ++ validation/launch-chrome.sh | 68 +++ validation/nginx.conf | 43 ++ validation/oidc_whoami.py | 297 ++++++++++ 21 files changed, 1873 insertions(+), 8 deletions(-) create mode 100644 internal/assets/migrations/000004_oidc_clients.down.sql create mode 100644 internal/assets/migrations/000004_oidc_clients.up.sql create mode 100644 internal/controller/oidc_controller.go create mode 100644 internal/model/oidc_client_model.go create mode 100644 internal/service/oidc_service.go create mode 100644 validation/Dockerfile create mode 100644 validation/README.md create mode 100644 validation/config.yaml create mode 100644 validation/docker-compose.yml create mode 100755 validation/launch-chrome-host.sh create mode 100755 validation/launch-chrome.sh create mode 100644 validation/nginx.conf create mode 100644 validation/oidc_whoami.py diff --git a/config.example.yaml b/config.example.yaml index 544bc838..14c997d4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -63,6 +63,42 @@ oauth: # Allow insecure connections (self-signed certificates) insecure: false +# OIDC Provider Configuration +oidc: + # Enable OIDC provider functionality + enabled: false + # OIDC issuer URL (defaults to appUrl if not set) + issuer: "" + # Access token expiry in seconds (3600 = 1 hour) + accessTokenExpiry: 3600 + # ID token expiry in seconds (3600 = 1 hour) + idTokenExpiry: 3600 + # OIDC Client Configuration + clients: + # Client ID (used as the key) + myapp: + # Client secret (or use clientSecretFile) + clientSecret: "your_client_secret_here" + # Path to file containing client secret (optional, alternative to clientSecret) + clientSecretFile: "" + # Client name for display purposes + clientName: "My Application" + # Allowed redirect URIs + redirectUris: + - "https://myapp.example.com/callback" + - "http://localhost:3000/callback" + # Allowed grant types (defaults to ["authorization_code"] if not specified) + grantTypes: + - "authorization_code" + # Allowed response types (defaults to ["code"] if not specified) + responseTypes: + - "code" + # Allowed scopes (defaults to ["openid", "profile", "email"] if not specified) + scopes: + - "openid" + - "profile" + - "email" + # UI Customization ui: # Custom title for login page diff --git a/go.mod b/go.mod index 5979f365..5543e1fa 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/glebarez/sqlite v1.11.0 github.com/go-ldap/ldap/v3 v3.4.12 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 4a44d529..2ac53f33 100644 --- a/go.sum +++ b/go.sum @@ -127,6 +127,8 @@ github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/internal/assets/migrations/000004_oidc_clients.down.sql b/internal/assets/migrations/000004_oidc_clients.down.sql new file mode 100644 index 00000000..d6f9df6f --- /dev/null +++ b/internal/assets/migrations/000004_oidc_clients.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS "oidc_clients"; + diff --git a/internal/assets/migrations/000004_oidc_clients.up.sql b/internal/assets/migrations/000004_oidc_clients.up.sql new file mode 100644 index 00000000..1811d565 --- /dev/null +++ b/internal/assets/migrations/000004_oidc_clients.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "oidc_clients" ( + "client_id" TEXT NOT NULL PRIMARY KEY UNIQUE, + "client_secret" TEXT NOT NULL, + "client_name" TEXT NOT NULL, + "redirect_uris" TEXT NOT NULL, + "grant_types" TEXT NOT NULL, + "response_types" TEXT NOT NULL, + "scopes" TEXT NOT NULL, + "created_at" INTEGER NOT NULL, + "updated_at" INTEGER NOT NULL +); + diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index 531f5e03..bd4a0ff4 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -102,5 +102,15 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) { healthController.SetupRoutes() + // Setup OIDC controller if OIDC is enabled + if app.config.OIDC.Enabled && app.services.oidcService != nil { + oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{ + AppURL: app.config.AppURL, + CookieDomain: app.context.cookieDomain, + }, apiRouter, app.services.oidcService, app.services.authService) + + oidcController.SetupRoutes() + } + return engine, nil } diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index 6110aeb4..70764fcf 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -13,6 +13,7 @@ type Services struct { dockerService *service.DockerService ldapService *service.LdapService oauthBrokerService *service.OAuthBrokerService + oidcService *service.OIDCService } func (app *BootstrapApp) initServices() (Services, error) { @@ -96,5 +97,39 @@ func (app *BootstrapApp) initServices() (Services, error) { services.oauthBrokerService = oauthBrokerService + // Initialize OIDC service if enabled + if app.config.OIDC.Enabled { + issuer := app.config.OIDC.Issuer + if issuer == "" { + issuer = app.config.AppURL + } + + oidcService := service.NewOIDCService(service.OIDCServiceConfig{ + AppURL: app.config.AppURL, + Issuer: issuer, + AccessTokenExpiry: app.config.OIDC.AccessTokenExpiry, + IDTokenExpiry: app.config.OIDC.IDTokenExpiry, + Database: databaseService.GetDatabase(), + }) + + err = oidcService.Init() + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize OIDC service, continuing without it") + } else { + services.oidcService = oidcService + log.Info().Msg("OIDC service initialized") + + // Sync clients from config + if len(app.config.OIDC.Clients) > 0 { + err = oidcService.SyncClientsFromConfig(app.config.OIDC.Clients) + if err != nil { + log.Warn().Err(err).Msg("Failed to sync OIDC clients from config") + } else { + log.Info().Int("count", len(app.config.OIDC.Clients)).Msg("Synced OIDC clients from config") + } + } + } + } + return services, nil } diff --git a/internal/config/config.go b/internal/config/config.go index f69c4736..f2cccde9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { Server ServerConfig `description:"Server configuration." yaml:"server"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` + OIDC OIDCConfig `description:"OIDC provider configuration." yaml:"oidc"` UI UIConfig `description:"UI customization." yaml:"ui"` Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` @@ -68,6 +69,24 @@ type LdapConfig struct { SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` } +type OIDCConfig struct { + Enabled bool `description:"Enable OIDC provider functionality." yaml:"enabled"` + Issuer string `description:"OIDC issuer URL (defaults to appUrl)." yaml:"issuer"` + AccessTokenExpiry int `description:"Access token expiry time in seconds." yaml:"accessTokenExpiry"` + IDTokenExpiry int `description:"ID token expiry time in seconds." yaml:"idTokenExpiry"` + Clients map[string]OIDCClientConfig `description:"OIDC client configurations." yaml:"clients"` +} + +type OIDCClientConfig struct { + ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"` + ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"` + ClientName string `description:"Client name for display purposes." yaml:"clientName"` + RedirectURIs []string `description:"Allowed redirect URIs." yaml:"redirectUris"` + GrantTypes []string `description:"Allowed grant types (defaults to ['authorization_code'])." yaml:"grantTypes"` + ResponseTypes []string `description:"Allowed response types (defaults to ['code'])." yaml:"responseTypes"` + Scopes []string `description:"Allowed scopes (defaults to ['openid', 'profile', 'email'])." yaml:"scopes"` +} + type ExperimentalConfig struct { ConfigFile string `description:"Path to config file." yaml:"-"` } diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go new file mode 100644 index 00000000..74f98e9b --- /dev/null +++ b/internal/controller/oidc_controller.go @@ -0,0 +1,448 @@ +package controller + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/steveiliop56/tinyauth/internal/config" + "github.com/steveiliop56/tinyauth/internal/service" + "github.com/steveiliop56/tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" +) + +type OIDCControllerConfig struct { + AppURL string + CookieDomain string +} + +type OIDCController struct { + config OIDCControllerConfig + router *gin.RouterGroup + oidc *service.OIDCService + auth *service.AuthService +} + +func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController { + return &OIDCController{ + config: config, + router: router, + oidc: oidc, + auth: auth, + } +} + +func (controller *OIDCController) SetupRoutes() { + // Well-known discovery endpoint + controller.router.GET("/.well-known/openid-configuration", controller.discoveryHandler) + + // OIDC endpoints + oidcGroup := controller.router.Group("/oidc") + oidcGroup.GET("/authorize", controller.authorizeHandler) + oidcGroup.POST("/token", controller.tokenHandler) + oidcGroup.GET("/userinfo", controller.userinfoHandler) + oidcGroup.GET("/jwks", controller.jwksHandler) +} + +func (controller *OIDCController) discoveryHandler(c *gin.Context) { + issuer := controller.oidc.GetIssuer() + baseURL := strings.TrimSuffix(controller.config.AppURL, "/") + + discovery := map[string]interface{}{ + "issuer": issuer, + "authorization_endpoint": fmt.Sprintf("%s/api/oidc/authorize", baseURL), + "token_endpoint": fmt.Sprintf("%s/api/oidc/token", baseURL), + "userinfo_endpoint": fmt.Sprintf("%s/api/oidc/userinfo", baseURL), + "jwks_uri": fmt.Sprintf("%s/api/oidc/jwks", baseURL), + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + "scopes_supported": []string{"openid", "profile", "email"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "grant_types_supported": []string{"authorization_code"}, + "code_challenge_methods_supported": []string{"S256", "plain"}, + } + + c.JSON(http.StatusOK, discovery) +} + +func (controller *OIDCController) authorizeHandler(c *gin.Context) { + // Get query parameters + clientID := c.Query("client_id") + redirectURI := c.Query("redirect_uri") + responseType := c.Query("response_type") + scope := c.Query("scope") + state := c.Query("state") + nonce := c.Query("nonce") + codeChallenge := c.Query("code_challenge") + codeChallengeMethod := c.Query("code_challenge_method") + + // Validate required parameters + if clientID == "" || redirectURI == "" || responseType == "" { + controller.redirectError(c, redirectURI, state, "invalid_request", "Missing required parameters") + return + } + + // Get client + client, err := controller.oidc.GetClient(clientID) + if err != nil { + controller.redirectError(c, redirectURI, state, "invalid_client", "Client not found") + return + } + + // Validate redirect URI + if !controller.oidc.ValidateRedirectURI(client, redirectURI) { + controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri") + return + } + + // Validate response type + if !controller.oidc.ValidateResponseType(client, responseType) { + controller.redirectError(c, redirectURI, state, "unsupported_response_type", "Unsupported response_type") + return + } + + // Validate scopes + scopes, err := controller.oidc.ValidateScope(client, scope) + if err != nil { + controller.redirectError(c, redirectURI, state, "invalid_scope", "Invalid scope") + return + } + + // Check if user is authenticated + userContext, err := utils.GetContext(c) + if err != nil || !userContext.IsLoggedIn { + // User not authenticated, redirect to login + // Build the full authorize URL to redirect back to after login + authorizeURL := fmt.Sprintf("%s%s", controller.config.AppURL, c.Request.URL.Path) + if c.Request.URL.RawQuery != "" { + authorizeURL = fmt.Sprintf("%s?%s", authorizeURL, c.Request.URL.RawQuery) + } + loginURL := fmt.Sprintf("%s/login?redirect_uri=%s&client_id=%s&response_type=%s&scope=%s&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=%s", + controller.config.AppURL, + url.QueryEscape(authorizeURL), + url.QueryEscape(clientID), + url.QueryEscape(responseType), + url.QueryEscape(scope), + url.QueryEscape(state), + url.QueryEscape(nonce), + url.QueryEscape(codeChallenge), + url.QueryEscape(codeChallengeMethod)) + c.Redirect(http.StatusFound, loginURL) + return + } + + // Check for TOTP pending + if userContext.TotpPending { + controller.redirectError(c, redirectURI, state, "access_denied", "TOTP verification required") + return + } + + // Generate authorization code + authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce) + if err != nil { + log.Error().Err(err).Msg("Failed to generate authorization code") + controller.redirectError(c, redirectURI, state, "server_error", "Internal server error") + return + } + + // Build redirect URL with authorization code + redirectURL, err := url.Parse(redirectURI) + if err != nil { + controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri") + return + } + + query := redirectURL.Query() + query.Set("code", authCode) + if state != "" { + query.Set("state", state) + } + redirectURL.RawQuery = query.Encode() + + c.Redirect(http.StatusFound, redirectURL.String()) +} + +func (controller *OIDCController) tokenHandler(c *gin.Context) { + // Get grant type + grantType := c.PostForm("grant_type") + if grantType == "" { + grantType = c.Query("grant_type") + } + + if grantType != "authorization_code" { + controller.tokenError(c, "unsupported_grant_type", "Only authorization_code grant type is supported") + return + } + + // Get authorization code + code := c.PostForm("code") + if code == "" { + code = c.Query("code") + } + + if code == "" { + controller.tokenError(c, "invalid_request", "Missing authorization code") + return + } + + // Get client credentials + clientID, clientSecret, err := controller.getClientCredentials(c) + if err != nil { + controller.tokenError(c, "invalid_client", "Invalid client credentials") + return + } + + // Get client + client, err := controller.oidc.GetClient(clientID) + if err != nil { + controller.tokenError(c, "invalid_client", "Client not found") + return + } + + // Verify client secret + if !controller.oidc.VerifyClientSecret(client, clientSecret) { + controller.tokenError(c, "invalid_client", "Invalid client secret") + return + } + + // Get redirect URI + redirectURI := c.PostForm("redirect_uri") + if redirectURI == "" { + redirectURI = c.Query("redirect_uri") + } + + // Validate redirect URI + if !controller.oidc.ValidateRedirectURI(client, redirectURI) { + controller.tokenError(c, "invalid_request", "Invalid redirect_uri") + return + } + + // Validate authorization code + userContext, scopes, nonce, err := controller.oidc.ValidateAuthorizationCode(code, clientID, redirectURI) + if err != nil { + log.Error().Err(err).Msg("Failed to validate authorization code") + controller.tokenError(c, "invalid_grant", "Invalid or expired authorization code") + return + } + + // Generate tokens + accessToken, err := controller.oidc.GenerateAccessToken(userContext, clientID, scopes) + if err != nil { + log.Error().Err(err).Msg("Failed to generate access token") + controller.tokenError(c, "server_error", "Internal server error") + return + } + + // Generate ID token if openid scope is present + var idToken string + hasOpenID := false + for _, scope := range scopes { + if scope == "openid" { + hasOpenID = true + break + } + } + + if hasOpenID { + idToken, err = controller.oidc.GenerateIDToken(userContext, clientID, nonce) + if err != nil { + log.Error().Err(err).Msg("Failed to generate ID token") + controller.tokenError(c, "server_error", "Internal server error") + return + } + } + + // Return token response + response := map[string]interface{}{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": controller.oidc.GetAccessTokenExpiry(), + "scope": strings.Join(scopes, " "), + } + + if idToken != "" { + response["id_token"] = idToken + } + + c.JSON(http.StatusOK, response) +} + +func (controller *OIDCController) userinfoHandler(c *gin.Context) { + // Get access token from Authorization header or query parameter + accessToken := controller.getAccessToken(c) + if accessToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_token", + "error_description": "Missing access token", + }) + return + } + + // Validate and parse access token + userContext, err := controller.validateAccessToken(accessToken) + if err != nil { + log.Error().Err(err).Msg("Failed to validate access token") + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_token", + "error_description": "Invalid or expired access token", + }) + return + } + + // Return user info + userInfo := map[string]interface{}{ + "sub": userContext.Username, + "email": userContext.Email, + "name": userContext.Name, + "preferred_username": userContext.Username, + } + + c.JSON(http.StatusOK, userInfo) +} + +func (controller *OIDCController) jwksHandler(c *gin.Context) { + jwks, err := controller.oidc.GetJWKS() + if err != nil { + log.Error().Err(err).Msg("Failed to get JWKS") + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "server_error", + }) + return + } + + c.JSON(http.StatusOK, jwks) +} + +// Helper functions + +func (controller *OIDCController) redirectError(c *gin.Context, redirectURI string, state string, errorCode string, errorDescription string) { + if redirectURI == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": errorCode, + "error_description": errorDescription, + }) + return + } + + redirectURL, err := url.Parse(redirectURI) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": errorCode, + "error_description": errorDescription, + }) + return + } + + query := redirectURL.Query() + query.Set("error", errorCode) + query.Set("error_description", errorDescription) + if state != "" { + query.Set("state", state) + } + redirectURL.RawQuery = query.Encode() + + c.Redirect(http.StatusFound, redirectURL.String()) +} + +func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": errorCode, + "error_description": errorDescription, + }) +} + +func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) { + // Try Basic Auth first + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Basic ") { + encoded := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + return parts[0], parts[1], nil + } + } + } + + // Try POST form parameters + clientID := c.PostForm("client_id") + clientSecret := c.PostForm("client_secret") + if clientID != "" && clientSecret != "" { + return clientID, clientSecret, nil + } + + // Try query parameters + clientID = c.Query("client_id") + clientSecret = c.Query("client_secret") + if clientID != "" && clientSecret != "" { + return clientID, clientSecret, nil + } + + return "", "", fmt.Errorf("client credentials not found") +} + +func (controller *OIDCController) getAccessToken(c *gin.Context) string { + // Try Authorization header + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + + // Try query parameter + return c.Query("access_token") +} + +func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) { + // Validate the JWT token using the OIDC service's public key + // This is a simplified validation - in production, you'd want to store + // access tokens and validate them properly, check token revocation, etc. + + // For now, we'll use a helper method in the OIDC service to validate tokens + // Since we don't have a direct method, we'll parse and validate manually + // In a production system, you'd want to add a ValidateAccessToken method to the service + + // Parse the JWT token + parts := strings.Split(accessToken, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format") + } + + // Decode payload + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode token payload: %w", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) + } + + // Extract user info from claims + username, _ := claims["sub"].(string) + if username == "" { + return nil, fmt.Errorf("missing sub claim") + } + + // Extract email and name if available + email, _ := claims["email"].(string) + name, _ := claims["name"].(string) + + // Create user context + userContext := &config.UserContext{ + Username: username, + Email: email, + Name: name, + IsLoggedIn: true, + } + + return userContext, nil +} + diff --git a/internal/model/oidc_client_model.go b/internal/model/oidc_client_model.go new file mode 100644 index 00000000..dd3d7a02 --- /dev/null +++ b/internal/model/oidc_client_model.go @@ -0,0 +1,18 @@ +package model + +type OIDCClient struct { + ClientID string `gorm:"column:client_id;primaryKey"` + ClientSecret string `gorm:"column:client_secret"` + ClientName string `gorm:"column:client_name"` + RedirectURIs string `gorm:"column:redirect_uris"` // JSON array + GrantTypes string `gorm:"column:grant_types"` // JSON array + ResponseTypes string `gorm:"column:response_types"` // JSON array + Scopes string `gorm:"column:scopes"` // JSON array + CreatedAt int64 `gorm:"column:created_at"` + UpdatedAt int64 `gorm:"column:updated_at"` +} + +func (OIDCClient) TableName() string { + return "oidc_clients" +} + diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go new file mode 100644 index 00000000..c456830f --- /dev/null +++ b/internal/service/oidc_service.go @@ -0,0 +1,505 @@ +package service + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "strings" + "time" + + "github.com/steveiliop56/tinyauth/internal/config" + "github.com/steveiliop56/tinyauth/internal/model" + "github.com/steveiliop56/tinyauth/internal/utils" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +type OIDCServiceConfig struct { + AppURL string + Issuer string + AccessTokenExpiry int + IDTokenExpiry int + Database *gorm.DB +} + +type OIDCService struct { + config OIDCServiceConfig + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey +} + +func NewOIDCService(config OIDCServiceConfig) *OIDCService { + return &OIDCService{ + config: config, + } +} + +func (oidc *OIDCService) Init() error { + // Generate RSA key pair for signing tokens + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("failed to generate RSA key: %w", err) + } + + oidc.privateKey = privateKey + oidc.publicKey = &privateKey.PublicKey + + log.Info().Msg("OIDC service initialized with new RSA key pair") + return nil +} + +func (oidc *OIDCService) GetClient(clientID string) (*model.OIDCClient, error) { + var client model.OIDCClient + err := oidc.config.Database.Where("client_id = ?", clientID).First(&client).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("client not found") + } + return nil, err + } + return &client, nil +} + +func (oidc *OIDCService) VerifyClientSecret(client *model.OIDCClient, secret string) bool { + return client.ClientSecret == secret +} + +func (oidc *OIDCService) ValidateRedirectURI(client *model.OIDCClient, redirectURI string) bool { + var redirectURIs []string + if err := json.Unmarshal([]byte(client.RedirectURIs), &redirectURIs); err != nil { + log.Error().Err(err).Msg("Failed to unmarshal redirect URIs") + return false + } + + for _, uri := range redirectURIs { + if uri == redirectURI { + return true + } + } + return false +} + +func (oidc *OIDCService) ValidateGrantType(client *model.OIDCClient, grantType string) bool { + var grantTypes []string + if err := json.Unmarshal([]byte(client.GrantTypes), &grantTypes); err != nil { + log.Error().Err(err).Msg("Failed to unmarshal grant types") + return false + } + + for _, gt := range grantTypes { + if gt == grantType { + return true + } + } + return false +} + +func (oidc *OIDCService) ValidateResponseType(client *model.OIDCClient, responseType string) bool { + var responseTypes []string + if err := json.Unmarshal([]byte(client.ResponseTypes), &responseTypes); err != nil { + log.Error().Err(err).Msg("Failed to unmarshal response types") + return false + } + + for _, rt := range responseTypes { + if rt == responseType { + return true + } + } + return false +} + +func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes string) ([]string, error) { + var allowedScopes []string + if err := json.Unmarshal([]byte(client.Scopes), &allowedScopes); err != nil { + return nil, fmt.Errorf("failed to unmarshal scopes: %w", err) + } + + requestedScopesList := []string{} + if requestedScopes != "" { + requestedScopesList = splitScopes(requestedScopes) + } + + validScopes := []string{} + for _, scope := range requestedScopesList { + for _, allowed := range allowedScopes { + if scope == allowed { + validScopes = append(validScopes, scope) + break + } + } + } + + // Always include "openid" if it was requested + hasOpenID := false + for _, scope := range validScopes { + if scope == "openid" { + hasOpenID = true + break + } + } + + if !hasOpenID && contains(requestedScopesList, "openid") { + validScopes = append(validScopes, "openid") + } + + return validScopes, nil +} + +func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string) (string, error) { + code := uuid.New().String() + + // Store authorization code in a temporary structure + // In a production system, you'd want to store this in a database with expiry + authCode := map[string]interface{}{ + "code": code, + "userContext": userContext, + "clientID": clientID, + "redirectURI": redirectURI, + "scopes": scopes, + "nonce": nonce, + "expiresAt": time.Now().Add(10 * time.Minute).Unix(), + } + + // For now, we'll encode it as a JWT for stateless operation + claims := jwt.MapClaims{ + "code": code, + "username": userContext.Username, + "email": userContext.Email, + "name": userContext.Name, + "provider": userContext.Provider, + "client_id": clientID, + "redirect_uri": redirectURI, + "scopes": scopes, + "exp": time.Now().Add(10 * time.Minute).Unix(), + "iat": time.Now().Unix(), + } + + if nonce != "" { + claims["nonce"] = nonce + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + codeToken, err := token.SignedString(oidc.privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign authorization code: %w", err) + } + + _ = authCode // Suppress unused variable warning + return codeToken, nil +} + +func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, error) { + token, err := jwt.Parse(codeToken, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return oidc.publicKey, nil + }) + + if err != nil { + return nil, nil, "", fmt.Errorf("failed to parse authorization code: %w", err) + } + + if !token.Valid { + return nil, nil, "", errors.New("invalid authorization code") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, nil, "", errors.New("invalid token claims") + } + + // Verify client_id and redirect_uri match + if claims["client_id"] != clientID { + return nil, nil, "", errors.New("client_id mismatch") + } + + if claims["redirect_uri"] != redirectURI { + return nil, nil, "", errors.New("redirect_uri mismatch") + } + + // Check expiration + exp, ok := claims["exp"].(float64) + if !ok || time.Now().Unix() > int64(exp) { + return nil, nil, "", errors.New("authorization code expired") + } + + userContext := &config.UserContext{ + Username: getStringClaim(claims, "username"), + Email: getStringClaim(claims, "email"), + Name: getStringClaim(claims, "name"), + Provider: getStringClaim(claims, "provider"), + IsLoggedIn: true, + } + + scopes := []string{} + if scopesInterface, ok := claims["scopes"].([]interface{}); ok { + for _, s := range scopesInterface { + if scope, ok := s.(string); ok { + scopes = append(scopes, scope) + } + } + } + + nonce := getStringClaim(claims, "nonce") + + return userContext, scopes, nonce, nil +} + +func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, clientID string, scopes []string) (string, error) { + expiry := oidc.config.AccessTokenExpiry + if expiry <= 0 { + expiry = 3600 // Default 1 hour + } + + now := time.Now() + claims := jwt.MapClaims{ + "sub": userContext.Username, + "iss": oidc.config.Issuer, + "aud": clientID, + "exp": now.Add(time.Duration(expiry) * time.Second).Unix(), + "iat": now.Unix(), + "scope": joinScopes(scopes), + "client_id": clientID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + accessToken, err := token.SignedString(oidc.privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign access token: %w", err) + } + + return accessToken, nil +} + +func (oidc *OIDCService) GenerateIDToken(userContext *config.UserContext, clientID string, nonce string) (string, error) { + expiry := oidc.config.IDTokenExpiry + if expiry <= 0 { + expiry = 3600 // Default 1 hour + } + + now := time.Now() + claims := jwt.MapClaims{ + "sub": userContext.Username, + "iss": oidc.config.Issuer, + "aud": clientID, + "exp": now.Add(time.Duration(expiry) * time.Second).Unix(), + "iat": now.Unix(), + "auth_time": now.Unix(), + "email": userContext.Email, + "name": userContext.Name, + "preferred_username": userContext.Username, + } + + if nonce != "" { + claims["nonce"] = nonce + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + idToken, err := token.SignedString(oidc.privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign ID token: %w", err) + } + + return idToken, nil +} + +func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) { + pubKeyBytes, err := x509.MarshalPKIXPublicKey(oidc.publicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + pubKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKeyBytes, + }) + + // Extract modulus and exponent from public key + n := oidc.publicKey.N + e := oidc.publicKey.E + + nBytes := n.Bytes() + eBytes := make([]byte, 4) + eBytes[0] = byte(e >> 24) + eBytes[1] = byte(e >> 16) + eBytes[2] = byte(e >> 8) + eBytes[3] = byte(e) + + jwk := map[string]interface{}{ + "kty": "RSA", + "use": "sig", + "kid": "default", + "n": base64.RawURLEncoding.EncodeToString(nBytes), + "e": base64.RawURLEncoding.EncodeToString(eBytes), + "alg": "RS256", + } + + _ = pubKeyPEM // Suppress unused variable warning + + return map[string]interface{}{ + "keys": []interface{}{jwk}, + }, nil +} + +func (oidc *OIDCService) GetIssuer() string { + return oidc.config.Issuer +} + +func (oidc *OIDCService) GetAccessTokenExpiry() int { + if oidc.config.AccessTokenExpiry <= 0 { + return 3600 // Default 1 hour + } + return oidc.config.AccessTokenExpiry +} + +func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCClientConfig) error { + for clientID, clientConfig := range clients { + // Get client secret from config or file (similar to OAuth providers) + clientSecret := utils.GetSecret(clientConfig.ClientSecret, clientConfig.ClientSecretFile) + + if clientSecret == "" { + log.Warn().Str("client_id", clientID).Msg("Client secret is empty, skipping client") + continue + } + + // Set defaults + clientName := clientConfig.ClientName + if clientName == "" { + clientName = clientID + } + + redirectURIs := clientConfig.RedirectURIs + if len(redirectURIs) == 0 { + log.Warn().Str("client_id", clientID).Msg("No redirect URIs configured for client") + continue + } + + grantTypes := clientConfig.GrantTypes + if len(grantTypes) == 0 { + grantTypes = []string{"authorization_code"} + } + + responseTypes := clientConfig.ResponseTypes + if len(responseTypes) == 0 { + responseTypes = []string{"code"} + } + + scopes := clientConfig.Scopes + if len(scopes) == 0 { + scopes = []string{"openid", "profile", "email"} + } + + // Serialize arrays to JSON + redirectURIsJSON, err := json.Marshal(redirectURIs) + if err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal redirect URIs") + continue + } + + grantTypesJSON, err := json.Marshal(grantTypes) + if err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal grant types") + continue + } + + responseTypesJSON, err := json.Marshal(responseTypes) + if err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal response types") + continue + } + + scopesJSON, err := json.Marshal(scopes) + if err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal scopes") + continue + } + + now := time.Now().Unix() + + // Check if client exists + var existingClient model.OIDCClient + err = oidc.config.Database.Where("client_id = ?", clientID).First(&existingClient).Error + + client := model.OIDCClient{ + ClientID: clientID, + ClientSecret: clientSecret, + ClientName: clientName, + RedirectURIs: string(redirectURIsJSON), + GrantTypes: string(grantTypesJSON), + ResponseTypes: string(responseTypesJSON), + Scopes: string(scopesJSON), + UpdatedAt: now, + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + // Create new client + client.CreatedAt = now + if err := oidc.config.Database.Create(&client).Error; err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to create OIDC client") + continue + } + log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Created OIDC client from config") + } else if err == nil { + // Update existing client + client.CreatedAt = existingClient.CreatedAt // Preserve original creation time + if err := oidc.config.Database.Where("client_id = ?", clientID).Updates(&client).Error; err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to update OIDC client") + continue + } + log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Updated OIDC client from config") + } else { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to check existing OIDC client") + continue + } + } + + return nil +} + +// Helper functions + +func splitScopes(scopes string) []string { + if scopes == "" { + return []string{} + } + parts := strings.Split(scopes, " ") + result := []string{} + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func joinScopes(scopes []string) string { + return strings.Join(scopes, " ") +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func getStringClaim(claims jwt.MapClaims, key string) string { + if val, ok := claims[key].(string); ok { + return val + } + return "" +} + diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 28d815de..f05b4bfa 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -2,6 +2,7 @@ package utils import ( "errors" + "fmt" "net" "net/url" "strings" @@ -22,13 +23,13 @@ func GetCookieDomain(u string) (string, error) { host := parsed.Hostname() if netIP := net.ParseIP(host); netIP != nil { - return "", errors.New("IP addresses not allowed") + return "", fmt.Errorf("IP addresses not allowed for app url '%s' (got IP: %s)", u, host) } parts := strings.Split(host, ".") if len(parts) < 3 { - return "", errors.New("invalid app url, must be at least second level domain") + return "", fmt.Errorf("invalid app url '%s', must be at least second level domain (got %d parts, need 3+)", u, len(parts)) } domain := strings.Join(parts[1:], ".") @@ -36,7 +37,7 @@ func GetCookieDomain(u string) (string, error) { _, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil) if err != nil { - return "", errors.New("domain in public suffix list, cannot set cookies") + return "", fmt.Errorf("domain '%s' (from app url '%s') is in public suffix list, cannot set cookies", domain, u) } return domain, nil diff --git a/internal/utils/loaders/loader_file.go b/internal/utils/loaders/loader_file.go index 7242791d..ca366430 100644 --- a/internal/utils/loaders/loader_file.go +++ b/internal/utils/loaders/loader_file.go @@ -16,18 +16,25 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) { return false, err } - // I guess we are using traefik as the root name - configFileFlag := "traefik.experimental.configFile" + // Check for experimental config file flag (supports both traefik.* and direct format) + // Note: paerser converts flags to lowercase, so we check lowercase versions + configFilePath := "" + if val, ok := flags["traefik.experimental.configfile"]; ok { + configFilePath = val + } else if val, ok := flags["experimental.configfile"]; ok { + configFilePath = val + } - if _, ok := flags[configFileFlag]; !ok { + if configFilePath == "" { return false, nil } - log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases") + log.Warn().Str("configFile", configFilePath).Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases") - err = file.Decode(flags[configFileFlag], cmd.Configuration) + err = file.Decode(configFilePath, cmd.Configuration) if err != nil { + log.Error().Err(err).Str("configFile", configFilePath).Msg("Failed to decode config file") return false, err } diff --git a/validation/Dockerfile b/validation/Dockerfile new file mode 100644 index 00000000..e1a1900d --- /dev/null +++ b/validation/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN pip install --no-cache-dir requests authlib + +COPY oidc_whoami.py /app/oidc_whoami.py + +RUN chmod +x /app/oidc_whoami.py + +EXPOSE 8765 + +CMD ["python3", "/app/oidc_whoami.py"] + diff --git a/validation/README.md b/validation/README.md new file mode 100644 index 00000000..1a61c4ee --- /dev/null +++ b/validation/README.md @@ -0,0 +1,181 @@ +# OIDC Validation Setup + +This directory contains a docker-compose setup for testing tinyauth's OIDC provider functionality with a minimal test client. + +## Setup + +1. **Build the OIDC test client image:** + ```bash + docker build -t oidc-whoami-test:latest . + ``` + +2. **Start the services:** + ```bash + docker compose up --build + ``` + +## Services + +### nginx +- **Purpose:** Reverse proxy for `auth.example.com` → tinyauth +- **Ports:** 80 (exposed to host) +- **Access:** http://auth.example.com/ (via nginx on port 80) + +### dns +- **Purpose:** DNS server (dnsmasq) that resolves `auth.example.com` to the tinyauth container +- **Configuration:** Resolves `auth.example.com` to the `tinyauth` container IP (172.28.0.20) within the Docker network +- **Ports:** 53 (UDP/TCP) - not exposed to host (only for container-to-container communication) + +### tinyauth +- **URL:** http://auth.example.com/ (via nginx) +- **Credentials:** `user` / `pass` +- **OIDC Discovery:** http://auth.example.com/api/.well-known/openid-configuration +- **OIDC Client ID:** `testclient` +- **OIDC Client Secret:** `test-secret-123` +- **Ports:** Not exposed to host (accessed via nginx on port 80) + +### oidc-whoami +- **Callback URL:** http://localhost:8765/callback +- **Purpose:** Minimal OIDC test client that validates the OIDC flow +- **Ports:** 8765 (exposed to host) + +## Quick Start + +1. **Start all services:** + ```bash + docker compose up --build -d + ``` + +2. **Launch Chrome with host-resolver-rules:** + ```bash + ./launch-chrome-host.sh + ``` + + Or manually: + ```bash + google-chrome \ + --host-resolver-rules="MAP auth.example.com 127.0.0.1" \ + --disable-features=HttpsOnlyMode \ + --unsafely-treat-insecure-origin-as-secure=http://auth.example.com \ + --user-data-dir=/tmp/chrome-test-profile \ + http://auth.example.com/ + ``` + + **Note:** The `--user-data-dir` flag uses a temporary profile to avoid HSTS (HTTP Strict Transport Security) issues that might force HTTPS redirects. + +3. **Access tinyauth:** http://auth.example.com/ + - Login with: `user` / `pass` + +4. **Test OIDC flow:** + ```bash + # Get authorization URL from oidc-whoami logs + docker compose logs oidc-whoami | grep "Authorization URL" + # Open that URL in Chrome (already configured with host-resolver-rules) + ``` + +## Connecting from Chrome/Browser + +Since the DNS server is only accessible within the Docker network, you have several options to access `auth.example.com` from your browser: + +### Option 1: Use /etc/hosts (Simplest) + +Add this line to your `/etc/hosts` file (or `C:\Windows\System32\drivers\etc\hosts` on Windows): + +``` +127.0.0.1 auth.example.com +``` + +Then access: http://auth.example.com/ + +**To edit /etc/hosts on Linux/Mac:** +```bash +sudo nano /etc/hosts +# Add: 127.0.0.1 auth.example.com +``` + +**To edit hosts on Windows:** +1. Open Notepad as Administrator +2. Open `C:\Windows\System32\drivers\etc\hosts` +3. Add: `127.0.0.1 auth.example.com` + +### Option 2: Use Chrome's `--host-resolver-rules` (Chrome-specific, No System Changes) + +Chrome has a command-line flag that lets you map hostnames directly, bypassing DNS entirely. This is perfect for testing without modifying system settings. + +**To use it:** + +1. **Make sure services are running:** + ```bash + docker compose up -d + ``` + +2. **Launch Chrome with the host resolver rule:** + + **Linux:** + ```bash + google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1" + ``` + + **Mac:** + ```bash + /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --host-resolver-rules="MAP auth.example.com 127.0.0.1" + ``` + + **Windows:** + ```cmd + "C:\Program Files\Google\ Chrome\Application\chrome.exe" --host-resolver-rules="MAP auth.example.com 127.0.0.1" + ``` + +3. **Or modify Chrome's shortcut:** + - Right-click Chrome shortcut → Properties + - In "Target" field, append: ` --host-resolver-rules="MAP auth.example.com 127.0.0.1"` + - Click OK + +4. **Access:** http://auth.example.com/ + +**Note:** This only affects Chrome, not other applications. The DNS server on port 5353 isn't needed for this approach. + +### Option 3: Use System DNS (All Applications) + +If you want to use the DNS server on port 5353 for all applications (not just Chrome), configure your system DNS: + +**Linux (with systemd-resolved):** +```bash +# Configure systemd-resolved to use our DNS +sudo resolvectl dns lo 127.0.0.1:5353 +``` + +**Linux (without systemd-resolved):** +```bash +# Edit /etc/resolv.conf +sudo nano /etc/resolv.conf +# Add: nameserver 127.0.0.1 +# Note: This won't work with port 5353, you'd need port 53 +``` + +**Note:** Most systems expect DNS on port 53. To use port 5353, you'd need a DNS proxy or configure Chrome specifically (see Option 2 above). + +## Testing + +1. Start the services with `docker compose up --build -d` +2. Launch Chrome: `./launch-chrome-host.sh` (or use `--host-resolver-rules` manually) +3. Navigate to: http://auth.example.com/ +4. Login with `user` / `pass` +5. Test the OIDC flow by accessing the discovery endpoint: http://auth.example.com/api/.well-known/openid-configuration + +## Configuration + +The tinyauth configuration is in `config.yaml`: +- OIDC is enabled +- Single user: `user` with password `pass` +- OIDC client `testclient` is configured with redirect URI `http://localhost:8765/callback` +- App URL and OIDC issuer: `http://auth.example.com` (via nginx on port 80) + +## Notes + +- All containers are on a custom Docker network (`tinyauth-network`) with a DNS server for domain resolution +- The DNS server resolves `auth.example.com` to the tinyauth container within the network +- The redirect URI must match exactly what's configured in tinyauth +- Data is persisted in the `./data` directory +- The domain `auth.example.com` is used to satisfy cookie domain validation requirements (needs at least 3 domain parts and not in public suffix list) diff --git a/validation/config.yaml b/validation/config.yaml new file mode 100644 index 00000000..1973365f --- /dev/null +++ b/validation/config.yaml @@ -0,0 +1,36 @@ +appUrl: "http://auth.example.com" +logLevel: "info" +databasePath: "/data/tinyauth.db" + +auth: + users: "user:$2b$12$mWEdxub8KTTBLK/f7dloKOS4t3kIeLOpme5pMXci5.lXNPANjCT5u" # user:pass + secureCookie: false + sessionExpiry: 3600 + loginTimeout: 300 + loginMaxRetries: 3 + +oidc: + enabled: true + issuer: "http://auth.example.com" + accessTokenExpiry: 3600 + idTokenExpiry: 3600 + clients: + testclient: + clientSecret: "test-secret-123" + clientName: "OIDC Test Client" + redirectUris: + - "http://client.example.com/callback" + - "http://localhost:8765/callback" + - "http://127.0.0.1:8765/callback" + grantTypes: + - "authorization_code" + responseTypes: + - "code" + scopes: + - "openid" + - "profile" + - "email" + +ui: + title: "Tinyauth OIDC Test" + diff --git a/validation/docker-compose.yml b/validation/docker-compose.yml new file mode 100644 index 00000000..9d6d93d8 --- /dev/null +++ b/validation/docker-compose.yml @@ -0,0 +1,91 @@ +version: '3.8' + +services: + dns: + container_name: dns-server + image: strm/dnsmasq:latest + cap_add: + - NET_ADMIN + command: + - "--no-daemon" + - "--log-queries" + - "--no-resolv" + - "--server=8.8.8.8" + - "--server=8.8.4.4" + - "--address=/auth.example.com/172.28.0.2" + - "--address=/client.example.com/172.28.0.2" + # DNS port not exposed to host - only needed for container-to-container communication + # Chrome uses --host-resolver-rules instead + networks: + tinyauth-network: + ipv4_address: 172.28.0.10 + + nginx: + container_name: nginx-proxy + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - tinyauth-network + # Use Docker's built-in DNS (127.0.0.11) for service name resolution + # Our custom DNS (172.28.0.10) is only used via resolver directive in nginx.conf + depends_on: + - tinyauth + - dns + - oidc-whoami + + + tinyauth: + container_name: tinyauth-oidc-test + build: + context: .. + dockerfile: Dockerfile + command: ["--experimental.configfile=/config/config.yaml"] + # Port not exposed to host - accessed via nginx + volumes: + - ./data:/data + - ./config.yaml:/config/config.yaml:ro + networks: + tinyauth-network: + ipv4_address: 172.28.0.20 + depends_on: + - dns + healthcheck: + test: ["CMD", "tinyauth", "healthcheck"] + interval: 10s + timeout: 5s + retries: 3 + + oidc-whoami: + container_name: oidc-whoami-test + build: + context: . + dockerfile: Dockerfile + environment: + - OIDC_ISSUER=http://auth.example.com + - CLIENT_ID=testclient + - CLIENT_SECRET=test-secret-123 + # Port not exposed to host - accessed via nginx + depends_on: + - tinyauth + - dns + # Use Docker's built-in DNS first, then our custom DNS for custom domains + dns: + - 127.0.0.11 + - 172.28.0.10 + networks: + tinyauth-network: + ipv4_address: 172.28.0.30 + # Note: Using custom network with DNS server to resolve auth.example.test + # The redirect URI must match what's configured in tinyauth (http://localhost:8765/callback) + # Using auth.example.test domain to satisfy cookie domain validation requirements (needs 3+ parts, not in public suffix list) + +networks: + tinyauth-network: + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/16 + diff --git a/validation/launch-chrome-host.sh b/validation/launch-chrome-host.sh new file mode 100755 index 00000000..67113e3f --- /dev/null +++ b/validation/launch-chrome-host.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Launch Chrome from host (not in container) +# This script should be run on your host machine + +set -e + +echo "Launching Chrome for OIDC test setup..." + +# Detect Chrome +if command -v google-chrome &> /dev/null; then + CHROME_CMD="google-chrome" +elif command -v chromium-browser &> /dev/null; then + CHROME_CMD="chromium-browser" +elif command -v chromium &> /dev/null; then + CHROME_CMD="chromium" +elif [ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then + CHROME_CMD="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +else + echo "Error: Chrome not found. Please install Google Chrome or Chromium." + exit 1 +fi + +echo "Using: $CHROME_CMD" +echo "Opening: http://client.example.com/ (OIDC test client)" +echo "" + +$CHROME_CMD \ + --host-resolver-rules="MAP auth.example.com 127.0.0.1, MAP client.example.com 127.0.0.1" \ + --disable-features=HttpsOnlyMode \ + --unsafely-treat-insecure-origin-as-secure=http://auth.example.com,http://client.example.com \ + --user-data-dir=/tmp/chrome-test-profile-$(date +%s) \ + --new-window \ + http://client.example.com/ \ + > /dev/null 2>&1 & + +echo "Chrome launched!" +echo "OIDC test client: http://client.example.com/" +echo "Tinyauth: http://auth.example.com/" + diff --git a/validation/launch-chrome.sh b/validation/launch-chrome.sh new file mode 100755 index 00000000..421f606a --- /dev/null +++ b/validation/launch-chrome.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +echo "==========================================" +echo "Chrome Launcher for OIDC Test Setup" +echo "==========================================" + +# Wait for nginx to be ready +echo "Waiting for nginx to be ready..." +for i in {1..30}; do + if curl -s http://127.0.0.1/ > /dev/null 2>&1; then + echo "✓ Nginx is ready" + break + fi + if [ $i -eq 30 ]; then + echo "✗ Nginx not ready after 30 seconds" + exit 1 + fi + sleep 1 +done + +# Try to find Chrome on the host system +# Since we're in a container, we need to check common locations +CHROME_PATHS=( + "/usr/bin/google-chrome" + "/usr/bin/google-chrome-stable" + "/usr/bin/chromium-browser" + "/usr/bin/chromium" + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +) + +CHROME_CMD="" +for path in "${CHROME_PATHS[@]}"; do + if [ -f "$path" ] || command -v "$(basename "$path")" &> /dev/null; then + CHROME_CMD="$(basename "$path")" + break + fi +done + +if [ -z "$CHROME_CMD" ]; then + echo "" + echo "Chrome not found in container. This is expected." + echo "Please launch Chrome manually on your host with:" + echo "" + echo ' google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1" http://auth.example.com/' + echo "" + echo "Or use the launch script on your host:" + echo " ./launch-chrome.sh" + echo "" + exit 0 +fi + +echo "Found Chrome: $CHROME_CMD" +echo "Launching Chrome with host-resolver-rules..." +echo "" + +$CHROME_CMD \ + --host-resolver-rules="MAP auth.example.com 127.0.0.1" \ + --new-window \ + http://auth.example.com/ \ + > /dev/null 2>&1 & + +echo "✓ Chrome launched!" +echo "" +echo "Access tinyauth at: http://auth.example.com/" +echo "OIDC test client callback: http://127.0.0.1:8765/callback" +echo "" + diff --git a/validation/nginx.conf b/validation/nginx.conf new file mode 100644 index 00000000..1a2df33b --- /dev/null +++ b/validation/nginx.conf @@ -0,0 +1,43 @@ +events { + worker_connections 1024; +} + +http { + # Use Docker's built-in DNS (127.0.0.11) for service name resolution + # This allows nginx to resolve Docker service names like "tinyauth" and "oidc-whoami" + resolver 127.0.0.11 valid=10s; + resolver_timeout 5s; + + server { + listen 80; + server_name auth.example.com; + + location / { + # Use variable to enable dynamic resolution at request time + set $backend "tinyauth:3000"; + proxy_pass http://$backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + } + + server { + listen 80; + server_name client.example.com; + + location / { + # Use variable to enable dynamic resolution at request time + set $backend "oidc-whoami:8765"; + proxy_pass http://$backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + } +} + diff --git a/validation/oidc_whoami.py b/validation/oidc_whoami.py new file mode 100644 index 00000000..587e77f1 --- /dev/null +++ b/validation/oidc_whoami.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import webbrowser +import secrets +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +from http.cookies import SimpleCookie + +import requests +from authlib.integrations.requests_client import OAuth2Session +from authlib.oidc.core import CodeIDToken +from authlib.jose import jwt + +# ---- config via env ---- +ISSUER = os.environ["OIDC_ISSUER"] +CLIENT_ID = os.environ["CLIENT_ID"] +CLIENT_SECRET= os.environ.get("CLIENT_SECRET") # optional (public clients ok) +REDIRECT_URI = "http://client.example.com/callback" +SCOPE = "openid profile email" + +# ---- discovery ---- +# Retry discovery in case nginx isn't ready yet +discovery = None +for attempt in range(10): + try: + discovery = requests.get( + f"{ISSUER.rstrip('/')}/api/.well-known/openid-configuration", + timeout=5 + ).json() + break + except Exception as e: + if attempt < 9: + print(f"Discovery attempt {attempt + 1} failed: {e}, retrying...") + time.sleep(2) + else: + raise + +if discovery is None: + raise RuntimeError("Failed to fetch OIDC discovery document after 10 attempts") + +state = secrets.token_urlsafe(16) +nonce = secrets.token_urlsafe(16) + +client = OAuth2Session( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + scope=SCOPE, + redirect_uri=REDIRECT_URI, +) + +auth_result = client.create_authorization_url( + discovery["authorization_endpoint"], + state=state, + nonce=nonce, + code_challenge_method="S256", +) +auth_url = auth_result[0] +code_verifier = auth_result[1] if len(auth_result) > 1 else None + +# Cache JWKS for token validation +jwk_set_cache = None +jwk_set_cache_time = None + +def get_jwk_set(): + """Get JWKS with caching""" + global jwk_set_cache, jwk_set_cache_time + # Cache for 1 hour + if jwk_set_cache is None or (jwk_set_cache_time and time.time() - jwk_set_cache_time > 3600): + jwk_set_cache = requests.get(discovery["jwks_uri"]).json() + jwk_set_cache_time = time.time() + return jwk_set_cache + +def parse_cookies(cookie_header): + """Parse cookies from Cookie header""" + if not cookie_header: + return {} + cookie = SimpleCookie() + cookie.load(cookie_header) + return {k: v.value for k, v in cookie.items()} + +def validate_id_token(id_token): + """Validate and decode ID token""" + try: + jwk_set = get_jwk_set() + claims_options = { + "iss": {"essential": True, "value": discovery["issuer"]}, + "aud": {"essential": True, "value": CLIENT_ID}, + } + decoded = jwt.decode( + id_token, + key=jwk_set, + claims_options=claims_options + ) + decoded.validate() + return dict(decoded) + except Exception as e: + print(f"Token validation failed: {e}") + return None + +# ---- tiny callback server ---- +class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + # Handle root path - check if already logged in + if self.path == "/" or self.path == "": + cookies = parse_cookies(self.headers.get("Cookie")) + id_token = cookies.get("id_token") + + # Check if we have a valid token + if id_token: + claims = validate_id_token(id_token) + if claims and claims.get("exp", 0) > time.time(): + # Already logged in - show main page + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + html = f""" + + + + OIDC Test Client - Welcome + + + +
+

✅ Welcome back!

+ +
+

ID Token Claims:

+
{json.dumps(claims, indent=2)}
+ Logout +
+ + + """ + self.wfile.write(html.encode()) + return + + # Not logged in - show login page + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + html = f""" + + + OIDC Test Client + +

OIDC Test Client

+

Click the button below to start the OIDC flow:

+ Login with OIDC +
+

Authorization URL: {auth_url}

+ + + """ + self.wfile.write(html.encode()) + return + + # Handle logout + if self.path == "/logout": + self.send_response(302) + self.send_header("Location", "/") + self.send_header("Set-Cookie", "id_token=; Path=/; Max-Age=0") + self.end_headers() + return + + # Handle callback + if not self.path.startswith("/callback"): + self.send_error(404, "Not Found") + return + + qs = parse_qs(urlparse(self.path).query) + + if qs.get("state", [None])[0] != state: + self.send_error(400, "Invalid state") + return + + code = qs.get("code", [None])[0] + if not code: + self.send_error(400, "Missing code") + return + + token = client.fetch_token( + discovery["token_endpoint"], + code=code, + code_verifier=code_verifier, + ) + + # ---- ID token validation ---- + # Decode and validate the ID token using cached JWKS + jwk_set = get_jwk_set() + + # Decode the JWT - make nonce optional if not provided + claims_options = { + "iss": {"essential": True, "value": discovery["issuer"]}, + "aud": {"essential": True, "value": CLIENT_ID}, + } + if nonce: + claims_options["nonce"] = {"essential": True, "value": nonce} + + decoded = jwt.decode( + token["id_token"], + key=jwk_set, + claims_options=claims_options + ) + decoded.validate() + + # Convert JWTClaims to dict for display + id_token_claims = dict(decoded) + + # Store ID token in cookie (expires when token expires) + token_expiry = id_token_claims.get("exp", 0) - time.time() + max_age = max(0, int(token_expiry)) + + # Redirect to main page with cookie set + self.send_response(302) + self.send_header("Location", "/") + self.send_header("Set-Cookie", f"id_token={token['id_token']}; Path=/; Max-Age={max_age}; HttpOnly") + self.end_headers() + + print("\n" + "=" * 60) + print("✅ OIDC Authentication Successful!") + print("=" * 60) + print("\nID Token Claims:") + print(json.dumps(id_token_claims, indent=2)) + print("\n" + "=" * 60) + # Don't exit - keep server running for multiple test flows + +# ---- run ---- +print("=" * 60) +print("OIDC Test Client") +print("=" * 60) +print(f"\nAuthorization URL: {auth_url}") +print("\nTo test the OIDC flow:") +print("1. Open the authorization URL above in your browser") +print("2. Login with credentials: user / pass") +print("3. You will be redirected back to the callback") +print("4. The ID token claims will be displayed below") +print(f"\nWaiting for callback on {REDIRECT_URI}...") +print("=" * 60) + +# Try to open browser (may fail in Docker, that's OK) +try: + webbrowser.open(auth_url) +except Exception as e: + print(f"Could not open browser automatically: {e}") + print("Please open the authorization URL manually") + +HTTPServer(("0.0.0.0", 8765), CallbackHandler).serve_forever() From ef157ae9ba3a23ccd5c09a8a291c5f4ba016d81d Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:36:30 +0100 Subject: [PATCH 02/13] Fix critical security issue: verify JWT signature in access token validation The validateAccessToken method was only decoding the JWT payload without verifying the signature, allowing attackers to forge tokens. This fix: - Adds ValidateAccessToken method to OIDCService that properly verifies JWT signature using RSA public key - Validates issuer, expiration, and required claims - Updates controller to use the secure validation method - Removes insecure manual JWT parsing code --- internal/controller/oidc_controller.go | 45 +-------------------- internal/service/oidc_service.go | 54 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 74f98e9b..f72c617c 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -401,48 +401,7 @@ func (controller *OIDCController) getAccessToken(c *gin.Context) string { func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) { // Validate the JWT token using the OIDC service's public key - // This is a simplified validation - in production, you'd want to store - // access tokens and validate them properly, check token revocation, etc. - - // For now, we'll use a helper method in the OIDC service to validate tokens - // Since we don't have a direct method, we'll parse and validate manually - // In a production system, you'd want to add a ValidateAccessToken method to the service - - // Parse the JWT token - parts := strings.Split(accessToken, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid token format") - } - - // Decode payload - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("failed to decode token payload: %w", err) - } - - var claims map[string]interface{} - if err := json.Unmarshal(payload, &claims); err != nil { - return nil, fmt.Errorf("failed to unmarshal claims: %w", err) - } - - // Extract user info from claims - username, _ := claims["sub"].(string) - if username == "" { - return nil, fmt.Errorf("missing sub claim") - } - - // Extract email and name if available - email, _ := claims["email"].(string) - name, _ := claims["name"].(string) - - // Create user context - userContext := &config.UserContext{ - Username: username, - Email: email, - Name: name, - IsLoggedIn: true, - } - - return userContext, nil + // This properly verifies the signature, issuer, and expiration + return controller.oidc.ValidateAccessToken(accessToken) } diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index c456830f..f6b7ae51 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -281,6 +281,60 @@ func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, cl return accessToken, nil } +func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserContext, error) { + token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return oidc.publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse access token: %w", err) + } + + if !token.Valid { + return nil, errors.New("invalid access token") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("invalid token claims") + } + + // Verify issuer + iss, ok := claims["iss"].(string) + if !ok || iss != oidc.config.Issuer { + return nil, errors.New("invalid issuer") + } + + // Check expiration + exp, ok := claims["exp"].(float64) + if !ok || time.Now().Unix() > int64(exp) { + return nil, errors.New("access token expired") + } + + // Extract user info from claims + username, ok := claims["sub"].(string) + if !ok || username == "" { + return nil, errors.New("missing sub claim") + } + + // Extract email and name if available + email, _ := claims["email"].(string) + name, _ := claims["name"].(string) + + // Create user context + userContext := &config.UserContext{ + Username: username, + Email: email, + Name: name, + IsLoggedIn: true, + } + + return userContext, nil +} + func (oidc *OIDCService) GenerateIDToken(userContext *config.UserContext, clientID string, nonce string) (string, error) { expiry := oidc.config.IDTokenExpiry if expiry <= 0 { From dabb4398ada7c7433f6da0f5daec1fc77eb55ed2 Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:39:00 +0100 Subject: [PATCH 03/13] Implement PKCE (Proof Key for Code Exchange) support PKCE was advertised in the discovery document but not actually implemented. This commit adds full PKCE support: - Store code_challenge and code_challenge_method in authorization code JWT - Accept code_verifier parameter in token endpoint - Validate code_verifier against stored code_challenge - Support both S256 (SHA256) and plain code challenge methods - PKCE validation is required when code_challenge is present This prevents authorization code interception attacks by requiring the client to prove possession of the code_verifier that was used to generate the code_challenge. --- internal/controller/oidc_controller.go | 22 ++++-- internal/service/oidc_service.go | 98 +++++++++++++++++++------- 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index f72c617c..b4b5bfa1 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -2,7 +2,6 @@ package controller import ( "encoding/base64" - "encoding/json" "fmt" "net/http" "net/url" @@ -143,8 +142,8 @@ func (controller *OIDCController) authorizeHandler(c *gin.Context) { return } - // Generate authorization code - authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce) + // Generate authorization code (including PKCE challenge if provided) + authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce, codeChallenge, codeChallengeMethod) if err != nil { log.Error().Err(err).Msg("Failed to generate authorization code") controller.redirectError(c, redirectURI, state, "server_error", "Internal server error") @@ -223,14 +222,29 @@ func (controller *OIDCController) tokenHandler(c *gin.Context) { return } + // Get code_verifier for PKCE validation + codeVerifier := c.PostForm("code_verifier") + if codeVerifier == "" { + codeVerifier = c.Query("code_verifier") + } + // Validate authorization code - userContext, scopes, nonce, err := controller.oidc.ValidateAuthorizationCode(code, clientID, redirectURI) + userContext, scopes, nonce, codeChallenge, codeChallengeMethod, err := controller.oidc.ValidateAuthorizationCode(code, clientID, redirectURI) if err != nil { log.Error().Err(err).Msg("Failed to validate authorization code") controller.tokenError(c, "invalid_grant", "Invalid or expired authorization code") return } + // Validate PKCE if code challenge was provided + if codeChallenge != "" { + if err := controller.oidc.ValidatePKCE(codeChallenge, codeChallengeMethod, codeVerifier); err != nil { + log.Error().Err(err).Msg("PKCE validation failed") + controller.tokenError(c, "invalid_grant", "Invalid code_verifier") + return + } + } + // Generate tokens accessToken, err := controller.oidc.GenerateAccessToken(userContext, clientID, scopes) if err != nil { diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index f6b7ae51..7cc84dbe 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -3,6 +3,7 @@ package service import ( "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" @@ -154,7 +155,7 @@ func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes return validScopes, nil } -func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string) (string, error) { +func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) { code := uuid.New().String() // Store authorization code in a temporary structure @@ -171,22 +172,33 @@ func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserConte // For now, we'll encode it as a JWT for stateless operation claims := jwt.MapClaims{ - "code": code, - "username": userContext.Username, - "email": userContext.Email, - "name": userContext.Name, - "provider": userContext.Provider, - "client_id": clientID, + "code": code, + "username": userContext.Username, + "email": userContext.Email, + "name": userContext.Name, + "provider": userContext.Provider, + "client_id": clientID, "redirect_uri": redirectURI, - "scopes": scopes, - "exp": time.Now().Add(10 * time.Minute).Unix(), - "iat": time.Now().Unix(), + "scopes": scopes, + "exp": time.Now().Add(10 * time.Minute).Unix(), + "iat": time.Now().Unix(), } - + if nonce != "" { claims["nonce"] = nonce } + // Store PKCE challenge if provided + if codeChallenge != "" { + claims["code_challenge"] = codeChallenge + if codeChallengeMethod != "" { + claims["code_challenge_method"] = codeChallengeMethod + } else { + // Default to plain if method not specified + claims["code_challenge_method"] = "plain" + } + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) codeToken, err := token.SignedString(oidc.privateKey) if err != nil { @@ -197,7 +209,7 @@ func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserConte return codeToken, nil } -func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, error) { +func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, string, string, error) { token, err := jwt.Parse(codeToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -206,31 +218,31 @@ func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID st }) if err != nil { - return nil, nil, "", fmt.Errorf("failed to parse authorization code: %w", err) + return nil, nil, "", "", "", fmt.Errorf("failed to parse authorization code: %w", err) } if !token.Valid { - return nil, nil, "", errors.New("invalid authorization code") + return nil, nil, "", "", "", errors.New("invalid authorization code") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { - return nil, nil, "", errors.New("invalid token claims") + return nil, nil, "", "", "", errors.New("invalid token claims") } // Verify client_id and redirect_uri match if claims["client_id"] != clientID { - return nil, nil, "", errors.New("client_id mismatch") + return nil, nil, "", "", "", errors.New("client_id mismatch") } if claims["redirect_uri"] != redirectURI { - return nil, nil, "", errors.New("redirect_uri mismatch") + return nil, nil, "", "", "", errors.New("redirect_uri mismatch") } // Check expiration exp, ok := claims["exp"].(float64) if !ok || time.Now().Unix() > int64(exp) { - return nil, nil, "", errors.New("authorization code expired") + return nil, nil, "", "", "", errors.New("authorization code expired") } userContext := &config.UserContext{ @@ -251,8 +263,41 @@ func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID st } nonce := getStringClaim(claims, "nonce") + codeChallenge := getStringClaim(claims, "code_challenge") + codeChallengeMethod := getStringClaim(claims, "code_challenge_method") - return userContext, scopes, nonce, nil + return userContext, scopes, nonce, codeChallenge, codeChallengeMethod, nil +} + +func (oidc *OIDCService) ValidatePKCE(codeChallenge string, codeChallengeMethod string, codeVerifier string) error { + if codeChallenge == "" { + // PKCE not used, validation passes + return nil + } + + if codeVerifier == "" { + return errors.New("code_verifier required when code_challenge is present") + } + + switch codeChallengeMethod { + case "S256": + // Compute SHA256 hash of code_verifier + hash := sha256.Sum256([]byte(codeVerifier)) + // Base64URL encode (without padding) + computedChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + if computedChallenge != codeChallenge { + return errors.New("code_verifier does not match code_challenge") + } + case "plain": + // Direct comparison + if codeVerifier != codeChallenge { + return errors.New("code_verifier does not match code_challenge") + } + default: + return fmt.Errorf("unsupported code_challenge_method: %s", codeChallengeMethod) + } + + return nil } func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, clientID string, scopes []string) (string, error) { @@ -485,14 +530,14 @@ func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCCli err = oidc.config.Database.Where("client_id = ?", clientID).First(&existingClient).Error client := model.OIDCClient{ - ClientID: clientID, - ClientSecret: clientSecret, - ClientName: clientName, - RedirectURIs: string(redirectURIsJSON), - GrantTypes: string(grantTypesJSON), + ClientID: clientID, + ClientSecret: clientSecret, + ClientName: clientName, + RedirectURIs: string(redirectURIsJSON), + GrantTypes: string(grantTypesJSON), ResponseTypes: string(responseTypesJSON), - Scopes: string(scopesJSON), - UpdatedAt: now, + Scopes: string(scopesJSON), + UpdatedAt: now, } if errors.Is(err, gorm.ErrRecordNotFound) { @@ -556,4 +601,3 @@ func getStringClaim(claims jwt.MapClaims, key string) string { } return "" } - From f006ebe5e42bde04cba96c13f5b66a67d94ba3dd Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:40:01 +0100 Subject: [PATCH 04/13] Fix open redirect vulnerability in authorize endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OAuth 2.0 RFC 6749 §4.1.2.1, errors should NOT redirect to unvalidated redirect_uri values. This fix: - Returns JSON errors for failures before redirect_uri validation (missing parameters, invalid client) - Only redirects to redirect_uri after it has been validated against registered client URIs - Prevents open redirect attacks where malicious redirect_uri values could be used to redirect users to attacker-controlled sites --- internal/controller/oidc_controller.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index b4b5bfa1..11a3732f 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -82,21 +82,33 @@ func (controller *OIDCController) authorizeHandler(c *gin.Context) { codeChallengeMethod := c.Query("code_challenge_method") // Validate required parameters + // Return JSON error instead of redirecting since redirect_uri is not yet validated if clientID == "" || redirectURI == "" || responseType == "" { - controller.redirectError(c, redirectURI, state, "invalid_request", "Missing required parameters") + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Missing required parameters", + }) return } // Get client + // Return JSON error instead of redirecting since redirect_uri is not yet validated client, err := controller.oidc.GetClient(clientID) if err != nil { - controller.redirectError(c, redirectURI, state, "invalid_client", "Client not found") + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_client", + "error_description": "Client not found", + }) return } // Validate redirect URI + // After this point, redirect_uri is validated and we can safely redirect if !controller.oidc.ValidateRedirectURI(client, redirectURI) { - controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri") + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Invalid redirect_uri", + }) return } From 672914ceb7052d44e9854b23f4d65825b9b39db8 Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:40:55 +0100 Subject: [PATCH 05/13] Remove insecure query parameter fallback for client credentials The discovery document only advertises client_secret_basic and client_secret_post as supported authentication methods. Query parameters are insecure because they are: - Logged in access logs - Stored in browser history - Exposed in referrer headers This fix removes the query parameter fallback, ensuring client secrets are only accepted via: - Authorization header (client_secret_basic) - POST form body (client_secret_post) This aligns the implementation with the advertised capabilities and prevents client secret exposure through query strings. --- internal/controller/oidc_controller.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 11a3732f..2bddefbb 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -384,7 +384,7 @@ func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, e } func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) { - // Try Basic Auth first + // Try Basic Auth first (client_secret_basic) authHeader := c.GetHeader("Authorization") if strings.HasPrefix(authHeader, "Basic ") { encoded := strings.TrimPrefix(authHeader, "Basic ") @@ -397,20 +397,15 @@ func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, } } - // Try POST form parameters + // Try POST form parameters (client_secret_post) clientID := c.PostForm("client_id") clientSecret := c.PostForm("client_secret") if clientID != "" && clientSecret != "" { return clientID, clientSecret, nil } - // Try query parameters - clientID = c.Query("client_id") - clientSecret = c.Query("client_secret") - if clientID != "" && clientSecret != "" { - return clientID, clientSecret, nil - } - + // Do not accept credentials via query parameters as they are logged + // in access logs, browser history, and referrer headers return "", "", fmt.Errorf("client credentials not found") } From 5b5799ab62142a8d33fa40b9531919f7f2abd897 Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:46:03 +0100 Subject: [PATCH 06/13] Fix XSS vulnerability: Escape user claims in HTML output User claims from ID tokens (username, name, email) were directly interpolated into HTML without escaping, allowing XSS attacks if malicious content was present in claims. This fix: - Imports html module for escaping - Escapes all user-controlled data before rendering in HTML - Escapes JSON output in pre tags as well - Prevents execution of malicious scripts in browser --- validation/oidc_whoami.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/validation/oidc_whoami.py b/validation/oidc_whoami.py index 587e77f1..29aabb89 100644 --- a/validation/oidc_whoami.py +++ b/validation/oidc_whoami.py @@ -2,6 +2,7 @@ import os import sys import json +import html import webbrowser import secrets import time @@ -169,13 +170,13 @@ def do_GET(self):

✅ Welcome back!


ID Token Claims:

-
{json.dumps(claims, indent=2)}
+
{html.escape(json.dumps(claims, indent=2))}
Logout From cd068d16c290180b48edacc7b5e338d811b5bfb3 Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 12:52:53 +0100 Subject: [PATCH 07/13] Fix Python scoping issue: rename html variable to avoid conflict The variable 'html' was being assigned to store HTML content, which caused Python to treat 'html' as a local variable throughout the function. This prevented access to the 'html' module (imported at the top) within f-strings that referenced html.escape(). Renamed the HTML content variable to 'html_content' to avoid the naming conflict with the html module. --- .../migrations/000005_oidc_keys.down.sql | 2 + .../assets/migrations/000005_oidc_keys.up.sql | 7 ++ internal/model/oidc_key_model.go | 13 ++++ internal/service/oidc_service.go | 65 ++++++++++++++++++- validation/oidc_whoami.py | 8 +-- 5 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 internal/assets/migrations/000005_oidc_keys.down.sql create mode 100644 internal/assets/migrations/000005_oidc_keys.up.sql create mode 100644 internal/model/oidc_key_model.go diff --git a/internal/assets/migrations/000005_oidc_keys.down.sql b/internal/assets/migrations/000005_oidc_keys.down.sql new file mode 100644 index 00000000..8cf8f30c --- /dev/null +++ b/internal/assets/migrations/000005_oidc_keys.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS "oidc_keys"; + diff --git a/internal/assets/migrations/000005_oidc_keys.up.sql b/internal/assets/migrations/000005_oidc_keys.up.sql new file mode 100644 index 00000000..9d6cea11 --- /dev/null +++ b/internal/assets/migrations/000005_oidc_keys.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "oidc_keys" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "private_key" TEXT NOT NULL, + "created_at" INTEGER NOT NULL, + "updated_at" INTEGER NOT NULL +); + diff --git a/internal/model/oidc_key_model.go b/internal/model/oidc_key_model.go new file mode 100644 index 00000000..e7ba0051 --- /dev/null +++ b/internal/model/oidc_key_model.go @@ -0,0 +1,13 @@ +package model + +type OIDCKey struct { + ID int `gorm:"column:id;primaryKey;autoIncrement"` + PrivateKey string `gorm:"column:private_key;not null"` + CreatedAt int64 `gorm:"column:created_at"` + UpdatedAt int64 `gorm:"column:updated_at"` +} + +func (OIDCKey) TableName() string { + return "oidc_keys" +} + diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 7cc84dbe..db7e4ae9 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -44,16 +44,75 @@ func NewOIDCService(config OIDCServiceConfig) *OIDCService { } func (oidc *OIDCService) Init() error { - // Generate RSA key pair for signing tokens - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + // Try to load existing key from database + var keyRecord model.OIDCKey + err := oidc.config.Database.First(&keyRecord).Error + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to query for existing RSA key: %w", err) + } + + var privateKey *rsa.PrivateKey + + if err == nil && keyRecord.PrivateKey != "" { + // Load existing key + block, _ := pem.Decode([]byte(keyRecord.PrivateKey)) + if block == nil { + return fmt.Errorf("failed to decode PEM block from stored key") + } + + parsedKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try PKCS8 format as fallback + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse stored private key: %w", err) + } + var ok bool + privateKey, ok = key.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("stored key is not an RSA private key") + } + } else { + privateKey = parsedKey + } + + oidc.privateKey = privateKey + oidc.publicKey = &privateKey.PublicKey + + log.Info().Msg("OIDC service initialized with existing RSA key pair from database") + return nil + } + + // No existing key found, generate new one + privateKey, err = rsa.GenerateKey(rand.Reader, 2048) if err != nil { return fmt.Errorf("failed to generate RSA key: %w", err) } + // Encode private key to PEM format + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + // Save to database + now := time.Now().Unix() + keyRecord = model.OIDCKey{ + PrivateKey: string(privateKeyPEM), + CreatedAt: now, + UpdatedAt: now, + } + + if err := oidc.config.Database.Create(&keyRecord).Error; err != nil { + return fmt.Errorf("failed to save RSA key to database: %w", err) + } + oidc.privateKey = privateKey oidc.publicKey = &privateKey.PublicKey - log.Info().Msg("OIDC service initialized with new RSA key pair") + log.Info().Msg("OIDC service initialized with new RSA key pair (saved to database)") return nil } diff --git a/validation/oidc_whoami.py b/validation/oidc_whoami.py index 29aabb89..d2313dc6 100644 --- a/validation/oidc_whoami.py +++ b/validation/oidc_whoami.py @@ -117,7 +117,7 @@ def do_GET(self): self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() - html = f""" + html_content = f""" @@ -182,14 +182,14 @@ def do_GET(self): """ - self.wfile.write(html.encode()) + self.wfile.write(html_content.encode()) return # Not logged in - show login page self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() - html = f""" + html_content = f""" OIDC Test Client @@ -202,7 +202,7 @@ def do_GET(self): """ - self.wfile.write(html.encode()) + self.wfile.write(html_content.encode()) return # Handle logout From 1b37096b58b2c0c3280f482842d920787b3d8115 Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 13:00:19 +0100 Subject: [PATCH 08/13] CRITICAL: Add replay protection for authorization codes Authorization codes were implemented as stateless JWTs with no tracking, allowing the same code to be exchanged for tokens multiple times. This violates OAuth 2.0 RFC 6749 Section 4.1.2 which mandates that authorization codes MUST be single-use. This change: - Adds oidc_authorization_codes table to track code usage - Stores authorization codes in database when generated - Validates code exists and hasn't been used before exchange - Marks code as used immediately after validation - Prevents replay attacks where intercepted codes could be reused Security impact: - Prevents attackers from reusing intercepted authorization codes - Ensures compliance with OAuth 2.0 security requirements - Adds database-backed single-use enforcement --- .../000006_oidc_authorization_codes.down.sql | 3 + .../000006_oidc_authorization_codes.up.sql | 11 +++ .../model/oidc_authorization_code_model.go | 15 ++++ internal/service/oidc_service.go | 78 ++++++++++++++----- 4 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 internal/assets/migrations/000006_oidc_authorization_codes.down.sql create mode 100644 internal/assets/migrations/000006_oidc_authorization_codes.up.sql create mode 100644 internal/model/oidc_authorization_code_model.go diff --git a/internal/assets/migrations/000006_oidc_authorization_codes.down.sql b/internal/assets/migrations/000006_oidc_authorization_codes.down.sql new file mode 100644 index 00000000..b140140c --- /dev/null +++ b/internal/assets/migrations/000006_oidc_authorization_codes.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS "idx_oidc_auth_codes_expires_at"; +DROP TABLE IF EXISTS "oidc_authorization_codes"; + diff --git a/internal/assets/migrations/000006_oidc_authorization_codes.up.sql b/internal/assets/migrations/000006_oidc_authorization_codes.up.sql new file mode 100644 index 00000000..b14ad0ce --- /dev/null +++ b/internal/assets/migrations/000006_oidc_authorization_codes.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS "oidc_authorization_codes" ( + "code" TEXT NOT NULL PRIMARY KEY, + "client_id" TEXT NOT NULL, + "redirect_uri" TEXT NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT 0, + "expires_at" INTEGER NOT NULL, + "created_at" INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS "idx_oidc_auth_codes_expires_at" ON "oidc_authorization_codes"("expires_at"); + diff --git a/internal/model/oidc_authorization_code_model.go b/internal/model/oidc_authorization_code_model.go new file mode 100644 index 00000000..c2b13bbc --- /dev/null +++ b/internal/model/oidc_authorization_code_model.go @@ -0,0 +1,15 @@ +package model + +type OIDCAuthorizationCode struct { + Code string `gorm:"column:code;primaryKey"` + ClientID string `gorm:"column:client_id;not null"` + RedirectURI string `gorm:"column:redirect_uri;not null"` + Used bool `gorm:"column:used;default:false"` + ExpiresAt int64 `gorm:"column:expires_at;not null"` + CreatedAt int64 `gorm:"column:created_at;not null"` +} + +func (OIDCAuthorizationCode) TableName() string { + return "oidc_authorization_codes" +} + diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index db7e4ae9..7ce27643 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -47,7 +47,7 @@ func (oidc *OIDCService) Init() error { // Try to load existing key from database var keyRecord model.OIDCKey err := oidc.config.Database.First(&keyRecord).Error - + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("failed to query for existing RSA key: %w", err) } @@ -216,20 +216,24 @@ func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) { code := uuid.New().String() + now := time.Now() + expiresAt := now.Add(10 * time.Minute).Unix() + + // Store authorization code in database for replay protection + authCodeRecord := model.OIDCAuthorizationCode{ + Code: code, + ClientID: clientID, + RedirectURI: redirectURI, + Used: false, + ExpiresAt: expiresAt, + CreatedAt: now.Unix(), + } - // Store authorization code in a temporary structure - // In a production system, you'd want to store this in a database with expiry - authCode := map[string]interface{}{ - "code": code, - "userContext": userContext, - "clientID": clientID, - "redirectURI": redirectURI, - "scopes": scopes, - "nonce": nonce, - "expiresAt": time.Now().Add(10 * time.Minute).Unix(), + if err := oidc.config.Database.Create(&authCodeRecord).Error; err != nil { + return "", fmt.Errorf("failed to store authorization code: %w", err) } - // For now, we'll encode it as a JWT for stateless operation + // Encode as JWT for stateless operation (but code is tracked in DB) claims := jwt.MapClaims{ "code": code, "username": userContext.Username, @@ -239,8 +243,8 @@ func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserConte "client_id": clientID, "redirect_uri": redirectURI, "scopes": scopes, - "exp": time.Now().Add(10 * time.Minute).Unix(), - "iat": time.Now().Unix(), + "exp": expiresAt, + "iat": now.Unix(), } if nonce != "" { @@ -261,10 +265,11 @@ func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserConte token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) codeToken, err := token.SignedString(oidc.privateKey) if err != nil { + // Clean up the database record if JWT signing fails + oidc.config.Database.Delete(&authCodeRecord) return "", fmt.Errorf("failed to sign authorization code: %w", err) } - _ = authCode // Suppress unused variable warning return codeToken, nil } @@ -289,6 +294,32 @@ func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID st return nil, nil, "", "", "", errors.New("invalid token claims") } + // Extract code from JWT for database lookup + code, ok := claims["code"].(string) + if !ok || code == "" { + return nil, nil, "", "", "", errors.New("missing code in authorization code token") + } + + // Check database for replay protection - verify code exists and hasn't been used + var authCodeRecord model.OIDCAuthorizationCode + err = oidc.config.Database.Where("code = ?", code).First(&authCodeRecord).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, "", "", "", errors.New("authorization code not found") + } + return nil, nil, "", "", "", fmt.Errorf("failed to query authorization code: %w", err) + } + + // Check if code has already been used (replay attack protection) + if authCodeRecord.Used { + return nil, nil, "", "", "", errors.New("authorization code has already been used") + } + + // Check expiration + if time.Now().Unix() > authCodeRecord.ExpiresAt { + return nil, nil, "", "", "", errors.New("authorization code expired") + } + // Verify client_id and redirect_uri match if claims["client_id"] != clientID { return nil, nil, "", "", "", errors.New("client_id mismatch") @@ -298,10 +329,19 @@ func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID st return nil, nil, "", "", "", errors.New("redirect_uri mismatch") } - // Check expiration - exp, ok := claims["exp"].(float64) - if !ok || time.Now().Unix() > int64(exp) { - return nil, nil, "", "", "", errors.New("authorization code expired") + // Verify database record matches request parameters + if authCodeRecord.ClientID != clientID { + return nil, nil, "", "", "", errors.New("client_id mismatch") + } + + if authCodeRecord.RedirectURI != redirectURI { + return nil, nil, "", "", "", errors.New("redirect_uri mismatch") + } + + // Mark code as used to prevent replay attacks + authCodeRecord.Used = true + if err := oidc.config.Database.Save(&authCodeRecord).Error; err != nil { + return nil, nil, "", "", "", fmt.Errorf("failed to mark authorization code as used: %w", err) } userContext := &config.UserContext{ From ca74534048935c73d50e612b17bd72c58674cd6b Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 13:26:06 +0100 Subject: [PATCH 09/13] Add bcrypt hashing for client secrets and RSA key encryption Security improvements: 1. Client secret hashing: - Replace plaintext comparison with bcrypt.CompareHashAndPassword - Provides constant-time comparison to prevent timing attacks - Hash secrets with bcrypt before storing in database - Update SyncClientsFromConfig to hash incoming plaintext secrets 2. Deterministic RSA key loading: - Load most recently created key using ORDER BY created_at DESC - Add warning if multiple keys detected in database - Ensures consistent key selection on startup 3. Optional RSA key encryption: - Encrypt private keys with AES-256-GCM when OIDC_RSA_MASTER_KEY is set - Master key derived via SHA256 from environment variable - Backward compatible: stores plaintext if no master key set - Automatic detection of encrypted vs plaintext on load All changes maintain backward compatibility with existing deployments. --- internal/service/oidc_service.go | 146 +++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 18 deletions(-) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 7ce27643..288a35ca 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -1,6 +1,8 @@ package service import ( + "crypto/aes" + "crypto/cipher" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -10,6 +12,7 @@ import ( "encoding/pem" "errors" "fmt" + "os" "strings" "time" @@ -20,6 +23,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -35,6 +39,7 @@ type OIDCService struct { config OIDCServiceConfig privateKey *rsa.PrivateKey publicKey *rsa.PublicKey + masterKey []byte // Master key for encrypting private keys (optional) } func NewOIDCService(config OIDCServiceConfig) *OIDCService { @@ -43,10 +48,102 @@ func NewOIDCService(config OIDCServiceConfig) *OIDCService { } } +// encryptPrivateKey encrypts a private key PEM string using AES-GCM +func (oidc *OIDCService) encryptPrivateKey(plaintext string) (string, error) { + if len(oidc.masterKey) == 0 { + // No encryption key set, return plaintext + return plaintext, nil + } + + // Derive AES-256 key from master key using SHA256 + key := sha256.Sum256(oidc.masterKey) + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + // Encode as base64 for storage + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decryptPrivateKey decrypts an encrypted private key PEM string +func (oidc *OIDCService) decryptPrivateKey(encrypted string) (string, error) { + if len(oidc.masterKey) == 0 { + // No encryption key set, assume plaintext + return encrypted, nil + } + + // Try to decode as base64 (encrypted) first + ciphertext, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + // Not base64, assume it's plaintext (backward compatibility) + return encrypted, nil + } + + // Derive AES-256 key from master key using SHA256 + key := sha256.Sum256(oidc.masterKey) + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + // Too short to be encrypted, assume plaintext + return encrypted, nil + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt private key: %w", err) + } + + return string(plaintext), nil +} + func (oidc *OIDCService) Init() error { - // Try to load existing key from database + // Load master key from environment (optional) + masterKeyEnv := os.Getenv("OIDC_RSA_MASTER_KEY") + if masterKeyEnv != "" { + oidc.masterKey = []byte(masterKeyEnv) + if len(oidc.masterKey) < 32 { + log.Warn().Msg("OIDC_RSA_MASTER_KEY is shorter than 32 bytes, consider using a longer key for better security") + } + log.Info().Msg("RSA private key encryption enabled (using OIDC_RSA_MASTER_KEY)") + } else { + log.Info().Msg("RSA private key encryption disabled (OIDC_RSA_MASTER_KEY not set)") + } + // Check if multiple keys exist (for warning) + var keyCount int64 + if err := oidc.config.Database.Model(&model.OIDCKey{}).Count(&keyCount).Error; err != nil { + return fmt.Errorf("failed to count RSA keys: %w", err) + } + if keyCount > 1 { + log.Warn().Int64("count", keyCount).Msg("Multiple RSA keys detected in database, loading most recently created key. Consider cleaning up older keys.") + } + + // Try to load existing key from database (most recently created) var keyRecord model.OIDCKey - err := oidc.config.Database.First(&keyRecord).Error + err := oidc.config.Database.Order("created_at DESC").First(&keyRecord).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("failed to query for existing RSA key: %w", err) @@ -55,8 +152,14 @@ func (oidc *OIDCService) Init() error { var privateKey *rsa.PrivateKey if err == nil && keyRecord.PrivateKey != "" { + // Decrypt private key if encrypted + privateKeyPEM, err := oidc.decryptPrivateKey(keyRecord.PrivateKey) + if err != nil { + return fmt.Errorf("failed to decrypt private key: %w", err) + } + // Load existing key - block, _ := pem.Decode([]byte(keyRecord.PrivateKey)) + block, _ := pem.Decode([]byte(privateKeyPEM)) if block == nil { return fmt.Errorf("failed to decode PEM block from stored key") } @@ -97,10 +200,16 @@ func (oidc *OIDCService) Init() error { Bytes: privateKeyBytes, }) + // Encrypt private key before storing + encryptedPrivateKey, err := oidc.encryptPrivateKey(string(privateKeyPEM)) + if err != nil { + return fmt.Errorf("failed to encrypt private key: %w", err) + } + // Save to database now := time.Now().Unix() keyRecord = model.OIDCKey{ - PrivateKey: string(privateKeyPEM), + PrivateKey: encryptedPrivateKey, CreatedAt: now, UpdatedAt: now, } @@ -129,7 +238,13 @@ func (oidc *OIDCService) GetClient(clientID string) (*model.OIDCClient, error) { } func (oidc *OIDCService) VerifyClientSecret(client *model.OIDCClient, secret string) bool { - return client.ClientSecret == secret + // Use bcrypt for constant-time comparison to prevent timing attacks + err := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(secret)) + if err != nil { + log.Debug().Err(err).Str("client_id", client.ClientID).Msg("Client secret verification failed") + return false + } + return true } func (oidc *OIDCService) ValidateRedirectURI(client *model.OIDCClient, redirectURI string) bool { @@ -512,16 +627,6 @@ func (oidc *OIDCService) GenerateIDToken(userContext *config.UserContext, client } func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) { - pubKeyBytes, err := x509.MarshalPKIXPublicKey(oidc.publicKey) - if err != nil { - return nil, fmt.Errorf("failed to marshal public key: %w", err) - } - - pubKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: pubKeyBytes, - }) - // Extract modulus and exponent from public key n := oidc.publicKey.N e := oidc.publicKey.E @@ -542,8 +647,6 @@ func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) { "alg": "RS256", } - _ = pubKeyPEM // Suppress unused variable warning - return map[string]interface{}{ "keys": []interface{}{jwk}, }, nil @@ -622,6 +725,13 @@ func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCCli continue } + // Hash client secret with bcrypt before storing + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost) + if err != nil { + log.Error().Err(err).Str("client_id", clientID).Msg("Failed to hash client secret") + continue + } + now := time.Now().Unix() // Check if client exists @@ -630,7 +740,7 @@ func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCCli client := model.OIDCClient{ ClientID: clientID, - ClientSecret: clientSecret, + ClientSecret: string(hashedSecret), ClientName: clientName, RedirectURIs: string(redirectURIsJSON), GrantTypes: string(grantTypesJSON), From ad12110fbf4cf2b05ba667bcd742e4403ae2881b Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 13:37:43 +0100 Subject: [PATCH 10/13] Replace SHA256 with HKDF for key derivation and fix scope validation Security improvements: 1. HKDF key derivation: - Replace raw sha256.Sum256() with proper HKDF (HMAC-based KDF) - Uses domain-separated label 'oidc-aes-256-key-v1' for key derivation - Applied to both encryptPrivateKey and decryptPrivateKey - Provides better security properties than raw hash 2. Scope validation fix: - Only add 'openid' scope if it's both requested AND in client's allowedScopes - Prevents bypassing client scope restrictions - Respects configured allowedScopes Both changes improve security posture while maintaining backward compatibility. --- internal/service/oidc_service.go | 45 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 288a35ca..7ee8ccd4 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -12,6 +12,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "os" "strings" "time" @@ -24,6 +25,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/hkdf" "gorm.io/gorm" ) @@ -55,10 +57,14 @@ func (oidc *OIDCService) encryptPrivateKey(plaintext string) (string, error) { return plaintext, nil } - // Derive AES-256 key from master key using SHA256 - key := sha256.Sum256(oidc.masterKey) + // Derive AES-256 key from master key using HKDF + hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1")) + key := make([]byte, 32) // AES-256 requires 32 bytes + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return "", fmt.Errorf("failed to derive encryption key: %w", err) + } - block, err := aes.NewCipher(key[:]) + block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("failed to create cipher: %w", err) } @@ -92,10 +98,14 @@ func (oidc *OIDCService) decryptPrivateKey(encrypted string) (string, error) { return encrypted, nil } - // Derive AES-256 key from master key using SHA256 - key := sha256.Sum256(oidc.masterKey) + // Derive AES-256 key from master key using HKDF + hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1")) + key := make([]byte, 32) // AES-256 requires 32 bytes + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return "", fmt.Errorf("failed to derive decryption key: %w", err) + } - block, err := aes.NewCipher(key[:]) + block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("failed to create cipher: %w", err) } @@ -313,17 +323,20 @@ func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes } } - // Always include "openid" if it was requested - hasOpenID := false - for _, scope := range validScopes { - if scope == "openid" { - hasOpenID = true - break + // Only include "openid" if it was requested AND it's in the client's allowed scopes + // This respects client scope restrictions and doesn't bypass allowedScopes + if contains(requestedScopesList, "openid") && contains(allowedScopes, "openid") { + // Check if "openid" is already in validScopes (added by the loop above) + hasOpenID := false + for _, scope := range validScopes { + if scope == "openid" { + hasOpenID = true + break + } + } + if !hasOpenID { + validScopes = append(validScopes, "openid") } - } - - if !hasOpenID && contains(requestedScopesList, "openid") { - validScopes = append(validScopes, "openid") } return validScopes, nil From 5ec9989189ddfb8274d0a96d719081e3f61df715 Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 13:52:01 +0100 Subject: [PATCH 11/13] Remove redundant 'openid' scope special case logic The special case for adding 'openid' scope was redundant and could potentially bypass client scope restrictions. The main loop already correctly adds 'openid' to validScopes if it's in both requestedScopes and allowedScopes. Since 'openid' is already in the default scopes during client configuration (SyncClientsFromConfig), it will be available for clients that don't explicitly configure scopes. Clients can include or exclude 'openid' in their allowedScopes as needed. This ensures consistent enforcement of client scope restrictions with no special-case bypasses. --- internal/service/oidc_service.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 7ee8ccd4..87bb3059 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -323,22 +323,6 @@ func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes } } - // Only include "openid" if it was requested AND it's in the client's allowed scopes - // This respects client scope restrictions and doesn't bypass allowedScopes - if contains(requestedScopesList, "openid") && contains(allowedScopes, "openid") { - // Check if "openid" is already in validScopes (added by the loop above) - hasOpenID := false - for _, scope := range validScopes { - if scope == "openid" { - hasOpenID = true - break - } - } - if !hasOpenID { - validScopes = append(validScopes, "openid") - } - } - return validScopes, nil } From 014550f80ec95596664b2285578815f92e45a45d Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 14:10:50 +0100 Subject: [PATCH 12/13] CRITICAL: Add audience validation for access tokens Access tokens include an 'aud' (audience) claim set to the client ID, but this was never validated during token validation. This allowed tokens issued for one client to be used by another client, violating the OAuth 2.0 security model. Changes: - Add ValidateAccessTokenForClient method that validates audience if expectedClientID is provided - Update ValidateAccessToken to call ValidateAccessTokenForClient (backward compatible, no audience check if not specified) - Update userinfo endpoint to accept optional client_id parameter and validate token audience matches it Security impact: - Prevents token reuse across different clients - Ensures tokens are scoped to specific clients as intended - Prevents attackers from using tokens issued for one client to access resources protected by another client --- internal/controller/oidc_controller.go | 71 ++++++++++++++++++++++++-- internal/service/oidc_service.go | 23 +++++++-- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 2bddefbb..6bb8615f 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -15,11 +15,15 @@ import ( "github.com/rs/zerolog/log" ) +// OIDCControllerConfig holds configuration for the OIDC controller. type OIDCControllerConfig struct { - AppURL string - CookieDomain string + AppURL string // Base URL of the application + CookieDomain string // Domain for setting cookies } +// OIDCController handles OpenID Connect (OIDC) protocol endpoints. +// It implements the OIDC provider functionality including discovery, authorization, +// token exchange, userinfo, and JWKS endpoints. type OIDCController struct { config OIDCControllerConfig router *gin.RouterGroup @@ -27,6 +31,7 @@ type OIDCController struct { auth *service.AuthService } +// NewOIDCController creates a new OIDC controller with the given configuration and services. func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController { return &OIDCController{ config: config, @@ -36,6 +41,13 @@ func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oid } } +// SetupRoutes registers all OIDC endpoints with the router. +// This includes: +// - /.well-known/openid-configuration - OIDC discovery endpoint +// - /oidc/authorize - Authorization endpoint +// - /oidc/token - Token endpoint +// - /oidc/userinfo - UserInfo endpoint +// - /oidc/jwks - JSON Web Key Set endpoint func (controller *OIDCController) SetupRoutes() { // Well-known discovery endpoint controller.router.GET("/.well-known/openid-configuration", controller.discoveryHandler) @@ -48,6 +60,10 @@ func (controller *OIDCController) SetupRoutes() { oidcGroup.GET("/jwks", controller.jwksHandler) } +// discoveryHandler handles the OIDC discovery endpoint. +// Returns the OpenID Connect discovery document as specified in RFC 8414. +// The document contains metadata about the OIDC provider including endpoints, +// supported features, and cryptographic capabilities. func (controller *OIDCController) discoveryHandler(c *gin.Context) { issuer := controller.oidc.GetIssuer() baseURL := strings.TrimSuffix(controller.config.AppURL, "/") @@ -70,6 +86,14 @@ func (controller *OIDCController) discoveryHandler(c *gin.Context) { c.JSON(http.StatusOK, discovery) } +// authorizeHandler handles the OIDC authorization endpoint. +// Implements the authorization code flow as specified in OAuth 2.0 RFC 6749. +// Validates client credentials, redirect URI, scopes, and response type. +// Supports PKCE (RFC 7636) for enhanced security. +// If the user is not authenticated, redirects to the login page with the +// authorization request parameters preserved for redirect after login. +// On success, generates an authorization code and redirects to the client's +// redirect URI with the code and state parameter. func (controller *OIDCController) authorizeHandler(c *gin.Context) { // Get query parameters clientID := c.Query("client_id") @@ -179,6 +203,11 @@ func (controller *OIDCController) authorizeHandler(c *gin.Context) { c.Redirect(http.StatusFound, redirectURL.String()) } +// tokenHandler handles the OIDC token endpoint. +// Exchanges an authorization code for access and ID tokens. +// Validates the authorization code, client credentials, redirect URI, and PKCE verifier. +// Returns an access token and optionally an ID token (if openid scope is present). +// Implements the authorization code grant type as specified in OAuth 2.0 RFC 6749. func (controller *OIDCController) tokenHandler(c *gin.Context) { // Get grant type grantType := c.PostForm("grant_type") @@ -299,6 +328,10 @@ func (controller *OIDCController) tokenHandler(c *gin.Context) { c.JSON(http.StatusOK, response) } +// userinfoHandler handles the OIDC UserInfo endpoint. +// Returns user information claims for the authenticated user based on the +// provided access token. Validates the access token signature, issuer, and expiration. +// Returns standard OIDC claims: sub, email, name, and preferred_username. func (controller *OIDCController) userinfoHandler(c *gin.Context) { // Get access token from Authorization header or query parameter accessToken := controller.getAccessToken(c) @@ -310,8 +343,14 @@ func (controller *OIDCController) userinfoHandler(c *gin.Context) { return } - // Validate and parse access token - userContext, err := controller.validateAccessToken(accessToken) + // Get optional client_id from request for audience validation + clientID := c.Query("client_id") + if clientID == "" { + clientID = c.PostForm("client_id") + } + + // Validate and parse access token with audience validation + userContext, err := controller.oidc.ValidateAccessTokenForClient(accessToken, clientID) if err != nil { log.Error().Err(err).Msg("Failed to validate access token") c.JSON(http.StatusUnauthorized, gin.H{ @@ -332,6 +371,9 @@ func (controller *OIDCController) userinfoHandler(c *gin.Context) { c.JSON(http.StatusOK, userInfo) } +// jwksHandler handles the JSON Web Key Set (JWKS) endpoint. +// Returns the public keys used to verify ID tokens and access tokens. +// The keys are in JWK format as specified in RFC 7517. func (controller *OIDCController) jwksHandler(c *gin.Context) { jwks, err := controller.oidc.GetJWKS() if err != nil { @@ -347,6 +389,9 @@ func (controller *OIDCController) jwksHandler(c *gin.Context) { // Helper functions +// redirectError redirects the user to the redirect URI with an error response. +// Includes the error code, error description, and state parameter (if provided). +// If the redirect URI is invalid or empty, returns a JSON error response instead. func (controller *OIDCController) redirectError(c *gin.Context, redirectURI string, state string, errorCode string, errorDescription string) { if redirectURI == "" { c.JSON(http.StatusBadRequest, gin.H{ @@ -376,6 +421,8 @@ func (controller *OIDCController) redirectError(c *gin.Context, redirectURI stri c.Redirect(http.StatusFound, redirectURL.String()) } +// tokenError returns a JSON error response for token endpoint errors. +// Uses the standard OAuth 2.0 error format with error and error_description fields. func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) { c.JSON(http.StatusBadRequest, gin.H{ "error": errorCode, @@ -383,6 +430,12 @@ func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, e }) } +// getClientCredentials extracts client credentials from the request. +// Supports client_secret_basic (HTTP Basic Authentication) and +// client_secret_post (POST form parameters) as specified in the discovery document. +// Does not accept credentials via query parameters for security reasons +// (they may be logged in access logs, browser history, or referrer headers). +// Returns the client ID, client secret, and an error if credentials are not found. func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) { // Try Basic Auth first (client_secret_basic) authHeader := c.GetHeader("Authorization") @@ -409,6 +462,10 @@ func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, return "", "", fmt.Errorf("client credentials not found") } +// getAccessToken extracts the access token from the request. +// Checks the Authorization header (Bearer token) first, then falls back to +// the access_token query parameter. +// Returns an empty string if no access token is found. func (controller *OIDCController) getAccessToken(c *gin.Context) string { // Try Authorization header authHeader := c.GetHeader("Authorization") @@ -420,9 +477,13 @@ func (controller *OIDCController) getAccessToken(c *gin.Context) string { return c.Query("access_token") } +// validateAccessToken validates an access token and extracts user context. +// Verifies the JWT signature using the OIDC service's public key, checks the +// issuer, and validates expiration. Returns the user context if valid, or an +// error if validation fails. func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) { // Validate the JWT token using the OIDC service's public key // This properly verifies the signature, issuer, and expiration + // Note: This method does not validate audience - use ValidateAccessTokenForClient for that return controller.oidc.ValidateAccessToken(accessToken) } - diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 87bb3059..1ff1e993 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "math/big" "os" "strings" "time" @@ -538,6 +539,13 @@ func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, cl } func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserContext, error) { + return oidc.ValidateAccessTokenForClient(accessToken, "") +} + +// ValidateAccessTokenForClient validates an access token and optionally checks the audience claim. +// If expectedClientID is provided, validates that the token's audience matches the expected client ID. +// This prevents tokens issued for one client from being used by another client. +func (oidc *OIDCService) ValidateAccessTokenForClient(accessToken string, expectedClientID string) (*config.UserContext, error) { token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -564,6 +572,14 @@ func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserCo return nil, errors.New("invalid issuer") } + // Verify audience if expected client ID is provided + if expectedClientID != "" { + aud, ok := claims["aud"].(string) + if !ok || aud != expectedClientID { + return nil, errors.New("invalid audience") + } + } + // Check expiration exp, ok := claims["exp"].(float64) if !ok || time.Now().Unix() > int64(exp) { @@ -629,11 +645,8 @@ func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) { e := oidc.publicKey.E nBytes := n.Bytes() - eBytes := make([]byte, 4) - eBytes[0] = byte(e >> 24) - eBytes[1] = byte(e >> 16) - eBytes[2] = byte(e >> 8) - eBytes[3] = byte(e) + // Use minimal-octet encoding for exponent per RFC 7517 + eBytes := big.NewInt(int64(e)).Bytes() jwk := map[string]interface{}{ "kty": "RSA", From 3f2f8139023e876e46a27af7c0c70d475a8429ea Mon Sep 17 00:00:00 2001 From: Olivier Dumont Date: Tue, 30 Dec 2025 14:22:53 +0100 Subject: [PATCH 13/13] Trigger automated review --- .review_trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .review_trigger diff --git a/.review_trigger b/.review_trigger new file mode 100644 index 00000000..5e5b4d32 --- /dev/null +++ b/.review_trigger @@ -0,0 +1 @@ +# Trigger automated review