Skip to content

feat: add Microsoft Entra External ID OAuth support #439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions playground/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ declare module '#auth-utils' {
atlassian?: string
apple?: string
azureb2c?: string
entraexternal?: string
kick?: string
salesforce?: string
slack?: string
Expand Down
12 changes: 12 additions & 0 deletions playground/server/routes/auth/entraexternal.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default defineOAuthEntraExternalEventHandler({
async onSuccess(event, { user }) {
await setUserSession(event, {
user: {
entraexternal: user.email,
},
loggedInAt: Date.now(),
})

return sendRedirect(event, '/')
},
})
12 changes: 12 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,18 @@ export default defineNuxtModule<ModuleOptions>({
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: '',
Expand Down
179 changes: 179 additions & 0 deletions src/runtime/server/lib/oauth/entraexternal.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
/**
* 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<OAuthEntraExternalConfig>) {
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 })
})
}
2 changes: 1 addition & 1 deletion src/runtime/types/oauth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void

Expand Down