Skip to content

Commit 06f41f4

Browse files
authored
feat: add Ory provider (#417)
1 parent c6e3ac3 commit 06f41f4

File tree

10 files changed

+223
-1
lines changed

10 files changed

+223
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ It can also be set using environment variables:
240240
- LiveChat
241241
- Microsoft
242242
- Okta
243+
- Ory
243244
- PayPal
244245
- Polar
245246
- Salesforce

playground/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,7 @@ NUXT_OAUTH_OKTA_CLIENT_ID=
142142
NUXT_OAUTH_OKTA_CLIENT_SECRET=
143143
NUXT_OAUTH_OKTA_DOMAIN=
144144
NUXT_OAUTH_OKTA_REDIRECT_URL=
145+
#Ory
146+
NUXT_OAUTH_ORY_CLIENT_ID=
147+
NUXT_OAUTH_ORY_CLIENT_SECRET=
148+
NUXT_OAUTH_ORY_SDK_URL=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,12 @@ const providers = computed(() =>
254254
disabled: Boolean(user.value?.okta),
255255
icon: 'i-simple-icons-okta',
256256
},
257+
{
258+
label: user.value?.ory || 'Ory',
259+
to: '/auth/ory',
260+
disabled: Boolean(user.value?.ory),
261+
icon: 'i-custom-ory',
262+
},
257263
].map(p => ({
258264
...p,
259265
prefetch: false,

playground/assets/icons/ory.svg

Lines changed: 1 addition & 0 deletions
Loading

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ declare module '#auth-utils' {
4444
slack?: string
4545
heroku?: string
4646
okta?: string
47+
ory?: string
4748
}
4849

4950
interface UserSession {

playground/nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ export default defineNuxtConfig({
2727
webAuthn: true,
2828
atproto: true,
2929
},
30+
icon: {
31+
customCollections: [{
32+
prefix: 'custom',
33+
dir: './assets/icons',
34+
}],
35+
},
3036
})

playground/server/routes/auth/ory.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export default defineOAuthOryEventHandler({
2+
config: {},
3+
async onSuccess(event, { user }) {
4+
await setUserSession(event, {
5+
user: {
6+
id: user?.sub,
7+
email: user?.email,
8+
ory: user?.email,
9+
},
10+
loggedInAt: Date.now(),
11+
})
12+
13+
return sendRedirect(event, '/')
14+
},
15+
})

src/module.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,5 +477,16 @@ export default defineNuxtModule<ModuleOptions>({
477477
scope: [],
478478
redirectURL: '',
479479
})
480+
// Ory OAuth
481+
runtimeConfig.oauth.ory = defu(runtimeConfig.oauth.ory, {
482+
clientId: '',
483+
clientSecret: '',
484+
sdkURL: '',
485+
redirectURL: '',
486+
scope: [],
487+
authorizationURL: '',
488+
tokenURL: '',
489+
userURL: '',
490+
})
480491
},
481492
})

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

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import {
6+
getOAuthRedirectURL,
7+
handleAccessTokenErrorResponse,
8+
handleInvalidState,
9+
handleMissingConfiguration,
10+
handlePkceVerifier,
11+
handleState,
12+
requestAccessToken,
13+
} from '../utils'
14+
import { createError, useRuntimeConfig } from '#imports'
15+
import type { OAuthConfig } from '#auth-utils'
16+
17+
/**
18+
* Ory OAuth2
19+
* @see https://www.ory.sh/docs/oauth2-oidc/authorization-code-flow
20+
*/
21+
22+
export interface OAuthOryConfig {
23+
/**
24+
* Ory OAuth Client ID
25+
* @default process.env.NUXT_OAUTH_ORY_CLIENT_ID
26+
*/
27+
clientId?: string
28+
/**
29+
* Ory OAuth Client Secret
30+
* @default process.env.NUXT_OAUTH_ORY_CLIENT_SECRET
31+
*/
32+
clientSecret?: string
33+
/**
34+
* Ory OAuth SDK URL
35+
* @default "https://playground.projects.oryapis.com" || process.env.NUXT_OAUTH_ORY_SDK_URL
36+
*/
37+
sdkURL?: string
38+
/**
39+
* Ory OAuth Scope
40+
* @default ['openid', 'offline']
41+
* @see https://www.ory.sh/docs/oauth2-oidc/openid-connect-claims-scope-custom
42+
* @example ['openid', 'offline', 'email']
43+
*/
44+
scope?: string[] | string
45+
/**
46+
* Ory OAuth Authorization URL
47+
* @default '/oauth2/auth'
48+
*/
49+
authorizationURL?: string
50+
51+
/**
52+
* Ory OAuth Token URL
53+
* @default '/oauth2/token'
54+
*/
55+
tokenURL?: string
56+
57+
/**
58+
* Extra authorization parameters to provide to the authorization URL
59+
* @example { allow_signup: 'true' }
60+
*/
61+
authorizationParams?: Record<string, string>
62+
63+
/**
64+
* Ory OAuth Userinfo URL
65+
* @see https://www.ory.sh/docs/oauth2-oidc/userinfo-oidc
66+
* @default '/userinfo'
67+
*/
68+
userURL?: string
69+
}
70+
71+
export function defineOAuthOryEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthOryConfig>) {
72+
return eventHandler(async (event: H3Event) => {
73+
config = defu(config, useRuntimeConfig(event).oauth?.ory, {
74+
scope: ['openid', 'offline'],
75+
sdkURL: 'https://playground.projects.oryapis.com',
76+
authorizationURL: '/oauth2/auth',
77+
tokenURL: '/oauth2/token',
78+
userURL: '/userinfo',
79+
authorizationParams: {},
80+
}) as OAuthOryConfig
81+
82+
// TODO: improve typing
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
84+
const query = getQuery<{ code?: string, state?: string, error: any }>(event)
85+
86+
if (query.error) {
87+
const error = createError({
88+
statusCode: 401,
89+
message: `Ory login failed: ${query.error || 'Unknown error'}`,
90+
data: query,
91+
})
92+
if (!onError) throw error
93+
return onError(event, error)
94+
}
95+
96+
if (!config.clientId || !config.sdkURL) {
97+
return handleMissingConfiguration(event, 'ory', ['clientId', 'sdkURL'], onError)
98+
}
99+
100+
const redirectURL = getOAuthRedirectURL(event)
101+
102+
// guarantee uniqueness of the scope and convert to string if it's an array
103+
if (Array.isArray(config.scope)) {
104+
config.scope = Array.from(new Set(config.scope)).join(' ')
105+
}
106+
107+
// Create pkce verifier
108+
const verifier = await handlePkceVerifier(event)
109+
const state = await handleState(event)
110+
111+
if (!query.code) {
112+
const authorizationURL = `${config.sdkURL}${config.authorizationURL}`
113+
return sendRedirect(
114+
event,
115+
withQuery(authorizationURL, {
116+
client_id: config.clientId,
117+
response_type: 'code',
118+
redirect_uri: redirectURL,
119+
scope: config.scope,
120+
state,
121+
code_challenge: verifier.code_challenge,
122+
code_challenge_method: verifier.code_challenge_method,
123+
...config.authorizationParams,
124+
}),
125+
)
126+
}
127+
128+
if (query.state !== state) {
129+
handleInvalidState(event, 'ory', onError)
130+
}
131+
132+
const tokenURL = `${config.sdkURL}${config.tokenURL}`
133+
const tokens = await requestAccessToken(tokenURL, {
134+
body: {
135+
grant_type: 'authorization_code',
136+
client_id: config.clientId,
137+
code: query.code as string,
138+
redirect_uri: redirectURL,
139+
scope: config.scope,
140+
code_verifier: verifier.code_verifier,
141+
},
142+
})
143+
144+
if (tokens.error) {
145+
return handleAccessTokenErrorResponse(event, 'ory', tokens, onError)
146+
}
147+
148+
const tokenType = tokens.token_type
149+
const accessToken = tokens.access_token
150+
151+
const userURL = `${config.sdkURL}${config.userURL}`
152+
// TODO: improve typing
153+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
154+
const user: any = await $fetch(userURL, {
155+
headers: {
156+
'User-Agent': `Ory-${config.clientId}`,
157+
'Authorization': `${tokenType} ${accessToken}`,
158+
},
159+
}).catch((error) => {
160+
return { error }
161+
})
162+
if (user.error) {
163+
const error = createError({
164+
statusCode: 401,
165+
message: `Ory userinfo failed: ${user.error || 'Unknown error'}`,
166+
data: user,
167+
})
168+
if (!onError) throw error
169+
return onError(event, error)
170+
}
171+
172+
return onSuccess(event, {
173+
tokens,
174+
user,
175+
})
176+
})
177+
}

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' | 'okta' | (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' | 'ory' | (string & {})
66

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

0 commit comments

Comments
 (0)