RKAT_ANTHROPIC_API_KEY, RKAT_OPENAI_API_KEY,
RKAT_GEMINI_API_KEY, or the matching provider-native names) - set a key
matching the effective model/provider. A no-hint rkat run does not select
a provider by discovering whichever key is present. The powerful path is
realm-scoped bindings:
declare a realm in your config, register global credentials via rkat auth login or binding-scoped credentials through REST/RPC, and pass --auth-binding <realm>:<binding> for a session or structural auth_binding on a mob-member
spawn.
Interactive OAuth/device
rkat auth login provisions nonsecret declarations
for the reserved global realm in the home-rooted doc at
~/.rkat/config.toml; tokens are stored separately through TokenStore.
Scripted API-key login only stores a token and needs matching binding config.
Once configured, global is the implicit tail of a
workspace realm’s chain, so a single sign-in is inherited by every workspace
realm (credential reads inherit down the chain). For a binding owned by a
specific realm such as
prod:openai, manage that realm’s config entries directly. HostAuthService
and RPC/REST credential writes are strict-owner and reject a child-addressed
inherited write with the owner identity. The current
CLI logout exception can clear an ancestor’s stored
credential when the loaded config resolves a child address to that owner;
explicitly address the true owner instead. See
Realm inheritance.What this guide is for
Use this guide when you need more than the env-var fast path:- explicit realm-scoped bindings
- OAuth or cloud-IAM-backed auth
- hot-swapping provider identity
- per-mob-member credential overrides
ProviderRuntimeRegistry. The environment
fallback is a synthesized binding with a typed
origin = "synthetic_env_default" and CredentialSourceSpec::Env. It is
ephemeral, is not a durable realm, and cannot participate in inheritance.
The fast path: env vars
RKAT_* variants take precedence over
provider-native names. With no model/provider/binding hints or configured
model, the catalog default is OpenAI gpt-6-astra, so an Anthropic key alone
does not make that default run work. Explicit models, binding defaults, and
compatible configured models retain their normal selection precedence;
configured bindings can supply credentials instead of this environment path.
If both public OpenAI and unprefixed Azure OpenAI env vars are present, the
public OpenAI env default wins. Use
RKAT_AZURE_OPENAI_API_KEY plus
RKAT_AZURE_OPENAI_ENDPOINT, or an explicit realm binding, to make Azure
OpenAI the env default in that case.
Azure image generation can additionally use
RKAT_AZURE_OPENAI_IMAGE_GENERATION_DEPLOYMENT and
RKAT_AZURE_OPENAI_IMAGE_GENERATION_API_VERSION (or their unprefixed
equivalents).
The powerful path: realms + bindings
A realm is a named collection of backend profiles, auth profiles, and provider bindings. It lets you:- Run multiple sessions against different accounts or tenants
- Mix OAuth (Claude.ai Pro/Max), api_key (keyed by CI), and cloud IAM (Bedrock/Vertex/Foundry) in one process
- Hot-swap a session’s active binding mid-conversation
- Give different mob members different credentials
Realm shape
openai) with an Azure-specific
backend/auth pair:
Log in
rkat auth login openai provisions the ChatGPT OAuth backend.
Interactive login uses the provider’s fixed OAuth/device flow; --backend
and --method are accepted but ignored in this mode.
For scripted API-key storage, first add a compatible managed-store binding to
~/.rkat/config.toml (or reuse matching existing declarations):
global:default_openai; it does
not provision a backend/auth/binding or materialize a global config
section. In this mode, --method chooses an allowed persisted-secret mode
(default api_key), and --backend is recorded in token metadata, not used
to provision or fully validate a runtime backend/auth pair. Without the
matching managed-store declarations, use the environment-key fast path
instead. Public OpenAI API keys and interactive ChatGPT OAuth belong to
different account systems and can expose different model sets.
GitHub Copilot uses device authorization and provisions three routes over one
shared credential account:
global:copilot_openai,
global:copilot_anthropic, and global:copilot_gemini; the Gemini route’s
default_model is gemini-3.8-flash rather than the API-key Gemini default.
They remain ordinary
OpenAI, Anthropic, and Gemini provider identities; credential_account = "github_copilot" only makes their GitHub token, derived Copilot token cache,
AuthMachine lifecycle, status, and logout shared. Meerkat queries the signed-in
account’s /models endpoint and rejects a known-unavailable model/dialect
before dispatch. If discovery is temporarily unavailable, auth still succeeds
and the request is attempted normally. A known-unavailable error includes the
compatible model IDs observed for that account. An ID outside Meerkat’s
built-in catalog runs when the request names a route of the account: the
account’s model list defines it, protocol and limits included (see
GitHub Copilot configured once); a
[models.<id>] entry for it still works, and wins.
The device sign-in’s routes send a Copilot token that Meerkat obtains from the
GitHub sign-in by a token exchange and replaces before it expires. A temporary
failure of that exchange (a network failure or timeout, HTTP 408, 429 or 5xx,
or a GitHub API rate limit) does not fail turns: the current token, minted from
the current sign-in, keeps serving until it expires, and the exchange is tried
again after its Retry-After (at least a second later), or after 30 seconds
without one. Once no valid token is left, a turn fails with a retryable error
(rate limited, server error, network timeout), which the agent loop retries. A
token Copilot rejects with a 401 is replaced on its next use, and the
account’s routes stay resolvable. Restoring or waking a session, or building
one whose output budget is set explicitly or in the configuration, succeeds
during such an outage on the account’s last known model list; the first
request then performs the exchange. An exchange 401, or a 403 that asks for an
account action, still needs a new sign-in.
Interactive login writes nonsecret backend/auth/binding declarations to the
home-rooted global doc (~/.rkat/config.toml), so configured sign-in is
inherited by workspace realms via the chain tail. Secret tokens are separate:
the stock CLI’s file-backed TokenStore uses
<platform-config>/meerkat/credentials/global/<credential-stem>.json,
where <platform-config> is dirs::config_dir(). Files are created with mode
0600 on Unix. --user-config-root relocates the config doc, not this token
root; custom hosts may choose a keyring or another TokenStore.
To use a binding owned by another realm (for example prod:openai), declare that
binding in the owning realm’s config and use an external source such as the env
profiles above, or complete login through REST/RPC with explicit realm_id,
binding_id, and profile_id. CLI login never populates a prod managed-store
profile; the host/RPC/REST write paths require the true owner.
GitHub Copilot with a service-account token
The Copilot routes can also authenticate with a GitHub token you supply, with no device sign-in, the way GitHub Copilot CLI signs in withCOPILOT_GITHUB_TOKEN: auth_method = "github_copilot_token". Use it for CI
jobs, servers and service accounts. The token itself is the Copilot API
credential; the first time a route uses it, Meerkat looks up the GitHub login,
the Copilot plan and the plan’s API host, and nothing is exchanged.
Token routes identify themselves to the Copilot API with
Copilot-Integration-Id: copilot-developer-cli, GitHub Copilot CLI’s
integration id, which the Copilot API accepts for personal access tokens and
OAuth tokens alike; it refuses vscode-chat, the device sign-in’s default, for
a personal access token. An integration_id in the backend options replaces
it on every request of that route (GitHub’s identity lookup sends none).
GitHub’s identity lookup may not answer a fine-grained personal access token
that the Copilot API accepts (it can answer 403 or 404). The route then uses
the default Copilot API host, https://api.githubcopilot.com, with no login
or plan, and only once the account’s model list loads there: a token that
cannot list the models is never used. A 401 or 403 from that list is Copilot
refusing the token (a missing Copilot Requests permission or seat, a policy),
not a revoked token: the request fails as refresh_failed, the same token is
asked about again after a minute, and it works once that is fixed. Only the
identity lookup’s own 401 marks a token revoked. Once such a route serves,
rkat auth test reports that GitHub named no account.
Write the three routes once, then provide the token in the environment:
--from-env writes these routes to ~/.rkat/config.toml and stores nothing.
When one of the variables is set, it checks the token first and prints the
account, for example ok: GitHub Copilot ready as svc-bot (plan business) via COPILOT_GITHUB_TOKEN; routes global:copilot_openai, global:copilot_anthropic, global:copilot_gemini. The routes read COPILOT_GITHUB_TOKEN, then
GH_TOKEN, then GITHUB_TOKEN, Copilot CLI’s order, and RKAT_<NAME> before
each; a variable that is set but empty is skipped, so an empty
COPILOT_GITHUB_TOKEN does not hide GH_TOKEN:
[realm.global] configures no Copilot route, the
three routes exist anyway: naming one, as in
--auth-binding global:copilot_anthropic, resolves it in token mode from
COPILOT_GITHUB_TOKEN, then GH_TOKEN, then GITHUB_TOKEN, so a CI job needs
only the variable. Nothing selects these routes by default, so an API-key
variable such as ANTHROPIC_API_KEY keeps its role for requests that name no
binding. Any Copilot route in [realm.global] turns them off: the device
sign-in’s routes, the routes above, a copilot backend under another name, or
a binding on the github_copilot account. The zero-config Gemini route has no
default model, so pass --model with it. rkat auth test and
rkat auth status read these routes too.
To store the token instead, as copilot login --with-token does, pipe it in
(or pass --secret):
global:account:github_copilot credential, and the routes read
source = { kind = "managed_store" }. RPC and REST store one with
auth/profile/create (auth_method = "github_copilot_token") on a route with
that source. Other sources need only a different source line, the same on
all three routes (the routes of one account read its token from one source:
a route over another source is refused before any request):
refresh_interval_ms publishes each token with that
lifetime: a minute before it ends, the next request runs the command again,
so the command runs again refresh_interval_ms minus one minute after each
run. refresh_interval_ms must therefore be at least 61000 (the lifetime
counts in whole seconds and must outlast that minute), and several minutes
serve better: 61000 runs the command again about every second, 600000
about every nine minutes. A shorter one is refused before the command runs,
as unsupported_combination (see below), rather than running it on every
request. The route reports that lifetime as its token’s expiry, so a session
built within it reuses the published token: building a session neither
publishes the token again nor runs the command, and it repeats neither the
account lookup nor the model listing. An external resolver is asked again
after the Copilot API refuses a token, so a rotated token heals that request.
Token kinds (read from the documented prefix only):
These follow GitHub’s requirements for GitHub Copilot CLI’s personal access
tokens: a fine-grained token owned by your personal account with the Copilot
Requests permission works, and a classic token is not supported.
Choosing a token: for automation that belongs to an organization, prefer a
GitHub App installation token. Its usage is billed to the organization and
follows its Copilot policies, and it needs no seat. A machine user’s
fine-grained token needs a Copilot seat of its own, and a seat is a license
for one user: keep machine-user tokens to single-purpose automation rather
than one token serving many people. Either way, use a dedicated machine user
or App, grant only Copilot Requests, and set a short expiry.
What each refusal looks like:
Which token value was refused or logged out is remembered only in the
process’s memory, by the account’s AuthMachine lease. A restart, or any other
publication on the account that names no token value (a sign-in, a stored
token, a refresh, the rollback of a failed login), replaces that record: a
refused token is then sent once more, and refused again, and a logged-out
token is used again. A refusal withdraws only the publication it names, so
GitHub’s refusal of a token the account has already replaced leaves the newer
token valid (the
stale_credential row).
rkat auth status copilot_openai shows auth_method: github_copilot_token
and the account’s lifecycle (absent before a process first uses a token from
the environment, then valid, reauth_required or released).
rkat auth test copilot_openai resolves the route and prints account: and
plan:; once a route serves a GitHub App installation token, or a token
GitHub’s identity lookup does not answer, it prints
account: not named by GitHub (...) and no plan:.
rkat auth login copilot --non-interactive names such an account
an unnamed GitHub account (an installation token
github.com installation (server-to-server)), and refuses to store a token
whose model listing Copilot refuses, with Copilot’s reason. A Rust host checks
a token without storing it, as the CLI does before it stores one, with
meerkat_copilot::verify_operator_token(token, &config), where config is
CopilotBackendConfig::from_token_options of the routes’ backend options, so
the check sends what the routes send: it refuses a classic personal access
token, looks up the Copilot identity and lists the account’s models, which
must succeed, with no AuthMachine, token store or cache effect, and answers
the token’s CopilotAccountIdentity (login, plan, api_base,
installation for a GitHub App installation token, and looked_up, whether
GitHub’s identity lookup answered for the token).
CopilotAuthorizer::account_identity reads the identity a route’s token
resolved to.
The token goes only to api.github.com (the identity lookup) and to an
https githubcopilot.com API host, and a redirect is refused. Meerkat keeps
the token out of its errors and log lines: a failed lookup or model listing is
reported by its status and GitHub’s own error message, never by the response
body, and a token the message quotes shows as <redacted>. A token a
provider, proxy or gateway echoes back is redacted too: the route removes the
last four bearer values its account signed requests with (the operator’s
GitHub token in token mode, the derived Copilot token after a device sign-in)
from rejection bodies, stream error events, data lines that do not parse and
the provider error fields it logs, and every error keeps its class. The
bearer, and every header value the route signs a provider request with, is
marked sensitive, so a request’s Debug output does not print it. Only a
verbatim echo is caught: an echo that a provider or gateway truncated,
re-encoded or otherwise transformed can keep part of a token. Meerkat stores
no token it reads from the environment, a command or a resolver; a stored
token uses the configured credential store, like an API key. A token in the
host environment is inherited by shell tools the host runs without
confinement, so hosts that run shell tools unconfined should prefer
managed_store, command or external_resolver. A credential command’s
stderr becomes error text: a minting script must not print the token there.
The three reserved routes and the github_copilot account hold one method at
a time. rkat auth login copilot (device sign-in) and
rkat auth login copilot --non-interactive (token) each refuse routes
configured for the other. To switch, run
rkat auth logout global:copilot_openai and remove the copilot_openai,
copilot_anthropic and copilot_gemini tables under [realm.global]; to use
both, define more routes on another account, for example
credential_account = "github_copilot_ci". Client identity headers stay in
the backend options (integration_id, editor_version, …), equal on
every route of the account. A mob member names its route with
--auth-binding global:copilot_anthropic, or
{"realm": "global", "binding": "copilot_anthropic"} on the wire. The warning
above about GitHub’s unpublished Copilot contract applies to this mode too.
The token route is tested against local stand-ins for GitHub and the Copilot
API. Its check against GitHub with a real fine-grained personal access token
has not run yet, so these answers are not yet observed live:
- whether GitHub’s identity lookup answers a fine-grained token of a Copilot Business or Enterprise machine user, with its login and API host (if it does not, the route uses the default host, above);
- whether the per-plan API hosts (
api.business.githubcopilot.com,api.enterprise.githubcopilot.com) accept such a token undercopilot-developer-cli, and whether they acceptBearerforgho_andghu_tokens; - Claude models (
/v1/messages) with a fine-grained token; - whether
/modelslists each model’s supported endpoints and an enabled policy for every plan’s token callers (without them a route falls back to its default endpoint); - what the Copilot API answers for a revoked or expired token used directly
as its bearer, and for its own rate limits and quotas (status, body and
Retry-Afterform); - whether GitHub’s identity lookup ever answers 401 for a valid token that lacks Copilot access, which would hold that token refused until its value changes, and whether it sends rate-limit headers;
- what a GitHub App installation token gets when its organization has not enabled server-to-server Copilot requests or the App lacks Copilot Requests: a 401 there holds the token refused until its value changes;
- the body form of Copilot API errors: public reports show plain text, which Meerkat reports as “no message” instead of GitHub’s reason;
- whether an organization that turns off the Copilot CLI policy refuses token
route requests, which present as
copilot-developer-cli; - whether a fine-grained token works on Copilot Individual plans at all (one public report shows a 401).
GitHub Copilot configured once
The account can also be configured with one section instead of the nine backend, auth and binding tables above.[realm.<realm>.copilot] states once
where the account’s routes read the GitHub token:
copilot_openai,
copilot_anthropic and copilot_gemini, on its github_copilot credential
account, each with auth_method = "github_copilot_token" over that source:
the routes the tables above declare, under the same names, so
rkat auth test, rkat auth status, rkat auth logout and
--auth-binding global:copilot_anthropic work as before, and realm/get
lists them. The realm lists (rkat auth realms, realm/list and
GET /realms) count a realm’s explicit tables only, so they show no
backends, auth profiles or bindings for a realm configured only by the
section. No route has a default model. Like the explicit tables, they are
the realm’s routes for their providers: with no other binding of a provider in
the realm, a request for a catalog model of that provider that names no
binding runs on the account, before an API key variable.
The section reads an env or an external_resolver source only. A secret in
config, a stored token, a credential command and the device sign-in use the
explicit tables. A realm configures its account one way: the section next to a
table that declares one of its routes, or next to another binding on its
github_copilot account, is refused when the realm loads, and both
rkat auth login copilot forms refuse a [realm.global] that has the
section. A section in another realm is another account: [realm.team.copilot]
configures team:account:github_copilot.
Choosing a model:
- A model of Meerkat’s catalog runs on the account’s route of its provider. A request that names any route of the account is served on the account’s route of the model’s provider; a request that also names a provider keeps the strict check.
-
A model only your account lists (no catalog row, no
[models.<id>]entry) runs when the request names any route of the account:The build asks the account’s model list about the model, after the route has looked the account up. The endpoints Copilot lists for the model choose the protocol, never its vendor: a model on/v1/messagesruns on the Anthropic Messages wire, otherwise a model on/responsesor/chat/completionsruns on the OpenAI wire, Responses first. The session moves to the account’s route of that protocol, whichever route of the account it named. The model’s limits come from the same list: its context window, prompt limit and output limit, and whether it takes images. Tool calls, streaming, structured outputs and thinking budgets are checked against the list on every request, as for every Copilot model. Nothing about the model is stored: a resumed session asks the account again. -
A request that names no binding cannot reach such a model: the account is
asked only on a route the request names. Without a provider the request is
refused as before (“cannot infer provider from model”); with one it is
refused after its credential resolves
(
model '<provider>:<id>' is absent from the effective model registry), or by that resolution’s own error. Name a route of the account as above. A live model switch or a fallback target to such a model, and any live reconfigure of a session that runs one (a provider parameter or binding change too), need a[models.<id>]entry.
A
[models.<id>] entry keeps owning its model: the account is never asked
about it, and its provider still decides the route. To move from the
explicit tables to the section, remove the copilot_openai,
copilot_anthropic and copilot_gemini tables of the realm and add the
section with the same source; the route names and the account stay, so
sessions that name the routes resume on them.
Known limitation: Meerkat Connect’s provider setup and first-target setup do
not yet list the routes a
[copilot] section declares, so a realm whose
Copilot account is configured only by the section can look unconfigured
there. Use the explicit tables with Connect until a later release. With the
explicit tables, Connect can offer all three Copilot routes as targets for a
model of one provider family; whichever is chosen, the session runs on the
account’s route of the model’s family, on the same credential (choosing
copilot_openai for an Anthropic model runs on copilot_anthropic).Use the binding
./.rkat/realms/prod/config.toml; adjust both paths together if you choose a
different root. --realm prod selects that config head and session realm.
--auth-binding selects an already-configured credential identity, not a
config file or the session realm.
Subcommands
For CLI auth administration,--realm prod is an owner-section selector inside
the scope’s already-loaded effective config, normally the workspace/global
chain. Unlike run --realm prod, it does not by itself load a separate
<state-root>/prod/config.toml or inherit missing profiles/bindings for
profile, status, test, or refresh. The default owner section is
global; already-loaded multi-section documents also work.
The prod examples below require its sections to be reachable first. For the
separate file above, add parent = "prod" to the
[realm.<workspace-realm-id>] section of the workspace head’s
config.toml, with both documents under $PWD/.rkat/realms. Find that
workspace ID with:
prod sections in the already-loaded workspace or
global document. Merely passing auth --realm prod is not this setup.
1
Login and profile management
2
Status and refresh
rkat auth refresh is a no-op for api_key / azure_api_key / static_bearer
auth methods (nothing to refresh). For OAuth-backed methods it
exchanges the persisted refresh token for a fresh access token and
writes the new bundle back to the TokenStore.3
Delete
CLI logout ownership
Current CLI exception: if the loaded config resolveschild:<binding>
to an inherited owner, rkat auth logout child:<binding> can clear that
owner’s stored credential. This includes a shared credential account used by
other routes. Explicitly address the true owner when signing out.
A local same-name binding shadows its ancestor. If config resolution fails,
logout falls back to the literal requested token key; a missing child is not
automatically an inherited deletion. A bare logout binding means global,
and --realm does not retarget it. HostAuthService and RPC/REST still enforce
strict-owner credential writes, and config get / set / patch retain the
raw-head read/write split.
Release auth smoke
make e2e-auth is the CI-safe auth smoke lane. It drives Meerkat’s
browser OAuth plumbing against a local OAuth fixture, verifies token-store
and AuthMachine lifecycle behavior across login, status, refresh, restart,
resolve, and logout, and skips optional live-provider canaries when their
seeded credentials are absent.
Real third-party browser login pages remain a manual pre-release check.
Do not automate provider-hosted OpenAI, Anthropic, or Google login pages in
CI; those pages are intentionally subject to MFA, CAPTCHA, account policy,
and provider UI changes.
Manual browser-login release checklist:
- Start login for the target provider.
- Confirm the system browser opens the provider-hosted login or consent page.
- Complete the callback and confirm Meerkat reports the login complete.
- Check auth status and confirm the target binding is
valid. - Run one tiny call through that binding.
- Logout and confirm auth status is cleared and the credential is gone.
Auth methods
Each provider’s runtime accepts a matrix of(backend_kind, auth_method) pairs. The table below summarizes what’s wired today.
External commands (
CredentialSourceSpec::Command) and file
descriptors (CredentialSourceSpec::FileDescriptor) let host
applications inject tokens without going through OAuth.
Credential header values are marked sensitive on every OpenAI Responses,
OpenAI-compatible Chat Completions and Anthropic Messages request, so the
request’s Debug output does not print them and HTTP/2 never indexes them:
the values a dynamic authorizer supplies (an external_authorizer route’s, a
Copilot route’s bearer) and a static API key’s (the OpenAI Authorization
bearer, Azure OpenAI’s api-key, the compatible client’s bearer, Anthropic’s
x-api-key). The Gemini client’s credential headers are not marked. A value
that is not a valid HTTP header value fails the request as an authentication
failure (“a credential header value is not a valid HTTP header value”) before
it is sent. Those clients also pass what they read from a text generation
response (a rejection body, a stream error event, a data line that does not
parse, the provider error fields they log) through the authorizer’s
HttpAuthorizer::redact_response_diagnostic before it reaches a caller or a
log; its default changes nothing, and the Copilot authorizer redacts the
bearers it signed with. Only a verbatim echo is redacted, and nothing redacts
a static API key a gateway echoes back. LlmError::redact_diagnostics,
meerkat_llm_core::streaming::redact_stream_diagnostics and
meerkat_llm_core::http::sensitive_header_value serve an embedder’s own
client.
Credential sources
Thesource field on an auth profile declares where credentials
come from.
FileDescriptor is a host-integration seam rather than a plain standalone resolver path. It requires a host-scoped reader/injection path, so treat it as an advanced embedding surface rather than a generic CLI config trick.Hot-Swap Between Turns
When a session is created with--auth-binding prod:default, its
persisted SessionMetadata.auth_binding carries that binding through
resume and hot-swap. Changing the model/provider on an RPC
turn/start request keeps the binding by default; pass an explicit
auth_binding on the turn request to scope the swap to a different
realm or binding.
ProviderRuntimeRegistry::resolve with the new
binding. It does not silently replace an explicit configured binding with the
environment fallback.
Per-mob-member override
The mob runtime does not promote ambient credentials into member authority. Live model-backed spawn paths that require credentials must receive an explicit binding, either from the compiled member declaration/overlay or asauth_binding on the host-side spawn request:
auth_binding object only; the colon-joined realm:binding[:profile]
form is a CLI input convenience and is converted before it crosses the
wire boundary.
Native desktop hosts
Native applications should useHostAuthService for the complete interactive
transaction instead of recreating the CLI or RPC flow. The service owns strict
binding-owner resolution, PKCE and one-time state, exchange, coordinated
TokenStore persistence, AuthMachine lifecycle publication, rollback, status,
and logout. The application owns only its loopback listener, browser launch,
and UI.
Open one persistence capability and share that exact capability between
AgentFactory and HostAuthService:
MeerkatMachine alive for as long as the factory and auth service;
the snippet returns it in Arc, while a real host normally stores all three in
one application state object. A TokenStore open
failure is returned as a fault and is never collapsed into
interactive_login_required.
Managed-store resolution also requires per-session AuthMachine bindings. Do
not use the factory’s standalone build mode for a runtime-backed desktop
session:
native-keyring feature and
replace default_auto() with default_keyring_auto(). That profile preserves
an existing CLI file credential as the per-key authority, but a brand-new key
is written to the OS keyring. Use the same keyring-aware backend in every
process that must see desktop-first credentials; the stock CLI intentionally
uses the file profile to avoid surprise OS keychain prompts.
Every token-store load, save and clear states its access: Status,
Unattended (provider setup, request preparation, refresh) or Interactive
(an explicit login, logout, save or clear). With a keyring-aware store:
statusnever waits for credential work and never reads the OS keychain. A credential it did not read carriescredential_observation(not_observed,owner_busy,pending,denied,interaction_requiredorunavailable); that is never absence.- Provider setup and refresh admit at most one bounded keychain lookup per
credential, off the async workers. Concurrent requests answer
credential_access_pendingat once, and capacity beyondKEYCHAIN_OPERATION_CAPACITYanswerscredential_store_unavailablewith no call. The OS may still show its own access dialog for that one lookup. - While that lookup or an interactive keychain call holds a credential’s
lease, provider requests, refresh, MCP and connector use answer
credential_access_pendingat once instead of waiting for it. - A denial (
credential_access_denied) is not retried until an interactive login, save or clear succeeds. A denial, a refused interaction or a locked, read-only or otherwise inaccessible keychain never falls back to the file store; only a platform report that there is no credential store does. - Auth errors carry the same four kinds as their
reason:credential_access_denied,credential_interaction_required,credential_access_pendingandcredential_store_unavailable. - Before dropping its runtime, a host settles credential work with
settle_in_flight_credential_mutations(bound)and reportsUnsettled { pending }instead of claiming cleanup.
- Bind the loopback listener and choose its redirect URI.
- Call
login_start(config, target, redirect_uri), then open the returnedauthorize_url. - Pass the callback’s
stateandcodetologin_complete. - Use
statusandlogoutfor secret-free UI projections and sign-out.
Connector OAuth
Connector OAuth stores a credential for a SaaS provider that is neither an LLM provider binding nor an MCP server. The trusted host names a credential slot, a realm-scoped storage address such as{realm_id: "tenant-a", slot_id: "drive-work"}, plus the connector’s descriptor: issuer, client,
resource, scopes, account strategy and account selection. The slot is not
proof of any account. The provider-verified account is bound to the stored
credential at completion and reported separately.
-
Account selection.
knownnames the provider account up front and refuses any other verified account.discoveradmits the login without an account and binds the account the strategy verifies. A discover login publishes only into an empty slot: a slot that holds any credential refuses it (occupied), even for the same account, so two racing logins cannot swap grants. To re-authorize an occupied slot, log in withknownand the account that status reports. Aknownreconnect replaces the slot’s credential only for the same account and the same issuer, client, resource and strategy; the loopback port and the requested scopes may change. Every refusal leaves the slot’s credential untouched. -
Account strategies. The default strategy,
oidc-userinfo-v1, proves the OIDC subject through the issuer’s UserInfo endpoint. Hosts with other provider evidence install aConnectorAccountStrategy(HostAuthService::with_connector_strategy). A strategy that verifies signed ID tokens declaresrequires_nonce: the owner mints a nonce per attempt, puts it in the authorize URL, keeps it with the attempt and hands it to the strategy at completion. -
Scopes. Granted scopes come from the token-endpoint response the owner
parsed itself (
scope_evidence: token_endpoint_response). A refresh response withoutscopekeeps the original grant (retained_on_refresh); one that narrows a required scope, or whose subject is not the bound account, is refused and the credential is unchanged. -
Endpoints. The issuer’s endpoints come from its RFC 8414 metadata or
OpenID Connect discovery. Its
issuermust match, PKCE S256 must be advertised and every endpoint must be https or loopback. Clients are public (PKCE, no client secret). -
Native hosts use
HostAuthService::connector_login_start,connector_login_complete,connector_login_cancel,connector_statusandconnector_logout(which disconnects a slot so it can take another account).connector_oauth_authority().bearer_token(slot)returns the access token for native use, refreshing it through the AuthMachine lifecycle; tokens never cross a wire surface. -
RPC and REST hosts use
auth/login/startandauth/login/completewith aconnectortarget,auth/login/cancelwith{connector: slot, state}and (RPC)auth/status/getwith{connector: slot}:Completion repeats the same target with the callback’scode,stateandredirect_uri; the admitted attempt’s descriptor must equal it. The result and status carry the slot,verified_account(issuer, strategy and subject) andscope_evidenceas separate fields.
state
and code are bearer material for one attempt and must never reach an agent,
tool result, transcript or log.
Refresh coordination
For managed credentials, refresh semantics are owned by theAuthMachine
DSL. AuthMachine leases, TokenStore lookup, and refresh coordination share one
credential identity: the owning realm plus binding and optional profile by
default, or the owning realm plus a configured credential_account. The
latter deliberately shares lifecycle and refresh across multiple route
bindings; binding identity still selects the route. This is the managed
runtime contract, not a claim that every CLI administrative path or
non-managed cloud authorizer uses identical account resolution.
On resolve, the runtime:
- Checks
expires_at. If present and within the refresh window (AUTH_LEASE_TTL_REFRESH_WINDOW_SECS, 60 seconds before expiry), marks the leaseExpiringvia the DSL. - Under
Expiring, the next resolve triggersBeginRefresh. In-process dedup ensures only one refresh HTTP call per credential identity even under concurrent resolves; cross-process dedup via filesystem lockfile whenrefresh-file-lockis enabled. - On successful refresh, fires
CompleteRefreshand updatesexpires_at. On failure,RefreshFailedTransient(retry) orRefreshFailedPermanent(routes toReauthRequired+ emits an[AUTH_REAUTH_REQUIRED]system notice on the session).
specs/machines/auth/.
Audit logging
Every accepted lifecycle transition emits a structured tracing event:target = "meerkat::auth::audit"- Fields:
lease_key,realm, optionalbinding/account/profileprojections,action,from_phase,to_phase
action = mark_publication_refused, also when it changes nothing: a refusal
of a token the account has already replaced, or on a lease already refused
or logged out, keeps from_phase equal to to_phase.
REST and RPC surfaces also emit audit events for user-initiated
actions: create_profile, delete_profile, login_oauth_complete,
login_device_complete, logout. These surface events have their own field
shapes (for example binding_key on a user-action event), not the lifecycle
transition schema. Filter any tracing::Subscriber on the
meerkat::auth::audit target to build a persistent audit log without assuming
every event on that target has the same fields.
Feature flags
meerkat-providers is a compatibility shim. Its feature aliases forward
shared auth primitives to meerkat-auth-core; they do not select provider
clients or activate provider-owned backend/auth branches:
The following native methods also require their provider-owned feature on a
non-WASM target:
These requirements concern the named methods, not every bearer, API-key, or
external-authorizer route to the same backend. Enabling the features does not
make native credential mechanisms available in a browser/WASM runtime.
The shipped
rkat CLI enables Anthropic, OpenAI, Gemini, OAuth,
native-keyring, and refresh-file locking in its dependency graph, while its
stock TokenStore profile remains file-backed. Select the corresponding
provider-owned features when a custom build needs the native methods above.
See also
- Hooks - observe auth events or gate turns on credential state
- Mobs - per-member
auth_bindingoverrides - Machine Authority - rationale behind single-owner runtime state
