Auth
Inbound authentication, tenant derivation, and isolation defenses. Source: felix.auth in felix-run/felix.
FELIX_AUTH_MODE:
| Mode | Behavior |
|---|---|
none |
Anonymous principal (tenant_id=default). Requires FELIX_ALLOW_INSECURE=true outside development when binding a non-loopback host |
api_key |
`Authorization: Bearer |
jwt |
Bearer JWT verified via FELIX_JWT_VERIFIERS + JWKS (FELIX_JWKS_PUBLIC for self-issued) |
Unauthenticated in every mode: the probes /health, /live, /ready, and everything under
/.well-known/*. That set is deliberately short.
/metricsis not in it. Its label values carry tenant-supplied manifest ids and remote MCP tool names, so an anonymous scrape discloses every tenant’s manifests and tools. It is not rate-limit exempt either. See observability for the credential a scraper needs./docsand/openapi.jsonare not in it unless the operator opts in withFELIX_DOCS_PUBLIC=true— they describe every route, management ones included, and were the one map of the surface an unauthenticated caller could read. Setting it on an authenticated deployment logs a warning at startup. UnderFELIX_AUTH_MODE=noneeverything is public anyway./redocdoes not exist. FastAPI’s Swagger and ReDoc are both disabled (docs_url=None,redoc_url=None);/docsis a Scalar page the harness mounts itself, under one CSP.
Missing credentials in api_key / jwt → 401 (fail closed). Mint self-issued tokens with felix mint-jwt,
or let people get their own with GitHub login or
Google sign-in.
GitHub login
Section titled “GitHub login”A person runs felix login, enters a code at github.com, and gets a short-lived Felix JWT for the
tenant their GitHub org maps to. GitHub OAuth apps issue no OIDC ID token, so there is nothing for
a verifier to point at. Felix runs the device flow on the caller’s behalf instead, checks
membership, and mints a self-issued token. The GitHub token is used for two reads and dropped.
There is no user table: the org map says who gets in, and signup, when it is on, adds
the accounts the operator invited.
FELIX_AUTH_MODE=jwtFELIX_JWT_VERIFIERS=self:felix-selfFELIX_JWKS_PRIVATE=... # RSA PEM; FELIX_JWKS_PUBLIC is its public halfFELIX_ALLOWED_TENANTS=acmeFELIX_GITHUB_CLIENT_ID=Ov23li... # OAuth app with "Enable Device Flow" ticked; no secret neededFELIX_GITHUB_ORG_TENANTS='{"acme-corp": {"id": 12345678, "tenant": "acme", "scopes": ["jobs:read", "manifests:write"]}}'- Orgs are pinned by numeric
id(gh api orgs/acme-corp --jq .id). A renamed or deleted org’s name can be registered by anyone, and matching on the name would hand that org’s members your tenant. A mismatch is refused and logged. - Only an
activemembership counts. A pending invitation is not membership. The app asks forread:org. An org with OAuth app access restrictions must approve the app before its members can log in (org_access_restricted). - Two orgs on one tenant merge their scopes. Orgs on different tenants make a login
ambiguous (409
tenant_ambiguous). Device codes are single-use, so that flow is spent; runfelix login --tenant <one>. - The subject is
github:<numeric user id>, never the renameable login. The token also carriesidp: githubandgithub_login. - Tokens last
FELIX_GITHUB_LOGIN_TTL_SECONDS(default 8h, at most 24h). Nothing revokes one early, so a member removed from the org keeps access until it expires. Rotating the key revokes everything. - Startup refuses a login that would mint tokens this process rejects. One probe token per
mapped tenant goes through the configured verifiers, which catches a mismatched key pair, a
missing
self:felix-self, a tenant outsideFELIX_ALLOWED_TENANTS, or afixed:verifier ahead of it that would override the tenant.
POST /auth/github/device and POST /auth/github/token are public, and only those two exact paths,
only while FELIX_GITHUB_CLIENT_ID is set.
Starting a flow spends the OAuth app’s GitHub quota, so starts have two hourly buckets of their own:
one per client address (FELIX_GITHUB_DEVICE_STARTS_PER_HOUR, default 10, with an IPv6 client keyed
by its /64), and one for the whole deployment (FELIX_GITHUB_DEVICE_STARTS_PER_HOUR_TOTAL, default
300), because the quota is shared and a botnet is many clients. A start its client’s bucket refuses
does not count toward the total. Reaching the deployment cap refuses every new login until the hour
turns (issued tokens are unaffected), so size it to your login volume; without FELIX_REDIS_URL
both buckets are per process. Every token minted is audited in its tenant as github_login. The
REST API has the request and refusal shapes.
felix login --save keeps one token per server in ~/.config/felix/token, and hands each back
only to the server that minted it. The file and its directory must be yours alone, or it is not
used. felix login refuses plain http:// except to localhost, since the token comes back over
that connection; --insecure overrides that.
Signup
Section titled “Signup”An account in none of the mapped orgs is not_a_member — unless signup is on. With
FELIX_GITHUB_SIGNUP=invite, the accounts the operator lists sign in to a personal tenant of
their own, and anyone else outside the orgs is refused with 403 not_invited.
FELIX_GITHUB_SIGNUP=invite # default offFELIX_GITHUB_SIGNUP_LOGINS=octocat:583231,hubot # login, or login:<numeric id>FELIX_GITHUB_SIGNUP_SCOPES=memory:read,audit:read- The tenant is
gh-<numeric GitHub id>, never the login: a login can be renamed and then registered by someone else, and a tenant must not follow it. - Pin each invite as
login:<id>(gh api users/octocat --jq .id). A pinned invite matches on the id alone, so a renamed account keeps it and a re-registered login does not inherit it. A bare login matches case-insensitively, and each match logs the id to pin. - Org membership wins. An invited account that is also an active member of a mapped org signs in to the org’s tenant, as it did before.
- Only Felix’s own sign-in can claim a personal tenant. It is in no
FELIX_ALLOWED_TENANTS, so the verifier admits agh-<id>claim only from aself:felix-selftoken withidp: githuband subjectgithub:<id>. Any other issuer, or any other subject, is refused. - Startup refuses
invitewith an empty list, signup without scopes,adminor*among them (they are what a stranger’s token carries), and any configured tenant shaped like a personal one. - There is no
openmode yet. Every personal tenant spends the deployment’s model credentials, and nothing caps one tenant’s total spend; admitting any GitHub account waits for that cap.
Changing the list is a config edit and a restart. GET /auth/methods reports the mode as
github_signup, and chat-ui’s sign-in card says when a deployment is invite-only. The first
github_login audited in a personal tenant carries signup: true.
Signing in from a browser
Section titled “Signing in from a browser”With a GitHub App behind FELIX_GITHUB_CLIENT_ID, a browser signs in by redirect instead of a
pasted code: GET /auth/github/authorize?return_to=… → GitHub → /auth/github/callback → back to
the app, which collects its token once with POST /auth/github/exchange. It is on while
FELIX_GITHUB_CLIENT_SECRET, FELIX_GITHUB_TOKEN_KEY (32 bytes, base64) and
FELIX_GITHUB_REDIRECT_ORIGINS are all set, and GET /auth/methods then reports
github_redirect: true.
- The only return target is an allowed origin.
return_tomust be on an origin inFELIX_GITHUB_REDIRECT_ORIGINS; the callback GitHub redirects to is<origin><FELIX_GITHUB_CALLBACK_PATH>(behind chat-ui’s proxy,/api/auth/github/callback), and each such URL must be registered on the App. - Only the browser that started a sign-in can finish it.
stateand the PKCE verifier live in a sealed, HttpOnly cookie; a callback carrying anotherstateis refused. - The token never rides a URL. The callback leaves it in a sealed HttpOnly cookie for one page
load, and
/exchangehands it over and clears it. Every outcome of the callback is a redirect back with a fragment,#felix_login=okor#felix_login_error=<code>, which never reaches a server or a log.
The device flow keeps working with the App’s client id, for felix login and the terminal.
Revoking
Section titled “Revoking”A sign-in through a GitHub App with expiring user tokens also keeps the person’s GitHub refresh
token, sealed with FELIX_GITHUB_TOKEN_KEY and bound to its tenant, user and column, so Felix can
act as them on a repository they open (next section). GET /github/connection says whether Felix
holds one for you; DELETE /github/connection forgets it and withdraws the App’s grant at GitHub —
chat-ui’s sign-out calls it. An operator holding github:admin lists them with
GET /github/connections and revokes anyone’s with DELETE /github/connections/{github_user_id}.
A Felix token itself still lasts its TTL: nothing revokes one early.
GitHub Actions
Section titled “GitHub Actions”A workflow has no person to approve a code, but it does have an identity: a job granted
id-token: write can ask GitHub for an OIDC ID token that names its repository, ref, workflow
file and event. POST /auth/github/actions checks GitHub’s signature on it and mints a Felix
token for the tenant the repository’s owner maps to. The job keeps no stored secret. The
exchange is on while FELIX_GITHUB_OIDC_AUDIENCE is set, and it needs no OAuth app.
FELIX_GITHUB_OIDC_AUDIENCE=https://felix.example.com # this deployment's own URLFELIX_GITHUB_ORG_TENANTS='{"acme-corp": {"id": 12345678, "tenant": "acme", "scopes": [], "actions": {"repositories": {"deploy": 87654321}, "refs": ["refs/heads/main"], "environments": ["production"], "scopes": ["manifests:write"]}}}'A valid ID token proves only that some workflow ran in a repository, so the org’s actions
block says which runs may log in:
-
Repositories are
{name: id}(gh api repos/acme-corp/deploy --jq .id), matched on the id, since a deleted repository’s name can be taken by any member who can create one. The org alone is not enough: an outside collaborator with write access to one repository can run a workflow there. -
At least one narrowing is required. Every narrowing you set must match:
refs(refs/heads/main,refs/tags/v*);workflows, which matchesjob_workflow_ref, the workflow file that actually ran (for a reusable workflow, the callee):acme-corp/deploy/.github/workflows/ship.yml@*;environments.
Patterns are case-sensitive globs, and
*also matches/. An environment with required reviewers is the strongest gate GitHub offers. -
pull_request_target,issue_commentandworkflow_runare refused unless listed inevents. They run on the base branch while acting on outside input, so arefofmainsays nothing about the code they execute. -
The block has its own
scopes; it never inherits the org’s. -
The audience must be this deployment’s own https URL. Startup refuses GitHub’s default (
https://github.com/<owner>), which tokens meant for other services carry. Keep it unique per deployment, or a token captured by staging can be replayed to production.
The token lasts FELIX_GITHUB_OIDC_TTL_SECONDS (default 15m, at most 1h). Its subject is
github-actions:<repository id>:<job_workflow_ref>. It carries idp: github-actions and
github_actor, never github_login, because whoever triggered the run did not authenticate.
Every exchange is audited in its tenant as github_actions_login, with the repository, ref,
workflow file, environment, event, sha, run and actors. The ID token is never logged or audited.
It is not single-use either: replaying one within its few minutes of life mints another token for
the same run.
In the workflow, with nothing to install, logging in inside the step that uses the token:
permissions: id-token: write # what lets the job ask GitHub for an ID token contents: readsteps: - name: Call Felix env: FELIX_URL: https://felix.example.com run: | id_token=$(curl -sS --fail-with-body -G "$ACTIONS_ID_TOKEN_REQUEST_URL" \ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ --data-urlencode "audience=$FELIX_URL" | jq -r .value) token=$(jq -n --arg t "$id_token" '{id_token: $t}' \ | curl -sS --fail-with-body -X POST "$FELIX_URL/auth/github/actions" \ -H 'content-type: application/json' -d @- | jq -r .access_token) echo "::add-mask::$token" curl -sS --fail-with-body "$FELIX_URL/manifests" -H "Authorization: Bearer $token"Nothing registers the Felix token with the job log’s masking for you, so ::add-mask:: it before
anything could print it. Keep it in the step that uses it. Writing it to $GITHUB_ENV hands it to
every later step, including any third-party action, and masking only hides it from the log. When
a later step does need it, pass it as a step output to just that step. --fail-with-body fails
the step on a refusal and still shows the error (workflow_not_granted, tenant_ambiguous and
its tenants).
Or, from Python, with felix-client (httpx and nothing else):
import asynciofrom felix_client import github_actions_login
token = asyncio.run(github_actions_login("https://felix.example.com"))print(f"::add-mask::{token.access_token}")The audience defaults to the server URL. Pass audience= if FELIX_GITHUB_OIDC_AUDIENCE is
something else, which only makes sense when both name the same server. The ID token is good there
for a few minutes, wherever it is sent.
Where the CLI is installed, felix login --github-actions [--audience …] does the same. Capture
its output, as in token=$(felix login --github-actions): stdout is the token alone. In a job it
also writes ::add-mask:: on stderr, but mask the captured value yourself as well.
For machines outside GitHub Actions (cron, another CI), mint a token for them with
felix mint-jwt.
Google sign-in
Section titled “Google sign-in”For a deployment that should let a few named people in, without opening sign-in to the public. Only the Google accounts the operator lists get a token, and no one can sign themselves up. The motivating case is a family install where a child’s account reaches one supervised agent and nothing else.
A browser signs in by redirect, the same shape as GitHub’s:
GET /auth/google/authorize?return_to=… → Google → /auth/google/callback → back to the app, which
collects its token once with POST /auth/google/exchange. Redirect is the only way in; there is no
device flow and no CLI login.
FELIX_AUTH_MODE=jwtFELIX_JWT_VERIFIERS=self:felix-selfFELIX_JWKS_PRIVATE=... # RSA PEM; FELIX_JWKS_PUBLIC is its public halfFELIX_ALLOWED_TENANTS=family,kidsFELIX_GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.comFELIX_GOOGLE_CLIENT_SECRET=...FELIX_GOOGLE_COOKIE_KEY=... # openssl rand -base64 32FELIX_GOOGLE_REDIRECT_ORIGINS=https://chat.example.comFELIX_GOOGLE_ACCOUNTS='{ "parent@gmail.com": {"tenant": "family", "scopes": ["sessions:read"]}, "kid@gmail.com": {"tenant": "kids", "scopes": [], "manifests": ["kids"], "sub": "1084…"}}'Sign-in is on while the client id, the secret, the cookie key and at least one redirect origin are
all set, and GET /auth/methods then reports google_redirect: true.
In Google Cloud, create an OAuth client of type Web application and register
<origin><FELIX_GOOGLE_CALLBACK_PATH> as its redirect URI. Behind chat-ui’s proxy that is
/api/auth/google/callback. Leave the consent screen in Testing and add each listed account as
a test user. Google then refuses everyone else before Felix is ever asked, so you get a second
allowlist for free. A supervised (Family Link) account may need a parent to allow third-party
“Sign in with Google” in Family Link.
Who gets in:
-
An entry is keyed by the Google-verified email. Matching ignores case, and Google must report
email_verified: true. Any other account is sent back as#felix_login_error=not_allowed, and the harness log names its email and Google account id. -
subpins the entry to one Google account. An address can be recycled or moved to another account; the id cannot. Sign in once, copy the id from the log line, and add it. -
What is checked on Google’s answer: the ID token comes from Google’s token endpoint, never from the browser. Felix verifies:
- its RS256 signature against Google’s published keys;
issandaud(your client id),expandiat;- a
noncethat only the browser which started this sign-in holds.
state, the nonce and the PKCE verifier live in a sealed HttpOnly cookie. -
The token is self-issued like GitHub’s:
subisgoogle:<id>, withidp: googleand the email. It lastsFELIX_GOOGLE_LOGIN_TTL_SECONDS(8 hours by default), and nothing revokes one early. Each sign-in is audited asgoogle_loginin its tenant. -
The person, for the client to show. Sign-in asks for
openid email profile./exchangereturns the verifiedemailand thenameandpictureGoogle reports alongside the token, and chat-ui draws them in its account chip. They are handed over once and never stored or minted into the token. A picture is passed only if it is anhttpsURL on*.googleusercontent.com. -
Secrets from a backend.
FELIX_GOOGLE_CLIENT_SECRETandFELIX_GOOGLE_COOKIE_KEYhydrate fromFELIX_SECRETS_BACKENDasGOOGLE_CLIENT_SECRETandGOOGLE_COOKIE_KEY, like GitHub’s pair, and are masked like any credential.
manifests pins the account to agents. The token carries a felix_manifests claim, and every
other manifest refuses it (manifest gates). Boot refuses a configuration that
would let a pinned account reach around its pin:
- Write scopes. A pinned account may carry only
<area>:readscopes. A write scope would let it edit the pinned manifest, schedule a job or run an eval. - A shared tenant. A pinned account may not share a tenant with an unpinned one. Threads, history and search are shared within a tenant, so a child in a parent’s tenant could read the parent’s conversations.
The bundled kids manifest is what such an account is meant for. It has no tools and stores no
long-term memory facts. Personal information is blocked in both directions, and a
child_appropriate judge scores every reply before any of it is released. If the judge’s model
call fails, it falls back to a keyword heuristic, so watch the judge’s route.
Per-person repositories
Section titled “Per-person repositories”A person opens one of their own repositories in a thread, and the agent works on a checkout that belongs to that thread alone. Every GitHub read and the clone act as the person, with a token minted from their stored connection; no route returns a GitHub token.
| Method | Path | Answer |
|---|---|---|
GET |
/github/repos?q= |
the repositories your App authorization reaches, the App’s installations, and install_url |
POST |
/chat/sessions/{thread_id}/workspace/repo |
{full_name, ref?} → 202 {state: "cloning", …} |
GET |
/chat/sessions/{thread_id}/workspace/repo |
{state, repo, base, branch, ahead, dirty, error} |
GET |
/chat/sessions/{thread_id}/workspace/repo/files?prefix=&limit= |
{state, files: [{path, kind, size, status}], truncated} |
DELETE |
/chat/sessions/{thread_id}/workspace/repo |
204; 503 workspace_unavailable when a hosted sandbox could not be cleared (the checkout is kept) |
-
The intersection, and only that. An App’s user token sees repositories where the App is installed and the person has access. A repository missing from the list is one the App was not installed on:
install_url(fromFELIX_GITHUB_APP_SLUG) is where to fix that. -
The checkout is the thread’s workspace. Every harness workspace tool, and the shell tool, works in it and nowhere else. A checkout still cloning, failed or expired stops the tools rather than falling back to the thread’s workspace scope under
FELIX_WORKSPACE_ROOT, and the checkout root (FELIX_REPO_CHECKOUT_ROOT, defaultFELIX_DATA_DIR/checkouts) is never inside it. -
Publishing is as the person.
github_publish: {auth: person, branch_prefix: …}publishes to the thread’s repository with the opener’s token, behind its approval as always. -
Bounded. A repository over
FELIX_REPO_CLONE_MAX_MB(500) is refused before cloning; a checkout unused forFELIX_REPO_CHECKOUT_TTL_DAYS(14) is removed by the worker. -
Under the hosted backend the checkout is the thread’s sandbox. It is cloned there (into an empty workspace only), listed and published from there, and destroyed with it on removal or expiry. Nothing of the repository is on the harness host.
-
The files are listed, not served. The listing is git’s: tracked files and untracked ones it does not ignore, each with its
lstatsize and status (clean,modified,added,deleted,untracked,conflicted). A symlink is listed as one and never followed.prefixnames a directory (a literal path, never a glob);limitdefaults to 2,000 and caps at 10,000, withtruncatedsaying the list was cut. A clone still running is409 checkout_cloning.
In chat-ui, the side pane’s Files tab has a Repository on the harness section that opens one and, once it is
ready, draws its files as a tree opened onto what git reports changed, re-read when each run
settles. Files there do not open: no route serves a checkout file’s contents. /harness/github
shows your connection and what it reaches.
Manifest gates
Section titled “Manifest gates”Routes call enforce_inbound_auth (via prepare_tenant_invoke):
inbound.allow_anonymous: false→ reject anonymousinbound.required_scopes→ all must be present on the principal → else 403- a credential carrying a
felix_manifestsclaim (a list of names) → the manifest must be in it → else 403. A claim that is not a list of strings is refused, not ignored.
The same check guards every surface that runs an agent: chat, /v1, A2A, MCP, side questions,
/chat/compact, a rewind’s summary, and each child delegation compiles. A durable run stores the
pin and is held to it on resume. A pinned manifest’s own spec.sub_agents are the operator’s choice
and are not checked again, as with auth.inbound. Google sign-in mints the claim
for an account with manifests. An api-key entry or another IdP’s token that carries it is held to
it as well. GET /v1/models lists only the pinned manifests to such a credential, so a client never
offers it an agent it would be refused, or defaults to one.
Management routes call require_mgmt_scopes with the scopes in felix.auth.mgmt — audit:read,
manifests:write, approvals:write, and the rest of the pairs listed in the
Management API.
RequestContext
Section titled “RequestContext”Auth middleware installs a contextvars-backed RequestContext: settings, principal (tenant / scopes / subject), and a fresh LimitState. Tool wrappers and patterns read it via get_context() / try_get_context() — no parameter threading, harder to tamper.
Tenant derivation (JWT)
Section titled “Tenant derivation (JWT)”Each verifier in FELIX_JWT_VERIFIERS takes the tenant one way, set with ;tenant=:
| Mode | Tenant |
|---|---|
claim (default) |
The token’s tenant_id, custom:tenant_id or tid claim, in that order. No claim → refused; it never falls back to a shared tenant. The value must be in FELIX_ALLOWED_TENANTS when that is set, and outside development a claim-mode verifier with no allowlist refuses to start |
issuer |
The first DNS label of the issuer host. Two issuers that collapse to one tenant refuse to start |
fixed:<tenant> |
Always that tenant |
Anonymous callers and api_key keys without a tenant use default.
Internal consumers
Section titled “Internal consumers”POST /internal/* is self-authenticating: shared secret header (FELIX_CONSUMER_SHARED_SECRET / X-Felix-Consumer-Secret) with constant-time compare. Required when auth_mode ≠ none (and in production).
Thread-id smuggling
Section titled “Thread-id smuggling”Caller-supplied thread suffixes (body.thread_id, x-thread-id) reject : and #. The server always prefixes {tenant_id}:.
Outbound
Section titled “Outbound”Manifest MCP / peer / container auth refs resolve outbound headers (bearer literals or OAuth client-credentials). Tokens may be cached encrypted at rest when FELIX_OAUTH_CACHE_KEY is set. Outbound hosts pass SSRF allow-list checks before a bearer is attached.