✅ Welcome back!
+ ++
ID Token Claims:
+{json.dumps(claims, indent=2)}
+ Logout
+ diff --git a/config.example.yaml b/config.example.yaml index 544bc83..14c997d 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 5979f36..5543e1f 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 4a44d52..2ac53f3 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 0000000..d6f9df6 --- /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 0000000..1811d56 --- /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 531f5e0..bd4a0ff 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 6110aeb..70764fc 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 f69c473..f2cccde 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 0000000..74f98e9 --- /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 0000000..dd3d7a0 --- /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 0000000..c456830 --- /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 28d815d..f05b4bf 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 7242791..ca36643 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 0000000..e1a1900 --- /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 0000000..1a61c4e --- /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 0000000..1973365 --- /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 0000000..9d6d93d --- /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 0000000..67113e3 --- /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 0000000..421f606 --- /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 0000000..1a2df33 --- /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 0000000..587e77f --- /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""" + + +
+Click the button below to start the OIDC flow:
+ Login with OIDC +Authorization URL: {auth_url}