Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions cmd/authk/common.go
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 4 additions & 16 deletions cmd/authk/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
}
Expand Down
25 changes: 10 additions & 15 deletions cmd/authk/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
125 changes: 125 additions & 0 deletions cmd/authk/refresh.go
Original file line number Diff line number Diff line change
@@ -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, "", "")
}
Loading
Loading