OAuth Authentication
This guide is about photons requesting third-party OAuth tokens, such as GitHub or Google tokens, on behalf of a user. To secure deployed MCP tool calls from agents, see Securing MCP with JWT. To register MCP clients with Photon's authorization server, see Registering an MCP Client with a Photon AS.
Photon's OAuth system lets photons request third-party API tokens on behalf of users. It implements OAuth 2.1 with PKCE, HMAC-signed state, and per-tenant encrypted token storage.
Table of Contents
- Quick Start
- How It Works
- Built-in Providers
- Yield Pattern — How photons request OAuth tokens
- Token Refresh — Automatic and manual refresh
- Error Handling — Catching and recovering from elicitation
- Testing OAuth Photons — Unit and integration testing
- Complete Example — Full working photon with GitHub OAuth
- Token Vault — Encryption and storage
- Security Model — PKCE, state signing, per-tenant encryption
- Well-Known Endpoints — OAuth discovery
Quick Start
Here's a minimal photon that authenticates with GitHub:
import { Photon } from '@portel/photon-core';
export default class GitHubAPI extends Photon {
/**
* Get authenticated user's profile
*/
async profile() {
// Yield to request an OAuth token
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['read:user'],
message: 'Reading your GitHub profile',
};
// Use the token to call GitHub API
const res = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
}What happens:
- When the photon yields
{ ask: 'oauth', ... }, the runtime intercepts it - If the user already authorized the app for this scope, the token is returned immediately
- If not, the user is redirected to GitHub's login, and the photon pauses
- After authorization, the photon resumes and receives the token
- The token is automatically stored in the encrypted vault for future use
How It Works
OAuth flow in Photon follows these steps:
┌─────────────────┐
│ Photon │
│ yield {oauth} │────────────────┐
└─────────────────┘ │
v
┌────────────────┐
│ OAuthContext │
│ checkGrant() │─── Has valid token? ──→ Return token
└────────────────┘
│
└─── No valid token
│
v
┌────────────────────┐
│ OAuthFlowHandler │
│ startElicitation() │─── Generate URL
└────────────────────┘
│
v
┌────────────────────┐
│ Throw │
│ OAuthElicitation │─── User redirected to provider
│ Required │
└────────────────────┘
│
[User logs in & authorizes]
│
v
┌────────────────────┐
│ /auth/oauth/ │
│ callback (server) │─── Exchange code for token
└────────────────────┘
│
v
┌────────────────────────┐
│ Encrypt & store in │
│ grant (token vault) │
└────────────────────────┘
│
[Photon retried]
│
v
┌────────────────────┐
│ checkGrant() finds │
│ token → Return it │
└────────────────────┘Step-by-step:
- Photon yields
{ ask: 'oauth', provider: 'github', scopes: ['repo'] } - OAuthContext checks for an existing grant (cached token)
- If found & valid → Return token immediately (instant, no user action)
- If not found → OAuthFlowHandler generates an authorization URL with PKCE + HMAC state
- Throw OAuthElicitationRequired with the URL, elicitation ID, and scopes
- MCP runtime formats this as an elicitation response (the client UI shows a login button)
- User clicks the link, authorizes at GitHub, gets redirected back
- Server's
/auth/oauth/callbackreceives the code, exchanges it for tokens using PKCE - Tokens encrypted in the vault using per-tenant AES-256
- Grant stored (tenant + photon + provider + scopes + encrypted tokens)
- Photon retried by the client (explicit or automatic)
- Second yield → OAuthContext finds the grant, returns token immediately
Built-in Providers
| Provider | ID | Default Scopes |
|---|---|---|
google | openid, email, profile | |
| GitHub | github | read:user, user:email |
| Microsoft | microsoft | openid, email, profile, User.Read |
Register providers with your client credentials:
registry.register('google', process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET);Custom providers can be registered with registerCustom():
registry.registerCustom({
id: 'slack',
name: 'Slack',
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
tokenUrl: 'https://slack.com/api/oauth.v2.access',
scopes: ['channels:read'],
clientId: '...',
clientSecret: '...',
});Token Refresh
Tokens automatically refresh when they expire (with a 5-minute buffer). Here's how:
Automatic refresh:
async getUser() {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['read:user'],
};
// If the cached token is expired but has a refresh token,
// OAuthContext automatically refreshes it and returns the new token.
// No code changes needed!
}How it works:
checkGrant()checks if the stored token is expired- If expired but a refresh token exists →
refreshGrant()exchanges the refresh token for a new one - New token is encrypted and stored
- Old token & refresh token are updated in the vault
- Photon receives the new token transparently
If refresh fails:
- The grant is marked invalid
- The next yield throws
OAuthElicitationRequiredagain - User must re-authorize
Manual token refresh: If you need to force a refresh (e.g., to request new scopes), throw OAuthElicitationRequired to trigger re-authorization:
async gitHubSearch(params: { query: string }) {
try {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['repo'],
};
// ... use token ...
} catch (error) {
if (error instanceof OAuthElicitationRequired) {
// Force re-authorization to request additional scopes
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['repo', 'admin:org_hook'], // Added new scope
message: 'This feature requires additional permissions',
};
// ... use token ...
}
}
}Error Handling
Pattern: Yield-Based Elicitation
The OAuth system never throws synchronously. Instead, it uses yield-based control flow:
async safeFetchRepos() {
try {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['repo'],
};
return { repos: await this.fetchFromGitHub(token) };
} catch (error) {
// Only network errors reach here, not elicitation errors
// (Elicitation errors are handled by the MCP runtime)
if (error instanceof Error) {
return { error: error.message };
}
}
}Pattern: Recovery After Elicitation
If you need to handle authorization differently based on the elicitation result:
async getProfileWithFallback() {
let token: string | undefined;
try {
token = yield {
ask: 'oauth',
provider: 'github',
scopes: ['read:user'],
message: 'Reading your GitHub profile',
};
} catch (error) {
if (error instanceof OAuthElicitationRequired) {
// User denied authorization or closed the login page
// You could provide a fallback:
return {
message: 'GitHub profile unavailable (authorization required)',
link: error.elicitationUrl,
};
}
}
// token is now guaranteed to be a string
const profile = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());
return profile;
}Testing OAuth Photons
Use MemoryElicitationStore and MemoryGrantStore in tests to simulate OAuth flows without hitting real providers:
import {
OAuthFlowHandler,
OAuthProviderRegistry,
MemoryElicitationStore,
MemoryGrantStore,
} from '@portel/photon-core';
import { LocalTokenVault } from '@portel/photon-core';
// Create test infrastructure
const registry = new OAuthProviderRegistry();
registry.register('github', 'test-client-id', 'test-client-secret');
const vault = new LocalTokenVault({ masterKey: 'test-key-32-chars-long-minimum' });
const elicitationStore = new MemoryElicitationStore();
const grantStore = new MemoryGrantStore();
const flow = new OAuthFlowHandler({
baseUrl: 'http://localhost:3000',
stateSecret: 'state-secret-key-32-chars-long!!',
providers: registry,
elicitationStore,
grantStore,
tokenVault: vault,
});
// Test 1: Elicitation is created for new auth
async function testNewAuth() {
const { url, elicitationId } = await flow.startElicitation(
{ id: 'session-1', userId: 'user-1' },
'my-photon',
'github',
['repo']
);
console.assert(url.includes('github.com/login'), 'URL should point to GitHub');
console.assert(elicitationId, 'Should have elicitation ID');
}
// Test 2: Grant is returned on second access
async function testGrantReuse() {
// Simulate a successful grant by manually storing one
const testToken = 'ghs_test_token_abc123';
const encrypted = await vault.encrypt('tenant-1', testToken);
const grant = await grantStore.create({
tenantId: 'tenant-1',
photonId: 'my-photon',
provider: 'github',
scopes: ['repo'],
accessTokenEncrypted: encrypted,
tokenExpiresAt: new Date(Date.now() + 3600 * 1000),
});
// Now checkGrant should return the token
const check = await flow.checkGrant(
'tenant-1',
'my-photon',
'github',
['repo']
);
console.assert(check.valid, 'Grant should be valid');
console.assert(check.token === testToken, 'Token should match');
}
await testNewAuth();
await testGrantReuse();Complete Example
A fully working photon that integrates GitHub OAuth:
import { Photon } from '@portel/photon-core';
export default class GitHub extends Photon {
/**
* Get your GitHub profile
*
* @returns User profile data
*/
async profile() {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['read:user'],
message: 'Reading your GitHub profile',
};
const res = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
/**
* List repositories for an organization
*
* @param org - Organization name
* @returns Array of repository objects
*/
async orgRepos(params: { org: string }) {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['read:org'],
message: `Reading ${params.org} repositories`,
};
const res = await fetch(
`https://api.github.com/orgs/${params.org}/repos?per_page=100`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.message);
}
return res.json();
}
/**
* Create a repository in an organization
*
* @param org - Organization name
* @param name - Repository name
* @returns Created repository object
*/
async createRepo(params: { org: string; name: string }) {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['repo', 'write:org'],
message: `Creating repository in ${params.org}`,
};
const res = await fetch(
`https://api.github.com/orgs/${params.org}/repos`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: params.name,
private: false,
description: 'Created via Photon',
}),
}
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to create repo');
}
return res.json();
}
}Token Vault
Tokens are encrypted at rest using AES-256-GCM with per-tenant derived keys.
| Vault | Use Case | Key Source |
|---|---|---|
LocalTokenVault | Dev / single-instance | Master key → scrypt per tenant |
KmsTokenVault | Production / multi-instance | AWS KMS / GCP KMS envelope encrypt |
// Development
const vault = new LocalTokenVault({ masterKey: process.env.TOKEN_MASTER_KEY });
// Production
const vault = new KmsTokenVault({ kms: awsKmsClient, getKeyId: (tenantId) => `alias/${tenantId}` });Yield Pattern
Photons request OAuth tokens by yielding an ask:
async fetchUserRepos(params: { org: string }) {
// Yield to request a token
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['repo', 'read:org'],
message: 'Reading organization repositories',
};
// Use the token
const res = await fetch(`https://api.github.com/orgs/${params.org}/repos`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}Fields:
ask: 'oauth'— Required, tells the runtime this is an OAuth askprovider— OAuth provider ID ('google','github','microsoft', or custom)scopes— Array of requested OAuth scopes (e.g.,['repo', 'read:org']for GitHub)message— Optional human-readable explanation (shown in UI before authorization)
Return value: The access token as a string. If the user denies authorization, the error is caught as OAuthElicitationRequired.
Handling Elicitation Errors
When a token isn't available, the yield throws OAuthElicitationRequired. The MCP runtime catches this and sends an elicitation response to the client:
import { OAuthElicitationRequired } from '@portel/photon-core';
async fetchRepos(params: { org: string }) {
try {
const token: string = yield {
ask: 'oauth',
provider: 'github',
scopes: ['repo'],
};
// ... use token ...
} catch (error) {
if (error instanceof OAuthElicitationRequired) {
// This error is caught by the MCP runtime and formatted as an elicitation response.
// The client shows a login button with the elicitation URL.
// After user authorizes, the client retries this method automatically.
throw error; // Re-throw to let MCP runtime handle it
}
}
}In practice, you don't need to catch this error — the MCP runtime handles it automatically. The error includes:
elicitationUrl— URL to send user for authorizationelicitationId— Tracks this specific authorization requestprovider— The OAuth providerscopes— The requested scopestoMCPError()— Converts to MCP error format for protocol compliance
Security Model
PKCE (RFC 7636)
Every authorization request uses Proof Key for Code Exchange:
- Generate a random
code_verifier(32 bytes, base64url) - Derive
code_challenge= HMAC-SHA256(verifier) - Send
code_challengewithcode_challenge_method=S256in the authorization request - Include
code_verifierin the token exchange
This prevents authorization code interception attacks.
HMAC State Signing
OAuth state parameters are signed with HMAC-SHA256:
- Serialize state (sessionId, elicitationId, photonId, provider, nonce, timestamp)
- Sign with
stateSecret - Encode as
base64url(payload|signature) - On callback, verify signature and check 5-minute max age
This prevents CSRF and state tampering.
Per-Tenant Encryption
LocalTokenVault: derives a unique AES-256 key per tenant using scrypt(masterKey, salt + tenantId)KmsTokenVault: uses envelope encryption with KMS-managed data keys, cached for 1 hour- All tokens stored as
base64(iv + authTag + ciphertext)
Identity-Aware Locking with @locked
Photon's @locked middleware integrates with authentication to enforce caller identity on locks. When a method is marked @locked, the runtime checks this.caller.id against the lock holder:
/**
* Sensitive operation - only the lock holder's caller can proceed
* @locked sensitive:write
* @auth required
*/
async deleteAccount(params: { userId: string }) {
// Lock held only by the caller who acquired it
// Other callers attempting this method receive an error
// until the lock is released
}Key differences from standard lock protocols:
- Standard locks are anonymous (any holder wins the race)
- Photon locks are identity-aware: only the caller who acquired the lock can execute the locked method
- This enables single-user resource protection (file checkouts, exclusive edits)
- Multi-node deployments with custom lock backends should replicate this identity check for consistency
Well-Known Endpoints
Implements RFC 9728 (Protected Resource Metadata) and RFC 8414 (Authorization Server Metadata).
GET /.well-known/oauth-protected-resource
Returns the protected resource metadata for a tenant:
{
"resource": "https://serv.example.com/tenant/my-tenant/mcp",
"authorization_servers": ["https://serv.example.com/tenant/my-tenant"],
"bearer_methods_supported": ["header"]
}GET /.well-known/oauth-authorization-server
Returns the authorization server metadata:
{
"issuer": "https://serv.example.com/tenant/my-tenant",
"authorization_endpoint": "https://serv.example.com/tenant/my-tenant/authorize",
"token_endpoint": "https://serv.example.com/tenant/my-tenant/token",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"]
}WWW-Authenticate Header
On 401 responses, the server includes:
Bearer realm="my-tenant", resource_metadata="https://serv.example.com/.well-known/oauth-protected-resource"With optional error details:
Bearer realm="my-tenant", resource_metadata="...", error="invalid_token", error_description="Token expired"