pi-keycloak-auth0.2.0
Keycloak OAuth2/OIDC login for the pi coding agent and Oh My Pi: PKCE browser and device flows, offline tokens, automatic refresh, OpenAI-compatible provider with model discovery.
Built for omp
The author tagged this package oh-my-pi.
Reports 0
No reports yet.
Readme
pi-keycloak-auth
Keycloak sign-in for the pi coding agent and Oh My Pi. Log in once with your organization's SSO, then keep working for days: the agent sends a short-lived Keycloak access token to your OpenAI-compatible LLM gateway and refreshes it automatically, with no restart and no new login.
- Three login methods for a public client with PKCE (S256), never a client secret: browser with automatic redirect capture, browser with a pasted redirect URL, and device code (RFC 8628) for SSH sessions, containers and CI.
- Offline tokens by default: a session survives nights, weekends and SSO session expiry.
- Automatic refresh through pi, safe with single-use (rotated) refresh tokens, and resilient to Keycloak or network hiccups.
- OpenAI-compatible provider whose models are read from the gateway's
/v1/modelswith your token, or declared by you, plus optional static headers. - pi and Oh My Pi from the same package and the same configuration file.
- No runtime dependency: Node built-ins and the global
fetch. One 40 KiB file, installable offline.
Contents
- Requirements
- Quick start
- Installation
- Configuration
- Logging in
- Keycloak client
- What the gateway receives
- Token refresh
- Logs
- Troubleshooting
- Security
- Contributing
Requirements
| Component | Version |
|---|---|
pi (@earendil-works/pi-coding-agent) |
tested with 1.5.0, on Node ≥ 22.19 |
or Oh My Pi (@oh-my-pi/pi-coding-agent) |
tested with 18.18.6, on Bun |
| Keycloak | 22 or later, tested with 26.4 |
Quick start
Ask your Keycloak admin for a public client set up as in Keycloak client.
Install the extension:
pi install npm:pi-keycloak-auth # pi omp plugin install pi-keycloak-auth # Oh My PiCreate
~/.pi/agent/keycloak-auth.json(pi) or~/.omp/agent/keycloak-auth.json(Oh My Pi); either agent also finds the other one's file:{ "issuer": "https://sso.example.com/realms/engineering", "clientId": "pi", "baseUrl": "https://llm-gateway.example.com/v1" }Start
pi(oromp), run/login keycloak, sign in, then pick a model with/model. The list comes from your gateway'sGET /v1/models, called with your Keycloak token.
Installation
Pick the mode that fits your environment; the extension behaves the same with each.
From npm
pi install npm:pi-keycloak-auth # latest
pi install npm:pi-keycloak-auth@0.2.0 # pinned
From a local directory or an offline tarball
The package has no dependencies, so nothing is downloaded at install time. pi installs
directories, not .tgz files: extract the tarball first.
# on a connected machine: npm pack pi-keycloak-auth, or take the .tgz from a GitHub release
tar -xzf pi-keycloak-auth-<version>.tgz # creates ./package
pi install ./package
pi install ./pi-keycloak-auth-<version>.tgz reports success, but the extension then fails to
load with Unknown file extension ".tgz": always install the extracted directory. A clone of
this repository works too, after npm ci && npm run build.
As a single file
Each GitHub release ships
pi-keycloak-auth.js (with SHA256SUMS). Drop it into pi's extension folder:
mkdir -p ~/.pi/agent/extensions
cp pi-keycloak-auth.js ~/.pi/agent/extensions/
Update or remove the extension with pi update / pi remove in the first two modes, or by
replacing or deleting the file in the third.
With Oh My Pi
Oh My Pi loads the same package; nothing differs but the commands and its agent folder,
~/.omp/agent:
omp plugin install pi-keycloak-auth # from npm
omp plugin link ./package # an extracted tarball or a clone (built)
cp pi-keycloak-auth.js ~/.omp/agent/extensions/ # the single file
Configuration
Settings come from one global file, keycloak-auth.json, and from PI_KC_* environment
variables. For each option the file wins over the environment, which wins over the default.
The file is the first one found of:
$PI_KC_CONFIG, a full path; when set, nothing else is tried.$PI_CODING_AGENT_DIR/keycloak-auth.jsonwhen that variable is set. Oh My Pi sets it for named profiles (omp --profile work), so each profile has its own file.- Otherwise the running agent's folder, then the other agent's:
~/.pi/agentfor pi,~/.omp/agentfor Oh My Pi (~/$PI_CONFIG_DIR/agentif you renamed its folder). One file therefore serves both agents.
Project-level configuration is never consulted, so a repository you open cannot send your tokens to another issuer or gateway.
{
"issuer": "https://sso.example.com/realms/engineering",
"clientId": "pi",
"baseUrl": "https://llm-gateway.example.com/v1",
"headers": { "X-Client": "pi" },
"discoverModels": true,
"models": [
{ "id": "qwen3-coder", "name": "Qwen3 Coder", "contextWindow": 262144, "maxTokens": 32768 },
{ "id": "gpt-oss-120b", "reasoning": true }
]
}
| Option (file) | Environment | Default | Meaning |
|---|---|---|---|
issuer |
PI_KC_ISSUER |
required | Realm URL, https://<host>/realms/<realm> |
clientId |
PI_KC_CLIENT_ID |
required | Public client id |
baseUrl |
PI_KC_BASE_URL |
required | Gateway base URL, the one ending in /v1 |
models |
PI_KC_MODELS |
none | Models, or details of discovered ones, see Models |
discoverModels |
PI_KC_DISCOVER_MODELS |
see Models | Read the model list from GET <baseUrl>/models |
providerId |
PI_KC_PROVIDER_ID |
keycloak |
pi provider id: /login <providerId>, <providerId>/<model> |
scopes |
PI_KC_SCOPES |
openid |
Extra scopes, array or space/comma separated. openid is always sent |
offlineAccess |
PI_KC_OFFLINE_ACCESS |
true |
Adds the offline_access scope |
headers |
PI_KC_HEADERS |
none | Static request headers. Env form: X-Client=pi,X-Team=core. Authorization is refused |
callbackHost |
PI_KC_CALLBACK_HOST |
127.0.0.1 |
Loopback address of the redirect listener |
callbackPort |
PI_KC_CALLBACK_PORT |
49170 |
Its port; 0 picks a free one |
redirectPath |
PI_KC_REDIRECT_PATH |
/callback |
Path of the redirect URI |
refreshLeewaySeconds |
PI_KC_REFRESH_LEEWAY_SECONDS |
30 |
Call Keycloak once the access token has less than this left |
browserTimeoutSeconds |
PI_KC_BROWSER_TIMEOUT_SECONDS |
300 |
How long a browser login waits for the redirect |
loginMethod |
PI_KC_LOGIN_METHOD |
ask | browser, paste or device: /login uses it without asking |
| — | PI_KC_CONFIG |
see above | Full path of the configuration file |
| — | PI_KC_LOG |
warn |
silent, error, warn, info or debug |
Models
With no models, the list is discovered: the extension calls the gateway's
GET <baseUrl>/models (the OpenAI model list) with your Keycloak access token and your static
headers, and offers every id it returns. With models declared, only those are offered, unless
discoverModels is true.
When both are used, the gateway decides which models exist and models describes them: a
discovered id takes the context window, output limit, reasoning, input and cost of the declared
model with the same id. Declared models the gateway does not list are dropped, and the declared
list is what is shown before the first discovery.
- In the file, each entry is a string (the id) or an object with
idand optionallyname,contextWindow,maxTokens,reasoning,input(["text"]or["text", "image"]),cost({ input, output, cacheRead, cacheWrite }in dollars per million tokens) andcompat(pi's OpenAI compatibility flags, passed through as is). PI_KC_MODELStakes ids only, comma separated (PI_KC_MODELS=qwen3-coder,gpt-oss-120b).- A model known only by its id, declared or discovered, gets
contextWindow128000,maxTokens16384,input: ["text"],reasoning: falseand cost 0. Declare it in the file to set real values: the OpenAI model list carries no such details.
When the list is fetched:
| Agent | Fetched | Between fetches |
|---|---|---|
| pi | at each interactive start and when /model opens, with a token pi refreshes first |
kept in ~/.pi/agent/models-store.json, used by pi -p and --list-models |
| Oh My Pi | right after /login, then when its 24-hour model cache expires |
kept in its model cache |
A failed fetch (gateway down, 401) keeps the previous list and logs a warning. Oh My Pi hands the extension the stored token without refreshing it; with Keycloak's short tokens that one has usually expired at the next start, so the extension uses the latest valid token it has seen in the session, or keeps the cached list. In practice the list is refreshed at each login and whenever the cache expires during a working session.
Incomplete configuration
The provider is still registered, as Keycloak (not configured), so it never silently
disappears. pi shows a message at startup, and /login and requests fail with it; it names
the missing or invalid fields and the config file, e.g. invalid or missing clientId, baseUrl. In this mode the provider points at https://not-configured.invalid, a reserved name
that never resolves, and sends nothing anywhere.
Logging in
/login keycloak asks how to sign in:
| Method | Best for | What happens |
|---|---|---|
| Browser, redirect captured automatically | desktops (listed first) | pi opens the login page; afterwards the browser shows Authentication complete and tries to close the tab |
| Browser, paste the redirect URL | when the callback port cannot be reached | after sign-in the browser fails to load http://127.0.0.1:…; copy that URL from the address bar into pi (the bare code also works) |
| Device code | SSH, containers, CI, Linux without display (listed first there) | open the shown URL on any device and confirm the code |
- Without a local browser, pi never tries to open one: URLs are printed instead.
- If the callback port is taken, the browser method switches to the paste method by itself.
- Starting a new login frees the port held by an abandoned one.
loginMethodskips the question and always uses that method.- Oh My Pi has no menu for this question: it is asked as text (
browser,pasteordevice; Enter keeps the recommended one, a prefix such asdis enough), and the device code is shown with its URL in the login dialog. Oh My Pi's RPC mode, used by editor integrations, cannot ask before showing a URL: there the recommended method is used, orloginMethod. - On a brand-new pi setup, pi then prints
Logged in to Keycloak (keycloak), but no default model is configured for provider "keycloak". Use /model to select a model.This is expected: choose a model with/model, or setdefaultProvider/defaultModelin pi's settings.
pi stores the credentials in ~/.pi/agent/auth.json (mode 0600), Oh My Pi in its agent
database under ~/.omp/agent. /logout removes them.
Keycloak client
One public client is enough. The full guide, with admin-console steps, a kcadm.sh script and
an importable definition, is in docs/keycloak-setup.md. In short:
- Client authentication off (public client).
- Standard flow and OAuth 2.0 Device Authorization Grant on; everything else off.
- PKCE code challenge method
S256. - Valid redirect URIs
http://127.0.0.1:49170/callback(plushttp://127.0.0.1/*ifcallbackPortis0). offline_accessas an optional client scope; users need theoffline_accessrole (included indefault-roles-<realm>).- An Audience mapper adding your gateway's audience to the access token.
- Access Token Lifespan of 10–15 minutes. pi refreshes a token as soon as it has less
than five minutes left, a threshold fixed in pi. With Keycloak's default 5 minutes every
request falls inside that window; the extension still calls Keycloak only in the last 30 s,
but pi locks and rewrites
auth.jsonon each of those requests.
What the gateway receives
Authorization: Bearer <Keycloak access token>
X-Client: pi
The bearer is the access token (a JWT with "typ": "Bearer"), never the ID token, so a
gateway that already validates Keycloak tokens needs no change. Validate it against the realm
keys (<issuer>/protocol/openid-connect/certs) and check:
| Claim | Expected |
|---|---|
iss |
the realm URL |
aud |
your gateway's audience (added by the Audience mapper) |
azp |
the pi client id |
exp |
in the future |
realm_access.roles, resource_access.<client>.roles |
the roles your gateway requires |
scope |
includes offline_access when offline tokens are in use |
Token refresh
- Before each request pi checks the stored token. With less than five minutes left it locks
auth.json, calls the extension, and saves the returned pair before unlocking, so several pi processes never redeem the same refresh token twice. - The extension calls Keycloak only when less than
refreshLeewaySecondsremain. Concurrent refreshes in one process share one call, and each result is remembered in memory for 60 seconds, so a late caller holding a just-rotated refresh token gets the new pair instead ofinvalid_grant. - Keycloak unreachable, 5xx, 429 or timeout: the current access token keeps being used while it is valid, and the refresh is retried on the next request.
- Refresh token rejected (
invalid_grant, e.g. offline session revoked or idle too long): the request fails withKeycloak session expired for "keycloak" (invalid_grant): … Run /login keycloak to sign in again., also shown as a notification. - Nothing runs in the background: after a night with pi left open, the first request of the morning refreshes the token and goes through.
Details and the request flow: docs/architecture.md.
Logs
Log lines start with [keycloak-auth] and go to stderr. PI_KC_LOG sets the level: silent,
error, warn (default), info, debug. In pi's interactive view they appear above the
editor, so keep warn day to day and use debug to troubleshoot.
Tokens, refresh tokens, authorization codes and PKCE verifiers are never logged. As a safety net, anything token-shaped in a log line is masked to at most its last four characters.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
not_allowed: Offline tokens not allowed for the user or client |
The user lacks the offline_access role, or the scope is not assigned to the client. Fix the realm, or set offlineAccess: false (sessions then end with the SSO session) |
invalid_grant after a long pause |
Offline session expired or revoked, or SSO session ended with offlineAccess: false. Run /login keycloak |
| Keycloak page Invalid parameter: redirect_uri | Add the exact redirect URI to the client (or http://127.0.0.1/* with callbackPort: 0) |
unauthorized_client with the device method |
Enable OAuth 2.0 Device Authorization Grant on the client |
Port 49170 on 127.0.0.1 is already in use |
Another program holds the port; the login falls back to pasting. Or set callbackPort: 0 |
| Provider shown as Keycloak (not configured) | Read the message: it lists the fields to fix and the config file path |
no default model is configured for provider "keycloak" |
Expected after the first login on a fresh setup: run /model |
No Keycloak model in /model |
Discovery failed or has not run yet: PI_KC_LOG=info shows discovered N model(s) or the gateway's answer. With pi, open /model again after the first /login. pi -p only sees models found by an earlier interactive pi |
omp --mode rpc refuses to start before the first login |
Oh My Pi's RPC mode needs a model to start with: declare one in models (with "discoverModels": true to keep discovery) and pass --model keycloak/<id> |
| Oh My Pi ignores the configuration | Check where it looks: the message names the file. Set PI_KC_CONFIG to point at it explicitly |
Unknown file extension ".tgz" |
Install the extracted tarball directory, not the .tgz |
| Gateway answers 401 or 403 | Compare iss, aud and roles in the gateway logs with What the gateway receives; docs/keycloak-setup.md shows how to inspect the token locally |
| Anything else | PI_KC_LOG=debug pi, then open an issue with the log (it contains no secret) |
Security
- Public client with PKCE S256 on every flow and
statechecked on every redirect; no client secret exists anywhere. - The loopback listener binds
127.0.0.1only, ignores forged redirects, and stops after one login or the timeout. - Credentials are stored by pi with mode
0600; the extension writes nothing else to disk. - Report vulnerabilities privately, see SECURITY.md.
Contributing
Issues and pull requests are welcome; see CONTRIBUTING.md and the code of conduct. Release notes are in CHANGELOG.md.
License
MIT © AyRickk