Skip to content

Commit c6b4847

Browse files
jonasfroelleratinuxautofix-ci[bot]
authored
feat: add atlassian oauth-provider (closes #307) (#308)
Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent dd56268 commit c6b4847

File tree

9 files changed

+236
-4
lines changed

9 files changed

+236
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ It can also be set using environment variables:
204204
205205
#### Supported OAuth Providers
206206

207+
- Atlassian
207208
- Auth0
208209
- Authentik
209210
- AWS Cognito

playground/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ NUXT_OAUTH_STRAVA_CLIENT_SECRET=
101101
NUXT_OAUTH_HUBSPOT_CLIENT_ID=
102102
NUXT_OAUTH_HUBSPOT_CLIENT_SECRET=
103103
NUXT_OAUTH_HUBSPOT_REDIRECT_URL=
104+
# Atlassian
105+
NUXT_OAUTH_ATLASSIAN_CLIENT_ID=
106+
NUXT_OAUTH_ATLASSIAN_CLIENT_SECRET=
107+
NUXT_OAUTH_ATLASSIAN_REDIRECT_URL=
104108
# Line
105109
NUXT_OAUTH_LINE_CLIENT_ID=
106110
NUXT_OAUTH_LINE_CLIENT_SECRET=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ const providers = computed(() =>
189189
disabled: Boolean(user.value?.hubspot),
190190
icon: 'i-simple-icons-hubspot',
191191
},
192+
{
193+
label: user.value?.atlassian || 'Atlassian',
194+
to: '/auth/atlassian',
195+
disabled: Boolean(user.value?.atlassian),
196+
icon: 'i-simple-icons-atlassian',
197+
},
192198
].map(p => ({
193199
...p,
194200
prefetch: false,

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ declare module '#auth-utils' {
3434
seznam?: string
3535
strava?: string
3636
hubspot?: string
37+
atlassian?: string
3738
}
3839

3940
interface UserSession {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineOAuthAtlassianEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
email: user.email,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

playground/server/routes/auth/line.get.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ export default defineOAuthLineEventHandler({
55
line: user.userId,
66
},
77
loggedInAt: Date.now(),
8-
});
8+
})
99

10-
return sendRedirect(event, '/');
10+
return sendRedirect(event, '/')
1111
},
12-
});
12+
})

src/module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,5 +361,11 @@ export default defineNuxtModule<ModuleOptions>({
361361
clientSecret: '',
362362
redirectURL: '',
363363
})
364+
// Atlassian OAuth
365+
runtimeConfig.oauth.atlassian = defu(runtimeConfig.oauth.atlassian, {
366+
clientId: '',
367+
clientSecret: '',
368+
redirectURL: '',
369+
})
364370
},
365371
})
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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 { randomUUID } from 'uncrypto'
6+
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
7+
import { useRuntimeConfig, createError } from '#imports'
8+
import type { OAuthConfig } from '#auth-utils'
9+
10+
interface AtlassianUser {
11+
account_id?: string // 000000-X0X0X0X0-X0X0-X0X0-X0X0-X0X0X0X0X0X0
12+
email?: string // @example [email protected]
13+
name?: string // @example John Doe
14+
picture?: string // @example https://secure.gravatar.com/avatar/xxx
15+
account_status?: string // @example active | inactive
16+
characteristics?: { not_mentionable?: boolean }
17+
last_updated?: string // @example 2024-10-13T15:35:16.933Z
18+
nickname?: string // @example John Doe
19+
locale?: string // @example en-US
20+
extended_profile?: { phone_numbers?: string[] }
21+
account_type?: string // @example atlassian
22+
email_verified?: boolean // @example true
23+
}
24+
25+
interface AtlassianTokens {
26+
access_token?: string // JWT
27+
expires_in?: number // seconds
28+
token_type?: string // @example Bearer
29+
scope?: string // @example 'read:account read:me'
30+
error?: string
31+
}
32+
33+
/**
34+
* @see https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps
35+
*/
36+
export interface OAuthAtlassianConfig {
37+
/**
38+
* Atlassian OAuth Client ID
39+
* @default process.env.NUXT_OAUTH_ATLASSIAN_CLIENT_ID
40+
* @see https://developer.atlassian.com/console/myapps
41+
*/
42+
clientId?: string
43+
/**
44+
* Atlassian OAuth Client Secret
45+
* @default process.env.NUXT_OAUTH_ATLASSIAN_CLIENT_SECRET
46+
* @see https://developer.atlassian.com/console/myapps
47+
*/
48+
clientSecret?: string
49+
/**
50+
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
51+
* @default process.env.NUXT_OAUTH_ATLASSIAN_REDIRECT_URL or current URL
52+
* @see https://developer.atlassian.com/console/myapps
53+
*/
54+
redirectURL?: string
55+
/**
56+
* Atlassian OAuth Scope
57+
* @default ['read:me', 'read:account']
58+
* @see [Jira scopes](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps) | [Confluence scopes](https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps)
59+
*
60+
* @example
61+
* User identity API: ['read:me', 'read:account']
62+
* Confluence API: ['read:confluence-user']
63+
* BRIE API: ['read:account:brie]
64+
* Jira platform REST API: ['read:jira-user']
65+
* Personal data reporting API: ['report:personal-data']
66+
*/
67+
scope?: string[]
68+
/**
69+
* Atlassian OAuth Audience URL
70+
* @default 'https://api.atlassian.com'
71+
*/
72+
audienceURL?: string
73+
/**
74+
* Atlassian OAuth Authorization URL
75+
* @default 'https://auth.atlassian.com/authorize'
76+
*/
77+
authorizationURL?: string
78+
/**
79+
* Atlassian OAuth Token URL
80+
* @default 'https://auth.atlassian.com/oauth/token'
81+
*/
82+
tokenURL?: string
83+
/**
84+
* Require email from user, adds the ['read:me'] scope if not present
85+
* @default false
86+
*/
87+
emailHasToBeVerified?: boolean
88+
/**
89+
* Extra authorization parameters to provide to the authorization URL
90+
* @default {}
91+
*/
92+
authorizationParams?: Record<string, string>
93+
}
94+
95+
/**
96+
* Atlassian User identity, Confluence, BRIE, Jira platform, Atlassian Personal data reporting
97+
*/
98+
export function defineOAuthAtlassianEventHandler({
99+
config,
100+
onSuccess,
101+
onError,
102+
}: OAuthConfig<OAuthAtlassianConfig>) {
103+
return eventHandler(async (event: H3Event) => {
104+
config = defu(config, useRuntimeConfig().oauth?.atlassian, {
105+
authorizationURL: 'https://auth.atlassian.com/authorize',
106+
tokenURL: 'https://auth.atlassian.com/oauth/token',
107+
audienceURL: 'https://api.atlassian.com',
108+
scope: ['read:me', 'read:account'],
109+
authorizationParams: {},
110+
}) as OAuthAtlassianConfig
111+
112+
if (!config.clientId || !config.clientSecret) {
113+
return handleMissingConfiguration(event, 'atlassian', ['clientId', 'clientSecret'], onError)
114+
}
115+
116+
if (config.scope?.length === 0) {
117+
config.scope = ['read:me']
118+
}
119+
120+
if (config.emailHasToBeVerified && !config.scope?.includes('read:me')) {
121+
config.scope?.push('read:me')
122+
}
123+
124+
const query = getQuery<{ code?: string, error?: string }>(event)
125+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
126+
127+
if (!query.code) {
128+
return sendRedirect(
129+
event,
130+
withQuery(config.authorizationURL as string, {
131+
audience: config.audienceURL,
132+
client_id: config.clientId,
133+
scope: config.scope?.join(' '),
134+
redirect_uri: redirectURL,
135+
state: randomUUID(),
136+
response_type: 'code',
137+
prompt: 'consent',
138+
...config.authorizationParams,
139+
}),
140+
)
141+
}
142+
143+
if (query.error) {
144+
const error = createError({
145+
statusCode: 401,
146+
message: `Atlassian login failed: ${query.error || 'Unknown error'}`,
147+
data: query,
148+
})
149+
if (!onError) throw error
150+
return onError(event, error)
151+
}
152+
153+
const tokens: AtlassianTokens = await requestAccessToken(config.tokenURL as string, {
154+
headers: {
155+
'Content-Type': 'application/json',
156+
},
157+
body: {
158+
grant_type: 'authorization_code',
159+
client_id: config.clientId,
160+
client_secret: config.clientSecret,
161+
code: query.code,
162+
redirect_uri: redirectURL,
163+
},
164+
})
165+
166+
if (tokens.error || !tokens.access_token) {
167+
return handleAccessTokenErrorResponse(event, 'atlassian', tokens, onError)
168+
}
169+
170+
const user = await $fetch<AtlassianUser>('https://api.atlassian.com/me', {
171+
headers: {
172+
'Authorization': `Bearer ${tokens.access_token}`,
173+
'Content-Type': 'application/json',
174+
},
175+
})
176+
177+
if (user.account_status === 'inactive') {
178+
const error = createError({
179+
statusCode: 403,
180+
statusMessage: 'Atlassian account is inactive',
181+
data: { accountStatus: user.account_status },
182+
})
183+
if (!onError) throw error
184+
return onError(event, error)
185+
}
186+
187+
if (!user.email_verified) {
188+
const error = createError({
189+
statusCode: 400,
190+
statusMessage: 'Email address is not verified',
191+
data: { email: user.email },
192+
})
193+
if (!onError) throw error
194+
return onError(event, error)
195+
}
196+
197+
return onSuccess(event, {
198+
user,
199+
tokens,
200+
})
201+
})
202+
}

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' | '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 & {})
3+
export type OAuthProvider = 'atlassian' | '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 & {})
44

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

0 commit comments

Comments
 (0)