axiam

package module
v1.0.0-alpha28 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

README

axiam SDK (Go)

CI Coverage Status Go Reference Go Report Card License

Official Go client SDK for AXIAM — Access eXtended Identity and Authorization Management.

Package identity

Contract conformance

This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19, §20, §21, §22 (including §6.1 mTLS).

§12.7, §14, §15, §20 and §22 are named rather than folded into the range because they landed after this SDK already claimed §1–§13: widening the range silently would turn a statement that was true when written into a different claim without anyone editing it.

See CONTRACT.md for the full cross-language behavioral contract.

Status

Implemented (Phase 18). REST client (login/MFA/refresh/logout, authz check/can/batch-check), gRPC client (authz check/batch-check plus GetUserInfo), AMQP consumer with HMAC verification, local JWKS verification, net/http middleware, OIDC/SSO relying-party helpers (§12 — "Login with AXIAM"), a webhook-signature verifier (§13) and the reactor runtime (§22 — ReactorServe) are all available. Runnable examples live under examples/.

Installation

go get github.com/ilpanich/axiam-go-sdk@latest

Or pin an explicit release:

go get github.com/ilpanich/axiam-go-sdk@vX.Y.Z
import axiam "github.com/ilpanich/axiam-go-sdk"

Usage

Login + MFA (§1, §5)
// tenantSlug is required — no default tenant (§5). Login and Refresh also
// require organization context (§5.1) — a tenant slug is only unique within
// an organization — so pass the org via WithOrgSlug (or WithOrgID for a UUID);
// a login without it is rejected with 400 "must provide org_id or org_slug".
client, err := axiam.NewClient(baseURL, tenantSlug, axiam.WithOrgSlug(orgSlug))
if err != nil {
	// handle error
}

result, err := client.Login(ctx, email, password)
if err != nil {
	// handle error
}
if result.MFARequired {
	completed, err := client.VerifyMfa(ctx, result.MFAToken, totpCode)
	// ...
}

See examples/login-mfa.

REST authorization checks — CheckAccess / Can / BatchCheck (§1)
allowed, reason, err := client.CheckAccess(ctx, "resource:read", resourceID)
canWrite, err := client.Can(ctx, "resource:write", resourceID)
results, err := client.BatchCheck(ctx, []axiam.AccessCheck{
	{Action: "resource:read", ResourceID: resourceID},
})

See examples/authz-check.

gRPC authorization checks (§1, §5, §9)
creds, err := axiamgrpc.NewTLSCredentials(nil, nil, nil) // strict TLS; arg 1 is an optional custom CA PEM for dev servers (§6)
conn, err := axiamgrpc.NewGRPCClient(target, creds, interceptor)
authzClient := axiamgrpc.NewAuthzClient(conn, refreshFn)

allowed, denyReason, err := authzClient.CheckAccess(ctx, axiamgrpc.CheckAccessRequest{
	TenantID: tenantID, SubjectID: subjectID, Action: "resource:read", ResourceID: resourceID,
})

See examples/grpc-checkaccess.

gRPC userinfo — GetUserInfo (§1.1)

GetUserInfo is the low-latency gRPC counterpart of the server's REST GET /oauth2/userinfo endpoint (CONTRACT.md §1.1). It invokes axiam.v1.UserInfoService/GetUserInfo on the same gRPC channel and shares the same auth + x-tenant-id interceptor as CheckAccess; the request is empty (identity is derived server-side from the bearer token) and it drives the same single-flight refresh + one-shot retry on UNAUTHENTICATED (§9).

userInfoClient := axiamgrpc.NewUserInfoClient(conn, refreshFn) // same conn as NewAuthzClient

info, err := userInfoClient.GetUserInfo(ctx)
// info.Sub, info.TenantID, info.OrgID are always present.
// info.Email / info.PreferredUsername are *string — non-nil only when the
// access token carries the "email" / "profile" scope respectively.

See examples/grpc-checkaccess.

mTLS / client certificates (§6.1)

AXIAM can authenticate IoT devices and service accounts by mutual TLS: the client presents an X.509 identity certificate (signed by the tenant's organization CA) that the server binds to a service account. Configure the client identity with WithClientCertificate — it is applied to both the REST and gRPC transports of the same logical client, and it never relaxes server verification (it is additive to WithCustomCA/§6, and the TLS-1.3 floor and strict RootCAs behavior are unchanged).

// PEM cert chain + PEM private key (PKCS#8 or PKCS#1).
client, err := axiam.NewClient(baseURL, tenantSlug,
	axiam.WithCustomCA(serverCAPEM),                 // trust the server's CA (§6)
	axiam.WithClientCertificate(certPEM, keyPEM),    // present our identity (§6.1)
)

// The same identity over gRPC — pass the SAME cert chain + key:
creds, err := axiamgrpc.NewTLSCredentials(serverCAPEM, certPEM, keyPEM)

mTLS is opt-in: omitting WithClientCertificate leaves the default bearer-cookie behavior unchanged. The private key is secret material (§7) — it is held behind the SDK's Sensitive type and never appears in any log, error, or display output, and there is no public getter for it.

AMQP consumer with HMAC verification (§8)
handler := func(ctx context.Context, event amqp.Event) error {
	// process event.Fields — hmac_signature has already been verified and removed
	return nil // Ack; return amqp.ErrDrop for a poison message (Nack, no requeue)
}
err := amqp.Consume(ctx, ch, queue, signingKey, handler)

See examples/amqp-consumer.

Reactors — AMQP extension actors (CONTRACT.md §22)

A reactor is an external process that subscribes to named hook events on the AXIAM bus and answers back — allow, deny, or a field-allow-listed mutation — inside a timeout the server declared. Zitadel Actions and Keycloak SPIs solve the same problem by loading third-party code into the authorization server; a reactor stays outside it, reachable only through a signed reply schema the server validates before it believes a word of it.

err := amqp.ReactorServe(ctx,
    // §8b: amqps:// only, optional CA bundle, no verification-skip switch anywhere.
    amqp.AMQPSDialer("amqps://broker.example:5671", amqp.WithReactorCABundle(caPEM)),
    amqp.ReactorConfig{
        TenantID:   tenantID,
        ReactorID:  reactorID, // the queue name is derived from it; the SERVER declared it
        SigningKey: subkey,    // the tenant AMQP subkey from the management API (§8.1)
    },
    func(ctx context.Context, ev amqp.ReactorEvent) (amqp.ReactorAnswer, error) {
        switch ev.Event {
        case amqp.ReactorEventTokenPreIssue:
            // `ext.` is the COMPLETE allow-list for this event.
            return amqp.ReactorMutate(map[string]string{"ext.department": "eng"}), nil
        case amqp.ReactorEventLoginPostAuth:
            if fraudulent(ev) {
                return amqp.ReactorDeny("embargoed region"), nil
            }
            return amqp.ReactorAllow(), nil // or ReactorAllowWithStepUp()
        }
        return amqp.ReactorAllow(), nil
    },
)
Binding handlers per event — ReactorMux (§22.14)

The switch above is the shape every multi-event reactor grows, and it has two failure modes that cost nothing to remove. ReactorMux is §22.14's declarative form for Go — pure sugar over the same ReactorServe, in the spirit of the §11 declarative authorization helpers:

handler, err := amqp.NewReactorMux().
    On(amqp.ReactorEventTokenPreIssue, enrichToken).
    On(amqp.ReactorEventLoginPostAuth, screenLogin).
    Handler()
if err != nil {
    return err // every rejected binding at once, not one per run
}
err = amqp.ReactorServe(ctx, dialer, cfg, handler)
  • A misspelled event is refused when you bind it, not discovered as an event that never fires. ReactorMux accepts only names in the §22.5 registry — which is also why it refuses the three hot-path operations §22.7 excludes: they are in no registry row.
  • An unbound event abstains — no reply, failure_policy decides (§22.8). A default: arm returning ReactorAllow() answers on behalf of code that never ran, which is how an operator's fail_closed setting gets defeated from inside the library (§22.10 rule 2).
  • A duplicate binding is an error rather than a silent overwrite, and mux.Events() feeds amqp.ReactorDefaultFailurePolicy so you can see what an unreachable reactor costs before you go live.

It adds no transport, no verification and no signing: it produces exactly the ReactorHandler ReactorServe already takes, and a handler's own error or panic reaches the runtime unchanged so nothing is published.

ReactorServe verifies every delivery before the handler sees it — key version, MAC, freshness, nonce, in that order — then signs the reply with the same tenant subkey. §8's HMAC runs in both directions here: a reply is an instruction to change a token or refuse a login, so an unsigned or stale one is not a weak reply, it is not a reply at all.

Five things this runtime does that are easy to get wrong, and are asserted against the server-generated vectors in amqp/testdata/reactor_v2_reference_vectors.json rather than documented and hoped for:

  • hmac_signature is serialized as null inside a reactor body, not omitted the way §8's own two message types omit it. This is the single most likely place to produce a MAC that never verifies, in either direction.
  • reason, patch and require_mfa are omitted when absent/false. A reply that serializes "require_mfa": false produces different canonical bytes and a different MAC.
  • A patch is sent unfiltered. One forbidden key rejects the whole patch server-side, and this SDK will not quietly drop sub to rescue the rest — that would leave you believing a field was set when it was dropped.
  • A handler that fails publishes nothing. No synthesized allow: the registration's failure_policy decides, which is what the operator configured. login.post_auth defaults to fail_closed.
  • It never declares an exchange, a queue or a binding. The server declares the per-reactor queue from the registration. A reactor that could bind could bind itself to *.token.pre_issue and read another tenant's issuance events.

The event registry, its per-event mutable-field allow-lists and §22.8's strictest-wins failure-policy composition are mirrored locally (amqp.ReactorEvents(), amqp.ReactorDefaultFailurePolicy(events)) because the delivery path validates against them with no network available; GET /api/v1/reactors/events serves the live copy.

Not hookable, and not offered anywhere in this SDK: the hot-path decision operations (the authorization check, the batch check and token introspection) are absent from the registry by design — §22.7 writes this as a MUST NOT because a reactor round-trip is milliseconds and the check path's budget is microseconds. An application that needs external input on an authorization decision writes a deny grant, which the engine evaluates in the hot path at hot-path cost.

timeout_ms reaches the handler both as ev.Timeout and as the handler context's deadline, so a handler that honours ctx sheds load instead of answering into a closed window. Telemetry (§19) is available via WithReactorTelemetryHook — and worth wiring, because a fail_open timeout produces allow and an audit record, so reactor health must never be inferred from the outcome alone.

See examples/reactor.

Webhook signature verification (§13)
// r.Body MUST be read into raw bytes and passed to Verify UNMODIFIED — never
// re-serialize a parsed JSON body, since that changes key order/whitespace
// and breaks the MAC.
body, err := io.ReadAll(r.Body)
if err != nil {
	http.Error(w, "failed to read body", http.StatusBadRequest)
	return
}

event, err := webhook.Verify(axiam.Sensitive(webhookSecret), r.Header.Get("X-Axiam-Signature"), body)
if err != nil {
	http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
	return
}

// Dedup at-least-once retries using the X-Axiam-Delivery header (not part of
// the MAC — keep a short-lived seen-set keyed on it).
deliveryID := r.Header.Get("X-Axiam-Delivery")
_ = deliveryID

// event.Type, event.Body are now safe to use.
switch event.Type {
case "user.created":
	// ...
}
w.WriteHeader(http.StatusOK)

webhook.Verify defaults to a ±300-second freshness window (override with webhook.WithTolerance) and returns a *webhook.VerifyError (matchable via errors.Is(err, webhook.ErrVerify)) on any failure — never a signature value, in either the returned error or any log output.

net/http middleware (§10)
verifier, err := axiam.NewJWKSVerifier(ctx, baseURL, nil)
guarded := middleware.Middleware(verifier, tenantSlug)(mux)

// inside a handler:
user, ok := middleware.UserFromContext(r.Context())

See examples/middleware-guard.

Local verification (§10.1)

Middleware applies the complete CONTRACT.md §10.1 minimum local-verification set on every request. Each rule fails closed — a required claim that is absent, unparseable, or of the wrong JSON type is a rejection, never a skipped check:

# Claim What the guard does
1 signature Verified against the org JWKS with alg pinned to EdDSA before any key lookup, so alg: none and HS-family confusion are rejected without ever consulting a key.
2 exp Required. No exp, or a non-numeric exp, is rejected. An absent exp is a permanent credential, not an absent constraint.
3 nbf Honoured when present; an nbf in the future is rejected. An absent nbf is valid.
4 tenant_id Required and asserted against the configured tenant. An absent claim — or a guard constructed with an empty tenant — is rejected. The JWKS is organization-wide, so a valid signature alone never bounds a token to a tenant.
5 iss Checked only when WithExpectedIssuer is configured. Unset by default.
6 aud Checked only when WithExpectedAudience is configured. Unset by default.
7 clock skew axiam.ClockSkewLeeway — a named 60-second constant applied to rules 2 and 3. Deliberately not operator-configurable.

iss and aud are conditional and default to unset; this SDK hardcodes no issuer or audience. Configure them when your deployment has an expectation to assert — a guard fronting a user-facing resource server should generally expect axiam:user:

guarded := middleware.Middleware(verifier, tenantSlug,
	middleware.WithExpectedIssuer("https://axiam.example.com"),
	middleware.WithExpectedAudience("axiam:user"),
)(mux)

JWKSVerifier.VerifySignatureOnlyUnchecked is the raw signature-only primitive §10.1 permits for integrators implementing their own policy. It is not a guard: it checks no claim at all, so an expired token, a token with no exp, and a token minted for a different tenant under the same org-wide JWKS all verify successfully. Use VerifyAccessToken (which Middleware wraps) unless you are implementing rules 2–7 yourself.

Declarative authorization helpers (§11)

On top of the §10 Middleware guard, middleware.RequireAuth, middleware.RequireAccess, and middleware.RequireRole add a per-route authorization layer (CONTRACT.md §11). Go has no macro/annotation/decorator facility, so these are per-route http.Handler wrappers under the same canonical require_auth / require_access / require_role vocabulary every other AXIAM SDK uses. They run strictly after the §10 guard — they never extract or verify a token themselves, only consuming the identity Middleware already injected — and they perform no decision caching: every request re-checks.

verifier, err := axiam.NewJWKSVerifier(ctx, baseURL, nil)
client, err := axiam.NewClient(baseURL, tenantSlug) // *axiam.Client satisfies middleware.AccessChecker

mux := http.NewServeMux()

// GET /docs/{id} requires the authenticated caller to pass a
// "documents:read" check for the {id} resolved from the path.
mux.Handle("/docs/{id}", middleware.RequireAccess(
	client, "documents:read", middleware.ResourceFromPath("id"),
)(docHandler))

// A route that only needs an authenticated identity, no resource check.
mux.Handle("/whoami", middleware.RequireAuth()(whoamiHandler))

// A cheap, LOCAL role check — no server round-trip, and NOT a substitute
// for RequireAccess's resource-level check.
mux.Handle("/admin", middleware.RequireRole("admin")(adminHandler))

guarded := middleware.Middleware(verifier, tenantSlug)(mux) // §10 guard wraps the whole mux

The check is always made for the request's authenticated user (subject_id), never the application's own client session — this is why RequireAccess takes a middleware.AccessChecker (satisfied by *axiam.Client's additive CheckAccessAs method) rather than reusing CheckAccess directly. A resource id that can't be resolved (missing path value, empty StaticResource, or a failing custom ResourceResolver) is a 400, never a silent allow. A transport failure while calling the authz endpoint fails closed with 503 — it is never treated as an allow.

See examples/middleware-guard (the GET /docs/{id} route).

OIDC / SSO relying-party helpers — "Login with AXIAM" (§12)

*axiam.Client exposes the nine canonical CONTRACT.md §12 operations for building an OIDC relying party against AXIAM's own OIDC provider, driving its client_credentials service-account grant, introspecting/revoking tokens, and stepping through the upstream-IdP federation endpoints:

Operation Wire call Purpose
OidcDiscover(ctx) GET /.well-known/openid-configuration Fetch and cache the discovery document (≥5 min TTL, single-flight per client).
OidcBegin(configuration, params) (none — pure local computation) Build the authorization URL with a CSPRNG state/nonce and an S256 PKCE challenge.
OidcExchange(ctx, params) POST /oauth2/token (authorization_code) Exchange a code for a token set, validating the ID token in full.
OidcRefresh(ctx, params) POST /oauth2/token (refresh_token) Refresh an OidcTokenSet under a single-flight guard.
LoginClientCredentials(ctx, params) POST /oauth2/token (client_credentials) Service-account machine-to-machine login.
Introspect(ctx, params) POST /oauth2/introspect RFC 7662 token introspection.
Revoke(ctx, params) POST /oauth2/revoke RFC 7009 token revocation (idempotent).
SsoStart(ctx, params) POST /api/v1/auth/federation/oidc/start Step 1 of upstream-IdP SSO.
SsoComplete(ctx, params) POST /api/v1/auth/federation/oidc/callback Step 2: establishes the session via Set-Cookie.

Configure the relying party's client credentials at construction time — client_id is needed for every grant and for §12.4's audience check, so it lives on the Client, never a per-call argument:

client, err := axiam.NewClient(baseURL, tenantSlug,
	axiam.WithOidcClientID("my-app"),
	axiam.WithOidcClientSecret(clientSecret), // omit for a public client
)

The caller owns the login state — the SDK stores nothing. OidcBegin returns AuthorizationRequest{URL, State, Nonce, CodeVerifier} and touches no store; persist State, Nonce and CodeVerifier in your own session (or via the optional axiam.OidcStateStore / axiam.NewMemoryOidcStateStore) and pass Nonce + CodeVerifier back into OidcExchange yourself:

configuration, err := client.OidcDiscover(ctx)
request, err := client.OidcBegin(configuration, axiam.OidcBeginParams{
	RedirectURI: redirectURI,
	Scope:       "openid profile email",
})
// ...persist request.State / request.Nonce / request.CodeVerifier, then...
http.Redirect(w, r, request.URL, http.StatusFound)

// on the callback, after checking the returned `state` matches:
tokens, err := client.OidcExchange(ctx, axiam.OidcExchangeParams{
	Code: code, CodeVerifier: request.CodeVerifier, Nonce: request.Nonce,
	RedirectURI: redirectURI, TenantID: tenantID,
})
fmt.Println(tokens.IDClaims.Sub) // the validated ID-token subject

middleware.OidcLoginHandler / middleware.OidcCallbackHandler wrap that same sequence as two http.Handlers, using an axiam.OidcStateStore to bridge the login and callback requests:

store := axiam.NewMemoryOidcStateStore(0) // 10-minute TTL, single-use consume
opts := middleware.OidcLoginOptions{
	Client: client, Store: store, RedirectURI: redirectURI, Scope: "openid profile",
	OnSuccess: func(w http.ResponseWriter, r *http.Request, tokens axiam.OidcTokenSet, entry axiam.OidcStateEntry) {
		// establish YOUR OWN application session here — the SDK never does.
	},
}
mux.Handle("/login", middleware.OidcLoginHandler(opts))
mux.Handle("/auth/callback", middleware.OidcCallbackHandler(opts))

access_token, refresh_token, id_token, client_secret and code_verifier are all Sensitive (§7/§12.5) — including while a code_verifier sits inside an AuthorizationRequest or an OidcStateStore entry. state and nonce are not secrets and are plain strings. PKCE is S256-only: plain is not implemented anywhere in this SDK. OidcRefresh runs under its own single-flight guard so concurrent callers share one wire call; OidcExchange/OidcRefresh validate any id_token against the full CONTRACT.md §12.4 checklist (EdDSA-only, issuer/audience/time/nonce) and discard the WHOLE token set — access and refresh token included — on any failure. An OAuth2ErrorResponse from /oauth2/* surfaces as *axiam.OAuthProtocolError, a sub-type of *axiam.AuthError (existing errors.Is(err, axiam.ErrAuth) / errors.As(err, &authErr) handling keeps matching it unchanged); a 401 from Introspect/Revoke never enters the §9 refresh guard.

See examples/oidc-login.

Device authorization grant (CONTRACT.md §14)

RFC 8628 — signing in a device that cannot show a browser: a TV, a CLI, a headless commissioning tool.

tokens, err := client.DeviceLogin(ctx, axiam.DeviceLoginParams{
    OnUserCode: func(a axiam.DeviceAuthorization) error {
        // Called BEFORE the first poll. Display it however the device can —
        // screen, QR code, e-ink panel. The SDK never prints it for you.
        fmt.Printf("visit %s and enter %s\n", a.VerificationURI, a.UserCode)
        return nil
    },
})

DeviceAuthorize and DevicePoll are also exported, for an application driving its own loop. The polling rules are where implementations go wrong:

  • slow_down raises the interval permanently. An SDK that backs off for one round and returns to the original interval will be told to slow down again, forever.
  • access_denied and expired_token stay distinct. A human said no, versus nobody answered — the only information the device can act on.
  • Polling stops at ExpiresIn, even if the server has not yet said expired_token.
  • A 5xx mid-poll is not terminal. A server restart must not lose a grant the user has already approved.

DeviceCode is Sensitive; UserCode deliberately is not — it exists to be read aloud, and wrapping it would defeat the one thing it is for. DeviceAuthorize sends no client_secret and does not refuse a Client built without one. Returning an error from OnUserCode aborts before any polling, and ctx cancellation is honoured between polls rather than only during them.

Per §14.3 rule 4, the token set is returned; AdoptAsCredential is the same opt-in flag LoginClientCredentials uses. See examples/device-login.

Token exchange (CONTRACT.md §15)

RFC 8693 — a service holding a user's token exchanging it for a narrower one before calling the next service.

exchanged, err := client.TokenExchange(ctx, axiam.TokenExchangeParams{
    SubjectToken:     axiam.Sensitive(userToken),
    SubjectTokenType: axiam.SubjectTokenTypeAccessToken, // required, no default
    Scopes:           []string{"orders:read"},
    Audience:         "orders-service",
})

Most of what this method does is refuse to be helpful:

  • No default SubjectTokenType. It is required (§15.1). Which kind of token you hold is something only you know, so the SDK will not pick — an empty value fails client-side rather than sending a type you did not choose.
  • No default ActorToken. Leaving it zero asks for impersonation; the SDK will not quietly substitute the client's own session token and turn that into a delegation.
  • No auto-narrowing after invalid_scope. The server refuses rather than silently narrowing precisely so the caller finds out here.
  • No refresh token, everExchangedToken has no such field. Re-run the exchange.
  • No adoption, and no flag to enable it. A MUST NOT, where LoginClientCredentials and DeviceLogin adoption is a MAY.

See examples/token-exchange.

External-IdP subject tokens (CONTRACT.md §15.7)

The same method exchanges a token minted by a trusted external IdP — a partner's Entra, Okta or Keycloak — for an AXIAM token scoped to what the resolved AXIAM user may actually do. There is no separate operation:

exchanged, err := client.TokenExchange(ctx, axiam.TokenExchangeParams{
    SubjectToken:     axiam.Sensitive(partnerToken),
    SubjectTokenType: axiam.SubjectTokenTypeJWT, // named, never guessed
    Scopes:           []string{"read:orders"},
    Audience:         "https://orders.internal",
})
  • SubjectTokenType is yours to state, and is required. The SDK never decodes the subject token to pick it, and never overrides what you named. There is no default: leaving it empty fails client-side with no wire call (§15.1), because a default would be the SDK choosing for you.
  • No actor token. Delegation across a trust boundary is unsupported in v1; sending one is invalid_request, which the SDK will not work around by dropping it and re-sending.
  • One refusal is distinguishable. invalid_grant whose description is the subject token's issuer is not configured for token exchange means fix the AXIAM trust configuration. Every other invalid_grant means fix your token, and is deliberately generic.
  • Forward the result as-is. It carries an ext_exchange claim naming the partner issuer; never strip it, and never read it as an authorization input. It also cannot be exchanged again — exchanges do not compose.

See examples/external-token-exchange and the operator guide, docs/api/federated-token-exchange.md.

UMA 2.0 — Protection API and ticket grant (CONTRACT.md §20)

The resource-server side of User-Managed Access: register what you guard, ask the authorization server what a caller would need, and redeem the resulting ticket.

// A PAT is a client-credentials token carrying `uma_protection` — never a user
// token, and never this client's own session (§20.2 rule 1).
session, _ := client.LoginClientCredentials(ctx, axiam.LoginClientCredentialsParams{
    Scope: axiam.UmaProtectionScope,
})
pat := session.AccessToken

resource, _ := client.UmaRegisterResource(ctx, pat, axiam.ResourceSet{
    Name: "invoice-7", Type: "document", ResourceScopes: []string{"view"},
})

// The returned ID IS the AXIAM resource id — no translation step.
ticket, _ := client.UmaRequestTicket(ctx, pat, []axiam.RequestedPermission{
    {ResourceID: resource.ID, ResourceScopes: []string{"view"}},
})

w.Header().Set("WWW-Authenticate", axiam.UmaChallengeHeader("invoices", issuer, ticket))

…and on the client side, having caught that 401:

challenge, ok := axiam.UmaParseChallenge(resp.Header.Get("WWW-Authenticate"))
if ok {
    rpt, err := client.UmaExchangeTicket(ctx, axiam.UmaExchangeTicketParams{
        Ticket: challenge.Ticket, ClaimToken: axiam.Sensitive(usersAccessToken),
    })
}

The rules this surface exists to enforce:

  • A ticket is never retried — not on 5xx, not on a timeout, not on invalid_grant. It is the one documented exception to §16's retry policy, and a security rule rather than a performance one: the ticket is consumed before the exchange is evaluated, so a failed exchange has already spent it and a retry is a second redemption. Under concurrency that is exactly the redemption a server whose storage engine the SDK cannot attest may admit twice (ilpanich/axiam#302). On failure, request a new ticket.
  • UmaParseChallenge does not exchange what it parsed. The as_uri names an authorization server you have not necessarily chosen to trust; auto-exchanging would send the requesting party's claim_token to whatever host answered the 401.
  • ClaimToken is required, never defaulted. It is the only channel that names the requesting party — defaulting it to your own PAT would mint an RPT for you. An empty one is refused client-side, so the ticket stays unspent.
  • No auto-narrowing on access_denied. A partial grant is refused whole; whether two-of-three permissions is useful is your application's judgement, not the SDK's.
  • The RPT is never adopted as this client's credential, and RequestingPartyToken has no refresh-token field.
  • UmaUpdateResource replaces the scope list rather than merging it, so omitting a scope removes it. There is no read-modify-write.
Emitting the challenge from the §11 guard

middleware.WithUmaChallenge wires the emit half into RequireAccess, so you do not hand-roll the mint-and-format on every denial:

challenger := &middleware.UmaChallenger{
    Realm: "invoices", ASURI: configuration.Issuer, PAT: pat, Minter: client,
}
mux.Handle("/invoices/{invoiceID}", middleware.RequireAccess(
    client, "invoices:read", middleware.ResourceFromPath("invoiceID"),
    middleware.WithUmaChallenge(challenger),
)(http.HandlerFunc(invoiceHandler)))
// A denial now answers 403 with
//   WWW-Authenticate: UMA realm="invoices", as_uri="…", ticket="…"

Two properties are deliberate, and both are asserted by counting Protection API calls rather than by inspection:

  • Opt-in. Emitting a challenge means minting a credential. A guard that did that on every denial by default would put a Protection API call — and a live ticket — behind every unauthorized request, which is a denial-of-service amplifier pointed at your own authorization server. An allow mints nothing, and neither does a 401 or a fail-closed 503: only a resource denial is answerable with a ticket.
  • A minting failure is not an escalation. An expired PAT or an unreachable Protection API still yields the plain 403 — never a 503, and never an allow.

The requested scope is the AXIAM action, so the ticket asks for exactly the authority that was refused and the engine's deny rules keep applying to whatever RPT comes back.

Both halves run end-to-end in examples/uma-resource-server and examples/uma-client.

Logout — RP-initiated and back-channel (CONTRACT.md §12.7)

LogoutURL builds the redirect; VerifyLogoutToken validates a token the OP pushed to your back-channel endpoint.

url, err := client.LogoutURL(ctx, axiam.LogoutURLParams{IDToken: idToken})

// …and at your registered backchannel_logout_uri:
verified, err := client.VerifyLogoutToken(ctx, logoutToken, nil)
if verified.SID != "" {
    endSession(verified.SID) // that session ONLY
}

The verifier is where the security weight sits — the input arrives unsolicited and instructs you to terminate a session. It checks the signature (same JWKS path as §12.4, which already pins EdDSA and requires a kid), iss, aud, that events carries the back-channel-logout key (the only thing separating a logout token from an ID token), that nonce is absent (its presence is how an ID token gets replayed as one), that something is named, and freshness.

It returns SID/Sub/JTI rather than a bare bool: you have to know which session to end. Dedup on JTI yourself — delivery is at-least-once, so a valid token legitimately arrives twice; the SDK has no durable store and an in-memory guard would silently drop a real second logout after a restart.

See examples/logout.

Decision reason codes (CONTRACT.md §11 rule 9)

AccessResult.ReasonCode distinguishes no_grant ("ask an admin for access") from denied_by_rule ("an admin has already decided") — opposite instructions to the person on the other end, which is why the contract forbids collapsing them into a bare false.

CheckAccess and AuthzClient.CheckAccess keep their (bool, string, error) tuples, which predate the field and cannot carry it; CheckAccessDecision on both returns the full result. An unrecognised code is surfaced verbatim and never changes Allowed.

Versioning

Releases are tagged vX.Y.Z. Pushing such a tag triggers the module-publish CI job, which verifies the tag was cut from main and asks proxy.golang.org to fetch it; pull-request events never trigger publish.

There is no registry upload step — for Go, the git tag is the release, and go get resolves it through the module proxy. API docs appear automatically on pkg.go.dev once the proxy has seen the tag.

Client quality-of-life (CONTRACT.md §16–§19)

Retry policy (§16)

Read-only authorization checks — CheckAccess, Can, CheckAccessAs, CheckAccessDecision, BatchCheck — retry transient failures under the contract's normative table: 3 attempts (1 initial + 2 retries), 200 ms base, 5 s cap, full jitter (uniform over [0, backoff]), and Retry-After honored as a floor.

This changed in D5. The previous policy used a 100 ms base, backoff *= 2 with no cap and no jitter, and ignored Retry-After entirely. Uncapped, the wait was bounded by nothing but the attempt count; unjittered, every client that saw the same outage retried at the same instant — the thundering herd the backoff is supposed to prevent.

Only failures that could plausibly succeed on a second attempt are retried: transport errors, 408, 429, 5xx. A 401 or 403 is an answer, not a transport failure, and surfaces after exactly one attempt. Nothing that changes server state is ever retried. A cancelled context wins over a pending backoff.

// Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
client, err := axiam.NewClient(baseURL, "acme", axiam.WithRetryDisabled())

There is deliberately no option for the attempt cap, base delay or delay cap: §16.1 forbids raising them, and eleven SDKs agreeing on one table is the point.

Deterministic shutdown (§18)

client.Close() releases the client's local resources and closes idle connections. It is idempotent, satisfies io.Closer, and any call afterwards returns a *NetworkError naming the cause rather than silently reconnecting.

Close does not log out. It never reaches the network. The server-side session deliberately outlives the Client value — that is what lets a process restart and resume — so a Close that logged out would silently end every user's session on each deploy. Call Logout first if ending the session is what you want.

Telemetry hooks (§19)

Wire metrics without this module depending on any metrics library:

client, err := axiam.NewClient(baseURL, "acme", axiam.WithTelemetryHook(
    func(e axiam.TelemetryEvent) {
        switch ev := e.(type) {
        case axiam.RequestEndEvent:
            histogram.Record(ctx, ev.Duration.Seconds(), /* labels */)
        case axiam.RetryEvent:
            counter.Add(ctx, 1, /* labels */)
        }
    },
))
  • A hook that panics cannot fail the operation that fired it — and in Go an unrecovered panic would take the process down, not just the request.
  • No event payload can carry a token. TelemetryEvent is a closed interface (its marker method is unexported) with fixed field sets — this surface exists to be shipped to a metrics backend.
  • Path templates, not URLs, so a metric label cannot become a cardinality bomb.

One RequestStartEvent/RequestEndEvent pair is emitted per attempt, so you can count real wire calls. See examples/telemetry-hook for the OpenTelemetry mapping.

Decision memo (§17) — opt-in, off by default

An optional TTL-bounded cache for CheckAccess results. Disabled by default, because §11.2 rule 6's ban on caching authorization decisions is still the default behaviour.

client, err := axiam.NewClient(baseURL, "acme", axiam.WithDecisionMemoTTL(5*time.Second))

What you are accepting. The staleness bound is the TTL, in both directions: a grant revoked on the server can still read as allowed for up to the TTL, and a grant just added can still read as denied for up to the TTL.

Reads-your-own-writes is not guaranteed. An admin UI that grants a role and immediately re-checks is the case that breaks, and it breaks silently. If that is your workload, leave this off.

The TTL is clamped to MaxMemoTTL (5 s) rather than rejected. Allows and denies are memoized identically — asymmetric caching would leak which outcome occurred through latency. Failures are never memoized: caching a transport error as a deny would turn a blip into a TTL-long outage. The memo is cleared on Login, VerifyMfa, Refresh and Logout, since entries are keyed by subject rather than by session. It is safe for concurrent use.

Documentation

Overview

Package axiam — OIDC / SSO relying-party helpers (CONTRACT.md §12, contract 1.4).

The nine canonical §12 operations, under the exact §12.2 Go names, as methods on the existing *Client: OidcDiscover, OidcBegin, OidcExchange, OidcRefresh, LoginClientCredentials, Introspect, Revoke, SsoStart, SsoComplete.

Everything besides the PKCE/CSPRNG primitives (oidc_pkce.go) and the issuer/audience/time/nonce checklist (oidc_idtoken.go) is reuse, not reimplementation (§12 forbids forking):

  • transport + §2 error mapping + §3 CSRF + §4 cookie jar + §5 tenant header + §6 TLS -> the SAME *http.Client / doRequest/newRequest choke point client.go already built;
  • §12.4 signature verification -> internal/jwks.Verifier, extended (never forked) with a raw-payload entry point;
  • §7/§12.5 redaction -> the existing Sensitive type.

Index

Constants

View Source
const (
	// ReasonCodeAllowed: an allow grant matched and no deny did.
	ReasonCodeAllowed = "allowed"
	// ReasonCodeNoGrant: nothing matched — default deny. Ask an admin for
	// access.
	ReasonCodeNoGrant = "no_grant"
	// ReasonCodeDeniedByRule: an explicit deny rule matched and overrode any
	// allow. An admin has already decided.
	ReasonCodeDeniedByRule = "denied_by_rule"
)

The three reason_code values CONTRACT.md §11 rule 9 defines.

Untyped string constants rather than a named type, so an unrecognised server value is still a valid AccessResult.ReasonCode and reaches the caller — a closed type would tempt the SDK to drop what it cannot name.

View Source
const (

	// DefaultDevicePollInterval is the polling interval used when the
	// authorization response omits `interval` (RFC 8628 §3.2, §14.2 rule 2).
	// An SDK MUST NOT hard-code a faster floor.
	DefaultDevicePollInterval = 5 * time.Second

	// SlowDownIncrement is added to the polling interval on each `slow_down`
	// (§14.2 rule 1). The increase is permanent and cumulative.
	SlowDownIncrement = 5 * time.Second
)
View Source
const (
	// SubjectTokenTypeAccessToken is an AXIAM-issued access token — the
	// same-domain exchange of §15.1. Name it explicitly; there is no default.
	SubjectTokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token"

	// SubjectTokenTypeJWT is a JWT from a trusted external issuer — the
	// cross-domain exchange of §15.7. AXIAM also accepts
	// SubjectTokenTypeAccessToken for an external issuer.
	SubjectTokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt"
)

The subject_token_type values AXIAM accepts, for TokenExchangeParams.SubjectTokenType (CONTRACT.md §15.7).

Named constants because the difference between these two URNs and a typo'd one is an invalid_request the caller has to go read RFC 8693 to decode.

View Source
const (
	// MaxAttempts is the §16.1 attempt cap: 1 initial + 2 retries.
	MaxAttempts = 3
	// BaseDelay is the §16.1 first backoff step.
	BaseDelay = 200 * time.Millisecond
	// MaxDelay is the §16.1 ceiling on any single computed backoff.
	MaxDelay = 5 * time.Second
)
View Source
const ClockSkewLeeway = jwks.ClockSkewLeeway

ClockSkewLeeway is the named, bounded clock-skew allowance this SDK applies to the exp and nbf checks (CONTRACT.md §10.1 rule 7). It is a constant and is deliberately NOT operator-configurable.

View Source
const DPoPIatLeeway = dpop.IatLeeway

DPoPIatLeeway is the "iat" freshness window, applied in both directions.

View Source
const MaxIDTokenClockSkewSec = 60

MaxIDTokenClockSkewSec is the CONTRACT.md §12.4 rule 5 ceiling for permitted ID-token clock skew: 60 seconds. WithOidcClockSkew clamps any larger configured value down to this ceiling; it is also the default when unconfigured.

View Source
const MaxMemoTTL = 5 * time.Second

MaxMemoTTL is the §17.1 rule 2 ceiling. A configured TTL above this is clamped, not rejected: a caller who asked for a minute wants caching, and silently giving them the maximum safe value beats failing construction.

View Source
const MinOidcDiscoveryTTL = 5 * time.Minute

MinOidcDiscoveryTTL is the CONTRACT.md §12.3 rule 6 FLOOR for the OIDC discovery-document cache TTL: 5 minutes. WithOidcDiscoveryTTL raises any smaller configured value up to this floor; it is also the default when unconfigured.

View Source
const OidcStateTTL = 10 * time.Minute

OidcStateTTL is the contract-mandated MAXIMUM TTL for stored login state: 10 minutes, matching the server's federation_login_state row lifetime (D-22, CONTRACT.md §12.3 rule 1).

View Source
const (

	// UmaProtectionScope is the scope a PAT must carry (§20.2 rule 1) — for
	// callers minting one through LoginClientCredentials.
	UmaProtectionScope = "uma_protection"
)

Variables

View Source
var (
	ErrAuth    = errors.New("axiam: authentication error")
	ErrAuthz   = errors.New("axiam: authorization error")
	ErrNetwork = errors.New("axiam: network error")
)

Sentinel errors for errors.Is-based discrimination convenience (CONTRACT.md §2, D-04). These are never returned directly — only *AuthError/*AuthzError/*NetworkError instances are, each of which implements Is(target) to match the corresponding sentinel.

View Source
var (
	ErrUnverifiableConfirmation   = jwks.ErrUnverifiableConfirmation
	ErrNoClientCertificate        = jwks.ErrNoClientCertificate
	ErrCertificateBindingMismatch = jwks.ErrCertificateBindingMismatch
	ErrNoDPoPProof                = jwks.ErrNoDPoPProof
	ErrDPoPBindingMismatch        = jwks.ErrDPoPBindingMismatch
)

Rule 9 sentinel errors, for guards that distinguish "nothing was presented" from "what was presented was wrong".

View Source
var CertificateThumbprintS256 = jwks.CertificateThumbprintS256

CertificateThumbprintS256 computes the RFC 8705 §3.1 "x5t#S256" of a DER client certificate.

View Source
var NewInMemoryDPoPJtiStore = dpop.NewInMemoryJtiStore

NewInMemoryDPoPJtiStore returns a single-process replay guard. Per-process, therefore per-instance: a multi-replica deployment needs a shared store.

View Source
var VerifyCertificateBinding = jwks.VerifyCertificateBinding

VerifyCertificateBinding applies rule 9 for certificate-bound tokens only.

It REFUSES a DPoP-bound or both-bound token rather than ignoring the half it cannot check — that refusal is what lets this narrower entry point stay in the API without becoming a downgrade path.

View Source
var VerifyDPoPProof = dpop.VerifyProof

VerifyDPoPProof performs all ten §21.7.2 checks and returns the proof key's RFC 7638 thumbprint — exactly the value PresentedProofs.DPoPThumbprint expects, so a guard can only pass on a thumbprint that came from a proof which actually verified.

View Source
var VerifyTokenBinding = jwks.VerifyTokenBinding

VerifyTokenBinding applies §10.1 rule 9 in full — the token's sender constraint against every proof the caller presented.

Prefer this over VerifyCertificateBinding unless the transport genuinely cannot produce a DPoP thumbprint. An unbound token is accepted with no proofs at all, so adopting it breaks no existing deployment.

Functions

func UmaChallengeHeader

func UmaChallengeHeader(realm, asURI string, ticket Sensitive) string

UmaChallengeHeader formats a `WWW-Authenticate: UMA` header value (§20.3, emit half) — for a resource server that has just minted a ticket and wants to tell the caller where to redeem it.

Types

type AccessCheck

type AccessCheck struct {
	Action     string `json:"action"`
	ResourceID string `json:"resource_id"`
	Scope      string `json:"scope,omitempty"`
	// SubjectID is optional and, when set, asks the server to evaluate the
	// check for this subject rather than the caller's own session
	// (CONTRACT.md §11.2 — declarative authorization helpers pass the
	// request's authenticated user_id here so the check runs for the end
	// user, not the application's own service-account session). Omitted
	// from the wire payload when empty, preserving today's request shape
	// for CheckAccess/Can/BatchCheck callers that never set it.
	SubjectID string `json:"subject_id,omitempty"`
}

AccessCheck is a single access check request (CONTRACT.md §1). ResourceID is a string (server-side UUID) rather than a typed UUID so callers can pass either a UUID string or, in future, other resource-id encodings without a breaking type change; the server is the source of truth for validation.

type AccessResult

type AccessResult struct {
	// Allowed reports whether the checked action is permitted.
	//
	// THIS FIELD ALONE CARRIES THE OUTCOME. ReasonCode explains it and never
	// contradicts it.
	Allowed bool `json:"allowed"`
	// Reason is the server's human-readable explanation, when it sent one.
	Reason string `json:"reason,omitempty"`
	// ReasonCode is the machine-readable decision reason (CONTRACT.md §11
	// rule 9, B1 deny-override): ReasonCodeAllowed, ReasonCodeNoGrant or
	// ReasonCodeDeniedByRule.
	//
	// THE TWO REFUSALS MEAN OPPOSITE THINGS to the person on the other end.
	// no_grant says "ask an admin for access"; denied_by_rule says "an admin
	// has already decided". An application that cannot tell them apart sends
	// users to raise tickets that will be refused — which is why the contract
	// forbids collapsing them into a bare false.
	//
	// Empty when the server omits the field: a newer SDK against an older
	// server treats it as absent, never as an error. An unrecognised value is
	// surfaced verbatim and never changes Allowed — which is why this is a
	// plain string rather than a defined type with a closed set of constants.
	ReasonCode string `json:"reason_code,omitempty"`
}

AccessResult is the outcome of a single access check (mirrors CheckAccessResponse).

type AuthError

type AuthError struct {
	Message string
	// Reason is an OPTIONAL stable, machine-readable failure code. It is
	// populated for CONTRACT.md §12.4 ID-token validation failures — one of
	// invalid_alg, unknown_kid, invalid_signature, invalid_issuer,
	// invalid_audience, token_expired, nonce_mismatch (§12 T1 reference
	// judgment call 2: the reason code rides on the EXISTING AuthError type
	// via this additive field, rather than a second error class) — and left
	// "" for every pre-existing AuthError construction site, which is fully
	// backward compatible (§12 port addendum item 17).
	Reason string
}

AuthError represents an authentication failure: wrong credentials, expired session, MFA failure, or a 401 on refresh (CONTRACT.md §2).

func (*AuthError) Error

func (e *AuthError) Error() string

func (*AuthError) Is

func (e *AuthError) Is(target error) bool

Is reports whether target is the ErrAuth sentinel, enabling errors.Is(err, ErrAuth) to match any *AuthError.

type AuthorizationRequest

type AuthorizationRequest struct {
	// URL is the fully-built authorization URL to redirect the browser to.
	URL string
	// State is a CSPRNG CSRF value (>=128 bits, base64url unpadded) to
	// compare against the `state` the IdP returns. Not a secret.
	State string
	// Nonce is a CSPRNG replay-protection value (>=128 bits) that must equal
	// the ID token's `nonce` claim. Not a secret.
	Nonce string
	// CodeVerifier is the PKCE verifier, secret for its whole lifetime
	// (§12.5). Pass it back into OidcExchange.
	CodeVerifier Sensitive
}

AuthorizationRequest is the result of OidcBegin — everything the caller needs to start an authorization-code + PKCE login (CONTRACT.md §12.1).

The caller owns this state (§12.3 rule 1). The SDK stores nothing: persist State, Nonce and CodeVerifier in your own HTTP session (or via an OidcStateStore), redirect the browser to URL, and pass Nonce and CodeVerifier back into OidcExchange when the code arrives.

type AuthzError

type AuthzError struct {
	Message    string
	Action     string
	ResourceID string
}

AuthzError represents an authorization failure: the caller is authenticated but lacks permission for the requested operation (CONTRACT.md §2). Action/ResourceID are optional and populated when known from the response body.

func (*AuthzError) Error

func (e *AuthzError) Error() string

func (*AuthzError) Is

func (e *AuthzError) Is(target error) bool

Is reports whether target is the ErrAuthz sentinel, enabling errors.Is(err, ErrAuthz) to match any *AuthzError.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the AXIAM SDK's REST entry point (CONTRACT.md §1-§10). See NewClient.

func NewClient

func NewClient(baseURL, tenantSlug string, opts ...Option) (*Client, error)

NewClient constructs a Client. baseURL and tenantSlug are positional and required (D-03): an empty tenantSlug returns an *AuthError — AXIAM is multi-tenant and there is no default tenant, so this can never be a silent default (CONTRACT.md §5, SC#1).

The returned Client always owns a per-instance cookiejar and a TLS-1.3-minimum transport; WithHTTPClient may override the Transport/timeout, but the SDK re-applies its own jar and TLS config over any supplied client afterward (D-09) so neither can be silently dropped or bypassed.

func (*Client) BatchCheck

func (c *Client) BatchCheck(ctx context.Context, reqs []AccessCheck) ([]AccessResult, error)

BatchCheck performs POST /api/v1/authz/check/batch (CONTRACT.md §1), evaluating an ordered list of checks; results are returned in the same order as reqs. Eligible for CF-01's bounded retry (read-only).

func (*Client) Can

func (c *Client) Can(ctx context.Context, action, resourceID string, scope ...string) (bool, error)

Can is an alias for CheckAccess targeting browser/UI scenarios (CONTRACT.md §1 note) — returns only the allowed boolean.

func (*Client) CheckAccess

func (c *Client) CheckAccess(ctx context.Context, action, resourceID string, scope ...string) (bool, string, error)

CheckAccess performs POST /api/v1/authz/check (CONTRACT.md §1), evaluating a single authorization check for the given action/ resourceID/scope. This is a read-only, idempotent operation eligible for CF-01's bounded retry on transient NetworkError.

func (*Client) CheckAccessAs

func (c *Client) CheckAccessAs(ctx context.Context, subjectID, action, resourceID string, scope ...string) (bool, string, error)

CheckAccessAs performs POST /api/v1/authz/check (CONTRACT.md §1) on behalf of subjectID rather than this Client's own session (CONTRACT.md §11.2). This is additive alongside CheckAccess — existing callers/signatures are unchanged — and exists specifically so declarative authorization helpers (middleware.RequireAccess) can evaluate the check for the request's authenticated user_id instead of the application's own (typically service-account) session. A blank subjectID behaves exactly like CheckAccess (the subject_id field is omitted from the wire request).

func (*Client) CheckAccessDecision

func (c *Client) CheckAccessDecision(ctx context.Context, subjectID, action, resourceID string, scope ...string) (AccessResult, error)

CheckAccessDecision performs the same check as CheckAccess but returns the FULL AccessResult, including the §11 rule 9 ReasonCode.

It exists because CheckAccess's (bool, string, error) tuple predates that field and cannot carry it without a breaking signature change. The distinction it surfaces is not cosmetic: no_grant means "ask an admin for access", denied_by_rule means "an admin has already decided", and an application that cannot tell them apart sends users to raise tickets that will be refused.

subjectID may be blank, in which case the check evaluates against this Client's own session exactly as CheckAccess does; a non-blank value behaves like CheckAccessAs (§11.2).

func (*Client) Close

func (c *Client) Close() error

Close releases this Client's local resources (CONTRACT.md §18).

It is idempotent — calling it twice is not an error. Cleanup runs from error paths, and an error path that itself fails hides the original problem. It returns error only to satisfy io.Closer; the error is always nil.

CLOSE DOES NOT LOG OUT. §18.1 rule 5: shutting down a client releases LOCAL resources and never reaches the network. The server-side session deliberately outlives the Client value, which is what lets a process restart and resume; a Close that logged out would silently end every user's session on each deploy. Call Logout first if ending the session is what you want.

After Close returns, every operation on this Client fails with *NetworkError rather than silently reconnecting.

func (*Client) DeviceAuthorize

func (c *Client) DeviceAuthorize(ctx context.Context, params DeviceAuthorizeParams) (DeviceAuthorization, error)

DeviceAuthorize performs `POST /oauth2/device_authorization` (CONTRACT.md §14.1) — start the device grant and obtain the code pair.

UNAUTHENTICATED BY DESIGN. A device that cannot show a browser also cannot hold a client secret, so this never sends client_secret and never refuses a Client built without one (§14.1).

Returns an *AuthError when the discovery document advertises no device_authorization_endpoint. The URL is never built by concatenation onto the issuer: that works against AXIAM and breaks against every other OP the same code is pointed at.

func (*Client) DeviceLogin

func (c *Client) DeviceLogin(ctx context.Context, params DeviceLoginParams) (OidcTokenSet, error)

DeviceLogin is the composed §14.3 helper: start the grant, hand the caller the user code, poll to completion.

params.OnUserCode is called BEFORE the first poll — §14.3 rule 2 requires the caller to have had the chance to display the code before polling begins. The SDK never prints it: what the device does with it (screen, QR code, e-ink panel) is the application's decision. An error from OnUserCode aborts without polling.

Per §14.3 rule 4 (contract 1.7 errata) the token set is RETURNED; whether it is adopted is params.AdoptAsCredential, the same opt-in flag LoginClientCredentials uses in this SDK.

Polling follows §14.2: the interval comes from the response; slow_down adds 5 s PERMANENTLY; authorization_pending loops; access_denied and expired_token raise distinct errors; polling stops at ExpiresIn even if the server has not yet said expired_token. A 5xx or transport failure mid-poll is NOT terminal (rule 6) — the loop absorbs it and tries again, bounded by the same deadline, because a server restart must not lose a grant the user has already approved.

ctx cancellation is honoured between polls: a device powering down should not have to wait out the interval.

func (*Client) DevicePoll

func (c *Client) DevicePoll(ctx context.Context, params DevicePollParams) (OidcTokenSet, error)

DevicePoll performs ONE `POST /oauth2/token` with the device-code grant (CONTRACT.md §14.1).

The raw single call, so an application driving its own loop (a UI rendering a countdown, say) can. All five RFC 8628 §3.5 answers surface as *OAuthProtocolError — authorization_pending and slow_down included — so a hand-rolled loop sees exactly what DeviceLogin sees. Most callers want DeviceLogin.

func (*Client) Introspect

func (c *Client) Introspect(ctx context.Context, params IntrospectParams) (IntrospectionResult, error)

Introspect performs `POST /oauth2/introspect` (RFC 7662, CONTRACT.md §12.1) — ask the server whether a token is active and, if so, for its metadata.

Requires confidential-client credentials (§12.1 note 4). A 401 here is a CLIENT-CREDENTIAL failure surfaced as *OAuthProtocolError; it never enters the §9 refresh guard, because refreshing the session cannot fix a bad client_secret (§12.3 rule 3) — Introspect never touches Client.guard or the oidc_refresh guard at all.

func (*Client) Login

func (c *Client) Login(ctx context.Context, email, password string) (LoginResult, error)

Login performs POST /api/v1/auth/login (CONTRACT.md §1). On success (no MFA), tokens are already present in the cookie jar and the org_id claim has been resolved+cached. When the server signals MFA is required, returns LoginResult{MFARequired: true, ...} — this is an expected outcome, not an error.

func (*Client) LoginClientCredentials

func (c *Client) LoginClientCredentials(ctx context.Context, params LoginClientCredentialsParams) (OidcTokenSet, error)

LoginClientCredentials performs `POST /oauth2/token` with `grant_type=client_credentials` (CONTRACT.md §12.1) — service-account machine-to-machine login.

Requests no "openid" scope, so the response carries no id_token. Pass params.AdoptAsCredential = true to additionally use the returned access token as this Client's bearer credential for subsequent REST calls (§12.1, a MAY).

Returns *AuthError, client-side with no wire call, when the Client was not constructed with WithOidcClientSecret — this grant cannot be performed by a public client.

func (*Client) Logout

func (c *Client) Logout(ctx context.Context) error

Logout performs POST /api/v1/auth/logout (CONTRACT.md §1) and clears in-memory token state.

func (*Client) LogoutURL

func (c *Client) LogoutURL(ctx context.Context, params LogoutURLParams) (string, error)

LogoutURL builds the RP-initiated logout URL to redirect the user agent to (CONTRACT.md §12.7.2).

Performs NO network I/O beyond the discovery fetch the SDK caches anyway, and does NOT clear this Client's own session: whether the local session ends is the application's decision — a backend holding a service-account session must not lose it because a USER logged out.

end_session_endpoint is read from discovery and never synthesised from the issuer (rule 1). Code that concatenates works against AXIAM and breaks against every other OP the same application is pointed at.

PostLogoutRedirectURI is passed through UNVALIDATED against any local list (rule 3): the allow-list lives in the client's server-side registration, and a client-side copy would drift and reject a URI an operator had just registered.

func (*Client) OidcBegin

func (c *Client) OidcBegin(configuration OidcConfiguration, params OidcBeginParams) (AuthorizationRequest, error)

OidcBegin builds an authorization request (CONTRACT.md §12.1) — PURE LOCAL COMPUTATION, no network I/O.

Generates a 32-byte CSPRNG State and Nonce (base64url, unpadded) and a fresh PKCE verifier/challenge pair using S256 ONLY — "plain" is not implemented anywhere in this SDK. The URL is built from configuration's AuthorizationEndpoint with exactly the eight parameters §12.1 rule 5 mandates, plus any ExtraParams the caller adds.

Nothing is stored: persist the returned State, Nonce and CodeVerifier yourself (§12.3 rule 1).

Returns a plain (non-taxonomy) error — deliberately NOT *AuthError — when ExtraParams tries to override one of the eight SDK-owned parameters: this is a programming error caught at call time (§12 port addendum item 9).

func (*Client) OidcDiscover

func (c *Client) OidcDiscover(ctx context.Context) (OidcConfiguration, error)

OidcDiscover performs `GET /.well-known/openid-configuration` (CONTRACT.md §12.1) — fetch and cache the OIDC discovery document, with a >=5-minute TTL and single-flight de-duplication of concurrent calls (§12.3 rule 6).

The document's own Issuer is authoritative for ID-token validation and may legitimately differ from the Client's base URL behind a proxy, so a mismatch is never treated as an error.

func (*Client) OidcExchange

func (c *Client) OidcExchange(ctx context.Context, params OidcExchangeParams) (OidcTokenSet, error)

OidcExchange performs `POST /oauth2/token` with `grant_type=authorization_code` (CONTRACT.md §12.1) — exchange an authorization code for a token set, validating the returned ID token in full before returning.

params.Nonce is mandatory: this grant always requests the "openid" scope, so §12.4 rule 6 always applies. If ANY §12.4 rule fails, the whole token set is discarded and *AuthError is raised with the matching Reason code — the access and refresh tokens from the same response are never returned (§12.4 rule 7).

func (*Client) OidcRefresh

func (c *Client) OidcRefresh(ctx context.Context, params OidcRefreshParams) (OidcTokenSet, error)

OidcRefresh performs `POST /oauth2/token` with `grant_type=refresh_token` (CONTRACT.md §12.1) under a single-flight refresh guard (§9): concurrent callers collapse into ONE HTTP request and all receive the same OidcTokenSet (or the same failure), with no retry loop on failure (§9.3).

This is a DISTINCT operation from Client.Refresh, which drives the cookie/opaque-token session path at POST /api/v1/auth/refresh (§5.1). The two are never merged, aliased, or made to fall back to one another (§12.1 "oidc_refresh vs refresh").

This method uses its OWN single-flight guard (oidcState.pendingRefresh) — a SEPARATE instance from the cookie-session Client.guard (internal/refreshguard.Guard). That type's RefreshIfNeeded API compares an "observed" axiam_access cookie value against its own cache, which has no meaning for an OAuth2 refresh_token grant operating on an entirely different, cookie-independent token namespace; reusing the literal same Guard instance for both would corrupt its cookie-session comparison state with an unrelated token stream. A dedicated guard, built from the exact mechanism CONTRACT.md §9 prescribes for Go (a mutex plus a channel carrying the shared result), still satisfies §9's actual requirement for THIS operation — exactly one in-flight refresh, waiters share the outcome, no retry on failure — without that cross-talk. (Documented deviation from the literal wording of the TypeScript reference, which shares one generic mutex-based guard across both operations because its guard has no token-comparison state to corrupt in the first place.)

An id_token in the response is validated against §12.4 rules 1-5 and 7; rule 6 (nonce) is skipped, since OIDC Core §12.2 does not require a nonce in a refresh-issued ID token.

func (*Client) Refresh

func (c *Client) Refresh(ctx context.Context) error

Refresh performs POST /api/v1/auth/refresh (CONTRACT.md §1), routed through the sync.Mutex single-flight guard (§9) so concurrent 401s share exactly one in-flight refresh call. A 401 on the refresh call itself is AuthError with no retry (§9.3).

func (*Client) Revoke

func (c *Client) Revoke(ctx context.Context, params RevokeParams) error

Revoke performs `POST /oauth2/revoke` (RFC 7009, CONTRACT.md §12.1) — revoke an access or refresh token.

Per RFC 7009 the server answers 200 for unknown, expired and already-revoked tokens alike, so revocation is IDEMPOTENT: any 2xx is success and no error is raised for a token the server has never seen. Only a 401 (client authentication failed) is an error, surfaced as *OAuthProtocolError (§12.1 note 5, §12.3 rule 3); a 5xx is still a *NetworkError (revoke returning void does not make a server error "success").

Returns *AuthError, client-side with no wire call, when the Client was not constructed with WithOidcClientSecret.

func (*Client) SsoComplete

func (c *Client) SsoComplete(ctx context.Context, params SsoCompleteParams) (SsoCompleteResult, error)

SsoComplete performs `POST /api/v1/auth/federation/oidc/callback` (CONTRACT.md §12.1) — step 2 of upstream SSO: consumes the single-use state, provisions or links the user, and establishes the session.

The session arrives as Set-Cookie, NOT in the response body (§12.1 note 6), so this call goes through the SAME §4 cookie-jar path every other authenticated call already uses — no separate wiring needed. On success the session is marked authenticated via the same absorption Login/VerifyMfa perform (decode the org_id claim, seed the refresh guard), mirroring the TypeScript reference's onAuthenticated() hook (CONTRACT.md §12 T1 judgment call 16).

§12.4 does not apply here — no ID token ever reaches the SDK on the federation path.

func (*Client) SsoStart

func (c *Client) SsoStart(ctx context.Context, params SsoStartParams) (SsoStartResult, error)

SsoStart performs `POST /api/v1/auth/federation/oidc/start` (CONTRACT.md §12.1) — step 1 of first-time SSO against an UPSTREAM IdP. No JWT required.

One tenant form (params.TenantID or params.TenantSlug) and one org form (params.OrgID or params.OrgSlug) must be resolvable, from the arguments or from the Client's own construction options (§5.1) — this Client always has a tenant slug (NewClient requires one), so the tenant form is always resolvable in practice; the organization form still needs WithOrgID/ WithOrgSlug (or an explicit argument) unless the Client already resolved one from a prior Login.

Redirect the browser to the returned AuthorizeURL and round-trip State back into SsoComplete unmodified — the server keeps the nonce to itself (§12.1 note 7).

Returns *AuthError, client-side with no wire call, when tenant or org context cannot be resolved.

func (*Client) TokenExchange

func (c *Client) TokenExchange(ctx context.Context, params TokenExchangeParams) (ExchangedToken, error)

TokenExchange performs `POST /oauth2/token` with the RFC 8693 grant (CONTRACT.md §15.1) — exchange a token for a NARROWER one.

The exchanging client authenticates (client_secret_post): unlike §14's device, this is a confidential service, so a Client with no secret fails here client-side, with no wire call.

What this method deliberately does NOT do:

  • No default ActorToken (§15.2 rule 1). Leaving it zero asks for IMPERSONATION; the SDK will not quietly reuse the client's own session token as the actor and turn that into a delegation.
  • No retry or downgrade on unauthorized_client (rule 2) — a registration fact an operator must fix.
  • No auto-narrowing on invalid_scope (rule 3). The server refuses instead of silently narrowing precisely so the caller finds out here.
  • No adoption (rule 5). The returned token is handed onward in one outbound call; adopting it would silently re-privilege every subsequent call this client makes. A MUST NOT, where LoginClientCredentials adoption is an opt-in MAY.

A cross-tenant subject token answers invalid_grant, identically to an expired one. The SDK does not try to tell them apart (§15.3): the server collapses them because distinguishing them is a tenant-enumeration signal.

func (*Client) UmaDeleteResource

func (c *Client) UmaDeleteResource(ctx context.Context, pat Sensitive, id string) error

UmaDeleteResource performs `DELETE /uma2/rreg/resource_set/{id}` (§20.1) — deregister a resource set.

func (*Client) UmaExchangeTicket

func (c *Client) UmaExchangeTicket(ctx context.Context, params UmaExchangeTicketParams) (RequestingPartyToken, error)

UmaExchangeTicket performs `POST /oauth2/token` with the UMA ticket grant (§20.1) — redeem a permission ticket for a Requesting Party Token.

Unlike the Protection API above, this is a token-endpoint grant: the CLIENT authenticates through the form body (client_secret_post), so a Client with no secret fails here client-side, with no wire call.

What this method deliberately does NOT do:

  • NO RETRY, EVER (§20.2 rule 6) — not on 5xx, not on a timeout, not on invalid_grant. This is the one documented exception to §16, and it is a security rule rather than a performance one: the ticket is consumed BEFORE the request is evaluated, so a failed exchange has already spent it, and a retry is a second redemption — exactly the concurrent redemption a server whose storage engine this SDK cannot attest may admit twice (ilpanich/axiam#302). The property holds structurally here: this call goes straight through doRequest and never touches retry.go's policy.
  • No defaulted ClaimToken (rule 2). It is the only channel that names the requesting party; defaulting it to the resource server's own PAT would mint an RPT for the resource server rather than for the user.
  • No auto-narrowing on access_denied (rule 3). A partial grant is refused whole, and whether two-of-three permissions is useful is the calling application's judgement, not this SDK's.
  • No adoption (rule 4). The RPT is the REQUESTING PARTY's token; adopting it would re-privilege every later call this resource server makes as that user.
  • No refresh token (rule 5) — the grant issues none, and RequestingPartyToken has nowhere to put one. Re-run the grant with a new ticket to get a fresh RPT.

The four ticket refusals — unknown, expired, already used, minted by another client — all arrive as one invalid_grant, and this SDK does not guess which (§20.4): the server collapses them because telling them apart lets a caller probe for live ticket handles.

func (*Client) UmaListResources

func (c *Client) UmaListResources(ctx context.Context, pat Sensitive) ([]string, error)

UmaListResources performs `GET /uma2/rreg/resource_set` (§20.1) — the ids THIS client registered.

Not the tenant's resource tree: the server scopes the listing to the registering client, so a PAT is not an enumeration handle.

func (*Client) UmaReadResource

func (c *Client) UmaReadResource(ctx context.Context, pat Sensitive, id string) (ResourceSet, error)

UmaReadResource performs `GET /uma2/rreg/resource_set/{id}` (§20.1).

func (*Client) UmaRegisterResource

func (c *Client) UmaRegisterResource(ctx context.Context, pat Sensitive, resource ResourceSet) (ResourceSet, error)

UmaRegisterResource performs `POST /uma2/rreg/resource_set` (§20.1) — register a resource set.

The returned ID is THE AXIAM RESOURCE ID, not a parallel identifier: the same UUID is directly usable as RequestedPermission.ResourceID and as the resource id anywhere else in this SDK.

pat is a Protection API Token — an ordinary access token obtained through LoginClientCredentials with the uma_protection scope. §20.2 rule 1: it must be a CLIENT-credentials token, because a minted ticket is bound to the client_id that minted it. This SDK never substitutes the client's own session token when the caller passes none; an empty pat is a client-side error with no wire call.

func (*Client) UmaRequestTicket

func (c *Client) UmaRequestTicket(ctx context.Context, pat Sensitive, permissions []RequestedPermission) (Sensitive, error)

UmaRequestTicket performs `POST /uma2/perm` (§20.1) — mint a permission ticket for the (resource, scopes) pairs a caller lacks.

The ticket comes back wrapped: for its 60-second life it is the credential that converts into an RPT, and a short lifetime is not the same as a harmless one (§20.6).

func (*Client) UmaUpdateResource

func (c *Client) UmaUpdateResource(ctx context.Context, pat Sensitive, id string, resource ResourceSet) (ResourceSet, error)

UmaUpdateResource performs `PUT /uma2/rreg/resource_set/{id}` (§20.1) — replace a resource set's state.

resource.ResourceScopes REPLACES the declared list; it does not merge with it (§20.2 rule 8). This method deliberately performs no read-modify-write: folding the current scopes into the payload as a convenience would make removing a scope impossible through this SDK.

func (*Client) VerifyLogoutToken

func (c *Client) VerifyLogoutToken(ctx context.Context, token string, configuration *OidcConfiguration) (VerifiedLogoutToken, error)

VerifyLogoutToken verifies a back-channel logout token the OP POSTed to this application's backchannel_logout_uri (CONTRACT.md §12.7.3).

Every check exists because skipping it has a name:

  1. Signature, through the same §12.4 JWKS verifier the ID-token path uses — no second key-fetching path — which already pins EdDSA and requires a kid, so key rotation cannot be defeated by omitting the header.
  2. iss/aud: a token minted for another RP is not accepted here.
  3. `events` carries the back-channel-logout key. This is what distinguishes a logout token from an ID token; skipping it means accepting a replayed ID token as a logout instruction.
  4. `nonce` is ABSENT. Back-Channel Logout 1.0 §2.4 forbids it, and its presence is the documented signature of an ID token being replayed. Rejected, not ignored.
  5. At least one of sid/sub — a token naming neither identifies nothing.
  6. exp in the future, iat recent.

Returns sid/sub/jti — never a bare bool, because the RP has to know WHICH session to end. Dedup on JTI yourself: delivery is at-least-once, so a valid token legitimately arrives twice, and an SDK-side guard would have no durable store and would silently drop a real second logout after a restart.

func (*Client) VerifyMfa

func (c *Client) VerifyMfa(ctx context.Context, mfaToken Sensitive, code string) (LoginResult, error)

VerifyMfa performs POST /api/v1/auth/mfa/verify (CONTRACT.md §1), completing the two-phase flow started by Login when MFARequired was true.

type ConfigClampedEvent

type ConfigClampedEvent struct {
	// Setting is the setting's name, e.g. "WithDecisionMemoTTL".
	Setting string
	// Requested is the value the caller asked for, rendered.
	Requested string
	// Effective is the value actually in force, rendered.
	Effective string
	// ContractReference is the §-reference for the limit, e.g. "§17.1 rule 2".
	ContractReference string
}

ConfigClampedEvent is emitted at construction, once per caller-supplied setting the SDK clamped (CONTRACT.md §19.1, §19.2 rule 6).

Two places in the contract require clamping rather than rejecting: §16.1's attempt cap, base delay and delay cap, and §17.1 rule 2's memo TTL. Both clamps are right — rejecting would break a caller whose configuration was merely optimistic, and honoring would let one client become the herd §16 exists to prevent. Doing it SILENTLY is the part that is wrong.

An operator who set a 60-second memo TTL believes they have one. They have five seconds, and their staleness reasoning is off by a factor of twelve with nothing anywhere to say so.

It is NOT emitted for a value already within its limit: an event that fires when nothing happened trains its reader to ignore it.

type Confirmation

type Confirmation = jwks.Confirmation

Confirmation is the RFC 7800 "cnf" claim carried by a sender-constrained token. Its presence changes what the token IS: it is no longer a bearer credential.

type DPoPJtiStore

type DPoPJtiStore = dpop.JtiStore

DPoPJtiStore is the §21.7.2 check 8 replay guard.

type DPoPRequest

type DPoPRequest = dpop.Request

DPoPRequest carries what VerifyDPoPProof needs about the current request.

type DeviceAuthorization

type DeviceAuthorization struct {
	// DeviceCode is the device's polling credential (§14.5 secret).
	DeviceCode Sensitive
	// UserCode is the short code the human types into the verification page.
	UserCode string
	// VerificationURI is where the human goes to enter UserCode.
	VerificationURI string
	// VerificationURIComplete embeds the user code in the URI, when the server
	// sent one — prefer it when the device can render a QR code. Never
	// synthesised by concatenation when absent (§14.3): its format is the
	// server's to choose.
	VerificationURIComplete string
	// ExpiresIn is the seconds until the grant expires. Polling stops here
	// (§14.2 rule 4).
	ExpiresIn int
	// Interval is the seconds between polls, from the response, defaulted to
	// 5 s when the server omitted it (§14.2 rule 2).
	Interval int
}

DeviceAuthorization is the DeviceAuthorizationResponse — what the device shows its user, plus the device_code it polls with (§14.1).

DeviceCode is Sensitive (§14.5): a bearer credential for the lifetime of the grant. UserCode deliberately is NOT — it exists to be read aloud and typed by a human, and wrapping it would defeat the one thing it is for. Neither may be logged; displaying UserCode is the caller's job.

type DeviceAuthorizeParams

type DeviceAuthorizeParams struct {
	// Scope is the space-separated scope string to request. Omitted when empty.
	Scope string
	// TenantID supplies the mandatory `tenant_id` query parameter (§12.1
	// note 2).
	TenantID string
	// Configuration is a pre-fetched discovery document; fetched via
	// OidcDiscover when zero.
	Configuration *OidcConfiguration
}

DeviceAuthorizeParams are the arguments to Client.DeviceAuthorize (CONTRACT.md §14.1).

type DeviceLoginParams

type DeviceLoginParams struct {
	// Scope is the space-separated scope string to request.
	Scope string
	// TenantID supplies the `tenant_id` query parameter.
	TenantID string
	// Configuration is a pre-fetched discovery document.
	Configuration *OidcConfiguration
	// OnUserCode is called with the DeviceAuthorization BEFORE the first poll
	// (§14.3 rule 2), so the caller can display the code. The SDK never prints
	// it: what the device does with it is the application's decision.
	//
	// Returning an error aborts the login without polling — a device that
	// cannot display the code has no reason to wait for an approval nobody can
	// give.
	OnUserCode func(DeviceAuthorization) error
	// AdoptAsCredential mirrors LoginClientCredentialsParams: when true, the
	// issued access token becomes this client's Authorization header.
	//
	// §14.3 rule 4 (contract 1.7) defers to the §12.1 adoption MAY, and this
	// SDK's settled posture there is an opt-in flag — so DeviceLogin takes the
	// same one rather than inventing a second posture.
	AdoptAsCredential bool
}

DeviceLoginParams are the arguments to Client.DeviceLogin (§14.3).

type DevicePollParams

type DevicePollParams struct {
	// DeviceCode comes from DeviceAuthorization.
	DeviceCode Sensitive
	// TenantID supplies the `tenant_id` query parameter.
	TenantID string
	// Configuration is a pre-fetched discovery document.
	Configuration *OidcConfiguration
}

DevicePollParams are the arguments to Client.DevicePoll (§14.1).

type ExchangedToken

type ExchangedToken struct {
	// AccessToken is the issued token (§15.5 secret).
	AccessToken Sensitive
	// IssuedTokenType is what the server actually issued. Mandatory in
	// RFC 8693 §2.2.1 and surfaced rather than dropped (§15.2 rule 6), so a
	// client that asked for one type and got another can tell.
	IssuedTokenType string
	// TokenType is the token type (Bearer).
	TokenType string
	// ExpiresIn is the lifetime in seconds — never longer than the subject
	// token's remaining life.
	ExpiresIn int
	// Scope is the GRANTED scope, which may be narrower than requested even on
	// success (§15.2 rule 7). Read it rather than assuming the request was
	// honoured verbatim.
	Scope string
}

ExchangedToken is the result of an exchange (wire schema TokenExchangeResponse, §15.1).

There is NO RefreshToken field, and that is deliberate (§15.2 rule 4). RFC 8693 issues none, so the type cannot represent one: an application that wants a fresh exchanged token re-runs the exchange. This result also never enters the §9 single-flight refresh guard — there is nothing to refresh.

type IDTokenClaims

type IDTokenClaims struct {
	// Iss is the issuer — matched for exact string equality against the
	// discovery document's issuer (rule 3).
	Iss string
	// Sub is the authenticated end user's stable identifier at AXIAM.
	Sub string
	// Aud is the audience — contains the relying party's client_id (rule 4).
	// May hold one or more values on the wire; always normalized to a slice
	// here.
	Aud []string
	// Exp is the expiry time (epoch seconds).
	Exp int64
	// Iat is the issued-at time (epoch seconds).
	Iat int64
	// Nbf is the not-before time (epoch seconds), when the server sends one.
	Nbf *int64
	// Nonce is the nonce echoed back from the authorization request (rule 6).
	Nonce string
	// Azp is the authorized party — required to equal client_id when Aud
	// holds multiple audiences (rule 4).
	Azp string
	// Extra preserves any claim not already modeled above (nil when none).
	Extra map[string]any
}

IDTokenClaims is the decoded, ALREADY-VALIDATED ID-token claim set carried by OidcTokenSet.IDClaims (CONTRACT.md §12.1).

Claim names are kept verbatim in their JWT/OIDC spelling (Iss, Sub, Aud, ...) rather than Go's usual field-name conventions: they are protocol identifiers a caller cross-references against OIDC Core. Extra preserves any further claim the server sends (e.g. email, preferred_username) — the ID token's full claim set is not enumerated by openapi.json, so unknown claims MUST be preserved and MUST NOT be rejected (§12.1).

type IDTokenFailureReason

type IDTokenFailureReason string

IDTokenFailureReason is one of the seven CONTRACT.md §12.3/§12.4 stable, machine-readable ID-token validation failure codes, carried on the resulting *AuthError's Reason field.

const (
	ReasonInvalidAlg       IDTokenFailureReason = "invalid_alg"
	ReasonUnknownKid       IDTokenFailureReason = "unknown_kid"
	ReasonInvalidSignature IDTokenFailureReason = "invalid_signature"
	ReasonInvalidIssuer    IDTokenFailureReason = "invalid_issuer"
	ReasonInvalidAudience  IDTokenFailureReason = "invalid_audience"
	ReasonTokenExpired     IDTokenFailureReason = "token_expired"
	ReasonNonceMismatch    IDTokenFailureReason = "nonce_mismatch"
)

The seven §12.4 reason codes, used verbatim (contract-fixed spelling).

type IntrospectParams

type IntrospectParams struct {
	// Token is the token to introspect.
	Token Sensitive
	// TokenTypeHint is an optional RFC 7662 token_type_hint (access_token /
	// refresh_token).
	TokenTypeHint string
	// TenantID is the tenant UUID for the tenant_id query parameter (§12.3
	// rule 4).
	TenantID string
	// Configuration is a pre-fetched discovery document. Fetched via
	// OidcDiscover when nil.
	Configuration *OidcConfiguration
}

IntrospectParams are the arguments to Introspect (RFC 7662). Requires confidential-client credentials (§12.1 note 4).

type IntrospectionResult

type IntrospectionResult struct {
	// Active reports whether the token is currently active.
	Active bool
	// Sub is the subject the token was issued to.
	Sub string
	// ClientID is the client the token was issued to.
	ClientID string
	// Scope is the scope granted to the token.
	Scope string
	// TokenType is the token type ("Bearer").
	TokenType string
	// Exp is the expiry time, epoch seconds. Zero when absent.
	Exp int64
	// Iat is the issued-at time, epoch seconds. Zero when absent.
	Iat int64
}

IntrospectionResult is the RFC 7662 introspection result (wire schema IntrospectionResponse). Only Active is guaranteed; the server omits the metadata fields for an inactive token (zero values below).

type JWKSVerifier

type JWKSVerifier = jwks.Verifier

JWKSVerifier is the public entry point for this SDK's local JWKS verification (CONTRACT.md §10/§10.1, D-06) — the shared local-verify mechanism consumed by the net/http middleware (package middleware). It is a thin re-export of the internal jwks.Verifier so callers outside this module never need to import an internal/ package directly.

Use JWKSVerifier.VerifyAccessToken: it applies the complete §10.1 minimum local-verification set (EdDSA-pinned signature, REQUIRED exp, honoured nbf, asserted tenant_id, conditional iss/aud, bounded clock skew).

JWKSVerifier.VerifySignatureOnlyUnchecked is the raw signature-only primitive §10.1 permits for integrators writing their own policy. It is NOT a guard: it checks no claim at all, so an expired token, a token carrying no exp, or a token minted for a DIFFERENT tenant under the same organization-wide JWKS all verify successfully. Do not build an authentication decision on it.

func NewJWKSVerifier

func NewJWKSVerifier(ctx context.Context, baseURL string, hc *http.Client) (*JWKSVerifier, error)

NewJWKSVerifier constructs a JWKSVerifier bound to {baseURL}/oauth2/jwks (trailing slash on baseURL trimmed before joining). hc may be nil, in which case a default *http.Client is used. The cache is registered but not eagerly populated; the first verification triggers the initial fetch.

This is the exported constructor middleware.Middleware examples wire against — see examples/middleware-guard.

type LoginClientCredentialsParams

type LoginClientCredentialsParams struct {
	// Scope is an optional scope to request. This grant requests no "openid"
	// scope and the response carries no id_token (§12.1).
	Scope string
	// TenantID is the tenant UUID for the tenant_id query parameter (§12.3
	// rule 4).
	TenantID string
	// Configuration is a pre-fetched discovery document. Fetched via
	// OidcDiscover when nil.
	Configuration *OidcConfiguration
	// AdoptAsCredential adopts the returned access_token as this Client's
	// bearer credential for subsequent REST calls on the same session — the
	// §12.1 "login_client_credentials as a credential source" allowance (a
	// MAY, hence opt-in and false by default). The token is held behind
	// Sensitive and applied only in decorateRequest (never a public field,
	// never the cookie jar, never sent to /oauth2/*).
	AdoptAsCredential bool
}

LoginClientCredentialsParams are the arguments to LoginClientCredentials (`grant_type=client_credentials`).

type LoginResult

type LoginResult struct {
	// MFARequired is true when the server responded with an MFA challenge
	// instead of a completed session; call VerifyMfa next with MFAToken.
	MFARequired bool
	// MFAToken carries the opaque challenge token when MFARequired is
	// true. Treated as sensitive (short-lived bearer of "logging in as
	// this user").
	MFAToken Sensitive
	// AvailableMethods lists MFA methods available to satisfy the
	// challenge (only populated when MFARequired is true).
	AvailableMethods []string
	// SessionID is the server-issued session id (only populated on a
	// completed, non-MFA-pending login/verify_mfa).
	SessionID string
	// ExpiresIn is the access token lifetime in seconds, as reported by
	// the server (only populated on a completed login/verify_mfa).
	ExpiresIn uint64
}

LoginResult is the outcome of Login/VerifyMfa (CF-04). MFA required is an expected outcome, not an error: check MFARequired before assuming the session is established.

type LogoutURLParams

type LogoutURLParams struct {
	// IDToken is a previously-issued ID token, placed in id_token_hint — the
	// only AUTHENTICATED statement of which session is being ended.
	IDToken Sensitive
	// PostLogoutRedirectURI is where the OP sends the browser afterwards.
	// Honoured only on exact match against the client's registered allow-list
	// — a server-side check the SDK deliberately does not duplicate (§12.7.2
	// rule 3).
	PostLogoutRedirectURI string
	// State is an opaque value echoed back on the redirect. Generated and
	// checked by the caller (§12.7.2 rule 2), never by the SDK.
	State string
	// Configuration is a pre-fetched discovery document.
	Configuration *OidcConfiguration
}

LogoutURLParams are the arguments to Client.LogoutURL (§12.7.2).

type MemoryOidcStateStore

type MemoryOidcStateStore struct {
	// contains filtered or unexported fields
}

MemoryOidcStateStore is an in-memory reference implementation of OidcStateStore (CONTRACT.md §12.3 rule 1): per-instance (never process-global), single-use, TTL-bounded. Expired entries are dropped lazily on Save/Consume — there is NO background timer/goroutine, since a library must not keep the host process alive on its own.

Suitable for a single-process app and for tests. A multi-instance deployment needs a shared store (Redis, a database) — implement OidcStateStore directly for that; nothing in this SDK assumes this type.

func NewMemoryOidcStateStore

func NewMemoryOidcStateStore(ttl time.Duration) *MemoryOidcStateStore

NewMemoryOidcStateStore constructs a MemoryOidcStateStore. ttl is the entry lifetime; zero, negative, or greater than OidcStateTTL is CLAMPED to OidcStateTTL (10 minutes) — CONTRACT.md §12.3 rule 1 fixes that as the maximum, while a shorter TTL is honoured verbatim (useful in tests).

func (*MemoryOidcStateStore) Consume

func (s *MemoryOidcStateStore) Consume(state string) (OidcStateEntry, bool)

Consume atomically returns and deletes the entry for state. Deletion happens BEFORE the expiry check, so even an expired hit is removed rather than left to accumulate, and a second call can never return the same entry twice regardless of timing.

func (*MemoryOidcStateStore) Save

func (s *MemoryOidcStateStore) Save(entry OidcStateEntry) error

Save persists entry under its own State, expiring ttl from now.

func (*MemoryOidcStateStore) Size

func (s *MemoryOidcStateStore) Size() int

Size reports the number of unexpired entries currently held. Intended for tests and metrics.

type NetworkError

type NetworkError struct {
	Message string
	// RetryAfter carries a server-supplied Retry-After hint (CONTRACT.md §16.1),
	// zero when the response had none.
	//
	// It is a DURATION parsed from the header, never the raw header value, so
	// the redaction invariant above is untouched: a duration cannot carry a
	// token, a URL, or anything else a header might. §16 honors it as a floor
	// on the backoff — the server is stating when it will be ready, so
	// retrying sooner is not permitted.
	RetryAfter time.Duration
	// contains filtered or unexported fields
}

NetworkError represents a transport-level failure: connection refused, timeout, TLS error, DNS failure, or a server-side 5xx (CONTRACT.md §2).

cause is unexported and MUST only ever be populated via newNetworkError, which redacts sensitive headers from any wrapped *http.Response BEFORE constructing the error (D-04, Phase 17 CR-04 carry-forward) — never construct a NetworkError directly from an unredacted *http.Response.

func (*NetworkError) Error

func (e *NetworkError) Error() string

func (*NetworkError) Is

func (e *NetworkError) Is(target error) bool

Is reports whether target is the ErrNetwork sentinel, enabling errors.Is(err, ErrNetwork) to match any *NetworkError.

func (*NetworkError) Unwrap

func (e *NetworkError) Unwrap() error

Unwrap exposes the underlying (already-redacted) cause for errors.Is/As and errors.Unwrap chains.

type OAuthProtocolError

type OAuthProtocolError struct {
	AuthError
	// ErrorCode is the RFC 6749 "error" field (e.g. invalid_grant).
	ErrorCode string
	// ErrorDescription is the RFC 6749 "error_description" field.
	ErrorDescription string
}

OAuthProtocolError represents an RFC 6749 protocol error returned by an `/oauth2/*` endpoint as an OAuth2ErrorResponse body — `invalid_grant`, `invalid_client`, `invalid_request`, `unsupported_grant_type`, etc. (CONTRACT.md §2, §12.3 rule 3).

It is a language-idiomatic SUB-TYPE of AuthError, not a fourth peer error type (§12 port addendum item 17): OAuthProtocolError embeds AuthError by value and implements Unwrap() returning *AuthError, so:

  • errors.Is(err, ErrAuth) matches via the promoted *AuthError.Is method (Is() is checked on err itself before any unwrapping), and
  • errors.As(err, &authErrPtr) (with authErrPtr *AuthError) matches by unwrapping once to the embedded AuthError.

Every pre-existing `switch err := err.(type) { case *AuthError: ... }` or `errors.As`/`errors.Is` call site that already handles AuthError keeps working unchanged against an *OAuthProtocolError value — this is precisely what makes contract 1.4 "non-breaking, additive" for Go.

func (*OAuthProtocolError) Unwrap

func (e *OAuthProtocolError) Unwrap() error

Unwrap exposes the embedded AuthError so errors.As(err, &authErrPtr) and errors.Unwrap chains keep matching *AuthError for an *OAuthProtocolError value (see the type doc comment above).

type OidcBeginParams

type OidcBeginParams struct {
	// RedirectURI is the relying party's redirect URI, echoed back into
	// OidcExchange unchanged.
	RedirectURI string
	// Scope is the requested scope, space-separated. "openid" is added
	// automatically when absent (§12.1 rule 4); the zero value requests
	// exactly "openid".
	Scope string
	// ExtraParams are additional caller-supplied authorization-request query
	// parameters (e.g. prompt, login_hint, ui_locales). §12.1 rule 5 allows
	// caller-supplied additions but forbids the SDK from adding any of its
	// own beyond the mandated eight: attempting to override one of those
	// eight is a PROGRAMMING ERROR, returned as a plain error — deliberately
	// NOT the AuthError/AuthzError/NetworkError taxonomy (§12 port addendum
	// item 9).
	ExtraParams map[string]string
}

OidcBeginParams are the arguments to OidcBegin — a pure local computation, no network I/O. ClientID comes from the Client's own configuration (WithOidcClientID), not a per-call argument (§12 T1 judgment call 21).

type OidcConfiguration

type OidcConfiguration struct {
	// Issuer is the value an ID token's `iss` claim must equal exactly.
	Issuer string `json:"issuer"`
	// AuthorizationEndpoint is what OidcBegin builds its redirect URL from.
	AuthorizationEndpoint string `json:"authorization_endpoint"`
	// TokenEndpoint is used by OidcExchange, OidcRefresh and
	// LoginClientCredentials.
	TokenEndpoint string `json:"token_endpoint"`
	// UserinfoEndpoint is advertised by the server but deliberately NEVER
	// called by this SDK (§12.3 rule 5).
	UserinfoEndpoint string `json:"userinfo_endpoint"`
	// JwksURI is the JWKS document whose keys verify ID-token signatures
	// (§12.4 rule 2).
	JwksURI string `json:"jwks_uri"`
	// RevocationEndpoint is the RFC 7009 endpoint used by Revoke.
	RevocationEndpoint string `json:"revocation_endpoint"`
	// IntrospectionEndpoint is the RFC 7662 endpoint used by Introspect.
	IntrospectionEndpoint string `json:"introspection_endpoint"`
	// ResponseTypesSupported lists OAuth2 response_type values the server
	// supports.
	ResponseTypesSupported []string `json:"response_types_supported"`
	// SubjectTypesSupported lists subject identifier types the server
	// supports.
	SubjectTypesSupported []string `json:"subject_types_supported"`
	// IDTokenSigningAlgValuesSupported is informational only: §12.4 rule 1
	// pins verification to EdDSA regardless of what appears here.
	IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
	// ScopesSupported lists scopes the server supports.
	ScopesSupported []string `json:"scopes_supported"`
	// TokenEndpointAuthMethodsSupported lists the client-authentication
	// methods the token endpoint supports (client_secret_post, §12.1 note 3).
	TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
	// ClaimsSupported lists claims the server may include in an ID token.
	ClaimsSupported []string `json:"claims_supported"`
	// GrantTypesSupported lists grant types the token endpoint supports.
	GrantTypesSupported []string `json:"grant_types_supported"`

	// DeviceAuthorizationEndpoint is the RFC 8628 endpoint used by
	// DeviceAuthorize (§14.1).
	//
	// Empty when the server does not implement the device grant, or when the
	// document came from a non-AXIAM OP. Its absence is an error at call time,
	// never a cue to build the URL by concatenation.
	DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"`
	// EndSessionEndpoint is the OIDC RP-Initiated Logout 1.0 endpoint used by
	// LogoutURL (§12.7.2 rule 1).
	//
	// Empty for the same reason, and the rule is stricter here: §12.7.2 rule 1
	// forbids synthesising this URL from the issuer. Code that concatenates
	// works against AXIAM and breaks against every other OP the same
	// application is pointed at.
	EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
	// BackchannelLogoutSupported reports whether the OP sends logout tokens.
	BackchannelLogoutSupported bool `json:"backchannel_logout_supported,omitempty"`
	// BackchannelLogoutSessionSupported reports whether those tokens carry
	// `sid`. AXIAM always sends it.
	BackchannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported,omitempty"`
}

OidcConfiguration is the OIDC Discovery 1.0 metadata document served by `GET /.well-known/openid-configuration` (wire schema OidcDiscoveryDocument, CONTRACT.md §12.1). Every field is required by the server's schema.

Issuer is the AUTHORITATIVE issuer for ID-token validation (§12.4 rule 3). It may legitimately differ from the client's base URL when AXIAM runs behind a proxy, so this SDK never rejects a document on an issuer/base-URL mismatch (§12.3 rule 6). Likewise JwksURI is read from here rather than hardcoded.

type OidcExchangeParams

type OidcExchangeParams struct {
	// Code is the authorization code the IdP redirected back with.
	Code string
	// CodeVerifier is the verifier from the matching AuthorizationRequest.
	CodeVerifier Sensitive
	// RedirectURI is the same redirect_uri that was sent on the
	// authorization request.
	RedirectURI string
	// Nonce is the nonce from the matching AuthorizationRequest. MANDATORY —
	// §12.4 rule 6 is not optional for this grant.
	Nonce string
	// TenantID is the tenant UUID for the token endpoint's required
	// tenant_id query parameter. When empty, falls back to the tenant UUID
	// resolved from a prior successful Login/Refresh (§12.3 rule 4).
	TenantID string
	// Configuration is a pre-fetched discovery document, to avoid
	// re-reading the (cached) one. Fetched via OidcDiscover when nil.
	Configuration *OidcConfiguration
}

OidcExchangeParams are the arguments to OidcExchange (`grant_type=authorization_code`).

type OidcRefreshParams

type OidcRefreshParams struct {
	// RefreshToken is the refresh token to redeem.
	RefreshToken Sensitive
	// Scope is an optional narrowed scope to request. Omitted from the form
	// body when empty.
	Scope string
	// TenantID is the tenant UUID for the tenant_id query parameter (§12.3
	// rule 4).
	TenantID string
	// Configuration is a pre-fetched discovery document. Fetched via
	// OidcDiscover when nil.
	Configuration *OidcConfiguration
}

OidcRefreshParams are the arguments to OidcRefresh (`grant_type=refresh_token`).

type OidcStateEntry

type OidcStateEntry struct {
	// State is the `state` value this entry is keyed by. Not a secret
	// (§12.3 rule 2).
	State string
	// Nonce is checked against the ID token's `nonce` claim. Not a secret
	// (§12.3 rule 2).
	Nonce string
	// CodeVerifier is the PKCE verifier for the matching authorization
	// request (§12.5 secret).
	CodeVerifier Sensitive
	// RedirectURI is the redirect_uri that was sent on the authorization
	// request and must be replayed on exchange.
	RedirectURI string
	// ReturnTo is optional application-owned data, e.g. the page the user
	// was heading to before login.
	ReturnTo string
}

OidcStateEntry is the tuple an OidcStateStore holds for one in-flight login.

CodeVerifier stays Sensitive while stored (§12.5: the verifier is secret for its whole lifetime, "including ... in any OidcStateStore entry").

type OidcStateStore

type OidcStateStore interface {
	// Save persists entry, keyed by its State, starting its TTL now.
	Save(entry OidcStateEntry) error
	// Consume atomically fetches AND REMOVES the entry for state. ok is
	// false when the state is unknown, already consumed, or expired — three
	// cases a caller MUST treat identically (as a failed login), because
	// distinguishing them leaks whether a state ever existed.
	Consume(state string) (entry OidcStateEntry, ok bool)
}

OidcStateStore is an OPTIONAL server-side store for in-flight OidcBegin state (CONTRACT.md §12.3 rule 1).

Implement this to back the login/callback handlers with your own storage (Redis, a database, an encrypted cookie). Two invariants are normative:

  1. Single-use: Consume MUST return the entry AND delete it atomically, so a replayed callback cannot reuse a state.
  2. Expiry: an entry older than the store's TTL (10 minutes, at most — OidcStateTTL) MUST NOT be returned.

type OidcTokenSet

type OidcTokenSet struct {
	// AccessToken is the OAuth2 access token (§12.5 secret).
	AccessToken Sensitive
	// TokenType is the token type the server issued ("Bearer").
	TokenType string
	// ExpiresIn is the access-token lifetime in seconds from the time of the
	// response.
	ExpiresIn int64
	// Scope is the granted scope, when the server narrowed or echoed it.
	Scope string
	// RefreshToken is the refresh token, when the grant issued one (§12.5
	// secret). Empty when absent.
	RefreshToken Sensitive
	// IDToken is the raw ID token, when the grant issued one (§12.5 secret).
	// Empty when absent.
	IDToken Sensitive
	// IDClaims is the validated ID-token claims — non-nil exactly when
	// IDToken is non-empty (§12.1, §12.4).
	IDClaims *IDTokenClaims
}

OidcTokenSet is a token set returned by the OAuth2 token endpoint (wire schema TokenResponse), returned by OidcExchange, OidcRefresh and LoginClientCredentials.

AccessToken, RefreshToken and IDToken are Sensitive (§12.5): String()/ fmt/JSON all redact them to "[SENSITIVE]", and the raw value is reachable only through the package-internal expose() accessor. RefreshToken and IDToken are the empty string when the grant did not issue one — no legitimate token is ever the empty string, matching the convention LoginResult.MFAToken already uses.

IDClaims is non-nil exactly when IDToken is non-empty, and holds the ALREADY-VALIDATED claim set (§12.4) — validation happens before this value is ever constructed, so an OidcTokenSet in your hands is never partially trusted (§12.4 rule 7).

type Option

type Option func(*clientConfig)

Option configures a Client at construction time (D-03).

func WithClientCertificate

func WithClientCertificate(certPEM, keyPEM []byte) Option

WithClientCertificate configures a client-certificate identity for mutual TLS (CONTRACT.md §6.1). certPEM is a PEM-encoded X.509 certificate chain and keyPEM is the matching PEM-encoded private key (PKCS#8 or PKCS#1). The SDK presents this identity on BOTH the REST transport (here) and any gRPC channel built for the same logical client (grpc.NewTLSCredentials).

Presenting a client certificate NEVER relaxes server verification: this is additive to WithCustomCA/§6 and keeps the SDK's TLS-1.3 floor and strict RootCAs behavior unchanged. A non-PEM cert/key pair is a construction-time error returned from NewClient, consistent with WithCustomCA.

The private key is secret material (§7): it is held behind the SDK's Sensitive type and never appears in any log, error, or display output.

func WithCustomCA

func WithCustomCA(pem []byte) Option

WithCustomCA adds a PEM-encoded CA certificate to the TLS verification chain (§6). This is the ONLY TLS-related escape hatch — there is no option anywhere in this SDK that disables or weakens certificate verification. Returns a construction-time error via NewClient if pem is not valid PEM.

func WithDecisionMemoTTL

func WithDecisionMemoTTL(ttl time.Duration) Option

WithDecisionMemoTTL enables the CONTRACT.md §17 client-side decision memo.

DISABLED BY DEFAULT — §11.2 rule 6's ban on caching authorization decisions is still the default behaviour, and this is the single opt-in exception.

What you are accepting: the staleness bound is ttl IN BOTH DIRECTIONS. A grant revoked on the server can still read as allowed for up to the TTL, and a grant just added can still read as denied for up to the TTL.

READS-YOUR-OWN-WRITES IS NOT GUARANTEED. An admin UI that grants a role and immediately re-checks is the case that breaks, and it breaks silently. If that is your workload, do not set this.

ttl is clamped to MaxMemoTTL rather than rejected, so asking for a minute gets you five seconds. Allows and denies are memoized identically (asymmetric caching leaks the outcome through latency), failures are never memoized, and the memo is cleared on any credential change.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient supplies a base *http.Client whose Transport/Timeout the SDK adopts. D-09: the SDK ALWAYS re-applies its own cookiejar and TLS config over the supplied client afterward — an override can never silently drop the jar (breaking every post-login request) or bypass TLS verification.

func WithLogger

func WithLogger(logger *slog.Logger) Option

func WithOidcClientID

func WithOidcClientID(clientID string) Option

WithOidcClientID sets the relying party's OAuth2 client_id (CONTRACT.md §12.1), used on every §12 grant and matched against the ID token's aud/azp (§12.4 rule 4). Required before calling any §12 operation other than OidcDiscover.

func WithOidcClientSecret

func WithOidcClientSecret(clientSecret string) Option

WithOidcClientSecret configures a confidential client's client_secret (CONTRACT.md §12.1), held behind Sensitive (§12.5). Omit for a public client: LoginClientCredentials, Introspect and Revoke then return an *AuthError client-side, without a wire call (§12.1 note 4 — a public client cannot call them).

func WithOidcClockSkew

func WithOidcClockSkew(seconds int) Option

WithOidcClockSkew overrides the permitted ID-token clock skew, in seconds. Clamped to [1, MaxIDTokenClockSkewSec] (60s) per CONTRACT.md §12.4 rule 5 — the contract forbids configuring it above that bound.

func WithOidcDiscoveryTTL

func WithOidcDiscoveryTTL(ttl time.Duration) Option

WithOidcDiscoveryTTL overrides the OIDC discovery-document cache TTL. Floored at MinOidcDiscoveryTTL (5 minutes) per CONTRACT.md §12.3 rule 6 — a smaller configured value is silently raised to the floor.

func WithOrgID

func WithOrgID(id uuid.UUID) Option

WithOrgID sets the organization UUID the real login/refresh endpoints require (RESEARCH.md Pitfall 3). Mutually exclusive with WithOrgSlug — last call wins.

func WithOrgSlug

func WithOrgSlug(slug string) Option

WithOrgSlug sets the organization slug the real login/refresh endpoints require (RESEARCH.md Pitfall 3). Mutually exclusive with WithOrgID — last call wins.

func WithRetryDisabled

func WithRetryDisabled() Option

WithLogger supplies an injectable, redaction-aware logger (CF-02). OFF by default (nil logger — the SDK never logs unless a logger is supplied). The SDK never emits raw token values regardless of the logger's configured level (Sensitive redacts itself in any log call). WithRetryDisabled turns off the CONTRACT.md §16 bounded read-only retry policy, making every operation exactly one attempt.

That is the right choice for a caller who owns their own retry layer — they know their deadline and this SDK does not — but it is not a way to make failures quieter: a transient *NetworkError simply surfaces immediately.

§16.1 permits this switch but forbids raising the attempt cap, base delay or delay cap above the contract's values, so there is no option for those: eleven SDKs agreeing on one table is the point.

func WithTelemetryHook

func WithTelemetryHook(hook TelemetryHook) Option

WithTelemetryHook installs a CONTRACT.md §19 telemetry sink.

It receives request start/end, §16 retry and §9 refresh events, so metrics can be wired without this module depending on any metrics library. See examples/telemetry_hook.

A hook that panics cannot fail the operation that fired it (§19.2 rule 2), and no event payload can carry a token — TelemetryEvent is a closed interface with fixed field sets (§19.2 rule 3). It is invoked on the calling goroutine, so it must not block; buffer on your side if you need async delivery.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout overrides the default request timeout applied to the SDK's http.Client (CF-03; default 30s).

type Outcome

type Outcome string

Outcome reports why a request finished.

const (
	// OutcomeSuccess indicates the call returned a usable response.
	OutcomeSuccess Outcome = "success"
	// OutcomeFailure indicates the call failed, at any layer.
	OutcomeFailure Outcome = "failure"
)

type PresentedProofs

type PresentedProofs = jwks.PresentedProofs

PresentedProofs carries what the caller proved about this connection and this request. See VerifyTokenBinding.

type RefreshEvent

type RefreshEvent struct {
	Role     RefreshRole
	Duration time.Duration
}

RefreshEvent is emitted around a §9 single-flight refresh.

type RefreshRole

type RefreshRole string

RefreshRole reports whether this caller performed a §9 refresh or waited on another goroutine's.

const (
	// RefreshLeader means this caller performed the refresh.
	RefreshLeader RefreshRole = "leader"
	// RefreshFollower means this caller waited on another's refresh.
	RefreshFollower RefreshRole = "follower"
)

type RequestEndEvent

type RequestEndEvent struct {
	Operation    string
	Method       string
	PathTemplate string
	Attempt      int
	// Status is the HTTP status, or 0 when the call never got a response.
	Status int
	// Duration is the wall-clock time this attempt took.
	Duration time.Duration
	Outcome  Outcome
}

RequestEndEvent is emitted after a call completes, success or failure.

type RequestStartEvent

type RequestStartEvent struct {
	// Operation is the canonical name, e.g. "CheckAccess".
	Operation string
	// Method is the HTTP method.
	Method string
	// PathTemplate is the route constant — "/api/v1/authz/check", never a URL
	// with ids substituted in. A metric label carrying a UUID is a cardinality
	// bomb.
	PathTemplate string
	// Attempt is 1 for the first try, incrementing per §16 retry.
	Attempt int
}

RequestStartEvent is emitted before an outbound call leaves the SDK.

type RequestedPermission

type RequestedPermission struct {
	// ResourceID is the AXIAM resource id — the same UUID the Protection API
	// returned as `_id`.
	ResourceID string
	// ResourceScopes are scope names, each of which the resource must already
	// declare. Matched exactly: no prefix or wildcard semantics in either
	// direction.
	ResourceScopes []string
}

RequestedPermission is one (resource, scopes) pair a resource server requires (§20.1).

type RequestingPartyToken

type RequestingPartyToken struct {
	// AccessToken is the RPT itself (§20.6 secret).
	AccessToken Sensitive
	// TokenType is the token type (Bearer).
	TokenType string
	// ExpiresIn is min(claim token remaining, server ceiling, 300s).
	ExpiresIn int
}

RequestingPartyToken is the result of the UMA ticket grant (§20.1).

There is NO RefreshToken field, and that is deliberate (§20.2 rule 5). The grant issues none, so an RPT cannot outlive the ticket that authorised it; an application that wants a fresh one re-runs the grant. This result never enters the §9 single-flight refresh guard — there is nothing to refresh.

type ResourceSet

type ResourceSet struct {
	// ID is assigned by the server on registration; empty on the way in.
	ID string
	// Name is the human-readable name, shown in the admin UI.
	Name string
	// Type is a free-form resource type. Omitted from the payload when empty,
	// so the server applies its own `uma_resource` default rather than storing
	// an empty string that sorts oddly next to hand-made resources.
	Type string
	// ResourceScopes are the scope names a resource server may ask for on this
	// resource.
	//
	// REPLACED WHOLESALE BY AN UPDATE, NEVER MERGED (§20.2 rule 8) — this SDK
	// does not read the current scopes and fold them into an update payload as
	// a convenience, because that would make removing a scope impossible
	// through it.
	ResourceScopes []string
}

ResourceSet is a UMA resource set — an AXIAM resource seen through the Protection API (CONTRACT.md §20.1).

ID is THE AXIAM RESOURCE ID, not a parallel identifier: the same UUID is directly usable as RequestedPermission.ResourceID, and as the resource id anywhere else in this SDK.

type RetryEvent

type RetryEvent struct {
	Operation string
	// Attempt is the attempt that just failed.
	Attempt int
	// Delay is the wait about to be taken, after jitter and any Retry-After.
	Delay time.Duration
	// Reason is a redacted failure description. Never carries a token, because
	// NetworkError.Error() is redacted at construction (D-04/CR-04).
	Reason string
}

RetryEvent is emitted before each §16 retry wait.

§16.5 requires this: a retried-then-succeeded operation is otherwise invisible — the caller sees a slow success and no signal that the server is failing. That silence is the standing objection to automatic retry.

type RevokeParams

type RevokeParams struct {
	// Token is the token to revoke.
	Token Sensitive
	// TokenTypeHint is an optional RFC 7009 token_type_hint.
	TokenTypeHint string
	// TenantID is the tenant UUID for the tenant_id query parameter (§12.3
	// rule 4).
	TenantID string
	// Configuration is a pre-fetched discovery document. Fetched via
	// OidcDiscover when nil.
	Configuration *OidcConfiguration
}

RevokeParams are the arguments to Revoke (RFC 7009). Requires confidential-client credentials (§12.1 note 4).

type RptPermission

type RptPermission struct {
	// ResourceID is the resource the engine allowed.
	ResourceID string
	// ResourceScopes are the scopes it allowed on that resource.
	ResourceScopes []string
	// Exp is the absolute expiry, seconds since the epoch.
	Exp int64
}

RptPermission is one entry of an RPT's `permissions` claim (§20.1).

A RECORD OF A DECISION ALREADY MADE, NOT A LIVE AUTHORIZATION ANSWER (§20.2 rule 7). These are the pairs the engine allowed when the RPT was minted; a grant revoked afterwards does not empty a live RPT. Do not cache them beyond the token's own expiry — which is why that expiry is short.

type Sensitive

type Sensitive string

Sensitive wraps a token-carrying string so it can never accidentally leak via fmt verbs, Go-syntax representation, or JSON encoding (CONTRACT.md §7, D-08). All token-carrying fields (access token, refresh token, MFA challenge token, AMQP signing key) MUST use this type.

The raw value is reachable only via the package-internal expose() accessor — Sensitive deliberately has no public getter.

func (Sensitive) Format

func (Sensitive) Format(f fmt.State, verb rune)

Format implements fmt.Formatter, closing the fmt-verb leak path (%v/%+v/%s/%q/width/precision) that a bare String() method does not fully cover — this is the CR-04 leak class this type exists to prevent.

func (Sensitive) GoString

func (Sensitive) GoString() string

GoString implements fmt.GoStringer, covering %#v (Go-syntax representation), which bypasses String()/Format() entirely if not implemented.

func (Sensitive) MarshalJSON

func (Sensitive) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler so any struct embedding a Sensitive field serializes the redacted placeholder rather than the raw value.

func (Sensitive) String

func (Sensitive) String() string

String implements fmt.Stringer. Covers direct String() calls and the default fmt verb behavior for types without a more specific Format/GoString override (String() alone would still leak on %#v without GoString below).

type SsoCompleteParams

type SsoCompleteParams struct {
	// State is the `state` value the IdP redirected back with — must be the
	// one SsoStart returned.
	State string
	// Code is the authorization code the IdP redirected back with.
	Code string
}

SsoCompleteParams are the arguments to SsoComplete (`POST /api/v1/auth/federation/oidc/callback`).

type SsoCompleteResult

type SsoCompleteResult struct {
	// UserID is the provisioned/linked user's UUID.
	UserID string
	// SessionID is the established session's UUID.
	SessionID string
	// ExpiresIn is the session/access-token lifetime in seconds.
	ExpiresIn int64
	// RedirectURI is the post-login destination that was stored during
	// SsoStart.
	RedirectURI string
}

SsoCompleteResult is the result of SsoComplete (wire schema SsoLoginSuccessResponse).

It carries NO token material — the session arrives as Set-Cookie, so the §4 cookie jar (already owned by every *Client) is what actually captures it (§12.1 note 6).

type SsoStartParams

type SsoStartParams struct {
	// FederationConfigID is the UUID of the server-side federation
	// configuration identifying the upstream IdP.
	FederationConfigID string
	// RedirectURI is the post-login destination, stored server-side and
	// echoed back by SsoComplete.
	RedirectURI string
	// TenantID is the tenant UUID. Defaults to the Client's own tenant when
	// empty (this SDK always constructs a Client with a tenant slug, so the
	// default tenant form is TenantSlug — see TenantSlug below).
	TenantID string
	// TenantSlug is the tenant slug — the tenant form used by default,
	// since NewClient always requires one.
	TenantSlug string
	// OrgID is the organization UUID. Defaults to the Client's configured
	// organization (WithOrgID) when empty.
	OrgID string
	// OrgSlug is the organization slug. Defaults to the Client's configured
	// organization (WithOrgSlug) when empty.
	OrgSlug string
}

SsoStartParams are the arguments to SsoStart (`POST /api/v1/auth/federation/oidc/start`).

One tenant form (TenantID or TenantSlug) and one org form (OrgID or OrgSlug) must be resolvable, from these fields or from the Client's own construction options (CONTRACT.md §5.1).

type SsoStartResult

type SsoStartResult struct {
	// AuthorizeURL is the upstream IdP authorization URL to redirect the
	// browser to.
	AuthorizeURL string
	// State is the single-use CSRF state to round-trip back into SsoComplete
	// unmodified.
	State string
	// ExpiresInSecs is the remaining TTL of the server-side state row, in
	// seconds (600 = 10 min).
	ExpiresInSecs int64
}

SsoStartResult is the result of SsoStart (wire schema OidcStartResponse).

There is deliberately no nonce: on the federation path the nonce never leaves the server (§12.1 note 7). Round-trip State into SsoComplete unmodified — the server stores it single-use with a 10-minute TTL and recovers the whole login context from it.

type TelemetryEvent

type TelemetryEvent interface {
	// contains filtered or unexported methods
}

TelemetryEvent is a §19 event.

The interface is closed — isTelemetryEvent is unexported, so no package outside this one can add a variant. That is what makes the "no field can carry a secret" guarantee checkable rather than aspirational.

type TelemetryHook

type TelemetryHook func(TelemetryEvent)

TelemetryHook is a caller-supplied sink.

It is invoked on the calling goroutine, so it must not block: §19.2 rule 4 makes buffering the caller's job so they can pick the policy. Every mature metrics library already buffers.

type TokenExchangeParams

type TokenExchangeParams struct {
	// SubjectToken is the token being exchanged (§15.5 secret). Required.
	SubjectToken Sensitive
	// SubjectTokenType names what kind of token SubjectToken is — one of the
	// SubjectTokenType* constants. REQUIRED (§15.1).
	//
	// There is no default. Go cannot make a struct field mandatory at compile
	// time, so leaving it empty fails CLIENT-SIDE with no wire call, the same
	// way a missing client secret does — rather than sending a type you did
	// not choose.
	//
	// Pass SubjectTokenTypeAccessToken for the same-domain exchange of §15.1,
	// or SubjectTokenTypeJWT for a trusted external issuer's JWT (§15.7).
	//
	// The SDK never reads SubjectToken to decide this value (§15.7). Which
	// kind of token you hold is something only you know; AXIAM refuses refresh
	// and ID token types by name, and the SDK will not retry a refusal as a
	// different type.
	SubjectTokenType string
	// ActorToken is the acting party, when this is a DELEGATION (§15.2
	// rule 1).
	//
	// Its absence selects IMPERSONATION — a different operation with different
	// risk. The SDK never fills this in for you.
	ActorToken Sensitive
	// Scopes are the scopes to request. Omitted from the body when empty.
	Scopes []string
	// Audience is the service the issued token is for.
	Audience string
	// Resource is the RFC 8707 synonym of Audience; the server refuses the
	// pair when they disagree.
	Resource string
	// TenantID supplies the `tenant_id` query parameter.
	TenantID string
	// Configuration is a pre-fetched discovery document.
	Configuration *OidcConfiguration
}

TokenExchangeParams are the arguments to Client.TokenExchange (§15.1).

A struct rather than positional arguments because four optional strings in positional order is a bug waiting to be written (§15.1).

type TokenValidationOptions

type TokenValidationOptions = jwks.ValidationOptions

TokenValidationOptions carries the relying party's §10.1 expectations for JWKSVerifier.VerifyAccessToken.

Tenant is required — an empty Tenant fails closed rather than accepting an arbitrary tenant's token (§10.1 rule 4). ExpectedIssuer and ExpectedAudience are optional and default to unset: an empty value means "no expectation configured, so no check" (§10.1 rules 5/6), never "expect the empty string". This SDK hardcodes no issuer or audience anywhere.

type UmaChallenge

type UmaChallenge struct {
	// Realm is the protection realm the resource server named.
	Realm string
	// AsURI is the authorization server the resource server nominates.
	// NOT AUTOMATICALLY TRUSTED — see UmaParseChallenge.
	AsURI string
	// Ticket is the ticket to exchange — a bearer credential for its
	// 60-second life (§20.6).
	Ticket Sensitive
}

UmaChallenge is a parsed `WWW-Authenticate: UMA` challenge (UMA 2.0 §3.2, §20.3).

func UmaParseChallenge

func UmaParseChallenge(header string) (UmaChallenge, bool)

UmaParseChallenge parses a `WWW-Authenticate: UMA …` header value (§20.3) into its three fields, returning ok=false when the header names a different scheme.

PURE LOCAL COMPUTATION — it performs NO exchange of the ticket it finds, and that is the point. Parsing a challenge and acting on it are separate decisions: the as_uri names an authorization server the client has not necessarily chosen to trust, and auto-exchanging would send the requesting party's claim_token to whatever host answered the 401. Return the parsed challenge and let the caller decide.

type UmaExchangeTicketParams

type UmaExchangeTicketParams struct {
	// Ticket is the permission ticket to redeem (§20.6 secret). Required.
	//
	// SINGLE-USE AND NOT RETRYABLE: it is spent whether or not the exchange
	// succeeds. A failure means "request a NEW ticket", never "send this one
	// again" (§20.2 rule 6).
	Ticket Sensitive
	// ClaimToken is the requesting party's access token (§20.6 secret).
	// Required, and never defaulted (§20.2 rule 2) — it is the only channel
	// that names the requesting party.
	ClaimToken Sensitive
	// TenantID supplies the `tenant_id` query parameter.
	TenantID string
	// Configuration is a pre-fetched discovery document.
	Configuration *OidcConfiguration
}

UmaExchangeTicketParams are the arguments to Client.UmaExchangeTicket (§20.1).

type VerifiedLogoutToken

type VerifiedLogoutToken struct {
	// SID is the session that ended. When non-empty, end only this session —
	// falling back to "every session for Sub" is over-reach the AXIAM server
	// itself refuses to make.
	SID string
	// Sub is the subject whose session ended.
	Sub string
	// JTI is the replay identifier.
	//
	// The RP dedups on this, not the SDK. Back-channel delivery is
	// at-least-once with retry, so a valid token legitimately arrives twice;
	// the SDK has no durable store and an in-memory guard would silently drop
	// a real second logout after a restart. Surfaced, never consumed.
	JTI string
}

VerifiedLogoutToken is what a verified logout token names (§12.7.3).

Deliberately NOT a bare bool: the RP has to know WHICH session to end, and a verifier that only says "valid" would force the caller to re-parse the token themselves, with none of the checks this type is proof of.

Directories

Path Synopsis
Package amqp implements the AXIAM AMQP event consumer: a closure-handler Consume loop that HMAC-SHA256-verifies every delivery BEFORE the caller's handler ever runs (CONTRACT.md §8, D-07, SC#4).
Package amqp implements the AXIAM AMQP event consumer: a closure-handler Consume loop that HMAC-SHA256-verifies every delivery BEFORE the caller's handler ever runs (CONTRACT.md §8, D-07, SC#4).
examples
amqp-consumer command
Command amqp-consumer demonstrates amqp.Consume with a closure handler that shows the full ack/nack matrix (CONTRACT.md §8, D-07).
Command amqp-consumer demonstrates amqp.Consume with a closure handler that shows the full ack/nack matrix (CONTRACT.md §8, D-07).
authz-check command
Command authz-check demonstrates the REST authorization surface: CheckAccess, Can (the browser/UI alias), and BatchCheck (CONTRACT.md §1).
Command authz-check demonstrates the REST authorization surface: CheckAccess, Can (the browser/UI alias), and BatchCheck (CONTRACT.md §1).
device-login command
Device Authorization Grant (CONTRACT.md §14) — signing in a device that cannot show a browser.
Device Authorization Grant (CONTRACT.md §14) — signing in a device that cannot show a browser.
external-token-exchange command
External-IdP token exchange (CONTRACT.md §15.7) — accepting a partner's token at an API gateway.
External-IdP token exchange (CONTRACT.md §15.7) — accepting a partner's token at an API gateway.
grpc-checkaccess command
Command grpc-checkaccess demonstrates the gRPC authorization transport: CheckAccess and BatchCheck over a lazily-connected *grpc.ClientConn (CONTRACT.md §1, §5, §9).
Command grpc-checkaccess demonstrates the gRPC authorization transport: CheckAccess and BatchCheck over a lazily-connected *grpc.ClientConn (CONTRACT.md §1, §5, §9).
login-mfa command
Command login-mfa demonstrates the two-phase Login/VerifyMfa flow (CONTRACT.md §1, §5).
Command login-mfa demonstrates the two-phase Login/VerifyMfa flow (CONTRACT.md §1, §5).
logout command
RP-initiated and back-channel logout (CONTRACT.md §12.7).
RP-initiated and back-channel logout (CONTRACT.md §12.7).
middleware-guard command
Command middleware-guard demonstrates wrapping a sample net/http route with middleware.Middleware (CONTRACT.md §10, SC#1), plus a second route additionally protected with middleware.RequireAccess (CONTRACT.md §11 declarative authorization helpers).
Command middleware-guard demonstrates wrapping a sample net/http route with middleware.Middleware (CONTRACT.md §10, SC#1), plus a second route additionally protected with middleware.RequireAccess (CONTRACT.md §11 declarative authorization helpers).
oidc-login command
Command oidc-login demonstrates "Login with AXIAM" — the OIDC/SSO relying-party helpers (CONTRACT.md §12) — wired into a plain net/http server via middleware.OidcLoginHandler and middleware.OidcCallbackHandler.
Command oidc-login demonstrates "Login with AXIAM" — the OIDC/SSO relying-party helpers (CONTRACT.md §12) — wired into a plain net/http server via middleware.OidcLoginHandler and middleware.OidcCallbackHandler.
reactor command
Command reactor demonstrates amqp.ReactorServe — an AXIAM Reactor, the AMQP extension actor of CONTRACT.md §22.
Command reactor demonstrates amqp.ReactorServe — an AXIAM Reactor, the AMQP extension actor of CONTRACT.md §22.
sender-constrained-guard command
Enforcing CONTRACT.md §10.1 rule 9 in a resource server — the full rule, covering certificate-bound (RFC 8705) and DPoP-bound (RFC 9449) tokens.
Enforcing CONTRACT.md §10.1 rule 9 in a resource server — the full rule, covering certificate-bound (RFC 8705) and DPoP-bound (RFC 9449) tokens.
telemetry-hook command
Telemetry hooks — CONTRACT.md §19.
Telemetry hooks — CONTRACT.md §19.
token-exchange command
Token Exchange (CONTRACT.md §15) — narrowing a user's token before calling the next service.
Token Exchange (CONTRACT.md §15) — narrowing a user's token before calling the next service.
uma-client command
Command uma-client is the client half of the UMA 2.0 (CONTRACT.md §20) example pair.
Command uma-client is the client half of the UMA 2.0 (CONTRACT.md §20) example pair.
uma-resource-server command
Command uma-resource-server is the resource-server half of the UMA 2.0 (CONTRACT.md §20) example pair.
Command uma-resource-server is the resource-server half of the UMA 2.0 (CONTRACT.md §20) example pair.
Package grpc implements the gRPC transport for AuthorizationService (CheckAccess/BatchCheckAccess) with strict TLS and a sync-safe auth/tenant interceptor (CONTRACT.md §5/§6, SC#3).
Package grpc implements the gRPC transport for AuthorizationService (CheckAccess/BatchCheckAccess) with strict TLS and a sync-safe auth/tenant interceptor (CONTRACT.md §5/§6, SC#3).
internal
dpop
Package dpop implements DPoP proof verification — CONTRACT.md §21.7.2 (RFC 9449), contract 1.16.
Package dpop implements DPoP proof verification — CONTRACT.md §21.7.2 (RFC 9449), contract 1.16.
jwks
Package jwks implements local JWKS fetch/cache/verification via lestrrat-go/jwx/v3 (D-06/§10), the shared local-verify primitive consumed by the net/http middleware (Plan 05) and any proactive-refresh check.
Package jwks implements local JWKS fetch/cache/verification via lestrrat-go/jwx/v3 (D-06/§10), the shared local-verify primitive consumed by the net/http middleware (Plan 05) and any proactive-refresh check.
refreshguard
Package refreshguard implements the sync.Mutex single-flight refresh guard required by CONTRACT.md §9: exactly one in-flight POST /api/v1/auth/refresh call across any number of concurrent callers observing the same expired access token, with a double-check-after-lock pattern and no retry loop on failure (§9.3).
Package refreshguard implements the sync.Mutex single-flight refresh guard required by CONTRACT.md §9: exactly one in-flight POST /api/v1/auth/refresh call across any number of concurrent callers observing the same expired access token, with a double-check-after-lock pattern and no retry loop on failure (§9.3).
Package middleware implements the net/http middleware / route-guard interface (CONTRACT.md §10, D-06).
Package middleware implements the net/http middleware / route-guard interface (CONTRACT.md §10, D-06).
Package webhook implements the T-145 / CONTRACT.md §13 webhook-signature verifier: HMAC-SHA256 verification of an inbound AXIAM webhook delivery, with Stripe-style signed-timestamp freshness checking.
Package webhook implements the T-145 / CONTRACT.md §13 webhook-signature verifier: HMAC-SHA256 verification of an inbound AXIAM webhook delivery, with Stripe-style signed-timestamp freshness checking.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL