Skip to content

Commit c0ca033

Browse files
bisandatinux
andauthored
feat: add Okta OAuth provider (#429)
Co-authored-by: Sébastien Chopin <seb@nuxt.com> Co-authored-by: Sébastien Chopin <atinux@gmail.com>
1 parent 615fcf9 commit c0ca033

File tree

9 files changed

+349
-2
lines changed

9 files changed

+349
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Dependencies
22
node_modules
3+
.pnpm-store
34

45
# Logs
56
*.log*

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ It can also be set using environment variables:
239239
- LinkedIn
240240
- LiveChat
241241
- Microsoft
242+
- Okta
242243
- PayPal
243244
- Polar
244245
- Salesforce

playground/.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,9 @@ NUXT_OAUTH_SLACK_REDIRECT_URL=
136136
#Heroku
137137
NUXT_OAUTH_HEROKU_CLIENT_ID=
138138
NUXT_OAUTH_HEROKU_CLIENT_SECRET=
139-
NUXT_OAUTH_HEROKU_REDIRECT_URL=
139+
NUXT_OAUTH_HEROKU_REDIRECT_URL=
140+
# Okta
141+
NUXT_OAUTH_OKTA_CLIENT_ID=
142+
NUXT_OAUTH_OKTA_CLIENT_SECRET=
143+
NUXT_OAUTH_OKTA_DOMAIN=
144+
NUXT_OAUTH_OKTA_REDIRECT_URL=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ const providers = computed(() =>
248248
disabled: Boolean(user.value?.heroku),
249249
icon: 'i-simple-icons-heroku',
250250
},
251+
{
252+
label: user.value?.okta || 'Okta',
253+
to: '/auth/okta',
254+
disabled: Boolean(user.value?.okta),
255+
icon: 'i-simple-icons-okta',
256+
},
251257
].map(p => ({
252258
...p,
253259
prefetch: false,

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ declare module '#auth-utils' {
4343
salesforce?: string
4444
slack?: string
4545
heroku?: string
46+
okta?: string
4647
}
4748

4849
interface UserSession {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default defineOAuthOktaEventHandler({
2+
config: {},
3+
async onSuccess(event, { user }) {
4+
await setUserSession(event, {
5+
user: {
6+
okta: user?.name,
7+
},
8+
loggedInAt: Date.now(),
9+
})
10+
11+
return sendRedirect(event, '/')
12+
},
13+
})

src/module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,5 +468,14 @@ export default defineNuxtModule<ModuleOptions>({
468468
redirectURL: '',
469469
scope: '',
470470
})
471+
// Okta OAuth
472+
runtimeConfig.oauth.okta = defu(runtimeConfig.oauth.okta, {
473+
clientId: '',
474+
clientSecret: '',
475+
domain: '',
476+
audience: '',
477+
scope: [],
478+
redirectURL: '',
479+
})
471480
},
472481
})

src/runtime/server/lib/oauth/okta.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect, createError, deleteCookie } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { handleMissingConfiguration, handleState, handleInvalidState, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
6+
import { useRuntimeConfig } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
export interface OpenIdConfig {
10+
authorization_endpoint: string
11+
token_endpoint: string
12+
userinfo_endpoint: string
13+
end_session_endpoint?: string
14+
[key: string]: unknown
15+
}
16+
17+
interface CachedOpenIdConfig {
18+
data: OpenIdConfig
19+
expiresAt: number
20+
}
21+
22+
const openIdConfigCache = new Map<string, CachedOpenIdConfig>()
23+
const DEFAULT_CACHE_TTL = 1000 * 60 * 60 * 24 // 24 hours in milliseconds
24+
25+
export interface OAuthConfigExt<TConfig, TResult = {
26+
user: Record<string, unknown>
27+
tokens: Record<string, unknown>
28+
openIdConfig: OpenIdConfig
29+
}> extends OAuthConfig<TConfig, TResult> {
30+
config?: TConfig
31+
onSuccess: (event: H3Event, result: TResult) => Promise<void> | void
32+
onError?: (event: H3Event, error: unknown) => Promise<void> | void
33+
}
34+
35+
export interface OAuthOktaConfig {
36+
/**
37+
* Okta OAuth Client ID
38+
* @default process.env.NUXT_OAUTH_OKTA_CLIENT_ID
39+
*/
40+
clientId?: string
41+
/**
42+
* Okta OAuth Client Secret
43+
* @default process.env.NUXT_OAUTH_OKTA_CLIENT_SECRET
44+
*/
45+
clientSecret?: string
46+
/**
47+
* Okta OAuth Issuer
48+
* @default process.env.NUXT_OAUTH_OKTA_DOMAIN
49+
*/
50+
domain?: string
51+
/**
52+
* Okta OAuth Authorization Server
53+
* @see https://developer.okta.com/docs/guides/customize-authz-server/main/#create-an-authorization-server
54+
* @default process.env.NUXT_OAUTH_OKTA_AUTHORIZATION_SERVER
55+
*/
56+
authorizationServer?: string
57+
/**
58+
* Okta OAuth Audience
59+
* @default process.env.NUXT_OAUTH_OKTA_AUDIENCE
60+
*/
61+
audience?: string
62+
/**
63+
* Okta OAuth Scope
64+
* @default []
65+
* @see https://okta.com/docs/get-started/apis/scopes/openid-connect-scopes
66+
* @example ['openid']
67+
*/
68+
scope?: string | string[]
69+
/**
70+
* Require email from user, adds the ['email'] scope if not present
71+
* @default false
72+
*/
73+
emailRequired?: boolean
74+
/**
75+
* Maximum Authentication Age. If the elapsed time is greater than this value, the OP must attempt to actively re-authenticate the end-user.
76+
* @default 0
77+
* @see https://okta.com/docs/authenticate/login/max-age-reauthentication
78+
*/
79+
maxAge?: number
80+
/**
81+
* Login connection. If no connection is specified, it will redirect to the standard Okta login page and show the Login Widget.
82+
* @default ''
83+
* @see https://okta.com/docs/api/authentication#social
84+
* @example 'github'
85+
*/
86+
connection?: string
87+
/**
88+
* Extra authorization parameters to provide to the authorization URL
89+
* @see https://okta.com/docs/api/authentication#social
90+
* @example { display: 'popup' }
91+
*/
92+
authorizationParams?: Record<string, string>
93+
/**
94+
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
95+
* @default process.env.NUXT_OAUTH_OKTA_REDIRECT_URL or current URL
96+
*/
97+
redirectURL?: string
98+
/**
99+
* OpenID Configuration cache TTL in milliseconds
100+
* @default 86400000 (24 hours)
101+
*/
102+
openIdConfigCacheTTL?: number
103+
}
104+
105+
export function defineOAuthOktaEventHandler({ config, onSuccess, onError }: OAuthConfigExt<OAuthOktaConfig>) {
106+
function normalizeScope(scope: unknown, emailRequired?: boolean): string[] {
107+
let result: string[]
108+
if (!scope || (typeof scope === 'string' && scope.trim() === '')) {
109+
result = ['openid', 'email', 'profile']
110+
}
111+
else if (typeof scope === 'string') {
112+
result = scope.split(/[,| ]+/).filter(Boolean)
113+
}
114+
else if (Array.isArray(scope) && scope.length > 0) {
115+
result = scope
116+
}
117+
else {
118+
result = ['openid', 'email', 'profile']
119+
}
120+
result = [...new Set(result)]
121+
if (emailRequired && !result.includes('email')) {
122+
result.push('email')
123+
}
124+
return result
125+
}
126+
127+
// Event handler for Okta OAuth. Is called multiple times during the OAuth flow.
128+
return eventHandler(async (event: H3Event) => {
129+
const runtimeConfig = useRuntimeConfig(event)
130+
config = defu(config, runtimeConfig.oauth?.okta, {
131+
authorizationParams: {},
132+
}) as OAuthOktaConfig
133+
134+
if (!config.clientId || !config.clientSecret || !config.domain || typeof config.domain !== 'string' || !config.domain.trim()) {
135+
return handleMissingConfiguration(event, 'okta', ['clientId', 'clientSecret', 'domain'], onError)
136+
}
137+
138+
const query = getQuery<{ code?: string, state?: string, error?: string, error_description?: string }>(event)
139+
140+
if (query.error) {
141+
const error = createError({
142+
statusCode: 401,
143+
message: `Okta login failed: ${query.error || 'Unknown error'} - ${query.error_description || ''}`,
144+
data: query,
145+
})
146+
if (!onError) throw error
147+
return onError(event, error)
148+
}
149+
150+
config.scope = normalizeScope(config.scope, config.emailRequired)
151+
152+
const getOpenIdConfig = async (openIdConfigurationUrl: string, event?: H3Event): Promise<OpenIdConfig> => {
153+
const now = Date.now()
154+
const cacheTTL = config?.openIdConfigCacheTTL || DEFAULT_CACHE_TTL
155+
156+
const cached = openIdConfigCache.get(openIdConfigurationUrl)
157+
if (cached) {
158+
if (cached.expiresAt > now) {
159+
return cached.data
160+
}
161+
else {
162+
openIdConfigCache.delete(openIdConfigurationUrl) // Lazy eviction of expired entry
163+
}
164+
}
165+
166+
let openIdConfig: OpenIdConfig | null = null
167+
try {
168+
openIdConfig = await $fetch<OpenIdConfig>(openIdConfigurationUrl)
169+
if (openIdConfig) {
170+
openIdConfigCache.set(openIdConfigurationUrl, {
171+
data: openIdConfig,
172+
expiresAt: now + cacheTTL,
173+
})
174+
}
175+
}
176+
catch (error) {
177+
if (config && config.domain) {
178+
const authz = config.authorizationServer
179+
openIdConfig = {
180+
authorization_endpoint: authz
181+
? `https://${config.domain}/oauth2/${authz}/v1/authorize`
182+
: `https://${config.domain}/oauth2/v1/authorize`,
183+
token_endpoint: authz
184+
? `https://${config.domain}/oauth2/${authz}/v1/token`
185+
: `https://${config.domain}/oauth2/v1/token`,
186+
userinfo_endpoint: authz
187+
? `https://${config.domain}/oauth2/${authz}/v1/userinfo`
188+
: `https://${config.domain}/oauth2/v1/userinfo`,
189+
end_session_endpoint: undefined,
190+
}
191+
192+
openIdConfigCache.set(openIdConfigurationUrl, {
193+
data: openIdConfig,
194+
expiresAt: now + Math.min(cacheTTL / 24, 1000 * 60 * 60), // 1 hour or 1/24th of configured TTL, whichever is smaller
195+
})
196+
}
197+
else {
198+
// Log and throw a more actionable error if OpenID config fetch fails and no fallback is possible
199+
console.error('Failed to fetch Okta OpenID configuration. Please check your Okta domain and network connectivity:', error)
200+
const err = createError({
201+
statusCode: 500,
202+
message: 'Could not get Okta OpenID configuration. Please verify that your Okta domain is correct and reachable, and that the OpenID configuration endpoint is accessible.',
203+
data: error,
204+
})
205+
if (onError) await onError(event!, err)
206+
throw err
207+
}
208+
}
209+
return openIdConfig
210+
}
211+
212+
const authServer = config.authorizationServer
213+
const openIdConfigurationUrl = authServer ? `https://${config.domain}/oauth2/${authServer}/.well-known/openid-configuration` : `https://${config.domain}/.well-known/openid-configuration`
214+
215+
const openIdConfig = await getOpenIdConfig(openIdConfigurationUrl, event)
216+
const authorizationURL = openIdConfig.authorization_endpoint
217+
const tokenURL = openIdConfig.token_endpoint
218+
const userInfoUrl = openIdConfig.userinfo_endpoint
219+
220+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
221+
222+
const state = await handleState(event)
223+
224+
// Step 1: Authorization Request
225+
if (!query.code) {
226+
return sendRedirect(
227+
event,
228+
withQuery(authorizationURL as string, {
229+
response_type: 'code',
230+
client_id: config.clientId,
231+
redirect_uri: redirectURL,
232+
scope: config.scope.join(' '),
233+
audience: config.audience || '',
234+
max_age: config.maxAge || 0,
235+
connection: config.connection || '',
236+
state,
237+
...config.authorizationParams,
238+
}),
239+
)
240+
}
241+
242+
// Step 2: Validate callback state
243+
if (query.state !== state) {
244+
deleteCookie(event, 'nuxt-auth-state')
245+
return handleInvalidState(event, 'okta', onError)
246+
}
247+
248+
// Step 3: Request Access Token
249+
const tokens = await requestAccessToken(tokenURL as string, {
250+
headers: {
251+
'Content-Type': 'application/x-www-form-urlencoded',
252+
},
253+
body: {
254+
response_type: 'code',
255+
grant_type: 'authorization_code',
256+
client_id: config.clientId,
257+
client_secret: config.clientSecret,
258+
scope: config.scope?.join(' '),
259+
redirect_uri: redirectURL,
260+
code: query.code,
261+
state: query.state,
262+
},
263+
})
264+
265+
if (tokens.error) {
266+
return handleAccessTokenErrorResponse(event, 'okta', tokens, onError)
267+
}
268+
269+
if (
270+
!tokens.access_token || typeof tokens.access_token !== 'string'
271+
|| !tokens.token_type || typeof tokens.token_type !== 'string'
272+
) {
273+
const err = createError({
274+
statusCode: 400,
275+
message: 'Invalid token response from Okta',
276+
data: tokens,
277+
})
278+
if (onError) return onError(event, err)
279+
throw err
280+
}
281+
282+
const tokenType = tokens.token_type
283+
const accessToken = tokens.access_token
284+
285+
// Step 4: Fetch user info from Okta using the access token
286+
let user: Record<string, unknown>
287+
try {
288+
user = await $fetch(userInfoUrl, {
289+
headers: {
290+
Authorization: `${tokenType} ${accessToken}`,
291+
},
292+
})
293+
}
294+
catch (error: unknown) {
295+
const err = createError({
296+
statusCode: 410,
297+
message: `Could not get Okta user info - ${error instanceof Error ? error.message : String(error)}`,
298+
data: tokens,
299+
})
300+
if (onError) return onError(event, err)
301+
throw err
302+
}
303+
304+
// Step 5: Call onSuccess handler with tokens, user, and OpenID config
305+
return onSuccess(event, {
306+
tokens,
307+
user,
308+
openIdConfig,
309+
})
310+
})
311+
}

src/runtime/types/oauth-config.ts

Lines changed: 1 addition & 1 deletion
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' | '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' | (string & {})
5+
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' | 'okta' | (string & {})
66

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

0 commit comments

Comments
 (0)