From b0da81f6e21a05fac6e3b47ec280ee978132fe1c Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Thu, 5 Jun 2025 12:48:59 +0200 Subject: [PATCH 1/5] feat: add ory provider --- README.md | 1 + playground/.env.example | 6 +- src/module.ts | 11 ++ src/runtime/server/lib/oauth/ory.ts | 176 ++++++++++++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/runtime/server/lib/oauth/ory.ts diff --git a/README.md b/README.md index 862f0133..8ed12473 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ It can also be set using environment variables: - LinkedIn - LiveChat - Microsoft +- Ory - PayPal - Polar - Salesforce diff --git a/playground/.env.example b/playground/.env.example index d2fbea9a..42242451 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -136,4 +136,8 @@ NUXT_OAUTH_SLACK_REDIRECT_URL= #Heroku NUXT_OAUTH_HEROKU_CLIENT_ID= NUXT_OAUTH_HEROKU_CLIENT_SECRET= -NUXT_OAUTH_HEROKU_REDIRECT_URL= \ No newline at end of file +NUXT_OAUTH_HEROKU_REDIRECT_URL= +#Ory +NUXT_OAUTH_ORY_CLIENT_ID= +NUXT_OAUTH_ORY_CLIENT_SECRET= +NUXT_OAUTH_ORY_SERVER_URL= diff --git a/src/module.ts b/src/module.ts index 99dbd418..6ea9e256 100644 --- a/src/module.ts +++ b/src/module.ts @@ -468,5 +468,16 @@ export default defineNuxtModule({ redirectURL: '', scope: '', }) + // Ory OAuth + runtimeConfig.oauth.ory = defu(runtimeConfig.oauth.ory, { + clientId: '', + clientSecret: '', + sdkURL: '', + redirectURL: '', + scope: [], + authorizationURL: '', + tokenURL: '', + userURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/ory.ts b/src/runtime/server/lib/oauth/ory.ts new file mode 100644 index 00000000..f55d2c02 --- /dev/null +++ b/src/runtime/server/lib/oauth/ory.ts @@ -0,0 +1,176 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { + getOAuthRedirectURL, + handleAccessTokenErrorResponse, + handleInvalidState, + handleMissingConfiguration, + handlePkceVerifier, + handleState, + requestAccessToken, +} from '../utils' +import { createError, useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +/** + * Ory OAuth2 + * @see https://www.ory.sh/docs/oauth2-oidc/authorization-code-flow + */ + +export interface OAuthOryConfig { + /** + * Ory OAuth Client ID + * @default process.env.NUXT_OAUTH_ORY_CLIENT_ID + */ + clientId?: string + /** + * Ory OAuth Client Secret + * @default process.env.NUXT_OAUTH_ORY_CLIENT_SECRET + */ + clientSecret?: string + /** + * Ory OAuth SDK URL + * @default "https://playground.projects.oryapis.com" || process.env.NUXT_OAUTH_ORY_SDK_URL + */ + sdkURL?: string + /** + * Ory OAuth Scope + * @default ['openid', 'offline'] + * @see https://docs.oryhydra.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps + * @example ['openid', 'offline', 'email'] + */ + scope?: string[] | string + /** + * Ory OAuth Authorization URL + * @default '/oauth2/auth' + */ + authorizationURL?: string + + /** + * Ory OAuth Token URL + * @default '/oauth2/token' + */ + tokenURL?: string + + /** + * Extra authorization parameters to provide to the authorization URL + * @example { allow_signup: 'true' } + */ + authorizationParams?: Record + + /** + * Ory OAuth Userinfo URL + * @default '/userinfo' + */ + userURL?: string +} + +export function oryHydraEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.ory, { + scope: ['openid', 'offline'], + sdkURL: 'https://playground.projects.oryapis.com', + authorizationURL: '/oauth2/auth', + tokenURL: '/oauth2/token', + userURL: '/userinfo', + authorizationParams: {}, + }) as OAuthOryConfig + + // TODO: improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const query = getQuery<{ code?: string, state?: string, error: any }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `ory login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.sdkURL) { + return handleMissingConfiguration(event, 'ory', ['clientId', 'sdkURL'], onError) + } + + const redirectURL = getOAuthRedirectURL(event) + + // guarantee uniqueness of the scope and convert to string if it's an array + if (Array.isArray(config.scope)) { + config.scope = Array.from(new Set(config.scope)).join(' ') + } + + // Create pkce verifier + const verifier = await handlePkceVerifier(event) + const state = await handleState(event) + + if (!query.code) { + const authorizationURL = `${config.sdkURL}${config.authorizationURL}` + return sendRedirect( + event, + withQuery(authorizationURL, { + client_id: config.clientId, + response_type: 'code', + redirect_uri: redirectURL, + scope: config.scope, + state, + code_challenge: verifier.code_challenge, + code_challenge_method: verifier.code_challenge_method, + ...config.authorizationParams, + }), + ) + } + + if (query.state !== state) { + handleInvalidState(event, 'ory', onError) + } + + const tokenURL = `${config.sdkURL}${config.tokenURL}` + const tokens = await requestAccessToken(tokenURL, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + code: query.code as string, + redirect_uri: redirectURL, + scope: config.scope, + code_verifier: verifier.code_verifier, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'ory', tokens, onError) + } + + const tokenType = tokens.token_type + const accessToken = tokens.access_token + + const userURL = `${config.sdkURL}${config.userURL}` + // TODO: improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = await $fetch(userURL, { + headers: { + 'User-Agent': `Ory-${config.clientId}`, + 'Authorization': `${tokenType} ${accessToken}`, + }, + }).catch((error) => { + return { error } + }) + if (user.error) { + const error = createError({ + statusCode: 401, + message: `ory login failed: ${user.error || 'Unknown error'}`, + data: user, + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { + tokens, + user, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index ca7d962d..63a6d8b7 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' | '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 & {}) +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' | 'ory' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From 69588e88d8a95faf7b5f4da16cb0e961bbf8371a Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Mon, 9 Jun 2025 09:21:22 +0200 Subject: [PATCH 2/5] feat: add ory provider in playground --- playground/.env.example | 2 +- playground/app.vue | 6 ++++++ playground/assets/icons/ory.svg | 1 + playground/auth.d.ts | 1 + playground/nuxt.config.ts | 6 ++++++ playground/server/routes/auth/ory.ts | 14 ++++++++++++++ 6 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 playground/assets/icons/ory.svg create mode 100644 playground/server/routes/auth/ory.ts diff --git a/playground/.env.example b/playground/.env.example index 42242451..5228171c 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -140,4 +140,4 @@ NUXT_OAUTH_HEROKU_REDIRECT_URL= #Ory NUXT_OAUTH_ORY_CLIENT_ID= NUXT_OAUTH_ORY_CLIENT_SECRET= -NUXT_OAUTH_ORY_SERVER_URL= +NUXT_OAUTH_ORY_SDK_URL= diff --git a/playground/app.vue b/playground/app.vue index 369b90f7..1f357d87 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -248,6 +248,12 @@ const providers = computed(() => disabled: Boolean(user.value?.heroku), icon: 'i-simple-icons-heroku', }, + { + label: user.value?.ory || 'Ory', + to: '/auth/ory', + disabled: Boolean(user.value?.ory), + icon: 'i-custom-ory', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/assets/icons/ory.svg b/playground/assets/icons/ory.svg new file mode 100644 index 00000000..3839b373 --- /dev/null +++ b/playground/assets/icons/ory.svg @@ -0,0 +1 @@ +Ory diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 61d718fb..f68476ab 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -43,6 +43,7 @@ declare module '#auth-utils' { salesforce?: string slack?: string heroku?: string + ory?: string } interface UserSession { diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index a70b02ee..a030c70f 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -27,4 +27,10 @@ export default defineNuxtConfig({ webAuthn: true, atproto: true, }, + icon: { + customCollections: [{ + prefix: 'custom', + dir: './assets/icons', + }], + }, }) diff --git a/playground/server/routes/auth/ory.ts b/playground/server/routes/auth/ory.ts new file mode 100644 index 00000000..b47c0736 --- /dev/null +++ b/playground/server/routes/auth/ory.ts @@ -0,0 +1,14 @@ +export default defineOAuthOryEventHandler({ + config: {}, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + email: user?.email, + ory: user?.email, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) From 5f2207ad0bf17aa4785257ace5756bf26d5404a8 Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Sat, 5 Jul 2025 23:55:54 +0200 Subject: [PATCH 3/5] fix: wrong function provider name --- src/runtime/server/lib/oauth/ory.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/lib/oauth/ory.ts b/src/runtime/server/lib/oauth/ory.ts index f55d2c02..8813cf1e 100644 --- a/src/runtime/server/lib/oauth/ory.ts +++ b/src/runtime/server/lib/oauth/ory.ts @@ -67,7 +67,7 @@ export interface OAuthOryConfig { userURL?: string } -export function oryHydraEventHandler({ config, onSuccess, onError }: OAuthConfig) { +export function defineOAuthOryEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { config = defu(config, useRuntimeConfig(event).oauth?.ory, { scope: ['openid', 'offline'], @@ -85,7 +85,7 @@ export function oryHydraEventHandler({ config, onSuccess, onError }: OAuthConfig if (query.error) { const error = createError({ statusCode: 401, - message: `ory login failed: ${query.error || 'Unknown error'}`, + message: `Ory login failed: ${query.error || 'Unknown error'}`, data: query, }) if (!onError) throw error @@ -161,7 +161,7 @@ export function oryHydraEventHandler({ config, onSuccess, onError }: OAuthConfig if (user.error) { const error = createError({ statusCode: 401, - message: `ory login failed: ${user.error || 'Unknown error'}`, + message: `Ory userinfo failed: ${user.error || 'Unknown error'}`, data: user, }) if (!onError) throw error From 7a277e895a2762f0ff0578f70ae767897398bf5c Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Sun, 6 Jul 2025 00:02:29 +0200 Subject: [PATCH 4/5] docs: add links to the online docs --- src/runtime/server/lib/oauth/ory.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/server/lib/oauth/ory.ts b/src/runtime/server/lib/oauth/ory.ts index 8813cf1e..8e2385e0 100644 --- a/src/runtime/server/lib/oauth/ory.ts +++ b/src/runtime/server/lib/oauth/ory.ts @@ -38,7 +38,7 @@ export interface OAuthOryConfig { /** * Ory OAuth Scope * @default ['openid', 'offline'] - * @see https://docs.oryhydra.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps + * @see https://www.ory.sh/docs/oauth2-oidc/openid-connect-claims-scope-custom * @example ['openid', 'offline', 'email'] */ scope?: string[] | string @@ -62,6 +62,7 @@ export interface OAuthOryConfig { /** * Ory OAuth Userinfo URL + * @see https://www.ory.sh/docs/oauth2-oidc/userinfo-oidc * @default '/userinfo' */ userURL?: string From 50eb9c826bd7e1d69d17c5d6da4e05fd262d35fa Mon Sep 17 00:00:00 2001 From: Jordan Labrosse Date: Sun, 6 Jul 2025 00:04:17 +0200 Subject: [PATCH 5/5] feat: add user id into ory playground mapping --- playground/server/routes/auth/ory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playground/server/routes/auth/ory.ts b/playground/server/routes/auth/ory.ts index b47c0736..efe48416 100644 --- a/playground/server/routes/auth/ory.ts +++ b/playground/server/routes/auth/ory.ts @@ -3,6 +3,7 @@ export default defineOAuthOryEventHandler({ async onSuccess(event, { user }) { await setUserSession(event, { user: { + id: user?.sub, email: user?.email, ory: user?.email, },