From db876cf358badefe57b3f1bc8f9f2b8c983b0639 Mon Sep 17 00:00:00 2001 From: David Stranava <61092270+stranavad@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:32:01 +0100 Subject: [PATCH] feat: add seznam oauth provider (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add seznam oauth provider * chore: add user type --------- Co-authored-by: Sébastien Chopin --- README.md | 1 + playground/.env.example | 3 + playground/app.vue | 6 + playground/auth.d.ts | 1 + playground/server/routes/auth/seznam.get.ts | 15 ++ src/module.ts | 6 + src/runtime/server/lib/oauth/seznam.ts | 179 ++++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 8 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 playground/server/routes/auth/seznam.get.ts create mode 100644 src/runtime/server/lib/oauth/seznam.ts diff --git a/README.md b/README.md index c07effe..a85a4f1 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ It can also be set using environment variables: - Microsoft - PayPal - Polar +- Seznam - Spotify - Steam - TikTok diff --git a/playground/.env.example b/playground/.env.example index 6154584..4cda106 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -50,6 +50,9 @@ NUXT_OAUTH_INSTAGRAM_CLIENT_SECRET= # PayPal NUXT_OAUTH_PAYPAL_CLIENT_ID= NUXT_OAUTH_PAYPAL_CLIENT_SECRET= +# Seznam +NUXT_OAUTH_SEZNAM_CLIENT_ID= +NUXT_OAUTH_SEZNAM_CLIENT_SECRET= # Steam NUXT_OAUTH_STEAM_API_KEY= # X diff --git a/playground/app.vue b/playground/app.vue index c6b6671..761768c 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -165,6 +165,12 @@ const providers = computed(() => disabled: Boolean(user.value?.authentik), icon: 'i-simple-icons-authentik', }, + { + label: user.value?.seznam || 'Seznam', + to: '/auth/seznam', + disabled: Boolean(user.value?.seznam), + icon: 'i-gravity-ui-lock', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 6e55822..71b55c3 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -30,6 +30,7 @@ declare module '#auth-utils' { polar?: string zitadel?: string authentik?: string + seznam?: string } interface UserSession { diff --git a/playground/server/routes/auth/seznam.get.ts b/playground/server/routes/auth/seznam.get.ts new file mode 100644 index 0000000..19aca33 --- /dev/null +++ b/playground/server/routes/auth/seznam.get.ts @@ -0,0 +1,15 @@ +export default defineOAUthSeznamEventHandler({ + config: { + scope: ['identity'], + }, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + seznam: `${user.firstname} ${user.lastname}`, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 6a9f212..4e3bac9 100644 --- a/src/module.ts +++ b/src/module.ts @@ -335,5 +335,11 @@ export default defineNuxtModule({ domain: '', redirectURL: '', }) + // Seznam OAuth + runtimeConfig.oauth.seznam = defu(runtimeConfig.oauth.seznam, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/seznam.ts b/src/runtime/server/lib/oauth/seznam.ts new file mode 100644 index 0000000..533a579 --- /dev/null +++ b/src/runtime/server/lib/oauth/seznam.ts @@ -0,0 +1,179 @@ +import defu from 'defu' +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleMissingConfiguration, requestAccessToken } from '../utils' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthSeznamConfig { + /** + * Seznam OAuth Client ID + * @default process.env.NUXT_OAUTH_SEZNAM_CLIENT_ID + */ + clientId?: string + + /** + * Seznam OAuth Client Secret + * @default process.env.NUXT_OAUTH_SEZNAM_CLIENT_SECRET + */ + clientSecret?: string + + /** + * Seznam OAuth Scope + * @default ['identity'] + * @see https://vyvojari.seznam.cz/oauth/scopes?lang=en + * @example ['identity', 'avatar'] + */ + scope?: string[] + + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * Redirect URL has to be set as well in the Seznam OAuth settings https://vyvojari.seznam.cz/oauth/admin in order for it to work + * @see https://vyvojari.seznam.cz/oauth/admin + * @default process.env.NUXT_OAUTH_SEZNAM_REDIRECT_URL + */ + redirectURL?: string + + /** + * Seznam OAuth Authorization URL + * @default 'https://login.szn.cz/api/v1/oauth/auth' + */ + authorizationURL?: string + + /** + * Seznam OAuth Token URL + * @default 'https://login.szn.cz/api/v1/oauth/token' + */ + tokenURL?: string + + /** + * Seznam OAuth User URL + * @default 'https://login.szn.cz/api/v1/user' + */ + userURL?: string +} + +export interface OAuthSeznamUser { + /** + * Unique persistent user account identifier + */ + oauth_user_id: string + + /** + * The user's e-mail address or null for users without an e-mail + */ + email: string | null + + /** + * Given name (when available) + */ + firstname: string + + /** + * Family name (when available) + */ + lastname: string + + /** + * User account identifier suitable for usage within the Seznam ad infrastructure + */ + advert_user_id: string + + /** + * (only when provided and validated by the user; null otherwise) + * Available only when you use the contact-phone scope + * @see https://vyvojari.seznam.cz/oauth/scopes?lang=en + */ + contact_phone?: string | null + + /** + * the image's URL + * Available only when you use the avatar scope + * @see https://vyvojari.seznam.cz/oauth/scopes?lang=en + */ + avatar_url?: string | null + + /** + * true/false value corresponding to the user's adult status + * Available only when you use the avatar scope + * @see https://vyvojari.seznam.cz/oauth/scopes?lang=en + */ + adulthood?: boolean + + /** + * date of birth in the ISO 8601 format (only when present; null otherwise) + * Available only when you use the birthday scope + * @see https://vyvojari.seznam.cz/oauth/scopes?lang=en + */ + birthday?: string | null + + /** + * one of the allowed strings "Male" / "Female" / "Other" (null if not set) + * Available only when you use the gender scope + * @see https://vyvojari.seznam.cz/oauth/scopes?lang=en + */ + gender?: string | null +} + +export function defineOAUthSeznamEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.google, { + authorizationURL: 'https://login.szn.cz/api/v1/oauth/auth', + tokenURL: 'https://login.szn.cz/api/v1/oauth/token', + userURL: 'https://login.szn.cz/api/v1/user', + }) as OAuthSeznamConfig + + const query = getQuery<{ code?: string, state?: string }>(event) + + if (!config.clientId) { + return handleMissingConfiguration(event, 'seznam', ['clientId'], onError) + } + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + if (!query.code) { + config.scope = config.scope || ['identity'] // identity is mandatory + + // Redirect to Seznam Oauth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope.join(','), + state: query.state || '', + }), + ) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + code: query.code as string, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectURL, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'seznam', tokens, onError) + } + + const accessToken = tokens.access_token + const user: OAuthSeznamUser = await $fetch( + config.userURL as string, + { + headers: { + Authorization: `bearer ${accessToken}`, + }, + }, + ) + + return onSuccess(event, { + tokens, + user, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index fade91b..59d4c41 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' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) +export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void