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
49 changes: 39 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,40 +188,67 @@ oidc: {

## File Discovery

`authk` employs a smart discovery mechanism for both the configuration file (`authk.cue`) and the `.env` file. It searches in the following order:
The configuration file (`authk.cue`) is searched in the following order:

1. **Explicit Path**: If provided via flags (`--config`, `--env`).
1. **Explicit Path**: If provided via `--config`.
2. **Current Directory**: Checks the current working directory.
3. **Parent Directories**: Walks up the directory tree to the root.
4. **Home Directory**: Checks the user's home directory (`$HOME`).

This allows you to run `authk` from any subdirectory within your project or rely on a global configuration in your home directory.

When the config has no `targets`, the `.env` file is found as follows:

- **Explicit path** (`--env front/.env`): used as is, relative to the current directory.
- **Project config** (not in the home directory root): searched from the current directory up to the config directory, never above. If none is found, it is created next to the config.
- **Global config** (`~/authk.cue`): searched from the current directory up to, but excluding, the home directory. If none is found, it is created in the current directory.

`~/.env` is never used implicitly: a project without `.env` must not write its token there, nor share it with every other project.

## Usage

### Agent (Long-running)

This is the main mode. The agent fetches a token, updates the `.env` file (or the configured targets), and keeps running to renew the token before it expires. It stops cleanly on `Ctrl+C` or `SIGTERM`.

```bash
./authk agent --env .env
./authk agent # in the foreground
./authk agent --detach # in the background (-d)
```

While the agent runs, `authk get` and `authk inspect` use its current token through a local socket instead of authenticating again. The agent is the only process that renews the token, which matters with refresh token rotation (Keycloak *Revoke Refresh Token*).

One agent runs per config file. Its socket lives in `$XDG_RUNTIME_DIR/authk/` (or a private directory in the system temp dir); set `AUTHK_SOCK` to use a specific path.
An agent serves one config file, and one `.env` file when the config has no `targets` (`--env` is ignored, with a warning, when the config defines `targets`). Several agents can run at the same time; a second agent for the same files is refused.

```bash
./authk agent status # show the agent state (--json available)
./authk agent refresh # renew the token now
./authk agent stop # stop the agent
./authk agent list # list running agents (alias: ls, --json available)
./authk agent status [ID] # show an agent state (--json available)
./authk agent refresh [ID] # renew the token now
./authk agent logs [ID] -f # follow the logs of a detached agent
./authk agent stop [ID] # stop an agent
./authk agent stop --all # stop every agent
```

Without ID, these commands act on the agent serving the current directory. An ID prefix is enough, as with Docker.

```
$ authk agent list
ID PID STARTED TOKEN MODE CONFIG WRITES
281028a4-b3def358 31337 2026-11-06 09:12 4m12s detached ~/dev/app/authk.cue ~/dev/app/front/.env
281028a4-05a23f95 31420 2026-11-06 09:13 4m30s detached ~/dev/app/authk.cue ~/dev/app/back/.env
d4a4eba5 31502 2026-11-06 09:30 1m58s foreground ~/dev/other/authk.cue ~/dev/other/out/.env
```

> Running `authk` without a command still starts the agent, but is deprecated.
`--detach` returns once the agent has its first token, or prints its log if it fails to start. Logs of detached agents are written to `$XDG_STATE_HOME/authk/logs` (default `~/.local/state/authk/logs`) and kept 7 days.

Sockets and registrations live in `$XDG_RUNTIME_DIR/authk/` (or a private directory in the system temp dir); set `AUTHK_SOCK` to use a specific socket path.

> Running `authk` without a command still starts the agent in the foreground, but is deprecated.

**Flags:**
- `--config`: Path to config file (default: `authk.cue`)
- `--env`: Path to .env file (default: `.env`)
- `--env`: Path to .env file (default: `.env`), when the config has no `targets`
- `--detach`, `-d`: Run in the background
- `--debug`: Enable debug logging

### Get Token (One-off)
Expand All @@ -233,7 +260,9 @@ Prints a valid token to stdout. Useful for piping to other commands.
curl -H "Authorization: Bearer $(./authk get)" https://api.example.com
```

If an agent runs, its current token is printed (renewed first if it expires within `--min-ttl`). Otherwise a new token is requested from the provider. If the agent runs but fails, `get` returns its error instead of authenticating on its own.
If an agent serves the config, its current token is printed (renewed first if it expires within `--min-ttl`). Otherwise a new token is requested from the provider. If the agent runs but fails, `get` returns its error instead of authenticating on its own.

When several agents serve the config (one per `.env` file), `get` uses the one writing the `.env` file found from the current directory, or given with `--env`; if none matches, it fails and lists them. Set `AUTHK_SOCK` to target a specific agent.

**Flags:**
- `--id-token`: Print ID Token instead of Access Token
Expand Down
161 changes: 130 additions & 31 deletions cmd/authk/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
Expand All @@ -23,28 +24,42 @@ import (
"golang.org/x/sync/errgroup"
)

var statusJSON bool
var (
statusJSON bool
stopAll bool
agentDetach bool
)

var agentCmd = &cobra.Command{
Use: "agent",
Short: "Keep the token valid and serve it to other commands",
Long: `Run in the foreground: fetch a token, write it to the configured targets and renew it
before it expires. While it runs, "authk get" and "authk inspect" use its current token
through a local socket instead of authenticating again.
Long: `Fetch a token, write it to the configured targets and renew it before it expires.
While it runs, "authk get" and "authk inspect" use its current token through a local
socket instead of authenticating again.

An agent serves one config file (and one .env file when the config has no targets).
Several agents can run at the same time: "authk agent list" shows them. Set AUTHK_SOCK
to use a specific socket path.

One agent runs per config file. Set AUTHK_SOCK to use a specific socket path.`,
With --detach, the agent runs in the background and logs to a file
("authk agent logs").`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runAgent(cmd.Context())
if agentDetach && !isDetachedChild() {
return detachAgent(cmd)
}
return runAgent(cmd.Context(), cmd.Flags().Changed("env"))
},
}

var agentStatusCmd = &cobra.Command{
Use: "status",
Short: "Show the status of the agent",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
client, err := agentClient()
Use: "status [ID]",
Short: "Show the status of an agent",
Long: `Show the status of the agent with the given ID (a prefix is enough), or of the agent
serving the current directory without ID.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := selectAgent(cmd.Context(), args)
if err != nil {
return err
}
Expand All @@ -57,11 +72,11 @@ var agentStatusCmd = &cobra.Command{
}

var agentRefreshCmd = &cobra.Command{
Use: "refresh",
Short: "Make the agent renew its token now",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
client, err := agentClient()
Use: "refresh [ID]",
Short: "Make an agent renew its token now",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := selectAgent(cmd.Context(), args)
if err != nil {
return err
}
Expand All @@ -74,39 +89,90 @@ var agentRefreshCmd = &cobra.Command{
}

var agentStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the agent",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
client, err := agentClient()
Use: "stop [ID]",
Short: "Stop an agent",
Long: `Stop the agent with the given ID (a prefix is enough), the agent serving the current
directory without ID, or every agent with --all.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if stopAll {
if len(args) > 0 {
return errors.New("--all does not take an ID")
}
return stopAllAgents(cmd)
}
client, err := selectAgent(ctx, args)
if err != nil {
return err
}
if err := client.Stop(cmd.Context()); err != nil {
st, err := client.Status(ctx)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), "Agent stopped")
if err := client.Stop(ctx); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Agent %s stopped\n", st.ID)
return err
},
}

func runAgent(parent context.Context) error {
printBanner()
func stopAllAgents(cmd *cobra.Command) error {
agents, err := agent.List(cmd.Context())
if err != nil {
return err
}
if len(agents) == 0 {
_, err := fmt.Fprintln(cmd.OutOrStdout(), "No agent running")
return err
}
var errs []error
for _, a := range agents {
if err := agent.NewClient(a.Socket).Stop(cmd.Context()); err != nil {
errs = append(errs, fmt.Errorf("agent %s: %w", a.ID, err))
continue
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Agent %s stopped\n", a.ID); err != nil {
return err
}
}
return errors.Join(errs...)
}

// runAgent runs the agent in the foreground. envChanged tells whether --env was given.
func runAgent(parent context.Context, envChanged bool) error {
if !isDetachedChild() {
printBanner()
}
setupLogger(zerolog.InfoLevel)

cfg, err := loadConfig()
if err != nil {
return err
}
resolveEnvFile()
if envChanged && len(cfg.Targets) > 0 {
log.Warn().Msg("--env is ignored: the config defines targets")
}

socket, err := agent.SocketPath(cfgFile)
// The agent identity: the config, and the .env file only when it is the target
configPath, err := agent.CanonicalPath(cfgFile)
if err != nil {
return err
}
// Fail fast, before authenticating, if an agent already serves this config
envKey := ""
if len(cfg.Targets) == 0 {
if envKey, err = agent.CanonicalPath(envFile); err != nil {
return err
}
}
id := agent.ID(configPath, envKey, os.Getenv(agent.SocketEnv))
socket := agent.SocketFor(id)

// Fail fast, before authenticating, if an agent already serves the same files
if _, err := agent.NewClient(socket).Status(parent); err == nil {
return fmt.Errorf("%w (socket %s)", agent.ErrAlreadyRunning, socket)
return fmt.Errorf("%w (agent %s)", agent.ErrAlreadyRunning, id)
}

targets := buildTargets(cfg)
Expand All @@ -126,19 +192,47 @@ func runAgent(parent context.Context) error {
}
updateTargets(targets, token)

ln, err := agent.Listen(socket)
if err != nil {
return err
}
entry := agent.Entry{
ID: id,
PID: os.Getpid(),
Socket: socket,
ConfigPath: configPath,
EnvFile: envKey,
LogFile: os.Getenv(logFileEnv),
StartedAt: time.Now(),
}
if err := agent.Register(entry); err != nil {
_ = ln.Close()
return err
}
defer func() {
if err := agent.Unregister(id); err != nil {
log.Warn().Err(err).Msg("Failed to unregister agent")
}
}()

refresher := agent.NewRefresher(client, token, func(t *oauth2.Token) { updateTargets(targets, t) })
info := agent.Info{
ConfigPath: cfgFile,
ID: id,
LogFile: entry.LogFile,
ConfigPath: configPath,
Issuer: cfg.OIDC.IssuerURL,
ClientID: cfg.OIDC.ClientID,
}
for _, t := range targets {
info.Targets = append(info.Targets, fmt.Sprintf("%s (%s → %s)", t.File, t.Type, t.Key))
if !slices.Contains(info.TargetFiles, t.File) {
info.TargetFiles = append(info.TargetFiles, t.File)
}
}

g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return refresher.Run(gctx) })
g.Go(func() error { return agent.Serve(gctx, socket, refresher, info, stop) })
g.Go(func() error { return agent.Serve(gctx, ln, refresher, info, stop) })
if err := g.Wait(); err != nil {
return err
}
Expand Down Expand Up @@ -198,7 +292,7 @@ func printStatus(cmd *cobra.Command, st *agent.Status) error {
line := func(label, format string, args ...any) {
fmt.Fprintf(&b, "%-14s"+format+"\n", append([]any{label + ":"}, args...)...)
}
line("Agent", "running (pid %d, since %s)", st.PID, st.StartedAt.Format(time.DateTime))
line("Agent", "%s, running (pid %d, since %s)", st.ID, st.PID, st.StartedAt.Format(time.DateTime))
line("Config", "%s", st.ConfigPath)
line("Issuer", "%s", st.Issuer)
line("Client", "%s", st.ClientID)
Expand All @@ -212,6 +306,9 @@ func printStatus(cmd *cobra.Command, st *agent.Status) error {
for _, t := range st.Targets {
line("Target", "%s", t)
}
if st.LogFile != "" {
line("Log file", "%s", st.LogFile)
}
_, err := io.WriteString(out, b.String())
return err
}
Expand All @@ -237,6 +334,8 @@ func init() {
for _, c := range []*cobra.Command{agentStatusCmd, agentRefreshCmd} {
c.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON")
}
agentStopCmd.Flags().BoolVar(&stopAll, "all", false, "Stop every running agent")
agentCmd.Flags().BoolVarP(&agentDetach, "detach", "d", false, "Run the agent in the background")
agentCmd.AddCommand(agentStatusCmd, agentRefreshCmd, agentStopCmd)
rootCmd.AddCommand(agentCmd)
}
Loading
Loading