Documentation
¶
Overview ¶
Package auth is the SDK-owned authentication layer: environment-scoped Ed25519 identity keys, the RFC 7523 JWT-bearer token exchange, cached token state, and the request-editor middleware that attaches bearers to generated clients.
It is lifted from the shipped internal/agentkey + internal/agentauth (their crypto and tests) and re-scoped per impl/4.1 + 4.2: keys move to <identity>_<env>.key under the XDG config dir, tokens to the XDG state dir, and the signing path uses go-jose. It is UX-free (no Cobra/theme) so the public SDK can import it.
Index ¶
- Constants
- Variables
- func AttachAuth(creds Credentials, req *http.Request) error
- func BearerToken(creds Credentials) (string, error)
- func CanReExchange(creds Credentials) bool
- func GetOrGenerateKey(ref IdentityRef) (ed25519.PrivateKey, error)
- func InvalidateTokens(ref IdentityRef) error
- func KeyPathForImport(ref IdentityRef) (string, error)
- func PurgeMaterial(ref IdentityRef) error
- func ReadAPIKey(ref IdentityRef) (string, error)
- func RefreshBearerToken(creds Credentials) (string, error)
- func RequestEditor(creds Credentials) func(ctx context.Context, req *http.Request) error
- func RequireSecureURL(rawurl string) error
- func RevokeToken(baseURL, accessToken, token string) error
- func SaveAPIKey(ref IdentityRef, key string) error
- func SaveTokens(ref IdentityRef, tokens *TokenSet) error
- type AssertionInvalidError
- type ClaimContext
- type Credentials
- type IdentityRef
- type JWK
- type JWKS
- type PendingError
- type RegistrationResult
- type TokenExchangeOutcome
- type TokenSet
Constants ¶
const APIKeyPrefix = "jak_"
APIKeyPrefix is the required prefix for a Jentic API key credential. It mirrors the shipped V1 constant so migrated keys and freshly-added keys validate the same way.
Variables ¶
var ErrNotRegistered = errors.New("identity is not registered in this environment")
ErrNotRegistered indicates the identity has no client-id registration in the active environment — the exchange cannot even be attempted. Exposed as a sentinel so CLI layers can map it to the NOT_AUTHENTICATED error code rather than a generic internal error.
Functions ¶
func AttachAuth ¶
func AttachAuth(creds Credentials, req *http.Request) error
AttachAuth stamps the appropriate Authorization header on req for creds. It is the shared attach path used by both the request-editor middleware and the response-side 401 retry (transport.go), so the two can never disagree on which credential to present. Order: transport guard -> injected token -> API-key credential -> disk token (exchanged if missing/expired).
func BearerToken ¶
func BearerToken(creds Credentials) (string, error)
BearerToken resolves the Authorization bearer value for creds: the injected token, the stored jak_* API key, or the cached disk token — exchanged (and persisted) when missing/expired. It is the credential-resolution half of AttachAuth, exported so callers that assemble their own requests (rather than going through the SDK's request editor) present exactly the same credential the SDK would. Callers own the transport-security guard AttachAuth applies (the SDK never sends a bearer to a non-HTTPS, non-loopback host — F3); the exchange itself is protected regardless by tokenEndpoint (F1).
func CanReExchange ¶
func CanReExchange(creds Credentials) bool
CanReExchange reports whether creds can mint a NEW token on a 401 (i.e. the exchange-backed disk path). Injected tokens and API keys are fixed credentials: a 401 on those is a hard denial, not something a re-exchange can fix, so the transport must not loop on them.
func GetOrGenerateKey ¶
func GetOrGenerateKey(ref IdentityRef) (ed25519.PrivateKey, error)
GetOrGenerateKey resolves the env-scoped Ed25519 private key for ref, generating and persisting (PKCS#8 PEM, 0600) a fresh one on first use. This is the only retained lazy side effect (impl/4.1 §2): it is local-only and never contacts the server, so it is safe for the auth middleware to call during a token exchange. The file-less path short-circuits before ever reaching here.
func InvalidateTokens ¶
func InvalidateTokens(ref IdentityRef) error
InvalidateTokens removes the cached token for ref so the next request forces a fresh RFC 7523 exchange. It is used by the response-side 401 policy (impl/4.2 / 13 §5): a server-rejected token (revoked, rotated signing key, clock drift the backend won't tolerate) looks valid on disk, so we must actively discard it before retrying rather than trust our own expiry math. A missing file is not an error — there is simply nothing to invalidate.
func KeyPathForImport ¶
func KeyPathForImport(ref IdentityRef) (string, error)
KeyPathForImport returns the on-disk path where ref's key file lives, creating the keys dir (0700). It exists so the migration path (jentic migrate) can copy a validated legacy PKCS#8 PEM key verbatim into the XDG layout — preserving the exact key bytes rather than generating a new keypair, which would break the already-registered client_id. The returned path is the same one GetOrGenerateKey reads/writes, so the copied key is picked up transparently.
func PurgeMaterial ¶
func PurgeMaterial(ref IdentityRef) error
PurgeMaterial removes ALL on-disk secret material for ref — the Ed25519 key (<config>/keys/<stem>.key), the cached tokens (<state>/<stem>_tokens.json), and the API-key credential (<state>/<stem>.apikey). It is called by `identity delete` / `context delete --identity` so deleting an identity does not leave its private key and tokens orphaned on disk after the config entry is gone (impl/1.3 §4a "delete removes its key/token files"; F8-34).
A missing file is not an error (nothing to remove). It aggregates removal errors so a permission problem on one file still attempts the others, and reports the first failure. It never touches config.yaml — the caller owns the config-map deletion via MutateConfig.
func ReadAPIKey ¶
func ReadAPIKey(ref IdentityRef) (string, error)
ReadAPIKey loads the API-key credential for ref, or an error if none exists.
func RefreshBearerToken ¶
func RefreshBearerToken(creds Credentials) (string, error)
RefreshBearerToken drops any cached token and forces a fresh assertion exchange, returning the new bearer value. This is how a caller picks up server-side grant changes that are baked into the token at mint time (scope grants — `jentic access refresh`): a refresh-token rotation would carry the old scopes forward unchanged, a fresh exchange re-reads them. Static credentials (injected token, jak_* API key) have nothing to re-mint; they are returned as-is, matching BearerToken's resolution order.
func RequestEditor ¶
RequestEditor returns a function that mutates outbound http.Requests to attach the bearer token, fetching/refreshing it as needed.
func RequireSecureURL ¶
RequireSecureURL is the exported transport guard: it parses rawurl and applies the same https-or-loopback invariant the SDK enforces before attaching a bearer (requireSecureHost). It exists so callers that hold the agent token outside the generated SDK transport — the broker POST in `jentic execute` — obey the same rule: the token must never traverse plaintext http to a non-loopback host (SEC-1). A malformed URL is rejected fail-closed.
func RevokeToken ¶
RevokeToken revokes a token (RFC 7009) at the base URL's /oauth/revoke endpoint, authenticated by accessToken. Revocation is best-effort by RFC (the server treats unknown tokens as success), but transport and non-2xx failures are returned so callers can warn. Same TLS/loopback invariant as the other auth-server routes (F1).
func SaveAPIKey ¶
func SaveAPIKey(ref IdentityRef, key string) error
SaveAPIKey persists a jak_* API key credential for ref (0600). It is the V2 successor to V1's per-profile `apikey` file: the credential is a first-class identity credential (Phase 4 item 4) but stored as a secret under XDG state, never in config.yaml (which round-trips through node merges and is not secret-safe). Returns an error if the key lacks the required prefix.
func SaveTokens ¶
func SaveTokens(ref IdentityRef, tokens *TokenSet) error
SaveTokens writes the token cache for ref (0600).
Concurrency: no lock. Two processes sharing the same identity+environment that exchange concurrently accept last-writer-wins — each exchange yields an independently valid token, so a clobbered file only causes a redundant exchange, never an invalid state (contrast config.MutateConfig, which locks because it mutates stateful status fields).
Types ¶
type AssertionInvalidError ¶
type AssertionInvalidError struct {
Detail string
}
AssertionInvalidError is a 400 invalid_grant whose detail signals that the signed assertion itself was rejected (bad audience/signature/expiry) rather than a pending approval. It exists so the register wait loop can STOP and surface an actionable fix (usually an audience mismatch — the CLI signed an aud the backend's canonical_base_url does not match) instead of polling forever as if the agent were merely unapproved (QA-9).
func (*AssertionInvalidError) Error ¶
func (e *AssertionInvalidError) Error() string
type ClaimContext ¶
type ClaimContext struct {
ClaimOutstanding bool
}
ClaimContext carries the caller-side facts the token-exchange classifier needs beyond the error itself. ClaimOutstanding is true while a claim token has been issued but the human has not yet claimed+approved the agent in the console: in that window the backend rejects the exchange with the SAME ambiguous 400 invalid_grant "Assertion is invalid" string it uses for a real audience mismatch (the approval-status gate runs before signature/audience). Callers that know a claim is outstanding pass true so the classifier treats that as pending rather than a hard failure.
type Credentials ¶
type Credentials struct {
BaseURL string
IdentityName string
EnvironmentName string
InjectedBearerToken string // file-less / bring-your-own-token override
}
Credentials is the minimal, UX-free input the auth middleware needs. The top-level client.Config is mapped into this by the SDK constructors, keeping client/auth free of Cobra/UX concepts.
func (Credentials) IdentityRef ¶
func (c Credentials) IdentityRef() IdentityRef
IdentityRef extracts the (identity, environment) pair keys/tokens are stored under, keeping the storage layer ignorant of the wider Credentials shape.
type IdentityRef ¶
IdentityRef identifies the (identity, environment) pair that all env-scoped cryptographic material is keyed by. It exists to kill a specific, security-relevant bug class: every key/token helper used to take (identityName, envName string) — two adjacent strings the compiler happily lets you transpose, silently resolving the WRONG key/token file. A single value makes that mistake unrepresentable and gives the "<identity>_<env>" filename stem one authoritative definition.
Deliberately NOT named "Scope": the control plane has RBAC *scopes* on an agent; this is a local storage reference, not an authorization scope.
func (IdentityRef) Stem ¶
func (r IdentityRef) Stem() (string, error)
Stem is the shared filename stem for this ref's on-disk material, e.g. "my-agent_prod". Key files append ".key"; token files append "_tokens.json".
SECURITY — path traversal: Identity/Environment are user-supplied names interpolated into file paths. Without validation, a name like "../../x" escapes the config dir and a name containing "_" could collide two refs' stems. Names are validated at creation (env add/identity add, impl/1.3 §3) AND re-checked fail-closed here, since config.yaml is user-editable after the fact.
type JWK ¶
type JWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Use string `json:"use"`
Alg string `json:"alg"`
}
JWK is a single JSON Web Key for an Ed25519 public key (OKP).
type PendingError ¶
type PendingError struct {
Detail string
}
PendingError indicates the agent is registered but not yet approved: the token endpoint returns 400 invalid_grant while approval is pending. Callers can distinguish "retry later" (exit 3 with --wait) from a hard denial.
func (*PendingError) Error ¶
func (e *PendingError) Error() string
type RegistrationResult ¶
type RegistrationResult struct {
ClientID string `json:"client_id"`
Status string `json:"status"`
// ClaimToken is the single-use ownership-claim token the control plane
// returns *once* on registration when claiming is enabled (jentic-one #1042).
// It is empty on the OSS default (no minter configured). We READ it here (a
// field the backend already returns) but never PERSIST it — it is a
// short-lived bearer capability, shown once, exactly like the RAT posture
// above. A human presents it to `jentic identity claim` to take ownership of
// this self-registered agent; an agent cannot claim itself.
ClaimToken string `json:"claim_token"`
}
RegistrationResult is the RFC 7591 Dynamic Client Registration response. registration_access_token is intentionally NOT retained: V2 re-derives all auth from the environment-scoped key, so a stored management token would be dead weight and a needless long-lived secret (same posture as the dropped refresh token — see tokens.go §1).
func Register ¶
func Register(baseURL, clientName string, jwks JWKS) (*RegistrationResult, error)
Register performs RFC 7591 Dynamic Client Registration against the control plane's /register endpoint. It is a hand-rolled POST (not a generated client call) because /register and /oauth/token are auth-server routes that are NOT part of the documented control-plane OpenAPI surface — the shipped V1 CLI (internal/authclient) speaks to them directly and this preserves that contract.
SECURITY (ref F1): the base URL is attacker-influenceable, and registration publishes the agent's PUBLIC key (not a secret) — but it still creates server-side state and returns a client_id we will sign assertions as, so we hold it to the same TLS/loopback invariant as tokenEndpoint. requireSecureHost keeps the two call sites from drifting.
type TokenExchangeOutcome ¶
type TokenExchangeOutcome int
TokenExchangeOutcome is the classified meaning of a token-exchange failure, with the claim-vs-audience ambiguity already resolved.
const ( // OutcomeOther is any failure that is neither pending nor a rejected // assertion (network, unexpected status, decode error, …). OutcomeOther TokenExchangeOutcome = iota // OutcomePending means the identity is registered but not active yet — // keep waiting / exit 3 with --wait. OutcomePending // OutcomeAssertionInvalid means the signed assertion was rejected (usually // an audience mismatch) — polling will never clear it, so stop with an // actionable fix. OutcomeAssertionInvalid )
func ClassifyTokenExchange ¶
func ClassifyTokenExchange(err error, cc ClaimContext) TokenExchangeOutcome
ClassifyTokenExchange resolves a token-exchange error into one of the three caller-facing outcomes, folding in the claim-outstanding disambiguation so the register wait loop and the data-plane session path share ONE rule (QA-9/ QA-24). A *PendingError is pending. A *AssertionInvalidError is a hard assertion failure UNLESS a claim is still outstanding, in which case the identical backend string means "not claimed/approved yet" and we treat it as pending. Anything else is OutcomeOther.
type TokenSet ¶
type TokenSet struct {
AccessToken string `json:"access_token"`
ExpiresAt time.Time `json:"expires_at"`
}
TokenSet is the cached access token for an identity+environment.
We deliberately do NOT store a refresh token. With the RFC 7523 JWT-bearer grant (oauth.go) the CLI can always mint a fresh access token from the env-scoped Ed25519 key with no user interaction, so a refresh token would be dead weight — and persisting one would be a needless long-lived secret on disk. On expiry we simply re-run the assertion exchange. (If the backend ever issues a human-interactive grant that genuinely needs refresh, add the field back THEN and use it in performOAuthExchange; don't store what you won't use.)
func ReadTokens ¶
func ReadTokens(ref IdentityRef) (*TokenSet, error)
ReadTokens loads the cached token for ref. A missing, unreadable, or corrupt file yields an error and a nil TokenSet, so callers can treat any failure as "no usable token" and re-exchange without risking a nil dereference.