Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add atlassian oauth-provider (closes #307) #308

Merged
merged 6 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ It can also be set using environment variables:

#### Supported OAuth Providers

- Atlassian
- Auth0
- Authentik
- AWS Cognito
Expand Down
4 changes: 4 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ NUXT_OAUTH_STRAVA_CLIENT_SECRET=
NUXT_OAUTH_HUBSPOT_CLIENT_ID=
NUXT_OAUTH_HUBSPOT_CLIENT_SECRET=
NUXT_OAUTH_HUBSPOT_REDIRECT_URL=
# Atlassian
NUXT_OAUTH_ATLASSIAN_CLIENT_ID=
NUXT_OAUTH_ATLASSIAN_CLIENT_SECRET=
NUXT_OAUTH_ATLASSIAN_REDIRECT_URL=
# Line
NUXT_OAUTH_LINE_CLIENT_ID=
NUXT_OAUTH_LINE_CLIENT_SECRET=
Expand Down
6 changes: 6 additions & 0 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ const providers = computed(() =>
disabled: Boolean(user.value?.hubspot),
icon: 'i-simple-icons-hubspot',
},
{
label: user.value?.atlassian || 'Atlassian',
to: '/auth/atlassian',
disabled: Boolean(user.value?.atlassian),
icon: 'i-simple-icons-atlassian',
},
].map(p => ({
...p,
prefetch: false,
Expand Down
1 change: 1 addition & 0 deletions playground/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ declare module '#auth-utils' {
seznam?: string
strava?: string
hubspot?: string
atlassian?: string
}

interface UserSession {
Expand Down
12 changes: 12 additions & 0 deletions playground/server/routes/auth/atlassian.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default defineOAuthAtlassianEventHandler({
async onSuccess(event, { user }) {
await setUserSession(event, {
user: {
email: user.email,
},
loggedInAt: Date.now(),
})

return sendRedirect(event, '/')
},
})
6 changes: 3 additions & 3 deletions playground/server/routes/auth/line.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export default defineOAuthLineEventHandler({
line: user.userId,
},
loggedInAt: Date.now(),
});
})

return sendRedirect(event, '/');
return sendRedirect(event, '/')
},
});
})
6 changes: 6 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,5 +361,11 @@ export default defineNuxtModule<ModuleOptions>({
clientSecret: '',
redirectURL: '',
})
// Atlassian OAuth
runtimeConfig.oauth.atlassian = defu(runtimeConfig.oauth.atlassian, {
clientId: '',
clientSecret: '',
redirectURL: '',
})
},
})
202 changes: 202 additions & 0 deletions src/runtime/server/lib/oauth/atlassian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type { H3Event } from 'h3'
import { eventHandler, getQuery, sendRedirect } from 'h3'
import { withQuery } from 'ufo'
import { defu } from 'defu'
import { randomUUID } from 'uncrypto'
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
import { useRuntimeConfig, createError } from '#imports'
import type { OAuthConfig } from '#auth-utils'

interface AtlassianUser {
account_id?: string // 000000-X0X0X0X0-X0X0-X0X0-X0X0-X0X0X0X0X0X0
email?: string // @example [email protected]
name?: string // @example John Doe
picture?: string // @example https://secure.gravatar.com/avatar/xxx
account_status?: string // @example active | inactive
characteristics?: { not_mentionable?: boolean }
last_updated?: string // @example 2024-10-13T15:35:16.933Z
nickname?: string // @example John Doe
locale?: string // @example en-US
extended_profile?: { phone_numbers?: string[] }
account_type?: string // @example atlassian
email_verified?: boolean // @example true
}

interface AtlassianTokens {
access_token?: string // JWT
expires_in?: number // seconds
token_type?: string // @example Bearer
scope?: string // @example 'read:account read:me'
error?: string
}

/**
* @see https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps
*/
export interface OAuthAtlassianConfig {
/**
* Atlassian OAuth Client ID
* @default process.env.NUXT_OAUTH_ATLASSIAN_CLIENT_ID
* @see https://developer.atlassian.com/console/myapps
*/
clientId?: string
/**
* Atlassian OAuth Client Secret
* @default process.env.NUXT_OAUTH_ATLASSIAN_CLIENT_SECRET
* @see https://developer.atlassian.com/console/myapps
*/
clientSecret?: string
/**
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
* @default process.env.NUXT_OAUTH_ATLASSIAN_REDIRECT_URL or current URL
* @see https://developer.atlassian.com/console/myapps
*/
redirectURL?: string
/**
* Atlassian OAuth Scope
* @default ['read:me', 'read:account']
* @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)
*
* @example
* User identity API: ['read:me', 'read:account']
* Confluence API: ['read:confluence-user']
* BRIE API: ['read:account:brie]
* Jira platform REST API: ['read:jira-user']
* Personal data reporting API: ['report:personal-data']
*/
scope?: string[]
/**
* Atlassian OAuth Audience URL
* @default 'https://api.atlassian.com'
*/
audienceURL?: string
/**
* Atlassian OAuth Authorization URL
* @default 'https://auth.atlassian.com/authorize'
*/
authorizationURL?: string
/**
* Atlassian OAuth Token URL
* @default 'https://auth.atlassian.com/oauth/token'
*/
tokenURL?: string
/**
* Require email from user, adds the ['read:me'] scope if not present
* @default false
*/
emailHasToBeVerified?: boolean
/**
* Extra authorization parameters to provide to the authorization URL
* @default {}
*/
authorizationParams?: Record<string, string>
}

/**
* Atlassian User identity, Confluence, BRIE, Jira platform, Atlassian Personal data reporting
*/
export function defineOAuthAtlassianEventHandler({
config,
onSuccess,
onError,
}: OAuthConfig<OAuthAtlassianConfig>) {
return eventHandler(async (event: H3Event) => {
config = defu(config, useRuntimeConfig().oauth?.atlassian, {
authorizationURL: 'https://auth.atlassian.com/authorize',
tokenURL: 'https://auth.atlassian.com/oauth/token',
audienceURL: 'https://api.atlassian.com',
scope: ['read:me', 'read:account'],
authorizationParams: {},
}) as OAuthAtlassianConfig

if (!config.clientId || !config.clientSecret) {
return handleMissingConfiguration(event, 'atlassian', ['clientId', 'clientSecret'], onError)
}

if (config.scope?.length === 0) {
config.scope = ['read:me']
}

if (config.emailHasToBeVerified && !config.scope?.includes('read:me')) {
config.scope?.push('read:me')
}

const query = getQuery<{ code?: string, error?: string }>(event)
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)

if (!query.code) {
return sendRedirect(
event,
withQuery(config.authorizationURL as string, {
audience: config.audienceURL,
client_id: config.clientId,
scope: config.scope?.join(' '),
redirect_uri: redirectURL,
state: randomUUID(),
response_type: 'code',
prompt: 'consent',
...config.authorizationParams,
}),
)
}

if (query.error) {
const error = createError({
statusCode: 401,
message: `Atlassian login failed: ${query.error || 'Unknown error'}`,
data: query,
})
if (!onError) throw error
return onError(event, error)
}

const tokens: AtlassianTokens = await requestAccessToken(config.tokenURL as string, {
headers: {
'Content-Type': 'application/json',
},
body: {
grant_type: 'authorization_code',
client_id: config.clientId,
client_secret: config.clientSecret,
code: query.code,
redirect_uri: redirectURL,
},
})

if (tokens.error || !tokens.access_token) {
return handleAccessTokenErrorResponse(event, 'atlassian', tokens, onError)
}

const user = await $fetch<AtlassianUser>('https://api.atlassian.com/me', {
headers: {
'Authorization': `Bearer ${tokens.access_token}`,
'Content-Type': 'application/json',
},
})

if (user.account_status === 'inactive') {
const error = createError({
statusCode: 403,
statusMessage: 'Atlassian account is inactive',
data: { accountStatus: user.account_status },
})
if (!onError) throw error
return onError(event, error)
}

if (!user.email_verified) {
const error = createError({
statusCode: 400,
statusMessage: 'Email address is not verified',
data: { email: user.email },
})
if (!onError) throw error
return onError(event, error)
}

return onSuccess(event, {
user,
tokens,
})
})
}
2 changes: 1 addition & 1 deletion src/runtime/types/oauth-config.ts
Original file line number Diff line number Diff line change
@@ -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' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {})
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 & {})

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

Expand Down