Inbound OAuth for MCP Photons
Inbound OAuth authenticates an MCP client to a Photon so it can discover and call tools on the caller's behalf. It is different from outbound provider OAuth, where a Photon obtains a token to call another service.
Declare inbound OAuth
Put the OAuth mode on the Photon class:
/**
* @auth oauth optional
*/
export default class Consult {
get role(): 'user' | 'customer' | 'host' {
if (this.caller.anonymous) return 'user';
return this.caller.role ?? 'customer';
}
}@auth oauth optionalpermits anonymous requests for public tools. A supplied bearer token is still verified. Authenticated callers can receive a different catalog through property-based exposure.@auth oauth requiredrequires a valid bearer token for every tool. An anonymous request receives an OAuth challenge.@auth email passkeyselects passwordless email sign-in with passkey support and requires authentication by default. The two method names are shorthand for the login methods used by Photon's inbound OAuth authorization server; OAuth remains the MCP transport contract.- Add
optionalat the end (@auth email passkey optional) when anonymous tools should remain available while authenticated tools use the same connection. - OAuth syntax is
@auth oauth <optional|required>. The older forms,@auth optionaland@auth required, are legacy authentication modes, not OAuth.
The email and passkey tokens describe the authentication methods a Photon expects its login page to offer. Photon owns the authentication state and challenge lifecycle; delivery is deliberately a callback so a Photon can use email, SMS, WhatsApp, or another channel without Photon depending on a vendor.
Use the standard adapter shape when wiring a login provider:
import type { AuthCodeDeliveryRequest } from '@portel/photon';
const authCodeDelivery = {
async sendCode(request: AuthCodeDeliveryRequest) {
await sendThroughYourPreferredChannel({
to: request.destination,
code: request.code,
purpose: request.purpose,
expiresAt: request.expiresAt,
});
},
};Register that adapter in the runtime's authCodeDelivery configuration. Photon generates the code, stores only a peppered hash, applies expiry and attempt limits, and consumes it exactly once. The callback receives the plaintext code only for delivery; never expose it as an MCP method or log it. A production deployment must provide a durable authChallengeStore.
The first verified code can bootstrap the account and authorize passkey enrollment. The WebAuthn ceremony and persistent credential store are still required before advertising a passkey option. Do not change a Photon to @auth email passkey until that login adapter implements those ceremonies.
Photon performs OAuth discovery and bearer verification at the MCP endpoint. Missing or invalid credentials are rejected; a valid token that lacks a required scope is rejected separately.
Brand the consent page
The consent page uses accessible Photon defaults and follows the browser's light/dark preference. Add an optional companion stylesheet to apply the Photon's own brand without maintaining a separate OAuth template:
consult.photon.ts
consult/assets/oauth.css:root {
--oauth-accent: #126c59;
--oauth-accent-strong: #0b5546;
--oauth-soft: #e8f5f1;
}
.oauth-mark {
border-radius: 50%;
}Photon appends this CSS after its defaults for both local OAuth and generated Cloudflare Workers. Keep selectors scoped to .oauth-* and override the documented --oauth-* variables where possible. Photon escapes closing style tags before embedding the asset, and the page remains under the runtime's strict content-security policy.
Anonymous, customer, and host access
Use three application roles:
| Caller | Typical capabilities |
|---|---|
Anonymous (user) | Find public availability and begin a booking. |
| Authenticated customer | Access that customer's bookings and permitted customer actions. |
| Authenticated host | Manage availability, appointments, promotions, and other owner operations. |
Authentication alone does not make a caller a host. The OAuth subject is mapped to a role by the deployment, and the Photon exposes that role through this.caller. Do not accept a role supplied in tool arguments. A provider may name the non-host role user; normalize it to customer in the Photon getter when the application needs to distinguish anonymous users from customers.
Role-based tool exposure
@class conditions are method-level and are evaluated for both tools/list and tools/call:
/**
* Find public slots.
* @class Consult {@role user}
* @readOnly
*/
async listAvailableSlots() {}
/**
* Read the caller's bookings.
* @class Consult {@role customer}
* @scope bookings:read
*/
async listMyBookings() {}
/**
* Change host availability.
* @class Consult {@role host}
* @scope availability:write
*/
async updateAvailability() {}The class name identifies the current Photon class or a policy class available to the Photon. Each property comparison is exact string equality; there is no inheritance or prefix matching. Multiple conditions are combined with AND:
/** @class Consult {@role host} {@plan pro} */
async premiumHostOperation() {}Missing classes or properties, malformed conditions, undefined values, and getter errors fail closed. Inaccessible tools are omitted from tools/list, including linked MCP UI metadata, and the same condition is checked again at tools/call. Catalog filtering is never the enforcement boundary.
Scopes
@scope is an additional method-level authorization check:
/** @scope bookings:read bookings:write */
async changeBooking() {}Space-separated scopes are required together; repeated tags are additive. If a method has no explicit scope, Photon infers <toolName>:read for @readOnly methods and <toolName>:write otherwise. Scopes come from the token's space-delimited scope claim. A caller must satisfy both its @class conditions and the method's scopes.
Local development
Set a stable local public URL when testing OAuth:
export PHOTON_PUBLIC_URL=http://localhost:8787The standalone runtime supports these identity settings:
| Variable | Purpose |
|---|---|
PHOTON_OAUTH_SINGLE_USER_ID | Local subject used for the single-user login flow. |
PHOTON_OAUTH_SINGLE_USER_ROLE | Role for that subject; defaults to host. |
PHOTON_OAUTH_HOST_SUBJECTS | Comma-separated subject IDs treated as hosts. Other verified subjects are customers. |
PHOTON_OAUTH_SUBJECT_HEADER | Optional trusted reverse-proxy header from which to read the subject. |
PHOTON_OAUTH_LOGIN_URL | External login URL; defaults to the runtime login route. |
PHOTON_OAUTH_KEY_ID | Optional signing-key identifier. |
PHOTON_OAUTH_JWT_SECRET | Optional local JWT secret. |
In development, Photon may generate ephemeral OAuth keys and secrets. For a non-development runtime, configure all of these explicitly:
PHOTON_OAUTH_PRIVATE_KEY_PEM
PHOTON_OAUTH_PUBLIC_KEY_PEM
PHOTON_OAUTH_ENCRYPTION_KEY
PHOTON_OAUTH_STATE_SECRETKeep signing, encryption, and state secrets stable across restarts and deploys; never use development-generated values in production.
Cloudflare deployment
Deploy the MCP endpoint with OAuth enabled:
photon host deploy cf consult \
--domain consult.example.comThe class-level @auth oauth ... tag enables OAuth automatically. Use --mcp-auth oauth only when overriding a Photon that has no OAuth tag; that explicit form defaults to required authentication.
The issuer must be a stable HTTPS URL. Set it explicitly with PHOTON_MCP_OAUTH_ISSUER, or provide a canonical --domain, --url, or --route from which Photon can derive it. Do not use an ephemeral URL that changes between deployments; the issuer is part of token and metadata validation.
Cloudflare OAuth state and the generated signing keypair are authoritative in the host Durable Object's persistent storage. An optional PHOTON_MCP_OAUTH_KV_ID binding is for integration, audit, replication, or migration; it is not the authoritative OAuth state store. Configure the generated login route with PHOTON_MCP_OAUTH_LOGIN_URL and PHOTON_MCP_OAUTH_LOGIN_SECRET. Photon does not trust unsigned identity headers; a login adapter must return the signed callback expected by the generated authorization server.
For Cloudflare Access, Photon also provides a built-in adapter. Protect /oauth/login with a Cloudflare Access application, set PHOTON_MCP_OAUTH_LOGIN_URL=https://your-host.example/oauth/login, and set PHOTON_MCP_OAUTH_HOST_SUBJECTS to a comma-separated allowlist of verified Access email identities that should receive the host role. Every other verified Access identity receives customer; unauthenticated requests and user-controlled identity headers are rejected.
Owner deployment checklist
The owner identity is deployment configuration. It must not be embedded in the Photon source file or accepted from a tool argument. For each deployment:
Create a Cloudflare Access self-hosted application for the exact
/oauth/loginpath and choose the identity providers that may sign in.Add an Access allow policy for the owner's verified email address (and any additional operators). Do not use a broad
Everyonepolicy for a host login route.Configure the Worker secrets:
shprintf '%s' 'https://consult.example.com/oauth/login' \ | wrangler secret put PHOTON_MCP_OAUTH_LOGIN_URL printf '%s' 'owner@example.com,operator@example.com' \ | wrangler secret put PHOTON_MCP_OAUTH_HOST_SUBJECTSDeploy, then verify the complete chain: Access login, OAuth consent, authorization-code exchange, host-only
tools/list, and a protectedtools/call.
PHOTON_MCP_OAUTH_HOST_SUBJECTS is an exact, case-insensitive email allowlist. Changing ownership means updating both this Worker secret and the matching Cloudflare Access policy. Removing an address from either place removes its host access after the caller's existing token expires or is revoked.
If the installed CLI does not list oauth for --mcp-auth, update the CLI before deploying; the guide does not change CLI compatibility.
Inbound versus outbound provider OAuth
| Inbound OAuth | Outbound provider OAuth | |
|---|---|---|
| Direction | MCP client authenticates to the Photon. | Photon authenticates to Google, GitHub, or another provider. |
| Purpose | Select and authorize Photon tools for a caller. | Let a Photon method call a third-party API for a user. |
| Photon surface | @auth oauth, this.caller, @class, and @scope. | yield { ask: 'oauth', provider, scopes } and the provider registry. |
| Credentials | Bearer token presented to the Photon's /mcp. | Provider access/refresh tokens stored and used by the Photon runtime. |
These flows can be used together, but they have separate issuers, consent, tokens, and security boundaries. See AUTH.md for outbound provider OAuth and DOCBLOCK-TAGS.md for the complete Photon metadata reference.
