diff --git a/README.md b/README.md index 1006f11f..bf1919df 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ It can also be set using environment variables: - LinkedIn - LiveChat - Microsoft +- OIDC / OpenID Connect (Generic) - Okta - Ory - PayPal @@ -411,7 +412,7 @@ export default defineWebAuthnRegisterEventHandler({ // If he registers a new account with credentials return z.object({ // we want the userName to be a valid email - userName: z.string().email() + userName: z.string().email() }).parse(userBody) }, async onSuccess(event, { credential, user }) { diff --git a/playground/app.vue b/playground/app.vue index 91f6ecc0..ebc91880 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -266,6 +266,11 @@ const providers = computed(() => disabled: Boolean(user.value?.ory), icon: 'i-custom-ory', }, + { + label: user.value?.oidc || 'OIDC', + to: '/auth/oidc', + disabled: Boolean(user.value?.oidc), + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 1f182324..654286a7 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -46,6 +46,7 @@ declare module '#auth-utils' { roblox?: string okta?: string ory?: string + oidc?: string } interface UserSession { @@ -62,4 +63,4 @@ declare module '#auth-utils' { } } -export {} +export { } diff --git a/playground/server/routes/auth/oidc.ts b/playground/server/routes/auth/oidc.ts new file mode 100644 index 00000000..3dffc713 --- /dev/null +++ b/playground/server/routes/auth/oidc.ts @@ -0,0 +1,15 @@ +export default defineOAuthOidcEventHandler({ + config: { + scope: ['openid', 'profile', 'email'], + }, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + oidc: user.name, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index b215622e..91e4249b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -495,5 +495,13 @@ export default defineNuxtModule({ tokenURL: '', userURL: '', }) + // OIDC OAuth + runtimeConfig.oauth.oidc = defu(runtimeConfig.oauth.oidc, { + clientId: '', + clientSecret: '', + openidConfig: '', + redirectUrl: '', + scope: [], + }) }, }) diff --git a/src/runtime/server/lib/oauth/oidc.ts b/src/runtime/server/lib/oauth/oidc.ts new file mode 100644 index 00000000..f8e2fd32 --- /dev/null +++ b/src/runtime/server/lib/oauth/oidc.ts @@ -0,0 +1,326 @@ +import type { OAuthConfig } from '#auth-utils' +import { useRuntimeConfig } from '#imports' +import { defu } from 'defu' +import type { H3Event } from 'h3' +import { createError, eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleInvalidState, handleMissingConfiguration, handlePkceVerifier, handleState, requestAccessToken } from '../utils' + +export interface OAuthOidcConfig { + /** + * OAuth Client ID + * + * @default process.env.NUXT_OAUTH_OIDC_CLIENT_ID + */ + clientId?: string + /** + * OAuth Client secret. + * If unset, PKCE will be used where no client secret is needed. + * + * @default process.env.NUXT_OAUTH_OIDC_CLIENT_SECRET + */ + clientSecret?: string + /** + * OpenID configuration. If a string is passed, it is considered to be the full URL to the OpenID configuration endpoint + * where all required endpoints are listed and fetched from automatically. + * + * Alternatively, an object can be set with the required endpoint URLs. + * + * @default process.env.NUXT_OAUTH_OIDC_OPENID_CONFIG + * @example "https://my-provider.com/.well-known/openid-configuration" + */ + openidConfig?: string | OIDCConfiguration + /** + * OAuth Scope + * + * @default ['openid'] + * @example ['openid', 'profile', 'email'] + */ + scope?: string[] + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * + * @default process.env.NUXT_OAUTH_OIDC_REDIRECT_URL + */ + redirectURL?: string + /** + * Additional custom parameters that are passed to the specific endpoint requests. + * Can be used to provide custom (query) parameters. + */ + parameters?: Partial> + +} + +/** + * Standard OIDC claims. + * + * @see: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ +interface OidcUser { + /** + * Subject - Identifier for the End-User at the Issuer. + */ + sub: string + + /** + * End-User's full name in displayable form including all name parts, + * possibly including titles and suffixes, ordered according to the + * End-User's locale and preferences. + */ + name?: string + + /** + * Given name(s) or first name(s) of the End-User. Note that in some cultures, + * people can have multiple given names; all can be present, with the names + * being separated by space characters. + */ + given_name?: string + + /** + * Surname(s) or last name(s) of the End-User. Note that in some cultures, + * people can have multiple family names or no family name; all can be present, + * with the names being separated by space characters. + */ + family_name?: string + + /** + * Middle name(s) of the End-User. Note that in some cultures, people can have + * multiple middle names; all can be present, with the names being separated by + * space characters. Also note that in some cultures, middle names are not used. + */ + middle_name?: string + + /** + * Casual name of the End-User that may or may not be the same as the given_name. + * For instance, a nickname value of Mike might be returned alongside a given_name + * value of Michael. + */ + nickname?: string + + /** + * Shorthand name by which the End-User wishes to be referred to at the RP, such as + * janedoe or j.doe. This value MAY be any valid JSON string including special + * characters such as @, /, or whitespace. The RP MUST NOT rely upon this value + * being unique. + */ + preferred_username?: string + + /** + * URL of the End-User's profile page. The contents of this Web page SHOULD be + * about the End-User. + */ + profile?: string + + /** + * URL of the End-User's profile picture. This URL MUST refer to an image file + * (for example, a PNG, JPEG, or GIF image file), rather than to a Web page + * containing an image. Note that this URL SHOULD specifically reference a profile + * photo of the End-User suitable for displaying when describing the End-User, + * rather than an arbitrary photo taken by the End-User. + */ + picture?: string + + /** + * URL of the End-User's Web page or blog. This Web page SHOULD contain information + * published by the End-User or an organization that the End-User is affiliated with. + */ + website?: string + + /** + * End-User's preferred e-mail address. Its value MUST conform to the RFC 5322 + * addr-spec syntax. The RP MUST NOT rely upon this value being unique. + */ + email?: string + + /** + * True if the End-User's e-mail address has been verified; otherwise false. + * When this Claim Value is true, this means that the OP took affirmative steps + * to ensure that this e-mail address was controlled by the End-User at the time + * the verification was performed. The means by which an e-mail address is verified + * is context specific, and dependent upon the trust framework or contractual + * agreements within which the parties are operating. + */ + email_verified?: boolean + + /** + * End-User's gender. Values defined by this specification are female and male. + * Other values MAY be used when neither of the defined values are applicable. + */ + gender?: string + + /** + * End-User's birthday, represented as an ISO 8601-1 YYYY-MM-DD format. The year + * MAY be 0000, indicating that it is omitted. To represent only the year, YYYY + * format is allowed. Note that depending on the underlying platform's date related + * function, providing just year can result in varying month and day, so the + * implementers need to take this factor into account to correctly process the dates. + */ + birthdate?: string + + /** + * String from IANA Time Zone Database representing the End-User's time zone. + * For example, Europe/Paris or America/Los_Angeles. + */ + zoneinfo?: string + + /** + * End-User's locale, represented as a BCP47 language tag. This is typically an + * ISO 639 Alpha-2 language code in lowercase and an ISO 3166-1 Alpha-2 country + * code in uppercase, separated by a dash. For example, en-US or fr-CA. As a + * compatibility note, some implementations have used an underscore as the separator + * rather than a dash, for example, en_US; Relying Parties MAY choose to accept + * this locale syntax as well. + */ + locale?: string + + /** + * End-User's preferred telephone number. E.164 is RECOMMENDED as the format of + * this Claim, for example, +1 (555) 555-5555 or +56 (2) 687 2400. If the phone + * number contains an extension, it is RECOMMENDED that the extension be represented + * using the RFC 3966 extension syntax, for example, +1 (555) 555-5555;ext=5678. + */ + phone_number?: string + + /** + * True if the End-User's phone number has been verified; otherwise false. When + * this Claim Value is true, this means that the OP took affirmative steps to + * ensure that this phone number was controlled by the End-User at the time the + * verification was performed. The means by which a phone number is verified is + * context specific, and dependent upon the trust framework or contractual + * agreements within which the parties are operating. When true, the phone_number + * Claim MUST be in E.164 format and any extensions MUST be represented in RFC 3966 format. + */ + phone_number_verified?: boolean + + /** + * End-User's preferred postal address. + */ + address?: AddressClaim + + /** + * Time the End-User's information was last updated. Its value is a JSON number + * representing the number of seconds from 1970-01-01T00:00:00Z as measured in + * UTC until the date/time. + */ + updated_at?: number +} + +/** + * Address claim structure as defined in OpenID Connect specification + */ +interface AddressClaim { + /** Full mailing address, formatted for display or use on a mailing label */ + formatted?: string + /** Full street address component, which may include house number, street name, post office box, and multi-line extended street address information */ + street_address?: string + /** City or locality component */ + locality?: string + /** State, province, prefecture, or region component */ + region?: string + /** Zip code or postal code component */ + postal_code?: string + /** Country name component */ + country?: string +} + +interface OidcTokens { + access_token: string + scope: string + token_type: string +} + +interface OIDCConfiguration { + authorization_endpoint: string + token_endpoint: string + userinfo_endpoint?: string +} + +/** + * Event handler for generic OAuth using OIDC and PKCE. + */ +export function defineOAuthOidcEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.oidc, { + scope: ['openid'], + } satisfies OAuthOidcConfig) + + const query = getQuery<{ code?: string, error?: string, state?: string }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `OIDC login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.openidConfig) { + return handleMissingConfiguration(event, 'oidc', ['clientId', 'openidConfig'], onError) + } + + const oidcConfig = typeof config.openidConfig === 'string' ? await $fetch(config.openidConfig) : config.openidConfig + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + const state = await handleState(event) + + // if no client secret is provided, we will use PKCE so no client secret is needed + const verifier = !config.clientSecret ? await handlePkceVerifier(event) : undefined + + if (!query.code) { + config.scope = config.scope || [] + + return sendRedirect( + event, + withQuery(oidcConfig.authorization_endpoint, { + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope.join(' '), + state, + response_type: 'code', + code_challenge: verifier?.code_challenge, + code_challenge_method: verifier?.code_challenge_method, + ...config.parameters?.authorization_endpoint, + }), + ) + } + + if (query.state !== state) { + return handleInvalidState(event, 'oidc', onError) + } + + const tokens = await requestAccessToken(oidcConfig.token_endpoint, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + code: query.code, + code_verifier: verifier?.code_verifier, + ...config.parameters?.token_endpoint, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'oidc', tokens, onError) + } + + let user = {} as TUser + + // some OIDC providers to not support a userinfo endpoint so we only call it when its defined inside the OIDC config + if (oidcConfig.userinfo_endpoint) { + user = await $fetch(oidcConfig.userinfo_endpoint, { + headers: { + Authorization: `${tokens.token_type} ${tokens.access_token}`, + }, + body: config.parameters?.userinfo_endpoint, + }) + } + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/server/lib/utils.ts b/src/runtime/server/lib/utils.ts index 699eda29..358142a7 100644 --- a/src/runtime/server/lib/utils.ts +++ b/src/runtime/server/lib/utils.ts @@ -42,7 +42,7 @@ export interface RequestAccessTokenOptions { */ // TODO: waiting for https://github.com/atinux/nuxt-auth-utils/pull/140 // eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function requestAccessToken(url: string, options: RequestAccessTokenOptions): Promise { +export async function requestAccessToken(url: string, options: RequestAccessTokenOptions): Promise { const headers = { 'Content-Type': 'application/x-www-form-urlencoded', ...options.headers,