diff --git a/README.md b/README.md index 862f0133..2397729e 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ It can also be set using environment variables: - AWS Cognito - Azure B2C - Battle.net +- Bitrix24 - Bluesky (AT Protocol) - Discord - Dropbox diff --git a/playground/.env.example b/playground/.env.example index d2fbea9a..934d921b 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -136,4 +136,7 @@ NUXT_OAUTH_SLACK_REDIRECT_URL= #Heroku NUXT_OAUTH_HEROKU_CLIENT_ID= NUXT_OAUTH_HEROKU_CLIENT_SECRET= -NUXT_OAUTH_HEROKU_REDIRECT_URL= \ No newline at end of file +NUXT_OAUTH_HEROKU_REDIRECT_URL= +# Bitrix24 +NUXT_OAUTH_BITRIX24_CLIENT_ID= +NUXT_OAUTH_BITRIX24_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 369b90f7..360f5639 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -248,6 +248,34 @@ const providers = computed(() => disabled: Boolean(user.value?.heroku), icon: 'i-simple-icons-heroku', }, + { + label: user.value?.bitrix24?.name || 'Bitrix24', + avatar: { + size: user.value?.bitrix24?.photo ? '2xs' : 'xs', + ui: { + + }, + src: user.value?.bitrix24?.photo || 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MiA0MiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0ic2l6ZS0yNCB0ZXh0LWJhc2UtbWFzdGVyIGRhcms6dGV4dC1iYXNlLTIwMCI+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNMjIuMDkgMTcuOTI2aC0xLjM4NnYzLjcxNmgzLjU1MXYtMS4zODZIMjIuMDl6bS0uNjE2IDcuMzU2YTQuNzE4IDQuNzE4IDAgMSAxIDAtOS40MzYgNC43MTggNC43MTggMCAwIDEgMCA5LjQzNm05LjE5NS02QTUuMTkgNS4xOSAwIDAgMCAyMy43MjEgMTRhNS4xOSA1LjE5IDAgMCAwLTkuODcyIDEuNjlBNi4yMzQgNi4yMzQgMCAwIDAgMTUuMjMzIDI4aDE0Ljc2MWMyLjQ0NCAwIDQuNDI1LTEuNzI0IDQuNDI1LTQuNDI1IDAtMy40OTctMy40MDYtNC4zNzktMy43NS00LjI5MyIvPjwvc3ZnPg==', + }, + click() { + // open user profile //// + if (user.value?.bitrix24) { + window.open(`${user.value?.bitrix24?.targetOrigin}/company/personal/user/${user.value?.bitrix24?.id}/`) + return + } + + // make auth //// + const authorizationServer = prompt('Enter your Bitrix24 URL', '') + if (authorizationServer) { + navigateTo({ + path: '/auth/bitrix24', + query: { authorizationServer }, + }, { + external: true, + }) + } + }, + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 61d718fb..35e9b2e1 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -43,6 +43,12 @@ declare module '#auth-utils' { salesforce?: string slack?: string heroku?: string + bitrix24?: { + id: number + name: string + photo: string + targetOrigin: string + } } interface UserSession { diff --git a/playground/server/routes/auth/bitrix24.ts b/playground/server/routes/auth/bitrix24.ts new file mode 100644 index 00000000..ce314346 --- /dev/null +++ b/playground/server/routes/auth/bitrix24.ts @@ -0,0 +1,25 @@ +export default defineOAuthBitrix24EventHandler({ + config: {}, + async onSuccess(event, { user, payload }) { + const userToSet = user?.name?.firstName && user?.name?.lastName + ? `${user.name.firstName} ${user.name.lastName}` + : user?.name?.firstName || user?.name?.lastName || user?.id || payload.memberId + + await setUserSession(event, { + user: { + bitrix24: { + id: user.id, + name: userToSet, + photo: user.photo, + targetOrigin: user.targetOrigin, + }, + }, + secure: { + b24Tokens: payload, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 99dbd418..d30ad8a6 100644 --- a/src/module.ts +++ b/src/module.ts @@ -468,5 +468,10 @@ export default defineNuxtModule({ redirectURL: '', scope: '', }) + // Bitrix24 OAuth + runtimeConfig.oauth.bitrix24 = defu(runtimeConfig.oauth.bitrix24, { + clientId: '', + clientSecret: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/bitrix24.ts b/src/runtime/server/lib/oauth/bitrix24.ts new file mode 100644 index 00000000..3495ab94 --- /dev/null +++ b/src/runtime/server/lib/oauth/bitrix24.ts @@ -0,0 +1,211 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { + handleMissingConfiguration, + handleAccessTokenErrorResponse, + requestAccessToken, + handleState, + handleInvalidState, +} from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface Bitrix24Tokens { + accessToken: string + clientEndpoint: string + domain: string + expiresIn: number + memberId: string + refreshToken: string + scope: string + serverEndpoint: string + status: string +} + +export interface Bitrix24UserProfile { + id?: number + isAdmin?: boolean + /** + * account address BX24 ( https://name.bitrix24.com ) + */ + targetOrigin?: string + name?: { + firstName?: string + lastName?: string + } + gender?: string + photo?: string + timeZone?: string + timeZoneOffset?: number +} + +/** + * Bitrix24 + * @memo Not send: `scope`, `redirect_uri` + * + * @see https://apidocs.bitrix24.com/api-reference/oauth/index.html + */ +export interface OAuthBitrix24Config { + /** + * Bitrix24 OAuth Client ID + * @default process.env.NUXT_OAUTH_BITRIX24_CLIENT_ID + */ + clientId?: string + + /** + * Bitrix24 OAuth Client Secret + * @default process.env.NUXT_OAUTH_BITRIX24_CLIENT_SECRET + */ + clientSecret?: string + + /** + * Bitrix24 OAuth Authorization URL + * @default '${baseURL}/oauth/authorize/' + */ + authorizationURL?: string + + /** + * Bitrix24 OAuth Token URL + * @default 'https://oauth.bitrix.info/oauth/token/' + */ + tokenURL?: string +} + +export function defineOAuthBitrix24EventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + if (event.method === 'HEAD') { + event.node.res.end() + return + } + + const runtimeConfig = useRuntimeConfig(event).oauth?.bitrix24 + + const query = getQuery<{ + authorizationServer?: string + code?: string + state?: string + }>(event) + + const authorizationServer = query?.authorizationServer + if ( + !query.code + && typeof authorizationServer === 'undefined' + ) { + const error = createError({ + statusCode: 500, + message: 'Query parameter `authorizationServer` empty or missing. Please provide a valid Bitrix24 authorizationServer.', + }) + if (!onError) throw error + return onError(event, error) + } + + config = defu(config, runtimeConfig, { + authorizationURL: `${authorizationServer}/oauth/authorize/`, + tokenURL: `https://oauth.bitrix.info/oauth/token/`, + }) as OAuthBitrix24Config + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration(event, 'bitrix24', ['clientId', 'clientSecret'], onError) + } + + const state = await handleState(event) + + if (!query.code) { + // Redirect to Bitrix24 oAuth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + client_id: config.clientId, + state, + }), + ) + } + + if (query?.state !== state) { + handleInvalidState(event, 'bitrix24', onError) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: '', + code: query.code, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'bitrix24', tokens, onError) + } + + const payload: Bitrix24Tokens = { + accessToken: tokens.access_token, + clientEndpoint: tokens.client_endpoint, + domain: tokens.domain, + expiresIn: tokens.expires_in, + memberId: tokens.member_id, + refreshToken: tokens.refresh_token, + scope: tokens.scope, + serverEndpoint: tokens.server_endpoint, + status: tokens.status, + } + + const response = await $fetch<{ + result: { + ID: string + NAME: string + ADMIN: boolean + LAST_NAME: string + PERSONAL_GENDER: string + PERSONAL_PHOTO: string + TIME_ZONE: string + TIME_ZONE_OFFSET: number + } + time: { + start: number + finish: number + duration: number + processing: number + date_start: string + date_finish: string + operating: number + } + }>(`${payload.clientEndpoint}profile` as string, { + params: { + auth: payload.accessToken, + }, + }) + + const user = { + id: Number.parseInt(response.result.ID), + isAdmin: response.result.ADMIN, + targetOrigin: `https://${tokens.client_endpoint.replaceAll('https://', '').replaceAll('http://', '').replace(/:(80|443)$/, '').replace('/rest/', '')}`, + name: { + firstName: response.result.NAME, + lastName: response.result.LAST_NAME, + }, + gender: response.result.PERSONAL_GENDER, + photo: response.result.PERSONAL_PHOTO, + timeZone: response.result.TIME_ZONE, + timeZoneOffset: response.result.TIME_ZONE_OFFSET, + } + + return onSuccess(event, { + user, + payload, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index ca7d962d..2e381162 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3' export type ATProtoProvider = 'bluesky' -export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | (string & {}) +export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | 'bitrix24' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void