diff --git a/README.md b/README.md index 918b703..0bf3cc7 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,10 @@ targets: [ When `targets` is defined, the global `tokenKey` and `idTokenKey` are ignored. +Relative `file` paths are resolved against the directory of the configuration file, so running `authk` from a subdirectory updates the same files. + +`.env` files are written atomically (temporary file + rename): readers never see a partially written file. An existing file keeps its permissions and a symlink is followed; a file created by `authk` gets `0600` permissions since it holds a token. + ## Secrets Management `authk` integrates with [vals](https://github.com/helmfile/vals) to support loading secrets securely from various sources. You can use special URI schemes in your configuration file to reference secrets instead of hardcoding them. diff --git a/cmd/authk/common.go b/cmd/authk/common.go new file mode 100644 index 0000000..2017f1e --- /dev/null +++ b/cmd/authk/common.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + + "github.com/codozor/authk/internal/config" + "github.com/codozor/authk/internal/env" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// setupLogger configures the global logger on stderr. --debug overrides the given level. +func setupLogger(level zerolog.Level) { + if debug { + level = zerolog.DebugLevel + } + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).Level(level) +} + +// loadConfig looks for the config file in the current directory, its parents and the +// home directory, then loads it. All commands must use it to behave consistently. +func loadConfig() (*config.Config, error) { + if found, err := env.Find(cfgFile); err == nil { + cfgFile = found + } + + cfg, err := config.Load(cfgFile) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +// resolveEnvFile looks for the .env file in the current directory, its parents and the +// home directory. If it is not found, the flag value is kept as is. +func resolveEnvFile() { + if found, err := env.Find(envFile); err == nil { + envFile = found + } +} diff --git a/cmd/authk/get.go b/cmd/authk/get.go index 67f7aad..4fef7f7 100644 --- a/cmd/authk/get.go +++ b/cmd/authk/get.go @@ -2,12 +2,9 @@ package main import ( "fmt" - "os" - "github.com/codozor/authk/internal/config" "github.com/codozor/authk/internal/oidc" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -20,29 +17,20 @@ var getCmd = &cobra.Command{ Short: "Get a valid token", Long: `Get a valid token from the OIDC provider and print it to stdout.`, RunE: func(cmd *cobra.Command, args []string) error { - // Setup Logger // Default to Error level to suppress Info logs (like "Using ... flow") - // unless debug is enabled. - logLevel := zerolog.ErrorLevel - if debug { - logLevel = zerolog.DebugLevel - } - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).Level(logLevel) + setupLogger(zerolog.ErrorLevel) - // Load Config - cfg, err := config.Load(cfgFile) + cfg, err := loadConfig() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return err } - // Initialize OIDC Client client, err := oidc.NewClient(cfg) if err != nil { return fmt.Errorf("failed to initialize OIDC client: %w", err) } - // Get Token - token, err := client.GetToken("", "") + token, err := client.GetToken(cmd.Context(), "", "") if err != nil { return fmt.Errorf("failed to get token: %w", err) } diff --git a/cmd/authk/inspect.go b/cmd/authk/inspect.go index 76b97a0..390a3a0 100644 --- a/cmd/authk/inspect.go +++ b/cmd/authk/inspect.go @@ -4,11 +4,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "path/filepath" "strconv" "strings" "time" - "github.com/codozor/authk/internal/config" "github.com/codozor/authk/internal/env" "github.com/fatih/color" "github.com/spf13/cobra" @@ -24,21 +24,11 @@ var inspectCmd = &cobra.Command{ Short: "Inspect the current token", Long: `Read the token from the .env file and display its decoded content. Use the --json flag for machine-readable output.`, RunE: func(cmd *cobra.Command, args []string) error { - // Try to find config file - if found, err := env.Find(cfgFile); err == nil { - cfgFile = found - } - - // Load Config - cfg, err := config.Load(cfgFile) + cfg, err := loadConfig() if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Try to find .env file - if found, err := env.Find(envFile); err == nil { - envFile = found + return err } + resolveEnvFile() // Determine which file and key to use targetFile := envFile @@ -52,8 +42,13 @@ var inspectCmd = &cobra.Command{ found := false // 1. If --env is explicitly provided, try to find a target matching that file and type if cmd.Flags().Changed("env") { + // Target files are absolute (resolved against the config dir): compare absolute paths + wanted, err := filepath.Abs(envFile) + if err != nil { + return fmt.Errorf("failed to resolve env file path: %w", err) + } for _, t := range cfg.Targets { - if t.File == envFile && t.Type == requestedType { + if t.File == wanted && t.Type == requestedType { targetKey = t.Key found = true break diff --git a/cmd/authk/refresh.go b/cmd/authk/refresh.go new file mode 100644 index 0000000..7b39d92 --- /dev/null +++ b/cmd/authk/refresh.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" +) + +// tokenClient is the part of oidc.Client used by the refresh loop. +type tokenClient interface { + GetToken(ctx context.Context, username, password string) (*oauth2.Token, error) + RefreshToken(ctx context.Context, oldToken *oauth2.Token) (*oauth2.Token, error) +} + +// refresher keeps a token valid until its context is cancelled. +type refresher struct { + client tokenClient + onToken func(*oauth2.Token) // called with every new token + + refreshBuffer time.Duration // renew this long before expiry (at most half the token lifetime) + minDelay time.Duration // never schedule a renewal sooner than this + retryDelay time.Duration // first delay after a failed renewal, doubled up to maxRetryDelay + maxRetryDelay time.Duration + checkInterval time.Duration // the wall clock is checked at least this often +} + +func newRefresher(client tokenClient, onToken func(*oauth2.Token)) *refresher { + return &refresher{ + client: client, + onToken: onToken, + refreshBuffer: 60 * time.Second, + minDelay: 10 * time.Second, + retryDelay: 10 * time.Second, + maxRetryDelay: 60 * time.Second, + checkInterval: 30 * time.Second, + } +} + +// run renews token before it expires and returns nil when ctx is cancelled. +func (r *refresher) run(ctx context.Context, token *oauth2.Token) error { + retryDelay := r.retryDelay + next, ok := r.nextRefresh(token) + for { + if !ok { + log.Warn().Msg("Token has no expiry: it will not be refreshed") + <-ctx.Done() + return nil + } + + log.Info().Time("refresh_at", next).Msg("Waiting for token refresh") + if !r.waitUntil(ctx, next) { + return nil + } + + newToken, err := r.renew(ctx, token) + if err != nil { + if ctx.Err() != nil { + return nil + } + log.Error().Err(err).Dur("retry_in", retryDelay).Msg("Failed to renew token") + next = time.Now().Round(0).Add(retryDelay) + retryDelay = min(retryDelay*2, r.maxRetryDelay) + continue + } + + retryDelay = r.retryDelay + token = newToken + r.onToken(token) + next, ok = r.nextRefresh(token) + } +} + +// nextRefresh returns when token must be renewed, or false if it never expires. +func (r *refresher) nextRefresh(token *oauth2.Token) (time.Time, bool) { + if token.Expiry.IsZero() { + return time.Time{}, false + } + // Round(0) strips the monotonic clock reading: comparisons then use the wall clock, + // which keeps running while the machine is suspended (the monotonic clock does not). + expiry := token.Expiry.Round(0) + now := time.Now().Round(0) + + buffer := min(r.refreshBuffer, expiry.Sub(now)/2) + at := expiry.Add(-buffer) + if earliest := now.Add(r.minDelay); at.Before(earliest) { + at = earliest + } + return at, true +} + +// waitUntil blocks until the wall clock reaches at (true) or ctx is cancelled (false). +// It wakes up at least every checkInterval, so a suspend does not delay the renewal. +func (r *refresher) waitUntil(ctx context.Context, at time.Time) bool { + for { + remaining := at.Sub(time.Now().Round(0)) + if remaining <= 0 { + return true + } + timer := time.NewTimer(min(remaining, r.checkInterval)) + select { + case <-ctx.Done(): + timer.Stop() + return false + case <-timer.C: + } + } +} + +// renew uses the refresh token when there is one, and falls back to a full authentication. +func (r *refresher) renew(ctx context.Context, token *oauth2.Token) (*oauth2.Token, error) { + if token.RefreshToken != "" { + newToken, err := r.client.RefreshToken(ctx, token) + if err == nil { + log.Debug().Msg("Token refreshed with refresh token") + return newToken, nil + } + if ctx.Err() != nil { + return nil, err + } + log.Warn().Err(err).Msg("Failed to refresh token, attempting full re-authentication") + } + return r.client.GetToken(ctx, "", "") +} diff --git a/cmd/authk/refresh_test.go b/cmd/authk/refresh_test.go new file mode 100644 index 0000000..f3e23b2 --- /dev/null +++ b/cmd/authk/refresh_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "golang.org/x/oauth2" +) + +// fakeClient records calls and returns tokens valid for lifetime. +type fakeClient struct { + mu sync.Mutex + lifetime time.Duration + refreshToken string // returned in new tokens + getErr error + gets int + refreshes int + refreshedAt []time.Time +} + +func (f *fakeClient) newToken() *oauth2.Token { + return &oauth2.Token{AccessToken: "t", RefreshToken: f.refreshToken, Expiry: time.Now().Add(f.lifetime)} +} + +func (f *fakeClient) GetToken(context.Context, string, string) (*oauth2.Token, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.gets++ + if f.getErr != nil { + return nil, f.getErr + } + return f.newToken(), nil +} + +func (f *fakeClient) RefreshToken(_ context.Context, old *oauth2.Token) (*oauth2.Token, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.refreshes++ + f.refreshedAt = append(f.refreshedAt, time.Now()) + return f.newToken(), nil +} + +func (f *fakeClient) counts() (gets, refreshes int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.gets, f.refreshes +} + +func testRefresher(c tokenClient, onToken func(*oauth2.Token)) *refresher { + r := newRefresher(c, onToken) + r.refreshBuffer = 200 * time.Millisecond + r.minDelay = 10 * time.Millisecond + r.retryDelay = 20 * time.Millisecond + r.maxRetryDelay = 40 * time.Millisecond + r.checkInterval = 10 * time.Millisecond + return r +} + +func TestRefresher_RefreshesBeforeExpiry(t *testing.T) { + client := &fakeClient{lifetime: time.Second, refreshToken: "rt"} + initial := client.newToken() + + var mu sync.Mutex + updates := 0 + ctx, cancel := context.WithCancel(context.Background()) + r := testRefresher(client, func(*oauth2.Token) { + mu.Lock() + updates++ + mu.Unlock() + cancel() // stop after the first renewal + }) + + done := make(chan error) + go func() { done <- r.run(ctx, initial) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("run() error = %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("run() did not return") + } + + gets, refreshes := client.counts() + if refreshes != 1 || gets != 0 { + t.Fatalf("expected 1 refresh and no full authentication, got refreshes=%d gets=%d", refreshes, gets) + } + if updates != 1 { + t.Errorf("expected 1 target update, got %d", updates) + } + // Renewed refreshBuffer before expiry, not when the token is almost expired + if margin := initial.Expiry.Sub(client.refreshedAt[0]); margin < 150*time.Millisecond || margin > 300*time.Millisecond { + t.Errorf("refreshed %v before expiry, want about 200ms", margin) + } +} + +func TestRefresher_NoRefreshTokenReauthenticates(t *testing.T) { + client := &fakeClient{lifetime: 300 * time.Millisecond} // client_credentials: no refresh token + ctx, cancel := context.WithCancel(context.Background()) + r := testRefresher(client, func(*oauth2.Token) { cancel() }) + + if err := r.run(ctx, client.newToken()); err != nil { + t.Fatalf("run() error = %v", err) + } + gets, refreshes := client.counts() + if gets != 1 || refreshes != 0 { + t.Errorf("expected 1 full authentication and no refresh, got gets=%d refreshes=%d", gets, refreshes) + } +} + +func TestRefresher_RetriesAfterFailure(t *testing.T) { + client := &fakeClient{lifetime: 100 * time.Millisecond, getErr: errors.New("provider down")} + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + r := testRefresher(client, func(*oauth2.Token) { t.Error("unexpected token update") }) + + if err := r.run(ctx, client.newToken()); err != nil { + t.Fatalf("run() error = %v", err) + } + // ~350ms of failures with a 20ms delay doubled up to 40ms: several retries, no busy loop + if gets, _ := client.counts(); gets < 3 || gets > 15 { + t.Errorf("expected a few retries, got %d", gets) + } +} + +func TestRefresher_StopsOnCancel(t *testing.T) { + client := &fakeClient{lifetime: time.Hour, refreshToken: "rt"} + ctx, cancel := context.WithCancel(context.Background()) + r := testRefresher(client, func(*oauth2.Token) {}) + + done := make(chan error) + go func() { done <- r.run(ctx, client.newToken()) }() + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("run() error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("run() did not stop on cancel") + } +} + +func TestRefresher_NextRefresh(t *testing.T) { + r := newRefresher(nil, nil) + + if _, ok := r.nextRefresh(&oauth2.Token{AccessToken: "t"}); ok { + t.Error("a token without expiry must not be scheduled") + } + + // Long-lived token: refreshBuffer before expiry + expiry := time.Now().Add(5 * time.Minute) + at, _ := r.nextRefresh(&oauth2.Token{Expiry: expiry}) + if d := expiry.Sub(at); d != 60*time.Second { + t.Errorf("long-lived token renewed %v before expiry, want 60s", d) + } + + // Short-lived token: at most half its lifetime before expiry + expiry = time.Now().Add(40 * time.Second) + at, _ = r.nextRefresh(&oauth2.Token{Expiry: expiry}) + if d := expiry.Sub(at); d < 19*time.Second || d > 21*time.Second { + t.Errorf("short-lived token renewed %v before expiry, want about 20s", d) + } + + // Expired token: not sooner than minDelay + at, _ = r.nextRefresh(&oauth2.Token{Expiry: time.Now().Add(-time.Minute)}) + if d := time.Until(at); d < 9*time.Second { + t.Errorf("expired token renewed in %v, want at least minDelay", d) + } +} diff --git a/cmd/authk/root.go b/cmd/authk/root.go index 29eda6a..3aa0e62 100644 --- a/cmd/authk/root.go +++ b/cmd/authk/root.go @@ -3,7 +3,8 @@ package main import ( "fmt" "os" - "time" + "os/signal" + "syscall" "github.com/codozor/authk/internal/config" "github.com/codozor/authk/internal/env" @@ -29,29 +30,13 @@ updating a .env file with the valid token.`, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { printBanner() + setupLogger(zerolog.InfoLevel) - // Setup Logger with Pretty Print - logLevel := zerolog.InfoLevel - if debug { - logLevel = zerolog.DebugLevel - } - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).Level(logLevel) - - // Try to find config file - if found, err := env.Find(cfgFile); err == nil { - cfgFile = found - } - - // Load Config - cfg, err := config.Load(cfgFile) + cfg, err := loadConfig() if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Try to find .env file - if found, err := env.Find(envFile); err == nil { - envFile = found + return err } + resolveEnvFile() // Prepare targets var targets []config.Target @@ -74,6 +59,10 @@ updating a .env file with the valid token.`, log.Info().Str("env_file", envFile).Msg("Configured with default targets") } + // Stop cleanly on Ctrl+C / SIGTERM + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + // Initialize OIDC Client client, err := oidc.NewClient(cfg) if err != nil { @@ -81,76 +70,44 @@ updating a .env file with the valid token.`, } // Initial Token Retrieval - token, err := client.GetToken("", "") + token, err := client.GetToken(ctx, "", "") if err != nil { return fmt.Errorf("failed to get initial token: %w", err) } - // Function to update all targets with current tokens - updateTargets := func(t *oauth2.Token) { - for _, target := range targets { - var tokenValue string - switch target.Type { - case "id_token": - idToken, ok := t.Extra("id_token").(string) - if !ok || idToken == "" { - log.Warn().Str("file", target.File).Msg("ID Token requested but not found in response") - continue - } - tokenValue = idToken - default: // access_token - tokenValue = t.AccessToken - } - - mgr := env.NewManager(target.File, target.Key) - if err := mgr.Update(tokenValue); err != nil { - log.Error().Err(err).Str("file", target.File).Str("type", target.Type).Msg("Failed to update token") - } else { - log.Info().Str("file", target.File).Str("type", target.Type).Msg("Token updated") - } - } - } - - // Initial update - updateTargets(token) + updateTargets(targets, token) - // Maintenance Loop - for { - // Calculate sleep time based on token expiry and a refresh buffer - refreshBuffer := 60 * time.Second // Refresh 60 seconds before expiry - sleepDuration := time.Until(token.Expiry) - refreshBuffer - if sleepDuration < 10*time.Second { // Ensure at least 10 seconds sleep - sleepDuration = 10 * time.Second - } + if err := newRefresher(client, func(t *oauth2.Token) { updateTargets(targets, t) }).run(ctx, token); err != nil { + return err + } + log.Info().Msg("Shutting down") + return nil + }, +} - log.Info().Dur("sleep_duration", sleepDuration).Msg("Waiting for token refresh") - time.Sleep(sleepDuration) - - // Attempt to refresh the token - newToken, err := client.RefreshToken(token) - if err != nil { - log.Error().Err(err).Msg("Failed to refresh token, attempting full re-authentication") - - // Try full re-authentication - newToken, err = client.GetToken("", "") - if err != nil { - log.Error().Err(err).Msg("Failed to re-authenticate") - // Retry after short delay - time.Sleep(10 * time.Second) - - // Force short sleep on next iteration to retry quickly - // By setting token.Expiry to now, time.Until will be negative, - // and sleepDuration will become 10s. - token.Expiry = time.Now() - continue - } +// updateTargets writes the current tokens to every target. +func updateTargets(targets []config.Target, t *oauth2.Token) { + for _, target := range targets { + var tokenValue string + switch target.Type { + case "id_token": + idToken, ok := t.Extra("id_token").(string) + if !ok || idToken == "" { + log.Warn().Str("file", target.File).Msg("ID Token requested but not found in response") + continue } + tokenValue = idToken + default: // access_token + tokenValue = t.AccessToken + } - // Update token and targets - token = newToken - updateTargets(token) + mgr := env.NewManager(target.File, target.Key) + if err := mgr.Update(tokenValue); err != nil { + log.Error().Err(err).Str("file", target.File).Str("type", target.Type).Msg("Failed to update token") + } else { + log.Info().Str("file", target.File).Str("type", target.Type).Msg("Token updated") } - }, + } } func printBanner() { diff --git a/internal/config/config.go b/internal/config/config.go index 52087f0..5446b59 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "cuelang.org/go/cue/cuecontext" @@ -103,9 +104,31 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("failed to decode config: %w", err) } + if err := cfg.resolveTargetPaths(path); err != nil { + return nil, err + } + return &cfg, nil } +// resolveTargetPaths makes relative target files relative to the config file directory, +// so that running authk from a subdirectory updates the same files. +func (c *Config) resolveTargetPaths(configPath string) error { + absConfig, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("failed to resolve config path: %w", err) + } + baseDir := filepath.Dir(absConfig) + for i, t := range c.Targets { + if filepath.IsAbs(t.File) { + c.Targets[i].File = filepath.Clean(t.File) + } else { + c.Targets[i].File = filepath.Join(baseDir, t.File) + } + } + return nil +} + func processEnvRefs(v interface{}) error { switch t := v.(type) { case map[string]interface{}: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index aab591e..c226dd3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -36,10 +36,11 @@ targets: [ t.Errorf("expected 2 targets, got %d", len(cfg.Targets)) } - if cfg.Targets[0].File != ".env.1" || cfg.Targets[0].Key != "KEY1" { + // Relative target files are resolved against the config file directory + if cfg.Targets[0].File != filepath.Join(tmpDir, ".env.1") || cfg.Targets[0].Key != "KEY1" { t.Errorf("unexpected target 0: %+v", cfg.Targets[0]) } - if cfg.Targets[1].File != ".env.2" || cfg.Targets[1].Key != "KEY2" { + if cfg.Targets[1].File != filepath.Join(tmpDir, ".env.2") || cfg.Targets[1].Key != "KEY2" { t.Errorf("unexpected target 1: %+v", cfg.Targets[1]) } } @@ -166,3 +167,39 @@ oidc: { t.Errorf("expected authMethod 'none', got %q", cfg.OIDC.AuthMethod) } } + +func TestLoad_TargetPaths(t *testing.T) { + tmpDir := t.TempDir() + absTarget := filepath.Join(t.TempDir(), "abs.env") + content := ` +package config + +oidc: { + issuerUrl: "https://example.com" + clientId: "client" +} + +targets: [ + { file: "apps/front/.env", key: "A" }, + { file: "` + filepath.ToSlash(absTarget) + `", key: "B" }, +] +` + configFile := filepath.Join(tmpDir, "authk.cue") + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Load from another working directory: paths must not depend on it + t.Chdir(t.TempDir()) + + cfg, err := Load(configFile) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if want := filepath.Join(tmpDir, "apps", "front", ".env"); cfg.Targets[0].File != want { + t.Errorf("relative target = %s, want %s", cfg.Targets[0].File, want) + } + if cfg.Targets[1].File != filepath.FromSlash(absTarget) { + t.Errorf("absolute target = %s, want %s", cfg.Targets[1].File, absTarget) + } +} diff --git a/internal/env/env.go b/internal/env/env.go index bee5469..ee8c3e1 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -122,20 +122,59 @@ func (m *Manager) readLines() ([]string, error) { return lines, scanner.Err() } +// newFileMode is the permission of a .env file created by authk: it holds a token. +const newFileMode os.FileMode = 0o600 + +// writeLines replaces the file atomically (temp file + rename) so that readers never +// see a truncated file. An existing file keeps its permissions; a new one gets 0600. +// A symlink is followed, so the link itself is preserved. func (m *Manager) writeLines(lines []string) error { - file, err := os.Create(m.filePath) + path := m.filePath + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + + mode := newFileMode + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } + + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".authk-*") if err != nil { - return fmt.Errorf("failed to create/open .env file: %w", err) + return fmt.Errorf("failed to create temporary .env file: %w", err) } - defer file.Close() + tmpName := tmp.Name() + committed := false + defer func() { + if !committed { + _ = tmp.Close() + _ = os.Remove(tmpName) + } + }() - writer := bufio.NewWriter(file) + writer := bufio.NewWriter(tmp) for _, line := range lines { if _, err := writer.WriteString(line + "\n"); err != nil { return err } } - return writer.Flush() + if err := writer.Flush(); err != nil { + return err + } + if err := tmp.Chmod(mode); err != nil { + return fmt.Errorf("failed to set .env file permissions: %w", err) + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("failed to replace .env file: %w", err) + } + committed = true + return nil } // Find searches for the given filename in the current directory and parent directories. diff --git a/internal/env/env_test.go b/internal/env/env_test.go index 94ab899..e81938e 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -3,6 +3,7 @@ package env import ( "os" "path/filepath" + "runtime" "testing" ) @@ -178,3 +179,86 @@ func TestFind_WithSeparator(t *testing.T) { t.Errorf("Find() = %s, want %s", found, path) } } + +func TestManager_Update_Permissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions") + } + dir := t.TempDir() + + // A new file holds a token: it must not be readable by others + newFile := filepath.Join(dir, ".env") + if err := NewManager(newFile, "TOKEN").Update("v1"); err != nil { + t.Fatalf("Update() error = %v", err) + } + info, err := os.Stat(newFile) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("new file mode = %o, want 600", got) + } + + // An existing file keeps its permissions + existing := filepath.Join(dir, ".env.shared") + if err := os.WriteFile(existing, []byte("OTHER=1\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(existing, 0o640); err != nil { // bypass umask + t.Fatal(err) + } + if err := NewManager(existing, "TOKEN").Update("v1"); err != nil { + t.Fatalf("Update() error = %v", err) + } + info, err = os.Stat(existing) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o640 { + t.Errorf("existing file mode = %o, want 640", got) + } + + // No temporary file is left behind + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("expected only the 2 .env files, got %v", names) + } +} + +func TestManager_Update_Symlink(t *testing.T) { + dir := t.TempDir() + realFile := filepath.Join(dir, "real.env") + link := filepath.Join(dir, ".env") + if err := os.WriteFile(realFile, []byte("TOKEN=old\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realFile, link); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + if err := NewManager(link, "TOKEN").Update("new"); err != nil { + t.Fatalf("Update() error = %v", err) + } + + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("the symlink was replaced by a regular file") + } + content, err := os.ReadFile(realFile) + if err != nil { + t.Fatal(err) + } + if string(content) != "TOKEN=\"new\"\n" { + t.Errorf("target file content = %q", content) + } +} diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 64e6383..1cc988f 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -2,6 +2,7 @@ package oidc import ( "context" + "errors" "fmt" "net/http" "time" @@ -14,19 +15,22 @@ import ( "golang.org/x/oauth2/clientcredentials" ) +// ErrNoRefreshToken is returned by RefreshToken when the token has no refresh token +// (e.g. client_credentials flow): the caller must re-authenticate instead. +var ErrNoRefreshToken = errors.New("no refresh token available") + type Client struct { cfg *config.Config provider *oidc.Provider oauth2Config *oauth2.Config + httpClient *http.Client public bool } func NewClient(cfg *config.Config) (*Client, error) { - ctx := context.Background() - - // Use custom HTTP client with timeout + // Use custom HTTP client with timeout, for discovery and for every token request httpClient := &http.Client{Timeout: 30 * time.Second} - ctx = oidc.ClientContext(ctx, httpClient) + ctx := oidc.ClientContext(context.Background(), httpClient) provider, err := oidc.NewProvider(ctx, cfg.OIDC.IssuerURL) if err != nil { @@ -79,12 +83,19 @@ func NewClient(cfg *config.Config) (*Client, error) { cfg: cfg, provider: provider, oauth2Config: oauth2Config, + httpClient: httpClient, public: public, }, nil } -func (c *Client) GetToken(username, password string) (*oauth2.Token, error) { - ctx := context.Background() +// withHTTPClient makes oauth2 and go-oidc use the client's HTTP client (and its timeout) +// instead of http.DefaultClient. +func (c *Client) withHTTPClient(ctx context.Context) context.Context { + return oidc.ClientContext(ctx, c.httpClient) +} + +func (c *Client) GetToken(ctx context.Context, username, password string) (*oauth2.Token, error) { + ctx = c.withHTTPClient(ctx) // Use config credentials if provided, otherwise fallback to args or client credentials user := username @@ -141,12 +152,17 @@ func (c *Client) GetToken(username, password string) (*oauth2.Token, error) { return token, nil } -// RefreshToken refreshes an expired token using the oauth2 library. -// It takes the existing *oauth2.Token which must contain a valid RefreshToken. -func (c *Client) RefreshToken(oldToken *oauth2.Token) (*oauth2.Token, error) { - ctx := context.Background() +// RefreshToken always performs a refresh_token grant, even if oldToken is still valid. +// It returns ErrNoRefreshToken if oldToken has no refresh token. +func (c *Client) RefreshToken(ctx context.Context, oldToken *oauth2.Token) (*oauth2.Token, error) { + if oldToken == nil || oldToken.RefreshToken == "" { + return nil, ErrNoRefreshToken + } + ctx = c.withHTTPClient(ctx) - tokenSource := c.oauth2Config.TokenSource(ctx, oldToken) + // oauth2.Config.TokenSource returns the token unchanged while it is still valid. + // Passing only the refresh token makes it invalid, which forces the refresh grant. + tokenSource := c.oauth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: oldToken.RefreshToken}) newToken, err := tokenSource.Token() if err != nil { return nil, fmt.Errorf("failed to refresh token: %w", err) diff --git a/internal/oidc/client_test.go b/internal/oidc/client_test.go index aef81b9..f56dbbb 100644 --- a/internal/oidc/client_test.go +++ b/internal/oidc/client_test.go @@ -1,8 +1,10 @@ package oidc import ( + "context" "encoding/base64" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -77,7 +79,7 @@ func TestClient_GetToken(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - token, err := client.GetToken("", "") + token, err := client.GetToken(context.Background(), "", "") if err != nil { t.Fatalf("GetToken() error = %v", err) } @@ -147,7 +149,7 @@ func TestClient_GetToken_Password(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - token, err := client.GetToken("testuser", "testpass") + token, err := client.GetToken(context.Background(), "testuser", "testpass") if err != nil { t.Fatalf("GetToken() error = %v", err) } @@ -210,7 +212,7 @@ func TestClient_GetToken_IDToken(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - token, err := client.GetToken("", "") + token, err := client.GetToken(context.Background(), "", "") if err != nil { t.Fatalf("GetToken() error = %v", err) } @@ -290,7 +292,7 @@ func TestClient_RefreshToken(t *testing.T) { Expiry: time.Now().Add(-1 * time.Hour), // Expired to force refresh } - token, err := client.RefreshToken(oldToken) + token, err := client.RefreshToken(context.Background(), oldToken) if err != nil { t.Fatalf("RefreshToken() error = %v", err) } @@ -368,7 +370,7 @@ func TestClient_GetToken_PublicClient(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - token, err := client.GetToken("testuser", "testpass") + token, err := client.GetToken(context.Background(), "testuser", "testpass") if err != nil { t.Fatalf("GetToken() error = %v", err) } @@ -377,9 +379,105 @@ func TestClient_GetToken_PublicClient(t *testing.T) { } // client_credentials is not possible without a secret - if _, err := client.GetToken("", ""); err == nil { + if _, err := client.GetToken(context.Background(), "", ""); err == nil { t.Error("expected error for public client without user credentials") } }) } } + +// newRefreshTestServer serves discovery and a refresh_token grant that returns a new token. +func newRefreshTestServer(t *testing.T, calls *int) *httptest.Server { + t.Helper() + var testServer *httptest.Server + testServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": testServer.URL, + "token_endpoint": testServer.URL + "/token", + "jwks_uri": testServer.URL + "/certs", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + }); err != nil { + t.Error(err) + } + case "/token": + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "valid_refresh" { + w.WriteHeader(http.StatusBadRequest) + return + } + *calls++ + w.Header().Set("Content-Type", "application/json") + // No refresh_token in the response: the old one must be kept + if err := json.NewEncoder(w).Encode(mockTokenResponse{ + AccessToken: "new_access_token", + ExpiresIn: 3600, + TokenType: "Bearer", + }); err != nil { + t.Error(err) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(testServer.Close) + return testServer +} + +func TestClient_RefreshToken_StillValidToken(t *testing.T) { + calls := 0 + srv := newRefreshTestServer(t, &calls) + + client, err := NewClient(&config.Config{OIDC: config.OIDCConfig{ + IssuerURL: srv.URL, ClientID: "client", ClientSecret: "secret", + }}) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + // Still valid for 30s: the refresh must happen anyway (refresh buffer is 60s) + oldToken := &oauth2.Token{ + AccessToken: "old_access_token", + RefreshToken: "valid_refresh", + Expiry: time.Now().Add(30 * time.Second), + } + + token, err := client.RefreshToken(context.Background(), oldToken) + if err != nil { + t.Fatalf("RefreshToken() error = %v", err) + } + if calls != 1 { + t.Errorf("expected 1 call to the token endpoint, got %d", calls) + } + if token.AccessToken != "new_access_token" { + t.Errorf("expected a new access token, got %s", token.AccessToken) + } + if token.RefreshToken != "valid_refresh" { + t.Errorf("expected the refresh token to be kept, got %q", token.RefreshToken) + } +} + +func TestClient_RefreshToken_NoRefreshToken(t *testing.T) { + calls := 0 + srv := newRefreshTestServer(t, &calls) + + client, err := NewClient(&config.Config{OIDC: config.OIDCConfig{ + IssuerURL: srv.URL, ClientID: "client", ClientSecret: "secret", + }}) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + _, err = client.RefreshToken(context.Background(), &oauth2.Token{AccessToken: "a"}) + if !errors.Is(err, ErrNoRefreshToken) { + t.Fatalf("expected ErrNoRefreshToken, got %v", err) + } + if calls != 0 { + t.Errorf("expected no call to the token endpoint, got %d", calls) + } +}