diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..74453b2 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: make build + + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Verify + run: make verify + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test + run: make test + + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Image + run: make image IMAGE_REPOSITORY=token-refresher-ci VERSION=${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c3610c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.cache/ +/bin/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c970391 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.26.0-bookworm AS builder + +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/token-refresher ./cmd/token-refresher + +FROM node:22-bookworm-slim + +ARG CODEX_VERSION=0.118.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && npm install -g @openai/codex@${CODEX_VERSION} \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/token-refresher /usr/local/bin/token-refresher + +USER node + +ENTRYPOINT ["/usr/local/bin/token-refresher"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cec4a0f --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +REGISTRY ?= ghcr.io/kelos-dev +IMAGE_NAME ?= token-refresher +IMAGE_REPOSITORY ?= $(REGISTRY)/$(IMAGE_NAME) +VERSION ?= latest + +BIN_DIR ?= bin +BIN ?= $(BIN_DIR)/token-refresher +GOCACHE ?= $(CURDIR)/.cache/go-build +GOMODCACHE ?= $(CURDIR)/.cache/go-mod +GO_SOURCES := $(shell find cmd internal -name '*.go' -type f 2>/dev/null) +BUILD_DIRS := $(BIN_DIR) $(GOCACHE) $(GOMODCACHE) + +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +.PHONY: all +all: build + +.PHONY: help +help: ## Display available targets. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-12s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +$(BUILD_DIRS): + mkdir -p $@ + +.PHONY: build +build: | $(BUILD_DIRS) ## Build the token-refresher binary. + CGO_ENABLED=0 GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go build -o $(BIN) ./cmd/token-refresher + +.PHONY: test +test: | $(BUILD_DIRS) ## Run unit tests. + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./... + +.PHONY: verify +verify: | $(BUILD_DIRS) ## Verify formatting, tests, and vet checks. + @unformatted="$$(gofmt -l $(GO_SOURCES))"; \ + if [[ -n "$$unformatted" ]]; then \ + echo "unformatted Go files:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./... + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go vet ./... + +.PHONY: update +update: | $(BUILD_DIRS) ## Format Go code and tidy modules. + gofmt -w $(GO_SOURCES) + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go mod tidy + +.PHONY: image +image: ## Build the container image. + docker build -t $(IMAGE_REPOSITORY):$(VERSION) . + +.PHONY: clean +clean: ## Remove local build artifacts. + rm -rf $(BIN_DIR) .cache diff --git a/README.md b/README.md index c752910..472c360 100644 --- a/README.md +++ b/README.md @@ -1 +1,89 @@ -# token-refresher \ No newline at end of file +# token-refresher + +`token-refresher` is a small Go job that keeps a coding agent auth blob in sync inside Kubernetes. + +The first provider is `codex`: + +- reads `auth.json` from a Kubernetes `Secret` +- copies it into an isolated temporary `HOME` +- runs a lightweight `codex exec` to trigger validation or refresh +- reads the resulting `auth.json` +- updates the Kubernetes `Secret` only if the file changed + +The runtime path is isolated on purpose. The refresher never mutates any existing host-side `~/.codex/auth.json` directly. + +## Why this shape + +Codex stores auth state in `~/.codex/auth.json`. For a Kubernetes `CronJob`, the cleanest flow is: + +1. fetch the current `auth.json` from a secret +2. materialize it in a temp home directory +3. let `codex` operate against that copied state +4. write the updated file back to the secret if it changed + +That makes the implementation safe for CronJobs and leaves room to add other agent providers later. + +## Current defaults + +- `CronJob` schedule: every 12 hours +- refresh behavior: every run performs a lightweight `codex exec` +- Kubernetes secret key: `auth.json` + +## Configuration + +Runtime configuration is intentionally small and uses flags instead of environment variables: + +- `--agent-provider`: provider name. Currently `codex` only. +- `--secret-name`: name of the Kubernetes secret to read and update. + +Everything else is fixed in code: + +- namespace: inferred from the in-cluster service account namespace +- secret key: `auth.json` +- Codex command: `codex` +- refresh behavior: run on every scheduled execution +- timeout: `10m` + +## Build + +```bash +make build +make verify +make test +make image IMAGE_REPOSITORY=ghcr.io/kelos-dev/token-refresher VERSION=latest +``` + +The container image includes: + +- the Go refresher binary +- Node.js +- `@openai/codex` + +`make update` formats Go files and runs `go mod tidy`. + +## Kubernetes setup + +Create the starting secret from an existing local Codex auth file: + +```bash +kubectl -n your-namespace create secret generic codex-auth \ + --from-file=auth.json=$HOME/.codex/auth.json +``` + +Apply RBAC and the `CronJob`: + +```bash +kubectl -n your-namespace apply -f deploy/kubernetes/rbac.yaml +kubectl -n your-namespace apply -f deploy/kubernetes/cronjob.yaml +``` + +Before applying the `CronJob`, edit `deploy/kubernetes/cronjob.yaml` and set: + +- `image` +- `--secret-name` if you use a different secret name + +## Notes + +- The job uses the in-cluster service account token and Kubernetes API directly. No `kubectl` dependency is required. +- The process updates the secret only when `auth.json` changes. +- The current implementation is deliberately provider-oriented so other agent auth formats can be added behind the same interface. diff --git a/cmd/token-refresher/main.go b/cmd/token-refresher/main.go new file mode 100644 index 0000000..b4b30c3 --- /dev/null +++ b/cmd/token-refresher/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/kelos-dev/token-refresher/internal/config" + "github.com/kelos-dev/token-refresher/internal/kube" + "github.com/kelos-dev/token-refresher/internal/providers" +) + +func main() { + log.SetFlags(0) + + if err := run(); err != nil { + log.Fatalf("token-refresher: %v", err) + } +} + +func run() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := config.Load(os.Args[1:]) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + client, err := kube.NewInClusterClient() + if err != nil { + return fmt.Errorf("create kubernetes client: %w", err) + } + + provider, err := providers.New(cfg) + if err != nil { + return fmt.Errorf("load provider: %w", err) + } + + secret, authJSON, err := client.GetSecretKey(ctx, cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey) + if err != nil { + return fmt.Errorf("get secret %s/%s[%s]: %w", cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey, err) + } + + log.Printf( + "provider=%s loaded secret=%s/%s key=%s bytes=%d", + provider.Name(), + cfg.SecretNamespace, + cfg.SecretName, + cfg.SecretKey, + len(authJSON), + ) + + result, err := provider.Refresh(ctx, authJSON) + if err != nil { + return fmt.Errorf("refresh auth data: %w", err) + } + + if result.ExpiresAt != nil { + log.Printf( + "provider=%s attempted_refresh=%t changed=%t expires_at=%s reason=%s", + provider.Name(), + result.AttemptedRefresh, + result.Changed, + result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"), + result.Reason, + ) + } else { + log.Printf( + "provider=%s attempted_refresh=%t changed=%t reason=%s", + provider.Name(), + result.AttemptedRefresh, + result.Changed, + result.Reason, + ) + } + + if !result.Changed { + log.Printf("provider=%s no secret update required", provider.Name()) + return nil + } + + if err := client.UpdateSecretKey(ctx, &secret, cfg.SecretKey, result.UpdatedAuth); err != nil { + return fmt.Errorf("update secret %s/%s[%s]: %w", cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey, err) + } + + log.Printf( + "provider=%s updated secret=%s/%s key=%s bytes=%d", + provider.Name(), + cfg.SecretNamespace, + cfg.SecretName, + cfg.SecretKey, + len(result.UpdatedAuth), + ) + + return nil +} diff --git a/deploy/kubernetes/cronjob.yaml b/deploy/kubernetes/cronjob.yaml new file mode 100644 index 0000000..767aa6d --- /dev/null +++ b/deploy/kubernetes/cronjob.yaml @@ -0,0 +1,29 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: token-refresher +spec: + schedule: "0 */12 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + spec: + serviceAccountName: token-refresher + restartPolicy: Never + containers: + - name: token-refresher + image: ghcr.io/kelos-dev/token-refresher:latest + imagePullPolicy: IfNotPresent + args: + - --agent-provider=codex + - --secret-name=codex-auth + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/deploy/kubernetes/rbac.yaml b/deploy/kubernetes/rbac.yaml new file mode 100644 index 0000000..0de9886 --- /dev/null +++ b/deploy/kubernetes/rbac.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: token-refresher +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: token-refresher +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: token-refresher +subjects: + - kind: ServiceAccount + name: token-refresher +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: token-refresher diff --git a/deploy/kubernetes/secret.example.yaml b/deploy/kubernetes/secret.example.yaml new file mode 100644 index 0000000..f5a1ce0 --- /dev/null +++ b/deploy/kubernetes/secret.example.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: codex-auth +type: Opaque +stringData: + auth.json: | + {} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6e1a203 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/kelos-dev/token-refresher + +go 1.26.0 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..836c69a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "flag" + "fmt" + "os" + "strings" + "time" +) + +const serviceAccountNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + +const ( + ProviderCodex = "codex" +) + +type Config struct { + Provider string + SecretNamespace string + SecretName string + SecretKey string + Codex CodexConfig +} + +type CodexConfig struct { + Command string + Prompt string + Timeout time.Duration + RefreshPolicy string + RefreshWindow time.Duration + AuthSubdir string + AuthFileName string +} + +func Load(args []string) (Config, error) { + cfg := Config{ + Provider: ProviderCodex, + SecretNamespace: readNamespaceFile(), + SecretKey: "auth.json", + Codex: CodexConfig{ + Command: "codex", + Prompt: "Reply with the single word ok.", + Timeout: 10 * time.Minute, + RefreshPolicy: "always", + RefreshWindow: 72 * time.Hour, + AuthSubdir: ".codex", + AuthFileName: "auth.json", + }, + } + + fs := flag.NewFlagSet("token-refresher", flag.ContinueOnError) + fs.StringVar(&cfg.Provider, "agent-provider", cfg.Provider, "agent provider to refresh") + fs.StringVar(&cfg.SecretName, "secret-name", "", "name of the Kubernetes secret containing auth.json") + if err := fs.Parse(args); err != nil { + return Config{}, err + } + + cfg.Provider = strings.ToLower(strings.TrimSpace(cfg.Provider)) + cfg.SecretName = strings.TrimSpace(cfg.SecretName) + + if cfg.SecretNamespace == "" { + return Config{}, fmt.Errorf("failed to determine in-cluster namespace from service account") + } + + if cfg.SecretName == "" { + return Config{}, fmt.Errorf("--secret-name must be set") + } + + switch cfg.Provider { + case ProviderCodex: + default: + return Config{}, fmt.Errorf("unsupported AGENT_PROVIDER %q", cfg.Provider) + } + + switch cfg.Codex.RefreshPolicy { + case "always", "threshold": + default: + return Config{}, fmt.Errorf("unsupported CODEX_REFRESH_POLICY %q", cfg.Codex.RefreshPolicy) + } + + if cfg.Codex.Timeout <= 0 { + return Config{}, fmt.Errorf("CODEX_REFRESH_TIMEOUT must be greater than zero") + } + + if cfg.Codex.RefreshWindow < 0 { + return Config{}, fmt.Errorf("CODEX_REFRESH_WINDOW cannot be negative") + } + + return cfg, nil +} + +func readNamespaceFile() string { + data, err := os.ReadFile(serviceAccountNamespacePath) + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) +} diff --git a/internal/kube/client.go b/internal/kube/client.go new file mode 100644 index 0000000..1546ed6 --- /dev/null +++ b/internal/kube/client.go @@ -0,0 +1,186 @@ +package kube + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const ( + serviceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + serviceAccountCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +) + +type Client struct { + baseURL *url.URL + token string + httpClient *http.Client +} + +type Secret struct { + APIVersion string `json:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty"` + Metadata SecretMetadata `json:"metadata"` + Type string `json:"type,omitempty"` + Immutable *bool `json:"immutable,omitempty"` + Data map[string]string `json:"data,omitempty"` +} + +type SecretMetadata struct { + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + ResourceVersion string `json:"resourceVersion,omitempty"` +} + +type apiStatus struct { + Message string `json:"message"` + Reason string `json:"reason"` + Code int `json:"code"` +} + +func NewInClusterClient() (*Client, error) { + host := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST")) + port := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT")) + if host == "" || port == "" { + return nil, fmt.Errorf("KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT must be set") + } + + tokenBytes, err := os.ReadFile(serviceAccountTokenPath) + if err != nil { + return nil, fmt.Errorf("read service account token: %w", err) + } + + caBytes, err := os.ReadFile(serviceAccountCAPath) + if err != nil { + return nil, fmt.Errorf("read service account CA: %w", err) + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caBytes) { + return nil, fmt.Errorf("parse service account CA") + } + + baseURL, err := url.Parse(fmt.Sprintf("https://%s:%s", host, port)) + if err != nil { + return nil, fmt.Errorf("parse kubernetes URL: %w", err) + } + + return &Client{ + baseURL: baseURL, + token: strings.TrimSpace(string(tokenBytes)), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + }, + }, + }, + }, nil +} + +func (c *Client) GetSecretKey(ctx context.Context, namespace, name, key string) (Secret, []byte, error) { + secret, err := c.GetSecret(ctx, namespace, name) + if err != nil { + return Secret{}, nil, err + } + + encoded, ok := secret.Data[key] + if !ok { + return Secret{}, nil, fmt.Errorf("secret key %q not found", key) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return Secret{}, nil, fmt.Errorf("decode secret key %q: %w", key, err) + } + + return secret, decoded, nil +} + +func (c *Client) UpdateSecretKey(ctx context.Context, secret *Secret, key string, value []byte) error { + if secret.Data == nil { + secret.Data = map[string]string{} + } + + secret.Data[key] = base64.StdEncoding.EncodeToString(value) + return c.UpdateSecret(ctx, *secret) +} + +func (c *Client) GetSecret(ctx context.Context, namespace, name string) (Secret, error) { + path := fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", namespace, name) + + var secret Secret + if err := c.doJSON(ctx, http.MethodGet, path, nil, &secret); err != nil { + return Secret{}, err + } + + return secret, nil +} + +func (c *Client) UpdateSecret(ctx context.Context, secret Secret) error { + path := fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", secret.Metadata.Namespace, secret.Metadata.Name) + return c.doJSON(ctx, http.MethodPut, path, secret, nil) +} + +func (c *Client) doJSON(ctx context.Context, method, path string, requestBody any, responseBody any) error { + var body io.Reader + if requestBody != nil { + payload, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + body = bytes.NewReader(payload) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(&url.URL{Path: path}).String(), body) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("perform request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var status apiStatus + if err := json.Unmarshal(respBytes, &status); err == nil && status.Message != "" { + return fmt.Errorf("kubernetes API %s: %s", resp.Status, status.Message) + } + return fmt.Errorf("kubernetes API %s: %s", resp.Status, strings.TrimSpace(string(respBytes))) + } + + if responseBody == nil || len(respBytes) == 0 { + return nil + } + + if err := json.Unmarshal(respBytes, responseBody); err != nil { + return fmt.Errorf("decode response: %w", err) + } + + return nil +} diff --git a/internal/providerapi/providerapi.go b/internal/providerapi/providerapi.go new file mode 100644 index 0000000..b98a3cd --- /dev/null +++ b/internal/providerapi/providerapi.go @@ -0,0 +1,19 @@ +package providerapi + +import ( + "context" + "time" +) + +type Provider interface { + Name() string + Refresh(ctx context.Context, authJSON []byte) (Result, error) +} + +type Result struct { + UpdatedAuth []byte + Changed bool + AttemptedRefresh bool + ExpiresAt *time.Time + Reason string +} diff --git a/internal/providers/codex/codex.go b/internal/providers/codex/codex.go new file mode 100644 index 0000000..d9f41e4 --- /dev/null +++ b/internal/providers/codex/codex.go @@ -0,0 +1,195 @@ +package codex + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/kelos-dev/token-refresher/internal/config" + "github.com/kelos-dev/token-refresher/internal/providerapi" +) + +type Provider struct { + cfg config.CodexConfig + runner runner +} + +type runner func(ctx context.Context, homeDir string, cfg config.CodexConfig) error + +type authPayload struct { + Tokens struct { + AccessToken string `json:"access_token"` + } `json:"tokens"` +} + +type jwtClaims struct { + Exp int64 `json:"exp"` +} + +func New(cfg config.CodexConfig) providerapi.Provider { + return &Provider{ + cfg: cfg, + runner: runCodexExec, + } +} + +func (p *Provider) Name() string { + return "codex" +} + +func (p *Provider) Refresh(ctx context.Context, authJSON []byte) (providerapi.Result, error) { + expiresAt, expiryErr := accessTokenExpiry(authJSON) + if p.cfg.RefreshPolicy == "threshold" && expiryErr == nil && time.Until(*expiresAt) > p.cfg.RefreshWindow { + return providerapi.Result{ + UpdatedAuth: authJSON, + Changed: false, + AttemptedRefresh: false, + ExpiresAt: expiresAt, + Reason: fmt.Sprintf( + "access token remains valid until %s; refresh threshold is %s", + expiresAt.UTC().Format(time.RFC3339), + p.cfg.RefreshWindow, + ), + }, nil + } + + tempHome, err := os.MkdirTemp("", "token-refresher-codex-*") + if err != nil { + return providerapi.Result{}, fmt.Errorf("create temp home: %w", err) + } + defer os.RemoveAll(tempHome) + + authPath := filepath.Join(tempHome, p.cfg.AuthSubdir, p.cfg.AuthFileName) + if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil { + return providerapi.Result{}, fmt.Errorf("create auth directory: %w", err) + } + + if err := os.WriteFile(authPath, authJSON, 0o600); err != nil { + return providerapi.Result{}, fmt.Errorf("write auth file: %w", err) + } + + if err := p.runner(ctx, tempHome, p.cfg); err != nil { + return providerapi.Result{}, err + } + + updatedAuth, err := os.ReadFile(authPath) + if err != nil { + return providerapi.Result{}, fmt.Errorf("read refreshed auth file: %w", err) + } + + updatedExpiry, _ := accessTokenExpiry(updatedAuth) + changed := !bytes.Equal(authJSON, updatedAuth) + reason := "codex exec completed without changing auth.json" + if changed { + reason = "codex exec completed and updated auth.json" + } + if expiryErr != nil { + reason = reason + "; original access token expiry was unavailable" + } + + return providerapi.Result{ + UpdatedAuth: updatedAuth, + Changed: changed, + AttemptedRefresh: true, + ExpiresAt: updatedExpiry, + Reason: reason, + }, nil +} + +func runCodexExec(ctx context.Context, homeDir string, cfg config.CodexConfig) error { + cmdCtx, cancel := context.WithTimeout(ctx, cfg.Timeout) + defer cancel() + + cmd := exec.CommandContext( + cmdCtx, + cfg.Command, + "exec", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--color", + "never", + cfg.Prompt, + ) + cmd.Env = withEnv(os.Environ(), "HOME="+homeDir) + + output, err := cmd.CombinedOutput() + if err != nil { + if cmdCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf("codex exec timed out after %s", cfg.Timeout) + } + return fmt.Errorf("codex exec failed: %w: %s", err, trimOutput(output, 2048)) + } + + return nil +} + +func accessTokenExpiry(authJSON []byte) (*time.Time, error) { + var payload authPayload + if err := json.Unmarshal(authJSON, &payload); err != nil { + return nil, fmt.Errorf("decode auth.json: %w", err) + } + + token := payload.Tokens.AccessToken + if token == "" { + return nil, fmt.Errorf("access token missing") + } + + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("access token is not a JWT") + } + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode JWT payload: %w", err) + } + + var claims jwtClaims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return nil, fmt.Errorf("decode JWT claims: %w", err) + } + + if claims.Exp == 0 { + return nil, fmt.Errorf("JWT exp claim missing") + } + + expiresAt := time.Unix(claims.Exp, 0).UTC() + return &expiresAt, nil +} + +func withEnv(base []string, keyValue string) []string { + key, _, ok := strings.Cut(keyValue, "=") + if !ok { + return append(base, keyValue) + } + + prefix := key + "=" + filtered := make([]string, 0, len(base)+1) + for _, entry := range base { + if strings.HasPrefix(entry, prefix) { + continue + } + filtered = append(filtered, entry) + } + + return append(filtered, keyValue) +} + +func trimOutput(output []byte, limit int) string { + text := strings.TrimSpace(string(output)) + if text == "" { + return "" + } + if len(text) <= limit { + return text + } + return text[len(text)-limit:] +} diff --git a/internal/providers/codex/codex_test.go b/internal/providers/codex/codex_test.go new file mode 100644 index 0000000..66de619 --- /dev/null +++ b/internal/providers/codex/codex_test.go @@ -0,0 +1,123 @@ +package codex + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kelos-dev/token-refresher/internal/config" +) + +func TestRefreshSkipsWhenTokenIsOutsideThreshold(t *testing.T) { + t.Parallel() + + authJSON := authFixture(t, time.Now().Add(7*24*time.Hour)) + + provider := &Provider{ + cfg: config.CodexConfig{ + RefreshPolicy: "threshold", + RefreshWindow: 72 * time.Hour, + AuthSubdir: ".codex", + AuthFileName: "auth.json", + }, + runner: func(context.Context, string, config.CodexConfig) error { + t.Fatal("runner should not be called") + return nil + }, + } + + result, err := provider.Refresh(context.Background(), authJSON) + if err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + if result.AttemptedRefresh { + t.Fatalf("Refresh() attempted refresh = true, want false") + } + + if result.Changed { + t.Fatalf("Refresh() changed = true, want false") + } +} + +func TestRefreshUpdatesAuthWhenRunnerMutatesFile(t *testing.T) { + t.Parallel() + + original := authFixture(t, time.Now().Add(2*time.Hour)) + updated := authFixture(t, time.Now().Add(10*24*time.Hour)) + + provider := &Provider{ + cfg: config.CodexConfig{ + RefreshPolicy: "always", + RefreshWindow: 72 * time.Hour, + AuthSubdir: ".codex", + AuthFileName: "auth.json", + }, + runner: func(_ context.Context, homeDir string, cfg config.CodexConfig) error { + authPath := filepath.Join(homeDir, cfg.AuthSubdir, cfg.AuthFileName) + return osWriteFile(authPath, updated) + }, + } + + result, err := provider.Refresh(context.Background(), original) + if err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + if !result.AttemptedRefresh { + t.Fatalf("Refresh() attempted refresh = false, want true") + } + + if !result.Changed { + t.Fatalf("Refresh() changed = false, want true") + } + + if string(result.UpdatedAuth) != string(updated) { + t.Fatalf("Refresh() updated auth mismatch") + } +} + +func authFixture(t *testing.T, expiresAt time.Time) []byte { + t.Helper() + + header := map[string]string{"alg": "RS256", "typ": "JWT"} + claims := map[string]any{ + "exp": expiresAt.Unix(), + "iat": time.Now().Unix(), + } + + headerBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + + claimsBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + + token := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + + base64.RawURLEncoding.EncodeToString(claimsBytes) + ".signature" + + payload := map[string]any{ + "auth_mode": "chatgpt", + "tokens": map[string]any{ + "access_token": token, + }, + } + + authJSON, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal auth.json: %v", err) + } + + return authJSON +} + +func osWriteFile(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/providers/provider.go b/internal/providers/provider.go new file mode 100644 index 0000000..8c9f5f6 --- /dev/null +++ b/internal/providers/provider.go @@ -0,0 +1,18 @@ +package providers + +import ( + "fmt" + + "github.com/kelos-dev/token-refresher/internal/config" + "github.com/kelos-dev/token-refresher/internal/providerapi" + "github.com/kelos-dev/token-refresher/internal/providers/codex" +) + +func New(cfg config.Config) (providerapi.Provider, error) { + switch cfg.Provider { + case config.ProviderCodex: + return codex.New(cfg.Codex), nil + default: + return nil, fmt.Errorf("unsupported provider %q", cfg.Provider) + } +}