From 133c0600d04edd0b70a5f71cba9c106c290fdac3 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 19:25:42 +0900 Subject: [PATCH 1/9] refactor(echo-http)!: rename env vars for OAuth2/OIDC shared configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename environment variables from OIDC_* prefix to hierarchical AUTH_* prefix system to prepare for OAuth2 Client Credentials Grant support. The new naming separates concerns: - AUTH_*: OAuth2-wide config (client validation, scopes, tokens) - AUTH_CODE_*: Authorization Code Flow specific (PKCE, sessions, redirects) - OIDC_*: OIDC-specific features (id_token signing) This enables sharing client validation and scope configuration across multiple OAuth2 grant types while maintaining clear boundaries between flow-specific settings. BREAKING CHANGE: All OIDC_* environment variables renamed: - OIDC_CLIENT_ID → AUTH_ALLOWED_CLIENT_ID - OIDC_CLIENT_SECRET → AUTH_ALLOWED_CLIENT_SECRET - OIDC_SUPPORTED_SCOPES → AUTH_SUPPORTED_SCOPES - OIDC_TOKEN_EXPIRY → AUTH_TOKEN_EXPIRY - OIDC_REQUIRE_PKCE → AUTH_CODE_REQUIRE_PKCE - OIDC_SESSION_TTL → AUTH_CODE_SESSION_TTL - OIDC_VALIDATE_REDIRECT_URI → AUTH_CODE_VALIDATE_REDIRECT_URI - OIDC_ALLOWED_REDIRECT_URIS → AUTH_CODE_ALLOWED_REDIRECT_URIS --- echo-http/config.go | 48 ++++++++++++++----------- echo-http/docs/api.md | 54 ++++++++++++++++++----------- echo-http/handlers/config.go | 29 +++++++++------- echo-http/handlers/oidc.go | 24 ++++++------- echo-http/handlers/oidc_jwt_test.go | 12 +++---- echo-http/handlers/oidc_test.go | 44 +++++++++++------------ echo-http/main.go | 20 +++++------ 7 files changed, 129 insertions(+), 102 deletions(-) diff --git a/echo-http/config.go b/echo-http/config.go index d38d388..71322ba 100644 --- a/echo-http/config.go +++ b/echo-http/config.go @@ -12,16 +12,20 @@ type Config struct { Host string Port string - // OIDC Configuration - OIDCClientID string - OIDCClientSecret string - OIDCSupportedScopes []string - OIDCRequirePKCE bool - OIDCSessionTTL int - OIDCTokenExpiry int - OIDCValidateRedirectURI bool - OIDCAllowedRedirectURIs string - OIDCEnableJWTSigning bool + // OAuth2 Configuration (shared across all flows) + AuthAllowedClientID string + AuthAllowedClientSecret string + AuthSupportedScopes []string + AuthTokenExpiry int + + // Authorization Code Flow Configuration + AuthCodeRequirePKCE bool + AuthCodeSessionTTL int + AuthCodeValidateRedirectURI bool + AuthCodeAllowedRedirectURIs string + + // OIDC Configuration (id_token specific) + OIDCEnableJWTSigning bool } func LoadConfig() *Config { @@ -32,16 +36,20 @@ func LoadConfig() *Config { Host: getEnv("HOST", "0.0.0.0"), Port: getEnv("PORT", "80"), - // OIDC settings - OIDCClientID: getEnv("OIDC_CLIENT_ID", ""), - OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""), - OIDCSupportedScopes: parseScopes(getEnv("OIDC_SUPPORTED_SCOPES", "openid,profile,email")), - OIDCRequirePKCE: getBoolEnv("OIDC_REQUIRE_PKCE", false), - OIDCSessionTTL: getIntEnv("OIDC_SESSION_TTL", 300), - OIDCTokenExpiry: getIntEnv("OIDC_TOKEN_EXPIRY", 3600), - OIDCValidateRedirectURI: getBoolEnv("OIDC_VALIDATE_REDIRECT_URI", true), - OIDCAllowedRedirectURIs: getEnv("OIDC_ALLOWED_REDIRECT_URIS", ""), - OIDCEnableJWTSigning: getBoolEnv("OIDC_ENABLE_JWT_SIGNING", false), + // OAuth2 settings (shared across all flows) + AuthAllowedClientID: getEnv("AUTH_ALLOWED_CLIENT_ID", ""), + AuthAllowedClientSecret: getEnv("AUTH_ALLOWED_CLIENT_SECRET", ""), + AuthSupportedScopes: parseScopes(getEnv("AUTH_SUPPORTED_SCOPES", "openid,profile,email")), + AuthTokenExpiry: getIntEnv("AUTH_TOKEN_EXPIRY", 3600), + + // Authorization Code Flow settings + AuthCodeRequirePKCE: getBoolEnv("AUTH_CODE_REQUIRE_PKCE", false), + AuthCodeSessionTTL: getIntEnv("AUTH_CODE_SESSION_TTL", 300), + AuthCodeValidateRedirectURI: getBoolEnv("AUTH_CODE_VALIDATE_REDIRECT_URI", false), + AuthCodeAllowedRedirectURIs: getEnv("AUTH_CODE_ALLOWED_REDIRECT_URIS", ""), + + // OIDC settings (id_token specific) + OIDCEnableJWTSigning: getBoolEnv("OIDC_ENABLE_JWT_SIGNING", false), } } diff --git a/echo-http/docs/api.md b/echo-http/docs/api.md index 7424afa..0910054 100644 --- a/echo-http/docs/api.md +++ b/echo-http/docs/api.md @@ -492,35 +492,49 @@ validation, and configurable client authentication. #### Environment Variables -Configure OIDC server behavior with these environment variables: - -| Variable | Default | Description | -| ---------------------------- | ----------------------- | ---------------------------------------------- | -| `OIDC_CLIENT_ID` | (empty - accept any) | Expected client_id (empty = any client_id) | -| `OIDC_CLIENT_SECRET` | (empty - public client) | Required client_secret (empty = not required) | -| `OIDC_SUPPORTED_SCOPES` | `openid,profile,email` | Comma-separated list of supported scopes | -| `OIDC_REQUIRE_PKCE` | `false` | Require PKCE for all clients (RFC 8252) | -| `OIDC_VALIDATE_REDIRECT_URI` | `false` | Enable redirect_uri validation | -| `OIDC_ALLOWED_REDIRECT_URIS` | (empty - allow all) | Comma-separated redirect URI patterns | -| `OIDC_SESSION_TTL` | `300` | Session timeout in seconds | -| `OIDC_TOKEN_EXPIRY` | `3600` | Token expiry in seconds | -| `OIDC_ENABLE_JWT_SIGNING` | `false` | Enable JWT signing (currently not implemented) | +Configure OAuth2/OIDC server behavior with these environment variables: + +**OAuth2 Configuration (shared across all flows):** + +| Variable | Default | Description | +| ---------------------------- | ----------------------- | --------------------------------------------------- | +| `AUTH_ALLOWED_CLIENT_ID` | (empty - accept any) | Allowed client_id for validation (empty = any) | +| `AUTH_ALLOWED_CLIENT_SECRET` | (empty - public client) | Required client_secret (empty = not required) | +| `AUTH_SUPPORTED_SCOPES` | `openid,profile,email` | Comma-separated list of supported scopes | +| `AUTH_TOKEN_EXPIRY` | `3600` | Access token expiry in seconds | + +**Authorization Code Flow Configuration:** + +| Variable | Default | Description | +| --------------------------------- | ------------------- | ----------------------------------------------- | +| `AUTH_CODE_REQUIRE_PKCE` | `false` | Require PKCE for all clients (RFC 8252) | +| `AUTH_CODE_SESSION_TTL` | `300` | Session timeout in seconds | +| `AUTH_CODE_VALIDATE_REDIRECT_URI` | `false` | Enable redirect_uri validation | +| `AUTH_CODE_ALLOWED_REDIRECT_URIS` | (empty - allow all) | Comma-separated redirect URI patterns | + +**OIDC Configuration (id_token specific):** + +| Variable | Default | Description | +| ------------------------- | ------- | ---------------------------------------------- | +| `OIDC_ENABLE_JWT_SIGNING` | `false` | Enable JWT signing (currently not implemented) | **Example Configuration:** ```bash # Strict validation for production-like testing -export OIDC_CLIENT_ID=my-app-client-id -export OIDC_CLIENT_SECRET=my-app-secret -export OIDC_SUPPORTED_SCOPES=openid,profile,email,custom_scope -export OIDC_REQUIRE_PKCE=true -export OIDC_VALIDATE_REDIRECT_URI=true -export OIDC_ALLOWED_REDIRECT_URIS=http://localhost:*,https://myapp.com/callback +export AUTH_ALLOWED_CLIENT_ID=my-app-client-id +export AUTH_ALLOWED_CLIENT_SECRET=my-app-secret +export AUTH_SUPPORTED_SCOPES=openid,profile,email,custom_scope +export AUTH_TOKEN_EXPIRY=3600 +export AUTH_CODE_REQUIRE_PKCE=true +export AUTH_CODE_VALIDATE_REDIRECT_URI=true +export AUTH_CODE_ALLOWED_REDIRECT_URIS=http://localhost:*,https://myapp.com/callback +export AUTH_CODE_SESSION_TTL=300 ``` #### Redirect URI Patterns -When `OIDC_VALIDATE_REDIRECT_URI=true`, supports these patterns: +When `AUTH_CODE_VALIDATE_REDIRECT_URI=true`, supports these patterns: - **Exact match**: `http://localhost:8080/callback` - **Wildcard port**: `http://localhost:*/callback` (any port) diff --git a/echo-http/handlers/config.go b/echo-http/handlers/config.go index 11d75a7..d8a0edf 100644 --- a/echo-http/handlers/config.go +++ b/echo-http/handlers/config.go @@ -1,20 +1,25 @@ package handlers -// globalConfig holds the global OIDC configuration. -// It will be used by OIDC handlers in Milestone 2 and beyond. +// globalConfig holds the global OAuth2/OIDC configuration. +// It is used by authentication handlers. var globalConfig *Config -// Config holds the OIDC configuration for handlers. +// Config holds the OAuth2/OIDC configuration for handlers. type Config struct { - OIDCClientID string - OIDCClientSecret string - OIDCSupportedScopes []string - OIDCRequirePKCE bool - OIDCSessionTTL int - OIDCTokenExpiry int - OIDCValidateRedirectURI bool - OIDCAllowedRedirectURIs string - OIDCEnableJWTSigning bool + // OAuth2 Configuration (shared across all flows) + AuthAllowedClientID string + AuthAllowedClientSecret string + AuthSupportedScopes []string + AuthTokenExpiry int + + // Authorization Code Flow Configuration + AuthCodeRequirePKCE bool + AuthCodeSessionTTL int + AuthCodeValidateRedirectURI bool + AuthCodeAllowedRedirectURIs string + + // OIDC Configuration (id_token specific) + OIDCEnableJWTSigning bool } // SetConfig sets the global configuration for handlers. diff --git a/echo-http/handlers/oidc.go b/echo-http/handlers/oidc.go index e326d0e..b3f9114 100644 --- a/echo-http/handlers/oidc.go +++ b/echo-http/handlers/oidc.go @@ -51,8 +51,8 @@ func OIDCDiscoveryHandler(w http.ResponseWriter, r *http.Request) { // Get scopes from config, or use defaults if not configured supportedScopes := []string{"openid", "profile", "email"} - if globalConfig != nil && len(globalConfig.OIDCSupportedScopes) > 0 { - supportedScopes = globalConfig.OIDCSupportedScopes + if globalConfig != nil && len(globalConfig.AuthSupportedScopes) > 0 { + supportedScopes = globalConfig.AuthSupportedScopes } discovery := OIDCDiscoveryResponse{ @@ -92,7 +92,7 @@ func validateScopes(requestedScope string) error { } requestedScopes := strings.Split(requestedScope, " ") - supportedScopes := globalConfig.OIDCSupportedScopes + supportedScopes := globalConfig.AuthSupportedScopes for _, rs := range requestedScopes { rs = strings.TrimSpace(rs) @@ -119,7 +119,7 @@ func validateScopes(requestedScope string) error { // getDefaultScopes returns default scopes as a space-separated string. // Returns all configured scopes joined by spaces. func getDefaultScopes() string { - return strings.Join(globalConfig.OIDCSupportedScopes, " ") + return strings.Join(globalConfig.AuthSupportedScopes, " ") } // OIDCAuthorizeHandler handles OIDC authorization requests @@ -147,7 +147,7 @@ func OIDCAuthorizeHandler(w http.ResponseWriter, r *http.Request) { } // Validate client_id value if configured - if globalConfig != nil && globalConfig.OIDCClientID != "" && clientID != globalConfig.OIDCClientID { + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" && clientID != globalConfig.AuthAllowedClientID { writeAuthorizationError(w, r, ErrorUnauthorizedClient, "unknown client_id", state, redirectURI) return } @@ -159,11 +159,11 @@ func OIDCAuthorizeHandler(w http.ResponseWriter, r *http.Request) { } // Validate redirect_uri if validation is enabled - if globalConfig != nil && globalConfig.OIDCValidateRedirectURI { + if globalConfig != nil && globalConfig.AuthCodeValidateRedirectURI { var allowedPatterns []string - if globalConfig.OIDCAllowedRedirectURIs != "" { + if globalConfig.AuthCodeAllowedRedirectURIs != "" { // Split comma-separated patterns - for _, pattern := range strings.Split(globalConfig.OIDCAllowedRedirectURIs, ",") { + for _, pattern := range strings.Split(globalConfig.AuthCodeAllowedRedirectURIs, ",") { if trimmed := strings.TrimSpace(pattern); trimmed != "" { allowedPatterns = append(allowedPatterns, trimmed) } @@ -192,7 +192,7 @@ func OIDCAuthorizeHandler(w http.ResponseWriter, r *http.Request) { } // Validate PKCE parameters - if globalConfig != nil && globalConfig.OIDCRequirePKCE && codeChallenge == "" { + if globalConfig != nil && globalConfig.AuthCodeRequirePKCE && codeChallenge == "" { writeAuthorizationError(w, r, ErrorInvalidRequest, "code_challenge is required", state, redirectURI) return } @@ -394,14 +394,14 @@ func OIDCTokenHandler(w http.ResponseWriter, r *http.Request) { } // Validate client_id value if configured - if globalConfig != nil && globalConfig.OIDCClientID != "" && clientID != globalConfig.OIDCClientID { + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" && clientID != globalConfig.AuthAllowedClientID { writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidClient, "unknown client_id") return } // Validate client_secret if configured (confidential client) - if globalConfig != nil && globalConfig.OIDCClientSecret != "" { - if clientSecret != globalConfig.OIDCClientSecret { + if globalConfig != nil && globalConfig.AuthAllowedClientSecret != "" { + if clientSecret != globalConfig.AuthAllowedClientSecret { writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidClient, "invalid client_secret") return } diff --git a/echo-http/handlers/oidc_jwt_test.go b/echo-http/handlers/oidc_jwt_test.go index bcfb9bb..dd2d3bb 100644 --- a/echo-http/handlers/oidc_jwt_test.go +++ b/echo-http/handlers/oidc_jwt_test.go @@ -119,7 +119,7 @@ func TestIDTokenJWTFormat(t *testing.T) { func TestTokenEndpointReturnsJWTIDToken(t *testing.T) { // Arrange SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", "", "", "") @@ -240,7 +240,7 @@ func TestIDTokenIssuerAndAudience(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Arrange SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", tt.user, "openid profile", "", "", "") @@ -311,7 +311,7 @@ func TestDemoPageClientIDParameter(t *testing.T) { t.Run("demo flow includes client_id in token exchange", func(t *testing.T) { // Arrange SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -557,9 +557,9 @@ func TestAuthorizeHandlerRedirectURIValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Arrange SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, - OIDCValidateRedirectURI: tt.validateRedirectURI, - OIDCAllowedRedirectURIs: tt.allowedRedirectURIs, + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthCodeValidateRedirectURI: tt.validateRedirectURI, + AuthCodeAllowedRedirectURIs: tt.allowedRedirectURIs, }) r := chi.NewRouter() diff --git a/echo-http/handlers/oidc_test.go b/echo-http/handlers/oidc_test.go index 745a8d8..1104325 100644 --- a/echo-http/handlers/oidc_test.go +++ b/echo-http/handlers/oidc_test.go @@ -181,7 +181,7 @@ func TestOIDCAuthorizeHandler_GET(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Initialize config for each test SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -500,7 +500,7 @@ func TestOIDCTokenHandler(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Initialize config for each test SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -595,8 +595,8 @@ func TestOIDCAuthorizeHandler_ClientIDValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCClientID: tt.configClientID, - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedClientID: tt.configClientID, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -719,9 +719,9 @@ func TestOIDCTokenHandler_ClientValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCClientID: tt.configClientID, - OIDCClientSecret: tt.configClientSec, - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedClientID: tt.configClientID, + AuthAllowedClientSecret: tt.configClientSec, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) // Create a valid auth code @@ -838,8 +838,8 @@ func TestOIDCTokenHandler_PKCEVerification(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCClientID: "", - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedClientID: "", + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) // Create an auth code with PKCE parameters @@ -895,7 +895,7 @@ func TestOIDCTokenHandler_PKCEVerification(t *testing.T) { func TestOIDCFullFlow(t *testing.T) { // Initialize config for test SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -1078,7 +1078,7 @@ func TestOIDCDemoHandler(t *testing.T) { func TestValidateScopes(t *testing.T) { SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) tests := []struct { @@ -1193,7 +1193,7 @@ func TestGetDefaultScopes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { SetConfig(&Config{ - OIDCSupportedScopes: tt.configScopes, + AuthSupportedScopes: tt.configScopes, }) result := getDefaultScopes() @@ -1253,8 +1253,8 @@ func TestOIDCAuthorizeHandler_ScopeValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCClientID: "", - OIDCSupportedScopes: tt.configScopes, + AuthAllowedClientID: "", + AuthSupportedScopes: tt.configScopes, }) r := chi.NewRouter() @@ -1335,7 +1335,7 @@ func TestOIDCDiscoveryHandler_DynamicScopes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCSupportedScopes: tt.configScopes, + AuthSupportedScopes: tt.configScopes, }) r := chi.NewRouter() @@ -1507,9 +1507,9 @@ func TestOIDCAuthorizeHandler_PKCEValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCClientID: "", - OIDCSupportedScopes: []string{"openid", "profile", "email"}, - OIDCRequirePKCE: tt.configRequirePKCE, + AuthAllowedClientID: "", + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthCodeRequirePKCE: tt.configRequirePKCE, }) r := chi.NewRouter() @@ -1583,7 +1583,7 @@ func TestOIDC_FullFlow_WithPKCE(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Initialize config for test SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -1724,7 +1724,7 @@ func TestOIDCNonceSupport(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Initialize config SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) r := chi.NewRouter() @@ -1906,7 +1906,7 @@ func TestCodeVerifierLengthValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) // Create auth code with PKCE challenge (use verifier as challenge for plain method) @@ -1962,7 +1962,7 @@ func TestCodeVerifierLengthValidation(t *testing.T) { func TestCodeVerifierLengthValidationWithoutPKCE(t *testing.T) { // Setup config SetConfig(&Config{ - OIDCSupportedScopes: []string{"openid", "profile", "email"}, + AuthSupportedScopes: []string{"openid", "profile", "email"}, }) // Create auth code WITHOUT PKCE challenge diff --git a/echo-http/main.go b/echo-http/main.go index 14ddfb1..4159f53 100644 --- a/echo-http/main.go +++ b/echo-http/main.go @@ -20,17 +20,17 @@ func main() { // Set API docs content for handler handlers.SetAPIDocs(apiDocs) - // Set OIDC config for handlers + // Set OAuth2/OIDC config for handlers handlers.SetConfig(&handlers.Config{ - OIDCClientID: cfg.OIDCClientID, - OIDCClientSecret: cfg.OIDCClientSecret, - OIDCSupportedScopes: cfg.OIDCSupportedScopes, - OIDCRequirePKCE: cfg.OIDCRequirePKCE, - OIDCSessionTTL: cfg.OIDCSessionTTL, - OIDCTokenExpiry: cfg.OIDCTokenExpiry, - OIDCValidateRedirectURI: cfg.OIDCValidateRedirectURI, - OIDCAllowedRedirectURIs: cfg.OIDCAllowedRedirectURIs, - OIDCEnableJWTSigning: cfg.OIDCEnableJWTSigning, + AuthAllowedClientID: cfg.AuthAllowedClientID, + AuthAllowedClientSecret: cfg.AuthAllowedClientSecret, + AuthSupportedScopes: cfg.AuthSupportedScopes, + AuthTokenExpiry: cfg.AuthTokenExpiry, + AuthCodeRequirePKCE: cfg.AuthCodeRequirePKCE, + AuthCodeSessionTTL: cfg.AuthCodeSessionTTL, + AuthCodeValidateRedirectURI: cfg.AuthCodeValidateRedirectURI, + AuthCodeAllowedRedirectURIs: cfg.AuthCodeAllowedRedirectURIs, + OIDCEnableJWTSigning: cfg.OIDCEnableJWTSigning, }) r := chi.NewRouter() From 431f8d2569c4d9bb51c76739648e0ecaab266bee Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 19:32:56 +0900 Subject: [PATCH 2/9] docs(echo-http): reorganize documentation structure for clarity Separate OIDC endpoints from Authentication endpoints and consolidate all environment variables into a dedicated top-level section. The new structure improves discoverability: - Environment Variables section moved to front (after Base URL) - Server and OAuth2/OIDC configs grouped by category - OIDC endpoints elevated to independent top-level section - Authentication endpoints now only cover Basic/Bearer auth This separation clarifies that OIDC is a full-featured OAuth2 protocol distinct from simple authentication mechanisms, and prepares the documentation for additional OAuth2 grant types (Client Credentials). --- echo-http/README.md | 34 ++++++++---- echo-http/docs/api.md | 121 ++++++++++++++++++++++++------------------ 2 files changed, 92 insertions(+), 63 deletions(-) diff --git a/echo-http/README.md b/echo-http/README.md index 1cfd265..1790f19 100644 --- a/echo-http/README.md +++ b/echo-http/README.md @@ -19,6 +19,8 @@ docker run -p 8080:80 ghcr.io/probitas-test/echo-http:latest ## Environment Variables +### Server Configuration + | Variable | Default | Description | | -------- | --------- | ------------ | | `HOST` | `0.0.0.0` | Bind address | @@ -32,6 +34,11 @@ docker run -p 3000:3000 -e PORT=3000 ghcr.io/probitas-test/echo-http:latest docker run -p 8080:8080 -v $(pwd)/.env:/app/.env ghcr.io/probitas-test/echo-http:latest ``` +### OAuth2/OIDC Configuration + +For OAuth2/OIDC functionality configuration (client validation, scopes, PKCE, etc.), +see the [Environment Variables](./docs/api.md#environment-variables) section in the API documentation. + ## API ### Echo Endpoints @@ -69,16 +76,23 @@ docker run -p 8080:8080 -v $(pwd)/.env:/app/.env ghcr.io/probitas-test/echo-http ### Authentication Endpoints -| Endpoint | Method | Description | -| ------------------------------------------------------ | -------- | ---------------------------------------- | -| `/basic-auth/{user}/{pass}` | GET | Basic auth (200 if match, 401 otherwise) | -| `/hidden-basic-auth/{user}/{pass}` | GET | Basic auth (200 if match, 404 otherwise) | -| `/bearer` | GET | Bearer token validation | -| `/oidc/{user}/{pass}/.well-known/openid-configuration` | GET | OIDC Discovery metadata (mock) | -| `/oidc/{user}/{pass}/authorize` | GET/POST | OIDC authorization endpoint (mock) | -| `/oidc/{user}/{pass}/callback` | GET | OIDC callback handler | -| `/oidc/{user}/{pass}/token` | POST | OIDC token endpoint (mock) | -| `/oidc/{user}/{pass}/demo` | GET | Interactive OIDC flow demo (browser) | +| Endpoint | Method | Description | +| ---------------------------------- | ------ | ---------------------------------------- | +| `/basic-auth/{user}/{pass}` | GET | Basic auth (200 if match, 401 otherwise) | +| `/hidden-basic-auth/{user}/{pass}` | GET | Basic auth (200 if match, 404 otherwise) | +| `/bearer` | GET | Bearer token validation | + +### OIDC Endpoints + +| Endpoint | Method | Description | +| ------------------------------------------------------ | -------- | ------------------------------------ | +| `/oidc/{user}/{pass}/.well-known/openid-configuration` | GET | OIDC Discovery metadata (mock) | +| `/oidc/{user}/{pass}/.well-known/jwks.json` | GET | JWKS endpoint (JSON Web Key Set) | +| `/oidc/{user}/{pass}/authorize` | GET/POST | OIDC authorization endpoint (mock) | +| `/oidc/{user}/{pass}/callback` | GET | OIDC callback handler | +| `/oidc/{user}/{pass}/token` | POST | OIDC token endpoint (mock) | +| `/oidc/{user}/{pass}/userinfo` | GET | UserInfo endpoint | +| `/oidc/{user}/{pass}/demo` | GET | Interactive OIDC flow demo (browser) | ### Cookie Endpoints diff --git a/echo-http/docs/api.md b/echo-http/docs/api.md index 0910054..4b1a5d9 100644 --- a/echo-http/docs/api.md +++ b/echo-http/docs/api.md @@ -10,6 +10,68 @@ > **Note:** The container listens on port 80. When using `docker compose up`, the > port is mapped to 18080 on the host. +## Environment Variables + +### Server Configuration + +| Variable | Default | Description | +| -------- | --------- | ------------ | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `80` | Listen port | + +### OAuth2/OIDC Configuration + +Configure OAuth2/OIDC server behavior with these environment variables: + +**OAuth2 Configuration (shared across all flows):** + +| Variable | Default | Description | +| ---------------------------- | ----------------------- | ---------------------------------------------- | +| `AUTH_ALLOWED_CLIENT_ID` | (empty - accept any) | Allowed client_id for validation (empty = any) | +| `AUTH_ALLOWED_CLIENT_SECRET` | (empty - public client) | Required client_secret (empty = not required) | +| `AUTH_SUPPORTED_SCOPES` | `openid,profile,email` | Comma-separated list of supported scopes | +| `AUTH_TOKEN_EXPIRY` | `3600` | Access token expiry in seconds | + +**Authorization Code Flow Configuration:** + +| Variable | Default | Description | +| --------------------------------- | ------------------- | --------------------------------------- | +| `AUTH_CODE_REQUIRE_PKCE` | `false` | Require PKCE for all clients (RFC 8252) | +| `AUTH_CODE_SESSION_TTL` | `300` | Session timeout in seconds | +| `AUTH_CODE_VALIDATE_REDIRECT_URI` | `false` | Enable redirect_uri validation | +| `AUTH_CODE_ALLOWED_REDIRECT_URIS` | (empty - allow all) | Comma-separated redirect URI patterns | + +**OIDC Configuration (id_token specific):** + +| Variable | Default | Description | +| ------------------------- | ------- | ---------------------------------------------- | +| `OIDC_ENABLE_JWT_SIGNING` | `false` | Enable JWT signing (currently not implemented) | + +**Example Configuration:** + +```bash +# Strict validation for production-like testing +export AUTH_ALLOWED_CLIENT_ID=my-app-client-id +export AUTH_ALLOWED_CLIENT_SECRET=my-app-secret +export AUTH_SUPPORTED_SCOPES=openid,profile,email,custom_scope +export AUTH_TOKEN_EXPIRY=3600 +export AUTH_CODE_REQUIRE_PKCE=true +export AUTH_CODE_VALIDATE_REDIRECT_URI=true +export AUTH_CODE_ALLOWED_REDIRECT_URIS=http://localhost:*,https://myapp.com/callback +export AUTH_CODE_SESSION_TTL=300 +``` + +### Redirect URI Patterns + +When `AUTH_CODE_VALIDATE_REDIRECT_URI=true`, supports these patterns: + +- **Exact match**: `http://localhost:8080/callback` +- **Wildcard port**: `http://localhost:*/callback` (any port) +- **Wildcard path**: `http://localhost:8080/*` (any path) +- **Multiple patterns**: Comma-separated list + +--- + ## Endpoints ### GET /get @@ -484,62 +546,15 @@ curl -H "Authorization: Bearer my-token-123" http://localhost:80/bearer **Response (failure):** 401 Unauthorized with `WWW-Authenticate: Bearer` header. -### OIDC (OpenID Connect) Test Server - -A fully-featured OIDC Authorization Code Flow test server for developing and testing -OIDC clients. Implements OpenID Connect Core 1.0 with support for PKCE, scope -validation, and configurable client authentication. - -#### Environment Variables - -Configure OAuth2/OIDC server behavior with these environment variables: - -**OAuth2 Configuration (shared across all flows):** - -| Variable | Default | Description | -| ---------------------------- | ----------------------- | --------------------------------------------------- | -| `AUTH_ALLOWED_CLIENT_ID` | (empty - accept any) | Allowed client_id for validation (empty = any) | -| `AUTH_ALLOWED_CLIENT_SECRET` | (empty - public client) | Required client_secret (empty = not required) | -| `AUTH_SUPPORTED_SCOPES` | `openid,profile,email` | Comma-separated list of supported scopes | -| `AUTH_TOKEN_EXPIRY` | `3600` | Access token expiry in seconds | - -**Authorization Code Flow Configuration:** - -| Variable | Default | Description | -| --------------------------------- | ------------------- | ----------------------------------------------- | -| `AUTH_CODE_REQUIRE_PKCE` | `false` | Require PKCE for all clients (RFC 8252) | -| `AUTH_CODE_SESSION_TTL` | `300` | Session timeout in seconds | -| `AUTH_CODE_VALIDATE_REDIRECT_URI` | `false` | Enable redirect_uri validation | -| `AUTH_CODE_ALLOWED_REDIRECT_URIS` | (empty - allow all) | Comma-separated redirect URI patterns | - -**OIDC Configuration (id_token specific):** - -| Variable | Default | Description | -| ------------------------- | ------- | ---------------------------------------------- | -| `OIDC_ENABLE_JWT_SIGNING` | `false` | Enable JWT signing (currently not implemented) | - -**Example Configuration:** - -```bash -# Strict validation for production-like testing -export AUTH_ALLOWED_CLIENT_ID=my-app-client-id -export AUTH_ALLOWED_CLIENT_SECRET=my-app-secret -export AUTH_SUPPORTED_SCOPES=openid,profile,email,custom_scope -export AUTH_TOKEN_EXPIRY=3600 -export AUTH_CODE_REQUIRE_PKCE=true -export AUTH_CODE_VALIDATE_REDIRECT_URI=true -export AUTH_CODE_ALLOWED_REDIRECT_URIS=http://localhost:*,https://myapp.com/callback -export AUTH_CODE_SESSION_TTL=300 -``` +--- -#### Redirect URI Patterns +## OIDC Endpoints -When `AUTH_CODE_VALIDATE_REDIRECT_URI=true`, supports these patterns: +A fully-featured OIDC test server for developing and testing OIDC clients. +Implements OpenID Connect Core 1.0 Authorization Code Flow with support for PKCE, +scope validation, and configurable client authentication. -- **Exact match**: `http://localhost:8080/callback` -- **Wildcard port**: `http://localhost:*/callback` (any port) -- **Wildcard path**: `http://localhost:8080/*` (any path) -- **Multiple patterns**: Comma-separated list +See the [Environment Variables](#environment-variables) section for configuration options. ### GET /oidc/{user}/{pass}/.well-known/openid-configuration From 6abaf16a30a011f763fcc5f28045dbaed86d593a Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 19:42:14 +0900 Subject: [PATCH 3/9] docs: align api.md structure across all echo-servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardizes documentation structure across echo-grpc, echo-graphql, and echo-connectrpc to match echo-http's layout. This improves consistency and makes configuration easier to find. Changes: - Move Environment Variables to top-level section after Base URL - Add Server Configuration subsection (HOST, PORT) to all servers - Remove redundant Endpoints table from echo-graphql - Add visual separator (---) between config and content sections - Standardize default value formatting with backticks All servers now follow: Base URL → Environment Variables → Content --- echo-connectrpc/docs/api.md | 23 ++++++++++++++--------- echo-graphql/docs/api.md | 18 ++++++++++-------- echo-grpc/docs/api.md | 31 +++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/echo-connectrpc/docs/api.md b/echo-connectrpc/docs/api.md index 433b4c5..af0baad 100644 --- a/echo-connectrpc/docs/api.md +++ b/echo-connectrpc/docs/api.md @@ -12,25 +12,28 @@ ## Environment Variables -The server supports the following environment variables for configuration: +### Server Configuration + +| Variable | Default | Description | +| -------- | --------- | ------------ | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `8080` | Listen port | ### Protocol Control | Variable | Default | Description | | -------------------- | ------- | ---------------------------- | -| `HOST` | 0.0.0.0 | Host address to bind | -| `PORT` | 8080 | Port number to listen on | -| `DISABLE_CONNECTRPC` | false | Disable Connect RPC protocol | -| `DISABLE_GRPC` | false | Disable gRPC protocol | -| `DISABLE_GRPC_WEB` | false | Disable gRPC-Web protocol | +| `DISABLE_CONNECTRPC` | `false` | Disable Connect RPC protocol | +| `DISABLE_GRPC` | `false` | Disable gRPC protocol | +| `DISABLE_GRPC_WEB` | `false` | Disable gRPC-Web protocol | ### Reflection Control | Variable | Default | Description | | --------------------------------- | ------- | ----------------------------------------------------------- | -| `REFLECTION_INCLUDE_DEPENDENCIES` | false | Include transitive dependencies (note: not fully supported) | -| `DISABLE_REFLECTION_V1` | false | Disable gRPC reflection v1 API | -| `DISABLE_REFLECTION_V1ALPHA` | false | Disable gRPC reflection v1alpha API | +| `REFLECTION_INCLUDE_DEPENDENCIES` | `false` | Include transitive dependencies (note: not fully supported) | +| `DISABLE_REFLECTION_V1` | `false` | Disable gRPC reflection v1 API | +| `DISABLE_REFLECTION_V1ALPHA` | `false` | Disable gRPC reflection v1alpha API | **Note:** At least one protocol must be enabled. The server will refuse to start if all protocols are disabled. @@ -47,6 +50,8 @@ DISABLE_GRPC=true DISABLE_GRPC_WEB=true ./echo-connectrpc DISABLE_REFLECTION_V1ALPHA=true ./echo-connectrpc ``` +--- + ## Protocol Connect RPC supports three protocols: diff --git a/echo-graphql/docs/api.md b/echo-graphql/docs/api.md index daf4d14..dec939c 100644 --- a/echo-graphql/docs/api.md +++ b/echo-graphql/docs/api.md @@ -10,14 +10,16 @@ > **Note:** The container listens on port 8080. When using `docker compose up`, the > port is mapped to 14000 on the host. -## Endpoints - -| Path | Description | -| ------------- | ------------------ | -| `/` | API documentation | -| `/graphql` | GraphQL endpoint | -| `/playground` | GraphQL Playground | -| `/health` | Health check | +## Environment Variables + +### Server Configuration + +| Variable | Default | Description | +| -------- | --------- | ------------ | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `8080` | Listen port | + +--- ## Schema diff --git a/echo-grpc/docs/api.md b/echo-grpc/docs/api.md index 919360a..5c625db 100644 --- a/echo-grpc/docs/api.md +++ b/echo-grpc/docs/api.md @@ -10,6 +10,27 @@ > **Note:** The container listens on port 50051. The same port is exposed > when using `docker compose up`. +## Environment Variables + +### Server Configuration + +| Variable | Default | Description | +| -------- | --------- | ------------ | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `50051` | Listen port | + +### gRPC Reflection Configuration + +| Variable | Default | Description | +| --------------------------------- | ------- | --------------------------------------------- | +| `REFLECTION_INCLUDE_DEPENDENCIES` | `false` | Include transitive dependencies in reflection | +| `DISABLE_REFLECTION_V1` | `false` | Disable gRPC reflection v1 API | +| `DISABLE_REFLECTION_V1ALPHA` | `false` | Disable gRPC reflection v1alpha API | + +These flags allow testing client compatibility with different reflection API versions. + +--- + ## Services ### Echo Service (echo.v1.Echo) @@ -607,6 +628,8 @@ grpcurl -plaintext -d '{"service": ""}' \ The server supports gRPC server reflection for service discovery (both v1 and v1alpha versions). +> **Configuration:** See [Environment Variables](#environment-variables) section for reflection configuration options. + ```bash # List all services grpcurl -plaintext localhost:50051 list @@ -618,14 +641,6 @@ grpcurl -plaintext localhost:50051 describe echo.v1.Echo grpcurl -plaintext localhost:50051 describe echo.v1.EchoRequest ``` -### Environment Variables - -- `REFLECTION_INCLUDE_DEPENDENCIES` (default: `false`) - Include transitive dependencies in reflection responses -- `DISABLE_REFLECTION_V1` (default: `false`) - Disable gRPC reflection v1 API -- `DISABLE_REFLECTION_V1ALPHA` (default: `false`) - Disable gRPC reflection v1alpha API - -These flags allow testing client compatibility with different reflection API versions. - ## Metadata Request metadata is echoed back in the `metadata` field of every response. Custom metadata can be sent using grpcurl's `-H` flag: From 0bb2ffb4115fc35798a993a1cb5969ff5ff9a2b9 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 22:53:00 +0900 Subject: [PATCH 4/9] feat(echo-http): add OAuth2/OIDC with environment-based authentication Implements OAuth2 and OpenID Connect endpoints alongside existing path-based OIDC handlers to support both hardcoded testing scenarios and environment-configured deployments. New capabilities: - Authorization Code flow with optional PKCE - Client Credentials grant - Resource Owner Password Credentials grant - OAuth2/OIDC discovery metadata endpoints - JWKS endpoint for public key distribution - UserInfo endpoint with scope-based claims - Session management with configurable TTL Configuration via environment variables: - AUTH_ALLOWED_GRANT_TYPES: Supported OAuth2 grant types - AUTH_ALLOWED_USERNAME/PASSWORD: Resource owner credentials - Existing AUTH_* variables for client validation, scopes, expiry Routes added under /oauth2/* namespace maintain separation from existing /oidc/{user}/{pass}/* path-based testing endpoints. --- echo-http/config.go | 23 + echo-http/handlers/config.go | 5 + echo-http/handlers/oauth2_authorize.go | 233 +++++ echo-http/handlers/oauth2_authorize_test.go | 216 +++++ echo-http/handlers/oauth2_discovery.go | 146 ++++ echo-http/handlers/oauth2_discovery_test.go | 185 ++++ echo-http/handlers/oauth2_endpoints.go | 296 +++++++ echo-http/handlers/oauth2_endpoints_test.go | 161 ++++ echo-http/handlers/oauth2_error.go | 234 ++++++ echo-http/handlers/oauth2_error_test.go | 212 +++++ echo-http/handlers/oauth2_session.go | 262 ++++++ echo-http/handlers/oauth2_token.go | 461 ++++++++++ echo-http/handlers/oauth2_token_test.go | 888 ++++++++++++++++++++ echo-http/handlers/oauth2_types.go | 116 +++ echo-http/handlers/oauth2_utils.go | 161 ++++ echo-http/handlers/oauth2_utils_test.go | 538 ++++++++++++ echo-http/main.go | 14 + 17 files changed, 4151 insertions(+) create mode 100644 echo-http/handlers/oauth2_authorize.go create mode 100644 echo-http/handlers/oauth2_authorize_test.go create mode 100644 echo-http/handlers/oauth2_discovery.go create mode 100644 echo-http/handlers/oauth2_discovery_test.go create mode 100644 echo-http/handlers/oauth2_endpoints.go create mode 100644 echo-http/handlers/oauth2_endpoints_test.go create mode 100644 echo-http/handlers/oauth2_error.go create mode 100644 echo-http/handlers/oauth2_error_test.go create mode 100644 echo-http/handlers/oauth2_session.go create mode 100644 echo-http/handlers/oauth2_token.go create mode 100644 echo-http/handlers/oauth2_token_test.go create mode 100644 echo-http/handlers/oauth2_types.go create mode 100644 echo-http/handlers/oauth2_utils.go create mode 100644 echo-http/handlers/oauth2_utils_test.go diff --git a/echo-http/config.go b/echo-http/config.go index 71322ba..f31afe3 100644 --- a/echo-http/config.go +++ b/echo-http/config.go @@ -17,6 +17,11 @@ type Config struct { AuthAllowedClientSecret string AuthSupportedScopes []string AuthTokenExpiry int + AuthAllowedGrantTypes []string + + // Resource Owner Password Credentials / Basic Auth + AuthAllowedUsername string + AuthAllowedPassword string // Authorization Code Flow Configuration AuthCodeRequirePKCE bool @@ -41,6 +46,11 @@ func LoadConfig() *Config { AuthAllowedClientSecret: getEnv("AUTH_ALLOWED_CLIENT_SECRET", ""), AuthSupportedScopes: parseScopes(getEnv("AUTH_SUPPORTED_SCOPES", "openid,profile,email")), AuthTokenExpiry: getIntEnv("AUTH_TOKEN_EXPIRY", 3600), + AuthAllowedGrantTypes: parseGrantTypes(getEnv("AUTH_ALLOWED_GRANT_TYPES", "authorization_code,client_credentials")), + + // Resource Owner Password Credentials / Basic Auth settings + AuthAllowedUsername: getEnv("AUTH_ALLOWED_USERNAME", "testuser"), + AuthAllowedPassword: getEnv("AUTH_ALLOWED_PASSWORD", "testpass"), // Authorization Code Flow settings AuthCodeRequirePKCE: getBoolEnv("AUTH_CODE_REQUIRE_PKCE", false), @@ -77,6 +87,19 @@ func parseScopes(s string) []string { return result } +// parseGrantTypes parses comma-separated grant types into a slice of strings. +// Empty values and surrounding whitespace are trimmed. +func parseGrantTypes(s string) []string { + grantTypes := strings.Split(s, ",") + result := make([]string, 0, len(grantTypes)) + for _, grantType := range grantTypes { + if trimmed := strings.TrimSpace(grantType); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + // getBoolEnv retrieves a boolean value from environment variables. // Returns true if the value is "true" or "1", false otherwise. // If the environment variable is not set or empty, returns defaultValue. diff --git a/echo-http/handlers/config.go b/echo-http/handlers/config.go index d8a0edf..b0ef853 100644 --- a/echo-http/handlers/config.go +++ b/echo-http/handlers/config.go @@ -11,6 +11,11 @@ type Config struct { AuthAllowedClientSecret string AuthSupportedScopes []string AuthTokenExpiry int + AuthAllowedGrantTypes []string + + // Resource Owner Password Credentials / Basic Auth + AuthAllowedUsername string + AuthAllowedPassword string // Authorization Code Flow Configuration AuthCodeRequirePKCE bool diff --git a/echo-http/handlers/oauth2_authorize.go b/echo-http/handlers/oauth2_authorize.go new file mode 100644 index 0000000..e5ae905 --- /dev/null +++ b/echo-http/handlers/oauth2_authorize.go @@ -0,0 +1,233 @@ +package handlers + +import ( + "fmt" + "html/template" + "net/http" +) + +// OAuth2AuthorizeHandler handles OAuth2/OIDC authorization requests with environment-based authentication. +// Uses AUTH_ALLOWED_USERNAME and AUTH_ALLOWED_PASSWORD from configuration. +// GET /oauth2/authorize - Display login form +// POST /oauth2/authorize - Process authentication +func OAuth2AuthorizeHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + handleOAuth2AuthorizeGET(w, r) + return + } + if r.Method == http.MethodPost { + handleOAuth2AuthorizePOST(w, r) + return + } + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +func handleOAuth2AuthorizeGET(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + scope := r.URL.Query().Get("scope") + responseType := r.URL.Query().Get("response_type") + state := r.URL.Query().Get("state") // Client-provided (optional) + codeChallenge := r.URL.Query().Get("code_challenge") + codeChallengeMethod := r.URL.Query().Get("code_challenge_method") + nonce := r.URL.Query().Get("nonce") // OIDC nonce parameter (optional) + + // Validate client_id (REQUIRED per OIDC spec) + if clientID == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "client_id parameter is required") + return + } + + // Validate client_id value if configured + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" && clientID != globalConfig.AuthAllowedClientID { + writeAuthorizationError(w, r, ErrorUnauthorizedClient, "unknown client_id", state, redirectURI) + return + } + + // Validate required parameters + if redirectURI == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "redirect_uri parameter is required") + return + } + + // Validate redirect_uri if validation is enabled + if globalConfig != nil && globalConfig.AuthCodeValidateRedirectURI { + var allowedPatterns []string + if globalConfig.AuthCodeAllowedRedirectURIs != "" { + // Split comma-separated patterns + for _, pattern := range splitScopes(globalConfig.AuthCodeAllowedRedirectURIs) { + if trimmed := pattern; trimmed != "" { + allowedPatterns = append(allowedPatterns, trimmed) + } + } + } + + if err := validateRedirectURI(redirectURI, allowedPatterns); err != nil { + writeAuthorizationError(w, r, ErrorInvalidRequest, "redirect_uri not in allowlist", state, redirectURI) + return + } + } + + if responseType != "code" { + writeAuthorizationError(w, r, ErrorUnsupportedResponseType, "only response_type=code is supported", state, redirectURI) + return + } + + // Validate and set default scope if not provided + if scope == "" { + scope = joinScopes(globalConfig.AuthSupportedScopes) + } else { + // Validate scopes + requestedScopes := splitScopes(scope) + for _, rs := range requestedScopes { + found := false + for _, ss := range globalConfig.AuthSupportedScopes { + if rs == ss { + found = true + break + } + } + if !found { + writeAuthorizationError(w, r, ErrorInvalidScope, fmt.Sprintf("unsupported scope: %s", rs), state, redirectURI) + return + } + } + } + + // Validate PKCE parameters + if globalConfig != nil && globalConfig.AuthCodeRequirePKCE && codeChallenge == "" { + writeAuthorizationError(w, r, ErrorInvalidRequest, "code_challenge is required", state, redirectURI) + return + } + + // If code_challenge is provided, validate method + if codeChallenge != "" { + // Default to "plain" if method not specified (per RFC 7636 Section 4.3) + if codeChallengeMethod == "" { + codeChallengeMethod = "plain" + } + + // Validate method is supported + if codeChallengeMethod != "plain" && codeChallengeMethod != "S256" { + writeAuthorizationError(w, r, ErrorInvalidRequest, "unsupported code_challenge_method", state, redirectURI) + return + } + } + + // Create a new session with PKCE parameters and nonce + session, err := DefaultSessionStore.CreateSession(state, redirectURI, scope, codeChallenge, codeChallengeMethod, nonce) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to create session") + return + } + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "oauth2_session", + Value: session.ID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + // Render login form + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := template.Must(template.New("login").Parse(oauth2LoginFormTemplate)) + data := struct { + State string + RedirectURI string + Scope string + AuthorizeURL string + }{ + State: session.State, + RedirectURI: redirectURI, + Scope: scope, + AuthorizeURL: "/oauth2/authorize", + } + _ = tmpl.Execute(w, data) +} + +func handleOAuth2AuthorizePOST(w http.ResponseWriter, r *http.Request) { + // Get session from cookie + cookie, err := r.Cookie("oauth2_session") + if err != nil { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "session not found") + return + } + + session, ok := DefaultSessionStore.GetSession(cookie.Value) + if !ok { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid or expired session") + return + } + + if err := r.ParseForm(); err != nil { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid form data") + return + } + + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + + // Validate required parameters + if username == "" || password == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "username and password are required") + return + } + + // Validate credentials against environment variables + if err := validateBasicAuthCredentials(username, password); err != nil { + writeOIDCError(w, http.StatusUnauthorized, ErrorAccessDenied, "invalid username or password") + return + } + + // Generate authorization code using session's redirect_uri, PKCE parameters, and nonce + authCode, err := DefaultSessionStore.CreateAuthCode(session.RedirectURI, username, session.Scope, session.CodeChallenge, session.CodeChallengeMethod, session.Nonce) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to create authorization code") + return + } + + // Delete the session as it's been used + DefaultSessionStore.DeleteSession(session.ID) + + // Clear session cookie + http.SetCookie(w, &http.Cookie{ + Name: "oauth2_session", + Value: "", + Path: "/", + MaxAge: -1, + }) + + // Redirect back to the client with the authorization code and state + redirectURL := session.RedirectURI + "?code=" + authCode.Code + if session.State != "" { + redirectURL += "&state=" + session.State + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +const oauth2LoginFormTemplate = ` + + + OAuth2 Login + + +

OAuth2 Login

+
+

+ +

+

+ +

+

+ +

+
+
+

Scope: {{.Scope}}

+

Redirect: {{.RedirectURI}}

+ +` diff --git a/echo-http/handlers/oauth2_authorize_test.go b/echo-http/handlers/oauth2_authorize_test.go new file mode 100644 index 0000000..48515ed --- /dev/null +++ b/echo-http/handlers/oauth2_authorize_test.go @@ -0,0 +1,216 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestOAuth2AuthorizeHandler_GET(t *testing.T) { + tests := []struct { + name string + config *Config + queryParams map[string]string + expectedCode int + expectCookie bool + }{ + { + name: "valid request", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthSupportedScopes: []string{"openid", "profile"}, + }, + queryParams: map[string]string{ + "client_id": "test-client", + "redirect_uri": "http://localhost/callback", + "response_type": "code", + "scope": "openid", + "state": "test-state", + }, + expectedCode: http.StatusOK, + expectCookie: true, + }, + { + name: "missing client_id", + config: &Config{}, + queryParams: map[string]string{ + "redirect_uri": "http://localhost/callback", + "response_type": "code", + }, + expectedCode: http.StatusBadRequest, + expectCookie: false, + }, + { + name: "missing redirect_uri", + config: &Config{ + AuthAllowedClientID: "test-client", + }, + queryParams: map[string]string{ + "client_id": "test-client", + "response_type": "code", + }, + expectedCode: http.StatusBadRequest, + expectCookie: false, + }, + { + name: "invalid response_type", + config: &Config{ + AuthAllowedClientID: "test-client", + }, + queryParams: map[string]string{ + "client_id": "test-client", + "redirect_uri": "http://localhost/callback", + "response_type": "token", + }, + expectedCode: http.StatusFound, // Redirect with error + expectCookie: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Build query string + query := url.Values{} + for k, v := range tt.queryParams { + query.Set(k, v) + } + + // Create request + req := httptest.NewRequest(http.MethodGet, "/oauth2/authorize?"+query.Encode(), nil) + w := httptest.NewRecorder() + + // Call handler + OAuth2AuthorizeHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + // Check cookie + cookies := w.Result().Cookies() + hasCookie := false + for _, c := range cookies { + if c.Name == "oauth2_session" { + hasCookie = true + break + } + } + if tt.expectCookie && !hasCookie { + t.Error("expected oauth2_session cookie") + } + }) + } +} + +func TestOAuth2AuthorizeHandler_POST(t *testing.T) { + tests := []struct { + name string + config *Config + setupSession func() string // Returns session ID + formData map[string]string + expectedCode int + }{ + { + name: "valid credentials", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + AuthSupportedScopes: []string{"openid"}, + }, + setupSession: func() string { + session, _ := DefaultSessionStore.CreateSession( + "test-state", + "http://localhost/callback", + "openid", + "", + "", + "", + ) + return session.ID + }, + formData: map[string]string{ + "username": "testuser", + "password": "testpass", + }, + expectedCode: http.StatusFound, // Redirect with code + }, + { + name: "invalid credentials", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + setupSession: func() string { + session, _ := DefaultSessionStore.CreateSession( + "test-state", + "http://localhost/callback", + "openid", + "", + "", + "", + ) + return session.ID + }, + formData: map[string]string{ + "username": "testuser", + "password": "wrongpass", + }, + expectedCode: http.StatusUnauthorized, + }, + { + name: "missing session", + config: &Config{}, + setupSession: func() string { + return "invalid-session-id" + }, + formData: map[string]string{ + "username": "testuser", + "password": "testpass", + }, + expectedCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Setup session + sessionID := tt.setupSession() + + // Build form data + formData := url.Values{} + for k, v := range tt.formData { + formData.Set(k, v) + } + + // Create request with session cookie + req := httptest.NewRequest(http.MethodPost, "/oauth2/authorize", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: "oauth2_session", + Value: sessionID, + }) + w := httptest.NewRecorder() + + // Call handler + OAuth2AuthorizeHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + }) + } +} diff --git a/echo-http/handlers/oauth2_discovery.go b/echo-http/handlers/oauth2_discovery.go new file mode 100644 index 0000000..d3dbd5a --- /dev/null +++ b/echo-http/handlers/oauth2_discovery.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// OAuth2MetadataResponse represents the OAuth 2.0 Authorization Server Metadata. +// Spec: RFC 8414 - OAuth 2.0 Authorization Server Metadata +type OAuth2MetadataResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported,omitempty"` + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + SubjectTypesSupported []string `json:"subject_types_supported,omitempty"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` + UserInfoEndpoint string `json:"userinfo_endpoint,omitempty"` +} + +// OAuth2MetadataHandler provides OAuth 2.0 Authorization Server Metadata. +// GET /.well-known/oauth-authorization-server +// Spec: RFC 8414 +func OAuth2MetadataHandler(w http.ResponseWriter, r *http.Request) { + baseURL := buildBaseURL(r) + + // Get allowed grant types from config + allowedGrantTypes := getAllowedGrantTypes() + + // Determine which endpoints to include based on allowed grant types + var authorizationEndpoint string + var responseTypesSupported []string + var codeChallengeMethodsSupported []string + + // Include authorization endpoint only if authorization_code is allowed + for _, gt := range allowedGrantTypes { + if gt == "authorization_code" { + authorizationEndpoint = baseURL + "/oauth2/authorize" + responseTypesSupported = []string{"code"} + codeChallengeMethodsSupported = []string{"plain", "S256"} + break + } + } + + // Get scopes from config, or use defaults if not configured + supportedScopes := []string{"openid", "profile", "email"} + if globalConfig != nil && len(globalConfig.AuthSupportedScopes) > 0 { + supportedScopes = globalConfig.AuthSupportedScopes + } + + metadata := OAuth2MetadataResponse{ + Issuer: baseURL, + AuthorizationEndpoint: authorizationEndpoint, + TokenEndpoint: baseURL + "/oauth2/token", + JwksURI: baseURL + "/.well-known/jwks.json", + ResponseTypesSupported: responseTypesSupported, + GrantTypesSupported: allowedGrantTypes, + SubjectTypesSupported: []string{ + "public", + }, + IDTokenSigningAlgValuesSupported: []string{ + "none", // Mock implementation - no actual JWT signing + }, + ScopesSupported: supportedScopes, + TokenEndpointAuthMethodsSupported: []string{ + "client_secret_post", + "client_secret_basic", + }, + CodeChallengeMethodsSupported: codeChallengeMethodsSupported, + UserInfoEndpoint: baseURL + "/oauth2/userinfo", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(metadata) +} + +// OIDCDiscoveryRootHandler provides OpenID Connect Discovery metadata for root path. +// GET /.well-known/openid-configuration +// Spec: OpenID Connect Discovery 1.0 +func OIDCDiscoveryRootHandler(w http.ResponseWriter, r *http.Request) { + baseURL := buildBaseURL(r) + + // Get allowed grant types from config + allowedGrantTypes := getAllowedGrantTypes() + + // Determine which endpoints to include based on allowed grant types + var authorizationEndpoint string + var responseTypesSupported []string + var codeChallengeMethodsSupported []string + + // Include authorization endpoint only if authorization_code is allowed + for _, gt := range allowedGrantTypes { + if gt == "authorization_code" { + authorizationEndpoint = baseURL + "/oauth2/authorize" + responseTypesSupported = []string{"code"} + codeChallengeMethodsSupported = []string{"plain", "S256"} + break + } + } + + // Get scopes from config, or use defaults if not configured + supportedScopes := []string{"openid", "profile", "email"} + if globalConfig != nil && len(globalConfig.AuthSupportedScopes) > 0 { + supportedScopes = globalConfig.AuthSupportedScopes + } + + // OIDC Discovery uses the same structure as OAuth2 metadata + // but is specifically for OIDC-compliant endpoints + discovery := OIDCDiscoveryResponse{ + Issuer: baseURL, + AuthorizationEndpoint: authorizationEndpoint, + TokenEndpoint: baseURL + "/oauth2/token", + UserInfoEndpoint: baseURL + "/oauth2/userinfo", + JwksURI: baseURL + "/.well-known/jwks.json", + ResponseTypesSupported: responseTypesSupported, + SubjectTypesSupported: []string{ + "public", + }, + IDTokenSigningAlgValuesSupported: []string{ + "none", // Mock implementation - no actual JWT signing + }, + ScopesSupported: supportedScopes, + GrantTypesSupported: allowedGrantTypes, + CodeChallengeMethodsSupported: codeChallengeMethodsSupported, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(discovery) +} + +// OAuth2JWKSHandler returns an empty JWKS (JSON Web Key Set) for root path. +// GET /.well-known/jwks.json +// Used by both OAuth2 and OIDC discovery endpoints. +func OAuth2JWKSHandler(w http.ResponseWriter, r *http.Request) { + // Return empty JWKS since we use alg="none" (no signature) + jwks := JWKSResponse{ + Keys: []interface{}{}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jwks) +} diff --git a/echo-http/handlers/oauth2_discovery_test.go b/echo-http/handlers/oauth2_discovery_test.go new file mode 100644 index 0000000..9d50871 --- /dev/null +++ b/echo-http/handlers/oauth2_discovery_test.go @@ -0,0 +1,185 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOAuth2MetadataHandler(t *testing.T) { + tests := []struct { + name string + config *Config + check func(*testing.T, *OAuth2MetadataResponse) + }{ + { + name: "default configuration", + config: &Config{ + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedGrantTypes: []string{"authorization_code", "client_credentials"}, + }, + check: func(t *testing.T, resp *OAuth2MetadataResponse) { + if resp.Issuer != "http://example.com" { + t.Errorf("expected issuer http://example.com, got %s", resp.Issuer) + } + if resp.TokenEndpoint != "http://example.com/oauth2/token" { + t.Errorf("unexpected token_endpoint: %s", resp.TokenEndpoint) + } + if len(resp.GrantTypesSupported) != 2 { + t.Errorf("expected 2 grant types, got %d", len(resp.GrantTypesSupported)) + } + // Should include authorization endpoint when authorization_code is allowed + if resp.AuthorizationEndpoint != "http://example.com/oauth2/authorize" { + t.Errorf("expected authorization_endpoint, got %s", resp.AuthorizationEndpoint) + } + }, + }, + { + name: "only client_credentials allowed", + config: &Config{ + AuthSupportedScopes: []string{"openid"}, + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + check: func(t *testing.T, resp *OAuth2MetadataResponse) { + // Should NOT include authorization endpoint when authorization_code is not allowed + if resp.AuthorizationEndpoint != "" { + t.Errorf("expected no authorization_endpoint for client_credentials only, got %s", resp.AuthorizationEndpoint) + } + if len(resp.ResponseTypesSupported) != 0 { + t.Error("expected no response_types_supported for client_credentials only") + } + }, + }, + { + name: "PKCE support included", + config: &Config{ + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + check: func(t *testing.T, resp *OAuth2MetadataResponse) { + if len(resp.CodeChallengeMethodsSupported) != 2 { + t.Errorf("expected 2 code_challenge_methods, got %d", len(resp.CodeChallengeMethodsSupported)) + } + found := make(map[string]bool) + for _, method := range resp.CodeChallengeMethodsSupported { + found[method] = true + } + if !found["plain"] || !found["S256"] { + t.Error("expected plain and S256 methods") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Create request + req := httptest.NewRequest(http.MethodGet, "http://example.com/.well-known/oauth-authorization-server", nil) + w := httptest.NewRecorder() + + // Call handler + OAuth2MetadataHandler(w, req) + + // Check status code + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + // Decode response + var resp OAuth2MetadataResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Run custom checks + if tt.check != nil { + tt.check(t, &resp) + } + }) + } +} + +func TestOIDCDiscoveryRootHandler(t *testing.T) { + tests := []struct { + name string + config *Config + check func(*testing.T, *OIDCDiscoveryResponse) + }{ + { + name: "default configuration", + config: &Config{ + AuthSupportedScopes: []string{"openid", "profile"}, + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + check: func(t *testing.T, resp *OIDCDiscoveryResponse) { + if resp.Issuer != "http://example.com" { + t.Errorf("expected issuer http://example.com, got %s", resp.Issuer) + } + if resp.TokenEndpoint != "http://example.com/oauth2/token" { + t.Errorf("unexpected token_endpoint: %s", resp.TokenEndpoint) + } + if resp.UserInfoEndpoint != "http://example.com/oauth2/userinfo" { + t.Errorf("unexpected userinfo_endpoint: %s", resp.UserInfoEndpoint) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Create request + req := httptest.NewRequest(http.MethodGet, "http://example.com/.well-known/openid-configuration", nil) + w := httptest.NewRecorder() + + // Call handler + OIDCDiscoveryRootHandler(w, req) + + // Check status code + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + // Decode response + var resp OIDCDiscoveryResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Run custom checks + if tt.check != nil { + tt.check(t, &resp) + } + }) + } +} + +func TestOAuth2JWKSHandler(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/.well-known/jwks.json", nil) + w := httptest.NewRecorder() + + OAuth2JWKSHandler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp JWKSResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Should return empty keys array (alg=none) + if len(resp.Keys) != 0 { + t.Errorf("expected empty keys array, got %d keys", len(resp.Keys)) + } +} diff --git a/echo-http/handlers/oauth2_endpoints.go b/echo-http/handlers/oauth2_endpoints.go new file mode 100644 index 0000000..09e516e --- /dev/null +++ b/echo-http/handlers/oauth2_endpoints.go @@ -0,0 +1,296 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "strings" +) + +// OAuth2CallbackHandler handles the callback from the authorization server. +// GET /oauth2/callback?code={code}&state={state} +func OAuth2CallbackHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + // Build token endpoint URL + baseURL := buildBaseURL(r) + tokenEndpoint := baseURL + "/oauth2/token" + + // Render callback page + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := template.Must(template.New("callback").Parse(oauth2CallbackTemplate)) + data := struct { + Code string + State string + Error string + TokenEndpoint string + }{ + Code: code, + State: state, + TokenEndpoint: tokenEndpoint, + } + + if code == "" { + data.Error = "No authorization code received" + } + + _ = tmpl.Execute(w, data) +} + +// OAuth2UserInfoHandler returns user information based on the access token. +// GET /oauth2/userinfo +// Requires Bearer token in Authorization header. +func OAuth2UserInfoHandler(w http.ResponseWriter, r *http.Request) { + // Extract Bearer token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + hint := buildUserInfoHint(r) + writeOIDCErrorWithHint(w, http.StatusUnauthorized, ErrorInvalidRequest, "missing authorization header", hint) + return + } + + // Validate Bearer scheme + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + hint := buildUserInfoHint(r) + writeOIDCErrorWithHint(w, http.StatusUnauthorized, ErrorInvalidRequest, "invalid authorization scheme", hint) + return + } + + // In a real implementation, we would validate the access token + // For this mock server, we accept any non-empty Bearer token + accessToken := parts[1] + if accessToken == "" { + hint := buildUserInfoHint(r) + writeOIDCErrorWithHint(w, http.StatusUnauthorized, ErrorInvalidRequest, "empty access token", hint) + return + } + + // Return generic user information (mock implementation) + // In a real implementation, we would look up the user associated with the token + username := "mockuser" + if globalConfig != nil && globalConfig.AuthAllowedUsername != "" { + username = globalConfig.AuthAllowedUsername + } + + userInfo := map[string]interface{}{ + "sub": username, + "name": username, + "email": fmt.Sprintf("%s@example.com", username), + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userInfo) +} + +// OAuth2DemoHandler provides an interactive demo of the OAuth2/OIDC flow. +// GET /oauth2/demo +func OAuth2DemoHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + errorParam := r.URL.Query().Get("error") + + // If no code and no error, initiate OAuth2 flow + if code == "" && errorParam == "" { + // Generate state for CSRF protection (demo client generates its own state) + demoState, err := generateRandomString(16) + if err != nil { + http.Error(w, "failed to generate state", http.StatusInternalServerError) + return + } + + // Store state in cookie for later verification + http.SetCookie(w, &http.Cookie{ + Name: "oauth2_demo_state", + Value: demoState, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + // Build redirect URI for this demo page + baseURL := buildBaseURL(r) + redirectURI := baseURL + "/oauth2/demo" + authorizeURL := fmt.Sprintf("/oauth2/authorize?client_id=demo-client&redirect_uri=%s&response_type=code&scope=openid%%20profile%%20email&state=%s", + url.QueryEscape(redirectURI), demoState) + + http.Redirect(w, r, authorizeURL, http.StatusFound) + return + } + + // Verify state matches (CSRF protection) + cookie, err := r.Cookie("oauth2_demo_state") + if err == nil && cookie.Value != state { + errorParam = "state_mismatch" + } + + // Clear demo state cookie + http.SetCookie(w, &http.Cookie{ + Name: "oauth2_demo_state", + Value: "", + Path: "/", + MaxAge: -1, + }) + + // Render demo page with code/error + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := template.Must(template.New("demo").Parse(oauth2DemoPageTemplate)) + + // Build URLs + baseURL := buildBaseURL(r) + tokenEndpoint := baseURL + "/oauth2/token" + redirectURI := baseURL + "/oauth2/demo" + + data := struct { + Code string + State string + Error string + TokenEndpoint string + RedirectURI string + }{ + Code: code, + State: state, + Error: errorParam, + TokenEndpoint: tokenEndpoint, + RedirectURI: redirectURI, + } + + _ = tmpl.Execute(w, data) +} + +const oauth2CallbackTemplate = ` + + + OAuth2 Callback + + + + +

OAuth2 Callback

+ {{if .Error}} +

Error: {{.Error}}

+ {{else}} +

Authorization successful

+

Authorization Code

+
{{.Code}}
+

State

+
{{.State}}
+

Token Exchange

+

+ +

+ +
+

Tokens

+

+        
+ {{end}} + +` + +const oauth2DemoPageTemplate = ` + + + OAuth2 Demo + + + + +

OAuth2 Demo

+ {{if .Error}} +

Error: {{.Error}}

+ {{else}} +

Authorization successful

+

Authorization Code

+
{{.Code}}
+

State

+
{{.State}}
+

Token Exchange

+

+ +

+ +
+

Tokens

+

+        
+ {{end}} + +` diff --git a/echo-http/handlers/oauth2_endpoints_test.go b/echo-http/handlers/oauth2_endpoints_test.go new file mode 100644 index 0000000..55cf940 --- /dev/null +++ b/echo-http/handlers/oauth2_endpoints_test.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOAuth2CallbackHandler(t *testing.T) { + tests := []struct { + name string + queryParams string + expectedCode int + checkBody bool + }{ + { + name: "with code and state", + queryParams: "?code=test-code&state=test-state", + expectedCode: http.StatusOK, + checkBody: true, + }, + { + name: "without code", + queryParams: "?state=test-state", + expectedCode: http.StatusOK, + checkBody: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/oauth2/callback"+tt.queryParams, nil) + w := httptest.NewRecorder() + + OAuth2CallbackHandler(w, req) + + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + if tt.checkBody { + body := w.Body.String() + if body == "" { + t.Error("expected HTML body") + } + } + }) + } +} + +func TestOAuth2UserInfoHandler(t *testing.T) { + tests := []struct { + name string + config *Config + authHeader string + expectedCode int + checkJSON bool + }{ + { + name: "valid bearer token", + config: &Config{ + AuthAllowedUsername: "testuser", + }, + authHeader: "Bearer test-token", + expectedCode: http.StatusOK, + checkJSON: true, + }, + { + name: "missing authorization header", + config: &Config{}, + authHeader: "", + expectedCode: http.StatusUnauthorized, + checkJSON: false, + }, + { + name: "invalid scheme", + config: &Config{}, + authHeader: "Basic dGVzdDp0ZXN0", + expectedCode: http.StatusUnauthorized, + checkJSON: false, + }, + { + name: "empty token", + config: &Config{}, + authHeader: "Bearer ", + expectedCode: http.StatusUnauthorized, + checkJSON: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + req := httptest.NewRequest(http.MethodGet, "/oauth2/userinfo", nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + w := httptest.NewRecorder() + + OAuth2UserInfoHandler(w, req) + + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + if tt.checkJSON { + var userInfo map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&userInfo); err != nil { + t.Fatalf("failed to decode JSON: %v", err) + } + if _, ok := userInfo["sub"]; !ok { + t.Error("expected 'sub' field in userinfo") + } + } + }) + } +} + +func TestOAuth2DemoHandler(t *testing.T) { + tests := []struct { + name string + queryParams string + expectedCode int + }{ + { + name: "initiate flow", + queryParams: "", + expectedCode: http.StatusFound, // Redirect to authorize + }, + { + name: "callback with code", + queryParams: "?code=test-code&state=test-state", + expectedCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = &Config{ + AuthSupportedScopes: []string{"openid", "profile"}, + } + defer func() { globalConfig = originalConfig }() + + req := httptest.NewRequest(http.MethodGet, "/oauth2/demo"+tt.queryParams, nil) + w := httptest.NewRecorder() + + OAuth2DemoHandler(w, req) + + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + }) + } +} diff --git a/echo-http/handlers/oauth2_error.go b/echo-http/handlers/oauth2_error.go new file mode 100644 index 0000000..881d19e --- /dev/null +++ b/echo-http/handlers/oauth2_error.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// OIDCError represents an OAuth 2.0/OIDC error response. +// Spec: RFC 6749 Section 5.2, OIDC Core Section 3.1.2.6 +type OIDCError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` + Hint string `json:"hint,omitempty"` // Non-standard but helpful for developers +} + +// Standard OAuth 2.0 error codes +const ( + ErrorInvalidRequest = "invalid_request" + ErrorUnauthorizedClient = "unauthorized_client" + ErrorAccessDenied = "access_denied" + ErrorUnsupportedResponseType = "unsupported_response_type" + ErrorInvalidScope = "invalid_scope" + ErrorServerError = "server_error" + ErrorTemporarilyUnavailable = "temporarily_unavailable" + ErrorInvalidClient = "invalid_client" + ErrorInvalidGrant = "invalid_grant" + ErrorUnsupportedGrantType = "unsupported_grant_type" +) + +// writeOIDCError writes an OAuth 2.0/OIDC compliant error response. +func writeOIDCError(w http.ResponseWriter, statusCode int, errorCode, description string) { + writeOIDCErrorWithHint(w, statusCode, errorCode, description, "") +} + +// writeOIDCErrorWithHint writes an OAuth 2.0/OIDC error response with an optional hint. +// The hint field contains helpful curl examples for developers. +func writeOIDCErrorWithHint(w http.ResponseWriter, statusCode int, errorCode, description, hint string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + errResp := OIDCError{ + Error: errorCode, + ErrorDescription: description, + Hint: hint, + } + + _ = json.NewEncoder(w).Encode(errResp) +} + +// writeAuthorizationError writes an error for authorization endpoint. +// Per OIDC spec, these errors should redirect to redirect_uri with error in query. +func writeAuthorizationError(w http.ResponseWriter, r *http.Request, errorCode, description, state, redirectURI string) { + if redirectURI == "" { + // No redirect_uri, return JSON error + writeOIDCError(w, http.StatusBadRequest, errorCode, description) + return + } + + // Build error redirect + redirectURL, err := url.Parse(redirectURI) + if err != nil { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid redirect_uri") + return + } + + query := redirectURL.Query() + query.Set("error", errorCode) + if description != "" { + query.Set("error_description", description) + } + if state != "" { + query.Set("state", state) + } + redirectURL.RawQuery = query.Encode() + + http.Redirect(w, r, redirectURL.String(), http.StatusFound) +} + +// buildClientCredentialsHint builds a hint for client_credentials grant errors. +func buildClientCredentialsHint(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + tokenURL := fmt.Sprintf("%s://%s/oauth2/token", scheme, r.Host) + + var clientID, clientSecret string + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" { + clientID = globalConfig.AuthAllowedClientID + clientSecret = globalConfig.AuthAllowedClientSecret + if clientSecret == "" { + clientSecret = "" + } + } else { + clientID = "your-client-id" + clientSecret = "your-client-secret" + } + + return fmt.Sprintf(`Example usage: + curl -X POST %s \ + -d "grant_type=client_credentials" \ + -d "client_id=%s" \ + -d "client_secret=%s" + +Configure via environment variables: + AUTH_ALLOWED_CLIENT_ID=%s + AUTH_ALLOWED_CLIENT_SECRET=%s + AUTH_ALLOWED_GRANT_TYPES=client_credentials`, tokenURL, clientID, clientSecret, clientID, clientSecret) +} + +// buildPasswordGrantHint builds a hint for password grant errors. +func buildPasswordGrantHint(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + tokenURL := fmt.Sprintf("%s://%s/oauth2/token", scheme, r.Host) + + var clientID, username, password string + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" { + clientID = globalConfig.AuthAllowedClientID + username = globalConfig.AuthAllowedUsername + password = globalConfig.AuthAllowedPassword + if username == "" { + username = "username" + } + if password == "" { + password = "password" + } + } else { + clientID = "your-client-id" + username = "username" + password = "password" + } + + return fmt.Sprintf(`Example usage: + curl -X POST %s \ + -d "grant_type=password" \ + -d "client_id=%s" \ + -d "username=%s" \ + -d "password=%s" \ + -d "scope=openid profile" + +Configure via environment variables: + AUTH_ALLOWED_CLIENT_ID=%s + AUTH_ALLOWED_USERNAME=%s + AUTH_ALLOWED_PASSWORD=%s + AUTH_ALLOWED_GRANT_TYPES=password`, tokenURL, clientID, username, password, clientID, username, password) +} + +// buildAuthorizationCodeHint builds a hint for authorization_code grant errors. +func buildAuthorizationCodeHint(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s", scheme, r.Host) + + var clientID string + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" { + clientID = globalConfig.AuthAllowedClientID + } else { + clientID = "your-client-id" + } + + return fmt.Sprintf(`The authorization code is invalid or expired. Start the flow again: + +1. Get authorization code: + %s/oauth2/authorize?client_id=%s&redirect_uri=http://localhost/callback&response_type=code&scope=openid + +2. Exchange code for token: + curl -X POST %s/oauth2/token \ + -d "grant_type=authorization_code" \ + -d "client_id=%s" \ + -d "code=YOUR_AUTH_CODE" \ + -d "redirect_uri=http://localhost/callback" + +Or try the demo page: + %s/oauth2/demo`, baseURL, clientID, baseURL, clientID, baseURL) +} + +// buildRefreshTokenHint builds a hint for refresh_token grant errors. +func buildRefreshTokenHint(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s", scheme, r.Host) + + var clientID string + if globalConfig != nil && globalConfig.AuthAllowedClientID != "" { + clientID = globalConfig.AuthAllowedClientID + } else { + clientID = "your-client-id" + } + + return fmt.Sprintf(`The refresh token is invalid or expired. Re-authenticate to get a new refresh token: + +Start authorization flow: + %s/oauth2/authorize?client_id=%s&redirect_uri=http://localhost/callback&response_type=code&scope=openid + +Or use password grant (if enabled): + curl -X POST %s/oauth2/token \ + -d "grant_type=password" \ + -d "client_id=%s" \ + -d "username=..." \ + -d "password=..."`, baseURL, clientID, baseURL, clientID) +} + +// buildUserInfoHint builds a hint for UserInfo endpoint errors. +func buildUserInfoHint(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s", scheme, r.Host) + + return fmt.Sprintf(`This endpoint requires a valid Bearer token. + +1. Get a token first: + curl -X POST %s/oauth2/token \ + -d "grant_type=client_credentials" \ + -d "client_id=your-client-id" \ + -d "client_secret=your-client-secret" + +2. Use the token: + curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" %s/oauth2/userinfo + +Or use the demo page to complete the flow: + %s/oauth2/demo`, baseURL, baseURL, baseURL) +} diff --git a/echo-http/handlers/oauth2_error_test.go b/echo-http/handlers/oauth2_error_test.go new file mode 100644 index 0000000..653eef6 --- /dev/null +++ b/echo-http/handlers/oauth2_error_test.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestWriteOIDCError(t *testing.T) { + tests := []struct { + name string + statusCode int + errorCode string + description string + expectedStatusCode int + expectedError string + expectedDesc string + }{ + { + name: "invalid_request error", + statusCode: http.StatusBadRequest, + errorCode: "invalid_request", + description: "client_id parameter is required", + expectedStatusCode: http.StatusBadRequest, + expectedError: "invalid_request", + expectedDesc: "client_id parameter is required", + }, + { + name: "invalid_client error", + statusCode: http.StatusUnauthorized, + errorCode: "invalid_client", + description: "unknown client_id", + expectedStatusCode: http.StatusUnauthorized, + expectedError: "invalid_client", + expectedDesc: "unknown client_id", + }, + { + name: "error without description", + statusCode: http.StatusBadRequest, + errorCode: "invalid_scope", + description: "", + expectedStatusCode: http.StatusBadRequest, + expectedError: "invalid_scope", + expectedDesc: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + + writeOIDCError(rec, tt.statusCode, tt.errorCode, tt.description) + + // Verify status code + if rec.Code != tt.expectedStatusCode { + t.Errorf("expected status code %d, got %d", tt.expectedStatusCode, rec.Code) + } + + // Verify Content-Type + contentType := rec.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", contentType) + } + + // Verify response body + var errResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + } + + if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { + t.Fatalf("failed to parse response body: %v", err) + } + + if errResp.Error != tt.expectedError { + t.Errorf("expected error %q, got %q", tt.expectedError, errResp.Error) + } + + if errResp.ErrorDescription != tt.expectedDesc { + t.Errorf("expected error_description %q, got %q", tt.expectedDesc, errResp.ErrorDescription) + } + }) + } +} + +func TestWriteAuthorizationError(t *testing.T) { + tests := []struct { + name string + errorCode string + description string + state string + redirectURI string + expectedStatusCode int + expectRedirect bool + expectJSONError bool + }{ + { + name: "redirect with error and state", + errorCode: "unauthorized_client", + description: "unknown client_id", + state: "xyz123", + redirectURI: "http://localhost/callback", + expectedStatusCode: http.StatusFound, + expectRedirect: true, + expectJSONError: false, + }, + { + name: "redirect with error without state", + errorCode: "invalid_scope", + description: "unsupported scope: admin", + state: "", + redirectURI: "http://localhost/callback", + expectedStatusCode: http.StatusFound, + expectRedirect: true, + expectJSONError: false, + }, + { + name: "no redirect_uri returns JSON error", + errorCode: "invalid_request", + description: "redirect_uri parameter is required", + state: "", + redirectURI: "", + expectedStatusCode: http.StatusBadRequest, + expectRedirect: false, + expectJSONError: true, + }, + { + name: "invalid redirect_uri returns JSON error", + errorCode: "invalid_request", + description: "some error", + state: "", + redirectURI: "://invalid-url", + expectedStatusCode: http.StatusBadRequest, + expectRedirect: false, + expectJSONError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/authorize", nil) + rec := httptest.NewRecorder() + + writeAuthorizationError(rec, req, tt.errorCode, tt.description, tt.state, tt.redirectURI) + + // Verify status code + if rec.Code != tt.expectedStatusCode { + t.Errorf("expected status code %d, got %d", tt.expectedStatusCode, rec.Code) + } + + if tt.expectRedirect { + // Verify redirect + location := rec.Header().Get("Location") + if location == "" { + t.Fatal("expected Location header, got none") + } + + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("failed to parse redirect URL: %v", err) + } + + query := redirectURL.Query() + + // Verify error parameter + if query.Get("error") != tt.errorCode { + t.Errorf("expected error=%q, got %q", tt.errorCode, query.Get("error")) + } + + // Verify error_description parameter + if tt.description != "" && query.Get("error_description") != tt.description { + t.Errorf("expected error_description=%q, got %q", tt.description, query.Get("error_description")) + } + + // Verify state parameter + if tt.state != "" && query.Get("state") != tt.state { + t.Errorf("expected state=%q, got %q", tt.state, query.Get("state")) + } + } + + if tt.expectJSONError { + // Verify JSON error response + contentType := rec.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", contentType) + } + + var errResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + } + + if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { + t.Fatalf("failed to parse response body: %v", err) + } + + // For invalid redirect_uri case, error code should be "invalid_request" + if tt.redirectURI == "://invalid-url" { + if errResp.Error != "invalid_request" { + t.Errorf("expected error=invalid_request for invalid redirect_uri, got %q", errResp.Error) + } + } else { + if errResp.Error != tt.errorCode { + t.Errorf("expected error %q, got %q", tt.errorCode, errResp.Error) + } + } + } + }) + } +} diff --git a/echo-http/handlers/oauth2_session.go b/echo-http/handlers/oauth2_session.go new file mode 100644 index 0000000..dd7076f --- /dev/null +++ b/echo-http/handlers/oauth2_session.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// Session represents an OIDC session +type Session struct { + ID string + State string // Client-provided state (optional, may be empty) + RedirectURI string + Scope string + CodeChallenge string // PKCE code_challenge parameter + CodeChallengeMethod string // PKCE method: "plain" or "S256" + Nonce string // OIDC nonce parameter for replay attack protection + CreatedAt time.Time +} + +// AuthCode represents an authorization code issued after authentication +type AuthCode struct { + Code string + RedirectURI string + Username string + Scope string + CodeChallenge string // PKCE code_challenge parameter + CodeChallengeMethod string // PKCE method: "plain" or "S256" + Nonce string // OIDC nonce parameter for replay attack protection + CreatedAt time.Time +} + +// RefreshToken represents a refresh token for obtaining new access tokens +type RefreshToken struct { + Token string + Username string + ClientID string + Scope string + Nonce string // OIDC nonce parameter (preserved from original authorization) + CreatedAt time.Time +} + +// SessionStore provides in-memory storage for OIDC sessions and authorization codes +type SessionStore struct { + sessions map[string]*Session // key = session ID + authCodes map[string]*AuthCode + refreshTokens map[string]*RefreshToken + mu sync.RWMutex + ttl time.Duration + refreshTTL time.Duration // Separate TTL for refresh tokens (longer than auth codes) +} + +var ( + // DefaultSessionStore is the global session store instance + DefaultSessionStore = NewSessionStore(5 * time.Minute) +) + +// NewSessionStore creates a new session store with the given TTL +func NewSessionStore(ttl time.Duration) *SessionStore { + store := &SessionStore{ + sessions: make(map[string]*Session), + authCodes: make(map[string]*AuthCode), + refreshTokens: make(map[string]*RefreshToken), + ttl: ttl, + refreshTTL: 24 * time.Hour, // Refresh tokens live much longer + } + // Start cleanup goroutine + go store.cleanup() + return store +} + +// CreateSession creates a new session with optional client-provided state, PKCE parameters, and nonce +func (s *SessionStore) CreateSession(state, redirectURI, scope, codeChallenge, codeChallengeMethod, nonce string) (*Session, error) { + sessionID, err := generateRandomString(32) + if err != nil { + return nil, err + } + + session := &Session{ + ID: sessionID, + State: state, // Client-provided (may be empty) + RedirectURI: redirectURI, + Scope: scope, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + Nonce: nonce, + CreatedAt: time.Now(), + } + + s.mu.Lock() + s.sessions[sessionID] = session + s.mu.Unlock() + + return session, nil +} + +// GetSession retrieves a session by session ID +func (s *SessionStore) GetSession(sessionID string) (*Session, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + session, ok := s.sessions[sessionID] + if !ok { + return nil, false + } + + // Check if session is expired + if time.Since(session.CreatedAt) > s.ttl { + return nil, false + } + + return session, true +} + +// DeleteSession removes a session by session ID +func (s *SessionStore) DeleteSession(sessionID string) { + s.mu.Lock() + delete(s.sessions, sessionID) + s.mu.Unlock() +} + +// CreateAuthCode creates a new authorization code with PKCE parameters and nonce +func (s *SessionStore) CreateAuthCode(redirectURI, username, scope, codeChallenge, codeChallengeMethod, nonce string) (*AuthCode, error) { + code, err := generateRandomString(32) + if err != nil { + return nil, err + } + + authCode := &AuthCode{ + Code: code, + RedirectURI: redirectURI, + Username: username, + Scope: scope, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + Nonce: nonce, + CreatedAt: time.Now(), + } + + s.mu.Lock() + s.authCodes[code] = authCode + s.mu.Unlock() + + return authCode, nil +} + +// GetAuthCode retrieves an authorization code +func (s *SessionStore) GetAuthCode(code string) (*AuthCode, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + authCode, ok := s.authCodes[code] + if !ok { + return nil, false + } + + // Check if auth code is expired + if time.Since(authCode.CreatedAt) > s.ttl { + return nil, false + } + + return authCode, true +} + +// DeleteAuthCode removes an authorization code (single-use) +func (s *SessionStore) DeleteAuthCode(code string) { + s.mu.Lock() + delete(s.authCodes, code) + s.mu.Unlock() +} + +// CreateRefreshToken creates a new refresh token +func (s *SessionStore) CreateRefreshToken(username, clientID, scope, nonce string) (*RefreshToken, error) { + token, err := generateRandomString(32) + if err != nil { + return nil, err + } + + refreshToken := &RefreshToken{ + Token: token, + Username: username, + ClientID: clientID, + Scope: scope, + Nonce: nonce, + CreatedAt: time.Now(), + } + + s.mu.Lock() + s.refreshTokens[token] = refreshToken + s.mu.Unlock() + + return refreshToken, nil +} + +// GetRefreshToken retrieves a refresh token +func (s *SessionStore) GetRefreshToken(token string) (*RefreshToken, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + refreshToken, ok := s.refreshTokens[token] + if !ok { + return nil, false + } + + // Check if refresh token is expired + if time.Since(refreshToken.CreatedAt) > s.refreshTTL { + return nil, false + } + + return refreshToken, true +} + +// DeleteRefreshToken removes a refresh token +func (s *SessionStore) DeleteRefreshToken(token string) { + s.mu.Lock() + delete(s.refreshTokens, token) + s.mu.Unlock() +} + +// cleanup periodically removes expired sessions and auth codes +func (s *SessionStore) cleanup() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + s.mu.Lock() + now := time.Now() + + // Clean up expired sessions + for sessionID, session := range s.sessions { + if now.Sub(session.CreatedAt) > s.ttl { + delete(s.sessions, sessionID) + } + } + + // Clean up expired auth codes + for code, authCode := range s.authCodes { + if now.Sub(authCode.CreatedAt) > s.ttl { + delete(s.authCodes, code) + } + } + + // Clean up expired refresh tokens + for token, refreshToken := range s.refreshTokens { + if now.Sub(refreshToken.CreatedAt) > s.refreshTTL { + delete(s.refreshTokens, token) + } + } + + s.mu.Unlock() + } +} + +// generateRandomString generates a cryptographically secure random string +func generateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/echo-http/handlers/oauth2_token.go b/echo-http/handlers/oauth2_token.go new file mode 100644 index 0000000..ec08f69 --- /dev/null +++ b/echo-http/handlers/oauth2_token.go @@ -0,0 +1,461 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// OAuth2TokenHandler is the unified token endpoint for OAuth2/OIDC flows. +// Supports both authorization_code and client_credentials grant types. +// POST /oauth2/token +func OAuth2TokenHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid form data") + return + } + + grantType := r.PostForm.Get("grant_type") + + // Validate grant_type is provided + if err := validateGrantType(grantType, getAllowedGrantTypes()); err != nil { + writeOIDCError(w, http.StatusBadRequest, ErrorUnsupportedGrantType, err.Error()) + return + } + + // Route to appropriate grant handler + switch grantType { + case "authorization_code": + handleAuthorizationCodeGrant(w, r) + case "client_credentials": + handleClientCredentialsGrant(w, r) + case "password": + handlePasswordGrant(w, r) + case "refresh_token": + handleRefreshTokenGrant(w, r) + default: + // This should never happen after validateGrantType, but handle defensively + writeOIDCError(w, http.StatusBadRequest, ErrorUnsupportedGrantType, fmt.Sprintf("unsupported grant_type: %s", grantType)) + } +} + +// handleClientCredentialsGrant handles the OAuth2 Client Credentials flow. +// Returns only access_token (no id_token, as there is no user context). +// RFC 6749 Section 4.4 +func handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) { + clientID := r.PostForm.Get("client_id") + clientSecret := r.PostForm.Get("client_secret") + scope := r.PostForm.Get("scope") + + // Validate client credentials (client_secret is required for confidential clients) + if err := validateClientCredentials(clientID, clientSecret, true); err != nil { + hint := buildClientCredentialsHint(r) + writeOIDCErrorWithHint(w, http.StatusUnauthorized, ErrorInvalidClient, err.Error(), hint) + return + } + + // Validate and set default scope if not provided + if scope == "" { + scope = joinScopes(globalConfig.AuthSupportedScopes) + } else { + // Split and validate requested scopes + requestedScopes := splitScopes(scope) + for _, rs := range requestedScopes { + found := false + for _, ss := range globalConfig.AuthSupportedScopes { + if rs == ss { + found = true + break + } + } + if !found { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidScope, fmt.Sprintf("unsupported scope: %s", rs)) + return + } + } + } + + // Generate access token + accessToken, err := generateRandomString(32) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to generate access token") + return + } + + // Get token expiry from config + expiresIn := 3600 // Default 1 hour + if globalConfig != nil && globalConfig.AuthTokenExpiry > 0 { + expiresIn = globalConfig.AuthTokenExpiry + } + + // Client Credentials flow does NOT include id_token or refresh_token + response := TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + Scope: scope, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// handleAuthorizationCodeGrant handles the OAuth2 Authorization Code flow with OIDC extension. +// Returns access_token, refresh_token, and id_token (OIDC). +// RFC 6749 Section 4.1 + OpenID Connect Core 1.0 +func handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { + code := r.PostForm.Get("code") + redirectURI := r.PostForm.Get("redirect_uri") + clientID := r.PostForm.Get("client_id") + clientSecret := r.PostForm.Get("client_secret") + codeVerifier := r.PostForm.Get("code_verifier") + + // Validate client_id (REQUIRED per OIDC spec) + if clientID == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "client_id parameter is required") + return + } + + // Determine if client_secret is required based on configuration + requireSecret := globalConfig != nil && globalConfig.AuthAllowedClientSecret != "" + + // Validate client credentials + if err := validateClientCredentials(clientID, clientSecret, requireSecret); err != nil { + writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidClient, err.Error()) + return + } + + // Validate required parameters + if code == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "code parameter is required") + return + } + + if redirectURI == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "redirect_uri parameter is required") + return + } + + // Validate authorization code + authCode, ok := DefaultSessionStore.GetAuthCode(code) + if !ok { + hint := buildAuthorizationCodeHint(r) + writeOIDCErrorWithHint(w, http.StatusBadRequest, ErrorInvalidGrant, "invalid or expired authorization code", hint) + return + } + + // Validate redirect URI matches + if authCode.RedirectURI != redirectURI { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "redirect_uri mismatch") + return + } + + // Validate PKCE if code_challenge was provided during authorization + if authCode.CodeChallenge != "" { + // code_verifier is required when code_challenge was used + if codeVerifier == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "code_verifier is required") + return + } + + // RFC 7636 Section 4.1: code_verifier length must be 43-128 characters + if len(codeVerifier) < 43 || len(codeVerifier) > 128 { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "code_verifier length must be between 43 and 128 characters (RFC 7636)") + return + } + + // Verify code_verifier against code_challenge + if !verifyPKCECodeChallenge(authCode.CodeChallenge, authCode.CodeChallengeMethod, codeVerifier) { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "invalid code_verifier") + return + } + } + + // Delete the authorization code (single-use) + DefaultSessionStore.DeleteAuthCode(code) + + // Generate access token + accessToken, err := generateRandomString(32) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to generate access token") + return + } + + // Create refresh token and store it + refreshTokenObj, err := DefaultSessionStore.CreateRefreshToken(authCode.Username, clientID, authCode.Scope, authCode.Nonce) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to generate refresh token") + return + } + + // Build issuer URL for ID token (base URL only for new endpoint) + issuer := buildBaseURL(r) + + // Get token expiry from config + expiresIn := 3600 // Default 1 hour + if globalConfig != nil && globalConfig.AuthTokenExpiry > 0 { + expiresIn = globalConfig.AuthTokenExpiry + } + + // Create ID token in JWT format with actual issuer, client_id, and nonce + idToken := generateOAuth2IDToken(issuer, clientID, authCode.Username, authCode.Nonce, expiresIn) + + response := TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + RefreshToken: refreshTokenObj.Token, + IDToken: idToken, + Scope: authCode.Scope, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// generateOAuth2IDToken creates a mock ID token in JWT format with algorithm "none". +// Returns a JWT in the format: header.payload.signature (where signature is empty for alg=none). +// Used by the new OAuth2 endpoint (non-deprecated). +func generateOAuth2IDToken(issuer, clientID, username, nonce string, expiresIn int) string { + // Header for JWT with alg="none" + header := map[string]string{ + "alg": "none", + "typ": "JWT", + } + headerJSON, _ := json.Marshal(header) + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + + // Payload (claims) + claims := map[string]interface{}{ + "iss": issuer, + "sub": username, + "aud": clientID, + "exp": time.Now().Add(time.Duration(expiresIn) * time.Second).Unix(), + "iat": time.Now().Unix(), + "name": username, + "email": fmt.Sprintf("%s@example.com", username), + } + // Include nonce claim only if provided + if nonce != "" { + claims["nonce"] = nonce + } + claimsJSON, _ := json.Marshal(claims) + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + + // JWT format: header.payload.signature (empty signature for "none") + return headerB64 + "." + claimsB64 + "." +} + +// handlePasswordGrant handles the OAuth2 Resource Owner Password Credentials flow. +// Returns access_token, refresh_token, and optionally id_token (if openid scope requested). +// RFC 6749 Section 4.3 (deprecated in OAuth 2.1, but useful for testing) +func handlePasswordGrant(w http.ResponseWriter, r *http.Request) { + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + clientID := r.PostForm.Get("client_id") + clientSecret := r.PostForm.Get("client_secret") + scope := r.PostForm.Get("scope") + + // Validate client_id (REQUIRED) + if clientID == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "client_id parameter is required") + return + } + + // Determine if client_secret is required based on configuration + requireSecret := globalConfig != nil && globalConfig.AuthAllowedClientSecret != "" + + // Validate client credentials + if err := validateClientCredentials(clientID, clientSecret, requireSecret); err != nil { + hint := buildPasswordGrantHint(r) + writeOIDCErrorWithHint(w, http.StatusUnauthorized, ErrorInvalidClient, err.Error(), hint) + return + } + + // Validate username and password against configured credentials + if err := validateBasicAuthCredentials(username, password); err != nil { + hint := buildPasswordGrantHint(r) + writeOIDCErrorWithHint(w, http.StatusUnauthorized, ErrorInvalidGrant, "invalid username or password", hint) + return + } + + // Validate and set default scope if not provided + if scope == "" { + scope = joinScopes(globalConfig.AuthSupportedScopes) + } else { + // Split and validate requested scopes + requestedScopes := splitScopes(scope) + for _, rs := range requestedScopes { + found := false + for _, ss := range globalConfig.AuthSupportedScopes { + if rs == ss { + found = true + break + } + } + if !found { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidScope, fmt.Sprintf("unsupported scope: %s", rs)) + return + } + } + } + + // Generate access token + accessToken, err := generateRandomString(32) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to generate access token") + return + } + + // Get token expiry from config + expiresIn := 3600 // Default 1 hour + if globalConfig != nil && globalConfig.AuthTokenExpiry > 0 { + expiresIn = globalConfig.AuthTokenExpiry + } + + // Create refresh token and store it + refreshTokenObj, err := DefaultSessionStore.CreateRefreshToken(username, clientID, scope, "") + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to generate refresh token") + return + } + + // Build response + response := TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + RefreshToken: refreshTokenObj.Token, + Scope: scope, + } + + // Include id_token only if openid scope is requested + if sliceContains(splitScopes(scope), "openid") { + issuer := buildBaseURL(r) + response.IDToken = generateOAuth2IDToken(issuer, clientID, username, "", expiresIn) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// handleRefreshTokenGrant handles the OAuth2 Refresh Token flow. +// Returns new access_token, optionally new refresh_token, and optionally id_token. +// RFC 6749 Section 6 +func handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + refreshToken := r.PostForm.Get("refresh_token") + clientID := r.PostForm.Get("client_id") + clientSecret := r.PostForm.Get("client_secret") + scope := r.PostForm.Get("scope") + + // Validate client_id (REQUIRED) + if clientID == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "client_id parameter is required") + return + } + + // Determine if client_secret is required based on configuration + requireSecret := globalConfig != nil && globalConfig.AuthAllowedClientSecret != "" + + // Validate client credentials + if err := validateClientCredentials(clientID, clientSecret, requireSecret); err != nil { + writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidClient, err.Error()) + return + } + + // Validate refresh_token parameter + if refreshToken == "" { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "refresh_token parameter is required") + return + } + + // Validate refresh token exists and is not expired + storedToken, ok := DefaultSessionStore.GetRefreshToken(refreshToken) + if !ok { + hint := buildRefreshTokenHint(r) + writeOIDCErrorWithHint(w, http.StatusBadRequest, ErrorInvalidGrant, "invalid or expired refresh token", hint) + return + } + + // Validate client_id matches the one that originally obtained the refresh token + if storedToken.ClientID != clientID { + hint := buildRefreshTokenHint(r) + writeOIDCErrorWithHint(w, http.StatusBadRequest, ErrorInvalidGrant, "client_id mismatch", hint) + return + } + + // Handle scope parameter + // If scope is provided, it must not exceed the original scope + finalScope := storedToken.Scope + if scope != "" { + requestedScopes := splitScopes(scope) + originalScopes := splitScopes(storedToken.Scope) + + // Verify all requested scopes were in the original grant + for _, rs := range requestedScopes { + if !sliceContains(originalScopes, rs) { + writeOIDCError(w, http.StatusBadRequest, ErrorInvalidScope, fmt.Sprintf("scope exceeds original grant: %s", rs)) + return + } + } + finalScope = scope + } + + // Generate new access token + accessToken, err := generateRandomString(32) + if err != nil { + writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to generate access token") + return + } + + // Get token expiry from config + expiresIn := 3600 // Default 1 hour + if globalConfig != nil && globalConfig.AuthTokenExpiry > 0 { + expiresIn = globalConfig.AuthTokenExpiry + } + + // Optionally issue a new refresh token (rotation) + // For simplicity, we'll reuse the same refresh token + // In production, you might want to implement refresh token rotation + + // Build response + response := TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + RefreshToken: refreshToken, // Reuse same refresh token + Scope: finalScope, + } + + // Include id_token only if openid scope is in the final scope + if sliceContains(splitScopes(finalScope), "openid") { + issuer := buildBaseURL(r) + response.IDToken = generateOAuth2IDToken(issuer, clientID, storedToken.Username, storedToken.Nonce, expiresIn) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// verifyPKCECodeChallenge verifies PKCE code_verifier against code_challenge. +// Supports "plain" and "S256" methods per RFC 7636. +func verifyPKCECodeChallenge(challenge, method, verifier string) bool { + switch method { + case "plain": + // Plain method: challenge == verifier + return challenge == verifier + + case "S256": + // S256 method: challenge == BASE64URL(SHA256(ASCII(verifier))) + h := sha256.Sum256([]byte(verifier)) + computed := base64.RawURLEncoding.EncodeToString(h[:]) + return challenge == computed + + default: + // Unknown or empty method + return false + } +} diff --git a/echo-http/handlers/oauth2_token_test.go b/echo-http/handlers/oauth2_token_test.go new file mode 100644 index 0000000..fdb9ceb --- /dev/null +++ b/echo-http/handlers/oauth2_token_test.go @@ -0,0 +1,888 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestOAuth2TokenHandler_ClientCredentials(t *testing.T) { + tests := []struct { + name string + config *Config + formData map[string]string + expectedCode int + expectError bool + errorType string + checkResponse func(*testing.T, *TokenResponse) + }{ + { + name: "valid client credentials", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + AuthSupportedScopes: []string{"openid", "profile"}, + AuthTokenExpiry: 7200, + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + formData: map[string]string{ + "grant_type": "client_credentials", + "client_id": "test-client", + "client_secret": "test-secret", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.AccessToken == "" { + t.Error("expected access_token") + } + if resp.TokenType != "Bearer" { + t.Errorf("expected token_type Bearer, got %s", resp.TokenType) + } + if resp.ExpiresIn != 7200 { + t.Errorf("expected expires_in 7200, got %d", resp.ExpiresIn) + } + // Client Credentials should NOT include id_token + if resp.IDToken != "" { + t.Error("client_credentials should not return id_token") + } + // Should NOT include refresh_token in basic implementation + if resp.RefreshToken != "" { + t.Error("client_credentials should not return refresh_token") + } + }, + }, + { + name: "with custom scope", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + formData: map[string]string{ + "grant_type": "client_credentials", + "client_id": "test-client", + "client_secret": "test-secret", + "scope": "openid profile", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.Scope != "openid profile" { + t.Errorf("expected scope 'openid profile', got %s", resp.Scope) + } + }, + }, + { + name: "unsupported scope", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + AuthSupportedScopes: []string{"openid", "profile"}, + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + formData: map[string]string{ + "grant_type": "client_credentials", + "client_id": "test-client", + "client_secret": "test-secret", + "scope": "invalid_scope", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidScope, + }, + { + name: "missing client_id", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + formData: map[string]string{ + "grant_type": "client_credentials", + "client_secret": "test-secret", + }, + expectedCode: http.StatusUnauthorized, + expectError: true, + errorType: ErrorInvalidClient, + }, + { + name: "invalid client_secret", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + formData: map[string]string{ + "grant_type": "client_credentials", + "client_id": "test-client", + "client_secret": "wrong-secret", + }, + expectedCode: http.StatusUnauthorized, + expectError: true, + errorType: ErrorInvalidClient, + }, + { + name: "grant_type not allowed", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + AuthAllowedGrantTypes: []string{"authorization_code"}, // client_credentials not allowed + }, + formData: map[string]string{ + "grant_type": "client_credentials", + "client_id": "test-client", + "client_secret": "test-secret", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorUnsupportedGrantType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Create request + formData := url.Values{} + for k, v := range tt.formData { + formData.Set(k, v) + } + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + // Call handler + OAuth2TokenHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + if tt.expectError { + // Check error response + var errResp OIDCError + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + if errResp.Error != tt.errorType { + t.Errorf("expected error %s, got %s", tt.errorType, errResp.Error) + } + } else if tt.checkResponse != nil { + // Check success response + var resp TokenResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + tt.checkResponse(t, &resp) + } + }) + } +} + +func TestOAuth2TokenHandler_AuthorizationCode(t *testing.T) { + tests := []struct { + name string + config *Config + setupAuthCode func() string // Returns authorization code + formData map[string]string + expectedCode int + expectError bool + errorType string + checkResponse func(*testing.T, *TokenResponse) + }{ + { + name: "valid authorization code", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "", + AuthSupportedScopes: []string{"openid", "profile"}, + AuthTokenExpiry: 7200, + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + setupAuthCode: func() string { + code, _ := DefaultSessionStore.CreateAuthCode( + "http://localhost/callback", + "testuser", + "openid profile", + "", + "", + "test-nonce", + ) + return code.Code + }, + formData: map[string]string{ + "grant_type": "authorization_code", + "client_id": "test-client", + "redirect_uri": "http://localhost/callback", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.AccessToken == "" { + t.Error("expected access_token") + } + if resp.RefreshToken == "" { + t.Error("expected refresh_token") + } + // Authorization Code with OIDC SHOULD include id_token + if resp.IDToken == "" { + t.Error("authorization_code should return id_token") + } + if resp.Scope != "openid profile" { + t.Errorf("expected scope 'openid profile', got %s", resp.Scope) + } + }, + }, + { + name: "with PKCE S256", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + setupAuthCode: func() string { + // Generate code_verifier + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // Compute S256 challenge + h := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(h[:]) + + code, _ := DefaultSessionStore.CreateAuthCode( + "http://localhost/callback", + "testuser", + "openid", + challenge, + "S256", + "", + ) + return code.Code + }, + formData: map[string]string{ + "grant_type": "authorization_code", + "client_id": "test-client", + "redirect_uri": "http://localhost/callback", + "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + }, + expectedCode: http.StatusOK, + }, + { + name: "invalid code_verifier", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + setupAuthCode: func() string { + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + h := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(h[:]) + + code, _ := DefaultSessionStore.CreateAuthCode( + "http://localhost/callback", + "testuser", + "openid", + challenge, + "S256", + "", + ) + return code.Code + }, + formData: map[string]string{ + "grant_type": "authorization_code", + "client_id": "test-client", + "redirect_uri": "http://localhost/callback", + "code_verifier": "wrong-verifier-but-valid-length-aaaaaaaaaaa", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "missing code_verifier when required", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + setupAuthCode: func() string { + code, _ := DefaultSessionStore.CreateAuthCode( + "http://localhost/callback", + "testuser", + "openid", + "test-challenge", + "plain", + "", + ) + return code.Code + }, + formData: map[string]string{ + "grant_type": "authorization_code", + "client_id": "test-client", + "redirect_uri": "http://localhost/callback", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "invalid authorization code", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + setupAuthCode: func() string { + return "invalid-code" + }, + formData: map[string]string{ + "grant_type": "authorization_code", + "client_id": "test-client", + "code": "invalid-code", + "redirect_uri": "http://localhost/callback", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "redirect_uri mismatch", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"authorization_code"}, + }, + setupAuthCode: func() string { + code, _ := DefaultSessionStore.CreateAuthCode( + "http://localhost/callback", + "testuser", + "openid", + "", + "", + "", + ) + return code.Code + }, + formData: map[string]string{ + "grant_type": "authorization_code", + "client_id": "test-client", + "redirect_uri": "http://wrong/callback", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidGrant, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Setup authorization code + code := tt.setupAuthCode() + + // Create request + formData := url.Values{} + for k, v := range tt.formData { + formData.Set(k, v) + } + // Add code if not already in formData + if _, exists := tt.formData["code"]; !exists { + formData.Set("code", code) + } + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + // Call handler + OAuth2TokenHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + if tt.expectError { + // Check error response + var errResp OIDCError + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + if errResp.Error != tt.errorType { + t.Errorf("expected error %s, got %s", tt.errorType, errResp.Error) + } + } else if tt.checkResponse != nil { + // Check success response + var resp TokenResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + tt.checkResponse(t, &resp) + } + }) + } +} + +func TestOAuth2TokenHandler_Password(t *testing.T) { + tests := []struct { + name string + config *Config + formData map[string]string + expectedCode int + expectError bool + errorType string + checkResponse func(*testing.T, *TokenResponse) + }{ + { + name: "valid credentials", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "", + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthTokenExpiry: 3600, + AuthAllowedGrantTypes: []string{"password"}, + }, + formData: map[string]string{ + "grant_type": "password", + "username": "testuser", + "password": "testpass", + "client_id": "test-client", + "scope": "openid profile", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.AccessToken == "" { + t.Error("expected access_token") + } + if resp.RefreshToken == "" { + t.Error("expected refresh_token") + } + if resp.IDToken == "" { + t.Error("expected id_token with openid scope") + } + if resp.TokenType != "Bearer" { + t.Errorf("expected token_type Bearer, got %s", resp.TokenType) + } + if resp.Scope != "openid profile" { + t.Errorf("expected scope 'openid profile', got %s", resp.Scope) + } + }, + }, + { + name: "without openid scope - no id_token", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + AuthSupportedScopes: []string{"profile", "email"}, + AuthAllowedGrantTypes: []string{"password"}, + }, + formData: map[string]string{ + "grant_type": "password", + "username": "testuser", + "password": "testpass", + "client_id": "test-client", + "scope": "profile email", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.IDToken != "" { + t.Error("should not return id_token without openid scope") + } + if resp.RefreshToken == "" { + t.Error("expected refresh_token") + } + }, + }, + { + name: "invalid username", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + AuthAllowedGrantTypes: []string{"password"}, + }, + formData: map[string]string{ + "grant_type": "password", + "username": "wronguser", + "password": "testpass", + "client_id": "test-client", + }, + expectedCode: http.StatusUnauthorized, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "invalid password", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + AuthAllowedGrantTypes: []string{"password"}, + }, + formData: map[string]string{ + "grant_type": "password", + "username": "testuser", + "password": "wrongpass", + "client_id": "test-client", + }, + expectedCode: http.StatusUnauthorized, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "unsupported scope", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + AuthSupportedScopes: []string{"openid", "profile"}, + AuthAllowedGrantTypes: []string{"password"}, + }, + formData: map[string]string{ + "grant_type": "password", + "username": "testuser", + "password": "testpass", + "client_id": "test-client", + "scope": "invalid_scope", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidScope, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Create request + formData := url.Values{} + for k, v := range tt.formData { + formData.Set(k, v) + } + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + // Call handler + OAuth2TokenHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + if tt.expectError { + // Check error response + var errResp OIDCError + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + if errResp.Error != tt.errorType { + t.Errorf("expected error %s, got %s", tt.errorType, errResp.Error) + } + } else if tt.checkResponse != nil { + // Check success response + var resp TokenResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + tt.checkResponse(t, &resp) + } + }) + } +} + +func TestOAuth2TokenHandler_RefreshToken(t *testing.T) { + tests := []struct { + name string + config *Config + setupToken func() string // Returns refresh token + formData map[string]string + expectedCode int + expectError bool + errorType string + checkResponse func(*testing.T, *TokenResponse) + }{ + { + name: "valid refresh token", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthTokenExpiry: 3600, + AuthAllowedGrantTypes: []string{"refresh_token"}, + }, + setupToken: func() string { + token, _ := DefaultSessionStore.CreateRefreshToken( + "testuser", + "test-client", + "openid profile email", + "test-nonce", + ) + return token.Token + }, + formData: map[string]string{ + "grant_type": "refresh_token", + "client_id": "test-client", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.AccessToken == "" { + t.Error("expected access_token") + } + if resp.RefreshToken == "" { + t.Error("expected refresh_token") + } + if resp.IDToken == "" { + t.Error("expected id_token with openid scope") + } + if resp.Scope != "openid profile email" { + t.Errorf("expected scope 'openid profile email', got %s", resp.Scope) + } + }, + }, + { + name: "scope narrowing - valid", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedGrantTypes: []string{"refresh_token"}, + }, + setupToken: func() string { + token, _ := DefaultSessionStore.CreateRefreshToken( + "testuser", + "test-client", + "openid profile email", + "", + ) + return token.Token + }, + formData: map[string]string{ + "grant_type": "refresh_token", + "client_id": "test-client", + "scope": "openid profile", // Narrower than original + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.Scope != "openid profile" { + t.Errorf("expected scope 'openid profile', got %s", resp.Scope) + } + }, + }, + { + name: "scope expansion - invalid", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthSupportedScopes: []string{"openid", "profile", "email"}, + AuthAllowedGrantTypes: []string{"refresh_token"}, + }, + setupToken: func() string { + token, _ := DefaultSessionStore.CreateRefreshToken( + "testuser", + "test-client", + "openid profile", + "", + ) + return token.Token + }, + formData: map[string]string{ + "grant_type": "refresh_token", + "client_id": "test-client", + "scope": "openid profile email", // Broader than original + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidScope, + }, + { + name: "invalid refresh token", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"refresh_token"}, + }, + setupToken: func() string { + return "invalid-token" + }, + formData: map[string]string{ + "grant_type": "refresh_token", + "client_id": "test-client", + "refresh_token": "invalid-token", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "client_id mismatch", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedGrantTypes: []string{"refresh_token"}, + }, + setupToken: func() string { + token, _ := DefaultSessionStore.CreateRefreshToken( + "testuser", + "other-client", + "openid", + "", + ) + return token.Token + }, + formData: map[string]string{ + "grant_type": "refresh_token", + "client_id": "test-client", + }, + expectedCode: http.StatusBadRequest, + expectError: true, + errorType: ErrorInvalidGrant, + }, + { + name: "without openid scope - no id_token", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthSupportedScopes: []string{"profile", "email"}, + AuthAllowedGrantTypes: []string{"refresh_token"}, + }, + setupToken: func() string { + token, _ := DefaultSessionStore.CreateRefreshToken( + "testuser", + "test-client", + "profile email", + "", + ) + return token.Token + }, + formData: map[string]string{ + "grant_type": "refresh_token", + "client_id": "test-client", + }, + expectedCode: http.StatusOK, + checkResponse: func(t *testing.T, resp *TokenResponse) { + if resp.IDToken != "" { + t.Error("should not return id_token without openid scope") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Setup refresh token + refreshToken := tt.setupToken() + + // Create request + formData := url.Values{} + for k, v := range tt.formData { + formData.Set(k, v) + } + // Add refresh_token if not already in formData + if _, exists := tt.formData["refresh_token"]; !exists { + formData.Set("refresh_token", refreshToken) + } + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + // Call handler + OAuth2TokenHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + if tt.expectError { + // Check error response + var errResp OIDCError + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + if errResp.Error != tt.errorType { + t.Errorf("expected error %s, got %s", tt.errorType, errResp.Error) + } + } else if tt.checkResponse != nil { + // Check success response + var resp TokenResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + tt.checkResponse(t, &resp) + } + }) + } +} + +func TestVerifyPKCECodeChallenge(t *testing.T) { + tests := []struct { + name string + challenge string + method string + verifier string + expected bool + }{ + { + name: "plain method - valid", + challenge: "test-challenge", + method: "plain", + verifier: "test-challenge", + expected: true, + }, + { + name: "plain method - invalid", + challenge: "test-challenge", + method: "plain", + verifier: "wrong-verifier", + expected: false, + }, + { + name: "S256 method - valid", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + method: "S256", + verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + expected: true, + }, + { + name: "S256 method - invalid", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + method: "S256", + verifier: "wrong-verifier", + expected: false, + }, + { + name: "unknown method", + challenge: "test-challenge", + method: "unknown", + verifier: "test-challenge", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := verifyPKCECodeChallenge(tt.challenge, tt.method, tt.verifier) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} diff --git a/echo-http/handlers/oauth2_types.go b/echo-http/handlers/oauth2_types.go new file mode 100644 index 0000000..fb44e30 --- /dev/null +++ b/echo-http/handlers/oauth2_types.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "fmt" + "net/url" + "strings" +) + +// OIDCDiscoveryResponse represents the OpenID Connect Discovery metadata +type OIDCDiscoveryResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JwksURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + ScopesSupported []string `json:"scopes_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +// TokenResponse represents the response from the token endpoint +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// JWKSResponse represents a JSON Web Key Set response +type JWKSResponse struct { + Keys []interface{} `json:"keys"` +} + +// validateRedirectURI validates that redirectURI matches one of the allowed patterns. +// Returns nil if validation passes, error otherwise. +// Empty or nil allowedPatterns means no restrictions (allow all). +// Supports wildcards: * for any port or path segment. +func validateRedirectURI(redirectURI string, allowedPatterns []string) error { + if len(allowedPatterns) == 0 { + return nil // No restrictions + } + + for _, pattern := range allowedPatterns { + if matchRedirectPattern(redirectURI, pattern) { + return nil + } + } + + return fmt.Errorf("redirect_uri not in allowlist") +} + +// matchRedirectPattern checks if uri matches pattern. +// Supports wildcards: +// - "http://localhost:*/callback" matches any port +// - "http://localhost:8080/*" matches any path +func matchRedirectPattern(uri, pattern string) bool { + // Exact match + if uri == pattern { + return true + } + + // Parse URI + uriParsed, err := url.Parse(uri) + if err != nil { + return false + } + + // Handle pattern specially to support wildcard port + // Replace :* with a valid port temporarily for parsing + patternForParsing := strings.Replace(pattern, ":*", ":9999", 1) + hasWildcardPort := patternForParsing != pattern + + patternParsed, err := url.Parse(patternForParsing) + if err != nil { + return false + } + + // Scheme must match exactly + if uriParsed.Scheme != patternParsed.Scheme { + return false + } + + // Host must match exactly + uriHost := uriParsed.Hostname() + patternHost := patternParsed.Hostname() + if uriHost != patternHost { + return false + } + + // Port matching: support wildcard * + if !hasWildcardPort { + // Ports must match exactly (including both being empty for default ports) + uriPort := uriParsed.Port() + patternPort := patternParsed.Port() + if uriPort != patternPort { + return false + } + } + // If hasWildcardPort, accept any port + + // Path matching: support wildcard * + if patternParsed.Path == "/*" { + return true // Any path allowed + } + + if uriParsed.Path != patternParsed.Path { + return false + } + + return true +} diff --git a/echo-http/handlers/oauth2_utils.go b/echo-http/handlers/oauth2_utils.go new file mode 100644 index 0000000..70211f8 --- /dev/null +++ b/echo-http/handlers/oauth2_utils.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "crypto/subtle" + "errors" + "fmt" + "net/http" + "strings" +) + +// validateClientCredentials validates client_id and client_secret against configured values. +// Returns error with appropriate message if validation fails. +// If requireSecret is true, validates both client_id and client_secret. +// If requireSecret is false, only validates client_id. +func validateClientCredentials(clientID, clientSecret string, requireSecret bool) error { + if clientID == "" { + return errors.New("client_id is required") + } + + // If no client_id is configured, accept any client (permissive mode for testing) + if globalConfig == nil || globalConfig.AuthAllowedClientID == "" { + return nil + } + + // Validate client_id + if clientID != globalConfig.AuthAllowedClientID { + return errors.New("unknown client_id") + } + + // Validate client_secret if required (confidential client) + if requireSecret || globalConfig.AuthAllowedClientSecret != "" { + if globalConfig.AuthAllowedClientSecret == "" { + return errors.New("client_secret is required but not configured") + } + if !constantTimeCompare(clientSecret, globalConfig.AuthAllowedClientSecret) { + return errors.New("invalid client_secret") + } + } + + return nil +} + +// validateGrantType checks if the requested grant type is in the allowed list. +// Returns error if not supported. +func validateGrantType(grantType string, allowedTypes []string) error { + if grantType == "" { + return errors.New("grant_type is required") + } + + if !isGrantTypeAllowed(grantType, allowedTypes) { + return fmt.Errorf("unsupported grant_type: %s", grantType) + } + + return nil +} + +// validateBasicAuthCredentials validates username and password against configured values. +// Uses constant-time comparison to prevent timing attacks. +// Returns error if credentials don't match or are not configured. +func validateBasicAuthCredentials(username, password string) error { + if username == "" || password == "" { + return errors.New("username and password are required") + } + + // Check if credentials are configured + if globalConfig == nil || globalConfig.AuthAllowedUsername == "" || globalConfig.AuthAllowedPassword == "" { + return errors.New("authentication credentials not configured") + } + + // Validate using constant-time comparison to prevent timing attacks + usernameMatch := constantTimeCompare(username, globalConfig.AuthAllowedUsername) + passwordMatch := constantTimeCompare(password, globalConfig.AuthAllowedPassword) + + if !usernameMatch || !passwordMatch { + return errors.New("invalid username or password") + } + + return nil +} + +// isGrantTypeAllowed checks if a grant type is in the allowed list. +func isGrantTypeAllowed(grantType string, allowedTypes []string) bool { + for _, allowed := range allowedTypes { + if grantType == allowed { + return true + } + } + return false +} + +// buildBaseURL constructs the base URL from the request, respecting X-Forwarded-Proto. +func buildBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + // Respect X-Forwarded-Proto header (common in reverse proxy setups) + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + + host := r.Host + return fmt.Sprintf("%s://%s", scheme, host) +} + +// buildIssuerURL constructs the issuer URL based on the request. +// For deprecated endpoints: includes /oidc/{user}/{pass} +// For new endpoints: uses base URL only +func buildIssuerURL(r *http.Request, user, pass string, deprecated bool) string { + baseURL := buildBaseURL(r) + if deprecated && user != "" && pass != "" { + return fmt.Sprintf("%s/oidc/%s/%s", baseURL, user, pass) + } + return baseURL +} + +// constantTimeCompare performs constant-time string comparison to prevent timing attacks. +// Returns true if strings are equal, false otherwise. +func constantTimeCompare(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// getAllowedGrantTypes returns the list of allowed grant types from global config. +// If not configured, returns default grant types. +func getAllowedGrantTypes() []string { + if globalConfig != nil && len(globalConfig.AuthAllowedGrantTypes) > 0 { + return globalConfig.AuthAllowedGrantTypes + } + // Default grant types + return []string{"authorization_code", "client_credentials"} +} + +// joinScopes joins a slice of scopes into a space-separated string. +func joinScopes(scopes []string) string { + return strings.Join(scopes, " ") +} + +// splitScopes splits a space-separated scope string into a slice of scopes. +func splitScopes(scope string) []string { + if scope == "" { + return []string{} + } + scopes := strings.Split(scope, " ") + result := make([]string, 0, len(scopes)) + for _, s := range scopes { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// sliceContains checks if a string slice contains a specific string +func sliceContains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/echo-http/handlers/oauth2_utils_test.go b/echo-http/handlers/oauth2_utils_test.go new file mode 100644 index 0000000..d587645 --- /dev/null +++ b/echo-http/handlers/oauth2_utils_test.go @@ -0,0 +1,538 @@ +package handlers + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" +) + +func TestValidateClientCredentials(t *testing.T) { + tests := []struct { + name string + config *Config + clientID string + clientSecret string + requireSecret bool + expectError bool + errorContains string + }{ + { + name: "missing client_id", + config: &Config{}, + clientID: "", + expectError: true, + }, + { + name: "no config - accept any client", + config: nil, + clientID: "any-client", + expectError: false, + }, + { + name: "empty allowed client_id - accept any client", + config: &Config{ + AuthAllowedClientID: "", + }, + clientID: "any-client", + expectError: false, + }, + { + name: "valid client_id - no secret required", + config: &Config{ + AuthAllowedClientID: "test-client", + }, + clientID: "test-client", + requireSecret: false, + expectError: false, + }, + { + name: "invalid client_id", + config: &Config{ + AuthAllowedClientID: "test-client", + }, + clientID: "wrong-client", + expectError: true, + errorContains: "unknown client_id", + }, + { + name: "valid client_id and client_secret", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + }, + clientID: "test-client", + clientSecret: "test-secret", + requireSecret: true, + expectError: false, + }, + { + name: "valid client_id but invalid client_secret", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + }, + clientID: "test-client", + clientSecret: "wrong-secret", + requireSecret: true, + expectError: true, + errorContains: "invalid client_secret", + }, + { + name: "secret configured but not provided", + config: &Config{ + AuthAllowedClientID: "test-client", + AuthAllowedClientSecret: "test-secret", + }, + clientID: "test-client", + clientSecret: "", + requireSecret: false, + expectError: true, + errorContains: "invalid client_secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config for this test + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + err := validateClientCredentials(tt.clientID, tt.clientSecret, tt.requireSecret) + + if tt.expectError && err == nil { + t.Error("expected error but got nil") + } + if !tt.expectError && err != nil { + t.Errorf("expected no error but got: %v", err) + } + if tt.errorContains != "" && err != nil { + if !contains(err.Error(), tt.errorContains) { + t.Errorf("expected error to contain %q, got: %v", tt.errorContains, err) + } + } + }) + } +} + +func TestValidateGrantType(t *testing.T) { + tests := []struct { + name string + grantType string + allowedTypes []string + expectError bool + errorContains string + }{ + { + name: "empty grant_type", + grantType: "", + expectError: true, + }, + { + name: "supported grant_type", + grantType: "authorization_code", + allowedTypes: []string{"authorization_code", "client_credentials"}, + expectError: false, + }, + { + name: "unsupported grant_type", + grantType: "password", + allowedTypes: []string{"authorization_code", "client_credentials"}, + expectError: true, + errorContains: "unsupported grant_type", + }, + { + name: "client_credentials supported", + grantType: "client_credentials", + allowedTypes: []string{"client_credentials"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGrantType(tt.grantType, tt.allowedTypes) + + if tt.expectError && err == nil { + t.Error("expected error but got nil") + } + if !tt.expectError && err != nil { + t.Errorf("expected no error but got: %v", err) + } + if tt.errorContains != "" && err != nil { + if !contains(err.Error(), tt.errorContains) { + t.Errorf("expected error to contain %q, got: %v", tt.errorContains, err) + } + } + }) + } +} + +func TestValidateBasicAuthCredentials(t *testing.T) { + tests := []struct { + name string + config *Config + username string + password string + expectError bool + errorContains string + }{ + { + name: "empty username", + config: &Config{}, + username: "", + password: "pass", + expectError: true, + }, + { + name: "empty password", + config: &Config{}, + username: "user", + password: "", + expectError: true, + }, + { + name: "credentials not configured", + config: &Config{}, + username: "user", + password: "pass", + expectError: true, + errorContains: "not configured", + }, + { + name: "valid credentials", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + username: "testuser", + password: "testpass", + expectError: false, + }, + { + name: "invalid username", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + username: "wronguser", + password: "testpass", + expectError: true, + errorContains: "invalid username or password", + }, + { + name: "invalid password", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + username: "testuser", + password: "wrongpass", + expectError: true, + errorContains: "invalid username or password", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config for this test + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + err := validateBasicAuthCredentials(tt.username, tt.password) + + if tt.expectError && err == nil { + t.Error("expected error but got nil") + } + if !tt.expectError && err != nil { + t.Errorf("expected no error but got: %v", err) + } + if tt.errorContains != "" && err != nil { + if !contains(err.Error(), tt.errorContains) { + t.Errorf("expected error to contain %q, got: %v", tt.errorContains, err) + } + } + }) + } +} + +func TestIsGrantTypeAllowed(t *testing.T) { + tests := []struct { + name string + grantType string + allowedTypes []string + expected bool + }{ + { + name: "grant type is allowed", + grantType: "authorization_code", + allowedTypes: []string{"authorization_code", "client_credentials"}, + expected: true, + }, + { + name: "grant type is not allowed", + grantType: "password", + allowedTypes: []string{"authorization_code", "client_credentials"}, + expected: false, + }, + { + name: "empty allowed list", + grantType: "authorization_code", + allowedTypes: []string{}, + expected: false, + }, + { + name: "single allowed type matches", + grantType: "client_credentials", + allowedTypes: []string{"client_credentials"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isGrantTypeAllowed(tt.grantType, tt.allowedTypes) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestBuildBaseURL(t *testing.T) { + tests := []struct { + name string + host string + tls bool + proto string + expected string + }{ + { + name: "http without TLS", + host: "localhost:8080", + tls: false, + expected: "http://localhost:8080", + }, + { + name: "https with TLS", + host: "example.com", + tls: true, + expected: "https://example.com", + }, + { + name: "X-Forwarded-Proto overrides", + host: "localhost:8080", + tls: false, + proto: "https", + expected: "https://localhost:8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = tt.host + if tt.tls { + req.TLS = &tls.ConnectionState{} // Non-nil TLS indicates HTTPS + } + if tt.proto != "" { + req.Header.Set("X-Forwarded-Proto", tt.proto) + } + + result := buildBaseURL(req) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestBuildIssuerURL(t *testing.T) { + tests := []struct { + name string + host string + user string + pass string + deprecated bool + expected string + }{ + { + name: "new endpoint - no user/pass in URL", + host: "localhost:8080", + user: "testuser", + pass: "testpass", + deprecated: false, + expected: "http://localhost:8080", + }, + { + name: "deprecated endpoint - includes user/pass", + host: "localhost:8080", + user: "testuser", + pass: "testpass", + deprecated: true, + expected: "http://localhost:8080/oidc/testuser/testpass", + }, + { + name: "deprecated with empty user/pass - base URL only", + host: "localhost:8080", + user: "", + pass: "", + deprecated: true, + expected: "http://localhost:8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = tt.host + + result := buildIssuerURL(req, tt.user, tt.pass, tt.deprecated) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestGetAllowedGrantTypes(t *testing.T) { + tests := []struct { + name string + config *Config + expected []string + }{ + { + name: "no config - return defaults", + config: nil, + expected: []string{"authorization_code", "client_credentials"}, + }, + { + name: "configured grant types", + config: &Config{ + AuthAllowedGrantTypes: []string{"client_credentials"}, + }, + expected: []string{"client_credentials"}, + }, + { + name: "empty grant types - return defaults", + config: &Config{ + AuthAllowedGrantTypes: []string{}, + }, + expected: []string{"authorization_code", "client_credentials"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config for this test + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + result := getAllowedGrantTypes() + if len(result) != len(tt.expected) { + t.Errorf("expected %d grant types, got %d", len(tt.expected), len(result)) + return + } + for i, expected := range tt.expected { + if result[i] != expected { + t.Errorf("expected grant type %q at index %d, got %q", expected, i, result[i]) + } + } + }) + } +} + +func TestJoinScopes(t *testing.T) { + tests := []struct { + name string + scopes []string + expected string + }{ + { + name: "single scope", + scopes: []string{"openid"}, + expected: "openid", + }, + { + name: "multiple scopes", + scopes: []string{"openid", "profile", "email"}, + expected: "openid profile email", + }, + { + name: "empty slice", + scopes: []string{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := joinScopes(tt.scopes) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestSplitScopes(t *testing.T) { + tests := []struct { + name string + scope string + expected []string + }{ + { + name: "single scope", + scope: "openid", + expected: []string{"openid"}, + }, + { + name: "multiple scopes", + scope: "openid profile email", + expected: []string{"openid", "profile", "email"}, + }, + { + name: "empty string", + scope: "", + expected: []string{}, + }, + { + name: "scopes with extra spaces", + scope: "openid profile email", + expected: []string{"openid", "profile", "email"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := splitScopes(tt.scope) + if len(result) != len(tt.expected) { + t.Errorf("expected %d scopes, got %d", len(tt.expected), len(result)) + return + } + for i, expected := range tt.expected { + if result[i] != expected { + t.Errorf("expected scope %q at index %d, got %q", expected, i, result[i]) + } + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/echo-http/main.go b/echo-http/main.go index 4159f53..c2ab5c4 100644 --- a/echo-http/main.go +++ b/echo-http/main.go @@ -26,6 +26,9 @@ func main() { AuthAllowedClientSecret: cfg.AuthAllowedClientSecret, AuthSupportedScopes: cfg.AuthSupportedScopes, AuthTokenExpiry: cfg.AuthTokenExpiry, + AuthAllowedGrantTypes: cfg.AuthAllowedGrantTypes, + AuthAllowedUsername: cfg.AuthAllowedUsername, + AuthAllowedPassword: cfg.AuthAllowedPassword, AuthCodeRequirePKCE: cfg.AuthCodeRequirePKCE, AuthCodeSessionTTL: cfg.AuthCodeSessionTTL, AuthCodeValidateRedirectURI: cfg.AuthCodeValidateRedirectURI, @@ -81,6 +84,17 @@ func main() { r.Get("/oidc/{user}/{pass}/userinfo", handlers.OIDCUserInfoHandler) r.Get("/oidc/{user}/{pass}/demo", handlers.OIDCDemoHandler) + // OAuth2/OIDC endpoints (environment-based auth) + r.Get("/.well-known/oauth-authorization-server", handlers.OAuth2MetadataHandler) + r.Get("/.well-known/openid-configuration", handlers.OIDCDiscoveryRootHandler) + r.Get("/.well-known/jwks.json", handlers.OAuth2JWKSHandler) + r.Get("/oauth2/authorize", handlers.OAuth2AuthorizeHandler) + r.Post("/oauth2/authorize", handlers.OAuth2AuthorizeHandler) + r.Get("/oauth2/callback", handlers.OAuth2CallbackHandler) + r.Post("/oauth2/token", handlers.OAuth2TokenHandler) + r.Get("/oauth2/userinfo", handlers.OAuth2UserInfoHandler) + r.Get("/oauth2/demo", handlers.OAuth2DemoHandler) + // Cookie endpoints r.Get("/cookies", handlers.CookiesHandler) r.Get("/cookies/set", handlers.CookiesSetHandler) From 85a25fbca9696c26ebde3ffc32654f70d333f373 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 23:02:20 +0900 Subject: [PATCH 5/9] feat(echo-http): add Basic Authentication with environment credentials Introduces Basic Auth endpoint that validates credentials against environment variables (AUTH_ALLOWED_USERNAME, AUTH_ALLOWED_PASSWORD). This provides a simple authentication mechanism alongside the existing OAuth2/OIDC endpoints. Capabilities: - Standard Basic Auth with RFC 7617 compliance - Credentials validated from environment configuration - Helpful error messages with curl examples - WWW-Authenticate challenge header on failures Route added: - GET /basic-auth - Validates Basic Auth credentials The implementation follows the same pattern as OAuth2 handlers, using the global config for credential validation and providing developer-friendly error responses with working examples. --- echo-http/handlers/basic_auth.go | 76 +++++++++++++++++ echo-http/handlers/basic_auth_test.go | 118 ++++++++++++++++++++++++++ echo-http/main.go | 3 + 3 files changed, 197 insertions(+) create mode 100644 echo-http/handlers/basic_auth.go create mode 100644 echo-http/handlers/basic_auth_test.go diff --git a/echo-http/handlers/basic_auth.go b/echo-http/handlers/basic_auth.go new file mode 100644 index 0000000..7344f5d --- /dev/null +++ b/echo-http/handlers/basic_auth.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// BasicAuthEnvHandler validates Basic Authentication credentials against environment variables. +// Uses AUTH_ALLOWED_USERNAME and AUTH_ALLOWED_PASSWORD from configuration. +// GET /basic-auth - Returns 200 if credentials match, 401 otherwise +func BasicAuthEnvHandler(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + writeBasicAuthError(w, r) + return + } + + // Validate credentials against environment variables + if err := validateBasicAuthCredentials(user, pass); err != nil { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + writeBasicAuthError(w, r) + return + } + + response := AuthResponse{ + Authenticated: true, + User: user, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// writeBasicAuthError writes a 401 response with helpful curl examples. +func writeBasicAuthError(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path) + + var username, password string + if globalConfig != nil && globalConfig.AuthAllowedUsername != "" && globalConfig.AuthAllowedPassword != "" { + username = globalConfig.AuthAllowedUsername + password = globalConfig.AuthAllowedPassword + } else { + username = "username" + password = "password" + } + + message := fmt.Sprintf(`Unauthorized + +This endpoint requires Basic Authentication. + +Example usage: + curl -u %s:%s %s + +Or with explicit Authorization header: + curl -H "Authorization: Basic $(echo -n '%s:%s' | base64)" %s + +Configure credentials via environment variables: + AUTH_ALLOWED_USERNAME=%s + AUTH_ALLOWED_PASSWORD=%s +`, + username, password, baseURL, + username, password, baseURL, + username, password, + ) + + _, _ = w.Write([]byte(message)) +} diff --git a/echo-http/handlers/basic_auth_test.go b/echo-http/handlers/basic_auth_test.go new file mode 100644 index 0000000..fffa1fb --- /dev/null +++ b/echo-http/handlers/basic_auth_test.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBasicAuthEnvHandler(t *testing.T) { + tests := []struct { + name string + config *Config + username string + password string + setAuth bool + expectedCode int + expectJSON bool + }{ + { + name: "valid credentials", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + username: "testuser", + password: "testpass", + setAuth: true, + expectedCode: http.StatusOK, + expectJSON: true, + }, + { + name: "invalid username", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + username: "wronguser", + password: "testpass", + setAuth: true, + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "invalid password", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + username: "testuser", + password: "wrongpass", + setAuth: true, + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "no auth header", + config: &Config{}, + setAuth: false, + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "credentials not configured", + config: &Config{}, + username: "testuser", + password: "testpass", + setAuth: true, + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Create request + req := httptest.NewRequest(http.MethodGet, "/basic-auth", nil) + if tt.setAuth { + req.SetBasicAuth(tt.username, tt.password) + } + w := httptest.NewRecorder() + + // Call handler + BasicAuthEnvHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + // Check WWW-Authenticate header for 401 responses + if w.Code == http.StatusUnauthorized { + if w.Header().Get("WWW-Authenticate") == "" { + t.Error("expected WWW-Authenticate header") + } + } + + // Check JSON response for successful auth + if tt.expectJSON { + var resp AuthResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if !resp.Authenticated { + t.Error("expected authenticated=true") + } + if resp.User != tt.username { + t.Errorf("expected user %s, got %s", tt.username, resp.User) + } + } + }) + } +} diff --git a/echo-http/main.go b/echo-http/main.go index c2ab5c4..1bf7a11 100644 --- a/echo-http/main.go +++ b/echo-http/main.go @@ -95,6 +95,9 @@ func main() { r.Get("/oauth2/userinfo", handlers.OAuth2UserInfoHandler) r.Get("/oauth2/demo", handlers.OAuth2DemoHandler) + // Basic Auth (environment-based) + r.Get("/basic-auth", handlers.BasicAuthEnvHandler) + // Cookie endpoints r.Get("/cookies", handlers.CookiesHandler) r.Get("/cookies/set", handlers.CookiesSetHandler) From 7352465df83c36ddcfd90c429caa00fcea176074 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 23:03:35 +0900 Subject: [PATCH 6/9] feat(echo-http): add Bearer token authentication with SHA1-hashed credentials Introduces Bearer token endpoint that validates SHA1(username:password) tokens against environment variables (AUTH_ALLOWED_USERNAME, AUTH_ALLOWED_PASSWORD). This complements Basic Auth by providing token-based authentication for testing scenarios. Capabilities: - Bearer token validation using SHA1 hash algorithm - Constant-time comparison to prevent timing attacks - Case-insensitive "Bearer" scheme recognition - Helpful error messages with token generation examples - WWW-Authenticate challenge header on failures Route added: - GET /bearer-auth - Validates Bearer token (SHA1 hash) Token format: SHA1("username:password") encoded as hex string. This provides a simple token derivation method for testing OAuth2 clients that require Bearer authentication. --- echo-http/handlers/bearer_auth.go | 115 ++++++++++++++++ echo-http/handlers/bearer_auth_test.go | 176 +++++++++++++++++++++++++ echo-http/main.go | 3 + 3 files changed, 294 insertions(+) create mode 100644 echo-http/handlers/bearer_auth.go create mode 100644 echo-http/handlers/bearer_auth_test.go diff --git a/echo-http/handlers/bearer_auth.go b/echo-http/handlers/bearer_auth.go new file mode 100644 index 0000000..b4ce0ba --- /dev/null +++ b/echo-http/handlers/bearer_auth.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "crypto/sha1" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// BearerAuthEnvHandler validates Bearer token authentication against environment variables. +// The expected token is SHA1(username:password) where username and password are from +// AUTH_ALLOWED_USERNAME and AUTH_ALLOWED_PASSWORD configuration. +// GET /bearer-auth - Returns 200 if token matches, 401 otherwise +func BearerAuthEnvHandler(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.Header().Set("WWW-Authenticate", `Bearer`) + writeBearerAuthError(w, r) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + w.Header().Set("WWW-Authenticate", `Bearer`) + writeBearerAuthError(w, r) + return + } + + token := parts[1] + if token == "" { + w.Header().Set("WWW-Authenticate", `Bearer`) + writeBearerAuthError(w, r) + return + } + + // Check if credentials are configured + if globalConfig == nil || globalConfig.AuthAllowedUsername == "" || globalConfig.AuthAllowedPassword == "" { + w.Header().Set("WWW-Authenticate", `Bearer`) + writeBearerAuthError(w, r) + return + } + + // Compute expected token as SHA1(username:password) + expectedToken := computeBearerToken(globalConfig.AuthAllowedUsername, globalConfig.AuthAllowedPassword) + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(token), []byte(expectedToken)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer`) + writeBearerAuthError(w, r) + return + } + + response := AuthResponse{ + Authenticated: true, + Token: token, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// writeBearerAuthError writes a 401 response with helpful curl examples. +func writeBearerAuthError(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path) + + var username, password, token string + if globalConfig != nil && globalConfig.AuthAllowedUsername != "" && globalConfig.AuthAllowedPassword != "" { + username = globalConfig.AuthAllowedUsername + password = globalConfig.AuthAllowedPassword + token = computeBearerToken(username, password) + } else { + username = "username" + password = "password" + token = computeBearerToken(username, password) + } + + message := fmt.Sprintf(`Unauthorized + +This endpoint requires Bearer token authentication. +The token is SHA1(username:password). + +Example usage: + curl -H "Authorization: Bearer %s" %s + +Generate token: + echo -n "%s:%s" | shasum -a 1 | cut -d' ' -f1 + +Configure credentials via environment variables: + AUTH_ALLOWED_USERNAME=%s + AUTH_ALLOWED_PASSWORD=%s +`, + token, baseURL, + username, password, + username, password, + ) + + _, _ = w.Write([]byte(message)) +} + +// computeBearerToken computes SHA1 hash of "username:password" +func computeBearerToken(username, password string) string { + data := fmt.Sprintf("%s:%s", username, password) + hash := sha1.Sum([]byte(data)) + return hex.EncodeToString(hash[:]) +} diff --git a/echo-http/handlers/bearer_auth_test.go b/echo-http/handlers/bearer_auth_test.go new file mode 100644 index 0000000..603a9d9 --- /dev/null +++ b/echo-http/handlers/bearer_auth_test.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBearerAuthEnvHandler(t *testing.T) { + tests := []struct { + name string + config *Config + authHeader string + expectedCode int + expectJSON bool + }{ + { + name: "valid token", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + // SHA1("testuser:testpass") = 1eac13f1578ef493b9ed5617a5f4a31b271eb667 + authHeader: "Bearer 1eac13f1578ef493b9ed5617a5f4a31b271eb667", + expectedCode: http.StatusOK, + expectJSON: true, + }, + { + name: "case insensitive bearer", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + authHeader: "bearer 1eac13f1578ef493b9ed5617a5f4a31b271eb667", + expectedCode: http.StatusOK, + expectJSON: true, + }, + { + name: "invalid token", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + authHeader: "Bearer wrongtoken", + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "no auth header", + config: &Config{}, + authHeader: "", + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "credentials not configured", + config: &Config{}, + authHeader: "Bearer sometoken", + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "empty token", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + authHeader: "Bearer ", + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "malformed header", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + authHeader: "Bearer", + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + { + name: "wrong auth type", + config: &Config{ + AuthAllowedUsername: "testuser", + AuthAllowedPassword: "testpass", + }, + authHeader: "Basic dGVzdHVzZXI6dGVzdHBhc3M=", + expectedCode: http.StatusUnauthorized, + expectJSON: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global config + originalConfig := globalConfig + globalConfig = tt.config + defer func() { globalConfig = originalConfig }() + + // Create request + req := httptest.NewRequest(http.MethodGet, "/bearer-auth", nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + w := httptest.NewRecorder() + + // Call handler + BearerAuthEnvHandler(w, req) + + // Check status code + if w.Code != tt.expectedCode { + t.Errorf("expected status %d, got %d", tt.expectedCode, w.Code) + } + + // Check WWW-Authenticate header for 401 responses + if w.Code == http.StatusUnauthorized { + if w.Header().Get("WWW-Authenticate") == "" { + t.Error("expected WWW-Authenticate header") + } + } + + // Check JSON response for successful auth + if tt.expectJSON { + var resp AuthResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if !resp.Authenticated { + t.Error("expected authenticated=true") + } + if resp.Token != "1eac13f1578ef493b9ed5617a5f4a31b271eb667" { + t.Errorf("expected token to be SHA1 hash, got %s", resp.Token) + } + } + }) + } +} + +func TestComputeBearerToken(t *testing.T) { + tests := []struct { + name string + username string + password string + expected string + }{ + { + name: "testuser:testpass", + username: "testuser", + password: "testpass", + expected: "1eac13f1578ef493b9ed5617a5f4a31b271eb667", + }, + { + name: "admin:secret", + username: "admin", + password: "secret", + expected: "7efaf6701fdf8c6780897f20d5a1a1526dd92029", + }, + { + name: "empty credentials", + username: "", + password: "", + expected: "05a79f06cf3f67f726dae68d18a2290f6c9a50c9", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := computeBearerToken(tt.username, tt.password) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} diff --git a/echo-http/main.go b/echo-http/main.go index 1bf7a11..3076f85 100644 --- a/echo-http/main.go +++ b/echo-http/main.go @@ -98,6 +98,9 @@ func main() { // Basic Auth (environment-based) r.Get("/basic-auth", handlers.BasicAuthEnvHandler) + // Bearer Token Auth (environment-based) + r.Get("/bearer-auth", handlers.BearerAuthEnvHandler) + // Cookie endpoints r.Get("/cookies", handlers.CookiesHandler) r.Get("/cookies/set", handlers.CookiesSetHandler) From 35cf881b83ebf681ddb08b1189d2461ff3afd945 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 23:06:07 +0900 Subject: [PATCH 7/9] refactor(echo-http)!: remove path-based authentication handlers Remove BasicAuthHandler, HiddenBasicAuthHandler, and BearerHandler implementations. These URL path-based handlers were an experimental approach that proved less practical than environment-based authentication. The AuthResponse type is retained as it may be useful for future authentication implementations. BREAKING CHANGE: /basic-auth/{user}/{pass}, /hidden-basic-auth/{user}/{pass}, and /bearer endpoints are no longer available. --- echo-http/handlers/auth.go | 103 -------------- echo-http/handlers/auth_test.go | 239 -------------------------------- echo-http/main.go | 5 - 3 files changed, 347 deletions(-) delete mode 100644 echo-http/handlers/auth_test.go diff --git a/echo-http/handlers/auth.go b/echo-http/handlers/auth.go index 7831e3d..4a16f77 100644 --- a/echo-http/handlers/auth.go +++ b/echo-http/handlers/auth.go @@ -1,110 +1,7 @@ package handlers -import ( - "crypto/subtle" - "encoding/json" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" -) - type AuthResponse struct { Authenticated bool `json:"authenticated"` User string `json:"user,omitempty"` Token string `json:"token,omitempty"` } - -// BasicAuthHandler validates Basic Authentication credentials. -// GET /basic-auth/{user}/{pass} - Returns 200 if credentials match, 401 otherwise -func BasicAuthHandler(w http.ResponseWriter, r *http.Request) { - expectedUser := chi.URLParam(r, "user") - expectedPass := chi.URLParam(r, "pass") - - user, pass, ok := r.BasicAuth() - if !ok { - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Use constant-time comparison to prevent timing attacks - userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(expectedUser)) == 1 - passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) == 1 - - if !userMatch || !passMatch { - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - response := AuthResponse{ - Authenticated: true, - User: user, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) -} - -// HiddenBasicAuthHandler is similar to BasicAuthHandler but doesn't prompt for credentials. -// GET /hidden-basic-auth/{user}/{pass} - Returns 404 instead of 401 if not authenticated -func HiddenBasicAuthHandler(w http.ResponseWriter, r *http.Request) { - expectedUser := chi.URLParam(r, "user") - expectedPass := chi.URLParam(r, "pass") - - user, pass, ok := r.BasicAuth() - if !ok { - http.NotFound(w, r) - return - } - - userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(expectedUser)) == 1 - passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) == 1 - - if !userMatch || !passMatch { - http.NotFound(w, r) - return - } - - response := AuthResponse{ - Authenticated: true, - User: user, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) -} - -// BearerHandler validates Bearer token authentication. -// GET /bearer - Returns 200 if valid Bearer token present, 401 otherwise -func BearerHandler(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - w.Header().Set("WWW-Authenticate", `Bearer`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - w.Header().Set("WWW-Authenticate", `Bearer`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - token := parts[1] - if token == "" { - w.Header().Set("WWW-Authenticate", `Bearer`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - response := AuthResponse{ - Authenticated: true, - Token: token, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) -} diff --git a/echo-http/handlers/auth_test.go b/echo-http/handlers/auth_test.go deleted file mode 100644 index 9e47cfe..0000000 --- a/echo-http/handlers/auth_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" -) - -func TestBasicAuthHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - authUser string - authPass string - setAuth bool - expectedStatus int - authenticated bool - }{ - { - name: "correct credentials", - user: "testuser", - pass: "testpass", - authUser: "testuser", - authPass: "testpass", - setAuth: true, - expectedStatus: http.StatusOK, - authenticated: true, - }, - { - name: "wrong password", - user: "testuser", - pass: "testpass", - authUser: "testuser", - authPass: "wrongpass", - setAuth: true, - expectedStatus: http.StatusUnauthorized, - }, - { - name: "wrong username", - user: "testuser", - pass: "testpass", - authUser: "wronguser", - authPass: "testpass", - setAuth: true, - expectedStatus: http.StatusUnauthorized, - }, - { - name: "no auth header", - user: "testuser", - pass: "testpass", - setAuth: false, - expectedStatus: http.StatusUnauthorized, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/basic-auth/{user}/{pass}", BasicAuthHandler) - - req := httptest.NewRequest(http.MethodGet, "/basic-auth/"+tt.user+"/"+tt.pass, nil) - if tt.setAuth { - req.SetBasicAuth(tt.authUser, tt.authPass) - } - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.authenticated { - var response AuthResponse - if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if !response.Authenticated { - t.Error("expected authenticated to be true") - } - if response.User != tt.authUser { - t.Errorf("expected user %q, got %q", tt.authUser, response.User) - } - } - - if tt.expectedStatus == http.StatusUnauthorized { - wwwAuth := rec.Header().Get("WWW-Authenticate") - if wwwAuth == "" { - t.Error("expected WWW-Authenticate header") - } - } - }) - } -} - -func TestHiddenBasicAuthHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - authUser string - authPass string - setAuth bool - expectedStatus int - }{ - { - name: "correct credentials", - user: "testuser", - pass: "testpass", - authUser: "testuser", - authPass: "testpass", - setAuth: true, - expectedStatus: http.StatusOK, - }, - { - name: "wrong credentials returns 404", - user: "testuser", - pass: "testpass", - authUser: "testuser", - authPass: "wrongpass", - setAuth: true, - expectedStatus: http.StatusNotFound, - }, - { - name: "no auth returns 404", - user: "testuser", - pass: "testpass", - setAuth: false, - expectedStatus: http.StatusNotFound, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/hidden-basic-auth/{user}/{pass}", HiddenBasicAuthHandler) - - req := httptest.NewRequest(http.MethodGet, "/hidden-basic-auth/"+tt.user+"/"+tt.pass, nil) - if tt.setAuth { - req.SetBasicAuth(tt.authUser, tt.authPass) - } - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - }) - } -} - -func TestBearerHandler(t *testing.T) { - tests := []struct { - name string - authHeader string - expectedStatus int - authenticated bool - expectedToken string - }{ - { - name: "valid bearer token", - authHeader: "Bearer my-secret-token", - expectedStatus: http.StatusOK, - authenticated: true, - expectedToken: "my-secret-token", - }, - { - name: "case insensitive bearer", - authHeader: "bearer my-token", - expectedStatus: http.StatusOK, - authenticated: true, - expectedToken: "my-token", - }, - { - name: "no auth header", - authHeader: "", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "wrong auth type", - authHeader: "Basic dXNlcjpwYXNz", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "empty token", - authHeader: "Bearer ", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "malformed header", - authHeader: "Bearer", - expectedStatus: http.StatusUnauthorized, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/bearer", BearerHandler) - - req := httptest.NewRequest(http.MethodGet, "/bearer", nil) - if tt.authHeader != "" { - req.Header.Set("Authorization", tt.authHeader) - } - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.authenticated { - var response AuthResponse - if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if !response.Authenticated { - t.Error("expected authenticated to be true") - } - if response.Token != tt.expectedToken { - t.Errorf("expected token %q, got %q", tt.expectedToken, response.Token) - } - } - - if tt.expectedStatus == http.StatusUnauthorized { - wwwAuth := rec.Header().Get("WWW-Authenticate") - if wwwAuth == "" { - t.Error("expected WWW-Authenticate header") - } - } - }) - } -} diff --git a/echo-http/main.go b/echo-http/main.go index 3076f85..43c4459 100644 --- a/echo-http/main.go +++ b/echo-http/main.go @@ -69,11 +69,6 @@ func main() { r.Get("/absolute-redirect/{n}", handlers.AbsoluteRedirectHandler) r.Get("/relative-redirect/{n}", handlers.RelativeRedirectHandler) - // Authentication endpoints - r.Get("/basic-auth/{user}/{pass}", handlers.BasicAuthHandler) - r.Get("/hidden-basic-auth/{user}/{pass}", handlers.HiddenBasicAuthHandler) - r.Get("/bearer", handlers.BearerHandler) - // OIDC endpoints r.Get("/oidc/{user}/{pass}/.well-known/openid-configuration", handlers.OIDCDiscoveryHandler) r.Get("/oidc/{user}/{pass}/.well-known/jwks.json", handlers.OIDCJWKSHandler) From 763416b46241310f682ef651c32fec44cea7a082 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 23:08:13 +0900 Subject: [PATCH 8/9] refactor(echo-http)!: remove path-based OIDC handlers Remove the /oidc/{user}/{pass}/* endpoint implementation. The path-based authentication approach made credential management difficult and exposed credentials in URLs (a security anti-pattern). The environment-based OAuth2/OIDC implementation (/.well-known/* endpoints) provides a more secure and standards-compliant alternative. BREAKING CHANGE: /oidc/{user}/{pass}/* endpoints (discovery, authorize, callback, token, userinfo, demo) are no longer available. --- echo-http/handlers/oidc.go | 931 ----------- echo-http/handlers/oidc_error.go | 71 - echo-http/handlers/oidc_error_test.go | 212 --- echo-http/handlers/oidc_jwt_test.go | 593 ------- echo-http/handlers/oidc_session.go | 193 --- echo-http/handlers/oidc_test.go | 2142 ------------------------- echo-http/main.go | 10 - 7 files changed, 4152 deletions(-) delete mode 100644 echo-http/handlers/oidc.go delete mode 100644 echo-http/handlers/oidc_error.go delete mode 100644 echo-http/handlers/oidc_error_test.go delete mode 100644 echo-http/handlers/oidc_jwt_test.go delete mode 100644 echo-http/handlers/oidc_session.go delete mode 100644 echo-http/handlers/oidc_test.go diff --git a/echo-http/handlers/oidc.go b/echo-http/handlers/oidc.go deleted file mode 100644 index b3f9114..0000000 --- a/echo-http/handlers/oidc.go +++ /dev/null @@ -1,931 +0,0 @@ -package handlers - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "html/template" - "net/http" - "net/url" - "strings" - "time" - - "github.com/go-chi/chi/v5" -) - -// OIDCDiscoveryResponse represents the OpenID Connect Discovery metadata -type OIDCDiscoveryResponse struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"userinfo_endpoint"` - JwksURI string `json:"jwks_uri"` - ResponseTypesSupported []string `json:"response_types_supported"` - SubjectTypesSupported []string `json:"subject_types_supported"` - IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` - ScopesSupported []string `json:"scopes_supported"` - GrantTypesSupported []string `json:"grant_types_supported"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` -} - -// OIDCDiscoveryHandler provides OpenID Connect Discovery metadata -// GET /oidc/{user}/{pass}/.well-known/openid-configuration -func OIDCDiscoveryHandler(w http.ResponseWriter, r *http.Request) { - user := chi.URLParam(r, "user") - pass := chi.URLParam(r, "pass") - - // Build base URL from request - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - // Check for X-Forwarded-Proto header (proxy support) - if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { - scheme = proto - } - - host := r.Host - baseURL := fmt.Sprintf("%s://%s", scheme, host) - oidcBase := fmt.Sprintf("%s/oidc/%s/%s", baseURL, url.PathEscape(user), url.PathEscape(pass)) - - // Get scopes from config, or use defaults if not configured - supportedScopes := []string{"openid", "profile", "email"} - if globalConfig != nil && len(globalConfig.AuthSupportedScopes) > 0 { - supportedScopes = globalConfig.AuthSupportedScopes - } - - discovery := OIDCDiscoveryResponse{ - Issuer: oidcBase, - AuthorizationEndpoint: oidcBase + "/authorize", - TokenEndpoint: oidcBase + "/token", - UserInfoEndpoint: oidcBase + "/userinfo", - JwksURI: oidcBase + "/.well-known/jwks.json", - ResponseTypesSupported: []string{ - "code", - }, - SubjectTypesSupported: []string{ - "public", - }, - IDTokenSigningAlgValuesSupported: []string{ - "none", // Mock implementation - no actual JWT signing - }, - ScopesSupported: supportedScopes, - GrantTypesSupported: []string{ - "authorization_code", - }, - CodeChallengeMethodsSupported: []string{ - "plain", - "S256", - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(discovery) -} - -// validateScopes validates that all requested scopes are supported. -// Returns error if any requested scope is not in the configured supported scopes. -func validateScopes(requestedScope string) error { - if requestedScope == "" { - return nil // Empty scope will use defaults - } - - requestedScopes := strings.Split(requestedScope, " ") - supportedScopes := globalConfig.AuthSupportedScopes - - for _, rs := range requestedScopes { - rs = strings.TrimSpace(rs) - if rs == "" { - continue - } - - found := false - for _, ss := range supportedScopes { - if rs == ss { - found = true - break - } - } - - if !found { - return fmt.Errorf("unsupported scope: %s", rs) - } - } - - return nil -} - -// getDefaultScopes returns default scopes as a space-separated string. -// Returns all configured scopes joined by spaces. -func getDefaultScopes() string { - return strings.Join(globalConfig.AuthSupportedScopes, " ") -} - -// OIDCAuthorizeHandler handles OIDC authorization requests -// GET /oidc/{user}/{pass}/authorize - Display login form -// POST /oidc/{user}/{pass}/authorize - Process authentication -func OIDCAuthorizeHandler(w http.ResponseWriter, r *http.Request) { - user := chi.URLParam(r, "user") - pass := chi.URLParam(r, "pass") - - if r.Method == http.MethodGet { - // GET: Display login form - clientID := r.URL.Query().Get("client_id") - redirectURI := r.URL.Query().Get("redirect_uri") - scope := r.URL.Query().Get("scope") - responseType := r.URL.Query().Get("response_type") - state := r.URL.Query().Get("state") // Client-provided (optional) - codeChallenge := r.URL.Query().Get("code_challenge") - codeChallengeMethod := r.URL.Query().Get("code_challenge_method") - nonce := r.URL.Query().Get("nonce") // OIDC nonce parameter (optional) - - // Validate client_id (REQUIRED per OIDC spec) - if clientID == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "client_id parameter is required") - return - } - - // Validate client_id value if configured - if globalConfig != nil && globalConfig.AuthAllowedClientID != "" && clientID != globalConfig.AuthAllowedClientID { - writeAuthorizationError(w, r, ErrorUnauthorizedClient, "unknown client_id", state, redirectURI) - return - } - - // Validate required parameters - if redirectURI == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "redirect_uri parameter is required") - return - } - - // Validate redirect_uri if validation is enabled - if globalConfig != nil && globalConfig.AuthCodeValidateRedirectURI { - var allowedPatterns []string - if globalConfig.AuthCodeAllowedRedirectURIs != "" { - // Split comma-separated patterns - for _, pattern := range strings.Split(globalConfig.AuthCodeAllowedRedirectURIs, ",") { - if trimmed := strings.TrimSpace(pattern); trimmed != "" { - allowedPatterns = append(allowedPatterns, trimmed) - } - } - } - - if err := validateRedirectURI(redirectURI, allowedPatterns); err != nil { - writeAuthorizationError(w, r, ErrorInvalidRequest, "redirect_uri not in allowlist", state, redirectURI) - return - } - } - - if responseType != "code" { - writeAuthorizationError(w, r, ErrorUnsupportedResponseType, "only response_type=code is supported", state, redirectURI) - return - } - - // Validate and set default scope if not provided - if scope == "" { - scope = getDefaultScopes() - } else { - if err := validateScopes(scope); err != nil { - writeAuthorizationError(w, r, ErrorInvalidScope, err.Error(), state, redirectURI) - return - } - } - - // Validate PKCE parameters - if globalConfig != nil && globalConfig.AuthCodeRequirePKCE && codeChallenge == "" { - writeAuthorizationError(w, r, ErrorInvalidRequest, "code_challenge is required", state, redirectURI) - return - } - - // If code_challenge is provided, validate method - if codeChallenge != "" { - // Default to "plain" if method not specified (per RFC 7636 Section 4.3) - if codeChallengeMethod == "" { - codeChallengeMethod = "plain" - } - - // Validate method is supported - if codeChallengeMethod != "plain" && codeChallengeMethod != "S256" { - writeAuthorizationError(w, r, ErrorInvalidRequest, "unsupported code_challenge_method", state, redirectURI) - return - } - } - - // Create a new session with PKCE parameters and nonce - session, err := DefaultSessionStore.CreateSession(state, redirectURI, scope, codeChallenge, codeChallengeMethod, nonce) - if err != nil { - writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to create session") - return - } - - // Set session cookie - http.SetCookie(w, &http.Cookie{ - Name: "oidc_session", - Value: session.ID, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - - // Render login form - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl := template.Must(template.New("login").Parse(loginFormTemplate)) - data := struct { - User string - Pass string - State string - RedirectURI string - Scope string - AuthorizeURL string - }{ - User: user, - Pass: pass, - State: session.State, - RedirectURI: redirectURI, - Scope: scope, - AuthorizeURL: fmt.Sprintf("/oidc/%s/%s/authorize", url.PathEscape(user), url.PathEscape(pass)), - } - _ = tmpl.Execute(w, data) - return - } - - // POST: Process authentication - expectedUser := user - expectedPass := pass - - // Get session from cookie - cookie, err := r.Cookie("oidc_session") - if err != nil { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "session not found") - return - } - - session, ok := DefaultSessionStore.GetSession(cookie.Value) - if !ok { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid or expired session") - return - } - - if err := r.ParseForm(); err != nil { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid form data") - return - } - - username := r.PostForm.Get("username") - password := r.PostForm.Get("password") - - // Validate required parameters - if username == "" || password == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "username and password are required") - return - } - - // Validate credentials against URL path - if username != expectedUser || password != expectedPass { - writeOIDCError(w, http.StatusUnauthorized, ErrorAccessDenied, "invalid username or password") - return - } - - // Generate authorization code using session's redirect_uri, PKCE parameters, and nonce - authCode, err := DefaultSessionStore.CreateAuthCode(session.RedirectURI, username, session.Scope, session.CodeChallenge, session.CodeChallengeMethod, session.Nonce) - if err != nil { - writeOIDCError(w, http.StatusInternalServerError, ErrorServerError, "failed to create authorization code") - return - } - - // Delete the session as it's been used - DefaultSessionStore.DeleteSession(session.ID) - - // Clear session cookie - http.SetCookie(w, &http.Cookie{ - Name: "oidc_session", - Value: "", - Path: "/", - MaxAge: -1, - }) - - // Redirect back to the client with the authorization code and state - redirectURL, _ := url.Parse(session.RedirectURI) - query := redirectURL.Query() - query.Set("code", authCode.Code) - if session.State != "" { - query.Set("state", session.State) // Only include if client provided it - } - redirectURL.RawQuery = query.Encode() - - http.Redirect(w, r, redirectURL.String(), http.StatusFound) -} - -// OIDCCallbackHandler handles the callback from the authorization server -// GET /oidc/{user}/{pass}/callback?code={code}&state={state} -func OIDCCallbackHandler(w http.ResponseWriter, r *http.Request) { - user := chi.URLParam(r, "user") - pass := chi.URLParam(r, "pass") - code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - - // Build token endpoint URL - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { - scheme = proto - } - host := r.Host - tokenEndpoint := fmt.Sprintf("%s://%s/oidc/%s/%s/token", scheme, host, url.PathEscape(user), url.PathEscape(pass)) - - // Render callback page - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl := template.Must(template.New("callback").Parse(callbackTemplate)) - data := struct { - Code string - State string - Error string - TokenEndpoint string - }{ - Code: code, - State: state, - TokenEndpoint: tokenEndpoint, - } - - if code == "" { - data.Error = "No authorization code received" - } - - _ = tmpl.Execute(w, data) -} - -// TokenResponse represents the response from the token endpoint -type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token,omitempty"` - IDToken string `json:"id_token,omitempty"` - Scope string `json:"scope,omitempty"` -} - -// OIDCTokenHandler exchanges an authorization code for tokens -// POST /oidc/{user}/{pass}/token -func OIDCTokenHandler(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid form data") - return - } - - grantType := r.PostForm.Get("grant_type") - code := r.PostForm.Get("code") - redirectURI := r.PostForm.Get("redirect_uri") - clientID := r.PostForm.Get("client_id") - clientSecret := r.PostForm.Get("client_secret") - codeVerifier := r.PostForm.Get("code_verifier") - - // Validate grant_type - if grantType != "authorization_code" { - writeOIDCError(w, http.StatusBadRequest, ErrorUnsupportedGrantType, "only grant_type=authorization_code is supported") - return - } - - // Validate client_id (REQUIRED per OIDC spec) - if clientID == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "client_id parameter is required") - return - } - - // Validate client_id value if configured - if globalConfig != nil && globalConfig.AuthAllowedClientID != "" && clientID != globalConfig.AuthAllowedClientID { - writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidClient, "unknown client_id") - return - } - - // Validate client_secret if configured (confidential client) - if globalConfig != nil && globalConfig.AuthAllowedClientSecret != "" { - if clientSecret != globalConfig.AuthAllowedClientSecret { - writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidClient, "invalid client_secret") - return - } - } - - // Validate required parameters - if code == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "code parameter is required") - return - } - - if redirectURI == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "redirect_uri parameter is required") - return - } - - // Validate authorization code - authCode, ok := DefaultSessionStore.GetAuthCode(code) - if !ok { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "invalid or expired authorization code") - return - } - - // Validate redirect URI matches - if authCode.RedirectURI != redirectURI { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "redirect_uri mismatch") - return - } - - // Validate PKCE if code_challenge was provided during authorization - if authCode.CodeChallenge != "" { - // code_verifier is required when code_challenge was used - if codeVerifier == "" { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "code_verifier is required") - return - } - - // RFC 7636 Section 4.1: code_verifier length must be 43-128 characters - if len(codeVerifier) < 43 || len(codeVerifier) > 128 { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "code_verifier length must be between 43 and 128 characters (RFC 7636)") - return - } - - // Verify code_verifier against code_challenge - if !verifyCodeChallenge(authCode.CodeChallenge, authCode.CodeChallengeMethod, codeVerifier) { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidGrant, "invalid code_verifier") - return - } - } - - // Delete the authorization code (single-use) - DefaultSessionStore.DeleteAuthCode(code) - - // Generate mock tokens - accessToken, _ := generateRandomString(32) - refreshToken, _ := generateRandomString(32) - - // Build issuer URL for ID token - user := chi.URLParam(r, "user") - pass := chi.URLParam(r, "pass") - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { - scheme = proto - } - host := r.Host - issuer := fmt.Sprintf("%s://%s/oidc/%s/%s", scheme, host, url.PathEscape(user), url.PathEscape(pass)) - - // Create ID token in JWT format with actual issuer, client_id, and nonce - idToken := generateMockIDToken(issuer, clientID, authCode.Username, authCode.Nonce) - - response := TokenResponse{ - AccessToken: accessToken, - TokenType: "Bearer", - ExpiresIn: 3600, - RefreshToken: refreshToken, - IDToken: idToken, - Scope: authCode.Scope, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) -} - -// generateMockIDToken creates a mock ID token in JWT format with algorithm "none". -// Returns a JWT in the format: header.payload.signature (where signature is empty for alg=none). -func generateMockIDToken(issuer, clientID, username, nonce string) string { - // Header for JWT with alg="none" - header := map[string]string{ - "alg": "none", - "typ": "JWT", - } - headerJSON, _ := json.Marshal(header) - headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) - - // Payload (claims) - claims := map[string]interface{}{ - "iss": issuer, - "sub": username, - "aud": clientID, - "exp": time.Now().Add(1 * time.Hour).Unix(), - "iat": time.Now().Unix(), - "name": username, - "email": fmt.Sprintf("%s@example.com", username), - } - // Include nonce claim only if provided - if nonce != "" { - claims["nonce"] = nonce - } - claimsJSON, _ := json.Marshal(claims) - claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) - - // JWT format: header.payload.signature (empty signature for "none") - return headerB64 + "." + claimsB64 + "." -} - -// verifyCodeChallenge verifies PKCE code_verifier against code_challenge. -// Supports "plain" and "S256" methods per RFC 7636. -func verifyCodeChallenge(challenge, method, verifier string) bool { - switch method { - case "plain": - // Plain method: challenge == verifier - return challenge == verifier - - case "S256": - // S256 method: challenge == BASE64URL(SHA256(ASCII(verifier))) - h := sha256.Sum256([]byte(verifier)) - computed := base64.RawURLEncoding.EncodeToString(h[:]) - return challenge == computed - - default: - // Unknown or empty method - return false - } -} - -// validateRedirectURI validates that redirectURI matches one of the allowed patterns. -// Returns nil if validation passes, error otherwise. -// Empty or nil allowedPatterns means no restrictions (allow all). -// Supports wildcards: * for any port or path segment. -func validateRedirectURI(redirectURI string, allowedPatterns []string) error { - if len(allowedPatterns) == 0 { - return nil // No restrictions - } - - for _, pattern := range allowedPatterns { - if matchRedirectPattern(redirectURI, pattern) { - return nil - } - } - - return fmt.Errorf("redirect_uri not in allowlist") -} - -// matchRedirectPattern checks if uri matches pattern. -// Supports wildcards: -// - "http://localhost:*/callback" matches any port -// - "http://localhost:8080/*" matches any path -func matchRedirectPattern(uri, pattern string) bool { - // Exact match - if uri == pattern { - return true - } - - // Parse URI - uriParsed, err := url.Parse(uri) - if err != nil { - return false - } - - // Handle pattern specially to support wildcard port - // Replace :* with a valid port temporarily for parsing - patternForParsing := strings.Replace(pattern, ":*", ":9999", 1) - hasWildcardPort := patternForParsing != pattern - - patternParsed, err := url.Parse(patternForParsing) - if err != nil { - return false - } - - // Scheme must match exactly - if uriParsed.Scheme != patternParsed.Scheme { - return false - } - - // Host must match exactly - uriHost := uriParsed.Hostname() - patternHost := patternParsed.Hostname() - if uriHost != patternHost { - return false - } - - // Port matching: support wildcard * - if !hasWildcardPort { - // Ports must match exactly (including both being empty for default ports) - uriPort := uriParsed.Port() - patternPort := patternParsed.Port() - if uriPort != patternPort { - return false - } - } - // If hasWildcardPort, accept any port - - // Path matching: support wildcard * - if patternParsed.Path == "/*" { - return true // Any path allowed - } - - if uriParsed.Path != patternParsed.Path { - return false - } - - return true -} - -const loginFormTemplate = ` - - - OIDC Login - - -

OIDC Login

-
-

- -

-

- -

-

- -

-
-
-

Expected: {{.User}}

-

Scope: {{.Scope}}

-

Redirect: {{.RedirectURI}}

- -` - -const callbackTemplate = ` - - - OIDC Callback - - - - -

OIDC Callback

- {{if .Error}} -

Error: {{.Error}}

- {{else}} -

Authorization successful

-

Authorization Code

-
{{.Code}}
-

State

-
{{.State}}
-

Token Exchange

-

- -

- -
-

Tokens

-

-        
- {{end}} - -` - -// OIDCDemoHandler provides an interactive demo of the OIDC flow -// GET /oidc/{user}/{pass}/demo -func OIDCDemoHandler(w http.ResponseWriter, r *http.Request) { - user := chi.URLParam(r, "user") - pass := chi.URLParam(r, "pass") - - code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - errorParam := r.URL.Query().Get("error") - - // If no code and no error, initiate OIDC flow - if code == "" && errorParam == "" { - // Generate state for CSRF protection (demo client generates its own state) - demoState, err := generateRandomString(16) - if err != nil { - http.Error(w, "failed to generate state", http.StatusInternalServerError) - return - } - - // Store state in cookie for later verification - http.SetCookie(w, &http.Cookie{ - Name: "demo_state", - Value: demoState, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - - // Build redirect URI for this demo page - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { - scheme = proto - } - - host := r.Host - redirectURI := fmt.Sprintf("%s://%s/oidc/%s/%s/demo", scheme, host, url.PathEscape(user), url.PathEscape(pass)) - authorizeURL := fmt.Sprintf("/oidc/%s/%s/authorize?redirect_uri=%s&response_type=code&scope=openid%%20profile%%20email&state=%s", - url.PathEscape(user), url.PathEscape(pass), url.QueryEscape(redirectURI), demoState) - - http.Redirect(w, r, authorizeURL, http.StatusFound) - return - } - - // Verify state matches (CSRF protection) - cookie, err := r.Cookie("demo_state") - if err == nil && cookie.Value != state { - errorParam = "state_mismatch" - } - - // Clear demo state cookie - http.SetCookie(w, &http.Cookie{ - Name: "demo_state", - Value: "", - Path: "/", - MaxAge: -1, - }) - - // Render demo page with code/error - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl := template.Must(template.New("demo").Parse(demoPageTemplate)) - - // Build token endpoint URL - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { - scheme = proto - } - host := r.Host - tokenEndpoint := fmt.Sprintf("%s://%s/oidc/%s/%s/token", scheme, host, url.PathEscape(user), url.PathEscape(pass)) - redirectURI := fmt.Sprintf("%s://%s/oidc/%s/%s/demo", scheme, host, url.PathEscape(user), url.PathEscape(pass)) - - data := struct { - User string - Pass string - Code string - State string - Error string - TokenEndpoint string - RedirectURI string - }{ - User: user, - Pass: pass, - Code: code, - State: state, - Error: errorParam, - TokenEndpoint: tokenEndpoint, - RedirectURI: redirectURI, - } - - _ = tmpl.Execute(w, data) -} - -// OIDCUserInfoHandler returns user information based on the access token -// GET /oidc/{user}/{pass}/userinfo -func OIDCUserInfoHandler(w http.ResponseWriter, r *http.Request) { - user := chi.URLParam(r, "user") - - // Extract Bearer token from Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidRequest, "missing authorization header") - return - } - - // Validate Bearer scheme - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidRequest, "invalid authorization scheme") - return - } - - // In a real implementation, we would validate the access token - // For this mock server, we accept any non-empty Bearer token - accessToken := parts[1] - if accessToken == "" { - writeOIDCError(w, http.StatusUnauthorized, ErrorInvalidRequest, "empty access token") - return - } - - // Return user information based on the user from URL path - userInfo := map[string]interface{}{ - "sub": user, - "name": user, - "email": fmt.Sprintf("%s@example.com", user), - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(userInfo) -} - -// JWKSResponse represents a JSON Web Key Set response -type JWKSResponse struct { - Keys []interface{} `json:"keys"` -} - -// OIDCJWKSHandler returns an empty JWKS (JSON Web Key Set) -// GET /oidc/{user}/{pass}/.well-known/jwks.json -func OIDCJWKSHandler(w http.ResponseWriter, r *http.Request) { - // Return empty JWKS since we use alg="none" (no signature) - jwks := JWKSResponse{ - Keys: []interface{}{}, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(jwks) -} - -const demoPageTemplate = ` - - - OIDC Demo - - - - -

OIDC Demo

- {{if .Error}} -

Error: {{.Error}}

- {{else}} -

Authorization successful

-

Authorization Code

-
{{.Code}}
-

State

-
{{.State}}
-

Token Exchange

-

- -

- -
-

Tokens

-

-        
- {{end}} - -` diff --git a/echo-http/handlers/oidc_error.go b/echo-http/handlers/oidc_error.go deleted file mode 100644 index 29163ab..0000000 --- a/echo-http/handlers/oidc_error.go +++ /dev/null @@ -1,71 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/url" -) - -// OIDCError represents an OAuth 2.0/OIDC error response. -// Spec: RFC 6749 Section 5.2, OIDC Core Section 3.1.2.6 -type OIDCError struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description,omitempty"` - ErrorURI string `json:"error_uri,omitempty"` -} - -// Standard OAuth 2.0 error codes -const ( - ErrorInvalidRequest = "invalid_request" - ErrorUnauthorizedClient = "unauthorized_client" - ErrorAccessDenied = "access_denied" - ErrorUnsupportedResponseType = "unsupported_response_type" - ErrorInvalidScope = "invalid_scope" - ErrorServerError = "server_error" - ErrorTemporarilyUnavailable = "temporarily_unavailable" - ErrorInvalidClient = "invalid_client" - ErrorInvalidGrant = "invalid_grant" - ErrorUnsupportedGrantType = "unsupported_grant_type" -) - -// writeOIDCError writes an OAuth 2.0/OIDC compliant error response. -func writeOIDCError(w http.ResponseWriter, statusCode int, errorCode, description string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - - errResp := OIDCError{ - Error: errorCode, - ErrorDescription: description, - } - - _ = json.NewEncoder(w).Encode(errResp) -} - -// writeAuthorizationError writes an error for authorization endpoint. -// Per OIDC spec, these errors should redirect to redirect_uri with error in query. -func writeAuthorizationError(w http.ResponseWriter, r *http.Request, errorCode, description, state, redirectURI string) { - if redirectURI == "" { - // No redirect_uri, return JSON error - writeOIDCError(w, http.StatusBadRequest, errorCode, description) - return - } - - // Build error redirect - redirectURL, err := url.Parse(redirectURI) - if err != nil { - writeOIDCError(w, http.StatusBadRequest, ErrorInvalidRequest, "invalid redirect_uri") - return - } - - query := redirectURL.Query() - query.Set("error", errorCode) - if description != "" { - query.Set("error_description", description) - } - if state != "" { - query.Set("state", state) - } - redirectURL.RawQuery = query.Encode() - - http.Redirect(w, r, redirectURL.String(), http.StatusFound) -} diff --git a/echo-http/handlers/oidc_error_test.go b/echo-http/handlers/oidc_error_test.go deleted file mode 100644 index 653eef6..0000000 --- a/echo-http/handlers/oidc_error_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" -) - -func TestWriteOIDCError(t *testing.T) { - tests := []struct { - name string - statusCode int - errorCode string - description string - expectedStatusCode int - expectedError string - expectedDesc string - }{ - { - name: "invalid_request error", - statusCode: http.StatusBadRequest, - errorCode: "invalid_request", - description: "client_id parameter is required", - expectedStatusCode: http.StatusBadRequest, - expectedError: "invalid_request", - expectedDesc: "client_id parameter is required", - }, - { - name: "invalid_client error", - statusCode: http.StatusUnauthorized, - errorCode: "invalid_client", - description: "unknown client_id", - expectedStatusCode: http.StatusUnauthorized, - expectedError: "invalid_client", - expectedDesc: "unknown client_id", - }, - { - name: "error without description", - statusCode: http.StatusBadRequest, - errorCode: "invalid_scope", - description: "", - expectedStatusCode: http.StatusBadRequest, - expectedError: "invalid_scope", - expectedDesc: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rec := httptest.NewRecorder() - - writeOIDCError(rec, tt.statusCode, tt.errorCode, tt.description) - - // Verify status code - if rec.Code != tt.expectedStatusCode { - t.Errorf("expected status code %d, got %d", tt.expectedStatusCode, rec.Code) - } - - // Verify Content-Type - contentType := rec.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("expected Content-Type application/json, got %s", contentType) - } - - // Verify response body - var errResp struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description,omitempty"` - } - - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse response body: %v", err) - } - - if errResp.Error != tt.expectedError { - t.Errorf("expected error %q, got %q", tt.expectedError, errResp.Error) - } - - if errResp.ErrorDescription != tt.expectedDesc { - t.Errorf("expected error_description %q, got %q", tt.expectedDesc, errResp.ErrorDescription) - } - }) - } -} - -func TestWriteAuthorizationError(t *testing.T) { - tests := []struct { - name string - errorCode string - description string - state string - redirectURI string - expectedStatusCode int - expectRedirect bool - expectJSONError bool - }{ - { - name: "redirect with error and state", - errorCode: "unauthorized_client", - description: "unknown client_id", - state: "xyz123", - redirectURI: "http://localhost/callback", - expectedStatusCode: http.StatusFound, - expectRedirect: true, - expectJSONError: false, - }, - { - name: "redirect with error without state", - errorCode: "invalid_scope", - description: "unsupported scope: admin", - state: "", - redirectURI: "http://localhost/callback", - expectedStatusCode: http.StatusFound, - expectRedirect: true, - expectJSONError: false, - }, - { - name: "no redirect_uri returns JSON error", - errorCode: "invalid_request", - description: "redirect_uri parameter is required", - state: "", - redirectURI: "", - expectedStatusCode: http.StatusBadRequest, - expectRedirect: false, - expectJSONError: true, - }, - { - name: "invalid redirect_uri returns JSON error", - errorCode: "invalid_request", - description: "some error", - state: "", - redirectURI: "://invalid-url", - expectedStatusCode: http.StatusBadRequest, - expectRedirect: false, - expectJSONError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/authorize", nil) - rec := httptest.NewRecorder() - - writeAuthorizationError(rec, req, tt.errorCode, tt.description, tt.state, tt.redirectURI) - - // Verify status code - if rec.Code != tt.expectedStatusCode { - t.Errorf("expected status code %d, got %d", tt.expectedStatusCode, rec.Code) - } - - if tt.expectRedirect { - // Verify redirect - location := rec.Header().Get("Location") - if location == "" { - t.Fatal("expected Location header, got none") - } - - redirectURL, err := url.Parse(location) - if err != nil { - t.Fatalf("failed to parse redirect URL: %v", err) - } - - query := redirectURL.Query() - - // Verify error parameter - if query.Get("error") != tt.errorCode { - t.Errorf("expected error=%q, got %q", tt.errorCode, query.Get("error")) - } - - // Verify error_description parameter - if tt.description != "" && query.Get("error_description") != tt.description { - t.Errorf("expected error_description=%q, got %q", tt.description, query.Get("error_description")) - } - - // Verify state parameter - if tt.state != "" && query.Get("state") != tt.state { - t.Errorf("expected state=%q, got %q", tt.state, query.Get("state")) - } - } - - if tt.expectJSONError { - // Verify JSON error response - contentType := rec.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("expected Content-Type application/json, got %s", contentType) - } - - var errResp struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description,omitempty"` - } - - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse response body: %v", err) - } - - // For invalid redirect_uri case, error code should be "invalid_request" - if tt.redirectURI == "://invalid-url" { - if errResp.Error != "invalid_request" { - t.Errorf("expected error=invalid_request for invalid redirect_uri, got %q", errResp.Error) - } - } else { - if errResp.Error != tt.errorCode { - t.Errorf("expected error %q, got %q", tt.errorCode, errResp.Error) - } - } - } - }) - } -} diff --git a/echo-http/handlers/oidc_jwt_test.go b/echo-http/handlers/oidc_jwt_test.go deleted file mode 100644 index dd2d3bb..0000000 --- a/echo-http/handlers/oidc_jwt_test.go +++ /dev/null @@ -1,593 +0,0 @@ -package handlers - -import ( - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/go-chi/chi/v5" -) - -// TestIDTokenJWTFormat verifies that ID token is returned in JWT format (P0-1) -func TestIDTokenJWTFormat(t *testing.T) { - tests := []struct { - name string - issuer string - clientID string - username string - }{ - { - name: "JWT format with standard values", - issuer: "http://localhost:8080/oidc/testuser/testpass", - clientID: "test-client-id", - username: "testuser", - }, - { - name: "JWT format with different issuer", - issuer: "https://example.com/oidc/admin/secret", - clientID: "another-client", - username: "admin", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Arrange - idToken := generateMockIDToken(tt.issuer, tt.clientID, tt.username, "") - - // Act: Split JWT into parts - parts := strings.Split(idToken, ".") - - // Assert: JWT must have exactly 3 parts (header.payload.signature) - if len(parts) != 3 { - t.Errorf("expected JWT to have 3 parts, got %d; token: %s", len(parts), idToken) - } - - // Assert: Header must decode to valid JSON with alg="none" and typ="JWT" - headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - t.Fatalf("failed to decode JWT header: %v", err) - } - - var header map[string]interface{} - if err := json.Unmarshal(headerJSON, &header); err != nil { - t.Fatalf("failed to parse JWT header JSON: %v", err) - } - - if header["alg"] != "none" { - t.Errorf("expected alg=none, got %v", header["alg"]) - } - - if header["typ"] != "JWT" { - t.Errorf("expected typ=JWT, got %v", header["typ"]) - } - - // Assert: Payload must decode to valid JSON with correct claims - payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - t.Fatalf("failed to decode JWT payload: %v", err) - } - - var claims map[string]interface{} - if err := json.Unmarshal(payloadJSON, &claims); err != nil { - t.Fatalf("failed to parse JWT payload JSON: %v", err) - } - - if claims["iss"] != tt.issuer { - t.Errorf("expected iss=%s, got %v", tt.issuer, claims["iss"]) - } - - if claims["sub"] != tt.username { - t.Errorf("expected sub=%s, got %v", tt.username, claims["sub"]) - } - - if claims["aud"] != tt.clientID { - t.Errorf("expected aud=%s, got %v", tt.clientID, claims["aud"]) - } - - // Assert: Signature part must be empty (alg=none) - if parts[2] != "" { - t.Errorf("expected empty signature for alg=none, got %s", parts[2]) - } - - // Assert: Standard claims exist - if _, ok := claims["exp"]; !ok { - t.Error("expected exp claim to exist") - } - - if _, ok := claims["iat"]; !ok { - t.Error("expected iat claim to exist") - } - - if claims["name"] != tt.username { - t.Errorf("expected name=%s, got %v", tt.username, claims["name"]) - } - - expectedEmail := tt.username + "@example.com" - if claims["email"] != expectedEmail { - t.Errorf("expected email=%s, got %v", expectedEmail, claims["email"]) - } - }) - } -} - -// TestTokenEndpointReturnsJWTIDToken verifies that token endpoint returns JWT format ID token (P0-1) -func TestTokenEndpointReturnsJWTIDToken(t *testing.T) { - // Arrange - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", "", "", "") - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - formData := url.Values{} - formData.Add("grant_type", "authorization_code") - formData.Add("client_id", "test-client") - formData.Add("code", authCode.Code) - formData.Add("redirect_uri", "http://localhost/callback") - - req := httptest.NewRequest(http.MethodPost, "/oidc/testuser/testpass/token", strings.NewReader(formData.Encode())) - req.Host = "localhost:8080" - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - // Act - r.ServeHTTP(rec, req) - - // Assert - if rec.Code != http.StatusOK { - t.Fatalf("expected status %d, got %d; body: %s", http.StatusOK, rec.Code, rec.Body.String()) - } - - var response TokenResponse - if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { - t.Fatalf("failed to parse JSON response: %v", err) - } - - if response.IDToken == "" { - t.Fatal("expected id_token in response") - } - - // Assert: ID token must be JWT format with 3 parts - parts := strings.Split(response.IDToken, ".") - if len(parts) != 3 { - t.Errorf("expected JWT to have 3 parts, got %d; token: %s", len(parts), response.IDToken) - } - - // Assert: Decode and verify header - headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - t.Fatalf("failed to decode JWT header: %v", err) - } - - var header map[string]interface{} - if err := json.Unmarshal(headerJSON, &header); err != nil { - t.Fatalf("failed to parse JWT header JSON: %v", err) - } - - if header["alg"] != "none" { - t.Errorf("expected alg=none, got %v", header["alg"]) - } - - // Assert: Decode and verify payload - payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - t.Fatalf("failed to decode JWT payload: %v", err) - } - - var claims map[string]interface{} - if err := json.Unmarshal(payloadJSON, &claims); err != nil { - t.Fatalf("failed to parse JWT payload JSON: %v", err) - } - - if claims["sub"] != "testuser" { - t.Errorf("expected sub=testuser, got %v", claims["sub"]) - } -} - -// TestIDTokenIssuerAndAudience verifies that iss and aud use actual values (P1-1) -func TestIDTokenIssuerAndAudience(t *testing.T) { - tests := []struct { - name string - user string - pass string - host string - forwardedProto string - clientID string - expectedIssuer string - expectedAudience string - }{ - { - name: "HTTP request with localhost", - user: "testuser", - pass: "testpass", - host: "localhost:8080", - forwardedProto: "", - clientID: "test-client-123", - expectedIssuer: "http://localhost:8080/oidc/testuser/testpass", - expectedAudience: "test-client-123", - }, - { - name: "HTTPS request via X-Forwarded-Proto", - user: "admin", - pass: "secret", - host: "example.com", - forwardedProto: "https", - clientID: "production-client", - expectedIssuer: "https://example.com/oidc/admin/secret", - expectedAudience: "production-client", - }, - { - name: "Different client ID", - user: "alice", - pass: "pass123", - host: "auth.example.com", - forwardedProto: "https", - clientID: "mobile-app", - expectedIssuer: "https://auth.example.com/oidc/alice/pass123", - expectedAudience: "mobile-app", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Arrange - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", tt.user, "openid profile", "", "", "") - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - formData := url.Values{} - formData.Add("grant_type", "authorization_code") - formData.Add("client_id", tt.clientID) - formData.Add("code", authCode.Code) - formData.Add("redirect_uri", "http://localhost/callback") - - req := httptest.NewRequest(http.MethodPost, "/oidc/"+tt.user+"/"+tt.pass+"/token", strings.NewReader(formData.Encode())) - req.Host = tt.host - if tt.forwardedProto != "" { - req.Header.Set("X-Forwarded-Proto", tt.forwardedProto) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - // Act - r.ServeHTTP(rec, req) - - // Assert - if rec.Code != http.StatusOK { - t.Fatalf("expected status %d, got %d; body: %s", http.StatusOK, rec.Code, rec.Body.String()) - } - - var response TokenResponse - if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { - t.Fatalf("failed to parse JSON response: %v", err) - } - - // Parse JWT to verify claims - parts := strings.Split(response.IDToken, ".") - if len(parts) != 3 { - t.Fatalf("expected JWT to have 3 parts, got %d", len(parts)) - } - - payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - t.Fatalf("failed to decode JWT payload: %v", err) - } - - var claims map[string]interface{} - if err := json.Unmarshal(payloadJSON, &claims); err != nil { - t.Fatalf("failed to parse JWT payload JSON: %v", err) - } - - // Assert: iss must match expected issuer - if claims["iss"] != tt.expectedIssuer { - t.Errorf("expected iss=%s, got %v", tt.expectedIssuer, claims["iss"]) - } - - // Assert: aud must match client_id - if claims["aud"] != tt.expectedAudience { - t.Errorf("expected aud=%s, got %v", tt.expectedAudience, claims["aud"]) - } - }) - } -} - -// TestDemoPageClientIDParameter verifies that demo page includes client_id in token exchange (P0-2) -func TestDemoPageClientIDParameter(t *testing.T) { - // This test verifies that the demo page template includes client_id extraction and usage - // We'll test the full flow to ensure client_id is passed through - t.Run("demo flow includes client_id in token exchange", func(t *testing.T) { - // Arrange - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Get("/oidc/{user}/{pass}/demo", OIDCDemoHandler) - - user := "testuser" - pass := "testpass" - - // Step 1: Access demo page (should redirect to authorize) - req1 := httptest.NewRequest(http.MethodGet, "/oidc/"+user+"/"+pass+"/demo", nil) - req1.Host = "localhost:8080" - rec1 := httptest.NewRecorder() - r.ServeHTTP(rec1, req1) - - if rec1.Code != http.StatusFound { - t.Fatalf("expected redirect status %d, got %d", http.StatusFound, rec1.Code) - } - - // Extract demo state cookie for later verification - var demoStateCookie *http.Cookie - for _, c := range rec1.Result().Cookies() { - if c.Name == "demo_state" { - demoStateCookie = c - break - } - } - - // Step 2: Simulate returning from authorize with code - // The demo page should render with client_id available - req2 := httptest.NewRequest(http.MethodGet, "/oidc/"+user+"/"+pass+"/demo?code=test-code&state="+demoStateCookie.Value, nil) - req2.Host = "localhost:8080" - req2.AddCookie(demoStateCookie) - rec2 := httptest.NewRecorder() - r.ServeHTTP(rec2, req2) - - if rec2.Code != http.StatusOK { - t.Fatalf("expected status %d, got %d", http.StatusOK, rec2.Code) - } - - // Assert: HTML should contain client_id in the template data or JavaScript - body := rec2.Body.String() - if !strings.Contains(body, "client_id") { - t.Error("expected demo page to reference client_id parameter") - } - }) -} - -// TestCallbackPageClientIDParameter verifies that callback page includes client_id in token exchange (P0-2) -func TestCallbackPageClientIDParameter(t *testing.T) { - t.Run("callback page should include mechanism to pass client_id", func(t *testing.T) { - // Arrange - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/callback", OIDCCallbackHandler) - - user := "testuser" - pass := "testpass" - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+user+"/"+pass+"/callback?code=test-code&state=test-state", nil) - rec := httptest.NewRecorder() - - // Act - r.ServeHTTP(rec, req) - - // Assert - if rec.Code != http.StatusOK { - t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) - } - - body := rec.Body.String() - // The callback page should include client_id in the form submission - if !strings.Contains(body, "client_id") { - t.Error("expected callback page to reference client_id parameter") - } - }) -} - -// TestValidateRedirectURI verifies redirect URI validation with patterns (P1-2) -func TestValidateRedirectURI(t *testing.T) { - tests := []struct { - name string - redirectURI string - allowedPatterns []string - expectError bool - }{ - { - name: "exact match", - redirectURI: "http://localhost:8080/callback", - allowedPatterns: []string{"http://localhost:8080/callback"}, - expectError: false, - }, - { - name: "wildcard port match", - redirectURI: "http://localhost:8080/callback", - allowedPatterns: []string{"http://localhost:*/callback"}, - expectError: false, - }, - { - name: "wildcard port different port", - redirectURI: "http://localhost:3000/callback", - allowedPatterns: []string{"http://localhost:*/callback"}, - expectError: false, - }, - { - name: "wildcard path match", - redirectURI: "http://localhost:8080/any/path/here", - allowedPatterns: []string{"http://localhost:8080/*"}, - expectError: false, - }, - { - name: "wildcard path with subpath", - redirectURI: "http://localhost:8080/callback/success", - allowedPatterns: []string{"http://localhost:8080/*"}, - expectError: false, - }, - { - name: "multiple patterns - first matches", - redirectURI: "http://localhost:8080/callback", - allowedPatterns: []string{"http://localhost:8080/callback", "http://example.com/*"}, - expectError: false, - }, - { - name: "multiple patterns - second matches", - redirectURI: "http://example.com/auth/callback", - allowedPatterns: []string{"http://localhost:8080/callback", "http://example.com/*"}, - expectError: false, - }, - { - name: "no match", - redirectURI: "http://evil.com/callback", - allowedPatterns: []string{"http://localhost:8080/callback"}, - expectError: true, - }, - { - name: "port mismatch without wildcard", - redirectURI: "http://localhost:3000/callback", - allowedPatterns: []string{"http://localhost:8080/callback"}, - expectError: true, - }, - { - name: "path mismatch", - redirectURI: "http://localhost:8080/different", - allowedPatterns: []string{"http://localhost:8080/callback"}, - expectError: true, - }, - { - name: "empty patterns - allow all", - redirectURI: "http://any.domain.com/anywhere", - allowedPatterns: []string{}, - expectError: false, - }, - { - name: "nil patterns - allow all", - redirectURI: "http://any.domain.com/anywhere", - allowedPatterns: nil, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Act - err := validateRedirectURI(tt.redirectURI, tt.allowedPatterns) - - // Assert - if (err != nil) != tt.expectError { - t.Errorf("expected error=%v, got error=%v", tt.expectError, err) - } - }) - } -} - -// TestAuthorizeHandlerRedirectURIValidation verifies authorization endpoint validates redirect_uri (P1-2) -func TestAuthorizeHandlerRedirectURIValidation(t *testing.T) { - tests := []struct { - name string - validateRedirectURI bool - allowedRedirectURIs string - requestRedirectURI string - expectedStatus int - expectErrorInRedirect bool - }{ - { - name: "validation disabled - any URI accepted", - validateRedirectURI: false, - allowedRedirectURIs: "http://localhost:8080/callback", - requestRedirectURI: "http://evil.com/callback", - expectedStatus: http.StatusOK, - expectErrorInRedirect: false, - }, - { - name: "validation enabled - exact match allowed", - validateRedirectURI: true, - allowedRedirectURIs: "http://localhost:8080/callback", - requestRedirectURI: "http://localhost:8080/callback", - expectedStatus: http.StatusOK, - expectErrorInRedirect: false, - }, - { - name: "validation enabled - wildcard port allowed", - validateRedirectURI: true, - allowedRedirectURIs: "http://localhost:*/callback", - requestRedirectURI: "http://localhost:3000/callback", - expectedStatus: http.StatusOK, - expectErrorInRedirect: false, - }, - { - name: "validation enabled - wildcard path allowed", - validateRedirectURI: true, - allowedRedirectURIs: "http://localhost:8080/*", - requestRedirectURI: "http://localhost:8080/auth/callback", - expectedStatus: http.StatusOK, - expectErrorInRedirect: false, - }, - { - name: "validation enabled - multiple patterns (comma-separated)", - validateRedirectURI: true, - allowedRedirectURIs: "http://localhost:8080/callback,http://localhost:3000/*", - requestRedirectURI: "http://localhost:3000/auth/cb", - expectedStatus: http.StatusOK, - expectErrorInRedirect: false, - }, - { - name: "validation enabled - not in allowlist", - validateRedirectURI: true, - allowedRedirectURIs: "http://localhost:8080/callback", - requestRedirectURI: "http://evil.com/callback", - expectedStatus: http.StatusFound, - expectErrorInRedirect: true, - }, - { - name: "validation enabled - empty allowlist allows all", - validateRedirectURI: true, - allowedRedirectURIs: "", - requestRedirectURI: "http://any.domain.com/callback", - expectedStatus: http.StatusOK, - expectErrorInRedirect: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Arrange - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - AuthCodeValidateRedirectURI: tt.validateRedirectURI, - AuthCodeAllowedRedirectURIs: tt.allowedRedirectURIs, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - - queryParams := url.Values{} - queryParams.Add("client_id", "test-client") - queryParams.Add("redirect_uri", tt.requestRedirectURI) - queryParams.Add("response_type", "code") - queryParams.Add("state", "test-state") - - req := httptest.NewRequest(http.MethodGet, "/oidc/testuser/testpass/authorize?"+queryParams.Encode(), nil) - rec := httptest.NewRecorder() - - // Act - r.ServeHTTP(rec, req) - - // Assert - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.expectErrorInRedirect && rec.Code == http.StatusFound { - location := rec.Header().Get("Location") - if !strings.Contains(location, "error=") { - t.Errorf("expected error in redirect URL, got: %s", location) - } - } - }) - } -} diff --git a/echo-http/handlers/oidc_session.go b/echo-http/handlers/oidc_session.go deleted file mode 100644 index e2856ee..0000000 --- a/echo-http/handlers/oidc_session.go +++ /dev/null @@ -1,193 +0,0 @@ -package handlers - -import ( - "crypto/rand" - "encoding/hex" - "sync" - "time" -) - -// Session represents an OIDC session -type Session struct { - ID string - State string // Client-provided state (optional, may be empty) - RedirectURI string - Scope string - CodeChallenge string // PKCE code_challenge parameter - CodeChallengeMethod string // PKCE method: "plain" or "S256" - Nonce string // OIDC nonce parameter for replay attack protection - CreatedAt time.Time -} - -// AuthCode represents an authorization code issued after authentication -type AuthCode struct { - Code string - RedirectURI string - Username string - Scope string - CodeChallenge string // PKCE code_challenge parameter - CodeChallengeMethod string // PKCE method: "plain" or "S256" - Nonce string // OIDC nonce parameter for replay attack protection - CreatedAt time.Time -} - -// SessionStore provides in-memory storage for OIDC sessions and authorization codes -type SessionStore struct { - sessions map[string]*Session // key = session ID - authCodes map[string]*AuthCode - mu sync.RWMutex - ttl time.Duration -} - -var ( - // DefaultSessionStore is the global session store instance - DefaultSessionStore = NewSessionStore(5 * time.Minute) -) - -// NewSessionStore creates a new session store with the given TTL -func NewSessionStore(ttl time.Duration) *SessionStore { - store := &SessionStore{ - sessions: make(map[string]*Session), - authCodes: make(map[string]*AuthCode), - ttl: ttl, - } - // Start cleanup goroutine - go store.cleanup() - return store -} - -// CreateSession creates a new session with optional client-provided state, PKCE parameters, and nonce -func (s *SessionStore) CreateSession(state, redirectURI, scope, codeChallenge, codeChallengeMethod, nonce string) (*Session, error) { - sessionID, err := generateRandomString(32) - if err != nil { - return nil, err - } - - session := &Session{ - ID: sessionID, - State: state, // Client-provided (may be empty) - RedirectURI: redirectURI, - Scope: scope, - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - Nonce: nonce, - CreatedAt: time.Now(), - } - - s.mu.Lock() - s.sessions[sessionID] = session - s.mu.Unlock() - - return session, nil -} - -// GetSession retrieves a session by session ID -func (s *SessionStore) GetSession(sessionID string) (*Session, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - session, ok := s.sessions[sessionID] - if !ok { - return nil, false - } - - // Check if session is expired - if time.Since(session.CreatedAt) > s.ttl { - return nil, false - } - - return session, true -} - -// DeleteSession removes a session by session ID -func (s *SessionStore) DeleteSession(sessionID string) { - s.mu.Lock() - delete(s.sessions, sessionID) - s.mu.Unlock() -} - -// CreateAuthCode creates a new authorization code with PKCE parameters and nonce -func (s *SessionStore) CreateAuthCode(redirectURI, username, scope, codeChallenge, codeChallengeMethod, nonce string) (*AuthCode, error) { - code, err := generateRandomString(32) - if err != nil { - return nil, err - } - - authCode := &AuthCode{ - Code: code, - RedirectURI: redirectURI, - Username: username, - Scope: scope, - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - Nonce: nonce, - CreatedAt: time.Now(), - } - - s.mu.Lock() - s.authCodes[code] = authCode - s.mu.Unlock() - - return authCode, nil -} - -// GetAuthCode retrieves an authorization code -func (s *SessionStore) GetAuthCode(code string) (*AuthCode, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - authCode, ok := s.authCodes[code] - if !ok { - return nil, false - } - - // Check if auth code is expired - if time.Since(authCode.CreatedAt) > s.ttl { - return nil, false - } - - return authCode, true -} - -// DeleteAuthCode removes an authorization code (single-use) -func (s *SessionStore) DeleteAuthCode(code string) { - s.mu.Lock() - delete(s.authCodes, code) - s.mu.Unlock() -} - -// cleanup periodically removes expired sessions and auth codes -func (s *SessionStore) cleanup() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - for range ticker.C { - s.mu.Lock() - now := time.Now() - - // Clean up expired sessions - for sessionID, session := range s.sessions { - if now.Sub(session.CreatedAt) > s.ttl { - delete(s.sessions, sessionID) - } - } - - // Clean up expired auth codes - for code, authCode := range s.authCodes { - if now.Sub(authCode.CreatedAt) > s.ttl { - delete(s.authCodes, code) - } - } - - s.mu.Unlock() - } -} - -// generateRandomString generates a cryptographically secure random string -func generateRandomString(length int) (string, error) { - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} diff --git a/echo-http/handlers/oidc_test.go b/echo-http/handlers/oidc_test.go deleted file mode 100644 index 1104325..0000000 --- a/echo-http/handlers/oidc_test.go +++ /dev/null @@ -1,2142 +0,0 @@ -package handlers - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/go-chi/chi/v5" -) - -func TestOIDCDiscoveryHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - host string - forwardedProto string - expectedScheme string - expectedIssuer string - expectedAuthzEndpt string - expectedTokenEndpt string - }{ - { - name: "http request", - user: "testuser", - pass: "testpass", - host: "localhost:8080", - expectedScheme: "http", - expectedIssuer: "http://localhost:8080/oidc/testuser/testpass", - expectedAuthzEndpt: "http://localhost:8080/oidc/testuser/testpass/authorize", - expectedTokenEndpt: "http://localhost:8080/oidc/testuser/testpass/token", - }, - { - name: "https via X-Forwarded-Proto", - user: "admin", - pass: "secret", - host: "example.com", - forwardedProto: "https", - expectedScheme: "https", - expectedIssuer: "https://example.com/oidc/admin/secret", - expectedAuthzEndpt: "https://example.com/oidc/admin/secret/authorize", - expectedTokenEndpt: "https://example.com/oidc/admin/secret/token", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/.well-known/openid-configuration", OIDCDiscoveryHandler) - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+tt.user+"/"+tt.pass+"/.well-known/openid-configuration", nil) - req.Host = tt.host - if tt.forwardedProto != "" { - req.Header.Set("X-Forwarded-Proto", tt.forwardedProto) - } - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) - } - - var discovery OIDCDiscoveryResponse - if err := json.Unmarshal(rec.Body.Bytes(), &discovery); err != nil { - t.Fatalf("failed to parse JSON response: %v", err) - } - - // Validate issuer - if discovery.Issuer != tt.expectedIssuer { - t.Errorf("expected issuer %s, got %s", tt.expectedIssuer, discovery.Issuer) - } - - // Validate authorization endpoint - if discovery.AuthorizationEndpoint != tt.expectedAuthzEndpt { - t.Errorf("expected authorization_endpoint %s, got %s", tt.expectedAuthzEndpt, discovery.AuthorizationEndpoint) - } - - // Validate token endpoint - if discovery.TokenEndpoint != tt.expectedTokenEndpt { - t.Errorf("expected token_endpoint %s, got %s", tt.expectedTokenEndpt, discovery.TokenEndpoint) - } - - // Validate supported response types - if len(discovery.ResponseTypesSupported) == 0 { - t.Error("expected response_types_supported to be non-empty") - } - if discovery.ResponseTypesSupported[0] != "code" { - t.Errorf("expected response_types_supported to include 'code'") - } - - // Validate supported subject types - if len(discovery.SubjectTypesSupported) == 0 { - t.Error("expected subject_types_supported to be non-empty") - } - if discovery.SubjectTypesSupported[0] != "public" { - t.Errorf("expected subject_types_supported to include 'public'") - } - - // Validate supported scopes - expectedScopes := []string{"openid", "profile", "email"} - if len(discovery.ScopesSupported) != len(expectedScopes) { - t.Errorf("expected %d scopes, got %d", len(expectedScopes), len(discovery.ScopesSupported)) - } - - // Validate supported grant types - if len(discovery.GrantTypesSupported) == 0 { - t.Error("expected grant_types_supported to be non-empty") - } - if discovery.GrantTypesSupported[0] != "authorization_code" { - t.Errorf("expected grant_types_supported to include 'authorization_code'") - } - - // Validate ID token signing algorithms - if len(discovery.IDTokenSigningAlgValuesSupported) == 0 { - t.Error("expected id_token_signing_alg_values_supported to be non-empty") - } - }) - } -} - -func TestOIDCAuthorizeHandler_GET(t *testing.T) { - tests := []struct { - name string - user string - pass string - clientID string - redirectURI string - scope string - responseType string - expectedStatus int - checkHTML bool - }{ - { - name: "valid request with all parameters", - user: "testuser", - pass: "testpass", - clientID: "test-client", - redirectURI: "http://localhost/callback", - scope: "openid profile email", - responseType: "code", - expectedStatus: http.StatusOK, - checkHTML: true, - }, - { - name: "valid request with default scope", - user: "testuser", - pass: "testpass", - clientID: "test-client", - redirectURI: "http://localhost/callback", - responseType: "code", - expectedStatus: http.StatusOK, - checkHTML: true, - }, - { - name: "missing redirect_uri", - user: "testuser", - pass: "testpass", - clientID: "test-client", - responseType: "code", - expectedStatus: http.StatusBadRequest, - }, - { - name: "unsupported response_type", - user: "testuser", - pass: "testpass", - clientID: "test-client", - redirectURI: "http://localhost/callback", - responseType: "token", - expectedStatus: http.StatusFound, // Now redirects with error - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Initialize config for each test - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - - queryParams := url.Values{} - if tt.clientID != "" { - queryParams.Add("client_id", tt.clientID) - } - if tt.redirectURI != "" { - queryParams.Add("redirect_uri", tt.redirectURI) - } - if tt.scope != "" { - queryParams.Add("scope", tt.scope) - } - if tt.responseType != "" { - queryParams.Add("response_type", tt.responseType) - } - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+tt.user+"/"+tt.pass+"/authorize?"+queryParams.Encode(), nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.checkHTML && rec.Code == http.StatusOK { - body := rec.Body.String() - if !strings.Contains(body, "OIDC Login") { - t.Errorf("expected HTML login form, got: %s", body) - } - if !strings.Contains(body, "username") { - t.Errorf("expected username field in form") - } - if !strings.Contains(body, "password") { - t.Errorf("expected password field in form") - } - } - }) - } -} - -func TestOIDCAuthorizeHandler_POST(t *testing.T) { - tests := []struct { - name string - urlUser string - urlPass string - username string - password string - state string - redirectURI string - expectedStatus int - checkRedirect bool - needsSession bool - }{ - { - name: "valid authorization", - urlUser: "testuser", - urlPass: "testpass", - username: "testuser", - password: "testpass", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusFound, - checkRedirect: true, - needsSession: true, - }, - { - name: "invalid credentials - username mismatch", - urlUser: "testuser", - urlPass: "testpass", - username: "wronguser", - password: "testpass", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusUnauthorized, - needsSession: true, - }, - { - name: "invalid credentials - password mismatch", - urlUser: "testuser", - urlPass: "testpass", - username: "testuser", - password: "wrongpass", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusUnauthorized, - needsSession: true, - }, - { - name: "missing username", - urlUser: "testuser", - urlPass: "testpass", - password: "testpass", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - needsSession: true, - }, - { - name: "missing password", - urlUser: "testuser", - urlPass: "testpass", - username: "testuser", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - needsSession: true, - }, - { - name: "missing state", - urlUser: "testuser", - urlPass: "testpass", - username: "testuser", - password: "testpass", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - }, - { - name: "invalid state", - urlUser: "testuser", - urlPass: "testpass", - username: "testuser", - password: "testpass", - state: "invalid-state", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - - // Create a session and set cookie if needed - var sessionCookie *http.Cookie - if tt.needsSession { - session, _ := DefaultSessionStore.CreateSession(tt.state, tt.redirectURI, "openid profile", "", "", "") - sessionCookie = &http.Cookie{ - Name: "oidc_session", - Value: session.ID, - } - } - - formData := url.Values{} - if tt.username != "" { - formData.Add("username", tt.username) - } - if tt.password != "" { - formData.Add("password", tt.password) - } - - req := httptest.NewRequest(http.MethodPost, "/oidc/"+tt.urlUser+"/"+tt.urlPass+"/authorize", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - if sessionCookie != nil { - req.AddCookie(sessionCookie) - } - - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.checkRedirect && rec.Code == http.StatusFound { - location := rec.Header().Get("Location") - if location == "" { - t.Errorf("expected Location header in redirect response") - } - if !strings.Contains(location, "code=") { - t.Errorf("expected code parameter in redirect URL") - } - // state is only included if client provided it - if tt.state != "" && !strings.Contains(location, "state=") { - t.Errorf("expected state parameter in redirect URL when client provided it") - } - } - }) - } -} - -func TestOIDCCallbackHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - code string - state string - checkHTML bool - }{ - { - name: "valid callback with code", - user: "testuser", - pass: "testpass", - code: "test-auth-code", - state: "test-state", - checkHTML: true, - }, - { - name: "callback without code", - user: "testuser", - pass: "testpass", - state: "test-state", - checkHTML: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/callback", OIDCCallbackHandler) - - queryParams := url.Values{} - if tt.code != "" { - queryParams.Add("code", tt.code) - } - if tt.state != "" { - queryParams.Add("state", tt.state) - } - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+tt.user+"/"+tt.pass+"/callback?"+queryParams.Encode(), nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) - } - - if tt.checkHTML { - body := rec.Body.String() - if !strings.Contains(body, "OIDC Callback") { - t.Errorf("expected HTML callback page, got: %s", body) - } - } - }) - } -} - -func TestOIDCTokenHandler(t *testing.T) { - // Create a valid auth code first - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", "", "", "") - - tests := []struct { - name string - user string - pass string - clientID string - grantType string - code string - redirectURI string - expectedStatus int - checkJSON bool - }{ - { - name: "valid token exchange", - user: "testuser", - pass: "testpass", - clientID: "test-client", - grantType: "authorization_code", - code: authCode.Code, - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusOK, - checkJSON: true, - }, - { - name: "missing grant_type", - user: "testuser", - pass: "testpass", - clientID: "test-client", - code: authCode.Code, - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - }, - { - name: "invalid grant_type", - user: "testuser", - pass: "testpass", - clientID: "test-client", - grantType: "implicit", - code: authCode.Code, - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - }, - { - name: "missing code", - user: "testuser", - pass: "testpass", - clientID: "test-client", - grantType: "authorization_code", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - }, - { - name: "missing redirect_uri", - user: "testuser", - pass: "testpass", - clientID: "test-client", - grantType: "authorization_code", - code: authCode.Code, - expectedStatus: http.StatusBadRequest, - }, - { - name: "invalid code", - user: "testuser", - pass: "testpass", - clientID: "test-client", - grantType: "authorization_code", - code: "invalid-code", - redirectURI: "http://localhost/callback", - expectedStatus: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Initialize config for each test - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - formData := url.Values{} - if tt.grantType != "" { - formData.Add("grant_type", tt.grantType) - } - if tt.clientID != "" { - formData.Add("client_id", tt.clientID) - } - if tt.code != "" { - formData.Add("code", tt.code) - } - if tt.redirectURI != "" { - formData.Add("redirect_uri", tt.redirectURI) - } - - req := httptest.NewRequest(http.MethodPost, "/oidc/"+tt.user+"/"+tt.pass+"/token", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.checkJSON && rec.Code == http.StatusOK { - var response TokenResponse - if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { - t.Errorf("failed to parse JSON response: %v", err) - } - - if response.AccessToken == "" { - t.Errorf("expected access_token in response") - } - if response.TokenType != "Bearer" { - t.Errorf("expected token_type=Bearer, got %s", response.TokenType) - } - if response.ExpiresIn <= 0 { - t.Errorf("expected positive expires_in value") - } - if response.IDToken == "" { - t.Errorf("expected id_token in response") - } - } - }) - } -} - -func TestOIDCAuthorizeHandler_ClientIDValidation(t *testing.T) { - tests := []struct { - name string - configClientID string - requestClientID string - expectedStatus int - expectedError string - }{ - { - name: "client_id parameter missing", - configClientID: "", - requestClientID: "", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidRequest, - }, - { - name: "any client_id accepted when config is empty", - configClientID: "", - requestClientID: "any-client", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "correct client_id when configured", - configClientID: "test-client", - requestClientID: "test-client", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "incorrect client_id when configured", - configClientID: "test-client", - requestClientID: "wrong-client", - expectedStatus: http.StatusFound, // Redirect with error - expectedError: ErrorUnauthorizedClient, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthAllowedClientID: tt.configClientID, - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - - // Build query parameters - queryParams := url.Values{} - if tt.requestClientID != "" { - queryParams.Add("client_id", tt.requestClientID) - } - queryParams.Add("redirect_uri", "http://localhost/callback") - queryParams.Add("response_type", "code") - queryParams.Add("state", "test-state") - - req := httptest.NewRequest(http.MethodGet, "/oidc/testuser/testpass/authorize?"+queryParams.Encode(), nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Validate status code - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - // Validate error response - if tt.expectedError != "" { - if rec.Code == http.StatusFound { - // Error should be in redirect URL - location := rec.Header().Get("Location") - if !strings.Contains(location, "error="+tt.expectedError) { - t.Errorf("expected error=%s in redirect URL, got: %s", tt.expectedError, location) - } - } else { - // Error should be in JSON response - var errResp OIDCError - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse error response: %v", err) - } - if errResp.Error != tt.expectedError { - t.Errorf("expected error=%s, got %s", tt.expectedError, errResp.Error) - } - } - } - }) - } -} - -func TestOIDCTokenHandler_ClientValidation(t *testing.T) { - tests := []struct { - name string - configClientID string - configClientSec string - requestClientID string - requestClientSec string - expectedStatus int - expectedError string - }{ - { - name: "client_id parameter missing", - configClientID: "", - configClientSec: "", - requestClientID: "", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidRequest, - }, - { - name: "any client_id accepted when config is empty", - configClientID: "", - configClientSec: "", - requestClientID: "any-client", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "correct client_id when configured", - configClientID: "test-client", - configClientSec: "", - requestClientID: "test-client", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "incorrect client_id when configured", - configClientID: "test-client", - configClientSec: "", - requestClientID: "wrong-client", - expectedStatus: http.StatusUnauthorized, - expectedError: ErrorInvalidClient, - }, - { - name: "client_secret required and correct", - configClientID: "test-client", - configClientSec: "test-secret", - requestClientID: "test-client", - requestClientSec: "test-secret", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "client_secret required but missing", - configClientID: "test-client", - configClientSec: "test-secret", - requestClientID: "test-client", - requestClientSec: "", - expectedStatus: http.StatusUnauthorized, - expectedError: ErrorInvalidClient, - }, - { - name: "client_secret required but incorrect", - configClientID: "test-client", - configClientSec: "test-secret", - requestClientID: "test-client", - requestClientSec: "wrong-secret", - expectedStatus: http.StatusUnauthorized, - expectedError: ErrorInvalidClient, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthAllowedClientID: tt.configClientID, - AuthAllowedClientSecret: tt.configClientSec, - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - // Create a valid auth code - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", "", "", "") - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - // Build form data - formData := url.Values{} - formData.Add("grant_type", "authorization_code") - formData.Add("code", authCode.Code) - formData.Add("redirect_uri", "http://localhost/callback") - if tt.requestClientID != "" { - formData.Add("client_id", tt.requestClientID) - } - if tt.requestClientSec != "" { - formData.Add("client_secret", tt.requestClientSec) - } - - req := httptest.NewRequest(http.MethodPost, "/oidc/testuser/testpass/token", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Validate status code - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - // Validate error response - if tt.expectedError != "" { - var errResp OIDCError - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse error response: %v", err) - } - if errResp.Error != tt.expectedError { - t.Errorf("expected error=%s, got %s", tt.expectedError, errResp.Error) - } - } - }) - } -} - -func TestOIDCTokenHandler_PKCEVerification(t *testing.T) { - tests := []struct { - name string - codeChallenge string - codeChallengeMethod string - codeVerifier string - expectedStatus int - expectedError string - }{ - { - name: "plain method - verification success", - codeChallenge: "test-challenge-plain-verifier-with-43-chars", - codeChallengeMethod: "plain", - codeVerifier: "test-challenge-plain-verifier-with-43-chars", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "plain method - verification failure", - codeChallenge: "test-challenge-plain-verifier-with-43-chars", - codeChallengeMethod: "plain", - codeVerifier: "wrong-verifier-that-is-long-enough-43-chars", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - { - name: "S256 method - verification success", - codeChallenge: "hCgqGmRwPjKdkmihOZdKwKgGirlXOSc6edj1J7fg3YQ", - codeChallengeMethod: "S256", - codeVerifier: "test-verifier-that-is-long-enough-for-pkce-", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "S256 method - verification failure", - codeChallenge: "hCgqGmRwPjKdkmihOZdKwKgGirlXOSc6edj1J7fg3YQ", - codeChallengeMethod: "S256", - codeVerifier: "wrong-verifier-that-is-long-enough-43-chars", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - { - name: "missing code_verifier when code_challenge was provided", - codeChallenge: "test-challenge-plain-verifier-with-43-chars", - codeChallengeMethod: "plain", - codeVerifier: "", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - { - name: "no verification when PKCE not used", - codeChallenge: "", - codeChallengeMethod: "", - codeVerifier: "", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "code_verifier provided when PKCE not used - should be ignored", - codeChallenge: "", - codeChallengeMethod: "", - codeVerifier: "some-verifier", - expectedStatus: http.StatusOK, - expectedError: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthAllowedClientID: "", - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - // Create an auth code with PKCE parameters - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", tt.codeChallenge, tt.codeChallengeMethod, "") - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - // Build form data - formData := url.Values{} - formData.Add("grant_type", "authorization_code") - formData.Add("client_id", "test-client") - formData.Add("code", authCode.Code) - formData.Add("redirect_uri", "http://localhost/callback") - if tt.codeVerifier != "" { - formData.Add("code_verifier", tt.codeVerifier) - } - - req := httptest.NewRequest(http.MethodPost, "/oidc/testuser/testpass/token", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Validate status code - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d; body: %s", tt.expectedStatus, rec.Code, rec.Body.String()) - } - - // Validate error response - if tt.expectedError != "" { - var errResp OIDCError - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse error response: %v", err) - } - if errResp.Error != tt.expectedError { - t.Errorf("expected error=%s, got %s", tt.expectedError, errResp.Error) - } - } else { - // Validate successful token response - var tokenResp TokenResponse - if err := json.Unmarshal(rec.Body.Bytes(), &tokenResp); err != nil { - t.Fatalf("failed to parse token response: %v", err) - } - if tokenResp.AccessToken == "" { - t.Error("expected access_token in response") - } - } - }) - } -} - -func TestOIDCFullFlow(t *testing.T) { - // Initialize config for test - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Get("/oidc/{user}/{pass}/callback", OIDCCallbackHandler) - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - user := "testuser" - pass := "testpass" - - // Step 1: Get login form (with client-provided state) - clientState := "client-random-state-123" - req1 := httptest.NewRequest(http.MethodGet, "/oidc/"+user+"/"+pass+"/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=code&state="+clientState, nil) - rec1 := httptest.NewRecorder() - r.ServeHTTP(rec1, req1) - - if rec1.Code != http.StatusOK { - t.Fatalf("login form request failed: %d", rec1.Code) - } - - // Extract session cookie - var sessionCookie *http.Cookie - for _, c := range rec1.Result().Cookies() { - if c.Name == "oidc_session" { - sessionCookie = c - break - } - } - if sessionCookie == nil { - t.Fatalf("no session cookie returned") - } - - // Step 2: Submit login form (redirect_uri comes from session) - formData := url.Values{} - formData.Add("username", user) - formData.Add("password", pass) - - req2 := httptest.NewRequest(http.MethodPost, "/oidc/"+user+"/"+pass+"/authorize", strings.NewReader(formData.Encode())) - req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req2.AddCookie(sessionCookie) - rec2 := httptest.NewRecorder() - r.ServeHTTP(rec2, req2) - - if rec2.Code != http.StatusFound { - t.Fatalf("authorize request failed: %d", rec2.Code) - } - - // Extract code and state from redirect URL - location := rec2.Header().Get("Location") - locationURL, _ := url.Parse(location) - code := locationURL.Query().Get("code") - if code == "" { - t.Fatalf("no code in redirect URL: %s", location) - } - returnedState := locationURL.Query().Get("state") - if returnedState != clientState { - t.Errorf("expected state %s, got %s", clientState, returnedState) - } - - // Step 3: Exchange code for tokens - tokenFormData := url.Values{} - tokenFormData.Add("grant_type", "authorization_code") - tokenFormData.Add("client_id", "test-client") - tokenFormData.Add("code", code) - tokenFormData.Add("redirect_uri", "http://localhost/callback") - - req3 := httptest.NewRequest(http.MethodPost, "/oidc/"+user+"/"+pass+"/token", strings.NewReader(tokenFormData.Encode())) - req3.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec3 := httptest.NewRecorder() - r.ServeHTTP(rec3, req3) - - if rec3.Code != http.StatusOK { - t.Fatalf("token request failed: %d", rec3.Code) - } - - var tokenResponse TokenResponse - if err := json.Unmarshal(rec3.Body.Bytes(), &tokenResponse); err != nil { - t.Fatalf("failed to parse token response: %v", err) - } - - if tokenResponse.AccessToken == "" { - t.Errorf("expected access_token in final response") - } - if tokenResponse.IDToken == "" { - t.Errorf("expected id_token in final response") - } -} - -func TestOIDCDemoHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - queryParams string - expectedStatus int - expectRedirect bool - checkHTML bool - }{ - { - name: "initial access redirects to login", - user: "testuser", - pass: "testpass", - queryParams: "", - expectedStatus: http.StatusFound, - expectRedirect: true, - checkHTML: false, - }, - { - name: "with code shows demo page", - user: "demouser", - pass: "demopass", - queryParams: "?code=abc123&state=xyz789", - expectedStatus: http.StatusOK, - expectRedirect: false, - checkHTML: true, - }, - { - name: "with error shows error page", - user: "testuser", - pass: "testpass", - queryParams: "?error=access_denied", - expectedStatus: http.StatusOK, - expectRedirect: false, - checkHTML: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/demo", OIDCDemoHandler) - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+tt.user+"/"+tt.pass+"/demo"+tt.queryParams, nil) - req.Host = "localhost:8080" - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.expectRedirect { - location := rec.Header().Get("Location") - if location == "" { - t.Errorf("expected Location header for redirect") - } - if !strings.Contains(location, "/authorize") { - t.Errorf("expected redirect to authorize, got %s", location) - } - if !strings.Contains(location, "redirect_uri=") { - t.Errorf("expected redirect_uri parameter in authorize URL") - } - } - - if tt.checkHTML { - body := rec.Body.String() - if !strings.Contains(body, "OIDC Interactive Demo") && !strings.Contains(body, "OIDC Demo") { - t.Errorf("expected demo page HTML title") - } - - // Check for authorization code if present - if strings.Contains(tt.queryParams, "code=") { - if !strings.Contains(body, "Authorization Code") { - t.Errorf("expected 'Authorization Code' section in HTML") - } - } - - // Check for error if present - if strings.Contains(tt.queryParams, "error=") { - if !strings.Contains(body, "Error") { - t.Errorf("expected 'Error' section in HTML") - } - } - } - }) - } -} - -func TestValidateScopes(t *testing.T) { - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - tests := []struct { - name string - requestedScope string - expectError bool - errorMessage string - }{ - { - name: "valid single scope", - requestedScope: "openid", - expectError: false, - }, - { - name: "valid multiple scopes", - requestedScope: "openid profile email", - expectError: false, - }, - { - name: "valid subset of scopes", - requestedScope: "openid profile", - expectError: false, - }, - { - name: "invalid scope", - requestedScope: "openid invalid", - expectError: true, - errorMessage: "unsupported scope: invalid", - }, - { - name: "all invalid scopes", - requestedScope: "admin superuser", - expectError: true, - errorMessage: "unsupported scope: admin", - }, - { - name: "empty scope", - requestedScope: "", - expectError: false, - }, - { - name: "scope with extra whitespace", - requestedScope: "openid profile email", - expectError: false, - }, - { - name: "scope with leading and trailing whitespace", - requestedScope: " openid profile email ", - expectError: false, - }, - { - name: "scope with empty strings in the middle", - requestedScope: "openid profile", - expectError: false, - }, - { - name: "duplicate scopes should be allowed", - requestedScope: "openid openid profile", - expectError: false, - }, - { - name: "case sensitive - invalid uppercase", - requestedScope: "OPENID", - expectError: true, - errorMessage: "unsupported scope: OPENID", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateScopes(tt.requestedScope) - if (err != nil) != tt.expectError { - t.Errorf("expected error=%v, got error=%v", tt.expectError, err) - } - if tt.expectError && err != nil && tt.errorMessage != "" { - if err.Error() != tt.errorMessage { - t.Errorf("expected error message %q, got %q", tt.errorMessage, err.Error()) - } - } - }) - } -} - -func TestGetDefaultScopes(t *testing.T) { - tests := []struct { - name string - configScopes []string - expectedDefault string - }{ - { - name: "default scopes from config", - configScopes: []string{"openid", "profile", "email"}, - expectedDefault: "openid profile email", - }, - { - name: "single scope", - configScopes: []string{"openid"}, - expectedDefault: "openid", - }, - { - name: "custom scopes", - configScopes: []string{"openid", "custom", "api"}, - expectedDefault: "openid custom api", - }, - { - name: "empty config scopes", - configScopes: []string{}, - expectedDefault: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - SetConfig(&Config{ - AuthSupportedScopes: tt.configScopes, - }) - - result := getDefaultScopes() - if result != tt.expectedDefault { - t.Errorf("expected default scopes %q, got %q", tt.expectedDefault, result) - } - }) - } -} - -func TestOIDCAuthorizeHandler_ScopeValidation(t *testing.T) { - tests := []struct { - name string - configScopes []string - requestScope string - expectedStatus int - expectedError string - }{ - { - name: "valid scopes accepted", - configScopes: []string{"openid", "profile", "email"}, - requestScope: "openid profile", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "all valid scopes accepted", - configScopes: []string{"openid", "profile", "email"}, - requestScope: "openid profile email", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "invalid scope rejected", - configScopes: []string{"openid", "profile", "email"}, - requestScope: "openid admin", - expectedStatus: http.StatusFound, - expectedError: ErrorInvalidScope, - }, - { - name: "empty scope uses defaults", - configScopes: []string{"openid", "profile", "email"}, - requestScope: "", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "scope with extra whitespace accepted", - configScopes: []string{"openid", "profile", "email"}, - requestScope: "openid profile email", - expectedStatus: http.StatusOK, - expectedError: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthAllowedClientID: "", - AuthSupportedScopes: tt.configScopes, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - - // Build query parameters - queryParams := url.Values{} - queryParams.Add("client_id", "test-client") - queryParams.Add("redirect_uri", "http://localhost/callback") - queryParams.Add("response_type", "code") - queryParams.Add("state", "test-state") - if tt.requestScope != "" { - queryParams.Add("scope", tt.requestScope) - } - - req := httptest.NewRequest(http.MethodGet, "/oidc/testuser/testpass/authorize?"+queryParams.Encode(), nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Validate status code - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - // Validate error response - if tt.expectedError != "" { - if rec.Code == http.StatusFound { - // Error should be in redirect URL - location := rec.Header().Get("Location") - if !strings.Contains(location, "error="+tt.expectedError) { - t.Errorf("expected error=%s in redirect URL, got: %s", tt.expectedError, location) - } - } else { - // Error should be in JSON response - var errResp OIDCError - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse error response: %v", err) - } - if errResp.Error != tt.expectedError { - t.Errorf("expected error=%s, got %s", tt.expectedError, errResp.Error) - } - } - } - }) - } -} - -func TestOIDCDiscoveryHandler_DynamicScopes(t *testing.T) { - tests := []struct { - name string - configScopes []string - expectedScopes []string - }{ - { - name: "default scopes", - configScopes: []string{"openid", "profile", "email"}, - expectedScopes: []string{"openid", "profile", "email"}, - }, - { - name: "custom scopes", - configScopes: []string{"openid", "custom", "api"}, - expectedScopes: []string{"openid", "custom", "api"}, - }, - { - name: "single scope", - configScopes: []string{"openid"}, - expectedScopes: []string{"openid"}, - }, - { - name: "many scopes", - configScopes: []string{"openid", "profile", "email", "address", "phone", "offline_access"}, - expectedScopes: []string{"openid", "profile", "email", "address", "phone", "offline_access"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthSupportedScopes: tt.configScopes, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/.well-known/openid-configuration", OIDCDiscoveryHandler) - - req := httptest.NewRequest(http.MethodGet, "/oidc/testuser/testpass/.well-known/openid-configuration", nil) - req.Host = "localhost:8080" - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) - } - - var discovery OIDCDiscoveryResponse - if err := json.Unmarshal(rec.Body.Bytes(), &discovery); err != nil { - t.Fatalf("failed to parse JSON response: %v", err) - } - - // Validate scopes_supported matches config - if len(discovery.ScopesSupported) != len(tt.expectedScopes) { - t.Errorf("expected %d scopes, got %d", len(tt.expectedScopes), len(discovery.ScopesSupported)) - } - - for i, expectedScope := range tt.expectedScopes { - if i >= len(discovery.ScopesSupported) { - t.Errorf("missing scope at index %d: %s", i, expectedScope) - continue - } - if discovery.ScopesSupported[i] != expectedScope { - t.Errorf("expected scope at index %d to be %s, got %s", i, expectedScope, discovery.ScopesSupported[i]) - } - } - }) - } -} - -func TestVerifyCodeChallenge(t *testing.T) { - tests := []struct { - name string - codeChallenge string - codeChallengeMethod string - codeVerifier string - expectedResult bool - }{ - { - name: "plain method - verification success", - codeChallenge: "test-challenge-plain", - codeChallengeMethod: "plain", - codeVerifier: "test-challenge-plain", - expectedResult: true, - }, - { - name: "plain method - verification failure", - codeChallenge: "test-challenge-plain", - codeChallengeMethod: "plain", - codeVerifier: "wrong-verifier", - expectedResult: false, - }, - { - name: "S256 method - verification success", - codeChallenge: "JBbiqONGWPaAmwXk_8bT6UnlPfrn65D32eZlJS-zGG0", - codeChallengeMethod: "S256", - codeVerifier: "test-verifier", - expectedResult: true, - }, - { - name: "S256 method - verification failure", - codeChallenge: "JBbiqONGWPaAmwXk_8bT6UnlPfrn65D32eZlJS-zGG0", - codeChallengeMethod: "S256", - codeVerifier: "wrong-verifier", - expectedResult: false, - }, - { - name: "invalid method returns false", - codeChallenge: "test-challenge", - codeChallengeMethod: "SHA512", - codeVerifier: "test-verifier", - expectedResult: false, - }, - { - name: "empty method returns false", - codeChallenge: "test-challenge", - codeChallengeMethod: "", - codeVerifier: "test-verifier", - expectedResult: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := verifyCodeChallenge(tt.codeChallenge, tt.codeChallengeMethod, tt.codeVerifier) - if result != tt.expectedResult { - t.Errorf("expected %v, got %v", tt.expectedResult, result) - } - }) - } -} - -func TestOIDCAuthorizeHandler_PKCEValidation(t *testing.T) { - tests := []struct { - name string - configRequirePKCE bool - codeChallenge string - codeChallengeMethod string - expectedStatus int - expectedError string - }{ - { - name: "PKCE parameters accepted and stored - plain method", - configRequirePKCE: false, - codeChallenge: "test-challenge-plain", - codeChallengeMethod: "plain", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "PKCE parameters accepted and stored - S256 method", - configRequirePKCE: false, - codeChallenge: "JBbiqONGWPaAmwXk_8bT6UnlPfrn65D32eZlJS-zGG0", - codeChallengeMethod: "S256", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "PKCE parameters accepted - default to plain when method not specified", - configRequirePKCE: false, - codeChallenge: "test-challenge-plain", - codeChallengeMethod: "", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "invalid code_challenge_method rejected", - configRequirePKCE: false, - codeChallenge: "test-challenge", - codeChallengeMethod: "SHA512", - expectedStatus: http.StatusFound, // Redirect with error - expectedError: ErrorInvalidRequest, - }, - { - name: "PKCE required when configured", - configRequirePKCE: true, - codeChallenge: "", - codeChallengeMethod: "", - expectedStatus: http.StatusFound, // Redirect with error - expectedError: ErrorInvalidRequest, - }, - { - name: "PKCE optional when not required", - configRequirePKCE: false, - codeChallenge: "", - codeChallengeMethod: "", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "PKCE required and provided - success", - configRequirePKCE: true, - codeChallenge: "test-challenge-plain", - codeChallengeMethod: "plain", - expectedStatus: http.StatusOK, - expectedError: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthAllowedClientID: "", - AuthSupportedScopes: []string{"openid", "profile", "email"}, - AuthCodeRequirePKCE: tt.configRequirePKCE, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - - // Build query parameters - queryParams := url.Values{} - queryParams.Add("client_id", "test-client") - queryParams.Add("redirect_uri", "http://localhost/callback") - queryParams.Add("response_type", "code") - queryParams.Add("state", "test-state") - if tt.codeChallenge != "" { - queryParams.Add("code_challenge", tt.codeChallenge) - } - if tt.codeChallengeMethod != "" { - queryParams.Add("code_challenge_method", tt.codeChallengeMethod) - } - - req := httptest.NewRequest(http.MethodGet, "/oidc/testuser/testpass/authorize?"+queryParams.Encode(), nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Validate status code - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - // Validate error response - if tt.expectedError != "" { - if rec.Code == http.StatusFound { - // Error should be in redirect URL - location := rec.Header().Get("Location") - if !strings.Contains(location, "error="+tt.expectedError) { - t.Errorf("expected error=%s in redirect URL, got: %s", tt.expectedError, location) - } - } else { - // Error should be in JSON response - var errResp OIDCError - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse error response: %v", err) - } - if errResp.Error != tt.expectedError { - t.Errorf("expected error=%s, got %s", tt.expectedError, errResp.Error) - } - } - } - }) - } -} - -func TestOIDC_FullFlow_WithPKCE(t *testing.T) { - tests := []struct { - name string - codeChallengeMethod string - codeVerifier string - }{ - { - name: "complete authorization flow with PKCE plain method", - codeChallengeMethod: "plain", - codeVerifier: "test-challenge-plain-verifier-with-43-chars", - }, - { - name: "complete authorization flow with PKCE S256 method", - codeChallengeMethod: "S256", - codeVerifier: "test-verifier-for-s256-with-at-least-43-chars", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Initialize config for test - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - user := "testuser" - pass := "testpass" - - // Generate PKCE parameters - var codeChallenge string - if tt.codeChallengeMethod == "plain" { - codeChallenge = tt.codeVerifier - } else { - // S256: BASE64URL(SHA256(verifier)) - h := sha256.Sum256([]byte(tt.codeVerifier)) - codeChallenge = base64.RawURLEncoding.EncodeToString(h[:]) - } - - // Step 1: Get login form with PKCE parameters - clientState := "client-random-state-123" - authURL := fmt.Sprintf("/oidc/%s/%s/authorize?client_id=test-client&redirect_uri=%s&response_type=code&scope=openid+profile&state=%s&code_challenge=%s&code_challenge_method=%s", - user, pass, - url.QueryEscape("http://localhost/callback"), - clientState, - url.QueryEscape(codeChallenge), - tt.codeChallengeMethod) - - req1 := httptest.NewRequest(http.MethodGet, authURL, nil) - rec1 := httptest.NewRecorder() - r.ServeHTTP(rec1, req1) - - if rec1.Code != http.StatusOK { - t.Fatalf("login form request failed: %d; body: %s", rec1.Code, rec1.Body.String()) - } - - // Extract session cookie - var sessionCookie *http.Cookie - for _, c := range rec1.Result().Cookies() { - if c.Name == "oidc_session" { - sessionCookie = c - break - } - } - if sessionCookie == nil { - t.Fatalf("no session cookie returned") - } - - // Step 2: Submit login form - formData := url.Values{} - formData.Add("username", user) - formData.Add("password", pass) - - req2 := httptest.NewRequest(http.MethodPost, "/oidc/"+user+"/"+pass+"/authorize", strings.NewReader(formData.Encode())) - req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req2.AddCookie(sessionCookie) - rec2 := httptest.NewRecorder() - r.ServeHTTP(rec2, req2) - - if rec2.Code != http.StatusFound { - t.Fatalf("authorize request failed: %d; body: %s", rec2.Code, rec2.Body.String()) - } - - // Extract code and state from redirect URL - location := rec2.Header().Get("Location") - locationURL, _ := url.Parse(location) - code := locationURL.Query().Get("code") - if code == "" { - t.Fatalf("no code in redirect URL: %s", location) - } - returnedState := locationURL.Query().Get("state") - if returnedState != clientState { - t.Errorf("expected state %s, got %s", clientState, returnedState) - } - - // Step 3: Exchange code for tokens with code_verifier - tokenFormData := url.Values{} - tokenFormData.Add("grant_type", "authorization_code") - tokenFormData.Add("client_id", "test-client") - tokenFormData.Add("code", code) - tokenFormData.Add("redirect_uri", "http://localhost/callback") - tokenFormData.Add("code_verifier", tt.codeVerifier) - - req3 := httptest.NewRequest(http.MethodPost, "/oidc/"+user+"/"+pass+"/token", strings.NewReader(tokenFormData.Encode())) - req3.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec3 := httptest.NewRecorder() - r.ServeHTTP(rec3, req3) - - if rec3.Code != http.StatusOK { - t.Fatalf("token request failed: %d; body: %s", rec3.Code, rec3.Body.String()) - } - - // Verify tokens returned - var tokenResponse TokenResponse - if err := json.Unmarshal(rec3.Body.Bytes(), &tokenResponse); err != nil { - t.Fatalf("failed to parse token response: %v", err) - } - - if tokenResponse.AccessToken == "" { - t.Error("expected access_token in final response") - } - if tokenResponse.IDToken == "" { - t.Error("expected id_token in final response") - } - if tokenResponse.RefreshToken == "" { - t.Error("expected refresh_token in final response") - } - }) - } -} - -// TestOIDCNonceSupport tests nonce parameter support per OIDC Core specification -func TestOIDCNonceSupport(t *testing.T) { - tests := []struct { - name string - nonce string - expectNonce bool - }{ - { - name: "nonce parameter provided in authorization request", - nonce: "test-nonce-123", - expectNonce: true, - }, - { - name: "nonce parameter not provided", - nonce: "", - expectNonce: false, - }, - { - name: "nonce parameter with special characters", - nonce: "nonce-with-special_chars.123", - expectNonce: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Initialize config - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/authorize", OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - user := "testuser" - pass := "testpass" - clientState := "state-123" - - // Step 1: Authorization request with nonce parameter - queryParams := url.Values{} - queryParams.Add("client_id", "test-client") - queryParams.Add("redirect_uri", "http://localhost/callback") - queryParams.Add("response_type", "code") - queryParams.Add("scope", "openid profile") - queryParams.Add("state", clientState) - if tt.nonce != "" { - queryParams.Add("nonce", tt.nonce) - } - - req1 := httptest.NewRequest(http.MethodGet, "/oidc/"+user+"/"+pass+"/authorize?"+queryParams.Encode(), nil) - rec1 := httptest.NewRecorder() - r.ServeHTTP(rec1, req1) - - if rec1.Code != http.StatusOK { - t.Fatalf("authorization request failed: %d; body: %s", rec1.Code, rec1.Body.String()) - } - - // Extract session cookie - var sessionCookie *http.Cookie - for _, c := range rec1.Result().Cookies() { - if c.Name == "oidc_session" { - sessionCookie = c - break - } - } - if sessionCookie == nil { - t.Fatalf("no session cookie returned") - } - - // Step 2: Submit credentials - formData := url.Values{} - formData.Add("username", user) - formData.Add("password", pass) - - req2 := httptest.NewRequest(http.MethodPost, "/oidc/"+user+"/"+pass+"/authorize", strings.NewReader(formData.Encode())) - req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req2.AddCookie(sessionCookie) - rec2 := httptest.NewRecorder() - r.ServeHTTP(rec2, req2) - - if rec2.Code != http.StatusFound { - t.Fatalf("authentication failed: %d; body: %s", rec2.Code, rec2.Body.String()) - } - - // Extract authorization code - location := rec2.Header().Get("Location") - locationURL, _ := url.Parse(location) - code := locationURL.Query().Get("code") - if code == "" { - t.Fatalf("no code in redirect URL: %s", location) - } - - // Step 3: Exchange code for tokens - tokenFormData := url.Values{} - tokenFormData.Add("grant_type", "authorization_code") - tokenFormData.Add("client_id", "test-client") - tokenFormData.Add("code", code) - tokenFormData.Add("redirect_uri", "http://localhost/callback") - - req3 := httptest.NewRequest(http.MethodPost, "/oidc/"+user+"/"+pass+"/token", strings.NewReader(tokenFormData.Encode())) - req3.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec3 := httptest.NewRecorder() - r.ServeHTTP(rec3, req3) - - if rec3.Code != http.StatusOK { - t.Fatalf("token request failed: %d; body: %s", rec3.Code, rec3.Body.String()) - } - - // Verify ID token contains or omits nonce - var tokenResponse TokenResponse - if err := json.Unmarshal(rec3.Body.Bytes(), &tokenResponse); err != nil { - t.Fatalf("failed to parse token response: %v", err) - } - - if tokenResponse.IDToken == "" { - t.Fatal("expected id_token in response") - } - - // Parse ID token (JWT format: header.payload.signature) - parts := strings.Split(tokenResponse.IDToken, ".") - if len(parts) != 3 { - t.Fatalf("invalid JWT format: expected 3 parts, got %d", len(parts)) - } - - // Decode payload - payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - t.Fatalf("failed to decode JWT payload: %v", err) - } - - var claims map[string]interface{} - if err := json.Unmarshal(payloadJSON, &claims); err != nil { - t.Fatalf("failed to parse JWT claims: %v", err) - } - - // Verify nonce claim - nonceValue, hasNonce := claims["nonce"] - if tt.expectNonce { - if !hasNonce { - t.Error("expected nonce claim in ID token, but it was not present") - } else if nonceValue != tt.nonce { - t.Errorf("expected nonce=%q, got %q", tt.nonce, nonceValue) - } - } else { - if hasNonce { - t.Errorf("expected no nonce claim in ID token, but found: %v", nonceValue) - } - } - }) - } -} - -// TestCodeVerifierLengthValidation tests RFC 7636 Section 4.1 length requirements -func TestCodeVerifierLengthValidation(t *testing.T) { - tests := []struct { - name string - codeVerifier string - expectedStatus int - expectedError string - }{ - { - name: "valid code_verifier length - 43 characters (minimum)", - codeVerifier: "1234567890123456789012345678901234567890123", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "valid code_verifier length - 128 characters (maximum)", - codeVerifier: "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "valid code_verifier length - 64 characters (middle range)", - codeVerifier: "1234567890123456789012345678901234567890123456789012345678901234", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "invalid code_verifier length - 42 characters (too short)", - codeVerifier: "123456789012345678901234567890123456789012", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - { - name: "invalid code_verifier length - 129 characters (too long)", - codeVerifier: "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - { - name: "invalid code_verifier length - 1 character (way too short)", - codeVerifier: "a", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - { - name: "invalid code_verifier length - 200 characters (way too long)", - codeVerifier: "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", - expectedStatus: http.StatusBadRequest, - expectedError: ErrorInvalidGrant, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - // Create auth code with PKCE challenge (use verifier as challenge for plain method) - codeChallenge := tt.codeVerifier - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", codeChallenge, "plain", "") - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - // Build form data - formData := url.Values{} - formData.Add("grant_type", "authorization_code") - formData.Add("client_id", "test-client") - formData.Add("code", authCode.Code) - formData.Add("redirect_uri", "http://localhost/callback") - formData.Add("code_verifier", tt.codeVerifier) - - req := httptest.NewRequest(http.MethodPost, "/oidc/testuser/testpass/token", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Validate status code - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d; body: %s", tt.expectedStatus, rec.Code, rec.Body.String()) - } - - // Validate error response - if tt.expectedError != "" { - var errResp OIDCError - if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { - t.Fatalf("failed to parse error response: %v", err) - } - if errResp.Error != tt.expectedError { - t.Errorf("expected error=%s, got %s", tt.expectedError, errResp.Error) - } - } else { - // Validate successful token response - var tokenResp TokenResponse - if err := json.Unmarshal(rec.Body.Bytes(), &tokenResp); err != nil { - t.Fatalf("failed to parse token response: %v", err) - } - if tokenResp.AccessToken == "" { - t.Error("expected access_token in response") - } - } - }) - } -} - -// TestCodeVerifierLengthValidationWithoutPKCE verifies length is not checked when PKCE is not used -func TestCodeVerifierLengthValidationWithoutPKCE(t *testing.T) { - // Setup config - SetConfig(&Config{ - AuthSupportedScopes: []string{"openid", "profile", "email"}, - }) - - // Create auth code WITHOUT PKCE challenge - authCode, _ := DefaultSessionStore.CreateAuthCode("http://localhost/callback", "testuser", "openid profile", "", "", "") - - r := chi.NewRouter() - r.Post("/oidc/{user}/{pass}/token", OIDCTokenHandler) - - // Build form data with short code_verifier (would be invalid with PKCE) - formData := url.Values{} - formData.Add("grant_type", "authorization_code") - formData.Add("client_id", "test-client") - formData.Add("code", authCode.Code) - formData.Add("redirect_uri", "http://localhost/callback") - formData.Add("code_verifier", "short") // Only 5 characters, would fail PKCE validation - - req := httptest.NewRequest(http.MethodPost, "/oidc/testuser/testpass/token", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - // Should succeed because PKCE was not used - if rec.Code != http.StatusOK { - t.Errorf("expected status %d, got %d; body: %s", http.StatusOK, rec.Code, rec.Body.String()) - } - - var tokenResp TokenResponse - if err := json.Unmarshal(rec.Body.Bytes(), &tokenResp); err != nil { - t.Fatalf("failed to parse token response: %v", err) - } - if tokenResp.AccessToken == "" { - t.Error("expected access_token in response") - } -} - -// TestOIDCUserInfoHandler tests the UserInfo endpoint -func TestOIDCUserInfoHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - accessToken string - expectedStatus int - expectJSON bool - }{ - { - name: "valid access token returns user info", - user: "testuser", - pass: "testpass", - accessToken: "valid-token-123", - expectedStatus: http.StatusOK, - expectJSON: true, - }, - { - name: "missing authorization header returns 401", - user: "testuser", - pass: "testpass", - accessToken: "", - expectedStatus: http.StatusUnauthorized, - expectJSON: false, - }, - { - name: "invalid authorization scheme returns 401", - user: "testuser", - pass: "testpass", - accessToken: "Basic invalid", - expectedStatus: http.StatusUnauthorized, - expectJSON: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/userinfo", OIDCUserInfoHandler) - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+tt.user+"/"+tt.pass+"/userinfo", nil) - if tt.accessToken != "" { - if strings.HasPrefix(tt.accessToken, "Basic") { - req.Header.Set("Authorization", tt.accessToken) - } else { - req.Header.Set("Authorization", "Bearer "+tt.accessToken) - } - } - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if tt.expectJSON && rec.Code == http.StatusOK { - var userInfo map[string]interface{} - if err := json.Unmarshal(rec.Body.Bytes(), &userInfo); err != nil { - t.Fatalf("failed to parse JSON response: %v", err) - } - - // Verify required claims - if userInfo["sub"] != tt.user { - t.Errorf("expected sub=%s, got %v", tt.user, userInfo["sub"]) - } - - if userInfo["name"] != tt.user { - t.Errorf("expected name=%s, got %v", tt.user, userInfo["name"]) - } - - expectedEmail := tt.user + "@example.com" - if userInfo["email"] != expectedEmail { - t.Errorf("expected email=%s, got %v", expectedEmail, userInfo["email"]) - } - } - }) - } -} - -// TestOIDCJWKSHandler tests the JWKS endpoint -func TestOIDCJWKSHandler(t *testing.T) { - tests := []struct { - name string - user string - pass string - expectedStatus int - }{ - { - name: "returns empty JWKS", - user: "testuser", - pass: "testpass", - expectedStatus: http.StatusOK, - }, - { - name: "works with different user", - user: "alice", - pass: "secret123", - expectedStatus: http.StatusOK, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := chi.NewRouter() - r.Get("/oidc/{user}/{pass}/.well-known/jwks.json", OIDCJWKSHandler) - - req := httptest.NewRequest(http.MethodGet, "/oidc/"+tt.user+"/"+tt.pass+"/.well-known/jwks.json", nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code) - } - - if rec.Code == http.StatusOK { - var jwks map[string]interface{} - if err := json.Unmarshal(rec.Body.Bytes(), &jwks); err != nil { - t.Fatalf("failed to parse JSON response: %v", err) - } - - // Verify keys array exists and is empty - keys, ok := jwks["keys"] - if !ok { - t.Error("expected 'keys' field in JWKS response") - } - - keysArray, ok := keys.([]interface{}) - if !ok { - t.Error("expected 'keys' to be an array") - } - - if len(keysArray) != 0 { - t.Errorf("expected empty keys array, got %d keys", len(keysArray)) - } - } - }) - } -} diff --git a/echo-http/main.go b/echo-http/main.go index 43c4459..eace0fc 100644 --- a/echo-http/main.go +++ b/echo-http/main.go @@ -69,16 +69,6 @@ func main() { r.Get("/absolute-redirect/{n}", handlers.AbsoluteRedirectHandler) r.Get("/relative-redirect/{n}", handlers.RelativeRedirectHandler) - // OIDC endpoints - r.Get("/oidc/{user}/{pass}/.well-known/openid-configuration", handlers.OIDCDiscoveryHandler) - r.Get("/oidc/{user}/{pass}/.well-known/jwks.json", handlers.OIDCJWKSHandler) - r.Get("/oidc/{user}/{pass}/authorize", handlers.OIDCAuthorizeHandler) - r.Post("/oidc/{user}/{pass}/authorize", handlers.OIDCAuthorizeHandler) - r.Get("/oidc/{user}/{pass}/callback", handlers.OIDCCallbackHandler) - r.Post("/oidc/{user}/{pass}/token", handlers.OIDCTokenHandler) - r.Get("/oidc/{user}/{pass}/userinfo", handlers.OIDCUserInfoHandler) - r.Get("/oidc/{user}/{pass}/demo", handlers.OIDCDemoHandler) - // OAuth2/OIDC endpoints (environment-based auth) r.Get("/.well-known/oauth-authorization-server", handlers.OAuth2MetadataHandler) r.Get("/.well-known/openid-configuration", handlers.OIDCDiscoveryRootHandler) From 3e305f15f4d5e24fdfbd24b405848561fef7f3da Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 6 Jan 2026 23:17:32 +0900 Subject: [PATCH 9/9] docs(echo-http): update API documentation for environment-based authentication Remove documentation for path-based authentication endpoints that were deleted in commits 35cf881 and 763416b. Update to reflect current environment-based implementation: - Replace /basic-auth/{user}/{pass} with /basic-auth - Replace /hidden-basic-auth/{user}/{pass} (removed entirely) - Replace /bearer with /bearer-auth - Replace /oidc/{user}/{pass}/* with /oauth2/* and /.well-known/* Clarify Bearer authentication requires both AUTH_ALLOWED_USERNAME and AUTH_ALLOWED_PASSWORD to compute SHA1(username:password) token. Add token generation examples for easier testing. --- echo-http/README.md | 49 ++--- echo-http/docs/api.md | 439 +++++++++++------------------------------- 2 files changed, 138 insertions(+), 350 deletions(-) diff --git a/echo-http/README.md b/echo-http/README.md index 1790f19..ff84021 100644 --- a/echo-http/README.md +++ b/echo-http/README.md @@ -76,23 +76,23 @@ see the [Environment Variables](./docs/api.md#environment-variables) section in ### Authentication Endpoints -| Endpoint | Method | Description | -| ---------------------------------- | ------ | ---------------------------------------- | -| `/basic-auth/{user}/{pass}` | GET | Basic auth (200 if match, 401 otherwise) | -| `/hidden-basic-auth/{user}/{pass}` | GET | Basic auth (200 if match, 404 otherwise) | -| `/bearer` | GET | Bearer token validation | - -### OIDC Endpoints - -| Endpoint | Method | Description | -| ------------------------------------------------------ | -------- | ------------------------------------ | -| `/oidc/{user}/{pass}/.well-known/openid-configuration` | GET | OIDC Discovery metadata (mock) | -| `/oidc/{user}/{pass}/.well-known/jwks.json` | GET | JWKS endpoint (JSON Web Key Set) | -| `/oidc/{user}/{pass}/authorize` | GET/POST | OIDC authorization endpoint (mock) | -| `/oidc/{user}/{pass}/callback` | GET | OIDC callback handler | -| `/oidc/{user}/{pass}/token` | POST | OIDC token endpoint (mock) | -| `/oidc/{user}/{pass}/userinfo` | GET | UserInfo endpoint | -| `/oidc/{user}/{pass}/demo` | GET | Interactive OIDC flow demo (browser) | +| Endpoint | Method | Description | +| -------------- | ------ | ---------------------------------------- | +| `/basic-auth` | GET | Basic auth (200 if match, 401 otherwise) | +| `/bearer-auth` | GET | Bearer token validation (SHA1 hash) | + +### OAuth2/OIDC Endpoints + +| Endpoint | Method | Description | +| ----------------------------------------- | -------- | ------------------------------------------- | +| `/.well-known/oauth-authorization-server` | GET | OAuth2 Authorization Server Metadata | +| `/.well-known/openid-configuration` | GET | OIDC Discovery metadata | +| `/.well-known/jwks.json` | GET | JWKS endpoint (JSON Web Key Set) | +| `/oauth2/authorize` | GET/POST | OAuth2/OIDC authorization endpoint | +| `/oauth2/callback` | GET | OAuth2/OIDC callback handler | +| `/oauth2/token` | POST | OAuth2/OIDC token endpoint | +| `/oauth2/userinfo` | GET | UserInfo endpoint | +| `/oauth2/demo` | GET | Interactive OAuth2/OIDC flow demo (browser) | ### Cookie Endpoints @@ -155,16 +155,17 @@ curl http://localhost:8080/delay/5 curl -L http://localhost:8080/redirect/3 # Basic authentication -curl -u user:pass http://localhost:8080/basic-auth/user/pass +curl -u user:pass http://localhost:8080/basic-auth -# Bearer token -curl -H "Authorization: Bearer my-token" http://localhost:8080/bearer +# Bearer token (SHA1 of username:password) +TOKEN=$(echo -n "user:pass" | shasum -a 1 | cut -d' ' -f1) +curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/bearer-auth -# OIDC interactive demo (open in browser for complete flow demonstration) -open "http://localhost:8080/oidc/testuser/testpass/demo" +# OAuth2/OIDC interactive demo (open in browser for complete flow demonstration) +open "http://localhost:8080/oauth2/demo" -# OIDC manual flow (for programmatic testing) -# curl "http://localhost:8080/oidc/testuser/testpass/authorize?redirect_uri=http://localhost:8080/oidc/testuser/testpass/callback&response_type=code" +# OAuth2/OIDC manual flow (for programmatic testing) +curl "http://localhost:8080/oauth2/authorize?client_id=my-app&redirect_uri=http://localhost:8080/oauth2/callback&response_type=code" # Cookie handling curl -c cookies.txt http://localhost:8080/cookies/set?session=abc123 diff --git a/echo-http/docs/api.md b/echo-http/docs/api.md index 4b1a5d9..d47fccf 100644 --- a/echo-http/docs/api.md +++ b/echo-http/docs/api.md @@ -483,20 +483,19 @@ curl -L http://localhost:80/relative-redirect/3 ## Authentication Endpoints -### GET /basic-auth/{user}/{pass} +### GET /basic-auth -Validate Basic Authentication credentials. Returns 200 if credentials match, 401 -otherwise. +Validate Basic Authentication credentials. -| Parameter | Type | Description | -| --------- | ------ | ----------------- | -| `user` | string | Expected username | -| `pass` | string | Expected password | +Configure credentials via environment variables: + +- `AUTH_ALLOWED_USERNAME`: Expected username +- `AUTH_ALLOWED_PASSWORD`: Expected password **Request:** ```bash -curl -u testuser:testpass http://localhost:80/basic-auth/testuser/testpass +curl -u testuser:testpass http://localhost:80/basic-auth ``` **Response (success):** @@ -510,29 +509,25 @@ curl -u testuser:testpass http://localhost:80/basic-auth/testuser/testpass **Response (failure):** 401 Unauthorized with `WWW-Authenticate: Basic` header. -### GET /hidden-basic-auth/{user}/{pass} +### GET /bearer-auth -Similar to `/basic-auth` but returns 404 instead of 401 on authentication failure. -Useful for testing authentication without browser prompts. +Validate Bearer token authentication. The expected token is SHA1(username:password). -| Parameter | Type | Description | -| --------- | ------ | ----------------- | -| `user` | string | Expected username | -| `pass` | string | Expected password | +Configure credentials via environment variables: -```bash -curl -u testuser:testpass http://localhost:80/hidden-basic-auth/testuser/testpass -``` +- `AUTH_ALLOWED_USERNAME`: Username +- `AUTH_ALLOWED_PASSWORD`: Password -### GET /bearer +Generate the token: -Validate Bearer token authentication. Returns 200 if a valid Bearer token is present, -401 otherwise. +```bash +echo -n "username:password" | shasum -a 1 | cut -d' ' -f1 +``` **Request:** ```bash -curl -H "Authorization: Bearer my-token-123" http://localhost:80/bearer +curl -H "Authorization: Bearer " http://localhost:80/bearer-auth ``` **Response (success):** @@ -540,7 +535,7 @@ curl -H "Authorization: Bearer my-token-123" http://localhost:80/bearer ```json { "authenticated": true, - "token": "my-token-123" + "token": "" } ``` @@ -548,39 +543,58 @@ curl -H "Authorization: Bearer my-token-123" http://localhost:80/bearer --- -## OIDC Endpoints +## OAuth2/OIDC Endpoints -A fully-featured OIDC test server for developing and testing OIDC clients. -Implements OpenID Connect Core 1.0 Authorization Code Flow with support for PKCE, -scope validation, and configurable client authentication. +A fully-featured OAuth2/OIDC test server for developing and testing OAuth2 and OIDC clients. +Implements OAuth 2.0 Authorization Framework and OpenID Connect Core 1.0 Authorization Code Flow +with support for PKCE, scope validation, and configurable client authentication. -See the [Environment Variables](#environment-variables) section for configuration options. +All endpoints use environment-based authentication. See the [Environment Variables](#environment-variables) +section for configuration options. -### GET /oidc/{user}/{pass}/.well-known/openid-configuration +### GET /.well-known/oauth-authorization-server -OpenID Connect Discovery endpoint (OIDC Discovery 1.0). Returns provider metadata -including endpoints, supported features, and capabilities. +OAuth 2.0 Authorization Server Metadata endpoint (RFC 8414). + +**Request:** + +```bash +curl http://localhost:80/.well-known/oauth-authorization-server +``` + +**Response:** + +```json +{ + "issuer": "http://localhost:80", + "authorization_endpoint": "http://localhost:80/oauth2/authorize", + "token_endpoint": "http://localhost:80/oauth2/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["plain", "S256"] +} +``` -**Path Parameters:** +### GET /.well-known/openid-configuration -- `user`: Username for authentication -- `pass`: Password for authentication +OpenID Connect Discovery endpoint (OIDC Discovery 1.0). Returns provider metadata +including endpoints, supported features, and capabilities. **Request:** ```bash -curl http://localhost:80/oidc/testuser/testpass/.well-known/openid-configuration +curl http://localhost:80/.well-known/openid-configuration ``` **Response:** ```json { - "issuer": "http://localhost:80/oidc/testuser/testpass", - "authorization_endpoint": "http://localhost:80/oidc/testuser/testpass/authorize", - "token_endpoint": "http://localhost:80/oidc/testuser/testpass/token", - "userinfo_endpoint": "http://localhost:80/oidc/testuser/testpass/userinfo", - "jwks_uri": "http://localhost:80/oidc/testuser/testpass/.well-known/jwks.json", + "issuer": "http://localhost:80", + "authorization_endpoint": "http://localhost:80/oauth2/authorize", + "token_endpoint": "http://localhost:80/oauth2/token", + "userinfo_endpoint": "http://localhost:80/oauth2/userinfo", + "jwks_uri": "http://localhost:80/.well-known/jwks.json", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["none"], @@ -590,48 +604,58 @@ curl http://localhost:80/oidc/testuser/testpass/.well-known/openid-configuration } ``` +### GET /.well-known/jwks.json + +JWKS (JSON Web Key Set) endpoint returning the public keys used to verify JWT signatures (RFC 7517). + +**Request:** + +```bash +curl http://localhost:80/.well-known/jwks.json +``` + +**Response:** + +```json +{ + "keys": [] +} +``` + **Notes:** -- The `issuer` and endpoint URLs are dynamically generated based on the request -- Supports `X-Forwarded-Proto` header for proxy environments (http/https detection) -- `scopes_supported` reflects `OIDC_SUPPORTED_SCOPES` configuration -- ID tokens use JWT format with `alg: "none"` (no signature) +- Returns an empty key set because this implementation uses `alg: "none"` (no signature) +- In a production OIDC provider, this would contain public keys in JWK format -### GET/POST /oidc/{user}/{pass}/authorize +### GET/POST /oauth2/authorize -OIDC authorization endpoint implementing OpenID Connect Core 1.0 Authorization Code -Flow with full parameter validation. +OAuth2/OIDC authorization endpoint implementing Authorization Code Flow with full parameter validation. **GET:** Display login form for user authentication **POST:** Process credentials and generate authorization code -**Path Parameters:** - -- `user`: Expected username -- `pass`: Expected password - **GET Query Parameters:** -| Parameter | Required | Description | -| ----------------------- | -------- | ------------------------------------------------------------- | -| `client_id` | **Yes** | Client identifier (validated if `OIDC_CLIENT_ID` configured) | -| `redirect_uri` | **Yes** | Callback URI (validated if `OIDC_VALIDATE_REDIRECT_URI=true`) | -| `response_type` | **Yes** | Must be `code` | -| `scope` | No | Space-separated scopes (default: all supported scopes) | -| `state` | No | CSRF protection token (recommended) | -| `nonce` | No | Replay attack protection (included in ID token) | -| `code_challenge` | No | PKCE code challenge (required if `OIDC_REQUIRE_PKCE=true`) | -| `code_challenge_method` | No | PKCE method: `plain` or `S256` (default: `plain`) | +| Parameter | Required | Description | +| ----------------------- | -------- | -------------------------------------------------------------------- | +| `client_id` | **Yes** | Client identifier (validated if `AUTH_ALLOWED_CLIENT_ID` configured) | +| `redirect_uri` | **Yes** | Callback URI (validated if `AUTH_CODE_VALIDATE_REDIRECT_URI=true`) | +| `response_type` | **Yes** | Must be `code` | +| `scope` | No | Space-separated scopes (default: all supported scopes) | +| `state` | No | CSRF protection token (recommended) | +| `nonce` | No | Replay attack protection (included in ID token) | +| `code_challenge` | No | PKCE code challenge (required if `AUTH_CODE_REQUIRE_PKCE=true`) | +| `code_challenge_method` | No | PKCE method: `plain` or `S256` (default: `plain`) | **POST Form Parameters:** -- `username` (required): Must match `{user}` in URL -- `password` (required): Must match `{pass}` in URL +- `username` (required): Must match `AUTH_ALLOWED_USERNAME` +- `password` (required): Must match `AUTH_ALLOWED_PASSWORD` -**GET Request (Basic):** +**Request:** ```bash -curl "http://localhost:80/oidc/testuser/testpass/authorize?\ +curl "http://localhost:80/oauth2/authorize?\ client_id=my-app&\ redirect_uri=http://localhost:8080/callback&\ response_type=code&\ @@ -639,63 +663,9 @@ scope=openid%20profile&\ state=random-csrf-token" ``` -**GET Request (with PKCE):** - -```bash -curl "http://localhost:80/oidc/testuser/testpass/authorize?\ -client_id=my-app&\ -redirect_uri=http://localhost:8080/callback&\ -response_type=code&\ -scope=openid%20profile&\ -state=random-csrf-token&\ -code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&\ -code_challenge_method=S256&\ -nonce=random-nonce-value" -``` - -**GET Response:** HTML login form with session cookie - -**POST Request:** - -```bash -curl -X POST http://localhost:80/oidc/testuser/testpass/authorize \ - -b "oidc_session=" \ - -d "username=testuser" \ - -d "password=testpass" -``` - -**POST Response:** 302 redirect to `redirect_uri` with: - -- `code`: Authorization code (single-use, 5-minute expiry) -- `state`: Original state parameter (if provided) - -**Error Responses:** - -OAuth 2.0 / OIDC compliant JSON error responses: - -```json -{ - "error": "invalid_request", - "error_description": "client_id parameter is required" -} -``` - -Common error codes: - -- `invalid_request`: Missing or invalid required parameter -- `unauthorized_client`: client_id not authorized -- `unsupported_response_type`: response_type is not `code` -- `invalid_scope`: Requested scope not supported - -### GET /oidc/{user}/{pass}/callback +### GET /oauth2/callback Display the authorization code and state received from the authorization server. -Provides a UI to exchange the code for tokens. - -**Path Parameters:** - -- `user`: Username (for URL consistency, not validated at callback) -- `pass`: Password (for URL consistency, not validated at callback) **Query Parameters:** @@ -705,64 +675,34 @@ Provides a UI to exchange the code for tokens. **Request:** ```bash -curl "http://localhost:80/oidc/testuser/testpass/callback?code=abc123&state=xyz789" +curl "http://localhost:80/oauth2/callback?code=abc123&state=xyz789" ``` -**Response:** HTML page displaying the code and offering token exchange. - -### POST /oidc/{user}/{pass}/token +### POST /oauth2/token -Token endpoint implementing OAuth 2.0 / OIDC token exchange. Validates authorization -code and returns access token, ID token (JWT), and refresh token. - -**Path Parameters:** - -- `user`: Username (for URL consistency) -- `pass`: Password (for URL consistency) +Token endpoint implementing OAuth 2.0 / OIDC token exchange. **Form Parameters:** -| Parameter | Required | Description | -| --------------- | -------- | ------------------------------------------------------------ | -| `grant_type` | **Yes** | Must be `authorization_code` | -| `code` | **Yes** | Authorization code from authorize endpoint | -| `client_id` | **Yes** | Client identifier (validated if `OIDC_CLIENT_ID` configured) | -| `redirect_uri` | **Yes** | Must match the URI from authorization request | -| `client_secret` | No | Required if `OIDC_CLIENT_SECRET` is configured | -| `code_verifier` | No | PKCE verifier (required if `code_challenge` was provided) | - -**Request (Public Client):** +| Parameter | Required | Description | +| --------------- | -------- | ------------------------------------------------------------- | +| `grant_type` | **Yes** | Must be `authorization_code` | +| `code` | **Yes** | Authorization code from authorize endpoint | +| `client_id` | **Yes** | Client identifier (validated if `AUTH_ALLOWED_CLIENT_ID` set) | +| `redirect_uri` | **Yes** | Must match the URI from authorization request | +| `client_secret` | No | Required if `AUTH_ALLOWED_CLIENT_SECRET` is configured | +| `code_verifier` | No | PKCE verifier (required if `code_challenge` was provided) | -```bash -curl -X POST http://localhost:80/oidc/testuser/testpass/token \ - -d "grant_type=authorization_code" \ - -d "code=" \ - -d "client_id=my-app" \ - -d "redirect_uri=http://localhost:8080/callback" -``` - -**Request (Confidential Client):** +**Request:** ```bash -curl -X POST http://localhost:80/oidc/testuser/testpass/token \ +curl -X POST http://localhost:80/oauth2/token \ -d "grant_type=authorization_code" \ -d "code=" \ -d "client_id=my-app" \ - -d "client_secret=my-app-secret" \ -d "redirect_uri=http://localhost:8080/callback" ``` -**Request (with PKCE):** - -```bash -curl -X POST http://localhost:80/oidc/testuser/testpass/token \ - -d "grant_type=authorization_code" \ - -d "code=" \ - -d "client_id=my-app" \ - -d "redirect_uri=http://localhost:8080/callback" \ - -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" -``` - **Response:** ```json @@ -776,97 +716,9 @@ curl -X POST http://localhost:80/oidc/testuser/testpass/token \ } ``` -**ID Token Format:** - -ID tokens are returned in JWT format (RFC 7519) with `alg: "none"`: - -``` -eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0L29pZGMvdGVzdHVzZXIvdGVzdHBhc3MiLCJzdWIiOiJ0ZXN0dXNlciIsImF1ZCI6Im15LWFwcCIsImV4cCI6MTcwOTU2MzIwMCwiaWF0IjoxNzA5NTU5NjAwLCJuYW1lIjoidGVzdHVzZXIiLCJlbWFpbCI6InRlc3R1c2VyQGV4YW1wbGUuY29tIn0. -``` - -**Decoded ID Token Claims:** - -```json -{ - "iss": "http://localhost/oidc/testuser/testpass", - "sub": "testuser", - "aud": "my-app", - "exp": 1709563200, - "iat": 1709559600, - "name": "testuser", - "email": "testuser@example.com", - "nonce": "random-nonce-value" -} -``` - -**Error Responses:** - -OAuth 2.0 compliant JSON error responses: - -```json -{ - "error": "invalid_grant", - "error_description": "code_verifier length must be between 43 and 128 characters (RFC 7636)" -} -``` - -Common error codes: - -- `invalid_request`: Missing required parameter -- `invalid_client`: Invalid client_id or client_secret -- `invalid_grant`: Invalid authorization code, expired code, PKCE verification failed, or code_verifier length invalid (43-128 chars per RFC 7636) -- `unsupported_grant_type`: grant_type is not `authorization_code` - -**Complete OIDC Flow Example:** - -```bash -# Step 1: Discover OIDC configuration -curl http://localhost:80/oidc/testuser/testpass/.well-known/openid-configuration - -# Step 2: Generate PKCE code_verifier and code_challenge (optional but recommended) -CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_') -CODE_CHALLENGE=$(echo -n $CODE_VERIFIER | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_') - -# Step 3: Build authorization URL -AUTH_URL="http://localhost:80/oidc/testuser/testpass/authorize?\ -client_id=my-app&\ -redirect_uri=http://localhost:8080/callback&\ -response_type=code&\ -scope=openid%20profile%20email&\ -state=random-csrf-token&\ -nonce=random-nonce&\ -code_challenge=$CODE_CHALLENGE&\ -code_challenge_method=S256" - -# Step 4: Open in browser (displays login form) -open "$AUTH_URL" - -# Step 5: After login and redirect, extract code from callback URL -# http://localhost:8080/callback?code=AUTHORIZATION_CODE&state=random-csrf-token - -# Step 6: Exchange authorization code for tokens -curl -X POST http://localhost:80/oidc/testuser/testpass/token \ - -d "grant_type=authorization_code" \ - -d "code=AUTHORIZATION_CODE" \ - -d "client_id=my-app" \ - -d "redirect_uri=http://localhost:8080/callback" \ - -d "code_verifier=$CODE_VERIFIER" - -# Step 7: Decode ID token (optional - for inspection) -# The ID token is a JWT with format: header.payload.signature -# You can decode it at jwt.io or using: -echo "PASTE_ID_TOKEN_HERE" | cut -d'.' -f2 | base64 -d | jq -``` - -### GET /oidc/{user}/{pass}/userinfo +### GET /oauth2/userinfo -UserInfo endpoint returning user profile information based on the access token (OIDC -Core Section 5.3). - -**Path Parameters:** - -- `user`: Username (used for generating user info) -- `pass`: Password (for URL consistency, not validated at userinfo) +UserInfo endpoint returning user profile information based on the access token (OIDC Core Section 5.3). **Headers:** @@ -876,95 +728,30 @@ Core Section 5.3). ```bash curl -H "Authorization: Bearer " \ - http://localhost:80/oidc/testuser/testpass/userinfo + http://localhost:80/oauth2/userinfo ``` **Response:** ```json { - "sub": "testuser", - "name": "testuser", - "email": "testuser@example.com" + "sub": "user", + "name": "user", + "email": "user@example.com" } ``` -**Error Responses:** - -- **401 Unauthorized**: Missing or invalid authorization header - -**Notes:** - -- This is a mock implementation that accepts any valid Bearer token -- User information is derived from the `{user}` path parameter -- In a real implementation, the access token would be validated and used to look up - user information - -### GET /oidc/{user}/{pass}/.well-known/jwks.json - -JWKS (JSON Web Key Set) endpoint returning the public keys used to verify JWT -signatures (RFC 7517). - -**Path Parameters:** - -- `user`: Username (for URL consistency) -- `pass`: Password (for URL consistency) +### GET /oauth2/demo -**Request:** - -```bash -curl http://localhost:80/oidc/testuser/testpass/.well-known/jwks.json -``` - -**Response:** - -```json -{ - "keys": [] -} -``` - -**Notes:** - -- Returns an empty key set because this implementation uses `alg: "none"` (no - signature) -- In a production OIDC provider, this would contain public keys in JWK format -- Clients can use this endpoint to discover signing keys dynamically - -### GET /oidc/{user}/{pass}/demo - -Interactive demonstration of the complete OIDC Authorization Code Flow. This endpoint -provides a browser-based walkthrough of all OIDC steps with visual feedback. - -**Purpose:** Educational tool for understanding OIDC flow and quick manual testing. - -**Flow:** - -1. Visit `/oidc/{user}/{pass}/demo` → Automatically redirects to authorize endpoint -2. Complete login form with credentials -3. View authorization code and state parameter -4. Click button to exchange code for tokens -5. View all tokens (access_token, id_token, refresh_token) +Interactive demonstration of the complete OAuth2/OIDC Authorization Code Flow. **Usage:** ```bash # Open in browser for interactive demo -open "http://localhost:80/oidc/testuser/testpass/demo" +open "http://localhost:80/oauth2/demo" ``` -**Features:** - -- ✅ Zero configuration required - just open in browser -- ✅ Visual step-by-step flow explanation -- ✅ One-click token exchange -- ✅ Displays all tokens and their purposes -- ✅ Educational notes about OIDC security concepts - -**Note:** This is a self-contained demo where the OIDC provider acts as its own -client. For programmatic testing of actual OIDC client applications, use the -individual endpoints (`/authorize`, `/token`) with your own redirect_uri. - --- ## Cookie Endpoints