diff --git a/README.md b/README.md index 862f0133..eef35794 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ It can also be set using environment variables: - Discord - Dropbox - Facebook +- FortyTwo (42) - GitHub - GitLab - Gitea diff --git a/playground/.env.example b/playground/.env.example index d2fbea9a..c231a479 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= +#FortyTwo +NUXT_OAUTH_FORTYTWO_CLIENT_ID= +NUXT_OAUTH_FORTYTWO_CLIENT_SECRET= +NUXT_OAUTH_FORTYTWO_REDIRECT_URL= diff --git a/playground/app.vue b/playground/app.vue index 369b90f7..f6056ffa 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?.fortytwo || 'FortyTwo', + to: '/auth/fortytwo', + disabled: Boolean(user.value?.fortytwo), + icon: 'i-simple-icons-42', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 61d718fb..e1b5cca2 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 + fortytwo?: string } interface UserSession { diff --git a/playground/server/routes/auth/fortytwo.get.ts b/playground/server/routes/auth/fortytwo.get.ts new file mode 100644 index 00000000..57cc453a --- /dev/null +++ b/playground/server/routes/auth/fortytwo.get.ts @@ -0,0 +1,11 @@ +export default defineOAuthFortyTwoEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + fortytwo: user.login, + }, + loggedInAt: Date.now(), + }) + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 99dbd418..188bf50b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -468,5 +468,11 @@ export default defineNuxtModule({ redirectURL: '', scope: '', }) + // FortyTwo OAuth + runtimeConfig.oauth.fortytwo = defu(runtimeConfig.oauth.fortytwo, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/fortytwo.ts b/src/runtime/server/lib/oauth/fortytwo.ts new file mode 100644 index 00000000..9f07b8a3 --- /dev/null +++ b/src/runtime/server/lib/oauth/fortytwo.ts @@ -0,0 +1,147 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthFortyTwoConfig { + /** + * FortyTwo OAuth Client ID. + * Defaults to `process.env.NUXT_OAUTH_FORTYTWO_CLIENT_ID`. + */ + clientId?: string + /** + * FortyTwo OAuth Client Secret. + * Defaults to `process.env.NUXT_OAUTH_FORTYTWO_CLIENT_SECRET`. + */ + clientSecret?: string + /** + * Scopes requested from the FortyTwo API. + * @default ['public'] + * @see https://api.intra.42.fr/apidoc/oauth#scopes + * @example ['public', 'profile'] + */ + scope?: string[] + /** + * The authorization URL for FortyTwo OAuth. + * @default 'https://api.intra.42.fr/oauth/authorize' + */ + authorizationURL?: string + /** + * The token exchange URL for FortyTwo OAuth. + * @default 'https://api.intra.42.fr/oauth/token' + */ + tokenURL?: string + /** + * The base URL for the FortyTwo API (used to fetch user data). + * @default 'https://api.intra.42.fr/v2' + */ + apiURL?: string + /** + * Override the automatically determined redirect URL for the OAuth callback. + * Useful in specific deployment environments where the public hostname might not be correctly inferred. + * Defaults to `process.env.NUXT_OAUTH_FORTY_TWO_REDIRECT_URL` or derived from the request. + */ + redirectURL?: string + /** + * Optional static `state` value to include in the OAuth flow for CSRF protection. + */ + state?: string +} + +// ULTRA-generic user interface. +// This is the minimum to avoid type errors on, for example, `user.id`. +interface FortyTwoUser { + id: number + login: string + email: string + + // To prevent TypeScript from complaining if you access other fields + // that the 42 API returns, but which you don't want to type here: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any +} + +interface FortyTwoTokens { + access_token: string + token_type: string + expires_in: number + refresh_token: string + scope: string // Space-separated string of granted scopes + created_at: number + secret_valid_until: number +} + +export function defineOAuthFortyTwoEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.fortytwo, { + authorizationURL: 'https://api.intra.42.fr/oauth/authorize', + tokenURL: 'https://api.intra.42.fr/oauth/token', + apiURL: 'https://api.intra.42.fr/v2', + scope: ['public'], + }) as OAuthFortyTwoConfig + + const query = getQuery<{ code?: string, error?: string, state?: string }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `FortyTwo login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.clientSecret) + return handleMissingConfiguration(event, 'fortytwo', ['clientId', 'clientSecret'], onError) + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope?.join(' '), + state: query.state || '', + }), + ) + + if (query.state !== config.state) { + const error = createError({ + statusCode: 403, + message: 'Invalid state parameter for FortyTwo OAuth. Possible CSRF attack.', + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + code: query.code, + state: query.state, + }, + }) + + if (tokens.error) + return handleAccessTokenErrorResponse(event, 'fortytwo', tokens, onError) + + const user: FortyTwoUser = await $fetch(`${config.apiURL}/me`, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }) + + return onSuccess(event, { user, tokens }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index ca7d962d..c44b9ab6 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' | 'fortytwo' | '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 OnError = (event: H3Event, error: H3Error) => Promise | void