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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,12 @@ CLIENT_ID=<uuid-from-server-logs>
CALLBACK_PORT=8888
SCOPE=read write
TOKEN_FILE=.authgate-tokens.json

# Caller-supplied extra JWT claims are attached via --extra-claims key=value
# (repeatable) or --extra-claims-file <path-to-env-file>. They have no
# environment variable equivalent; instead, point --extra-claims-file at a
# file like:
#
# project=acme-prod
# trace_id=req-42
# count=7
77 changes: 55 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,31 +250,35 @@ Configuration is resolved in priority order: **CLI flag → environment variable

### Environment variables

| Variable | Default | Description |
| --------------- | ----------------------- | ------------------------------------------------- |
| `SERVER_URL` | `http://localhost:8080` | AuthGate server base URL |
| `CLIENT_ID` | _(required)_ | OAuth client ID (UUID from server logs) |
| `CLIENT_SECRET` | _(empty)_ | Client secret — omit for public/PKCE clients |
| `CALLBACK_PORT` | `8888` | Local port for the redirect callback server |
| `REDIRECT_URI` | _(auto-computed)_ | Override computed redirect URI |
| `SCOPE` | `read write` | Space-separated OAuth scopes |
| `TOKEN_FILE` | `.authgate-tokens.json` | Path to the token cache file |
| `TOKEN_STORE` | `auto` | Storage backend: `auto`, `file`, or `keyring` |
| Variable | Default | Description |
| --------------- | ----------------------- | --------------------------------------------- |
| `SERVER_URL` | `http://localhost:8080` | AuthGate server base URL |
| `CLIENT_ID` | _(required)_ | OAuth client ID (UUID from server logs) |
| `CLIENT_SECRET` | _(empty)_ | Client secret — omit for public/PKCE clients |
| `CALLBACK_PORT` | `8888` | Local port for the redirect callback server |
| `REDIRECT_URI` | _(auto-computed)_ | Override computed redirect URI |
| `SCOPE` | `read write` | Space-separated OAuth scopes |
| `TOKEN_FILE` | `.authgate-tokens.json` | Path to the token cache file |
| `TOKEN_STORE` | `auto` | Storage backend: `auto`, `file`, or `keyring` |

> Extra JWT claims are configured via the `--extra-claims` / `--extra-claims-file` flags (see below). They have no environment-variable equivalent because each claim is its own key=value entry.

### CLI flags

| Flag | Env equivalent | Description |
| ----------------- | --------------- | --------------------------------------------- |
| `--server-url` | `SERVER_URL` | AuthGate server URL |
| `--client-id` | `CLIENT_ID` | OAuth client ID |
| `--client-secret` | `CLIENT_SECRET` | Client secret (confidential clients only) |
| `--redirect-uri` | `REDIRECT_URI` | Override computed redirect URI |
| `--port` | `CALLBACK_PORT` | Local callback port |
| `--scope` | `SCOPE` | OAuth scopes |
| `--token-file` | `TOKEN_FILE` | Token cache file path |
| `--token-store` | `TOKEN_STORE` | Storage backend: `auto`, `file`, or `keyring` |
| `--device` | — | Force Device Code Flow |
| `--version` | — | Print version and exit |
| Flag | Env equivalent | Description |
| --------------------- | --------------- | ------------------------------------------------------ |
| `--server-url` | `SERVER_URL` | AuthGate server URL |
| `--client-id` | `CLIENT_ID` | OAuth client ID |
| `--client-secret` | `CLIENT_SECRET` | Client secret (confidential clients only) |
| `--redirect-uri` | `REDIRECT_URI` | Override computed redirect URI |
| `--port` | `CALLBACK_PORT` | Local callback port |
| `--scope` | `SCOPE` | OAuth scopes |
| `--token-file` | `TOKEN_FILE` | Token cache file path |
| `--token-store` | `TOKEN_STORE` | Storage backend: `auto`, `file`, or `keyring` |
| `--device` | — | Force Device Code Flow |
| `--extra-claims` | — | Caller-supplied JWT claim as `key=value` (repeatable) |
| `--extra-claims-file` | — | Path to a `.env`-style file (one `key=value` per line) |
| `--version` | — | Print version and exit |

### Usage examples

Expand All @@ -293,10 +297,39 @@ Configuration is resolved in priority order: **CLI flag → environment variable

# Inspect what the server knows about the stored access token
./bin/authgate-cli token inspect

# Attach caller-supplied JWT claims (sent on every token grant + refresh)
./bin/authgate-cli \
--extra-claims project=acme-prod \
--extra-claims trace_id=req-42 \
--extra-claims count=7

# Or load them from a .env-style file (file values are merged first; flag values override)
./bin/authgate-cli --extra-claims-file ./claims.env
```

---

## Caller-supplied extra JWT claims

For workflows where one CLI binary needs to attach per-account or per-request context (project code, trace ID, routing hints, …) to the issued JWT, pass them with `--extra-claims key=value` (repeatable) or load them from a `.env`-style file via `--extra-claims-file`.

```bash
# claims.env
project=acme-prod
code_partition=ap-northeast-1
trace_id=req-42
count=7 # parses as JSON number
enabled=true # parses as JSON boolean
tags=["a","b"] # parses as JSON array
```

Values are inferred as JSON when they parse (numbers, booleans, arrays, objects, quoted strings, `null`); everything else is treated as a plain string. The CLI sends the merged map as the `extra_claims` form parameter on **every** token request — authorization code, device code, and refresh — so the claims survive a refresh without you having to re-invoke the flag (per-process, not persisted to disk).

> The server enforces reserved-key rejection (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `scope`, `client_id`, …), size limits, and admin-managed overrides. Caller-supplied claims are appropriate for trace IDs, request context, and routing hints, but **must not** be trusted by downstream resource servers for authorization decisions without independent verification. See the AuthGate server docs for the full trust model.

---

## Authentication Flows

### Authorization Code Flow with PKCE (browser)
Expand Down
4 changes: 4 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ func refreshAccessToken(
if !cfg.IsPublicClient() {
data.Set("client_secret", cfg.ClientSecret)
}
// Server doesn't persist extra_claims across refresh, so re-send them.
if cfg.ExtraClaims != "" {
data.Set(extraClaimsFormKey, cfg.ExtraClaims)
}

tokenResp, err := doTokenExchange(ctx, cfg, cfg.Endpoints.TokenURL, data,
func(errResp ErrorResponse, _ []byte) error {
Expand Down
3 changes: 3 additions & 0 deletions browser_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ func exchangeCode(
if !cfg.IsPublicClient() {
data.Set("client_secret", cfg.ClientSecret)
}
if cfg.ExtraClaims != "" {
data.Set(extraClaimsFormKey, cfg.ExtraClaims)
}

tokenResp, err := doTokenExchange(ctx, cfg, cfg.Endpoints.TokenURL, data, nil)
if err != nil {
Expand Down
134 changes: 134 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package main

import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -47,6 +51,9 @@ var (
flagDiscoveryTimeout string
flagRevocationTimeout string
flagMaxResponseBodySize string

flagExtraClaims []string
flagExtraClaimsFile string
)

const (
Expand All @@ -68,6 +75,14 @@ const (
// maxResponseBodySizeCap prevents users from setting an excessively large
// response body limit that could cause OOM via io.ReadAll.
maxResponseBodySizeCap int64 = 100 * 1024 * 1024 // 100 MB

// extraClaimsFormKey is the form parameter name defined by the AuthGate
// server's caller-supplied extra-claims feature.
extraClaimsFormKey = "extra_claims"

// maxExtraClaimsFileSize bounds godotenv reads so a malicious file can't
// OOM the CLI before the server's much smaller raw-payload limit fires.
maxExtraClaimsFileSize int64 = 64 * 1024 // 64 KiB
)

// AppConfig holds all resolved configuration for the CLI application.
Expand Down Expand Up @@ -98,6 +113,11 @@ type AppConfig struct {
DiscoveryTimeout time.Duration
RevocationTimeout time.Duration
MaxResponseBodySize int64

// ExtraClaims is a compact JSON object string sent verbatim as the
// `extra_claims` form parameter on every token request. Server validates
// reserved keys and size limits; CLI only re-encodes for normalization.
ExtraClaims string
}

// IsPublicClient returns true when no client secret is configured —
Expand Down Expand Up @@ -144,6 +164,10 @@ func registerFlags(cmd *cobra.Command) {
StringVar(&flagRevocationTimeout, "revocation-timeout", "", "Timeout for token revocation requests (e.g. 10s, 1m)")
cmd.PersistentFlags().
StringVar(&flagMaxResponseBodySize, "max-response-body-size", "", "Maximum response body size in bytes (e.g. 1048576)")
cmd.PersistentFlags().
StringArrayVar(&flagExtraClaims, "extra-claims", nil, "Caller-supplied JWT claim as key=value (repeatable; values that parse as JSON keep their type, e.g. count=42, enabled=true, tags=[\"a\"])")
cmd.PersistentFlags().
StringVar(&flagExtraClaimsFile, "extra-claims-file", "", "Path to a .env-style file (key=value lines) supplying extra_claims; merged before --extra-claims so flags override file entries")
}

// loadStoreConfig initialises only the token store and client ID — the minimum
Expand Down Expand Up @@ -279,9 +303,119 @@ func loadConfig() *AppConfig {
}
}

extra, err := resolveExtraClaims(flagExtraClaims, flagExtraClaimsFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid extra_claims: %v\n", err)
os.Exit(1)
}
cfg.ExtraClaims = extra

return cfg
}

// resolveExtraClaims merges caller-supplied JWT claims from the flag and the
// optional .env-style file, then returns a compact JSON object string ready
// to send as the `extra_claims` form parameter. File entries are applied
// first; flag entries override on conflicting keys. Returns "" when the
// merged map is empty so the caller can omit the parameter entirely.
//
// Validation is intentionally minimal — the server enforces reserved keys
// and size limits and returns descriptive `invalid_request` errors which
// the CLI surfaces as-is.
func resolveExtraClaims(flagPairs []string, filePath string) (string, error) {
merged := map[string]any{}

if filePath != "" {
fileClaims, err := loadExtraClaimsFile(filePath)
if err != nil {
return "", fmt.Errorf("--extra-claims-file %q: %w", filePath, err)
}
maps.Copy(merged, fileClaims)
}

// Error messages reference the pair by index rather than echoing the raw
// pair, so a value the user accidentally typed (e.g. a secret) doesn't
// land in stderr or CI logs.
for i, pair := range flagPairs {
k, v, err := parseExtraClaimPair(pair)
if err != nil {
return "", fmt.Errorf("--extra-claims #%d: %w", i+1, err)
}
merged[k] = v
}

if len(merged) == 0 {
return "", nil
}

encoded, err := json.Marshal(merged)
if err != nil {
return "", fmt.Errorf("encode %s: %w", extraClaimsFormKey, err)
}
return string(encoded), nil
}

func loadExtraClaimsFile(path string) (map[string]any, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()

// Read limit+1 so an oversized file is detected explicitly rather than
// truncated silently by io.LimitReader (which would partially apply
// claims with no error).
data, err := io.ReadAll(io.LimitReader(f, maxExtraClaimsFileSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxExtraClaimsFileSize {
return nil, fmt.Errorf("file too large: limit is %d bytes", maxExtraClaimsFileSize)
}

parsed, err := godotenv.Parse(bytes.NewReader(data))
if err != nil {
return nil, err
}
out := make(map[string]any, len(parsed))
for k, v := range parsed {
out[k] = parseClaimValue(v)
}
return out, nil
}

func parseExtraClaimPair(pair string) (string, any, error) {
idx := strings.IndexByte(pair, '=')
if idx <= 0 {
return "", nil, errors.New("must be key=value with a non-empty key")
}
return pair[:idx], parseClaimValue(pair[idx+1:]), nil
}

// parseClaimValue tries to decode raw as JSON so users can write count=42
// or tags=["a","b"] without thinking in JSON terms, falling back to a plain
// string. UseNumber preserves integer claims exactly (mirrors token_cmd.go's
// JWT decoder) — without it, IDs above 2^53 silently round. Inputs with
// trailing non-whitespace data fall back to the raw string so e.g.
// "42 not-a-number" stays a string instead of decoding as 42.
func parseClaimValue(raw string) any {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
dec := json.NewDecoder(strings.NewReader(trimmed))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return raw
}
Comment thread
appleboy marked this conversation as resolved.
var sink any
if err := dec.Decode(&sink); !errors.Is(err, io.EOF) {
return raw
}
return v
}

// newTokenStore creates a token store backend based on the given mode.
func newTokenStore(
mode, tokenFilePath, keyringService string,
Expand Down
3 changes: 3 additions & 0 deletions device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ func exchangeDeviceCode(
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
data.Set("device_code", deviceCode)
data.Set("client_id", cID)
if cfg.ExtraClaims != "" {
data.Set(extraClaimsFormKey, cfg.ExtraClaims)
}

resp, err := cfg.RetryClient.Post(reqCtx, tokenURL,
retry.WithBody("application/x-www-form-urlencoded", strings.NewReader(data.Encode())),
Expand Down
Loading
Loading