← vibesdk
import { BaseOAuthProvider, type OAuthClientAuthMethod } from './base';
import type { OAuthUserInfo } from '../../types/auth-types';
import { createLogger } from '../../logger';
const logger = createLogger('CloudflareConnectOAuth');
/**
* Cloudflare AI Gateway OAuth scopes. These are fixed by the features the worker
* uses (reading the user's identity, listing/creating gateways, running inference,
* and keeping a refresh token), so they are hardcoded rather than configurable.
*
* Note: Cloudflare uses dotted scope identifiers (e.g. `aig.read`), NOT the OIDC
* colon style. Requesting unsupported scopes such as `email`/`profile` results in
* an `invalid_scope` error from the authorization endpoint.
*/
const CLOUDFLARE_GATEWAY_SCOPES = [
'user-details.read',
'ai.read',
'ai.write',
'aig.read',
'aig.run',
'aig.write',
'workers-scripts.write',
'offline_access',
] as const;
/**
* "Login with Cloudflare" needs the gateway scopes (so it can auto-connect the AI
* Gateway in the same round-trip) plus `openid` for the identity assertion. Email
* and name come from the userinfo endpoint via `user-details.read`.
*/
const CLOUDFLARE_LOGIN_SCOPES = ['openid', ...CLOUDFLARE_GATEWAY_SCOPES];
/**
* Cloudflare OAuth endpoints. These are fixed Cloudflare dashboard/API URLs, so they
* are hardcoded rather than read from configuration.
*/
const CLOUDFLARE_OAUTH_AUTH_URL = 'https://dash.cloudflare.com/oauth2/authorize';
const CLOUDFLARE_OAUTH_TOKEN_URL = 'https://dash.cloudflare.com/oauth2/token';
const CLOUDFLARE_OAUTH_USERINFO_URL = 'https://api.cloudflare.com/client/v4/user';
interface CloudflareUserFields {
sub?: string;
id?: string;
email?: string;
name?: string;
first_name?: string;
last_name?: string;
picture?: string;
email_verified?: boolean;
}
/**
* The userinfo endpoint is the Cloudflare API v4 `/user` route, which wraps the
* payload in a `{ success, result }` envelope. Some OIDC userinfo endpoints return
* the fields flat instead, so we tolerate both shapes.
*/
interface CloudflareUserInfoResponse extends CloudflareUserFields, Record<string, unknown> {
result?: CloudflareUserFields;
}
export class CloudflareConnectOAuthProvider extends BaseOAuthProvider {
protected readonly provider = 'cloudflare' as const;
protected readonly authorizationUrl: string;
protected readonly tokenUrl: string;
protected readonly userInfoUrl: string;
protected readonly scopes: string[];
/**
* Cloudflare requires `client_secret_basic` (Basic Auth header) for token
* requests rather than the RFC 6749 body-encoded credentials.
*/
protected readonly clientAuthMethod: OAuthClientAuthMethod = 'basic';
/**
* When true (login flow), a missing/unusable email is a hard error rather than
* the `unknown@cloudflare.local` fallback used by the gateway-connect flow.
*/
private readonly requireEmail: boolean;
constructor(
clientId: string,
clientSecret: string,
redirectUri: string,
authorizationUrl: string,
tokenUrl: string,
userInfoUrl?: string,
scopes?: string[],
requireEmail = false,
) {
super(clientId, clientSecret, redirectUri);
this.authorizationUrl = authorizationUrl;
this.tokenUrl = tokenUrl;
this.userInfoUrl = userInfoUrl || '';
this.scopes = scopes && scopes.length > 0 ? scopes : ['openid'];
this.requireEmail = requireEmail;
}
async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
if (!this.userInfoUrl) {
if (this.requireEmail) {
throw new Error('Cloudflare userInfoUrl is not configured; cannot resolve identity for login');
}
logger.warn('Cloudflare userInfoUrl not configured, returning minimal user info');
return {
id: 'cloudflare',
email: 'unknown@cloudflare.local',
};
}
try {
const response = await fetch(this.userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Failed to get user info from Cloudflare', {
status: response.status,
error: errorText.substring(0, 200),
});
if (this.requireEmail) {
throw new Error(`Cloudflare userinfo request failed with status ${response.status}`);
}
return {
id: 'cloudflare',
email: 'unknown@cloudflare.local',
providerData: { status: response.status },
};
}
const data = (await response.json()) as CloudflareUserInfoResponse;
// `/client/v4/user` nests fields under `result`; OIDC userinfo returns them
// flat. Prefer the envelope when present, else fall back to the top level.
const user = data.result ?? data;
const composedName =
user.name ||
[user.first_name, user.last_name].filter(Boolean).join(' ') ||
undefined;
if (this.requireEmail && !user.email) {
throw new Error('Cloudflare userinfo did not return an email; cannot create a login identity');
}
return {
id: String(user.sub || user.id || 'cloudflare'),
email: user.email || 'unknown@cloudflare.local',
name: composedName,
picture: user.picture,
// Cloudflare dashboard accounts require a verified email to exist, but
// the userinfo endpoint does not always return `email_verified`. Default
// to verified when the flag is absent so the auth-layer verified-email
// gate does not incorrectly reject legitimate Cloudflare logins.
emailVerified: user.email_verified ?? true,
providerData: data,
};
} catch (error) {
if (this.requireEmail) {
// Surface the failure so login does not silently create a bogus identity.
throw error;
}
logger.error('Error getting Cloudflare user info', error);
return {
id: 'cloudflare',
email: 'unknown@cloudflare.local',
};
}
}
static create(env: Env, baseUrl: string): CloudflareConnectOAuthProvider {
// Check if Cloudflare limits/OAuth feature is enabled. A missing encryption key
// disables the feature entirely (same as ENABLE_CLOUDFLARE_LIMITS not being true),
// since tokens cannot be encrypted/signed without it.
if (env.ENABLE_CLOUDFLARE_LIMITS !== 'true' || !env.CF_OAUTH_ENCRYPTION_KEY) {
throw new Error('Cloudflare OAuth is not enabled on this deployment');
}
if (!env.CLOUDFLARE_OAUTH_CLIENT_ID || !env.CLOUDFLARE_OAUTH_CLIENT_SECRET) {
throw new Error('Cloudflare OAuth credentials not configured');
}
const redirectUri = `${baseUrl}/auth/callback`;
const scopes = [...CLOUDFLARE_GATEWAY_SCOPES];
return new CloudflareConnectOAuthProvider(
env.CLOUDFLARE_OAUTH_CLIENT_ID,
env.CLOUDFLARE_OAUTH_CLIENT_SECRET,
redirectUri,
CLOUDFLARE_OAUTH_AUTH_URL,
CLOUDFLARE_OAUTH_TOKEN_URL,
CLOUDFLARE_OAUTH_USERINFO_URL,
scopes,
);
}
/**
* Build a provider configured for the "Login with Cloudflare" identity flow.
*
* Differences from `create()`:
* - redirect URI points at the standard auth callback (`/api/auth/callback/cloudflare`),
* - requests broad identity + gateway scopes so the login callback can also
* auto-connect the AI Gateway in the same round-trip,
* - does NOT require `ENABLE_CLOUDFLARE_LIMITS` (login is independent of limits),
* - requires a real email from userinfo (no `unknown@cloudflare.local` fallback).
*/
static createForLogin(env: Env, baseUrl: string): CloudflareConnectOAuthProvider {
// Identity login only needs the OAuth client credentials. CF_OAUTH_ENCRYPTION_KEY
// is NOT required here: it is only used by the best-effort AI Gateway auto-connect
// (encrypting the token cookie), which is skipped when the key/feature is absent.
if (!env.CLOUDFLARE_OAUTH_CLIENT_ID || !env.CLOUDFLARE_OAUTH_CLIENT_SECRET) {
throw new Error('Cloudflare OAuth credentials not configured');
}
const redirectUri = `${baseUrl}/api/auth/callback/cloudflare`;
const scopes = [...CLOUDFLARE_LOGIN_SCOPES];
return new CloudflareConnectOAuthProvider(
env.CLOUDFLARE_OAUTH_CLIENT_ID,
env.CLOUDFLARE_OAUTH_CLIENT_SECRET,
redirectUri,
CLOUDFLARE_OAUTH_AUTH_URL,
CLOUDFLARE_OAUTH_TOKEN_URL,
CLOUDFLARE_OAUTH_USERINFO_URL,
scopes,
true,
);
}
/**
* True when the deployment has the Cloudflare OAuth client configured for login.
* Only the client credentials are needed for identity login; the AI Gateway
* auto-connect (which needs CF_OAUTH_ENCRYPTION_KEY) is optional and best-effort.
*/
static isLoginConfigured(env: Env): boolean {
return !!env.CLOUDFLARE_OAUTH_CLIENT_ID && !!env.CLOUDFLARE_OAUTH_CLIENT_SECRET;
}
}