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..5228171c 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_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 @@
+
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..efe48416
--- /dev/null
+++ b/playground/server/routes/auth/ory.ts
@@ -0,0 +1,15 @@
+export default defineOAuthOryEventHandler({
+ config: {},
+ async onSuccess(event, { user }) {
+ await setUserSession(event, {
+ user: {
+ id: user?.sub,
+ email: user?.email,
+ ory: user?.email,
+ },
+ loggedInAt: Date.now(),
+ })
+
+ return sendRedirect(event, '/')
+ },
+})
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..8e2385e0
--- /dev/null
+++ b/src/runtime/server/lib/oauth/ory.ts
@@ -0,0 +1,177 @@
+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://www.ory.sh/docs/oauth2-oidc/openid-connect-claims-scope-custom
+ * @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
+ * @see https://www.ory.sh/docs/oauth2-oidc/userinfo-oidc
+ * @default '/userinfo'
+ */
+ userURL?: string
+}
+
+export function defineOAuthOryEventHandler({ 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 userinfo 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