DEV Community

Artemii Amelin
Artemii Amelin

Posted on

shell.online Now Accepts Any OpenID Provider. The Verifier Reads Seven Claims, and None of Them Is About the Agent

An individual Internet-Draft from March, with no IETF standing yet, proposes ten new ID-token claims for AI agents: agent_id, agent_owner, agent_trust_score, agent_capabilities, agent_spend_limit and five more. Okta went further in August and now registers an agent as a first-class identity next to the humans and issues it short-lived tokens. Both are answers to a real question, which is what an identity provider should say about an agent.

shell.online merged its first OpenID Connect sign-in today, in PR #157, authored by Martin Monperrus with compatibility follow-ups from the maintainers. It is worth being precise about what the new verifier reads out of a token, because the list is short and the agent claims are not on it.

Seven claims, all of them about a person

The server-side verifier lives in app/server/lib/oidc-token.ts. Every field it pulls from a payload is visible in one grep:

payload.sub
payload.email
payload.email_verified
payload.name
payload.preferred_username
payload.given_name
payload.auth_time
Enter fullscreen mode Exit fullscreen mode

sub becomes the account key, and an empty one is rejected before it can become a row. name falls back through preferred_username and then given_name, because Keycloak only sends name when an account has both a given and a family name, and a username-only account would otherwise get a blank display name. auth_time is converted from seconds to milliseconds and carried through as the original sign-in time, which is the value the account deletion and vault reset routes compare against a ten-minute window. A refreshed token keeps the old auth_time, so a refresh alone does not satisfy them.

Nothing else. Not agent_id, not agent_owner, not a trust score. Signature, expiry, issuer and audience are enforced by jose before the payload is looked at, restricted to RS256.

That is the right list for this service, and the reason is where the agents actually sit. In shell.online, a coding agent is a process on a machine that the CLI linked to an account. The browser viewer, the person, is who signs in. The agent's session shows up in that person's workspace because the machine was linked under their sub, not because the agent presented a token of its own. So an ID token that said something about an agent would have no place to go. The identity the accounts app needs to bind is the human's, and the seven standard claims cover it. Agent identity is a separate layer for us: on Pilot Protocol an agent is identified by the key it holds on the overlay network and the address that key maps to, which is a transport-level fact and not something a browser sign-in provider is asked to vouch for.

Why the audience has to be typed in separately

The configuration reader in app/server/lib/config.ts takes an issuer and a separate audience, and refuses to guess the second from the first. The comment in the file explains it plainly: for Firebase the audience happens to equal the project id, but for any other provider it is the client id, and deriving it from the issuer would accept tokens minted for a different application on the same provider.

That is a Keycloak-shaped concern. One realm can issue tokens to a dozen clients, and a token for the wiki is a perfectly valid, correctly signed token from the same issuer. The verifier's test file has a case for exactly this: "rejects a token minted for another client", next to "rejects a token from another issuer on the same provider".

Firebase itself is now described as one OpenID provider among others. The old FIREBASE_PROJECT_ID variable is shorthand for an issuer of https://securetoken.google.com/<project> and an audience equal to the project id, with a fixed JWKS URL. Both paths produce the same IssuerSettings object and go through the same verifier. The server's entry point imports createVerifier from the OIDC module only; the older Firebase-specific verifier still exists in the tree but is no longer what index.ts wires in.

Discovery happens on the first token, not at boot

The key set is found by fetching /.well-known/openid-configuration on the issuer and reading jwks_uri, unless OIDC_JWKS_URI names it directly. Discovery is deferred to the first token that arrives, and the comment says why: the app and its provider come up together under one compose file, and a service that refused to start because its neighbour was still starting would be worse than a first request that waits. A failed discovery is not cached, so a provider that was briefly down recovers on the next token without a restart. The test named "retries discovery after a failure instead of caching it" pins that down.

The browser policy is built from the issuer

Sign-in in the browser is Authorization Code with PKCE through oidc-client-ts, as a public client with no secret. The Content-Security-Policy in app/server/lib/browser-headers.ts is derived from the issuer's origin rather than a hostname list: connect-src for the discovery document, JWKS and token endpoint, form-action for the authorization redirect, and frame-src for the silent renewal iframe. Two headers flip depending on mode. X-Frame-Options is DENY under Firebase and SAMEORIGIN under OIDC, because the renewal iframe loads the app's own callback route, and frame-ancestors moves from 'none' to 'self' for the same reason. The comment notes what the wrong setting looks like in practice: sessions quietly ending at token expiry rather than any error a person sees.

The callback route has to distinguish three callers: an iframe doing silent renewal, a popup doing re-authentication with prompt=login, and an ordinary top-level redirect. Only the last one navigates. The return path carried through the provider's state is accepted only if it is a path on this app, since anyone can start a sign-in carrying any value they like and a full URL there would be an open redirect.

What this means for a self-hosted deployment

Set VITE_OIDC_ISSUER and VITE_OIDC_CLIENT_ID and the app switches. Set one without the other and the deployment check fails rather than producing a broken sign-in screen. CI now builds both modes, with a fake https://auth.ci.example.com/realms/ci issuer for the OIDC one, so neither can regress silently. Registration and password reset have no separate button under OIDC. Both belong to whoever holds the password, and the provider's own page shows the links exactly when its realm allows them.

Which brings it back to the agent-identity drafts. If a deployment's provider starts issuing agent_owner claims, this verifier will ignore them. The store interface in app/server/lib/store.ts shows where an agent's provenance actually lives: the device row for a machine is scoped by the owning uid, so a machine id learned elsewhere can never select another account's device, and when an account is deleted the record of what happened on those machines is kept while the email is dropped. An identity provider can vouch for a person. What that person's agent did on a terminal is recorded on the machine that ran it and under the account that linked it, and the new sign-in path leaves that untouched.

Top comments (0)