From 27492d432a2dd5e0c436c43af1f0266b593610fc Mon Sep 17 00:00:00 2001 From: Franklin Tina <76698025+franklin-tina@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:54:22 -0500 Subject: [PATCH 1/2] feat: add Cloudflare OAuth provider --- README.md | 1 + playground/app/pages/index.vue | 6 + .../server/routes/auth/cloudflare.get.ts | 17 ++ playground/shared/types/auth.d.ts | 1 + src/module.ts | 6 + src/runtime/server/lib/oauth/cloudflare.ts | 172 ++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 7 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 playground/server/routes/auth/cloudflare.get.ts create mode 100644 src/runtime/server/lib/oauth/cloudflare.ts diff --git a/README.md b/README.md index 32d62772..cef469d5 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,7 @@ It can also be set using environment variables: - Battle.net - Bluesky (AT Protocol) - Box.com +- Cloudflare - Discord - Dropbox - Facebook diff --git a/playground/app/pages/index.vue b/playground/app/pages/index.vue index a1dd052d..4c8835c0 100644 --- a/playground/app/pages/index.vue +++ b/playground/app/pages/index.vue @@ -28,6 +28,12 @@ const providers = computed(() => disabled: Boolean(user.value?.github), icon: 'i-simple-icons-github', }, + { + title: user.value?.cloudflare || 'Cloudflare', + to: '/auth/cloudflare', + disabled: Boolean(user.value?.cloudflare), + icon: 'i-simple-icons-cloudflare', + }, { title: user.value?.bluesky || 'Bluesky', onClick() { diff --git a/playground/server/routes/auth/cloudflare.get.ts b/playground/server/routes/auth/cloudflare.get.ts new file mode 100644 index 00000000..a83c6141 --- /dev/null +++ b/playground/server/routes/auth/cloudflare.get.ts @@ -0,0 +1,17 @@ +export default defineOAuthCloudflareEventHandler({ + config: { + // Cloudflare has no default scope — set the client's registered API-permission ID(s) + // (from GET /client/v4/oauth/scopes). `openid` is not a Cloudflare scope. + scope: ['user-details.read'], + }, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + cloudflare: user.sub, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/playground/shared/types/auth.d.ts b/playground/shared/types/auth.d.ts index b65e932f..b354296b 100644 --- a/playground/shared/types/auth.d.ts +++ b/playground/shared/types/auth.d.ts @@ -14,6 +14,7 @@ declare module '#auth-utils' { microsoft?: string discord?: string battledotnet?: string + cloudflare?: string box?: string keycloak?: string line?: string diff --git a/src/module.ts b/src/module.ts index fb21aa73..a477c06d 100644 --- a/src/module.ts +++ b/src/module.ts @@ -293,6 +293,12 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Cloudflare OAuth + runtimeConfig.oauth.cloudflare = defu(runtimeConfig.oauth.cloudflare, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) // Atproto OAuth for (const provider of atprotoProviders) { diff --git a/src/runtime/server/lib/oauth/cloudflare.ts b/src/runtime/server/lib/oauth/cloudflare.ts new file mode 100644 index 00000000..5c58046e --- /dev/null +++ b/src/runtime/server/lib/oauth/cloudflare.ts @@ -0,0 +1,172 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect, createError } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleInvalidState, handleMissingConfiguration, handlePkceVerifier, handleState, requestAccessToken } from '../utils' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +/** + * Cloudflare OAuth provider — Authorization Code flow (the only flow Cloudflare supports). + * + * References: + * - Endpoints: https://developers.cloudflare.com/fundamentals/oauth/integrate-with-cloudflare/ + * - Flow/PKCE: https://developers.cloudflare.com/fundamentals/oauth/create-an-oauth-client/#supported-oauth-flows + * - Scopes: https://developers.cloudflare.com/fundamentals/oauth/create-an-oauth-client/#select-scopes + * + * Scopes are dot-notation API-permission names (e.g. `workers-platform.read`) fetched from + * `GET /client/v4/oauth/scopes` — `openid` is NOT a Cloudflare scope. PKCE (S256) is required + * for public clients (auth method `none`) and optional for confidential clients (client secret). + */ + +export interface OAuthCloudflareConfig { + /** + * Cloudflare OAuth Client ID + * @default process.env.NUXT_OAUTH_CLOUDFLARE_CLIENT_ID + */ + clientId?: string + /** + * Cloudflare OAuth Client Secret + * @default process.env.NUXT_OAUTH_CLOUDFLARE_CLIENT_SECRET + */ + clientSecret?: string + /** + * Cloudflare OAuth scopes — dot-notation API token permission IDs from + * `GET /client/v4/oauth/scopes`, matching the scopes the client was registered with. + * Cloudflare requires at least one; there is no default (`openid` is not a Cloudflare scope). + * @see https://developers.cloudflare.com/fundamentals/oauth/create-an-oauth-client/#select-scopes + * @example ['workers-platform.read'] + */ + scope?: string[] + /** + * Cloudflare OAuth Authorization URL + * @default 'https://dash.cloudflare.com/oauth2/auth' + */ + authorizationURL?: string + /** + * Cloudflare OAuth Token URL + * @default 'https://dash.cloudflare.com/oauth2/token' + */ + tokenURL?: string + /** + * Cloudflare OIDC userinfo URL. Returns standard claims (`sub`, `iss`, `aud`, …); + * use `GET /client/v4/user` for email/name. + * @default 'https://dash.cloudflare.com/oauth2/userinfo' + */ + userURL?: string + /** + * Extra authorization parameters to provide to the authorization URL + */ + authorizationParams?: Record + /** + * Redirect URL to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_CLOUDFLARE_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +/** + * Claims returned by Cloudflare's `/oauth2/userinfo` endpoint. `sub` is the stable + * Cloudflare user ID; the rest are standard OIDC token claims. There is no email/name + * here — for richer profile data call `GET /client/v4/user` with the access token. + */ +export interface CloudflareUser { + /** Stable Cloudflare user ID. */ + sub: string + aud?: string[] + iss?: string + iat?: number + auth_time?: number + rat?: number +} + +export interface CloudflareTokens { + access_token: string + token_type: string + expires_in: number + /** Present when scope includes `offline_access` */ + refresh_token?: string + /** Present when scope includes `openid` */ + id_token?: string + scope: string +} + +export function defineOAuthCloudflareEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.cloudflare, { + authorizationURL: 'https://dash.cloudflare.com/oauth2/auth', + tokenURL: 'https://dash.cloudflare.com/oauth2/token', + userURL: 'https://dash.cloudflare.com/oauth2/userinfo', + authorizationParams: {}, + }) as OAuthCloudflareConfig + + const query = getQuery<{ code?: string, error?: string, error_description?: string, state?: string }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `Cloudflare login failed: ${query.error_description || query.error}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration(event, 'cloudflare', ['clientId', 'clientSecret'], onError) + } + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + const state = await handleState(event) + // PKCE (S256) is supported and recommended by Cloudflare even for confidential clients. + const pkce = await handlePkceVerifier(event) + + if (!query.code) { + // Cloudflare requires ≥1 scope (a dot-notation API permission); there is no universal + // default, so send exactly what's configured and omit the param when none is set. + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope?.length ? config.scope.join(' ') : undefined, + state, + code_challenge: pkce.code_challenge, + code_challenge_method: pkce.code_challenge_method, + ...config.authorizationParams, + }), + ) + } + + if (query.state !== state) { + return handleInvalidState(event, 'cloudflare', onError) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + code: query.code, + code_verifier: pkce.code_verifier, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'cloudflare', tokens, onError) + } + + const user = await $fetch(config.userURL as string, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }) + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 9d00b9d4..0a3647c9 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3' export type ATProtoProvider = 'bluesky' -export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | 'roblox' | 'okta' | 'ory' | 'shopifyCustomer' | 'oidc' | 'osu' | 'riotgames' | 'box' | (string & {}) +export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cloudflare' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | 'roblox' | 'okta' | 'ory' | 'shopifyCustomer' | 'oidc' | 'osu' | 'riotgames' | 'box' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From 308c595e78a9887961ff1d2d016486094f3462a2 Mon Sep 17 00:00:00 2001 From: Franklin Tina <76698025+franklin-tina@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:57:32 -0500 Subject: [PATCH 2/2] fix: address coderabbit --- src/runtime/server/lib/oauth/cloudflare.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/runtime/server/lib/oauth/cloudflare.ts b/src/runtime/server/lib/oauth/cloudflare.ts index 5c58046e..0149657b 100644 --- a/src/runtime/server/lib/oauth/cloudflare.ts +++ b/src/runtime/server/lib/oauth/cloudflare.ts @@ -26,7 +26,9 @@ export interface OAuthCloudflareConfig { */ clientId?: string /** - * Cloudflare OAuth Client Secret + * Cloudflare OAuth Client Secret. Required for confidential clients + * (`client_secret_post`/`client_secret_basic`); omit for public PKCE-only + * clients (`token_endpoint_auth_method=none`). * @default process.env.NUXT_OAUTH_CLOUDFLARE_CLIENT_SECRET */ clientSecret?: string @@ -112,8 +114,8 @@ export function defineOAuthCloudflareEventHandler({ config, onSuccess, onError } return onError(event, error) } - if (!config.clientId || !config.clientSecret) { - return handleMissingConfiguration(event, 'cloudflare', ['clientId', 'clientSecret'], onError) + if (!config.clientId) { + return handleMissingConfiguration(event, 'cloudflare', ['clientId'], onError) } const redirectURL = config.redirectURL || getOAuthRedirectURL(event) @@ -147,7 +149,8 @@ export function defineOAuthCloudflareEventHandler({ config, onSuccess, onError } body: { grant_type: 'authorization_code', client_id: config.clientId, - client_secret: config.clientSecret, + // Public (PKCE-only) clients authenticate with `none` and send no secret. + ...(config.clientSecret ? { client_secret: config.clientSecret } : {}), redirect_uri: redirectURL, code: query.code, code_verifier: pkce.code_verifier,