Skip to content

Commit 6eca959

Browse files
committed
refactor(structure): ♻️ Reorganized handlers
1 parent 4ff91f2 commit 6eca959

File tree

6 files changed

+409
-400
lines changed

6 files changed

+409
-400
lines changed

src/runtime/server/handler/callback.ts

Lines changed: 199 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,202 @@
1-
import type { UserSession } from '../../types'
2-
import { sendRedirect } from 'h3'
3-
import { callbackEventHandler } from '../lib/oidc'
4-
import { setUserSession } from '../utils/session'
1+
import type { H3Event } from 'h3'
2+
import type { OAuthConfig, PersistentSession, ProviderKeys, TokenRequest, TokenRespose, Tokens, UserSession } from '../../types'
3+
import type { OidcProviderConfig } from '../utils/provider'
4+
import { deleteCookie, eventHandler, getQuery, getRequestURL, readBody, sendRedirect } from 'h3'
5+
import { ofetch } from 'ofetch'
6+
import { normalizeURL, parseURL } from 'ufo'
7+
import * as providerPresets from '../../providers'
8+
import { validateConfig } from '../utils/config'
9+
import { configMerger, convertObjectToSnakeCase, convertTokenRequestToType, oidcErrorHandler, useOidcLogger } from '../utils/oidc'
10+
import { encryptToken, parseJwtToken, validateToken } from '../utils/security'
11+
import { getUserSessionId, setUserSession, useAuthSession } from '../utils/session'
12+
// @ts-expect-error - Missing Nitro type exports in Nuxt
13+
import { useRuntimeConfig, useStorage } from '#imports'
14+
import { textToBase64 } from 'undio'
15+
16+
function callbackEventHandler({ onSuccess }: OAuthConfig<UserSession>) {
17+
const logger = useOidcLogger()
18+
return eventHandler(async (event: H3Event) => {
19+
const provider = event.path.split('/')[2] as ProviderKeys
20+
const config = configMerger(useRuntimeConfig().oidc.providers[provider] as OidcProviderConfig, providerPresets[provider])
21+
22+
const validationResult = validateConfig(config, config.requiredProperties)
23+
24+
if (!validationResult.valid) {
25+
logger.error(`[${provider}] Missing configuration properties: `, validationResult.missingProperties?.join(', '))
26+
oidcErrorHandler(event, 'Invalid configuration')
27+
}
28+
29+
const session = await useAuthSession(event)
30+
31+
const { code, state, id_token, admin_consent, error, error_description }: { code: string; state: string; id_token: string; admin_consent: string; error: string; error_description: string } = event.method === 'POST' ? await readBody(event) : getQuery(event)
32+
33+
// Check for admin consent callback
34+
if (admin_consent) {
35+
const url = getRequestURL(event)
36+
sendRedirect(event, `${url.origin}/auth/${provider}/login`, 200)
37+
}
38+
39+
// Verify id_token, if available (hybrid flow)
40+
if (id_token) {
41+
const parsedIdToken = parseJwtToken(id_token)
42+
if (parsedIdToken.nonce !== session.data.nonce) {
43+
oidcErrorHandler(event, 'Nonce mismatch')
44+
}
45+
}
46+
47+
// Check for valid callback
48+
if (!code || (config.state && !state) || error) {
49+
if (error) {
50+
logger.error(`[${provider}] ${error}`, error_description && `: ${error_description}`)
51+
}
52+
if (!code) {
53+
oidcErrorHandler(event, 'Callback failed, missing code')
54+
}
55+
oidcErrorHandler(event, 'Callback failed')
56+
}
57+
58+
// Check for valid state
59+
if (config.state && (state !== session.data.state)) {
60+
oidcErrorHandler(event, 'State mismatch')
61+
}
62+
63+
// Construct request header object
64+
const headers: HeadersInit = {}
65+
66+
// Validate if authentication information should be send in header or body
67+
if (config.authenticationScheme === 'header') {
68+
const encodedCredentials = textToBase64(`${config.clientId}:${config.clientSecret}`, { dataURL: false })
69+
headers.authorization = `Basic ${encodedCredentials}`
70+
}
71+
72+
// Construct form data for token request
73+
const requestBody: TokenRequest = {
74+
client_id: config.clientId,
75+
code,
76+
grant_type: config.grantType,
77+
...config.redirectUri && { redirect_uri: config.redirectUri },
78+
...config.scopeInTokenRequest && config.scope && { scope: config.scope.join(' ') },
79+
...config.pkce && { code_verifier: session.data.codeVerifier },
80+
...(config.authenticationScheme && config.authenticationScheme === 'body') && { client_secret: normalizeURL(config.clientSecret) },
81+
...config.additionalTokenParameters && convertObjectToSnakeCase(config.additionalTokenParameters),
82+
}
83+
84+
// Make token request
85+
let tokenResponse: TokenRespose
86+
try {
87+
tokenResponse = await ofetch(
88+
config.tokenUrl,
89+
{
90+
method: 'POST',
91+
headers,
92+
body: convertTokenRequestToType(requestBody, config.tokenRequestType ?? undefined),
93+
},
94+
)
95+
}
96+
catch (error: any) {
97+
// Log ofetch error data to console
98+
logger.error(error?.data ? `${error.data.error}: ${error.data.error_description}` : error)
99+
100+
// Handle Microsoft consent_required error
101+
if (error?.data?.suberror === 'consent_required') {
102+
const consentUrl = `https://login.microsoftonline.com/${parseURL(config.authorizationUrl).pathname.split('/')[1]}/adminconsent?client_id=${config.clientId}`
103+
return sendRedirect(
104+
event,
105+
consentUrl,
106+
302,
107+
)
108+
}
109+
return oidcErrorHandler(event, 'Token request failed')
110+
}
111+
112+
// Initialize tokens object
113+
let tokens: Tokens
114+
115+
// Validate tokens only if audience is matched
116+
const accessToken = parseJwtToken(tokenResponse.access_token, !!config.skipAccessTokenParsing)
117+
const idToken = tokenResponse.id_token ? parseJwtToken(tokenResponse.id_token) : undefined
118+
if ([config.audience as string, config.clientId].some(audience => accessToken.aud?.includes(audience) || idToken?.aud?.includes(audience)) && (config.validateAccessToken || config.validateIdToken)) {
119+
// Get OIDC configuration
120+
const openIdConfiguration = (config.openIdConfiguration && typeof config.openIdConfiguration === 'object') ? config.openIdConfiguration : typeof config.openIdConfiguration === 'string' ? await ofetch(config.openIdConfiguration) : await (config.openIdConfiguration!)(config)
121+
const validationOptions = { jwksUri: openIdConfiguration.jwks_uri as string, issuer: openIdConfiguration.issuer as string, ...config.audience && { audience: [config.audience, config.clientId] } }
122+
try {
123+
tokens = {
124+
accessToken: config.validateAccessToken ? await validateToken(tokenResponse.access_token, validationOptions) : accessToken,
125+
...tokenResponse.refresh_token && { refreshToken: tokenResponse.refresh_token },
126+
...tokenResponse.id_token && { idToken: config.validateIdToken ? await validateToken(tokenResponse.id_token, validationOptions) : parseJwtToken(tokenResponse.id_token) },
127+
}
128+
}
129+
catch (error) {
130+
return oidcErrorHandler(event, `[${provider}] Token validation failed: ${error}`)
131+
}
132+
}
133+
else {
134+
logger.info('Skipped token validation')
135+
tokens = {
136+
accessToken,
137+
...tokenResponse.refresh_token && { refreshToken: tokenResponse.refresh_token },
138+
...tokenResponse.id_token && { idToken: parseJwtToken(tokenResponse.id_token) },
139+
}
140+
}
141+
142+
// Construct user object
143+
const timestamp = Math.trunc(Date.now() / 1000) // Use seconds instead of milliseconds to align with JWT
144+
const user: UserSession = {
145+
canRefresh: !!tokens.refreshToken,
146+
loggedInAt: timestamp,
147+
updatedAt: timestamp,
148+
expireAt: tokens.accessToken.exp || timestamp + useRuntimeConfig().oidc.session.maxAge!,
149+
provider,
150+
}
151+
152+
// Request userinfo
153+
try {
154+
if (config.userInfoUrl) {
155+
const userInfoResult = await ofetch(config.userInfoUrl, {
156+
headers: {
157+
Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`,
158+
},
159+
})
160+
user.userInfo = config.filterUserInfo ? Object.fromEntries(Object.entries(userInfoResult).filter(([key]) => config.filterUserInfo?.includes(key))) : userInfoResult
161+
}
162+
}
163+
catch {
164+
logger.warn(`[${provider}] Failed to fetch userinfo`)
165+
}
166+
167+
// Get user name from access token
168+
if (config.userNameClaim) {
169+
user.userName = (config.userNameClaim in tokens.accessToken) ? tokens.accessToken[config.userNameClaim] as string : ''
170+
}
171+
172+
// Get optional claims from id token
173+
if (config.optionalClaims && tokens.idToken) {
174+
const parsedIdToken = tokens.idToken
175+
user.claims = {}
176+
config.optionalClaims.forEach(claim => parsedIdToken[claim] && ((user.claims as Record<string, unknown>)[claim] = (parsedIdToken[claim])))
177+
}
178+
179+
if (tokenResponse.refresh_token || config.exposeAccessToken || config.exposeIdToken) {
180+
const tokenKey = process.env.NUXT_OIDC_TOKEN_KEY as string
181+
const persistentSession: PersistentSession = {
182+
exp: accessToken.exp as number,
183+
iat: accessToken.iat as number,
184+
accessToken: await encryptToken(tokenResponse.access_token, tokenKey),
185+
...tokenResponse.refresh_token && { refreshToken: await encryptToken(tokenResponse.refresh_token, tokenKey) },
186+
...tokenResponse.id_token && { idToken: await encryptToken(tokenResponse.id_token, tokenKey) },
187+
}
188+
const userSessionId = await getUserSessionId(event)
189+
await useStorage('oidc').setItem<PersistentSession>(userSessionId, persistentSession)
190+
}
191+
192+
await session.clear()
193+
deleteCookie(event, 'oidc')
194+
return onSuccess(event, {
195+
user,
196+
callbackRedirectUrl: config.callbackRedirectUrl as string,
197+
})
198+
})
199+
}
5200

6201
export default callbackEventHandler({
7202
async onSuccess(event, { user, callbackRedirectUrl }) {

src/runtime/server/handler/dev.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,78 @@
1-
import type { UserSession } from '../../types'
2-
import { sendRedirect } from 'h3'
3-
import { devEventHandler } from '../lib/oidc'
4-
import { setUserSession } from '../utils/session'
1+
import type { H3Event } from 'h3'
2+
import type { OAuthConfig, UserSession } from '../../types'
3+
import { useRuntimeConfig } from '#imports'
4+
import { deleteCookie, eventHandler, sendRedirect } from 'h3'
5+
import { SignJWT } from 'jose'
6+
import { subtle } from 'uncrypto'
7+
import { useOidcLogger } from '../utils/oidc'
8+
import { generateRandomUrlSafeString } from '../utils/security'
9+
import { setUserSession, useAuthSession } from '../utils/session'
10+
11+
export function devEventHandler({ onSuccess }: OAuthConfig<UserSession>) {
12+
const logger = useOidcLogger()
13+
return eventHandler(async (event: H3Event) => {
14+
logger.warn('Using dev auth handler with static auth information')
15+
16+
const session = await useAuthSession(event)
17+
18+
// Construct user object
19+
const timestamp = Math.trunc(Date.now() / 1000) // Use seconds instead of milliseconds to align with JWT
20+
const user: UserSession = {
21+
canRefresh: false,
22+
loggedInAt: timestamp,
23+
updatedAt: timestamp,
24+
expireAt: timestamp + 86400, // Adding one day
25+
provider: 'dev',
26+
userName: useRuntimeConfig().oidc.devMode?.userName || 'Nuxt OIDC Auth Dev',
27+
...useRuntimeConfig().oidc.devMode?.userInfo && { userInfo: useRuntimeConfig().oidc.devMode?.userInfo },
28+
...useRuntimeConfig().oidc.devMode?.idToken && { idToken: useRuntimeConfig().oidc.devMode?.idToken },
29+
...useRuntimeConfig().oidc.devMode?.accessToken && { accessToken: useRuntimeConfig().oidc.devMode?.accessToken },
30+
...useRuntimeConfig().oidc.devMode?.claims && { claims: useRuntimeConfig().oidc.devMode?.claims },
31+
}
32+
33+
// Generate JWT dev token - Keys are only used in local dev mode, these are statically generated unsafe keys.
34+
if (useRuntimeConfig().oidc.devMode?.generateAccessToken) {
35+
let key
36+
let alg
37+
if (useRuntimeConfig().oidc.devMode?.tokenAlgorithm === 'asymmetric') {
38+
alg = 'RS256'
39+
const keyPair = await subtle.generateKey(
40+
{
41+
name: 'RSASSA-PKCS1-v1_5',
42+
modulusLength: 2048,
43+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
44+
hash: { name: 'SHA-256' },
45+
},
46+
true,
47+
['sign', 'verify'],
48+
)
49+
key = keyPair.privateKey
50+
}
51+
else {
52+
alg = 'HS256'
53+
key = new TextEncoder().encode(
54+
generateRandomUrlSafeString(),
55+
)
56+
}
57+
const jwt = await new SignJWT(useRuntimeConfig().oidc.devMode?.claims || {})
58+
.setProtectedHeader({ alg })
59+
.setIssuedAt()
60+
.setIssuer(useRuntimeConfig().oidc.devMode?.issuer || 'nuxt:oidc:auth:issuer')
61+
.setAudience(useRuntimeConfig().oidc.devMode?.audience || 'nuxt:oidc:auth:audience')
62+
.setExpirationTime('24h')
63+
.setSubject(useRuntimeConfig().oidc.devMode?.subject || 'nuxt:oidc:auth:subject')
64+
.sign(key)
65+
user.accessToken = jwt
66+
}
67+
68+
await session.clear()
69+
deleteCookie(event, 'oidc')
70+
71+
return onSuccess(event, {
72+
user,
73+
})
74+
})
75+
}
576

677
export default devEventHandler({
778
async onSuccess(event, { user }) {
Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,76 @@
1-
import { loginEventHandler } from '../lib/oidc'
1+
import type { H3Event } from 'h3'
2+
import type { AuthorizationRequest, PkceAuthorizationRequest, ProviderKeys } from '../../types'
3+
import type { OidcProviderConfig } from '../utils/provider'
4+
import { useRuntimeConfig } from '#imports'
5+
import { eventHandler, getQuery, getRequestHeader, sendRedirect } from 'h3'
6+
import { withQuery } from 'ufo'
7+
import * as providerPresets from '../../providers'
8+
import { validateConfig } from '../utils/config'
9+
import { configMerger, convertObjectToSnakeCase, oidcErrorHandler, useOidcLogger } from '../utils/oidc'
10+
import { generatePkceCodeChallenge, generatePkceVerifier, generateRandomUrlSafeString } from '../utils/security'
11+
import { useAuthSession } from '../utils/session'
12+
13+
function loginEventHandler() {
14+
const logger = useOidcLogger()
15+
return eventHandler(async (event: H3Event) => {
16+
// TODO: Is this the best way to get the current provider?
17+
const provider = event.path.split('/')[2] as ProviderKeys
18+
const config = configMerger(useRuntimeConfig().oidc.providers[provider] as OidcProviderConfig, providerPresets[provider])
19+
const validationResult = validateConfig(config, config.requiredProperties)
20+
21+
if (!validationResult.valid) {
22+
logger.error(`[${provider}] Missing configuration properties:`, validationResult.missingProperties?.join(', '))
23+
oidcErrorHandler(event, 'Invalid configuration')
24+
}
25+
26+
// Initialize auth session
27+
const session = await useAuthSession(event)
28+
await session.update({
29+
state: generateRandomUrlSafeString(),
30+
codeVerifier: generatePkceVerifier(),
31+
redirect: getRequestHeader(event, 'referer'),
32+
})
33+
34+
// Get client side query parameters
35+
const additionalClientAuthParameters: Record<string, string> = {}
36+
if (config.allowedClientAuthParameters?.length) {
37+
const clientQueryParams = getQuery(event)
38+
config.allowedClientAuthParameters.forEach((param) => {
39+
if (clientQueryParams[param]) {
40+
additionalClientAuthParameters[param] = clientQueryParams[param] as string
41+
}
42+
})
43+
}
44+
45+
const query: AuthorizationRequest | PkceAuthorizationRequest = {
46+
client_id: config.clientId,
47+
response_type: config.responseType,
48+
...config.state && { state: session.data.state },
49+
...config.scope && { scope: config.scope.join(' ') },
50+
...config.responseMode && { response_mode: config.responseMode },
51+
...config.redirectUri && { redirect_uri: config.redirectUri },
52+
...config.prompt && { prompt: config.prompt.join(' ') },
53+
...config.pkce && { code_challenge: await generatePkceCodeChallenge(session.data.codeVerifier), code_challenge_method: 'S256' },
54+
...config.additionalAuthParameters && convertObjectToSnakeCase(config.additionalAuthParameters),
55+
...additionalClientAuthParameters && convertObjectToSnakeCase(additionalClientAuthParameters),
56+
}
57+
58+
// Handling hybrid flows or mitigate replay attacks with nonce
59+
if (config.responseType.includes('token') || config.nonce) {
60+
const nonce = generateRandomUrlSafeString()
61+
await session.update({ nonce })
62+
query.response_mode = 'form_post'
63+
query.nonce = nonce
64+
if (!query.scope?.includes('openid'))
65+
query.scope = `openid ${query.scope}`
66+
}
67+
68+
return sendRedirect(
69+
event,
70+
config.encodeRedirectUri ? withQuery(config.authorizationUrl, query).replace(query.redirect_uri!, encodeURI(query.redirect_uri!)) : withQuery(config.authorizationUrl, query),
71+
200,
72+
)
73+
})
74+
}
275

376
export default loginEventHandler()

0 commit comments

Comments
 (0)