WorkingAgents needs to authenticate MCP clients – Claude Desktop, Claude Code, third-party agents – without manual token management. The answer is OAuth 2.1 Authorization Code Flow with PKCE. This document describes how it works and how it is implemented.
The Problem
Before OAuth, every MCP client needed a manually generated bearer token. An admin generates the token in IEx, copies it into the client config, and hopes it doesn’t expire or get rotated. That works for one user. It does not work for a team, and it does not work for clients that users install themselves.
OAuth solves this: any compliant client can authenticate users through a standard browser flow. No manual token management, no admin involvement, no tokens in config files.
The Flow
WorkingAgents implements OAuth 2.1 Authorization Code Flow with PKCE plus Dynamic Client Registration (RFC 7591).
Client WorkingAgents Browser/User
│ │ │
│── POST /register ─────────>│ │
│<─ client_id, secret ───────│ │
│ │ │
│── redirect to /authorize ──────────────────────────> │
│ │<── login form submitted ─│
│ │── validate credentials │
│ │── issue auth code ───────│
│<─ redirect with ?code= ────────────────────────────── │
│ │ │
│── POST /token ────────────>│ │
│ (code + code_verifier) │── verify PKCE │
│<─ access_token ────────────│ │
│ │ │
│── API calls with Bearer ──>│ │
Step 1: Dynamic Client Registration
An MCP client self-registers before the first user interaction:
POST /register
Content-Type: application/json
{
"client_name": "Claude Desktop",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"]
}
Response:
{
"client_id": "wa_abc123...",
"client_secret": "cs_xyz789...",
"redirect_uris": ["http://localhost:3000/callback"]
}
No admin involvement. Any MCP-compliant client can register itself.
Step 2: PKCE Setup
Before redirecting the user, the client generates a PKCE pair:
import secrets, hashlib, base64
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
The client keeps code_verifier secret. It sends only code_challenge to the server.
Step 3: Authorization Request
GET /authorize
?client_id=wa_abc123
&redirect_uri=http://localhost:3000/callback
&response_type=code
&state=random_state_value
&code_challenge=<SHA256 of verifier>
&code_challenge_method=S256
The user sees the login form. On successful authentication, WorkingAgents creates a single-use authorization code (valid 10 minutes) and redirects:
http://localhost:3000/callback?code=<auth_code>&state=random_state_value
Step 4: Token Exchange
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<auth_code>
&client_id=wa_abc123
&redirect_uri=http://localhost:3000/callback
&code_verifier=<original verifier>
The server verifies SHA256(code_verifier) == code_challenge from step 3. If valid, it issues an access token:
{
"access_token": "...",
"token_type": "bearer",
"expires_in": 2592000
}
The client uses this token for all subsequent requests:
Authorization: Bearer <access_token>
Why PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception. Without it, if an attacker intercepts the ?code= redirect, they can exchange it for a token. With PKCE, the auth code is useless without the code_verifier that only the legitimate client holds.
OAuth 2.1 requires PKCE for all public clients. WorkingAgents requires it for all clients, including confidential ones.
Token Format
Access tokens are encrypted user IDs, not opaque random strings backed by a database. The encryption uses the same key as browser session cookies (Plug.Crypto.MessageEncryptor, AES-256). On each request, the server decrypts the token to recover user_id, then loads the user’s permission map from AccessControl.
This means:
- No token store to query on every request
- No database lookup in the hot path
- Token revocation requires either the token to expire or a blocklist (not yet implemented)
Server Discovery
MCP clients can discover the OAuth configuration automatically:
GET /.well-known/oauth-authorization-server
{
"issuer": "https://workingagents.ai",
"authorization_endpoint": "https://workingagents.ai/authorize",
"token_endpoint": "https://workingagents.ai/token",
"registration_endpoint": "https://workingagents.ai/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"]
}
A compliant MCP client fetches this once and configures itself automatically.
Integration with the Permission System
Once a token is issued, it carries the full permission model. The bearer token decrypts to a user ID. AccessControl.get_permissions(user_id) returns the permission map: permanent keys, temporary TTL grants, and role-based grants merged together. Every tool call checks this map. Revoking a permission takes effect on the next API call – no token reissue needed.
Current Limitations
No persistence across restarts. Client registrations and active auth codes are stored in ETS (in-memory). A server restart clears them. Clients must re-register and users must re-authenticate. A SQLite backend would fix this.
No token revocation. Tokens are valid for 30 days with no revocation endpoint. The only current remediation is to change the server’s secret key, which invalidates all tokens globally.
No refresh tokens. After 30 days the user goes through the full login flow again.
These are the right trade-offs for an early deployment. The ETS store is fast and simple; persistence and revocation can be added once the system is in regular production use.
Authentication Paths Summary
WorkingAgents supports four authentication paths, all converging on the same permission model:
| Path | Used by |
|---|---|
| Browser cookie | Web UI users after login |
| OAuth bearer token | MCP clients (Claude Desktop, Claude Code, third-party) |
| Manually generated bearer token | Direct API access, IEx testing |
| WebSocket ticket | Browser WebSocket upgrade (single-use nonce) |
All four ultimately resolve to a user ID and a permission map. The access control layer is the same regardless of how the user got in.