From 1139ba91f9e15320e6fe79a79be21c279da2f851 Mon Sep 17 00:00:00 2001 From: Syrex-o Date: Sat, 15 Nov 2025 21:36:23 +0100 Subject: [PATCH 1/6] add shopifyCustomer type --- src/runtime/types/oauth-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 27a17f94..8ef06fc2 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' | 'roblox' | 'okta' | 'ory' | (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' | 'roblox' | 'okta' | 'ory' | 'shopifyCustomer' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From 54877647dc94226d292285717534f4a65d43d4c1 Mon Sep 17 00:00:00 2001 From: Syrex-o Date: Sat, 15 Nov 2025 21:38:31 +0100 Subject: [PATCH 2/6] add shopify customer oauth handler --- .../server/lib/oauth/shopifyCustomer.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/runtime/server/lib/oauth/shopifyCustomer.ts diff --git a/src/runtime/server/lib/oauth/shopifyCustomer.ts b/src/runtime/server/lib/oauth/shopifyCustomer.ts new file mode 100644 index 00000000..78a1922a --- /dev/null +++ b/src/runtime/server/lib/oauth/shopifyCustomer.ts @@ -0,0 +1,174 @@ +import type { H3Event } from 'h3' +import { createError, eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleMissingConfiguration, handlePkceVerifier, handleState, requestAccessToken } from '../utils' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +interface ShopifyCustomer { + customer: { + firstName: string | null + lastName: string | null + emailAddress: { + emailAddress: string + } + } +} + +interface AccessTokenResponse { + access_token: string + expires_in: number + id_token: string + refresh_token: string + error?: string +} + +interface CustomerDiscoveryResponse { + issuer: string + token_endpoint: string + authorization_endpoint: string + end_session_endpoint: string +} + +interface CustomerApiDiscoveryResponse { + graphql_api: string + mcp_api: string +} + +export interface OAuthShopifyCustomerConfig { + /** + * Shopify shop domain ID + * @default process.env.NUXT_OAUTH_SHOPIFY_CUSTOMER_SHOP_DOMAIN + * @example 123.myshopify.com + */ + shopDomain?: string + + /** + * Shopify Customer Client ID + * @default process.env.NUXT_OAUTH_SHOPIFY_CUSTOMER_CLIENT_ID + */ + clientId?: string + + /** + * Shopify Customer OAuth Scope + * @default ['openid', 'email', 'customer-account-api:full'] + * @example ['openid', 'email', 'customer-account-api:full'] + */ + scope?: string[] + + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_SHOPIFY_CUSTOMER_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +export function defineOAuthShopifyCustomerEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.shopifyCustomer, {}) as OAuthShopifyCustomerConfig + + const query = getQuery<{ code?: string, state?: string }>(event) + + if (!config.clientId || !config.shopDomain) { + return handleMissingConfiguration(event, 'spotify', ['clientId', 'shopDomain'], onError) + } + + // Create pkce verifier + const verifier = await handlePkceVerifier(event) + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + const discoveryResponse: CustomerDiscoveryResponse | null = await $fetch(`https://${config.shopDomain}/.well-known/openid-configuration`) + .then(d => d as CustomerDiscoveryResponse) + .catch(() => null) + if (!discoveryResponse?.issuer) { + const error = createError({ + statusCode: 400, + message: 'Getting Shopify discovery endpoint failed.', + }) + if (!onError) throw error + return onError(event, error) + } + + const state = await handleState(event) + + if (!query.code) { + // guarantee uniqueness of the scope + config.scope = config.scope && config.scope.length > 0 ? config.scope : ['openid', 'email', 'customer-account-api:full'] + config.scope = [...new Set(config.scope)] + + // Redirect to Shopify Login page + return sendRedirect( + event, + withQuery(discoveryResponse.authorization_endpoint, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope.join(' '), + state, + code_challenge: verifier.code_challenge, + code_challenge_method: verifier.code_challenge_method, + }), + ) + } + + const tokens: AccessTokenResponse = await requestAccessToken(discoveryResponse.token_endpoint, { + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + redirect_uri: redirectURL, + code: query.code as string, + code_verifier: verifier.code_verifier, + }, + }).catch(() => ({ error: 'failed' })) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'shopifyCustomer', tokens, onError) + } + + // get api + const apiDiscoveryUrl: CustomerApiDiscoveryResponse | null = await $fetch(`https://${config.shopDomain}/.well-known/customer-account-api`) + .then(d => d as CustomerApiDiscoveryResponse) + .catch(() => null) + + if (!apiDiscoveryUrl?.graphql_api) { + const error = createError({ + statusCode: 400, + message: 'Getting Shopify api endpoints failed.', + }) + if (!onError) throw error + return onError(event, error) + } + + const user: ShopifyCustomer | null = await $fetch(apiDiscoveryUrl.graphql_api, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': tokens.access_token, + }, + body: JSON.stringify({ + operationName: 'getCustomer', + query: 'query { customer { firstName lastName emailAddress { emailAddress }}}', + }), + }).then(d => (d as { data: ShopifyCustomer }).data) + .catch(() => null) + + if (!user || !user.customer) { + const error = createError({ + statusCode: 400, + message: 'Getting Shopify Customer failed.', + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { + tokens, + user: user.customer, + }) + }) +} From 3603bcf4eaad7a3000c67d045625bc6247373e40 Mon Sep 17 00:00:00 2001 From: Syrex-o Date: Sat, 15 Nov 2025 21:38:40 +0100 Subject: [PATCH 3/6] add event handler --- .../server/routes/auth/shopifyCustomer.get.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 playground/server/routes/auth/shopifyCustomer.get.ts diff --git a/playground/server/routes/auth/shopifyCustomer.get.ts b/playground/server/routes/auth/shopifyCustomer.get.ts new file mode 100644 index 00000000..d6bef41c --- /dev/null +++ b/playground/server/routes/auth/shopifyCustomer.get.ts @@ -0,0 +1,14 @@ +export default defineOAuthShopifyCustomerEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + firstName: user?.firstName, + lastName: user?.lastName, + email: user?.emailAddress?.emailAddress, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) From 0ae6fe477b2891f5d89ce18f92d827f89231383e Mon Sep 17 00:00:00 2001 From: Syrex-o Date: Sat, 15 Nov 2025 21:38:59 +0100 Subject: [PATCH 4/6] add shopify runtimeConfig --- src/module.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/module.ts b/src/module.ts index 479915af..290069a0 100644 --- a/src/module.ts +++ b/src/module.ts @@ -521,5 +521,12 @@ export default defineNuxtModule({ tokenURL: '', userURL: '', }) + // Shopify Customer + runtimeConfig.oauth.shopifyCustomer = defu(runtimeConfig.oauth.shopifyCustomer, { + shopDomain: '', + clientId: '', + redirectURL: '', + scope: [], + }) }, }) From 39c673d33648f6b702502c28cbb9b015ae7e35d4 Mon Sep 17 00:00:00 2001 From: Syrex-o Date: Sat, 15 Nov 2025 21:40:36 +0100 Subject: [PATCH 5/6] add env example --- playground/.env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/playground/.env.example b/playground/.env.example index f466fcf1..3f90d326 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -129,6 +129,10 @@ NUXT_OAUTH_LIVECHAT_CLIENT_SECRET= NUXT_OAUTH_SALESFORCE_CLIENT_ID= NUXT_OAUTH_SALESFORCE_CLIENT_SECRET= NUXT_OAUTH_SALESFORCE_REDIRECT_URL= +#Shopify Customer +NUXT_OAUTH_SHOPIFY_CUSTOMER_SHOP_DOMAIN= +NUXT_OAUTH_SHOPIFY_CUSTOMER_CLIENT_ID= +NUXT_OAUTH_SHOPIFY_CUSTOMER_REDIRECT_URL= #Slack NUXT_OAUTH_SLACK_CLIENT_ID= NUXT_OAUTH_SLACK_CLIENT_SECRET= From 70fc6758c5d86fc8e85b4ab300f5d1b76026183c Mon Sep 17 00:00:00 2001 From: Syrex-o Date: Sat, 15 Nov 2025 21:40:59 +0100 Subject: [PATCH 6/6] add shopifx customer login button --- playground/app/pages/index.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/playground/app/pages/index.vue b/playground/app/pages/index.vue index d5438d89..bce184b3 100644 --- a/playground/app/pages/index.vue +++ b/playground/app/pages/index.vue @@ -275,6 +275,12 @@ const providers = computed(() => disabled: Boolean(user.value?.ory), icon: 'i-custom-ory', }, + { + title: user.value?.shopifyCustomer || 'Shopify Customer', + to: '/auth/shopifyCustomer', + disabled: Boolean(user.value?.shopifyCustomer), + icon: 'i-simple-icons-shopify', + }, ].map(p => ({ ...p, prefetch: false,