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.
No global state, no env-var reads, no implicit URLs. Every endpoint, identifier, and default value is supplied by the embedding CLI through a Config struct.
go get github.com/entireio/auth-go@latest
| Package | What it does |
|---|---|
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 |
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 |
TokenSet value type plus unverified JWT claim parsing. Rejects alg:none (RFC 7515 / RFC 7518 §3.6 known attack vector). The package never validates signatures — that's the issuing server's responsibility. Callers use Claims for routing decisions (which issuer, which audience) and UX (display the principal handle), not as a security boundary. |
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 |
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. |
The internal/oauthhttp package holds shared HTTP body-reading + JSON-decoding helpers (detects HTML responses from captive portals / proxy intercepts and surfaces them as actionable errors instead of unmarshal failures). It is unexported in the Go sense — not importable by other modules — and not part of the public API surface.
Defense-in-depth checks layered on top of server-side validation:
- HTTPS required. Both
sts.Clientanddeviceflow.Clientrejecthttp://BaseURLs unlessAllowInsecureHTTPis set, and that opt-in is still limited to loopback (localhost/127.0.0.1/::1) so production misconfigurations fail loudly. alg:noneJWTs rejected.tokens.ParseClaimsdecodes the JWT header and refuses the unsigned shape (any case variant ofnone). Even though claim use is routing-only, this keeps an obvious attack surface closed.verification_urivalidated. The device-code response field is what your CLI echoes and opens in the user's browser — a malicious AS pointing it at a phishing page would be a credential-harvesting vector. The library rejects non-https (loopback http excepted), embeddeduser:pass@hostuserinfo, and control characters in the URI.- Resource origins validated.
tokenmanager.TokenrequiresTokenRequest.Resourceto be an origin URL: absolute scheme + host, HTTPS unless loopback HTTP is explicitly enabled, and no userinfo/path/query/fragment. This prevents cache fragmentation and accidental STS requests for surprising resource strings. - OAuth responses are bounded and strict. Success and error bodies are capped at
MaxResponseBytes; oversized responses are rejected, HTML/captive-portal responses get actionable errors, and JSON success bodies with trailing data are refused. - STS wire shape is explicit.
tokenmanagerdefaultssubject_token_typeto the RFC 8693 access-token URN and exposesConfig.SubjectTokenTypefor STS endpoints that require the structural JWT URN.
import (
"github.com/entireio/auth-go/deviceflow"
"github.com/entireio/auth-go/sts"
"github.com/entireio/auth-go/tokenmanager"
"github.com/entireio/auth-go/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",
// Optional: defaults to sts.SubjectTokenTypeAccessToken. If your STS
// requires the structural JWT URN instead, uncomment:
// SubjectTokenType: sts.SubjectTokenTypeJWT,
})
if err != nil { /* misconfiguration */ }dfc, err := deviceflow.New(&deviceflow.Client{
BaseURL: issuer,
ClientID: clientID,
Scope: "cli",
DeviceCodePath: "/oauth/device/code",
TokenPath: "/oauth/token",
})
if err != nil { /* required field missing */ }
dc, err := dfc.StartDeviceAuth(ctx)
// show dc.UserCode + dc.VerificationURI to user, then drive the poll loop:
ts, err := dfc.PollUntil(ctx, dc)
if err != nil { /* surface RFC 8628 §3.5 sentinel as needed */ }
if ts == nil { /* defensive: PollUntil's contract is non-nil on nil err */ }
if err := mgr.SaveCoreToken(*ts); err != nil { /* keyring failed */ }deviceflow.New (and sts.New) validate required fields at
construction time rather than at the first request — misconfiguration
becomes a startup error rather than a confusing AS-side rejection
mid-flow. Field-bag &deviceflow.Client{...} construction still
works for callers that prefer it.
SaveCoreToken takes the full tokens.TokenSet so RefreshToken,
absolute ExpiresAt, and Scope survive the round-trip through the
keyring — earlier versions silently dropped these.
PollUntil is the helper most embedders want. It honours dc.Interval,
applies the RFC 8628 §3.5 +5s bump on slow_down, stops at the
dc.ExpiresIn ceiling, and returns terminal sentinels (ErrAccessDenied,
ErrExpiredToken, ErrInvalidGrant) unwrapped so callers can
errors.Is. Use PollDeviceAuth directly only when you need to render
per-tick state in your own UI.
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.
By default, tokenmanager sends subject_token_type=urn:ietf:params:oauth:token-type:access_token for the stored core bearer. This is the role-based RFC 8693 token type and works for STS endpoints that treat the device-flow result as an OAuth access token even when its wire format is JWT. If your STS requires the structural JWT token type, configure SubjectTokenType: sts.SubjectTokenTypeJWT.
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.
- 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.Clientandsts.Clientare 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.ParseClaimsis 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 consumers naturally get separate credential stores. - Caller controls the wire shape. Default values for RFC 8693 fields are explicit and overridable.
tokenmanagerdefaultsrequested_token_typeto the standard access-token URN andsubject_token_typetoaccess_token; callers can setRequestedTokenType,SubjectTokenType,scope, audience, resource, and package-specificExtrafields as needed.
- Pick a stable service name for
tokenstore.NewKeyring(...). Don't change it later — renaming orphans every existing user's stored credentials. - Pick a
client_idthat the auth server recognises. - Decide your
STSPath: typically the OAuth token endpoint per RFC 8693 convention, or a dedicated path if your auth server exposes one. - Decide whether the STS expects
subject_token_type=access_token(thetokenmanagerdefault) orjwt(SubjectTokenType: sts.SubjectTokenTypeJWT). - Construct the
tokenmanager.Manageronce at startup; pass it to your data-API call sites. - For multi-environment users (regions, staging), key the keyring by issuer URL —
Manager.Issuer()returns the configured value.
The library is the client side of OAuth 2.0 device flow + RFC 8693 token exchange. It receives bearers from an authorization server and presents them to data APIs. The threat model is:
- The auth server is trusted. This library does not verify JWT
signatures — that's the data API's job.
tokens.ParseClaimsis documented as unverified and is used for routing decisions only. - The data API is trusted. The library hands it a bearer; what the API does with it is out of scope.
- The transport is trusted only when TLS-protected. Both
deviceflow.Clientandsts.Clientrejecthttp://BaseURLs unlessAllowInsecureHTTPis set, and even then only loopback hosts are accepted. TheTransportfield is for observability and proxies, not for disabling TLS verification. - OS keyrings are trusted within the user's session. The
tokenstore.Keyringimpl keys credentials by(service, account); pick a stable, unique service name per CLI and treat anything the keyring returns as untrusted bytes (the impl already rejects JSON-shaped junk and empty access tokens).
Defenses in depth that the library applies regardless:
- Rejects
alg:noneJWTs and any alg containing non-alphanumeric characters (closes whitespace / zero-width-space bypass attempts). - Rejects absolute
Path/DeviceCodePath/TokenPathvalues (defeats redirect-via-config attacks againsturl.ResolveReference). - Validates
TokenRequest.Resourceas an origin URL and normalises it for same-host / JWT-audience shortcut comparisons and cache keys. - Normalises
tokenmanager.Config.Issuerso cosmetic differences (trailing slash, host case, default port) can't split keyring state across two effective issuers. - Sanitises server-supplied
error_descriptionand non-JSON error body text (strips control chars, caps length) before wrapping into Go errors — so a hostile AS can't paint terminals or balloon logs. - Rejects oversized OAuth responses and JSON success responses with trailing data after the first JSON value.
- Defaults STS
subject_token_typetoaccess_token, withConfig.SubjectTokenTypeavailable when an STS expectsjwt. - Refuses to cache exchanged tokens with non-positive
expires_in(forces a fresh exchange instead of treating unknown-lifetime bearers as "valid forever"). - Caps cache entries' lifetime at 1h when
ExpiresAtis unset.
If you find a security issue, please email the maintainers privately rather than opening a public issue — coordinated disclosure gives a window to ship a fix before the report becomes searchable.
- OIDC discovery / ID tokens. This library is OAuth 2.0 only. If you need OIDC
/.well-known/openid-configuration+ ID-token verification, layercoreos/go-oidcon 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'soppackage.
Used in production by entireio/cli. Issues and PRs welcome.
Every user-visible change must land with a CHANGELOG.md entry in the same PR, under the top ## Unreleased heading. Tagging a release is then a two-step move:
- Rename
## Unreleasedto## vX.Y.Z — YYYY-MM-DD, commit, and merge through a regular PR. - Tag that merge commit with
git tag -a vX.Y.Z -m "..."andgit push --tags.
Each ## Unreleased bullet must describe behaviour that changed since the last tag — not since some earlier point. When a CHANGELOG entry survives a release without being moved under a version header, the next release inherits stale claims (e.g. v0.3.4's draft initially still listed v0.3.2's :access_token default as "new").
Version bumps follow 0.x SemVer with one local convention: while pre-1.0, breaking changes may ship as patch bumps when the surface is narrow and well-documented (v0.3.2 changed the default subject_token_type as a patch).
MIT — see LICENSE.