diff --git a/README.md b/README.md index a604ba8..3e11b02 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ It can also be set using environment variables: - Google - Instagram - Keycloak +- Linear - LinkedIn - Microsoft - PayPal diff --git a/playground/.env.example b/playground/.env.example index deeb47e..a12b96a 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -73,4 +73,7 @@ NUXT_OAUTH_DROPBOX_CLIENT_ID= NUXT_OAUTH_DROPBOX_CLIENT_SECRET= # Polar NUXT_OAUTH_POLAR_CLIENT_ID= -NUXT_OAUTH_POLAR_CLIENT_SECRET= \ No newline at end of file +NUXT_OAUTH_POLAR_CLIENT_SECRET= +# Linear +NUXT_OAUTH_LINEAR_CLIENT_ID= +NUXT_OAUTH_LINEAR_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 79ad324..e6d7970 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?.linear || 'Linear', + to: '/auth/linear', + disabled: Boolean(user.value?.linear), + icon: 'i-simple-icons-linear', + }, { label: user.value?.linkedin || 'LinkedIn', to: '/auth/linkedin', diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 0a8f070..79ef4bc 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 + linear?: string linkedin?: string cognito?: string facebook?: string diff --git a/playground/server/routes/auth/linear.get.ts b/playground/server/routes/auth/linear.get.ts new file mode 100644 index 0000000..047b5c7 --- /dev/null +++ b/playground/server/routes/auth/linear.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthLinearEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + linear: user.email, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 4413647..b5103e6 100644 --- a/src/module.ts +++ b/src/module.ts @@ -214,6 +214,12 @@ export default defineNuxtModule({ realm: '', redirectURL: '', }) + // Linear OAuth + runtimeConfig.oauth.linear = defu(runtimeConfig.oauth.linear, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) // LinkedIn OAuth runtimeConfig.oauth.linkedin = defu(runtimeConfig.oauth.linkedin, { clientId: '', diff --git a/src/runtime/server/lib/oauth/linear.ts b/src/runtime/server/lib/oauth/linear.ts new file mode 100644 index 0000000..4e788a0 --- /dev/null +++ b/src/runtime/server/lib/oauth/linear.ts @@ -0,0 +1,136 @@ +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 OAuthLinearConfig { + /** + * Linear OAuth Client ID + * @default process.env.NUXT_OAUTH_LINEAR_CLIENT_ID + */ + clientId?: string + /** + * Linear OAuth Client Secret + * @default process.env.NUXT_OAUTH_LINEAR_CLIENT_SECRET + */ + clientSecret?: string + /** + * Linear OAuth Scope + * @default ['read'] + * @see https://developers.linear.app/docs/oauth/authentication#scopes + * @example ['read', 'write', 'issues:create', 'comments:create', 'timeSchedule:write', 'admin'] + */ + scope?: string[] + /** + * Linear OAuth Authorization URL + * @default 'https://linear.app/oauth/authorize' + */ + authorizationURL?: string + /** + * Linear OAuth Token URL + * @default 'https://api.linear.app/oauth/token' + */ + tokenURL?: string + /** + * Extra authorization parameters to provide to the authorization URL + * @see https://developers.linear.app/docs/oauth/authentication#id-2.-redirect-user-access-requests-to-linear + */ + authorizationParams?: Record + /** + * Redirect URL to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_LINEAR_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +export function defineOAuthLinearEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.linear, { + authorizationURL: 'https://linear.app/oauth/authorize', + tokenURL: 'https://api.linear.app/oauth/token', + authorizationParams: {}, + }) as OAuthLinearConfig + + const query = getQuery<{ code?: string, error?: string }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `Linear login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration(event, 'linear', ['clientId', 'clientSecret'], onError) + } + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) { + config.scope = config.scope || ['read'] + // Redirect to Linear OAuth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope.join(' '), + ...config.authorizationParams, + }), + ) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + code: query.code, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'linear', tokens, onError) + } + + const accessToken = tokens.access_token + // TODO: improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = await $fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: '{ viewer { id name email } }', + }), + }) + + if (!user.data || !user.data.viewer) { + const error = createError({ + statusCode: 500, + message: 'Could not get Linear user', + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { + tokens, + user: user.data.viewer, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 3c748e0..e5abff9 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' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'x' | 'xsuaa' | 'yandex' | (string & {}) +export type OAuthProvider = 'auth0' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'x' | 'xsuaa' | 'yandex' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void