Skip to content

Commit e231207

Browse files
n-roweatinux
andauthored
feat: add azure b2c oauth provider (#362)
Co-authored-by: Sébastien Chopin <[email protected]>
1 parent 1d95af3 commit e231207

File tree

7 files changed

+247
-7
lines changed

7 files changed

+247
-7
lines changed

playground/app.vue

+10-5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ const providers = computed(() =>
7474
disabled: Boolean(user.value?.microsoft),
7575
icon: 'i-simple-icons-microsoft',
7676
},
77+
{
78+
label: user.value?.azureb2c || 'Azure B2C',
79+
to: '/auth/azureb2c',
80+
disabled: Boolean(user.value?.azureb2c),
81+
icon: 'i-simple-icons-microsoftazure',
82+
},
7783
{
7884
label: user.value?.cognito || 'Cognito',
7985
to: '/auth/cognito',
@@ -223,7 +229,7 @@ const providers = computed(() =>
223229
prefetch: false,
224230
external: true,
225231
to: inPopup.value ? '#' : p.to,
226-
click: inPopup.value ? () => openInPopup(p.to) : p.click,
232+
click: inPopup.value && p.to ? () => openInPopup(p.to) : p.click,
227233
})),
228234
)
229235
</script>
@@ -235,9 +241,7 @@ const providers = computed(() =>
235241
</template>
236242
<template #right>
237243
<AuthState>
238-
<template
239-
#default="{ loggedIn, clear }"
240-
>
244+
<template #default="{ loggedIn, clear }">
241245
<AuthRegister />
242246
<AuthLogin />
243247
<WebAuthnModal />
@@ -279,7 +283,8 @@ const providers = computed(() =>
279283
<UMain>
280284
<UContainer>
281285
<div class="text-xs mt-4">
282-
Popup mode <UToggle
286+
Popup mode
287+
<UToggle
283288
v-model="inPopup"
284289
size="xs"
285290
name="open-in-popup"

playground/auth.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ declare module '#auth-utils' {
3838
hubspot?: string
3939
atlassian?: string
4040
apple?: string
41+
azureb2c?: string
4142
}
4243

4344
interface UserSession {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineOAuthAzureB2CEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
azureb2c: user.email,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

src/module.ts

+11
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,17 @@ export default defineNuxtModule<ModuleOptions>({
235235
userURL: '',
236236
redirectURL: '',
237237
})
238+
// Azure OAuth
239+
runtimeConfig.oauth.azureb2c = defu(runtimeConfig.oauth.azureb2c, {
240+
clientId: '',
241+
policy: '',
242+
tenant: '',
243+
scope: [],
244+
authorizationURL: '',
245+
tokenURL: '',
246+
userURL: '',
247+
redirectURL: '',
248+
})
238249
// Discord OAuth
239250
runtimeConfig.oauth.discord = defu(runtimeConfig.oauth.discord, {
240251
clientId: '',
+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken, handlePkceVerifier, handleState, handleInvalidState } from '../utils'
6+
import { useRuntimeConfig, createError } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
export interface OAuthAzureB2CConfig {
10+
/**
11+
* Azure OAuth Client ID
12+
* @default process.env.NUXT_OAUTH_AZUREB2C_CLIENT_ID
13+
*/
14+
clientId?: string
15+
/**
16+
* Azure OAuth Policy
17+
* @default process.env.NUXT_OAUTH_AZUREB2C_POLICY
18+
*/
19+
policy?: string
20+
/**
21+
* Azure OAuth Tenant ID
22+
* @default process.env.NUXT_OAUTH_AZUREB2C_TENANT
23+
*/
24+
tenant?: string
25+
/**
26+
* Azure OAuth Scope
27+
* @default ['offline_access']
28+
* @see https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#scopes
29+
*/
30+
scope?: string[]
31+
/**
32+
* Azure OAuth Authorization URL
33+
* @default 'https://${tenant}.onmicrosoft.com/${policy}/oauth2/v2.0/token'
34+
* @see https://learn.microsoft.com/en-us/azure/active-directory-b2c/openid-connect
35+
*/
36+
authorizationURL?: string
37+
/**
38+
* Azure OAuth Token URL
39+
* @default 'https://${tenant}.onmicrosoft.com/${policy}/oauth2/v2.0/token'
40+
* @see https://learn.microsoft.com/en-us/azure/active-directory-b2c/openid-connect
41+
*/
42+
tokenURL?: string
43+
/**
44+
* Azure OAuth User URL
45+
* @default 'https://graph.microsoft.com/v1.0/me'
46+
* @see https://docs.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0&tabs=http
47+
*/
48+
userURL?: string
49+
/**
50+
* Extra authorization parameters to provide to the authorization URL
51+
* @see https://learn.microsoft.com/en-us/azure/active-directory-b2c/authorization-code-flow
52+
*/
53+
authorizationParams?: Record<string, string>
54+
/**
55+
* Redirect URL to prevent in prod prevent redirect_uri mismatch http to https
56+
* @default process.env.NUXT_OAUTH_AZUREB2C_REDIRECT_URL
57+
* @see https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
58+
*/
59+
redirectURL?: string
60+
}
61+
62+
export function defineOAuthAzureB2CEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthAzureB2CConfig>) {
63+
return eventHandler(async (event: H3Event) => {
64+
config = defu(config, useRuntimeConfig(event).oauth?.azureb2c, {
65+
authorizationParams: {},
66+
}) as OAuthAzureB2CConfig
67+
68+
const query = getQuery<{ code?: string, state?: string }>(event)
69+
70+
if (!config.clientId || !config.policy || !config.tenant) {
71+
return handleMissingConfiguration(event, 'azureb2c', ['clientId', 'policy', 'tenant'], onError)
72+
}
73+
74+
const authorizationURL = config.authorizationURL || `https://${config.tenant}.b2clogin.com/${config.tenant}.onmicrosoft.com/${config.policy}/oauth2/v2.0/authorize`
75+
const tokenURL = config.tokenURL || `https://${config.tenant}.b2clogin.com/${config.tenant}.onmicrosoft.com/${config.policy}/oauth2/v2.0/token`
76+
77+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
78+
79+
// guarantee uniqueness of the scope
80+
config.scope = config.scope && config.scope.length > 0 ? config.scope : ['openid']
81+
config.scope = [...new Set(config.scope)]
82+
83+
// Create pkce verifier
84+
const verifier = await handlePkceVerifier(event)
85+
const state = await handleState(event)
86+
87+
if (!query.code) {
88+
// Redirect to Azure B2C Oauth page
89+
return sendRedirect(
90+
event,
91+
withQuery(authorizationURL as string, {
92+
client_id: config.clientId,
93+
response_type: 'code',
94+
redirect_uri: redirectURL,
95+
scope: config.scope.join(' '),
96+
state,
97+
code_challenge: verifier.code_challenge,
98+
code_challenge_method: verifier.code_challenge_method,
99+
...config.authorizationParams,
100+
}),
101+
)
102+
}
103+
104+
if (query.state !== state) {
105+
handleInvalidState(event, 'azureb2c', onError)
106+
}
107+
108+
console.info('code verifier', verifier.code_verifier)
109+
const tokens = await requestAccessToken(tokenURL, {
110+
body: {
111+
grant_type: 'authorization_code',
112+
client_id: config.clientId,
113+
scope: config.scope.join(' '),
114+
code: query.code as string,
115+
redirect_uri: redirectURL,
116+
response_type: 'code',
117+
code_verifier: verifier.code_verifier,
118+
},
119+
})
120+
121+
if (tokens.error) {
122+
return handleAccessTokenErrorResponse(event, 'azureb2c', tokens, onError)
123+
}
124+
125+
const tokenType = tokens.token_type
126+
const accessToken = tokens.access_token
127+
const userURL = config.userURL || 'https://graph.microsoft.com/v1.0/me'
128+
// TODO: improve typing
129+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
130+
const user: any = await $fetch(userURL, {
131+
headers: {
132+
Authorization: `${tokenType} ${accessToken}`,
133+
},
134+
}).catch((error) => {
135+
return { error }
136+
})
137+
if (user.error) {
138+
const error = createError({
139+
statusCode: 401,
140+
message: `azureb2c login failed: ${user.error || 'Unknown error'}`,
141+
data: user,
142+
})
143+
if (!onError) throw error
144+
return onError(event, error)
145+
}
146+
147+
return onSuccess(event, {
148+
tokens,
149+
user,
150+
})
151+
})
152+
}

src/runtime/server/lib/utils.ts

+60-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type { H3Event } from 'h3'
1+
import { type H3Event, deleteCookie, getCookie, setCookie } from 'h3'
22
import { getRequestURL } from 'h3'
33
import { FetchError } from 'ofetch'
44
import { snakeCase, upperFirst } from 'scule'
55
import * as jose from 'jose'
6+
import { subtle, getRandomValues } from 'uncrypto'
67
import type { OAuthProvider, OnError } from '#auth-utils'
78
import { createError } from '#imports'
89

@@ -100,6 +101,18 @@ export function handleMissingConfiguration(event: H3Event, provider: OAuthProvid
100101
return onError(event, error)
101102
}
102103

104+
export function handleInvalidState(event: H3Event, provider: OAuthProvider, onError?: OnError) {
105+
const message = `${upperFirst(provider)} login failed: state mismatch`
106+
107+
const error = createError({
108+
statusCode: 500,
109+
message,
110+
})
111+
112+
if (!onError) throw error
113+
return onError(event, error)
114+
}
115+
103116
/**
104117
* JWT signing using jose
105118
*
@@ -156,3 +169,49 @@ export async function verifyJwt<T>(
156169

157170
return payload as T
158171
}
172+
173+
function encodeBase64Url(input: Uint8Array): string {
174+
return btoa(String.fromCharCode.apply(null, input as unknown as number[]))
175+
.replace(/\+/g, '-')
176+
.replace(/\//g, '_')
177+
.replace(/=+$/g, '')
178+
}
179+
180+
function getRandomBytes(size: number = 32) {
181+
return getRandomValues(new Uint8Array(size))
182+
}
183+
184+
export async function handlePkceVerifier(event: H3Event) {
185+
let verifier = getCookie(event, 'nuxt-auth-pkce')
186+
if (verifier) {
187+
deleteCookie(event, 'nuxt-auth-pkce')
188+
return { code_verifier: verifier }
189+
}
190+
191+
// Create new verifier
192+
verifier = encodeBase64Url(getRandomBytes())
193+
setCookie(event, 'nuxt-auth-pkce', verifier)
194+
195+
// Get pkce
196+
const encodedPkce = new TextEncoder().encode(verifier)
197+
const pkceHash = await subtle.digest('SHA-256', encodedPkce)
198+
const pkce = encodeBase64Url(new Uint8Array(pkceHash))
199+
200+
return {
201+
code_verifier: verifier,
202+
code_challenge: pkce,
203+
code_challenge_method: 'S256',
204+
}
205+
}
206+
207+
export async function handleState(event: H3Event) {
208+
let state = getCookie(event, 'nuxt-auth-state')
209+
if (state) {
210+
deleteCookie(event, 'nuxt-auth-state')
211+
return state
212+
}
213+
214+
state = encodeBase64Url(getRandomBytes(8))
215+
setCookie(event, 'nuxt-auth-state', state)
216+
return state
217+
}

src/runtime/types/oauth-config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3'
22

33
export type ATProtoProvider = 'bluesky'
44

5-
export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | (string & {})
5+
export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | (string & {})
66

77
export type OnError = (event: H3Event, error: H3Error) => Promise<void> | void
88

0 commit comments

Comments
 (0)