App proxy and OAuth
Expose an authenticated app-level URL backed by a user's selected pod
Overview
Every published app has a stable app proxy origin derived from its manifest identifier:
https://<app-identifier>-proxy.luvabase.appFor an app with the identifier calendar, the origin is:
https://calendar-proxy.luvabase.appAppend the path implemented by your app when configuring a client. For example, an MCP server exposed at /mcp uses:
https://calendar-proxy.luvabase.app/mcpThis is different from a pod URL such as https://calendar-a1b2c.luvabase.app. A pod URL identifies one installation. The app proxy URL identifies the published app and stays the same for every user. During OAuth, the user chooses which of their active installations the client may access.
The -proxy suffix is reserved by Luvabase and cannot be used at the end of an app identifier.
What Luvabase handles
The app proxy provides server-wide OAuth protection. Luvabase:
- Returns an OAuth
401 Unauthorizedchallenge for unauthenticated requests. - Publishes protected-resource metadata for the app.
- Authenticates the user and asks them to select an active installation of the app.
- Issues an access token bound to both the app proxy resource and the selected pod.
- Validates the token, resource, pod, expiration, and current pod membership on every request.
- Forwards the authenticated request to the selected pod.
Your app does not need to implement OAuth discovery, client registration, consent, token issuance, token refresh, or bearer-token validation.
The access token is removed before the request reaches your app. Luvabase instead injects its trusted member headers, which are available through getSession(request).
App requirements
The app must be published and installed as an active pod. Implement the endpoint you want clients to call just like any other route in your Worker.
For example:
import { getSession, type RuntimeEnv } from "luvabase/runtime"
export default {
async fetch(request: Request, env: RuntimeEnv) {
const url = new URL(request.url)
if (url.pathname !== "/mcp") {
return new Response("Not found", { status: 404 })
}
const session = getSession(request)
if (!session.member) {
return Response.json({ error: "Not authenticated" }, { status: 401 })
}
// Pass the request to your MCP transport or other authenticated handler.
return Response.json({ member: session.member })
},
}The additional authentication check is a useful safeguard, although correctly authenticated app-proxy requests already include a member.
Apart from the protected-resource metadata endpoint, all app-proxy paths are protected. A pod's public access level does not make its app proxy public. Use the normal pod URL for public web routes.
OAuth discovery
Clients can fetch the protected-resource metadata without authenticating:
GET https://calendar-proxy.luvabase.app/.well-known/oauth-protected-resourceThe response identifies the exact OAuth resource and Luvabase authorization server:
{
"resource": "https://calendar-proxy.luvabase.app",
"authorization_servers": ["https://auth.luvabase.com"]
}The resource identifier is the origin without a trailing slash. OAuth clients must return this exact value in authorization and token requests.
Unauthenticated requests to other paths return a challenge pointing to that document:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://calendar-proxy.luvabase.app/.well-known/oauth-protected-resource"Connect an MCP server from ChatGPT
Implement your MCP server at a path such as /mcp, publish the app, and install at least one pod. In ChatGPT, use the complete app proxy endpoint as the remote MCP server URL:
https://calendar-proxy.luvabase.app/mcpNo client ID, client secret, authorization URL, or token URL needs to be copied into the app. ChatGPT discovers the OAuth configuration from the app proxy and identifies itself using its Client ID Metadata Document (CIMD). Luvabase supports ChatGPT's public-client flow with authorization code, PKCE using S256, refresh tokens, and RFC 9207 issuer identification.
When the user connects:
- ChatGPT discovers the protected-resource and authorization-server metadata.
- Luvabase opens its sign-in and authorization page.
- The user chooses one of their active installations and grants access.
- ChatGPT receives a resource-bound access token.
- Requests to
/mcpare forwarded to the selected pod with the authenticated member available throughgetSession(request).
For an MCP server that declares tool security, describe protected tools with an OAuth security scheme. Luvabase currently grants access to the whole selected pod and does not enforce granular OAuth scopes, so do not use scope names as an app-level security boundary yet.
server.registerTool(
"list_events",
{
description: "List calendar events",
inputSchema: {},
securitySchemes: [{ type: "oauth2", scopes: [] }],
},
async () => {
// ...
},
)See the official OpenAI authentication guide for ChatGPT's MCP and OAuth requirements.
Verify your deployment
Confirm that metadata is public:
curl https://calendar-proxy.luvabase.app/.well-known/oauth-protected-resourceThen confirm that your endpoint requires OAuth:
curl -i https://calendar-proxy.luvabase.app/mcpThe second request should return 401 Unauthorized with a WWW-Authenticate header. After that, test the complete login, installation selection, token exchange, and MCP request using ChatGPT or an OAuth-capable MCP inspector.