diff --git a/gateway/README_OIDC.md b/gateway/README_OIDC.md new file mode 100644 index 0000000..4ee1381 --- /dev/null +++ b/gateway/README_OIDC.md @@ -0,0 +1,152 @@ +# OIDC Authentication for TinyApp Gateway + +This document explains how to configure and use OIDC (OpenID Connect) authentication with the TinyApp Gateway. + +## Overview + +The TinyApp Gateway now supports OIDC authentication, allowing you to secure access to your applications using any OIDC-compliant identity provider such as: + +- Google +- Microsoft Azure AD +- Auth0 +- Keycloak +- Okta +- And many others + +## Configuration + +### Environment Variables + +To enable OIDC authentication, set the following environment variables: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `OIDC_ENABLED` | No | `false` | Enable/disable OIDC authentication | +| `OIDC_ISSUER_URL` | Yes* | - | The issuer URL of your OIDC provider (e.g., `https://accounts.google.com`) | +| `OIDC_CLIENT_ID` | Yes* | - | The client ID registered with your OIDC provider | +| `OIDC_CLIENT_SECRET` | Yes* | - | The client secret for your OIDC application | +| `OIDC_REDIRECT_URL` | Yes* | - | The callback URL (e.g., `http://localhost:8005/auth/callback`) | +| `OIDC_SCOPES` | No | `openid,profile,email` | Comma-separated list of OIDC scopes to request | + +*Required when `OIDC_ENABLED=true` + +### Example Configuration + +```bash +# Enable OIDC authentication +export OIDC_ENABLED=true + +# Google OIDC configuration example +export OIDC_ISSUER_URL=https://accounts.google.com +export OIDC_CLIENT_ID=your-client-id.apps.googleusercontent.com +export OIDC_CLIENT_SECRET=your-client-secret +export OIDC_REDIRECT_URL=http://localhost:8005/auth/callback +export OIDC_SCOPES=openid,profile,email + +# Azure AD OIDC configuration example +export OIDC_ISSUER_URL=https://login.microsoftonline.com/your-tenant-id/v2.0 +export OIDC_CLIENT_ID=your-application-id +export OIDC_CLIENT_SECRET=your-client-secret +export OIDC_REDIRECT_URL=http://localhost:8005/auth/callback +``` + +## Setting Up OIDC Providers + +### Google + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Navigate to "APIs & Services" > "Credentials" +4. Click "Create Credentials" > "OAuth 2.0 Client IDs" +5. Set the application type to "Web application" +6. Add your redirect URI: `http://your-gateway-host:port/auth/callback` +7. Note the Client ID and Client Secret + +### Microsoft Azure AD + +1. Go to the [Azure Portal](https://portal.azure.com/) +2. Navigate to "Azure Active Directory" > "App registrations" +3. Click "New registration" +4. Set the redirect URI to `http://your-gateway-host:port/auth/callback` +5. Go to "Certificates & secrets" and create a new client secret +6. Note the Application (client) ID, Directory (tenant) ID, and the client secret value + +### Auth0 + +1. Go to your [Auth0 Dashboard](https://manage.auth0.com/) +2. Navigate to "Applications" and create a new Regular Web Application +3. Configure the Allowed Callback URLs: `http://your-gateway-host:port/auth/callback` +4. Note the Domain, Client ID, and Client Secret + +## Authentication Flow + +When OIDC is enabled, the gateway implements the following authentication flow: + +1. **Unauthenticated Request**: When a user accesses the gateway without authentication, they are redirected to the OIDC provider's login page +2. **Login**: The user logs in with their credentials at the OIDC provider +3. **Callback**: The OIDC provider redirects back to the gateway with an authorization code +4. **Token Exchange**: The gateway exchanges the authorization code for an ID token +5. **Session Creation**: The gateway creates a secure session cookie containing user information +6. **Access Granted**: Subsequent requests use the session cookie for authentication + +## Authentication Endpoints + +The gateway provides the following authentication endpoints: + +- `/auth/login` - Initiates the OIDC login flow +- `/auth/callback` - Handles the OIDC callback (configured in your OIDC provider) +- `/auth/logout` - Logs out the user and clears the session + +## User Information + +When OIDC authentication is enabled, the gateway will: + +- Track actual usernames in metrics instead of using "any-user" +- Use the following priority for username identification: + 1. `preferred_username` claim + 2. `email` claim + 3. `sub` (subject) claim + +## Security Considerations + +1. **HTTPS in Production**: Always use HTTPS in production environments to protect authentication cookies and tokens +2. **Secure Cookies**: Session cookies are marked as `HttpOnly` and `Secure` (when HTTPS is used) +3. **State Parameter**: The implementation uses a secure state parameter to prevent CSRF attacks +4. **Token Validation**: ID tokens are properly verified using the OIDC provider's public keys +5. **Session Expiration**: Sessions automatically expire based on the ID token expiration time + +## Troubleshooting + +### Common Issues + +1. **"Authentication failed: state mismatch"** + - Ensure cookies are enabled in your browser + - Check that the redirect URL exactly matches what's configured in your OIDC provider + +2. **"failed to create OIDC provider"** + - Verify the `OIDC_ISSUER_URL` is correct and accessible + - Check network connectivity to the OIDC provider + +3. **"Authentication failed: token exchange failed"** + - Verify the `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` are correct + - Ensure the redirect URL is properly configured in your OIDC provider + +### Debug Mode + +Enable debug logging to troubleshoot authentication issues: + +```bash +export LOG_LEVEL=debug +``` + +This will provide detailed logs about the authentication process. + +## Disabling OIDC Authentication + +To disable OIDC authentication and return to the previous behavior: + +```bash +export OIDC_ENABLED=false +``` + +Or simply remove/unset the `OIDC_ENABLED` environment variable. \ No newline at end of file diff --git a/gateway/auth/oidc.go b/gateway/auth/oidc.go new file mode 100644 index 0000000..f43e894 --- /dev/null +++ b/gateway/auth/oidc.go @@ -0,0 +1,619 @@ +/* +Copyright 2024 BlackRock, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/tinymultiverse/tinyapp/gateway/internal" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +const ( + StateTokenName = "oidc_state" + SessionTokenName = "oidc_session" + LoginPath = "/auth/login" + CallbackPath = "/auth/callback" + LogoutPath = "/auth/logout" +) + +type OIDCAuth struct { + provider *oidc.Provider + oauth2Config oauth2.Config + verifier *oidc.IDTokenVerifier + scopes []string + envVars internal.EnvVars +} + +type UserInfo struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + PreferredUsername string `json:"preferred_username"` + Roles []string `json:"roles"` // User roles for authorization + Scopes []string `json:"scopes"` // OAuth scopes + Groups []string `json:"groups"` // User groups (alternative to roles) +} + +type SessionData struct { + UserInfo UserInfo `json:"user_info"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresAt time.Time `json:"expires_at"` + Scopes []string `json:"token_scopes"` // Scopes granted by the user to this app +} + +func NewOIDCAuth(envVars internal.EnvVars) (*OIDCAuth, error) { + if !envVars.OIDCEnabled { + return nil, nil + } + + if envVars.OIDCIssuerURL == "" || envVars.OIDCClientID == "" || + envVars.OIDCClientSecret == "" || envVars.OIDCRedirectURL == "" { + return nil, fmt.Errorf("OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URL must be set when OIDC_ENABLED is true") + } + + ctx := context.Background() + provider, err := oidc.NewProvider(ctx, envVars.OIDCIssuerURL) + if err != nil { + return nil, fmt.Errorf("failed to create OIDC provider: %w", err) + } + + scopes := strings.Split(envVars.OIDCScopes, ",") + for i, scope := range scopes { + scopes[i] = strings.TrimSpace(scope) + } + + oauth2Config := oauth2.Config{ + ClientID: envVars.OIDCClientID, + ClientSecret: envVars.OIDCClientSecret, + RedirectURL: envVars.OIDCRedirectURL, + Endpoint: provider.Endpoint(), + Scopes: scopes, + } + + verifier := provider.Verifier(&oidc.Config{ClientID: envVars.OIDCClientID}) + + return &OIDCAuth{ + provider: provider, + oauth2Config: oauth2Config, + verifier: verifier, + scopes: scopes, + envVars: envVars, + }, nil +} + +func (o *OIDCAuth) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle auth endpoints + switch r.URL.Path { + case LoginPath: + o.handleLogin(w, r) + return + case CallbackPath: + o.handleCallback(w, r) + return + case LogoutPath: + o.handleLogout(w, r) + return + } + + // Check if user is authenticated + if !o.isAuthenticated(r) { + // Redirect to login + state := generateRandomString(32) + http.SetCookie(w, &http.Cookie{ + Name: StateTokenName, + Value: state, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + MaxAge: 300, // 5 minutes + }) + + authURL := o.oauth2Config.AuthCodeURL(state) + zap.S().Debugw("redirecting to OIDC provider", "url", authURL) + http.Redirect(w, r, authURL, http.StatusFound) + return + } + + // User is authenticated, proceed to next handler + next.ServeHTTP(w, r) + }) +} + +func (o *OIDCAuth) handleLogin(w http.ResponseWriter, r *http.Request) { + state := generateRandomString(32) + http.SetCookie(w, &http.Cookie{ + Name: StateTokenName, + Value: state, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + MaxAge: 300, // 5 minutes + }) + + authURL := o.oauth2Config.AuthCodeURL(state) + zap.S().Infow("initiating OIDC login", "redirect_url", authURL) + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (o *OIDCAuth) handleCallback(w http.ResponseWriter, r *http.Request) { + // Verify state parameter + stateCookie, err := r.Cookie(StateTokenName) + if err != nil { + zap.S().Errorw("state cookie not found", "error", err) + http.Error(w, "Authentication failed: state not found", http.StatusBadRequest) + return + } + + if r.URL.Query().Get("state") != stateCookie.Value { + zap.S().Error("state parameter mismatch") + http.Error(w, "Authentication failed: state mismatch", http.StatusBadRequest) + return + } + + // Clear state cookie + http.SetCookie(w, &http.Cookie{ + Name: StateTokenName, + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + + // Exchange authorization code for tokens + oauth2Token, err := o.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + zap.S().Errorw("failed to exchange code for token", "error", err) + http.Error(w, "Authentication failed: token exchange failed", http.StatusInternalServerError) + return + } + + // Extract ID token + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + zap.S().Error("no id_token field in oauth2 token") + http.Error(w, "Authentication failed: no ID token", http.StatusInternalServerError) + return + } + + // Verify ID token + idToken, err := o.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + zap.S().Errorw("failed to verify ID token", "error", err) + http.Error(w, "Authentication failed: token verification failed", http.StatusInternalServerError) + return + } + + // Extract user info from ID token + var userInfo UserInfo + if err := idToken.Claims(&userInfo); err != nil { + zap.S().Errorw("failed to extract user info from ID token", "error", err) + http.Error(w, "Authentication failed: failed to extract user info", http.StatusInternalServerError) + return + } + + // Extract additional claims for authorization + var allClaims map[string]interface{} + if err := idToken.Claims(&allClaims); err != nil { + zap.S().Warnw("failed to extract additional claims", "error", err) + } else { + zap.S().Debugw("all JWT claims", "claims", allClaims) + + // Extract roles from custom claim + roleClaim := o.envVars.AuthzRoleClaim + zap.S().Debugw("looking for role claim", "claim_name", roleClaim) + if roleValue, exists := allClaims[roleClaim]; exists { + zap.S().Debugw("found role claim", "claim_name", roleClaim, "value", roleValue, "type", fmt.Sprintf("%T", roleValue)) + if roles, ok := o.extractStringSlice(roleValue); ok { + userInfo.Roles = roles + zap.S().Debugw("extracted roles", "roles", roles) + } else { + zap.S().Warnw("failed to extract roles from claim", "claim_name", roleClaim, "value", roleValue) + } + } else { + zap.S().Warnw("role claim not found in token", "claim_name", roleClaim, "available_claims", func() []string { + keys := make([]string, 0, len(allClaims)) + for k := range allClaims { + keys = append(keys, k) + } + return keys + }()) + } + + // Extract groups (alternative to roles) + if groupClaim, exists := allClaims["groups"]; exists { + if groups, ok := o.extractStringSlice(groupClaim); ok { + userInfo.Groups = groups + } + } + + // Extract scope claim for OAuth scopes + scopeClaim := o.envVars.AuthzScopeClaim + zap.S().Debugw("looking for scope claim", "claim_name", scopeClaim) + if scopeValue, exists := allClaims[scopeClaim]; exists { + zap.S().Debugw("found scope claim", "claim_name", scopeClaim, "value", scopeValue, "type", fmt.Sprintf("%T", scopeValue)) + + // Handle different scope formats + if scopes, ok := o.extractStringSlice(scopeValue); ok { + // Array format: ["read", "write", "admin"] + userInfo.Scopes = scopes + zap.S().Debugw("extracted scopes as array", "scopes", scopes) + } else if scope, ok := scopeValue.(string); ok { + // String format: "read write admin" or single scope + if strings.Contains(scope, " ") { + // Space-separated string + userInfo.Scopes = strings.Fields(scope) + } else { + // Single scope + userInfo.Scopes = []string{scope} + } + zap.S().Debugw("extracted scopes as string", "scopes", userInfo.Scopes) + } else { + zap.S().Warnw("failed to extract scopes from claim", "claim_name", scopeClaim, "value", scopeValue) + } + } else { + zap.S().Warnw("scope claim not found in token", "claim_name", scopeClaim, "available_claims", func() []string { + keys := make([]string, 0, len(allClaims)) + for k := range allClaims { + keys = append(keys, k) + } + return keys + }()) + } + } + + zap.S().Infow("user authenticated successfully", + "user", userInfo.Sub, + "email", userInfo.Email, + "roles", userInfo.Roles, + "groups", userInfo.Groups, + "scope", userInfo.Scopes) + + // Create session with OAuth tokens for delegation + sessionData := SessionData{ + UserInfo: userInfo, + AccessToken: oauth2Token.AccessToken, + RefreshToken: oauth2Token.RefreshToken, + TokenType: oauth2Token.TokenType, + ExpiresAt: oauth2Token.Expiry, + Scopes: o.scopes, // Scopes granted to this app + } + + zap.S().Debugw("storing OAuth session", + "user", userInfo.Sub, + "token_type", oauth2Token.TokenType, + "expires_at", oauth2Token.Expiry, + "has_refresh_token", oauth2Token.RefreshToken != "", + "granted_scopes", o.scopes) + + sessionJSON, err := json.Marshal(sessionData) + if err != nil { + zap.S().Errorw("failed to marshal session data", "error", err) + http.Error(w, "Authentication failed: session creation failed", http.StatusInternalServerError) + return + } + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: SessionTokenName, + Value: base64.StdEncoding.EncodeToString(sessionJSON), + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + Expires: idToken.Expiry, + }) + + zap.S().Infow("user authenticated successfully", "user", userInfo.Sub, "email", userInfo.Email) + + // Redirect to root or intended page + redirectURL := "/" + if returnTo := r.URL.Query().Get("return_to"); returnTo != "" { + redirectURL = returnTo + } + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (o *OIDCAuth) handleLogout(w http.ResponseWriter, r *http.Request) { + // Clear session cookie + http.SetCookie(w, &http.Cookie{ + Name: SessionTokenName, + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + + zap.S().Info("user logged out") + + // Redirect to root or OIDC provider logout if supported + http.Redirect(w, r, "/", http.StatusFound) +} + +func (o *OIDCAuth) isAuthenticated(r *http.Request) bool { + sessionCookie, err := r.Cookie(SessionTokenName) + if err != nil { + return false + } + + sessionJSON, err := base64.StdEncoding.DecodeString(sessionCookie.Value) + if err != nil { + zap.S().Debugw("failed to decode session cookie", "error", err) + return false + } + + var sessionData SessionData + if err := json.Unmarshal(sessionJSON, &sessionData); err != nil { + zap.S().Debugw("failed to unmarshal session data", "error", err) + return false + } + + // Check if session has expired + if time.Now().After(sessionData.ExpiresAt) { + zap.S().Debug("session has expired") + return false + } + + return true +} + +func (o *OIDCAuth) GetUserInfo(r *http.Request) (*UserInfo, error) { + sessionData, err := o.getSessionData(r) + if err != nil { + return nil, err + } + return &sessionData.UserInfo, nil +} + +// GetUserAccessToken returns the user's access token for making API calls on their behalf +func (o *OIDCAuth) GetUserAccessToken(r *http.Request) (string, error) { + sessionData, err := o.getSessionData(r) + if err != nil { + return "", err + } + + // Check if token is expired and try to refresh + if time.Now().After(sessionData.ExpiresAt) && sessionData.RefreshToken != "" { + zap.S().Debugw("access token expired, attempting refresh", "user", sessionData.UserInfo.Sub) + + newToken, err := o.refreshToken(sessionData.RefreshToken) + if err != nil { + zap.S().Errorw("failed to refresh token", "error", err) + return "", fmt.Errorf("token expired and refresh failed: %w", err) + } + + // Update session with new token + sessionData.AccessToken = newToken.AccessToken + sessionData.ExpiresAt = newToken.Expiry + if newToken.RefreshToken != "" { + sessionData.RefreshToken = newToken.RefreshToken + } + + // TODO: Update the session cookie with new token data + zap.S().Infow("access token refreshed", "user", sessionData.UserInfo.Sub) + } + + return sessionData.AccessToken, nil +} + +// GetOAuth2Client returns a configured HTTP client that automatically includes the user's access token +func (o *OIDCAuth) GetOAuth2Client(r *http.Request) (*http.Client, error) { + accessToken, err := o.GetUserAccessToken(r) + if err != nil { + return nil, err + } + + // Create OAuth2 token source for automatic token handling + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: accessToken, + TokenType: "Bearer", + }) + + return oauth2.NewClient(r.Context(), tokenSource), nil +} + +// MakeAPICall makes an HTTP request on behalf of the user using their access token +func (o *OIDCAuth) MakeAPICall(r *http.Request, method, url string, body []byte) (*http.Response, error) { + client, err := o.GetOAuth2Client(r) + if err != nil { + return nil, fmt.Errorf("failed to get OAuth client: %w", err) + } + + var reqBody *strings.Reader + if body != nil { + reqBody = strings.NewReader(string(body)) + } + + req, err := http.NewRequestWithContext(r.Context(), method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if body != nil && method != "GET" { + req.Header.Set("Content-Type", "application/json") + } + + zap.S().Debugw("making API call on behalf of user", + "method", method, + "url", url, + "has_body", body != nil) + + return client.Do(req) +} + +// getSessionData is a helper to extract session data from request +func (o *OIDCAuth) getSessionData(r *http.Request) (*SessionData, error) { + sessionCookie, err := r.Cookie(SessionTokenName) + if err != nil { + return nil, fmt.Errorf("session not found") + } + + sessionJSON, err := base64.StdEncoding.DecodeString(sessionCookie.Value) + if err != nil { + return nil, fmt.Errorf("failed to decode session: %w", err) + } + + var sessionData SessionData + if err := json.Unmarshal(sessionJSON, &sessionData); err != nil { + return nil, fmt.Errorf("failed to unmarshal session: %w", err) + } + + return &sessionData, nil +} + +// refreshToken attempts to refresh the user's access token +func (o *OIDCAuth) refreshToken(refreshToken string) (*oauth2.Token, error) { + tokenSource := o.oauth2Config.TokenSource(context.Background(), &oauth2.Token{ + RefreshToken: refreshToken, + }) + + return tokenSource.Token() +} + +// CheckAuthorization verifies if the user has the required roles and scopes +func (o *OIDCAuth) CheckAuthorization(r *http.Request) error { + if !o.envVars.AuthzEnabled { + return nil // Authorization disabled, allow access + } + + userInfo, err := o.GetUserInfo(r) + if err != nil { + return fmt.Errorf("authorization failed: %w", err) + } + + // Check required roles + if o.envVars.AuthzRequiredRoles != "" { + requiredRoles := o.parseCommaSeparated(o.envVars.AuthzRequiredRoles) + fmt.Println("User Roles:", userInfo.Roles, "Required Roles:", requiredRoles) + if !o.hasAnyRole(userInfo, requiredRoles) { + zap.S().Warnw("user lacks required roles", "user", userInfo.Sub, "required", requiredRoles, "user_roles", userInfo.Roles) + return fmt.Errorf("access denied: user lacks required roles %v", requiredRoles) + } + } + + // Check required OAuth scopes + if o.envVars.AuthzRequiredScopes != "" { + requiredScopes := o.parseCommaSeparated(o.envVars.AuthzRequiredScopes) + userScopes := userInfo.Scopes // o.parseCommaSeparated(userInfo.Scopes) + fmt.Println("User Scopes:", userScopes, "Required Scopes:", requiredScopes) + if !o.hasAnyScope(userScopes, requiredScopes) { + zap.S().Warnw("user lacks required scopes", "user", userInfo.Sub, "required", requiredScopes, "user_scopes", userScopes) + return fmt.Errorf("access denied: user lacks required scopes %v", requiredScopes) + } + } + + zap.S().Debugw("authorization check passed", "user", userInfo.Sub) + return nil +} + +// hasAnyRole checks if the user has any of the required roles +func (o *OIDCAuth) hasAnyRole(userInfo *UserInfo, requiredRoles []string) bool { + for _, required := range requiredRoles { + for _, userRole := range userInfo.Roles { + if strings.TrimSpace(userRole) == strings.TrimSpace(required) { + return true + } + } + // Also check groups as roles (some OIDC providers use groups instead of roles) + for _, userGroup := range userInfo.Groups { + if strings.TrimSpace(userGroup) == strings.TrimSpace(required) { + return true + } + } + } + return false +} + +// hasAnyScope checks if the user has any of the required OAuth scopes +func (o *OIDCAuth) hasAnyScope(userScopes []string, requiredScopes []string) bool { + for _, required := range requiredScopes { + for _, userScope := range userScopes { + if strings.TrimSpace(userScope) == strings.TrimSpace(required) { + return true + } + } + } + return false +} + +// parseCommaSeparated splits a comma-separated string into a slice of trimmed strings +func (o *OIDCAuth) parseCommaSeparated(input string) []string { + if input == "" { + return []string{} + } + parts := strings.Split(input, ",") + result := make([]string, len(parts)) + for i, part := range parts { + result[i] = strings.TrimSpace(part) + } + return result +} + +// extractStringSlice converts various claim formats to a string slice +func (o *OIDCAuth) extractStringSlice(claim interface{}) ([]string, bool) { + switch v := claim.(type) { + case []string: + return v, true + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result, len(result) > 0 + case string: + // Handle comma-separated string + return o.parseCommaSeparated(v), true + default: + return nil, false + } +} + +// AuthorizationMiddleware adds authorization checks to the authentication middleware +func (o *OIDCAuth) AuthorizationMiddleware(next http.Handler) http.Handler { + return o.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check authorization after authentication + if err := o.CheckAuthorization(r); err != nil { + zap.S().Warnw("authorization failed", "error", err, "path", r.URL.Path) + http.Error(w, "Access Forbidden: "+err.Error(), http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + })) +} + +func generateRandomString(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + panic(err) + } + return base64.URLEncoding.EncodeToString(bytes)[:length] +} diff --git a/gateway/cmd/start.go b/gateway/cmd/start.go index eec4376..56f6e2d 100644 --- a/gateway/cmd/start.go +++ b/gateway/cmd/start.go @@ -42,13 +42,34 @@ func Start() { zap.S().Fatalw("could not process environment variables", "error", err) } + // Validate OIDC configuration if enabled + if envVars.OIDCEnabled { + if envVars.OIDCIssuerURL == "" || envVars.OIDCClientID == "" || + envVars.OIDCClientSecret == "" || envVars.OIDCRedirectURL == "" { + zap.S().Fatal("OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URL must be set if OIDC_ENABLED is true") + } + } + proxyConfig, err := proxy.NewProxyServerConfig(envVars) if err != nil { zap.S().Fatalw("failed to set up proxy", "error", err) } mux := http.NewServeMux() - mux.Handle("/", proxyConfig) + + // Apply OIDC middleware if enabled + var handler http.Handler = proxyConfig + if proxyConfig.OIDCAuth != nil { + if envVars.AuthzEnabled { + zap.S().Info("OIDC authentication and authorization enabled") + handler = proxyConfig.OIDCAuth.AuthorizationMiddleware(proxyConfig) + } else { + zap.S().Info("OIDC authentication enabled (no authorization)") + handler = proxyConfig.OIDCAuth.Middleware(proxyConfig) + } + } + + mux.Handle("/", handler) addr := ":" + envVars.HttpPort if envVars.MetricsEnabled { diff --git a/gateway/internal/envvars.go b/gateway/internal/envvars.go index d42fe8d..9517cd1 100644 --- a/gateway/internal/envvars.go +++ b/gateway/internal/envvars.go @@ -26,4 +26,20 @@ type EnvVars struct { MetricsPort string `env:"METRICS_PORT"` // Required if METRICS_ENABLED is true MetricsPath string `env:"METRICS_PATH"` // Required if METRICS_ENABLED is true URLSubPath string `env:"URL_SUB_PATH" envDefault:"/"` + + // OIDC Configuration + OIDCEnabled bool `env:"OIDC_ENABLED" envDefault:"true"` + OIDCIssuerURL string `env:"OIDC_ISSUER_URL" envDefault:"http://localhost:9000/realms/tinyapp"` // Required if OIDC_ENABLED is true + OIDCClientID string `env:"OIDC_CLIENT_ID" envDefault:"tinyapp-gateway"` // Required if OIDC_ENABLED is true + OIDCClientSecret string `env:"OIDC_CLIENT_SECRET" envDefault:"gateway-secret-123"` // Required if OIDC_ENABLED is true + OIDCRedirectURL string `env:"OIDC_REDIRECT_URL" envDefault:"http://localhost:8005/auth/callback"` // Required if OIDC_ENABLED is true + OIDCScopes string `env:"OIDC_SCOPES" envDefault:"openid,profile,email"` + + // Authorization Configuration + AuthzEnabled bool `env:"AUTHZ_ENABLED" envDefault:"true"` // Enable role-based authorization + AuthzRequiredRoles string `env:"AUTHZ_REQUIRED_ROLES" envDefault:"user,member"` // Comma-separated list of required roles + AuthzRequiredScopes string `env:"AUTHZ_REQUIRED_SCOPES" envDefault:"read"` // Comma-separated list of required OAuth scopes + AuthzRoleClaim string `env:"AUTHZ_ROLE_CLAIM" envDefault:"roles"` // JWT claim containing user roles + AuthzScopeClaim string `env:"AUTHZ_SCOPE_CLAIM" envDefault:"scopes"` // JWT claim containing OAuth scopes + AuthzAdminRoles string `env:"AUTHZ_ADMIN_ROLES" envDefault:"admin"` // Comma-separated list of admin roles (bypass all checks) } diff --git a/gateway/proxy/proxy.go b/gateway/proxy/proxy.go index c9f069b..aa0c9fb 100644 --- a/gateway/proxy/proxy.go +++ b/gateway/proxy/proxy.go @@ -17,12 +17,14 @@ limitations under the License. package proxy import ( + "io" "net/http" "net/http/httputil" "net/url" "path" "strings" + "github.com/tinymultiverse/tinyapp/gateway/auth" "github.com/tinymultiverse/tinyapp/gateway/internal" "github.com/tinymultiverse/tinyapp/gateway/util/metrics" globalutil "github.com/tinymultiverse/tinyapp/util" @@ -34,6 +36,7 @@ type proxyServerConfig struct { SecondaryProxy *httputil.ReverseProxy SecondaryTargetPattern string URLSubPath string + OIDCAuth *auth.OIDCAuth } func NewProxyServerConfig(envVars internal.EnvVars) (*proxyServerConfig, error) { @@ -53,19 +56,33 @@ func NewProxyServerConfig(envVars internal.EnvVars) (*proxyServerConfig, error) secondaryProxy = httputil.NewSingleHostReverseProxy(secondaryTargetUrl) } + // Initialize OIDC authentication if enabled + oidcAuth, err := auth.NewOIDCAuth(envVars) + if err != nil { + return nil, err + } + return &proxyServerConfig{ Proxy: proxy, SecondaryProxy: secondaryProxy, SecondaryTargetPattern: envVars.SecondaryTargetPattern, URLSubPath: envVars.URLSubPath, + OIDCAuth: oidcAuth, }, nil } func (p *proxyServerConfig) ServeHTTP(res http.ResponseWriter, req *http.Request) { zap.S().Debugw("got a request", "host", req.Host, "method", req.Method, "requestURL", req.URL.String()) + // Handle OAuth delegation routes + if p.OIDCAuth != nil && strings.HasPrefix(req.URL.Path, "/api/") { + p.handleDelegatedRequest(res, req) + return + } + if p.SecondaryProxy != nil && strings.Contains(req.URL.Path, p.SecondaryTargetPattern) { zap.S().Debugw("routing to secondary proxy", "path", req.URL.Path) + p.enhanceRequestWithUserContext(req) p.SecondaryProxy.ServeHTTP(res, req) return } @@ -73,9 +90,127 @@ func (p *proxyServerConfig) ServeHTTP(res http.ResponseWriter, req *http.Request // Only increment user count if the request URL is app homepage if path.Clean(req.URL.Path) == path.Clean(p.URLSubPath) { zap.S().Info("Incrementing user count") - // TODO Once integrated with OAuth, get actual username from auth server - metrics.UsernameCounter.WithLabelValues(globalutil.AnyUserName).Inc() + + // Get actual username from OIDC if authentication is enabled + username := globalutil.AnyUserName + if p.OIDCAuth != nil { + if userInfo, err := p.OIDCAuth.GetUserInfo(req); err == nil { + // Use preferred username if available, otherwise use email or sub + if userInfo.PreferredUsername != "" { + username = userInfo.PreferredUsername + } else if userInfo.Email != "" { + username = userInfo.Email + } else { + username = userInfo.Sub + } + } + } + + metrics.UsernameCounter.WithLabelValues(username).Inc() } + // Enhance primary proxy requests with user context + p.enhanceRequestWithUserContext(req) p.Proxy.ServeHTTP(res, req) } + +// handleDelegatedRequest handles API requests that need OAuth delegation +func (p *proxyServerConfig) handleDelegatedRequest(res http.ResponseWriter, req *http.Request) { + if p.OIDCAuth == nil { + http.Error(res, "OAuth not configured", http.StatusInternalServerError) + return + } + + // Handle OAuth delegation example endpoints + switch { + case strings.HasPrefix(req.URL.Path, "/api/user-profile") || strings.HasPrefix(req.URL.Path, "/api/user-data"): + // Use the OAuth example handler + p.OIDCAuth.OAuthDelegationHandler().ServeHTTP(res, req) + return + case strings.HasPrefix(req.URL.Path, "/api/proxy/"): + // Generic API proxy with user's credentials + p.handleGenericAPIProxy(res, req) + return + default: + // Forward to backend with user's access token + p.enhanceRequestWithUserContext(req) + p.Proxy.ServeHTTP(res, req) + } +} + +// handleGenericAPIProxy forwards requests to external APIs using user's access token +func (p *proxyServerConfig) handleGenericAPIProxy(res http.ResponseWriter, req *http.Request) { + // Extract the target URL from the path: /api/proxy/https://api.example.com/endpoint + targetPath := strings.TrimPrefix(req.URL.Path, "/api/proxy/") + if targetPath == "" { + http.Error(res, "Missing target URL in path", http.StatusBadRequest) + return + } + + // Make the API call on behalf of the user + body := make([]byte, 0) + if req.Body != nil { + defer req.Body.Close() + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + zap.S().Errorw("failed to read request body", "error", err) + } + } + + resp, err := p.OIDCAuth.MakeAPICall(req, req.Method, targetPath, body) + if err != nil { + zap.S().Errorw("failed to make delegated API call", "error", err, "target", targetPath) + http.Error(res, "Failed to call external API", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + res.Header().Add(key, value) + } + } + + // Copy status code and body + res.WriteHeader(resp.StatusCode) + io.Copy(res, resp.Body) + + zap.S().Debugw("successfully proxied delegated API call", "target", targetPath, "status", resp.StatusCode) +} + +// enhanceRequestWithUserContext adds user context headers to the request +func (p *proxyServerConfig) enhanceRequestWithUserContext(req *http.Request) { + if p.OIDCAuth == nil { + return + } + + userInfo, err := p.OIDCAuth.GetUserInfo(req) + if err != nil { + // User not authenticated, continue without enhancement + return + } + + // Add user context headers for the backend + req.Header.Set("X-User-ID", userInfo.Sub) + req.Header.Set("X-User-Email", userInfo.Email) + req.Header.Set("X-User-Name", userInfo.Name) + req.Header.Set("X-User-Username", userInfo.PreferredUsername) + + // Add roles and scopes if available + if len(userInfo.Roles) > 0 { + req.Header.Set("X-User-Roles", strings.Join(userInfo.Roles, ",")) + } + if len(userInfo.Scopes) > 0 { + req.Header.Set("X-User-Scopes", strings.Join(userInfo.Scopes, ",")) + } + + // Add user's access token as Authorization header (optional - depends on backend needs) + if accessToken, err := p.OIDCAuth.GetUserAccessToken(req); err == nil { + req.Header.Set("Authorization", "Bearer "+accessToken) + zap.S().Debugw("enhanced request with user context", "user", userInfo.Sub, "has_token", true) + } else { + zap.S().Debugw("enhanced request with user context", "user", userInfo.Sub, "has_token", false, "token_error", err) + } +} diff --git a/go.mod b/go.mod index 4d0c781..33ee3bb 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/caarlos0/env/v10 v10.0.0 + github.com/coreos/go-oidc/v3 v3.16.0 github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 github.com/itchyny/gojq v0.12.7 @@ -11,6 +12,7 @@ require ( github.com/prometheus/client_golang v1.14.0 github.com/spf13/cobra v1.6.1 go.uber.org/zap v1.24.0 + golang.org/x/oauth2 v0.30.0 google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.6 @@ -33,6 +35,7 @@ require ( github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fatih/color v1.13.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -70,7 +73,6 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/term v0.33.0 // indirect diff --git a/go.sum b/go.sum index 22cf6c1..bedee62 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= +github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -46,6 +48,8 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew= github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=