Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7d2adb5
Add auth/ library: device flow + tokens + token storage
khaong May 6, 2026
cd0648a
Wire cmd/entire/cli/auth shims to use the shared auth/ library
khaong May 6, 2026
a94bf89
Wire ENTIRE_AUTH_PROVIDER_VERSION switch to select v1 or v2
khaong May 6, 2026
6624e25
Add auth/sts: RFC 8693 OAuth 2.0 Token Exchange client
khaong May 6, 2026
9e9bc3f
auth: surface friendly error when OAuth response is HTML, not JSON
khaong May 7, 2026
0f05c15
auth/deviceflow: surface error_description from RFC 8628 §3.5 errors
khaong May 7, 2026
bb13cbd
api/auth_tokens: route to /api/v1/auth/tokens or /api/auth/tokens by …
khaong May 7, 2026
d5737db
auth: clear lint findings (errcheck, gosec G101/G117, unparam, goconst)
khaong May 7, 2026
c492a54
auth: split-host config + RFC 8693 token exchange (auth/tokenmanager)
khaong May 7, 2026
d9322bc
auth: route STS to provider.stsPath; make STSPath optional in tokenma…
khaong May 8, 2026
ead027c
search: route bearer through auth.TokenForResource
khaong May 8, 2026
16746fd
auth: fix legacy keyring fallback + cover gaps surfaced by review
khaong May 8, 2026
5173d30
auth: round-2 review fixes (DeleteCoreToken order, coverage, deprecat…
khaong May 8, 2026
f33b79d
Fix token exchange resource routing
khaong May 8, 2026
d8ccd26
Make auth tests independent of provider env
khaong May 8, 2026
a9aeb9e
dispatch: route bearer through tokenmanager + document the auth pattern
khaong May 8, 2026
b410d50
auth: PR review fixes (PollDeviceAuth retry, doc accuracy)
khaong May 8, 2026
9c2b070
auth: PR review fixes (parallel-safe clock pin, struct cache key)
khaong May 8, 2026
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
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,32 @@ if settings.IsSummarizeEnabled() {
- `settings/settings.go` - `EntireSettings` struct, `Load()`, and helper methods
- `config.go` - Higher-level config functions that use settings (for `cli` package consumers)

### Auth and token resolution

The CLI uses a shareable auth library at `auth/` (subpackages: `deviceflow`, `sts`, `tokens`, `tokenstore`, `tokenmanager`). The `cmd/entire/cli/auth/` package wraps it with entire-specific config (provider table, keyring service name) and exposes the call surface that command code should use.

**For every data-API call, get the bearer through one of these two entry points — never read the keyring directly:**

```go
// Preferred — for callers that need an *api.Client.
client, err := cli.NewAuthenticatedAPIClient(ctx, insecureHTTP)

// Direct — for callers that hand the bearer to a non-api package
// (e.g. the search service client, dispatch CloudClient).
bearer, err := auth.TokenForResource(ctx, serviceURL)
```

Both route through `tokenmanager.Manager.Token`, which:

1. Returns `auth.ErrNotLoggedIn` when the keyring is empty.
2. Hits the same-host shortcut when `api.AuthBaseURL() == resourceURL`.
3. Hits the JWT-`aud`-includes-resource shortcut when the core token is already valid for `resourceURL` (caller didn't request an explicit `Audience`).
4. Otherwise runs an RFC 8693 token exchange against the auth host's STS endpoint and caches the result per `(core token, resource, audience, requested-token-type, scope)`.

**Don't use `auth.LookupCurrentToken` for data-API calls.** It returns the raw core token (audience = auth host). On split-host deployments (`ENTIRE_AUTH_BASE_URL` set) the data API will reject it with 401. `LookupCurrentToken` is correct only for auth-host-targeted commands (`auth list/revoke/status`, `logout`) — they intentionally hold the auth-audience bearer.

**Test injection:** at the cmd layer use `auth.SetManagerForTest(t, mgr)` with a `tokenmanager.Manager` constructed via `tokenmanager.New(Config{Exchange: ...})`. The manager's `Config.Exchange` and `Config.Now` fields are test seams — production callers leave them nil.

### Logging vs User Output

- **Internal/debug logging**: Use `logging.Debug/Info/Warn/Error(ctx, msg, attrs...)` from `cmd/entire/cli/logging/`. Writes to `.entire/logs/`.
Expand Down
120 changes: 120 additions & 0 deletions auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# auth — shareable OAuth 2.0 client library for internal CLIs

Provider-agnostic Go library for CLIs that authenticate end-users via OAuth 2.0 device flow (RFC 8628), present resource-scoped bearer tokens to data APIs, and (when the auth host and data API live on different origins) exchange tokens via RFC 8693 STS.

The library has no global state, no env-var reads, and no implicit URLs. Every endpoint, identifier, and default value is supplied by the embedding CLI through a `Config` struct. That keeps it usable by any CLI in the org without forking.

## Subpackages

| Package | What it does |
|---|---|
| [`deviceflow`](./deviceflow/) | RFC 8628 OAuth 2.0 Device Authorization Grant client. Polls the token endpoint, surfaces RFC 8628 §3.5 error codes (`authorization_pending`, `slow_down`, `access_denied`, `expired_token`, `invalid_grant`) as Go sentinels with optional `error_description`. |
| [`sts`](./sts/) | RFC 8693 OAuth 2.0 Token Exchange client. Provider-agnostic — caller supplies endpoint path, `subject_token_type`, `requested_token_type`, optional `audience` / `resource` / `scope`, and any provider-specific `Extra` form fields (e.g. `client_id`). |
| [`tokens`](./tokens/) | `TokenSet` value type plus unverified JWT claim parsing. The package never validates signatures — that's the issuing server's responsibility. CLIs use `Claims` for routing decisions (which issuer, which audience) and UX (display the principal handle), not as a security boundary. |
| [`tokenstore`](./tokenstore/) | `Store` interface for token persistence + `Keyring` reference impl backed by `github.com/zalando/go-keyring`. Each CLI passes its own service name so credentials are isolated across CLIs sharing this library. Returns `ErrNotFound` for unknown profiles and `ErrMalformed` (wrapped) when a stored entry exists but can't be decoded — used by upgrade fallbacks. |
| [`tokenmanager`](./tokenmanager/) | Orchestration: stores the device-flow core token, runs RFC 8693 exchanges when needed to obtain resource-scoped bearers, caches the results until expiry, and short-circuits when no exchange is needed (same-host or core-token's `aud` already covers the resource). Most CLIs only need to interact with this package directly. |

Internal helper:

| Package | What it does |
|---|---|
| [`internal/oauthhttp`](./internal/oauthhttp/) | Shared HTTP body-reading + JSON-decoding helpers. Detects HTML responses (captive portal / proxy intercept) and surfaces them as actionable errors instead of unmarshal failures. Not exported. |

## Quick start

The typical embedding CLI does roughly this at startup:

```go
import (
"github.com/entireio/cli/auth/deviceflow"
"github.com/entireio/cli/auth/tokenmanager"
"github.com/entireio/cli/auth/tokenstore"
)

const (
issuer = "https://auth.example.com" // auth host base URL
clientID = "my-cli" // public OAuth client_id
)

store := tokenstore.NewKeyring("my-cli") // service name = your CLI's name

// One Manager per CLI process. Construct from your CLI's identity.
mgr, err := tokenmanager.New(tokenmanager.Config{
Issuer: issuer,
ClientID: clientID,
STSPath: "/oauth/token", // RFC 8693 endpoint; usually the OAuth token endpoint
Store: store,
Scope: "cli",
})
if err != nil { /* misconfiguration */ }
```

### Login

```go
dfc := &deviceflow.Client{
BaseURL: issuer,
ClientID: clientID,
Scope: "cli",
DeviceCodePath: "/oauth/device/code",
TokenPath: "/oauth/token",
}

dc, err := dfc.StartDeviceAuth(ctx)
// ... show dc.UserCode + dc.VerificationURI to user, then poll ...
ts, err := dfc.PollDeviceAuth(ctx, dc.DeviceCode)
if err != nil { /* surface RFC 8628 §3.5 sentinel as needed */ }

if err := mgr.SaveCoreToken(ts.AccessToken); err != nil { /* keyring failed */ }
```

### Calling a data API

```go
bearer, err := mgr.TokenForResource(ctx, "https://api.example.com")
if errors.Is(err, tokenmanager.ErrNotLoggedIn) {
// prompt user to run `mycli login`
}
// bearer is valid for https://api.example.com
req.Header.Set("Authorization", "Bearer "+bearer)
```

The manager picks the right strategy automatically:

- Same-host (`Issuer == resource`): hands back the core token verbatim.
- JWT-`aud`-includes shortcut: same, when the core token's audience already covers the resource (e.g. multi-audience tokens).
- Otherwise: runs an RFC 8693 exchange against `Issuer + STSPath`, caches the exchanged token by `(core, resource, audience, requested_token_type, scope)` until expiry.

### Logout

```go
if err := mgr.DeleteCoreToken(); err != nil { /* keyring failed */ }
```

Deletes the keyring entry first; only clears the in-memory exchange cache on success, so a failed delete doesn't leave the CLI thinking it's logged out while the keyring still holds the token.

## Design principles

- **No globals, no env-var reads, no implicit URLs.** Everything ships through `Config`. The library should compile and run identically inside any CLI.
- **Provider-agnostic.** `deviceflow.Client` and `sts.Client` are field-bag structs; neither knows about your provider's endpoint paths or token-type URIs. Pass them in.
- **Bearer-presenter, not bearer-validator.** This library is for CLIs that *receive* tokens from an auth server and *present* them to a resource server. JWT signature verification is intentionally not done — the resource server validates. `tokens.ParseClaims` is documented as unverified and used only for routing decisions.
- **Per-CLI keyring isolation.** Each CLI passes a unique service name to `tokenstore.NewKeyring`. OS keyrings key by `(service, account)`, so different CLIs naturally get separate credential stores.
- **Caller controls the wire shape.** Default values (RFC 8693 `requested_token_type`, `scope`, audience-empty) live in the embedding CLI's wiring, not in this library.

## Embedding checklist for a new CLI

1. Pick a stable service name for `tokenstore.NewKeyring(...)`. **Don't change it later** — renaming orphans every existing user's stored credentials.
2. Pick a `client_id` that the auth server recognises.
3. Decide your `STSPath`: typically the OAuth token endpoint per RFC 8693 convention, or a dedicated path if your auth server exposes one.
4. Construct the `tokenmanager.Manager` once at startup; pass it to your data-API call sites.
5. For multi-environment users (regions, staging), key the keyring by issuer URL — `Manager.Issuer()` returns the configured value.

## Non-goals

- **OIDC discovery / ID tokens.** This library is OAuth 2.0 only. If you need OIDC `/.well-known/openid-configuration` + ID-token verification, layer `coreos/go-oidc` on top.
- **PKCE / authorization code flow.** Device flow only; CLIs almost never need code flow.
- **Server-side OIDC.** If you're building an *issuer*, look at `zitadel/oidc`'s `op` package.

## Status

Used in production by [`entireio/cli`](https://github.com/entireio/cli). Open to additional internal CLI consumers — file an issue if you hit a gap.
Loading
Loading