Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add generic oidc provider #25

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ It can also be set using environment variables:
- Microsoft
- Spotify
- Twitch
- OpenID Connect

You can add your favorite provider by creating a new file in [src/runtime/server/lib/oauth/](./src/runtime/server/lib/oauth/).

Expand Down
1 change: 1 addition & 0 deletions playground/server/routes/auth/auth0.get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export default oauth.auth0EventHandler({
config: {
emailRequired: true,
checks: ['state']
},
async onSuccess(event, { user }) {
await setUserSession(event, {
Expand Down
10 changes: 10 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ export default defineNuxtModule<ModuleOptions>({
sameSite: 'lax'
}
})
// Security settings
runtimeConfig.nuxtAuthUtils = defu(runtimeConfig.nuxtAuthUtils, {})
runtimeConfig.nuxtAuthUtils.security = defu(runtimeConfig.nuxtAuthUtils.security, {
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 15
}
})
// OAuth settings
runtimeConfig.oauth = defu(runtimeConfig.oauth, {})
// GitHub OAuth
Expand Down
27 changes: 25 additions & 2 deletions src/runtime/server/lib/oauth/auth0.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { H3Event } from 'h3'
import type { H3Event, H3Error } from 'h3'
import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from 'h3'
import { withQuery, parsePath } from 'ufo'
import { ofetch } from 'ofetch'
import { defu } from 'defu'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'
import { type OAuthChecks, checks } from '../../utils/security'

export interface OAuthAuth0Config {
/**
Expand All @@ -24,7 +25,7 @@ export interface OAuthAuth0Config {
domain?: string
/**
* Auth0 OAuth Audience
* @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE
* @default ''
*/
audience?: string
/**
Expand All @@ -45,6 +46,13 @@ export interface OAuthAuth0Config {
* @see https://auth0.com/docs/authenticate/login/max-age-reauthentication
*/
maxAge?: number
/**
* checks
* @default []
* @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce
* @see https://auth0.com/docs/protocols/oauth2/oauth-state
*/
checks?: OAuthChecks[]
/**
* Login connection. If no connection is specified, it will redirect to the standard Auth0 login page and show the Login Widget.
* @default ''
Expand Down Expand Up @@ -73,6 +81,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA

const redirectUrl = getRequestURL(event).href
if (!code) {
const authParam = await checks.create(event, config.checks) // Initialize checks
config.scope = config.scope || ['openid', 'offline_access']
if (config.emailRequired && !config.scope.includes('email')) {
config.scope.push('email')
Expand All @@ -87,11 +96,24 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA
scope: config.scope.join(' '),
audience: config.audience || '',
max_age: config.maxAge || 0,
<<<<<<< HEAD
...authParam
=======
connection: config.connection || ''
>>>>>>> main

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a remainder of a merge conflict here

})
)
}

// Verify checks
let checkResult
try {
checkResult = await checks.use(event, config.checks)
} catch (error) {
if (!onError) throw error
return onError(event, error as H3Error)
}

const tokens: any = await ofetch(
tokenURL as string,
{
Expand All @@ -105,6 +127,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA
client_secret: config.clientSecret,
redirect_uri: parsePath(redirectUrl).pathname,
code,
...checkResult
}
}
).catch(error => {
Expand Down
172 changes: 172 additions & 0 deletions src/runtime/server/lib/oauth/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { H3Event, H3Error } from 'h3'
import { eventHandler, createError, getQuery, sendRedirect } from 'h3'
import { withQuery } from 'ufo'
import { ofetch } from 'ofetch'
import { defu } from 'defu'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'
import { type OAuthChecks, checks } from '../../utils/security'
import { validateConfig } from '../../utils/config'

export interface OAuthOidcConfig {
/**
* OIDC Client ID
* @default process.env.NUXT_OAUTH_OIDC_CLIENT_ID
*/
clientId?: string
/**
* OIDC Client Secret
* @default process.env.NUXT_OAUTH_OIDC_CLIENT_SECRET
*/
clientSecret?: string
/**
* OIDC Response Type
* @default process.env.NUXT_OAUTH_OIDC_RESPONSE_TYPE
*/
responseType?: string
/**
* OIDC Authorization Endpoint URL
* @default process.env.NUXT_OAUTH_OIDC_AUTHORIZATION_URL
*/
authorizationUrl?: string
/**
* OIDC Token Endpoint URL
* @default process.env.NUXT_OAUTH_OIDC_TOKEN_URL
*/
tokenUrl?: string
/**
* OIDC Userino Endpoint URL
* @default process.env.NUXT_OAUTH_OIDC_USERINFO_URL
*/
userinfoUrl?: string
/**
* OIDC Redirect URI
* @default process.env.NUXT_OAUTH_OIDC_USERINFO_URL
*/
redirectUri?: string
/**
* OIDC Code challenge method
* @default process.env.NUXT_OAUTH_OIDC_CODE_CHALLENGE_METHOD
*/
codeChallengeMethod?: string
/**
* OIDC Grant Type
* @default process.env.NUXT_OAUTH_OIDC_GRANT_TYPE
*/
grantType?: string
/**
* OIDC Claims
* @default process.env.NUXT_OAUTH_OIDC_AUDIENCE
*/
audience?: string
/**
* OIDC Claims
* @default {}
*/
claims?: {}
/**
* OIDC Scope
* @default []
* @example ['openid']
*/
scope?: string[]
/**
* A list of checks to add to the OIDC Flow (eg. 'state' or 'pkce')
* @default []
* @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce
* @see https://auth0.com/docs/protocols/oauth2/oauth-state
*/
checks?: OAuthChecks[]
}

export function oidcEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthOidcConfig>) {
return eventHandler(async (event: H3Event) => {
// @ts-ignore
config = defu(config, useRuntimeConfig(event).oauth?.oidc) as OAuthOidcConfig
const { code } = getQuery(event)

const validationResult = validateConfig(config, ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userinfoUrl', 'redirectUri', 'responseType'])

if (!validationResult.valid && validationResult.error) {
if (!onError) throw validationResult.error
return onError(event, validationResult.error)
}

if (!code) {
const authParams = await checks.create(event, config.checks) // Initialize checks
// Redirect to OIDC login page
return sendRedirect(
event,
withQuery(config.authorizationUrl as string, {
response_type: config.responseType,
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config?.scope?.join(' ') || 'openid',
claims: config?.claims || {},
grant_type: config.grantType || 'authorization_code',
audience: config.audience || null,
...authParams
})
)
}

// Verify checks
let checkResult
try {
checkResult = await checks.use(event, config.checks)
} catch (error) {
if (!onError) throw error
return onError(event, error as H3Error)
}

// @ts-ignore
const queryString = new URLSearchParams({
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: config.redirectUri,
response_type: config.responseType,
grant_type: config.grantType || 'authorization_code',
...checkResult
})

// Request tokens.
const tokens: any = await ofetch(
config.tokenUrl as string,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: queryString.toString(),
}
).catch(error => {
return { error }
})
if (tokens.error) {
const error = createError({
statusCode: 401,
message: `OIDC login failed: ${tokens.error?.data?.error_description || 'Unknown error'}`,
data: tokens
})
if (!onError) throw error
return onError(event, error)
}

const tokenType = tokens.token_type
const accessToken = tokens.access_token
const userInfoUrl = config.userinfoUrl || ''

// Request userinfo.
const user: any = await ofetch(userInfoUrl, {
headers: {
Authorization: `${tokenType} ${accessToken}`
}
})

return onSuccess(event, {
tokens,
user
})
})
}
27 changes: 27 additions & 0 deletions src/runtime/server/utils/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { H3Error } from 'h3'

export type configValidationResult = {
valid: boolean,
error?: H3Error
}

export function validateConfig(config: any, requiredKeys: string[]): configValidationResult {
const missingKeys: string[] = []
requiredKeys.forEach(key => {
if (!config[key]) {
missingKeys.push(key)
}
})
if (missingKeys.length) {
const error = createError({
statusCode: 500,
message: `Missing config keys: ${missingKeys.join(', ')}. Please pass the required parameters either as env variables or as part of the config parameter.`
})

return {
valid: false,
error
}
}
return { valid: true }
}
2 changes: 2 additions & 0 deletions src/runtime/server/utils/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { discordEventHandler } from '../lib/oauth/discord'
import { battledotnetEventHandler } from '../lib/oauth/battledotnet'
import { keycloakEventHandler } from '../lib/oauth/keycloak'
import { linkedinEventHandler } from '../lib/oauth/linkedin'
import { oidcEventHandler } from '../lib/oauth/oidc'
import { cognitoEventHandler } from '../lib/oauth/cognito'

export const oauth = {
Expand All @@ -21,5 +22,6 @@ export const oauth = {
battledotnetEventHandler,
keycloakEventHandler,
linkedinEventHandler,
oidcEventHandler,
cognitoEventHandler
}
Loading