From 8ac827fb8eb040ed6eecfc41da87a64e0694edec Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 13 Dec 2024 21:33:04 +0100 Subject: [PATCH] feat: add atlassian oauth-provider --- README.md | 1 + playground/.env.example | 6 +- playground/app.vue | 6 + playground/auth.d.ts | 1 + .../server/routes/auth/atlassian.get.ts | 12 ++ src/module.ts | 6 + src/runtime/server/lib/oauth/atlassian.ts | 198 ++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 playground/server/routes/auth/atlassian.get.ts create mode 100644 src/runtime/server/lib/oauth/atlassian.ts diff --git a/README.md b/README.md index 988c6d42..2cbe49f7 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ It can also be set using environment variables: #### Supported OAuth Providers +- Atlassian - Auth0 - Authentik - AWS Cognito diff --git a/playground/.env.example b/playground/.env.example index a6281550..91841f0e 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= +# Atlassian +NUXT_OAUTH_ATLASSIAN_CLIENT_ID= +NUXT_OAUTH_ATLASSIAN_CLIENT_SECRET= +NUXT_OAUTH_ATLASSIAN_REDIRECT_URL= diff --git a/playground/app.vue b/playground/app.vue index aeed5703..089d1431 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -183,6 +183,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, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 35c136e4..d2c08816 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -33,6 +33,7 @@ declare module '#auth-utils' { seznam?: string strava?: string hubspot?: string + atlassian?: string } interface UserSession { diff --git a/playground/server/routes/auth/atlassian.get.ts b/playground/server/routes/auth/atlassian.get.ts new file mode 100644 index 00000000..48b60ea7 --- /dev/null +++ b/playground/server/routes/auth/atlassian.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthAtlassianEventHandler({ + async onSuccess(event, { user, tokens }) { + await setUserSession(event, { + user: { + email: user.email, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + } +}) diff --git a/src/module.ts b/src/module.ts index ed9e3da0..01f9cd3f 100644 --- a/src/module.ts +++ b/src/module.ts @@ -354,5 +354,11 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Atlassian OAuth + runtimeConfig.oauth.atlassian = defu(runtimeConfig.oauth.atlassian, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/atlassian.ts b/src/runtime/server/lib/oauth/atlassian.ts new file mode 100644 index 00000000..cf2e3db7 --- /dev/null +++ b/src/runtime/server/lib/oauth/atlassian.ts @@ -0,0 +1,198 @@ +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' +import { randomUUID } from 'uncrypto' + +interface AtlassianUser { + account_id?: string // 000000-X0X0X0X0-X0X0-X0X0-X0X0-X0X0X0X0X0X0 + email?: string // @example max.mustermann@example.com + name?: string // @example Max Mustermann + 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 Max Mustermann + 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 +} + +/** + * Atlassian User identity, Confluence, BRIE, Jira platform, Atlassian Personal data reporting + */ +export function defineOAuthAtlassianEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + 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('https://api.atlassian.com/me', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json' + } + }) + + if (user.account_status === 'inactive') { + throw createError({ + statusCode: 403, + statusMessage: 'Atlassian account is inactive', + data: { accountStatus: user.account_status } + }) + } + + if (!user.email_verified) { + throw createError({ + statusCode: 400, + statusMessage: 'Email address is not verified', + data: { email: user.email } + }) + } + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 188c03b3..62f169ad 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 = 'atlassian' | '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 OnError = (event: H3Event, error: H3Error) => Promise | void