DEV Community

Krish Verma
Krish Verma

Posted on

How I built keyless OAuth sign-in for my AI companion: PKCE, a single-use loopback callback, and a token that never touches disk

How I built keyless OAuth sign-in for my AI companion: PKCE, a single-use loopback callback, and a token that never touches disk

Ankita is my open-source desktop AI companion — a little companion, a lot more possible. To be genuinely useful it has to act in my apps: read a thread, send the email, schedule the thing. All of that goes through Composio's MCP server, and that server has opinions about auth: it rejects API keys outright. Project keys (ak_), CLI keys (uak_), and the x-consumer-api-key header all get a 401 with "not a valid AuthKit JWT". The only credential it accepts is a Bearer minted by the standard MCP OAuth flow.

That left me with a problem no 16-year-old's users want to solve: nobody is going to a developer console, registering an OAuth client, and pasting a client secret into my app. The sign-in had to be keyless — the app registers itself as a client, the user logs in through their own browser, and no secret ever lives on disk. The whole thing is one file: src/integrations/composio-oauth.mjs, 352 lines.

The flow: discovery to vault in five steps

discovery → dynamic client registration → browser authorize
  → single-use loopback callback → PKCE code exchange → vault grant
Enter fullscreen mode Exit fullscreen mode

Nothing needs to be hosted. That's the rule the entire implementation hangs on. The redirect URI is a loopback listener on 127.0.0.1 that lives only for the duration of one sign-in (RFC 8252). No public callback, no domain, no certificate, no broker. The listener binds a preferred port (5757) and falls back to an OS-assigned one if it's taken; on every exit path — success, state mismatch, user denial, the five-minute "a human logging in by hand" timeout — the server is closed so nothing keeps listening after sign-in. One code, one use, then silence.

The client is public. Dynamic client registration sends token_endpoint_auth_method: "none" — no client secret is issued or stored, ever. The PKCE S256 challenge is the entire defense for the code: the verifier is 32 bytes from crypto.randomBytes, the challenge is its SHA-256, and the callback listener checks the state parameter before doing anything else. A mismatched state gets a plain-text 400 and no grant is written. This is exercised directly: one of the tests is literally "a callback with the wrong state is refused and no grant is written".

The token is bound to the exact server. The MCP authorization spec requires the RFC 8707 resource indicator — the MCP server URL the token is for — in both the authorize and the token request, so the authorization server binds the token's audience to that one server and servers reject a token issued without it. The code comment says it plainly: "Omitting it is the difference between a granted sign-in and a 400 from the authorization server." I only know this because I shipped it wrong first.

The security boundary I hardcoded

Before any network call happens, every URL in the flow passes assertTrustedEndpoint: in production it must be https on *.composio.dev, must not carry credentials in the userinfo, and a loopback URL is only allowed for tests or a user-approved self-hosted authorization server — off by default. The header comment states the intent: a URL that arrives inside tool output or a redirect chain can never become a live connection. A companion that browses the web will eventually be shown a malicious URL by a webpage; the allowlist means that URL can never turn into an OAuth handshake.

The token never leaves the vault. The grant file on disk holds no secret — just grantId, the endpoint, the connected apps, and timestamps. The actual token goes to the OS keychain through the injected secret store. withToken hands the plaintext token to a callback and never returns it, matching the existing SecureStore contract. Revoking is one action: drop the vaulted token, forget the metadata. The store re-reads from disk before every mutation (_fresh()), the same pattern every other store in the codebase uses, so the desktop engine and the chat tool can't end up with two views of the same sign-in.

One practical detail I like: openUrl is injected. The desktop passes Electron's shell.openExternal; the tests pass a capture function. The full connect flow runs against injected fetch and URL opening in the test suite — 12 tests, all green, including "a denied authorization fails the connect instead of hanging" and "a token endpoint that returns no token is an error, not an empty grant".

The open question

The design decision I'd genuinely argue about: the endpoint allowlist is hardcoded to *.composio.dev. It's a vendor name baked into the security boundary — the tightest possible answer to "tool output can never become a live OAuth connection", but it also means every new provider is a code change, not a config entry. The alternative is a user-managed trust list, which is more general and one malicious paste away from being the exact attack the hardcoded list prevents. I chose hardcoded because the companion acts autonomously while the user is away, so the trust boundary has to hold when nobody's watching. Would you keep the vendor name in the security boundary, or reach for a managed allowlist?

The repo is open source (MIT) at https://github.com/akyourowngames/A.N.K.I.T.A — src/integrations/composio-oauth.mjs is readable end to end if you want to poke at the state handling or the loopback lifecycle. Tell me where you'd draw the line.

Top comments (0)