✅ Welcome back!
+ ++
ID Token Claims:
+{html.escape(json.dumps(claims, indent=2))}
+ Logout
+ 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 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/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/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/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..6bb8615f --- /dev/null +++ b/internal/controller/oidc_controller.go @@ -0,0 +1,489 @@ +package controller + +import ( + "encoding/base64" + "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" +) + +// OIDCControllerConfig holds configuration for the OIDC controller. +type OIDCControllerConfig struct { + 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 + oidc *service.OIDCService + 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, + router: router, + oidc: oidc, + auth: auth, + } +} + +// 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) + + // 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) +} + +// 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, "/") + + 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) +} + +// 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") + 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 + // Return JSON error instead of redirecting since redirect_uri is not yet validated + if clientID == "" || redirectURI == "" || responseType == "" { + 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 { + 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) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "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 (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") + 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()) +} + +// 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") + 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 + } + + // Get code_verifier for PKCE validation + codeVerifier := c.PostForm("code_verifier") + if codeVerifier == "" { + codeVerifier = c.Query("code_verifier") + } + + // Validate authorization code + 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 { + 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) +} + +// 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) + if accessToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_token", + "error_description": "Missing access token", + }) + return + } + + // 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{ + "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) +} + +// 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 { + 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 + +// 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{ + "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()) +} + +// 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, + "error_description": errorDescription, + }) +} + +// 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") + 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 (client_secret_post) + clientID := c.PostForm("client_id") + clientSecret := c.PostForm("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") +} + +// 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") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + + // Try query parameter + 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/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/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/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 new file mode 100644 index 00000000..1ff1e993 --- /dev/null +++ b/internal/service/oidc_service.go @@ -0,0 +1,822 @@ +package service + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "os" + "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" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/hkdf" + "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 + masterKey []byte // Master key for encrypting private keys (optional) +} + +func NewOIDCService(config OIDCServiceConfig) *OIDCService { + return &OIDCService{ + config: config, + } +} + +// 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 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) + 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 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) + 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 { + // 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.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) + } + + 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(privateKeyPEM)) + 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, + }) + + // 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: encryptedPrivateKey, + 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 (saved to database)") + 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 { + // 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 { + 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 + } + } + } + + return validScopes, nil +} + +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(), + } + + if err := oidc.config.Database.Create(&authCodeRecord).Error; err != nil { + return "", fmt.Errorf("failed to store authorization code: %w", err) + } + + // Encode as JWT for stateless operation (but code is tracked in DB) + 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": expiresAt, + "iat": 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 { + // Clean up the database record if JWT signing fails + oidc.config.Database.Delete(&authCodeRecord) + return "", fmt.Errorf("failed to sign authorization code: %w", err) + } + + return codeToken, nil +} + +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"]) + } + 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") + } + + // 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") + } + + if claims["redirect_uri"] != redirectURI { + return nil, nil, "", "", "", errors.New("redirect_uri mismatch") + } + + // 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{ + 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") + codeChallenge := getStringClaim(claims, "code_challenge") + codeChallengeMethod := getStringClaim(claims, "code_challenge_method") + + 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) { + 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) 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"]) + } + 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") + } + + // 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) { + 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 { + 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) { + // Extract modulus and exponent from public key + n := oidc.publicKey.N + e := oidc.publicKey.E + + nBytes := n.Bytes() + // Use minimal-octet encoding for exponent per RFC 7517 + eBytes := big.NewInt(int64(e)).Bytes() + + jwk := map[string]interface{}{ + "kty": "RSA", + "use": "sig", + "kid": "default", + "n": base64.RawURLEncoding.EncodeToString(nBytes), + "e": base64.RawURLEncoding.EncodeToString(eBytes), + "alg": "RS256", + } + + 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 + } + + // 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 + var existingClient model.OIDCClient + err = oidc.config.Database.Where("client_id = ?", clientID).First(&existingClient).Error + + client := model.OIDCClient{ + ClientID: clientID, + ClientSecret: string(hashedSecret), + 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..d2313dc6 --- /dev/null +++ b/validation/oidc_whoami.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import html +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_content = f""" + + +
+Click the button below to start the OIDC flow:
+ Login with OIDC +Authorization URL: {auth_url}