How OpenSEO's OAuth Provider Works for Hosted Authentication
OpenSEO's OAuth provider implements the full OAuth 2.0 authorization code flow on Cloudflare Workers, tightly integrating with BetterAuth to resolve user sessions, enforce organization context, and issue access tokens with mandatory MCP scope.
The open-source every-app/open-seo repository provides a reference implementation of a hosted MCP (Model Context Protocol) service with built-in authentication. For developers building multi-tenant SaaS applications, understanding how OpenSEO handles identity management through OAuth is essential. This article examines the complete authentication flow—from initial provider creation through token issuance—based on the actual source code.
Architecture Overview
OpenSEO runs on Cloudflare Workers and uses the @cloudflare/workers-oauth-provider library as its OAuth foundation. The hosted authentication mode couples this provider with BetterAuth to obtain verified user sessions and organization context.
Two files form the core of this system:
src/server/mcp/oauth-provider.ts— the main OAuth provider implementation (~100 lines)src/middleware/ensure-user/hosted.ts— BetterAuth session resolution and organization handling
Provider Initialization and Context Resolution
Lazy Provider Creation
The createOpenSeoOAuthProvider function builds an OAuthProvider instance on first request. It configures:
- The resource URL (derived from
BETTER_AUTH_URLenvironment variable) - Callback handlers for token exchange and client registration
- Props factory for user context injection
// src/server/mcp/oauth-provider.ts (lines 38-45)
const oauthProvider = new OAuthProvider({
apiRoute: "/api/auth/oauth2/",
apiHostname: getHostedBaseUrl(),
// ... additional configuration
});
Resolving the Hosted User Context
Every request flows through resolveHostedContext in src/middleware/ensure-user/hosted.ts. This middleware:
- Calls
getAuth().api.getSession()to fetch the BetterAuth session - Verifies or creates a default organization for the user
- Returns
{ userId, userEmail, organizationId }for the OAuth flow
If no session exists, the user is redirected to /sign-in before continuing.
// Conceptual flow based on src/middleware/ensure-user/hosted.ts
async function resolveHostedContext(req: Request) {
const session = await getAuth().api.getSession({ headers: req.headers });
if (!session) return { redirect: "/sign-in" };
const organization = await ensureOrganization(session.user.id);
return { userId: session.user.id, userEmail: session.user.email, organizationId: organization.id };
}
The Authorization Flow
Step 1: Authorization Endpoint
Clients initiate OAuth by redirecting users to:
GET /api/auth/oauth2/authorize?response_type=code&client_id=...&redirect_uri=...&scope=mcp&state=...
The handleOAuthAuthorizeRequest function (lines 51‑70) processes this:
// src/server/mcp/oauth-provider.ts — handleOAuthAuthorizeRequest
const result = await oauth.parseAuthRequest(url);
if (!userContext.userId) {
// Redirect to sign-in, preserving original parameters
return redirectToSignIn(url);
}
// Build consent URL with original parameters encoded
return redirect(buildConsentUrl(url));
Key behaviors:
- Unauthenticated users → redirected to
/sign-in - Authenticated users → redirected to
/oauth-consent
Step 2: Consent Page
The consent UI (src/routes/_authenticated.oauth-consent.tsx) displays the requested scopes and captures user approval:
// React component posting decision to backend
const submit = async (accept: boolean) => {
await fetch("/api/oauth/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accept, query: originalParams }),
});
};
Step 3: Consent Validation and Authorization Completion
The handleOAuthConsentResponse function (lines 72‑90) validates the submission:
// src/server/mcp/oauth-provider.ts — handleOAuthConsentResponse
const validated = csrfProtected(consentResponseSchema, body);
if (!validated.accept) {
return oauth.completeError(redirectUri, "access_denied", state);
}
// Rebuild and re-parse the original authorize request
const authRequest = buildAuthorizeRequestFromConsentQuery(validated.query);
const scopes = getGrantedMcpScopes(authRequest);
// Complete with user context props
return oauth.completeAuthorization({
request: authRequest,
props: createWorkersOAuthMcpProps(userContext, baseUrl),
});
This generates an authorization code and redirects to the client's redirect_uri with ?code=...&state=....
Token Exchange and Access Token Issuance
The Token Endpoint
Clients exchange codes at POST /api/auth/oauth2/token:
// Example token exchange request
const response = await fetch("https://app.openseo.so/api/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: authorizationCode,
redirect_uri: "https://myapp.example.com/callback",
client_id: "my-client-id",
client_secret: "my-client-secret",
}),
});
Scope Enforcement
The tokenExchangeCallback (lines 16‑22) enforces critical validation:
tokenExchangeCallback: async (props, grantedScopes) => {
// Mandatory: all tokens must include 'mcp' scope
if (!grantedScopes.includes("mcp")) {
throw new Error("mcp scope is required");
}
return {
...props,
grantedScopes, // Propagated to token properties
};
}
Every access token issued by OpenSEO's OAuth provider must include the mcp scope. This ensures MCP API compatibility.
Client Registration and API Key Short-circuit
Dynamic Client Registration
OpenSEO supports OAuth 2.0 Dynamic Client Registration at /api/auth/oauth2/register:
// src/server/mcp/oauth-provider.ts (lines 53-57)
async handleClientRegistrationRequest(request: Request) {
const normalized = normalizeClientRegistrationRequest(await request.json());
return oauth.handleClientRegistrationRequest(request, normalized);
}
Registered clients expire after one year (MCP_CLIENT_REGISTRATION_TTL_SECONDS).
API Key Authentication (Pre-OAuth Bypass)
Before initiating the full OAuth flow, handleMcpApiKeyRequest (lines 50‑52) checks for API key headers:
// Short-circuit for service-to-service authentication
const apiKeyResult = await handleMcpApiKeyRequest(request);
if (apiKeyResult) return apiKeyResult; // Skip OAuth entirely
This enables automated clients to bypass user consent flows.
Infrastructure: Base URLs and Cleanup
Hosted Base URL Configuration
The getHostedBaseUrl() function in src/lib/auth.ts (lines 96‑100) validates the deployment environment:
export function getHostedBaseUrl(): string {
const url = env.BETTER_AUTH_URL;
if (!url) throw new Error("BETTER_AUTH_URL is required");
if (!url.startsWith("https://") && !url.includes("localhost")) {
throw new Error("BETTER_AUTH_URL must use HTTPS");
}
return url;
}
Garbage Collection
A periodic cron task purgeExpiredData (lines 64‑71) removes stale KV entries for:
- Expired authorization grants
- Orphaned access tokens
- Abandoned client registrations
Complete Flow Example
// 1. Client redirects user to OpenSEO
const authUrl = new URL("https://app.openseo.so/api/auth/oauth2/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", "myapp");
authUrl.searchParams.set("redirect_uri", "https://myapp.com/callback");
authUrl.searchParams.set("scope", "mcp read");
authUrl.searchParams.set("state", crypto.randomUUID());
window.location.href = authUrl.toString();
// 2. After user consent, exchange code for token
const tokenRes = await fetch("https://app.openseo.so/api/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: receivedCode,
redirect_uri: "https://myapp.com/callback",
client_id: "myapp",
client_secret: "secret",
}),
});
const { access_token } = await tokenRes.json();
// 3. Use token for MCP API calls
const mcpData = await fetch("https://app.openseo.so/api/mcp/query", {
headers: { Authorization: `Bearer ${access_token}` },
});
Summary
-
OpenSEO's OAuth provider is built on
@cloudflare/workers-oauth-providerwith BetterAuth integration for hosted authentication. -
User context resolution happens in
resolveHostedContext(src/middleware/ensure-user/hosted.ts), which fetches sessions and ensures organization membership. -
Mandatory
mcpscope is enforced at token exchange—no exceptions. -
Consent flow uses CSRF-protected forms posting to
/api/oauth/consent, with denial returning standardaccess_deniederrors. -
API key short-circuit allows service accounts to bypass OAuth entirely via
handleMcpApiKeyRequest. -
KV garbage collection runs periodically via
purgeExpiredDatato clean stale grants and tokens.
Frequently Asked Questions
What OAuth 2.0 grants does OpenSEO support?
OpenSEO implements the authorization code grant for browser-based flows. The tokenExchangeCallback in src/server/mcp/oauth-provider.ts handles code-to-token exchange. Implicit and client credentials grants are not supported in the current implementation.
How does OpenSEO handle users without organizations?
The resolveHostedContext middleware automatically creates a default hosted organization if the authenticated user has none. This guarantees that every OAuth context includes an organizationId, which is required for MCP resource isolation.
Can I use OpenSEO's OAuth provider without BetterAuth?
The hosted implementation requires BetterAuth for session resolution. However, the core OAuthProvider from @cloudflare/workers-oauth-provider is generic—you could replace resolveHostedContext with custom session logic for alternative identity providers.
What happens if a user denies consent?
The handleOAuthConsentResponse function validates the denial and calls oauth.completeError with the access_denied error code. The user is redirected back to the client's redirect_uri with ?error=access_denied&state=... per OAuth 2.0 specification.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →