Skip to content

Commit c5e8029

Browse files
committed
feat: add OIDC provider
1 parent c6198b8 commit c5e8029

File tree

7 files changed

+340
-3
lines changed

7 files changed

+340
-3
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ It can also be set using environment variables:
236236
- LinkedIn
237237
- LiveChat
238238
- Microsoft
239+
- OIDC / OpenID Connect (Generic)
239240
- Okta
240241
- Ory
241242
- PayPal
@@ -408,7 +409,7 @@ export default defineWebAuthnRegisterEventHandler({
408409
// If he registers a new account with credentials
409410
return z.object({
410411
// we want the userName to be a valid email
411-
userName: z.string().email()
412+
userName: z.string().email()
412413
}).parse(userBody)
413414
},
414415
async onSuccess(event, { credential, user }) {

playground/app/pages/index.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,11 @@ const providers = computed(() =>
275275
disabled: Boolean(user.value?.ory),
276276
icon: 'i-custom-ory',
277277
},
278+
{
279+
label: user.value?.oidc || 'OIDC',
280+
to: '/auth/oidc',
281+
disabled: Boolean(user.value?.oidc),
282+
},
278283
].map(p => ({
279284
...p,
280285
prefetch: false,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export default defineOAuthOidcEventHandler({
2+
config: {
3+
scope: ['openid', 'profile', 'email'],
4+
},
5+
async onSuccess(event, { user }) {
6+
await setUserSession(event, {
7+
user: {
8+
oidc: user.name,
9+
},
10+
loggedInAt: Date.now(),
11+
})
12+
13+
return sendRedirect(event, '/')
14+
},
15+
})

playground/shared/types/auth.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ declare module '#auth-utils' {
4747
roblox?: string
4848
okta?: string
4949
ory?: string
50+
oidc?: string
5051
}
5152

5253
interface UserSession {
@@ -63,4 +64,4 @@ declare module '#auth-utils' {
6364
}
6465
}
6566

66-
export {}
67+
export { }

src/module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,5 +521,13 @@ export default defineNuxtModule<ModuleOptions>({
521521
tokenURL: '',
522522
userURL: '',
523523
})
524+
// OIDC OAuth
525+
runtimeConfig.oauth.oidc = defu(runtimeConfig.oauth.oidc, {
526+
clientId: '',
527+
clientSecret: '',
528+
configUrl: '',
529+
redirectUrl: '',
530+
scope: [],
531+
})
524532
},
525533
})
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import type { OAuthConfig } from '#auth-utils'
2+
import { useRuntimeConfig } from '#imports'
3+
import { defu } from 'defu'
4+
import type { H3Event } from 'h3'
5+
import { createError, eventHandler, getQuery, sendRedirect } from 'h3'
6+
import { withQuery } from 'ufo'
7+
import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleInvalidState, handleMissingConfiguration, handlePkceVerifier, handleState, requestAccessToken } from '../utils'
8+
9+
export interface OAuthOidcConfig {
10+
/**
11+
* OAuth Client ID
12+
*
13+
* @default process.env.NUXT_OAUTH_OIDC_CLIENT_ID
14+
*/
15+
clientId?: string
16+
/**
17+
* OAuth Client Secret
18+
*
19+
* @default process.env.NUXT_OAUTH_OIDC_CLIENT_SECRET
20+
*/
21+
clientSecret?: string
22+
/**
23+
* URL to the OpenID Configuration endpoint. Used to fetch the endpoint URLs from.
24+
*
25+
* @default process.env.NUXT_OAUTH_OIDC_CONFIG_URL
26+
* @example "https://my-provider.com/nidp/oauth/nam/.well-known/openid-configuration"
27+
*/
28+
configUrl?: string
29+
/**
30+
* OAuth Scope
31+
*
32+
* @default ['openid']
33+
* @example ['openid', 'profile', 'email']
34+
*/
35+
scope?: string[]
36+
/**
37+
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
38+
*
39+
* @default process.env.NUXT_OAUTH_OIDC_REDIRECT_URL
40+
*/
41+
redirectURL?: string
42+
/**
43+
* Whether to use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636).
44+
*
45+
* @default true
46+
*/
47+
usePKCE?: boolean
48+
}
49+
50+
/**
51+
* Standard OIDC claims.
52+
*
53+
* @see: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
54+
*/
55+
interface OidcUser {
56+
/**
57+
* Subject - Identifier for the End-User at the Issuer.
58+
*/
59+
sub: string
60+
61+
/**
62+
* End-User's full name in displayable form including all name parts,
63+
* possibly including titles and suffixes, ordered according to the
64+
* End-User's locale and preferences.
65+
*/
66+
name?: string
67+
68+
/**
69+
* Given name(s) or first name(s) of the End-User. Note that in some cultures,
70+
* people can have multiple given names; all can be present, with the names
71+
* being separated by space characters.
72+
*/
73+
given_name?: string
74+
75+
/**
76+
* Surname(s) or last name(s) of the End-User. Note that in some cultures,
77+
* people can have multiple family names or no family name; all can be present,
78+
* with the names being separated by space characters.
79+
*/
80+
family_name?: string
81+
82+
/**
83+
* Middle name(s) of the End-User. Note that in some cultures, people can have
84+
* multiple middle names; all can be present, with the names being separated by
85+
* space characters. Also note that in some cultures, middle names are not used.
86+
*/
87+
middle_name?: string
88+
89+
/**
90+
* Casual name of the End-User that may or may not be the same as the given_name.
91+
* For instance, a nickname value of Mike might be returned alongside a given_name
92+
* value of Michael.
93+
*/
94+
nickname?: string
95+
96+
/**
97+
* Shorthand name by which the End-User wishes to be referred to at the RP, such as
98+
* janedoe or j.doe. This value MAY be any valid JSON string including special
99+
* characters such as @, /, or whitespace. The RP MUST NOT rely upon this value
100+
* being unique.
101+
*/
102+
preferred_username?: string
103+
104+
/**
105+
* URL of the End-User's profile page. The contents of this Web page SHOULD be
106+
* about the End-User.
107+
*/
108+
profile?: string
109+
110+
/**
111+
* URL of the End-User's profile picture. This URL MUST refer to an image file
112+
* (for example, a PNG, JPEG, or GIF image file), rather than to a Web page
113+
* containing an image. Note that this URL SHOULD specifically reference a profile
114+
* photo of the End-User suitable for displaying when describing the End-User,
115+
* rather than an arbitrary photo taken by the End-User.
116+
*/
117+
picture?: string
118+
119+
/**
120+
* URL of the End-User's Web page or blog. This Web page SHOULD contain information
121+
* published by the End-User or an organization that the End-User is affiliated with.
122+
*/
123+
website?: string
124+
125+
/**
126+
* End-User's preferred e-mail address. Its value MUST conform to the RFC 5322
127+
* addr-spec syntax. The RP MUST NOT rely upon this value being unique.
128+
*/
129+
email?: string
130+
131+
/**
132+
* True if the End-User's e-mail address has been verified; otherwise false.
133+
* When this Claim Value is true, this means that the OP took affirmative steps
134+
* to ensure that this e-mail address was controlled by the End-User at the time
135+
* the verification was performed. The means by which an e-mail address is verified
136+
* is context specific, and dependent upon the trust framework or contractual
137+
* agreements within which the parties are operating.
138+
*/
139+
email_verified?: boolean
140+
141+
/**
142+
* End-User's gender. Values defined by this specification are female and male.
143+
* Other values MAY be used when neither of the defined values are applicable.
144+
*/
145+
gender?: string
146+
147+
/**
148+
* End-User's birthday, represented as an ISO 8601-1 YYYY-MM-DD format. The year
149+
* MAY be 0000, indicating that it is omitted. To represent only the year, YYYY
150+
* format is allowed. Note that depending on the underlying platform's date related
151+
* function, providing just year can result in varying month and day, so the
152+
* implementers need to take this factor into account to correctly process the dates.
153+
*/
154+
birthdate?: string
155+
156+
/**
157+
* String from IANA Time Zone Database representing the End-User's time zone.
158+
* For example, Europe/Paris or America/Los_Angeles.
159+
*/
160+
zoneinfo?: string
161+
162+
/**
163+
* End-User's locale, represented as a BCP47 language tag. This is typically an
164+
* ISO 639 Alpha-2 language code in lowercase and an ISO 3166-1 Alpha-2 country
165+
* code in uppercase, separated by a dash. For example, en-US or fr-CA. As a
166+
* compatibility note, some implementations have used an underscore as the separator
167+
* rather than a dash, for example, en_US; Relying Parties MAY choose to accept
168+
* this locale syntax as well.
169+
*/
170+
locale?: string
171+
172+
/**
173+
* End-User's preferred telephone number. E.164 is RECOMMENDED as the format of
174+
* this Claim, for example, +1 (555) 555-5555 or +56 (2) 687 2400. If the phone
175+
* number contains an extension, it is RECOMMENDED that the extension be represented
176+
* using the RFC 3966 extension syntax, for example, +1 (555) 555-5555;ext=5678.
177+
*/
178+
phone_number?: string
179+
180+
/**
181+
* True if the End-User's phone number has been verified; otherwise false. When
182+
* this Claim Value is true, this means that the OP took affirmative steps to
183+
* ensure that this phone number was controlled by the End-User at the time the
184+
* verification was performed. The means by which a phone number is verified is
185+
* context specific, and dependent upon the trust framework or contractual
186+
* agreements within which the parties are operating. When true, the phone_number
187+
* Claim MUST be in E.164 format and any extensions MUST be represented in RFC 3966 format.
188+
*/
189+
phone_number_verified?: boolean
190+
191+
/**
192+
* End-User's preferred postal address.
193+
*/
194+
address?: AddressClaim
195+
196+
/**
197+
* Time the End-User's information was last updated. Its value is a JSON number
198+
* representing the number of seconds from 1970-01-01T00:00:00Z as measured in
199+
* UTC until the date/time.
200+
*/
201+
updated_at?: number
202+
}
203+
204+
/**
205+
* Address claim structure as defined in OpenID Connect specification
206+
*/
207+
export interface AddressClaim {
208+
/** Full mailing address, formatted for display or use on a mailing label */
209+
formatted?: string
210+
/** Full street address component, which may include house number, street name, post office box, and multi-line extended street address information */
211+
street_address?: string
212+
/** City or locality component */
213+
locality?: string
214+
/** State, province, prefecture, or region component */
215+
region?: string
216+
/** Zip code or postal code component */
217+
postal_code?: string
218+
/** Country name component */
219+
country?: string
220+
}
221+
222+
interface OidcTokens {
223+
access_token: string
224+
scope: string
225+
token_type: string
226+
}
227+
228+
/**
229+
* Event handler for generic OAuth using OIDC and PKCE.
230+
*/
231+
export function defineOAuthOidcEventHandler<TUser = OidcUser>({ config, onSuccess, onError }: OAuthConfig<OAuthOidcConfig, { user: TUser, tokens: OidcTokens }>) {
232+
return eventHandler(async (event: H3Event) => {
233+
config = defu(config, useRuntimeConfig(event).oauth?.oidc, {
234+
scope: ['openid'],
235+
usePKCE: true,
236+
} satisfies OAuthOidcConfig)
237+
238+
const query = getQuery<{ code?: string, error?: string, state?: string }>(event)
239+
240+
if (query.error) {
241+
const error = createError({
242+
statusCode: 401,
243+
message: `OIDC login failed: ${query.error || 'Unknown error'}`,
244+
data: query,
245+
})
246+
if (!onError) throw error
247+
return onError(event, error)
248+
}
249+
250+
if (!config.clientId || !config.clientSecret || !config.configUrl) {
251+
return handleMissingConfiguration(event, 'oidc', ['clientId', 'clientSecret', 'configUrl'], onError)
252+
}
253+
254+
const oidcConfig = await $fetch<{ authorization_endpoint: string, token_endpoint: string, userinfo_endpoint: string }>(config.configUrl)
255+
256+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
257+
const state = await handleState(event)
258+
const verifier = config.usePKCE ? await handlePkceVerifier(event) : undefined
259+
260+
if (!query.code) {
261+
config.scope = config.scope || []
262+
263+
return sendRedirect(
264+
event,
265+
withQuery(oidcConfig.authorization_endpoint, {
266+
client_id: config.clientId,
267+
redirect_uri: redirectURL,
268+
scope: config.scope.join(' '),
269+
state,
270+
response_type: 'code',
271+
code_challenge: verifier?.code_challenge,
272+
code_challenge_method: verifier?.code_challenge_method,
273+
}),
274+
)
275+
}
276+
277+
if (query.state !== state) {
278+
return handleInvalidState(event, 'oidc', onError)
279+
}
280+
281+
const tokens = await requestAccessToken<OidcTokens & { error?: unknown }>(oidcConfig.token_endpoint, {
282+
body: {
283+
grant_type: 'authorization_code',
284+
client_id: config.clientId,
285+
client_secret: config.clientSecret,
286+
redirect_uri: redirectURL,
287+
code: query.code,
288+
code_verifier: verifier?.code_verifier,
289+
},
290+
})
291+
292+
if (tokens.error) {
293+
return handleAccessTokenErrorResponse(event, 'oidc', tokens, onError)
294+
}
295+
296+
const user = await $fetch<TUser>(oidcConfig.userinfo_endpoint, {
297+
headers: {
298+
Authorization: `${tokens.token_type} ${tokens.access_token}`,
299+
},
300+
})
301+
302+
return onSuccess(event, {
303+
user,
304+
tokens,
305+
})
306+
})
307+
}

src/runtime/server/lib/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface RequestAccessTokenOptions {
4242
*/
4343
// TODO: waiting for https://github.com/atinux/nuxt-auth-utils/pull/140
4444
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45-
export async function requestAccessToken(url: string, options: RequestAccessTokenOptions): Promise<any> {
45+
export async function requestAccessToken<T = any>(url: string, options: RequestAccessTokenOptions): Promise<T> {
4646
const headers = {
4747
'Content-Type': 'application/x-www-form-urlencoded',
4848
...options.headers,

0 commit comments

Comments
 (0)