From c1cc2f52aee1e971571315c5de3f4ac32d28d4bb Mon Sep 17 00:00:00 2001 From: Stephan Johnson Date: Sat, 9 Aug 2025 15:10:36 +0200 Subject: [PATCH 1/2] feat: add Microsoft Entra External ID OAuth support --- playground/.env.example | 4 + playground/app.vue | 6 + playground/auth.d.ts | 1 + .../server/routes/auth/entraexternal.get.ts | 12 ++ src/module.ts | 12 ++ src/runtime/server/lib/oauth/entraexternal.ts | 179 ++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 playground/server/routes/auth/entraexternal.get.ts create mode 100644 src/runtime/server/lib/oauth/entraexternal.ts diff --git a/playground/.env.example b/playground/.env.example index f466fcf1..7db48cc8 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -25,6 +25,10 @@ NUXT_OAUTH_AUTH0_DOMAIN= NUXT_OAUTH_MICROSOFT_CLIENT_ID= NUXT_OAUTH_MICROSOFT_CLIENT_SECRET= NUXT_OAUTH_MICROSOFT_TENANT= +# Microsoft Entra External ID +NUXT_OAUTH_ENTRAEXTERNAL_CLIENT_ID= +NUXT_OAUTH_ENTRAEXTERNAL_TENANT= +NUXT_OAUTH_ENTRAEXTERNAL_REDIRECT_URL= # Discord NUXT_OAUTH_DISCORD_CLIENT_ID= NUXT_OAUTH_DISCORD_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 91f6ecc0..35e1fa1f 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -80,6 +80,12 @@ const providers = computed(() => disabled: Boolean(user.value?.azureb2c), icon: 'i-simple-icons-microsoftazure', }, + { + label: user.value?.entraexternal || 'Entra External ID', + to: '/auth/entraexternal', + disabled: Boolean(user.value?.entraexternal), + icon: 'i-simple-icons-microsoftazure', + }, { label: user.value?.cognito || 'Cognito', to: '/auth/cognito', diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 1f182324..2d8d16c0 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -39,6 +39,7 @@ declare module '#auth-utils' { atlassian?: string apple?: string azureb2c?: string + entraexternal?: string kick?: string salesforce?: string slack?: string diff --git a/playground/server/routes/auth/entraexternal.get.ts b/playground/server/routes/auth/entraexternal.get.ts new file mode 100644 index 00000000..793cd543 --- /dev/null +++ b/playground/server/routes/auth/entraexternal.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthEntraExternalEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + entraexternal: user.email, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index b215622e..27f91830 100644 --- a/src/module.ts +++ b/src/module.ts @@ -246,6 +246,18 @@ export default defineNuxtModule({ userURL: '', redirectURL: '', }) + // Microsoft Entra External ID + runtimeConfig.oauth.entraexternal = defu(runtimeConfig.oauth.entraexternal, { + clientId: '', + clientSecret: '', + tenant: '', + tenantId: '', + scope: [], + authorizationURL: '', + tokenURL: '', + userURL: '', + redirectURL: '', + }) // Discord OAuth runtimeConfig.oauth.discord = defu(runtimeConfig.oauth.discord, { clientId: '', diff --git a/src/runtime/server/lib/oauth/entraexternal.ts b/src/runtime/server/lib/oauth/entraexternal.ts new file mode 100644 index 00000000..e6388ca7 --- /dev/null +++ b/src/runtime/server/lib/oauth/entraexternal.ts @@ -0,0 +1,179 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken, handlePkceVerifier, handleState, handleInvalidState } from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +/** + * Microsoft Entra External ID (CIAM) OAuth (OIDC) handler + * Minimal code-flow with PKCE, no MSAL. Parity with Azure B2C style, + * but using `ciamlogin.com` endpoints and OIDC UserInfo. + */ +export interface OAuthEntraExternalConfig { + /** + * Entra External OAuth Client ID (Application ID) + * @default process.env.NUXT_OAUTH_ENTRAEXTERNAL_CLIENT_ID + */ + clientId?: string + /** + * Entra External Tenant identifier used in endpoints. + * Accepts tenant subdomain (e.g. "contoso"). + * Used as: https://{tenant}.ciamlogin.com/{tenantId}/oauth2/v2.0/... + * @default process.env.NUXT_OAUTH_ENTRAEXTERNAL_TENANT + * @see https://learn.microsoft.com/entra/architecture/deployment-external-operations (ciamlogin format) + */ + tenant?: string + /** + * Entra External Tenant identifier used in endpoints. + * Accepts tenant GUID. + * Used as: https://{tenant}.ciamlogin.com/{tenantId}/oauth2/v2.0/... + * @default process.env.NUXT_OAUTH_ENTRAEXTERNAL_TENANT_ID + * @see https://learn.microsoft.com/entra/architecture/deployment-external-operations (ciamlogin format) + */ + tenantId?: string + /** + * Scopes for OIDC. Keep minimal: 'openid' required. + * Add 'offline_access' for refresh tokens; 'profile','email' for basic claims. + * Use 'User.Read' only if you plan to call Microsoft Graph /me. + * @default ['openid offline_access profile email'] + * @see https://learn.microsoft.com/entra/identity-platform/scopes-oidc + * @see https://learn.microsoft.com/entra/external-id/customers/concept-supported-features-customers (allowed perms in external tenants) + */ + scope?: string[] + /** + * Authorization endpoint. + * Default (user flow can be passed via `authorizationParams.p` if required). + * @example https://{tenant}.ciamlogin.com/{tenant}/oauth2/v2.0/authorize + * @see https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow + */ + authorizationURL?: string + /** + * Token endpoint. + * @example https://{tenant}.ciamlogin.com/{tenant}/oauth2/v2.0/token + * @see https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow + */ + tokenURL?: string + /** + * OIDC UserInfo endpoint (preferred over Graph /me for basic claims). + * @default 'https://graph.microsoft.com/oidc/userinfo' + * @see https://learn.microsoft.com/entra/identity-platform/userinfo + */ + userURL?: string + /** + * Extra authorization parameters (e.g. { p: 'B2C_1_signup_signin' } if needed) + */ + authorizationParams?: Record + /** + * Explicit redirect URL to avoid mismatch (e.g., HTTP→HTTPS) + * @default process.env.NUXT_OAUTH_ENTRAEXTERNAL_REDIRECT_URL or getOAuthRedirectURL(event) + */ + redirectURL?: string +} + +export function defineOAuthEntraExternalEventHandler({ + config, + onSuccess, + onError +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu( + config, + useRuntimeConfig(event).oauth?.entraexternal, + { authorizationParams: {} } + ) as OAuthEntraExternalConfig + + const query = getQuery<{ code?: string; state?: string }>(event) + + if (!config.clientId || !config.tenant) { + return handleMissingConfiguration( + event, + 'entraexternal', + ['clientId', 'tenant'], + onError + ) + } + + // Build Entra External endpoints (ciamlogin) + const authorizationURL = + config.authorizationURL || + `https://${config.tenant}.ciamlogin.com/${config.tenantId}/oauth2/v2.0/authorize` + const tokenURL = + config.tokenURL || + `https://${config.tenant}.ciamlogin.com/${config.tenantId}/oauth2/v2.0/token` + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + // Ensure minimal, unique scopes (OIDC requires 'openid') + config.scope = (config.scope && config.scope.length > 0) ? config.scope : ['openid'] + config.scope = [...new Set(config.scope)] + + // PKCE + state + const verifier = await handlePkceVerifier(event) + const state = await handleState(event) + + // Step 1: redirect to Entra External authorize + if (!query.code) { + return sendRedirect( + event, + withQuery(authorizationURL as string, { + client_id: config.clientId, + response_type: 'code', + redirect_uri: redirectURL, + scope: config.scope.join(' '), + state, + code_challenge: verifier.code_challenge, + code_challenge_method: verifier.code_challenge_method, + ...config.authorizationParams + }) + ) + } + + // Step 2: callback – validate state, exchange code + if (query.state !== state) { + return handleInvalidState(event, 'entraexternal', onError) + } + + const tokens = await requestAccessToken(tokenURL, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + code: query.code as string, + redirect_uri: redirectURL, + code_verifier: verifier.code_verifier, + // optional but fine to include: + scope: config.scope.join(' ') + }, + headers: { + origin: event.node.req.headers.origin || '', + } + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'entraexternal', tokens, onError) + } + + // Prefer OIDC UserInfo for basic claims; Graph /me requires User.Read consent + const tokenType = tokens.token_type + const accessToken = tokens.access_token + const userURL = config.userURL || 'https://graph.microsoft.com/oidc/userinfo' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = await $fetch(userURL, { + headers: { Authorization: `${tokenType} ${accessToken}` } + }).catch((error) => ({ error })) + + if (user.error) { + const error = createError({ + statusCode: 401, + message: `entraexternal login failed: ${user.error || 'Unknown error'}`, + data: user + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { tokens, user }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 27a17f94..1999950c 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' | (string & {}) +export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox'| 'entraexternal' | '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' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From e57cebb1d562fec82177499511adafd46c313ead Mon Sep 17 00:00:00 2001 From: Stephan Johnson Date: Sat, 9 Aug 2025 15:39:11 +0200 Subject: [PATCH 2/2] fix: correct formatting and improve readability in Entra External OAuth handler --- src/runtime/server/lib/oauth/entraexternal.ts | 58 +++++++++---------- src/runtime/types/oauth-config.ts | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/runtime/server/lib/oauth/entraexternal.ts b/src/runtime/server/lib/oauth/entraexternal.ts index e6388ca7..73697c8f 100644 --- a/src/runtime/server/lib/oauth/entraexternal.ts +++ b/src/runtime/server/lib/oauth/entraexternal.ts @@ -75,33 +75,33 @@ export interface OAuthEntraExternalConfig { export function defineOAuthEntraExternalEventHandler({ config, onSuccess, - onError + onError, }: OAuthConfig) { return eventHandler(async (event: H3Event) => { config = defu( config, useRuntimeConfig(event).oauth?.entraexternal, - { authorizationParams: {} } + { authorizationParams: {} }, ) as OAuthEntraExternalConfig - const query = getQuery<{ code?: string; state?: string }>(event) + const query = getQuery<{ code?: string, state?: string }>(event) if (!config.clientId || !config.tenant) { return handleMissingConfiguration( event, 'entraexternal', ['clientId', 'tenant'], - onError + onError, ) } // Build Entra External endpoints (ciamlogin) - const authorizationURL = - config.authorizationURL || - `https://${config.tenant}.ciamlogin.com/${config.tenantId}/oauth2/v2.0/authorize` - const tokenURL = - config.tokenURL || - `https://${config.tenant}.ciamlogin.com/${config.tenantId}/oauth2/v2.0/token` + const authorizationURL + = config.authorizationURL + || `https://${config.tenant}.ciamlogin.com/${config.tenantId}/oauth2/v2.0/authorize` + const tokenURL + = config.tokenURL + || `https://${config.tenant}.ciamlogin.com/${config.tenantId}/oauth2/v2.0/token` const redirectURL = config.redirectURL || getOAuthRedirectURL(event) @@ -125,8 +125,8 @@ export function defineOAuthEntraExternalEventHandler({ state, code_challenge: verifier.code_challenge, code_challenge_method: verifier.code_challenge_method, - ...config.authorizationParams - }) + ...config.authorizationParams, + }), ) } @@ -135,20 +135,20 @@ export function defineOAuthEntraExternalEventHandler({ return handleInvalidState(event, 'entraexternal', onError) } - const tokens = await requestAccessToken(tokenURL, { - body: { - grant_type: 'authorization_code', - client_id: config.clientId, - code: query.code as string, - redirect_uri: redirectURL, - code_verifier: verifier.code_verifier, - // optional but fine to include: - scope: config.scope.join(' ') - }, - headers: { - origin: event.node.req.headers.origin || '', - } - }) + const tokens = await requestAccessToken(tokenURL, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + code: query.code as string, + redirect_uri: redirectURL, + code_verifier: verifier.code_verifier, + // optional but fine to include: + scope: config.scope.join(' '), + }, + headers: { + origin: event.node.req.headers.origin || '', + }, + }) if (tokens.error) { return handleAccessTokenErrorResponse(event, 'entraexternal', tokens, onError) @@ -161,14 +161,14 @@ export function defineOAuthEntraExternalEventHandler({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const user: any = await $fetch(userURL, { - headers: { Authorization: `${tokenType} ${accessToken}` } - }).catch((error) => ({ error })) + headers: { Authorization: `${tokenType} ${accessToken}` }, + }).catch(error => ({ error })) if (user.error) { const error = createError({ statusCode: 401, message: `entraexternal login failed: ${user.error || 'Unknown error'}`, - data: user + data: user, }) if (!onError) throw error return onError(event, error) diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 1999950c..49a3b1f4 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'| 'entraexternal' | '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' | (string & {}) +export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'entraexternal' | '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' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void