Provider-agnostic Go library for CLIs that authenticate end-users via OAuth 2.0 device flow (RFC 8628) or loopback authorization code + PKCE (RFC 8252), 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. |
authcode | RFC 8252 OAuth 2.0 Authorization Code Grant client for native apps: PKCE (RFC 7636, S256) over a 127.0.0.1 loopback redirect. Start binds the listener and builds the browser URL; Wait blocks for the redirect; Exchange redeems the code. Opening the browser stays the caller's job. |
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:
sts.Client and deviceflow.Client reject http:// BaseURLs unless AllowInsecureHTTP is set, and that opt-in is still limited to loopback (localhost / 127.0.0.1 / ::1) so production misconfigurations fail loudly.alg:none JWTs rejected. tokens.ParseClaims decodes the JWT header and refuses the unsigned shape (any case variant of none). Even though claim use is routing-only, this keeps an obvious attack surface closed.verification_uri validated. 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), embedded user:pass@host userinfo, and control characters in the URI.tokenmanager.Token requires TokenRequest.Resource to 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.MaxResponseBytes; oversized responses are rejected, HTML/captive-portal responses get actionable errors, and JSON success bodies with trailing data are refused.tokenmanager defaults subject_token_type to the RFC 8693 access-token URN and exposes Config.SubjectTokenType for 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:
Issuer == resource): hands back the core token verbatim.aud-includes shortcut: same, when the core token's audience already covers the resource (e.g. multi-audience tokens).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.
Config. The library should compile and run identically inside any CLI.deviceflow.Client and sts.Client are field-bag structs; neither knows about your provider's endpoint paths or token-type URIs. Pass them in.tokens.ParseClaims is documented as unverified and used only for routing decisions.tokenstore.NewKeyring. OS keyrings key by (service, account), so consumers naturally get separate credential stores.tokenmanager defaults requested_token_type to the standard access-token URN and subject_token_type to access_token; callers can set RequestedTokenType, SubjectTokenType, scope, audience, resource, and package-specific Extra fields as needed.tokenstore.NewKeyring(...). Don't change it later — renaming orphans every existing user's stored credentials.client_id that the auth server recognises.STSPath: typically the OAuth token endpoint per RFC 8693 convention, or a dedicated path if your auth server exposes one.subject_token_type=access_token (the tokenmanager default) or jwt (SubjectTokenType: sts.SubjectTokenTypeJWT).tokenmanager.Manager once at startup; pass it to your data-API call sites.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:
tokens.ParseClaims is
documented as unverified and is used for routing decisions only.deviceflow.Client and sts.Client reject http:// BaseURLs
unless AllowInsecureHTTP is set, and even then only loopback hosts
are accepted. The Transport field is for observability and proxies,
not for disabling TLS verification.tokenstore.Keyring impl 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:
alg:none JWTs and any alg containing non-alphanumeric
characters (closes whitespace / zero-width-space bypass attempts).Path / DeviceCodePath / TokenPath values
(defeats redirect-via-config attacks against url.ResolveReference).TokenRequest.Resource as an origin URL and normalises it
for same-host / JWT-audience shortcut comparisons and cache keys.tokenmanager.Config.Issuer so cosmetic differences
(trailing slash, host case, default port) can't split keyring state
across two effective issuers.error_description and 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.subject_token_type to access_token, with
Config.SubjectTokenType available when an STS expects jwt.expires_in
(forces a fresh exchange instead of treating unknown-lifetime
bearers as "valid forever").ExpiresAt is 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.
/.well-known/openid-configuration + ID-token verification, layer coreos/go-oidc on top.zitadel/oidc's op package.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:
## Unreleased to ## vX.Y.Z — YYYY-MM-DD, commit, and merge through a regular PR.git tag -a vX.Y.Z -m "..." and git 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.
Go
99.1%
Provider-agnostic Go library for CLIs that authenticate end-users via OAuth 2.0 device flow (RFC 8628) or loopback authorization code + PKCE (RFC 8252), 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. |
authcode | RFC 8252 OAuth 2.0 Authorization Code Grant client for native apps: PKCE (RFC 7636, S256) over a 127.0.0.1 loopback redirect. Start binds the listener and builds the browser URL; Wait blocks for the redirect; Exchange redeems the code. Opening the browser stays the caller's job. |
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:
sts.Client and deviceflow.Client reject http:// BaseURLs unless AllowInsecureHTTP is set, and that opt-in is still limited to loopback (localhost / 127.0.0.1 / ::1) so production misconfigurations fail loudly.alg:none JWTs rejected. tokens.ParseClaims decodes the JWT header and refuses the unsigned shape (any case variant of none). Even though claim use is routing-only, this keeps an obvious attack surface closed.verification_uri validated. 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), embedded user:pass@host userinfo, and control characters in the URI.tokenmanager.Token requires TokenRequest.Resource to 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.MaxResponseBytes; oversized responses are rejected, HTML/captive-portal responses get actionable errors, and JSON success bodies with trailing data are refused.tokenmanager defaults subject_token_type to the RFC 8693 access-token URN and exposes Config.SubjectTokenType for 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:
Issuer == resource): hands back the core token verbatim.aud-includes shortcut: same, when the core token's audience already covers the resource (e.g. multi-audience tokens).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.
Config. The library should compile and run identically inside any CLI.deviceflow.Client and sts.Client are field-bag structs; neither knows about your provider's endpoint paths or token-type URIs. Pass them in.tokens.ParseClaims is documented as unverified and used only for routing decisions.tokenstore.NewKeyring. OS keyrings key by (service, account), so consumers naturally get separate credential stores.tokenmanager defaults requested_token_type to the standard access-token URN and subject_token_type to access_token; callers can set RequestedTokenType, SubjectTokenType, scope, audience, resource, and package-specific Extra fields as needed.tokenstore.NewKeyring(...). Don't change it later — renaming orphans every existing user's stored credentials.client_id that the auth server recognises.STSPath: typically the OAuth token endpoint per RFC 8693 convention, or a dedicated path if your auth server exposes one.subject_token_type=access_token (the tokenmanager default) or jwt (SubjectTokenType: sts.SubjectTokenTypeJWT).tokenmanager.Manager once at startup; pass it to your data-API call sites.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:
tokens.ParseClaims is
documented as unverified and is used for routing decisions only.deviceflow.Client and sts.Client reject http:// BaseURLs
unless AllowInsecureHTTP is set, and even then only loopback hosts
are accepted. The Transport field is for observability and proxies,
not for disabling TLS verification.tokenstore.Keyring impl 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:
alg:none JWTs and any alg containing non-alphanumeric
characters (closes whitespace / zero-width-space bypass attempts).Path / DeviceCodePath / TokenPath values
(defeats redirect-via-config attacks against url.ResolveReference).TokenRequest.Resource as an origin URL and normalises it
for same-host / JWT-audience shortcut comparisons and cache keys.tokenmanager.Config.Issuer so cosmetic differences
(trailing slash, host case, default port) can't split keyring state
across two effective issuers.error_description and 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.subject_token_type to access_token, with
Config.SubjectTokenType available when an STS expects jwt.expires_in
(forces a fresh exchange instead of treating unknown-lifetime
bearers as "valid forever").ExpiresAt is 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.
/.well-known/openid-configuration + ID-token verification, layer coreos/go-oidc on top.zitadel/oidc's op package.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:
## Unreleased to ## vX.Y.Z — YYYY-MM-DD, commit, and merge through a regular PR.git tag -a vX.Y.Z -m "..." and git 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.
Go
99.1%