diff --git a/README.md b/README.md index 52a10339..8ee6d848 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ It can also be set using environment variables: - Linear - LinkedIn - Microsoft +- Okta - PayPal - Polar - Seznam diff --git a/playground/app.vue b/playground/app.vue index 39ba4828..f8c12178 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -218,6 +218,12 @@ const providers = computed(() => disabled: Boolean(user.value?.apple), icon: 'i-simple-icons-apple', }, + { + label: user.value?.okta || 'Okta', + to: '/auth/okta', + disabled: Boolean(user.value?.okta), + icon: 'i-simple-icons-okta', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 7aeb6025..ff6bfbb2 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -38,6 +38,7 @@ declare module '#auth-utils' { hubspot?: string atlassian?: string apple?: string + okta?: string } interface UserSession { diff --git a/playground/server/routes/auth/okta.get.ts b/playground/server/routes/auth/okta.get.ts new file mode 100644 index 00000000..722312c3 --- /dev/null +++ b/playground/server/routes/auth/okta.get.ts @@ -0,0 +1,15 @@ +export default defineOAuthOktaEventHandler({ + config: { + emailRequired: true, + }, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + email: user.email, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 9c199fc2..07418555 100644 --- a/src/module.ts +++ b/src/module.ts @@ -247,6 +247,13 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Okta OAuth + runtimeConfig.oauth.okta = defu(runtimeConfig.oauth.okta, { + clientId: '', + clientSecret: '', + domain: '', + redirectURL: '', + }) // Atproto OAuth for (const provider of atprotoProviders) { diff --git a/src/runtime/server/lib/oauth/okta.ts b/src/runtime/server/lib/oauth/okta.ts new file mode 100644 index 00000000..de9236a4 --- /dev/null +++ b/src/runtime/server/lib/oauth/okta.ts @@ -0,0 +1,111 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthOktaConfig { + /** + * Okta OAuth Client ID + * @default process.env.NUXT_OAUTH_OKTA_CLIENT_ID + */ + clientId?: string + /** + * Okta OAuth Client Secret + * @default process.env.NUXT_OAUTH_OKTA_CLIENT_SECRET + */ + clientSecret?: string + /** + * Okta OAuth Client Secret + * @default process.env.NUXT_OAUTH_OKTA_DOMAIN + */ + domain?: string + /** + * Okta OAuth Scope + * @default [] + * @see https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/#scopes-and-supported-endpoints + * @example ['okta.myAccount.email.read'] + */ + scope?: string[] + /** + * Require email from user, adds the ['okta.myAccount.email.read'] scope if not present + * @default false + */ + emailRequired?: boolean + + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_OKTA_REDIRECT_URL + */ + redirectURL?: string +} + +export function defineOAuthOktaEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.okta, { + authorizationParams: {}, + }) as OAuthOktaConfig + + if (!config.clientId || !config.clientSecret || !config.domain) { + return handleMissingConfiguration(event, 'okta', ['clientId', 'clientSecret', 'domain'], onError) + } + const authorizationURL = `https://${config.domain}.okta.com/oauth2/v1/authorize` + const tokenURL = `https://${config.domain}.okta.com/oauth2/v1/token` + + const query = getQuery<{ code?: string }>(event) + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) { + config.scope = config.scope || [] + if (config.emailRequired && !config.scope.includes('okta.myAccount.email.read')) { + config.scope.push('okta.myAccount.email.read') + } + + return sendRedirect( + event, + withQuery(authorizationURL as string, { + client_id: config.clientId, + redirect_uri: redirectURL, + response_type: 'code', + scope: config.scope.join(' '), + state: 'foo', + }), + ) + } + + const tokens = await requestAccessToken(tokenURL as string, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + code: query.code, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'okta', tokens, onError) + } + + const accessToken = tokens.access_token + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const emails: any[] = await $fetch(`https://${config.domain}.okta.com/idp/myaccount/emails`, { + headers: { + Accept: 'application/json; okta-version=1.0.0', + Authorization: `Bearer ${accessToken}`, + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = { + email: emails[0]?.profile?.email, + } + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 5bb31c75..72c4b409 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' | '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' | (string & {}) +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' | 'okta' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void