diff --git a/README.md b/README.md index 988c6d42..832d13f7 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ It can also be set using environment variables: - Hubspot - Instagram - Keycloak +- Line - Linear - LinkedIn - Microsoft diff --git a/playground/.env.example b/playground/.env.example index a6281550..4ded4d65 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -100,4 +100,8 @@ NUXT_OAUTH_STRAVA_CLIENT_SECRET= # Hubspot NUXT_OAUTH_HUBSPOT_CLIENT_ID= NUXT_OAUTH_HUBSPOT_CLIENT_SECRET= -NUXT_OAUTH_HUBSPOT_REDIRECT_URL= \ No newline at end of file +NUXT_OAUTH_HUBSPOT_REDIRECT_URL= +# Line +NUXT_OAUTH_LINE_CLIENT_ID= +NUXT_OAUTH_LINE_CLIENT_SECRET= +NUXT_OAUTH_LINE_REDIRECT_URL= diff --git a/playground/app.vue b/playground/app.vue index aeed5703..87ff3326 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -33,6 +33,12 @@ const providers = computed(() => disabled: Boolean(user.value?.gitlab), icon: 'i-simple-icons-gitlab', }, + { + label: user.value?.line || 'Line', + to: '/auth/line', + disabled: Boolean(user.value?.line), + icon: 'i-simple-icons-line', + }, { label: user.value?.linear || 'Linear', to: '/auth/linear', diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 61a0bed1..43ff0c1a 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -13,6 +13,7 @@ declare module '#auth-utils' { discord?: string battledotnet?: string keycloak?: string + line?: string linear?: string linkedin?: string cognito?: string diff --git a/playground/server/routes/auth/line.get.ts b/playground/server/routes/auth/line.get.ts new file mode 100644 index 00000000..32515cc5 --- /dev/null +++ b/playground/server/routes/auth/line.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthLineEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + line: user.userId, + }, + loggedInAt: Date.now(), + }); + + return sendRedirect(event, '/'); + }, +}); diff --git a/src/module.ts b/src/module.ts index b769b701..aced7fe4 100644 --- a/src/module.ts +++ b/src/module.ts @@ -355,5 +355,11 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Line OAuth + runtimeConfig.oauth.line = defu(runtimeConfig.oauth.line, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/line.ts b/src/runtime/server/lib/oauth/line.ts new file mode 100644 index 00000000..db3a91f5 --- /dev/null +++ b/src/runtime/server/lib/oauth/line.ts @@ -0,0 +1,139 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect, createError } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthLineConfig { + /** + * Line OAuth Client ID + * @default process.env.NUXT_OAUTH_LINE_CLIENT_ID + */ + clientId?: string + + /** + * Line OAuth Client Secret + * @default process.env.NUXT_OAUTH_LINE_CLIENT_SECRET + */ + clientSecret?: string + + /** + * Line OAuth Scope + * @default ['profile', 'openid'] + * @see https://developers.line.biz/en/docs/line-login/integrate-line-login/ + */ + scope?: string[] + + /** + * Line OAuth Authorization URL + * @default 'https://access.line.me/oauth2/v2.1/authorize' + */ + authorizationURL?: string + + /** + * Line OAuth Token URL + * @default 'https://api.line.me/oauth2/v2.1/token' + */ + tokenURL?: string + + /** + * Line OAuth User Info URL + * @default 'https://api.line.me/v2/profile' + */ + userURL?: string + + /** + * Extra authorization parameters to provide to the authorization URL + * @example { bot_prompt: 'normal' } + */ + authorizationParams?: Record + + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_LINE_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +export function defineOAuthLineEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.line, { + authorizationURL: 'https://access.line.me/oauth2/v2.1/authorize', + tokenURL: 'https://api.line.me/oauth2/v2.1/token', + userURL: 'https://api.line.me/v2/profile', + authorizationParams: {}, + }) as OAuthLineConfig + + const query = getQuery<{ code?: string, error?: string, state?: string }>(event) + + if (query.error) { + return onError( + event, + new Error(`Line login failed: ${query.error || 'Unknown error'}`), + ) + } + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration(event, 'line', ['clientId', 'clientSecret'], onError) + } + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + if (!query.code) { + config.scope = config.scope || ['profile', 'openid'] + // Redirect to Line OAuth page + 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 || '', + ...config.authorizationParams, + }), + ) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + code: query.code as string, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'line', tokens, onError) + } + + const accessToken = tokens.access_token + const user = await $fetch(config.userURL as string, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!user) { + const error = createError({ + statusCode: 500, + message: 'Could not get Line user', + data: tokens, + }) + 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 188c03b3..cc0f9e47 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -1,6 +1,6 @@ import type { H3Event, H3Error } from 'h3' -export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) +export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void