diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..026b5f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,190 @@ +# Claude Development Guide for River Guide + +This file contains project-specific instructions for Claude when working on River Guide. + +## Code Quality and Linting + +### Running Linters + +Always run linting before committing changes: + +```bash +# Run golangci-lint with auto-fix +golangci-lint run --fix + +# Build and test +go build ./... +go test ./... +``` + +### Key Linting Rules + +- Use `golangci-lint run --fix` to automatically fix most issues +- Replace string concatenation with `+=` operator: `path += "/"` not `path = path + "/"` +- Use `http.NoBody` instead of `nil` for HTTP request bodies +- Define constants for magic numbers (session timeouts, key sizes, etc.) +- Use proper context key types (custom type, not string) +- Mark unused parameters with `_` prefix +- Follow struct field alignment recommendations +- Always run `npx prettier --write` on modified files + +### Testing Requirements + +- Always run tests after changes: `go test ./...` +- Add test coverage for new functionality +- Use proper mocking and table-driven tests +- Test both success and error scenarios + +## OIDC Implementation + +### Security Principles + +- Store minimal session data to avoid cookie size limits +- Clear invalid sessions gracefully - redirect to login, don't show technical errors +- Use cryptographically secure random keys +- Validate all configuration parameters together +- Log detailed errors server-side but show friendly messages to users + +### Session Management + +- Session cookies should be HttpOnly, Secure (for HTTPS), SameSite=Lax +- Store only: `user_groups`, `token_expiry`, `authenticated` flag, and individual claim keys +- Never store full ID tokens in sessions (too large) +- Store claims as individual session keys (e.g., `user_claim_sub`, `user_claim_email`) to avoid gob serialization issues with maps +- Clear corrupted sessions and redirect to login + +### Configuration + +- All OIDC params (issuer, client-id, client-secret, redirect-url) must be provided together +- Scopes are configurable with sensible defaults +- Only request "groups" scope if group filtering is configured +- Validate redirect URL format for cookie security settings + +## Error Handling Patterns + +### Session Errors + +Use the `clearSessionAndRedirectToLogin()` helper for any session-related errors: + +```go +if err != nil { + clearSessionAndRedirectToLogin(w, r, fmt.Sprintf("Handler: session error: %v", err)) + return +} +``` + +### User-Friendly Messages + +- Log technical details server-side +- Show user-friendly messages in browser +- For OIDC errors, display provider error messages when available +- Clear invalid state gracefully without exposing internals + +## Logging + +### User-Aware Logging + +The application includes user identification in request logs: + +- Anonymous: `"GET / -> 200 OK in 5ms"` +- Authenticated: `"GET /toggle user=john.doe@company.com -> 200 OK in 12ms"` + +### Context Usage + +Add user info to request context for logging: + +```go +userSubject, _ := session.Values["user_subject"].(string) +ctx := context.WithValue(r.Context(), userSubjectKey, userSubject) +next.ServeHTTP(w, r.WithContext(ctx)) +``` + +## Development Workflow + +### Before Committing + +1. Run `golangci-lint run --fix` to fix code quality issues +2. Run `go build ./...` to ensure compilation +3. Run `go test ./...` to ensure tests pass +4. Check that changes work with both AWS and Azure providers +5. Test OIDC flows if authentication code was modified + +### Git Practices + +- Write descriptive commit messages +- Include "Co-Authored-By: Claude " in commits (though the user's config overrides this) +- Group related changes into single commits +- Test functionality before pushing + +## Architecture Notes + +### Provider Pattern + +- CloudProvider interface supports AWS and Azure +- Each provider handles its own authentication and resource management +- RDS support is optional and AWS-specific + +### Middleware Stack + +1. Negroni recovery middleware +2. Static file serving +3. UserAwareLogger (custom request logging) +4. AuthMiddleware (OIDC authentication) +5. Router (gorilla/mux) + +### Security Considerations + +- Session keys are generated using crypto/rand +- Cookie security flags are set based on redirect URL scheme +- Group-based authorization is enforced after authentication +- All session errors result in clean logout and redirect to login + +## Common Patterns + +### Constants + +Define constants for magic numbers: + +```go +const ( + sessionKeySize = 32 + sessionMaxAge = 86400 // 24 hours +) +``` + +### Context Keys + +Use typed context keys: + +```go +type contextKey string +const userSubjectKey contextKey = "user_subject" +``` + +### Error Handling + +Always handle errors gracefully and provide user-friendly messages while logging technical details. + +### Session Storage + +Avoid storing maps in sessions due to gob serialization issues. Store complex data as individual keys: + +```go +// Don't do this (gob serialization issues) +session.Values["user_claims"] = map[string]string{"sub": "user123", "email": "user@example.com"} + +// Do this instead +session.Values["user_claim_sub"] = "user123" +session.Values["user_claim_email"] = "user@example.com" + +// Reconstruct when needed +claims := make(map[string]string) +for key, value := range session.Values { + if strings.HasPrefix(key, "user_claim_") { + claimName := strings.TrimPrefix(key, "user_claim_") + if claimValue, ok := value.(string); ok { + claims[claimName] = claimValue + } + } +} +``` diff --git a/README.md b/README.md index fe11b4c..4523160 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,13 @@ The application accepts several flags: - `--primary-color`: primary color for text (default is "#333"). - `--favicon`: path to favicon (default is embedded favicon). - `--rds`: enable support to control RDS instances (default is `false`). +- `--oidc-issuer`: OIDC issuer URL (optional) +- `--oidc-client-id`: OIDC client ID (optional) +- `--oidc-client-secret`: OIDC client secret (optional) +- `--oidc-redirect-url`: OIDC redirect URL (optional) +- `--oidc-groups`: comma-separated list of allowed OIDC groups (optional) +- `--oidc-scopes`: comma-separated list of OIDC scopes to request (optional, defaults to "openid,profile,email" plus "groups" if --oidc-groups is set) +- `--oidc-log-claims`: comma-separated list of OIDC claims to include in request logs (optional, defaults to "sub") ### Configuration file @@ -174,6 +181,43 @@ instance, to set the title, you could use the following command: export RIVER_GUIDE_TITLE="My Custom Title" ``` +### Optional OIDC login + +River Guide can optionally protect the UI with an OIDC login. Set the following flags (or their configuration equivalents) to enable authentication: + +- `--oidc-issuer`: the OIDC issuer URL +- `--oidc-client-id`: the client ID registered with the issuer +- `--oidc-client-secret`: the client secret for the client ID +- `--oidc-redirect-url`: redirect URL configured for the client +- `--oidc-groups`: comma-separated list of groups allowed to access the UI (optional) +- `--oidc-scopes`: comma-separated list of OIDC scopes to request (optional) +- `--oidc-log-claims`: comma-separated list of OIDC claims to include in request logs (optional) + +All four of the issuer, client ID, client secret, and redirect URL must be provided for authentication to be enabled. The redirect URL must exactly match the value configured for your OIDC client. + +If `--oidc-groups` is omitted, users from any group are allowed. If `--oidc-scopes` is omitted, the default scopes are "openid,profile,email" (plus "groups" if --oidc-groups is set). You can override the scopes entirely by providing custom values. If `--oidc-log-claims` is omitted, only the "sub" (subject) claim is logged with requests. + +Example YAML configuration: + +```yaml +oidc-issuer: https://auth.example.com +oidc-client-id: my-app +oidc-client-secret: super-secret +oidc-redirect-url: https://my-app.example.com/callback +oidc-groups: + - admins + - operators +# Optional: override default scopes +oidc-scopes: + - openid + - profile + - email +# Optional: customize claims shown in logs (defaults to sub) +oidc-log-claims: + - email + - name +``` + ## API The application provides the following endpoints: diff --git a/cmd/assets/landing.gohtml b/cmd/assets/landing.gohtml new file mode 100644 index 0000000..777e10a --- /dev/null +++ b/cmd/assets/landing.gohtml @@ -0,0 +1,30 @@ + + + + + {{ .Title }} + + + +

{{ .Title }}

+

You need to log in to continue.

+ Log in + + diff --git a/cmd/root.go b/cmd/root.go index 83f45a0..a75304d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ package cmd import ( "context" + "crypto/rand" _ "embed" "fmt" "html/template" @@ -35,6 +36,11 @@ import ( "sync" "time" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "github.com/gorilla/sessions" + "golang.org/x/oauth2" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" "github.com/aws/aws-sdk-go-v2/config" @@ -48,11 +54,55 @@ import ( "github.com/urfave/negroni/v3" ) +const ( + sessionKeySize = 32 + sessionMaxAge = 86400 // 24 hours +) + +type contextKey string + +const userSubjectKey contextKey = "user_subject" + var ( cfgFile string subscriptionID string + oidcProvider *oidc.Provider + oidcVerifier *oidc.IDTokenVerifier + oauth2Config *oauth2.Config + sessionStore *sessions.CookieStore + allowedGroups []string + oidcEnabled bool ) +// Custom logger that includes user information +type UserAwareLogger struct { + *log.Logger +} + +func (l *UserAwareLogger) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + start := time.Now() + userInfo := "" + if claims := r.Context().Value(userSubjectKey); claims != nil { + if claimsMap, ok := claims.(map[string]string); ok && len(claimsMap) > 0 { + var parts []string + for key, value := range claimsMap { + parts = append(parts, fmt.Sprintf("%s=%s", key, value)) + } + userInfo = fmt.Sprintf(" user=%s", strings.Join(parts, ",")) + } + } + next(rw, r) + res := rw.(negroni.ResponseWriter) + l.Printf("%s %s%s -> %d %s in %v", + r.Method, + r.URL.RequestURI(), + userInfo, + res.Status(), + http.StatusText(res.Status()), + time.Since(start), + ) +} + type ServerType string const ( @@ -102,6 +152,13 @@ func init() { rootCmd.Flags().String("resource-group-name", "", "filter instances based on their resource group membership (only used with the Azure provider)") rootCmd.Flags().String("subscription-id", "", "subscription ID (required for Azure)") rootCmd.Flags().Bool("rds", false, "enable RDS support") + rootCmd.Flags().String("oidc-issuer", "", "OIDC issuer URL") + rootCmd.Flags().String("oidc-client-id", "", "OIDC client ID") + rootCmd.Flags().String("oidc-client-secret", "", "OIDC client secret") + rootCmd.Flags().String("oidc-redirect-url", "", "OIDC redirect URL") + rootCmd.Flags().StringSlice("oidc-groups", []string{}, "allowed OIDC groups") + rootCmd.Flags().StringSlice("oidc-scopes", []string{}, "OIDC scopes to request (defaults to openid, profile, email, and groups if --oidc-groups is set)") + rootCmd.Flags().StringSlice("oidc-log-claims", []string{"sub"}, "OIDC claims to include in request logs (e.g. sub, email, name)") err := viper.BindPFlags(rootCmd.Flags()) if err != nil { @@ -336,6 +393,9 @@ func (a *AzureProvider) PowerOffAll(sb *ServerBank) error { //go:embed assets/index.gohtml var indexTemplate string +//go:embed assets/landing.gohtml +var landingTemplate string + // IndexHandler handles the index page. func (h *APIHandler) IndexHandler(w http.ResponseWriter, _ *http.Request) { tmpl := template.Must(template.New("index").Parse(indexTemplate)) @@ -374,6 +434,23 @@ func (h *APIHandler) IndexHandler(w http.ResponseWriter, _ *http.Request) { } } +// LandingHandler serves the landing page prompting for login. +func LandingHandler(w http.ResponseWriter, _ *http.Request) { + tmpl := template.Must(template.New("landing").Parse(landingTemplate)) + data := struct { + Title string + PrimaryColor string + LoginPath string + }{ + Title: viper.GetString("title"), + PrimaryColor: viper.GetString("primary-color"), + LoginPath: filepath.Join(viper.GetString("path-prefix"), "login"), + } + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + // ToggleHandler handles the start/stop button toggle. func (h *APIHandler) ToggleHandler(w http.ResponseWriter, _ *http.Request) { sb, err := h.GetServerBank(viper.GetStringMapString("tags")) @@ -415,6 +492,216 @@ func FaviconHandler(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, viper.GetString("favicon")) } +func LoginHandler(w http.ResponseWriter, r *http.Request) { + if !oidcEnabled { + http.Redirect(w, r, viper.GetString("path-prefix"), http.StatusFound) + return + } + state := uuid.NewString() + session, err := sessionStore.Get(r, "oidc") + if err != nil { + clearSessionAndRedirectToLogin(w, r, fmt.Sprintf("LoginHandler: session error: %v", err)) + return + } + session.Values["state"] = state + if err := session.Save(r, w); err != nil { + log.Printf("LoginHandler: failed to save session: %v", err) + http.Error(w, fmt.Sprintf("failed to save session: %v", err), http.StatusInternalServerError) + return + } + http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound) +} + +func CallbackHandler(w http.ResponseWriter, r *http.Request) { + if !oidcEnabled { + http.Redirect(w, r, viper.GetString("path-prefix"), http.StatusFound) + return + } + + // Check for OAuth error response + if errCode := r.URL.Query().Get("error"); errCode != "" { + errDesc := r.URL.Query().Get("error_description") + if errDesc == "" { + errDesc = "Authentication failed" + } + errorMsg := fmt.Sprintf("OAuth error: %s - %s", errCode, errDesc) + http.Error(w, errorMsg, http.StatusBadRequest) + return + } + + session, err := sessionStore.Get(r, "oidc") + if err != nil { + clearSessionAndRedirectToLogin(w, r, fmt.Sprintf("CallbackHandler: session error: %v", err)) + return + } + storedState, ok := session.Values["state"].(string) + if !ok || r.URL.Query().Get("state") != storedState { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + token, err := oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + errorMsg := fmt.Sprintf("Token exchange failed: %v", err) + http.Error(w, errorMsg, http.StatusInternalServerError) + return + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "No ID token in response. Check OIDC provider configuration.", http.StatusInternalServerError) + return + } + idToken, err := oidcVerifier.Verify(r.Context(), rawIDToken) + if err != nil { + errorMsg := fmt.Sprintf("Invalid ID token: %v", err) + http.Error(w, errorMsg, http.StatusUnauthorized) + return + } + // Parse all claims into a map for flexible access + var allClaims map[string]interface{} + if err := idToken.Claims(&allClaims); err != nil { + http.Error(w, "failed to parse claims", http.StatusInternalServerError) + return + } + + // Extract groups for authorization + var groups []string + if groupsVal, ok := allClaims["groups"]; ok { + if groupsSlice, ok := groupsVal.([]interface{}); ok { + for _, g := range groupsSlice { + if groupStr, ok := g.(string); ok { + groups = append(groups, groupStr) + } + } + } + } + + if len(allowedGroups) > 0 && !hasAllowedGroup(groups) { + errorMsg := fmt.Sprintf("Access denied. User groups %v are not in allowed groups %v", groups, allowedGroups) + http.Error(w, errorMsg, http.StatusForbidden) + return + } + + // Store essential claims for session management + session.Values["user_groups"] = groups + + // Store configurable claims for logging as individual session keys (avoids gob map serialization issues) + logClaims := viper.GetStringSlice("oidc-log-claims") + // Clear any existing claim keys first + for key := range session.Values { + if keyStr, ok := key.(string); ok && strings.HasPrefix(keyStr, "user_claim_") { + delete(session.Values, key) + } + } + // Store each claim as a separate session key + for _, claimName := range logClaims { + if claimValue, exists := allClaims[claimName]; exists { + session.Values["user_claim_"+claimName] = fmt.Sprintf("%v", claimValue) + } + } + session.Values["token_expiry"] = idToken.Expiry.Unix() + session.Values["authenticated"] = true + delete(session.Values, "state") + if err := session.Save(r, w); err != nil { + log.Printf("CallbackHandler: failed to save session: %v", err) + http.Error(w, fmt.Sprintf("failed to save session: %v", err), http.StatusInternalServerError) + return + } + http.Redirect(w, r, viper.GetString("path-prefix"), http.StatusFound) +} + +func hasAllowedGroup(groups []string) bool { + for _, g := range groups { + for _, a := range allowedGroups { + if g == a { + return true + } + } + } + return false +} + +// clearSessionAndRedirectToLogin clears the session cookie and redirects to login +func clearSessionAndRedirectToLogin(w http.ResponseWriter, r *http.Request, logMsg string) { + log.Print(logMsg) + // Clear the session cookie by setting it to expire immediately + pathPrefix := viper.GetString("path-prefix") + if pathPrefix == "" { + pathPrefix = "/" + } else if !strings.HasSuffix(pathPrefix, "/") { + pathPrefix += "/" + } + + http.SetCookie(w, &http.Cookie{ + Name: "oidc", + Value: "", + Path: pathPrefix, + MaxAge: -1, + HttpOnly: true, + }) + + loginPath := strings.TrimSuffix(viper.GetString("path-prefix"), "/") + "/login" + http.Redirect(w, r, loginPath, http.StatusFound) +} + +// AuthMiddleware implements negroni.Handler for OIDC authentication +type AuthMiddleware struct{} + +func (a *AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if !oidcEnabled { + next(w, r) + return + } + pathPrefix := viper.GetString("path-prefix") + path := strings.TrimPrefix(r.URL.Path, pathPrefix) + path = strings.TrimPrefix(path, "/") + if path == "login" || path == "callback" || path == "favicon.ico" || strings.HasPrefix(path, "login") || strings.HasPrefix(path, "callback") { + next(w, r) + return + } + session, err := sessionStore.Get(r, "oidc") + if err != nil { + clearSessionAndRedirectToLogin(w, r, fmt.Sprintf("AuthMiddleware: session error: %v", err)) + return + } + authenticated, ok := session.Values["authenticated"].(bool) + if !ok || !authenticated { + if r.Method == http.MethodGet && (path == "" || path == "/") { + LandingHandler(w, r) + } else { + loginPath := strings.TrimSuffix(pathPrefix, "/") + "/login" + http.Redirect(w, r, loginPath, http.StatusFound) + } + return + } + expiry, _ := session.Values["token_expiry"].(int64) + if expiry > 0 && time.Now().Unix() > expiry { + loginPath := strings.TrimSuffix(viper.GetString("path-prefix"), "/") + "/login" + http.Redirect(w, r, loginPath, http.StatusFound) + return + } + // Check group authorization if required + if len(allowedGroups) > 0 { + userGroups, _ := session.Values["user_groups"].([]string) + if !hasAllowedGroup(userGroups) { + http.Error(w, "Access denied: user is not in an allowed group", http.StatusForbidden) + return + } + } + // Add user info to request context for logging + // Reconstruct claims map from individual session keys + userLogClaims := make(map[string]string) + for key, value := range session.Values { + if keyStr, ok := key.(string); ok && strings.HasPrefix(keyStr, "user_claim_") { + claimName := strings.TrimPrefix(keyStr, "user_claim_") + if claimValue, ok := value.(string); ok { + userLogClaims[claimName] = claimValue + } + } + } + ctx := context.WithValue(r.Context(), userSubjectKey, userLogClaims) + next(w, r.WithContext(ctx)) +} + func getVMClient(subscriptionID string) (*armcompute.VirtualMachinesClient, error) { cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { @@ -437,6 +724,66 @@ func serve() { provider := viper.GetString("provider") enableRds := viper.GetBool("rds") + oidcIssuer := viper.GetString("oidc-issuer") + oidcClientID := viper.GetString("oidc-client-id") + oidcClientSecret := viper.GetString("oidc-client-secret") + oidcRedirectURL := viper.GetString("oidc-redirect-url") + allowedGroups = viper.GetStringSlice("oidc-groups") + oidcScopes := viper.GetStringSlice("oidc-scopes") + + if oidcIssuer != "" || oidcClientID != "" || oidcClientSecret != "" || oidcRedirectURL != "" { + if oidcIssuer == "" || oidcClientID == "" || oidcClientSecret == "" || oidcRedirectURL == "" { + log.Fatal("OIDC configuration incomplete: all of issuer, client-id, client-secret, and redirect-url must be provided") + } + var err error + oidcEnabled = true + ctx := context.TODO() + oidcProvider, err = oidc.NewProvider(ctx, oidcIssuer) + if err != nil { + log.Fatalf("failed to init oidc provider: %v", err) + } + oidcVerifier = oidcProvider.Verifier(&oidc.Config{ClientID: oidcClientID}) + + // Use custom scopes if provided, otherwise use defaults + var scopes []string + if len(oidcScopes) > 0 { + scopes = oidcScopes + } else { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + // Only request groups scope if allowed groups are configured + if len(allowedGroups) > 0 { + scopes = append(scopes, "groups") + } + } + + oauth2Config = &oauth2.Config{ + ClientID: oidcClientID, + ClientSecret: oidcClientSecret, + Endpoint: oidcProvider.Endpoint(), + RedirectURL: oidcRedirectURL, + Scopes: scopes, + } + sessionKey := make([]byte, sessionKeySize) + if _, err := rand.Read(sessionKey); err != nil { + log.Fatalf("failed to generate session key: %v", err) + } + sessionStore = sessions.NewCookieStore(sessionKey) + pathPrefix := viper.GetString("path-prefix") + if pathPrefix == "" { + pathPrefix = "/" + } else if !strings.HasSuffix(pathPrefix, "/") { + pathPrefix += "/" + } + sessionStore.Options = &sessions.Options{ + Path: pathPrefix, + MaxAge: sessionMaxAge, + HttpOnly: true, + Secure: strings.HasPrefix(oidcRedirectURL, "https://"), + SameSite: http.SameSiteLaxMode, + } + log.Printf("OIDC session configuration: Path=%s, Secure=%v, MaxAge=%d", pathPrefix, strings.HasPrefix(oidcRedirectURL, "https://"), sessionMaxAge) + } + var cloudProvider CloudProvider switch strings.ToLower(provider) { case "aws": @@ -447,6 +794,9 @@ func serve() { if err != nil { log.Fatal(err) } + if cfg.Region == "" { + log.Fatal("AWS region is required when using AWS provider. Set the AWS_REGION environment variable.") + } var rdsClient *rds.Client if enableRds { rdsClient = rds.NewFromConfig(cfg) @@ -482,9 +832,15 @@ func serve() { rp.HandleFunc("/", apiHandler.IndexHandler).Methods("GET") rp.HandleFunc("/favicon.ico", FaviconHandler).Methods("GET") rp.HandleFunc("/toggle", apiHandler.ToggleHandler).Methods("POST") + rp.HandleFunc("/login", LoginHandler).Methods("GET") + rp.HandleFunc("/callback", CallbackHandler).Methods("GET") // Add middleware - n := negroni.Classic() // Includes some default middlewares + n := negroni.New() + n.Use(negroni.NewRecovery()) + n.Use(negroni.NewStatic(http.Dir("public"))) + n.Use(&AuthMiddleware{}) // Auth runs first to add user context + n.Use(&UserAwareLogger{Logger: log.StandardLogger()}) // Logger runs after auth to access user context n.UseHandler(rp) server := &http.Server{ diff --git a/cmd/root_test.go b/cmd/root_test.go index 6112dfc..edab047 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,9 +1,18 @@ package cmd import ( + "bytes" + "context" + "encoding/gob" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/urfave/negroni/v3" ) func TestExtractResourceGroupName(t *testing.T) { @@ -32,3 +41,288 @@ func TestNormalizeStatus(t *testing.T) { } } } + +func TestHasAllowedGroup(t *testing.T) { + tests := []struct { + name string + userGroups []string + allowedGroups []string + want bool + }{ + { + name: "user has allowed group", + userGroups: []string{"admin", "users"}, + allowedGroups: []string{"admin", "operators"}, + want: true, + }, + { + name: "user has no allowed groups", + userGroups: []string{"users", "readers"}, + allowedGroups: []string{"admin", "operators"}, + want: false, + }, + { + name: "empty allowed groups", + userGroups: []string{"admin"}, + allowedGroups: []string{}, + want: false, + }, + { + name: "empty user groups", + userGroups: []string{}, + allowedGroups: []string{"admin"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set global allowedGroups for testing + oldAllowedGroups := allowedGroups + allowedGroups = tt.allowedGroups + defer func() { allowedGroups = oldAllowedGroups }() + + if got := hasAllowedGroup(tt.userGroups); got != tt.want { + t.Errorf("hasAllowedGroup(%v) = %v, want %v", tt.userGroups, got, tt.want) + } + }) + } +} + +func TestLoginHandler(t *testing.T) { + tests := []struct { + name string + oidcEnabled bool + expectedCode int + }{ + { + name: "OIDC disabled redirects to prefix", + oidcEnabled: false, + expectedCode: http.StatusFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldOidcEnabled := oidcEnabled + oidcEnabled = tt.oidcEnabled + defer func() { oidcEnabled = oldOidcEnabled }() + + viper.Set("path-prefix", "/test/") + + req, _ := http.NewRequest("GET", "/login", http.NoBody) + rr := httptest.NewRecorder() + handler := http.HandlerFunc(LoginHandler) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != tt.expectedCode { + t.Errorf("handler returned wrong status code: got %v want %v", + status, tt.expectedCode) + } + + if !tt.oidcEnabled { + expectedLocation := "/test/" + if location := rr.Header().Get("Location"); location != expectedLocation { + t.Errorf("handler returned wrong location: got %v want %v", + location, expectedLocation) + } + } + }) + } +} + +func TestAuthMiddleware(t *testing.T) { + tests := []struct { + name string + path string + oidcEnabled bool + expectNext bool + }{ + { + name: "OIDC disabled passes through", + oidcEnabled: false, + path: "/", + expectNext: true, + }, + { + name: "login path passes through", + oidcEnabled: true, + path: "/test/login", + expectNext: true, + }, + { + name: "callback path passes through", + oidcEnabled: true, + path: "/test/callback", + expectNext: true, + }, + { + name: "favicon passes through", + oidcEnabled: true, + path: "/test/favicon.ico", + expectNext: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldOidcEnabled := oidcEnabled + oidcEnabled = tt.oidcEnabled + defer func() { oidcEnabled = oldOidcEnabled }() + + viper.Set("path-prefix", "/test/") + + nextCalled := false + nextFunc := func(_ http.ResponseWriter, _ *http.Request) { + nextCalled = true + } + + req, _ := http.NewRequest("GET", tt.path, http.NoBody) + rr := httptest.NewRecorder() + + authMiddleware := &AuthMiddleware{} + authMiddleware.ServeHTTP(rr, req, nextFunc) + + if nextCalled != tt.expectNext { + t.Errorf("next handler called = %v, want %v", nextCalled, tt.expectNext) + } + }) + } +} + +func TestUserAwareLogger(t *testing.T) { + var logOutput strings.Builder + logger := &UserAwareLogger{ + Logger: &logrus.Logger{ + Out: &logOutput, + Formatter: &logrus.TextFormatter{DisableTimestamp: true}, + Level: logrus.InfoLevel, + }, + } + + tests := []struct { + name string + userClaims map[string]string + expectedInLog string + }{ + { + name: "request without user", + userClaims: nil, + expectedInLog: "GET /test -> 200", + }, + { + name: "request with single claim", + userClaims: map[string]string{"sub": "user123"}, + expectedInLog: "GET /test user=sub=user123 -> 200", + }, + { + name: "request with multiple claims", + userClaims: map[string]string{"sub": "user123", "email": "user@example.com", "name": "John Doe"}, + expectedInLog: "GET /test user=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logOutput.Reset() + + req, _ := http.NewRequest("GET", "/test", http.NoBody) + if tt.userClaims != nil { + ctx := context.WithValue(req.Context(), userSubjectKey, tt.userClaims) + req = req.WithContext(ctx) + } + + rw := negroni.NewResponseWriter(httptest.NewRecorder()) + next := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + } + + logger.ServeHTTP(rw, req, next) + + logOutput := logOutput.String() + if tt.name == "request with multiple claims" { + // For multiple claims, just check that user= is present and contains expected claims + if !strings.Contains(logOutput, "user=") || + !strings.Contains(logOutput, "sub=user123") || + !strings.Contains(logOutput, "email=user@example.com") || + !strings.Contains(logOutput, "name=John Doe") { + t.Errorf("Expected log to contain all claims, got %q", logOutput) + } + } else if !strings.Contains(logOutput, tt.expectedInLog) { + t.Errorf("Expected log to contain %q, got %q", tt.expectedInLog, logOutput) + } + }) + } +} + +func TestSessionClaimsSerialization(t *testing.T) { + // Test that individual claim strings can be serialized/deserialized by gob + // This is what gorilla/sessions uses internally when we store claims as separate keys + + testSessionValues := map[string]interface{}{ + "user_claim_sub": "user123", + "user_claim_email": "user@example.com", + "user_claim_name": "John Doe", + "authenticated": true, + "token_expiry": int64(1234567890), + } + + // Test gob encoding (what sessions does) + var buf bytes.Buffer + encoder := gob.NewEncoder(&buf) + err := encoder.Encode(testSessionValues) + if err != nil { + t.Fatalf("Failed to encode session values: %v", err) + } + + // Test gob decoding + var decoded map[string]interface{} + decoder := gob.NewDecoder(&buf) + err = decoder.Decode(&decoded) + if err != nil { + t.Fatalf("Failed to decode session values: %v", err) + } + + // Verify data integrity + if len(decoded) != len(testSessionValues) { + t.Errorf("Decoded map has wrong length: got %d, want %d", len(decoded), len(testSessionValues)) + } + + for key, expectedValue := range testSessionValues { + if actualValue, exists := decoded[key]; !exists { + t.Errorf("Missing key %q in decoded map", key) + } else if actualValue != expectedValue { + t.Errorf("Wrong value for key %q: got %v, want %v", key, actualValue, expectedValue) + } + } + + // Test claim reconstruction + reconstructedClaims := make(map[string]string) + for key, value := range decoded { + if strings.HasPrefix(key, "user_claim_") { + claimName := strings.TrimPrefix(key, "user_claim_") + if claimValue, ok := value.(string); ok { + reconstructedClaims[claimName] = claimValue + } + } + } + + expectedClaims := map[string]string{ + "sub": "user123", + "email": "user@example.com", + "name": "John Doe", + } + + if len(reconstructedClaims) != len(expectedClaims) { + t.Errorf("Reconstructed claims has wrong length: got %d, want %d", len(reconstructedClaims), len(expectedClaims)) + } + + for key, expectedValue := range expectedClaims { + if actualValue, exists := reconstructedClaims[key]; !exists { + t.Errorf("Missing claim %q in reconstructed map", key) + } else if actualValue != expectedValue { + t.Errorf("Wrong value for claim %q: got %q, want %q", key, actualValue, expectedValue) + } + } +} diff --git a/go.mod b/go.mod index 4d9b3f6..ec807d0 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/frgrisk/river-guide -go 1.23.0 +go 1.24 -toolchain go1.24.1 +toolchain go1.24.3 require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 @@ -12,17 +12,21 @@ require ( github.com/aws/aws-sdk-go-v2/service/ec2 v1.239.0 github.com/aws/aws-sdk-go-v2/service/rds v1.100.1 github.com/aws/smithy-go v1.22.5 + github.com/coreos/go-oidc/v3 v3.15.0 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/gorilla/sessions v1.4.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/urfave/negroni/v3 v3.1.1 + golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.16.0 ) require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.1 // indirect @@ -34,24 +38,24 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.26.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.35.1 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/sagikazarmark/locafero v0.10.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.14.0 // indirect + github.com/spf13/cast v1.9.2 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 96d17be..27378d2 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 h1:z7Mqz6l0EFH549GvHEqfjKvi+cRScxLWbaoeLm9wxVQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0/go.mod h1:v6gbfH+7DG7xH2kUNs+ZJ9tF6O3iNnR85wMtmr+F54o= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= @@ -48,6 +48,8 @@ github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= +github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -56,18 +58,26 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= +github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -78,8 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -89,20 +99,21 @@ github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= +github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -113,20 +124,20 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=