Skip to content

Commit c1291cd

Browse files
blqkeatinux
andauthored
feat: add linear provider
* feat: add linear provider * chore: remove duplicate note * chore: use email --------- Co-authored-by: Sébastien Chopin <[email protected]>
1 parent 2719753 commit c1291cd

File tree

8 files changed

+167
-2
lines changed

8 files changed

+167
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ It can also be set using environment variables:
215215
- Google
216216
- Instagram
217217
- Keycloak
218+
- Linear
218219
- LinkedIn
219220
- Microsoft
220221
- PayPal

playground/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,7 @@ NUXT_OAUTH_DROPBOX_CLIENT_ID=
7373
NUXT_OAUTH_DROPBOX_CLIENT_SECRET=
7474
# Polar
7575
NUXT_OAUTH_POLAR_CLIENT_ID=
76-
NUXT_OAUTH_POLAR_CLIENT_SECRET=
76+
NUXT_OAUTH_POLAR_CLIENT_SECRET=
77+
# Linear
78+
NUXT_OAUTH_LINEAR_CLIENT_ID=
79+
NUXT_OAUTH_LINEAR_CLIENT_SECRET=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ const providers = computed(() =>
3333
disabled: Boolean(user.value?.gitlab),
3434
icon: 'i-simple-icons-gitlab',
3535
},
36+
{
37+
label: user.value?.linear || 'Linear',
38+
to: '/auth/linear',
39+
disabled: Boolean(user.value?.linear),
40+
icon: 'i-simple-icons-linear',
41+
},
3642
{
3743
label: user.value?.linkedin || 'LinkedIn',
3844
to: '/auth/linkedin',

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ declare module '#auth-utils' {
1313
discord?: string
1414
battledotnet?: string
1515
keycloak?: string
16+
linear?: string
1617
linkedin?: string
1718
cognito?: string
1819
facebook?: string
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineOAuthLinearEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
linear: user.email,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

src/module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ export default defineNuxtModule<ModuleOptions>({
214214
realm: '',
215215
redirectURL: '',
216216
})
217+
// Linear OAuth
218+
runtimeConfig.oauth.linear = defu(runtimeConfig.oauth.linear, {
219+
clientId: '',
220+
clientSecret: '',
221+
redirectURL: '',
222+
})
217223
// LinkedIn OAuth
218224
runtimeConfig.oauth.linkedin = defu(runtimeConfig.oauth.linkedin, {
219225
clientId: '',
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
6+
import { useRuntimeConfig, createError } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
export interface OAuthLinearConfig {
10+
/**
11+
* Linear OAuth Client ID
12+
* @default process.env.NUXT_OAUTH_LINEAR_CLIENT_ID
13+
*/
14+
clientId?: string
15+
/**
16+
* Linear OAuth Client Secret
17+
* @default process.env.NUXT_OAUTH_LINEAR_CLIENT_SECRET
18+
*/
19+
clientSecret?: string
20+
/**
21+
* Linear OAuth Scope
22+
* @default ['read']
23+
* @see https://developers.linear.app/docs/oauth/authentication#scopes
24+
* @example ['read', 'write', 'issues:create', 'comments:create', 'timeSchedule:write', 'admin']
25+
*/
26+
scope?: string[]
27+
/**
28+
* Linear OAuth Authorization URL
29+
* @default 'https://linear.app/oauth/authorize'
30+
*/
31+
authorizationURL?: string
32+
/**
33+
* Linear OAuth Token URL
34+
* @default 'https://api.linear.app/oauth/token'
35+
*/
36+
tokenURL?: string
37+
/**
38+
* Extra authorization parameters to provide to the authorization URL
39+
* @see https://developers.linear.app/docs/oauth/authentication#id-2.-redirect-user-access-requests-to-linear
40+
*/
41+
authorizationParams?: Record<string, string>
42+
/**
43+
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
44+
* @default process.env.NUXT_OAUTH_LINEAR_REDIRECT_URL or current URL
45+
*/
46+
redirectURL?: string
47+
}
48+
49+
export function defineOAuthLinearEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthLinearConfig>) {
50+
return eventHandler(async (event: H3Event) => {
51+
config = defu(config, useRuntimeConfig(event).oauth?.linear, {
52+
authorizationURL: 'https://linear.app/oauth/authorize',
53+
tokenURL: 'https://api.linear.app/oauth/token',
54+
authorizationParams: {},
55+
}) as OAuthLinearConfig
56+
57+
const query = getQuery<{ code?: string, error?: string }>(event)
58+
59+
if (query.error) {
60+
const error = createError({
61+
statusCode: 401,
62+
message: `Linear login failed: ${query.error || 'Unknown error'}`,
63+
data: query,
64+
})
65+
if (!onError) throw error
66+
return onError(event, error)
67+
}
68+
69+
if (!config.clientId || !config.clientSecret) {
70+
return handleMissingConfiguration(event, 'linear', ['clientId', 'clientSecret'], onError)
71+
}
72+
73+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
74+
75+
if (!query.code) {
76+
config.scope = config.scope || ['read']
77+
// Redirect to Linear OAuth page
78+
return sendRedirect(
79+
event,
80+
withQuery(config.authorizationURL as string, {
81+
response_type: 'code',
82+
client_id: config.clientId,
83+
redirect_uri: redirectURL,
84+
scope: config.scope.join(' '),
85+
...config.authorizationParams,
86+
}),
87+
)
88+
}
89+
90+
const tokens = await requestAccessToken(config.tokenURL as string, {
91+
headers: {
92+
'Content-Type': 'application/x-www-form-urlencoded',
93+
},
94+
body: {
95+
grant_type: 'authorization_code',
96+
client_id: config.clientId,
97+
client_secret: config.clientSecret,
98+
redirect_uri: redirectURL,
99+
code: query.code,
100+
},
101+
})
102+
103+
if (tokens.error) {
104+
return handleAccessTokenErrorResponse(event, 'linear', tokens, onError)
105+
}
106+
107+
const accessToken = tokens.access_token
108+
// TODO: improve typing
109+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
110+
const user: any = await $fetch('https://api.linear.app/graphql', {
111+
method: 'POST',
112+
headers: {
113+
'Authorization': `Bearer ${accessToken}`,
114+
'Content-Type': 'application/json',
115+
},
116+
body: JSON.stringify({
117+
query: '{ viewer { id name email } }',
118+
}),
119+
})
120+
121+
if (!user.data || !user.data.viewer) {
122+
const error = createError({
123+
statusCode: 500,
124+
message: 'Could not get Linear user',
125+
data: tokens,
126+
})
127+
if (!onError) throw error
128+
return onError(event, error)
129+
}
130+
131+
return onSuccess(event, {
132+
tokens,
133+
user: user.data.viewer,
134+
})
135+
})
136+
}

src/runtime/types/oauth-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { H3Event, H3Error } from 'h3'
22

3-
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 & {})
3+
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 & {})
44

55
export type OnError = (event: H3Event, error: H3Error) => Promise<void> | void
66

0 commit comments

Comments
 (0)