Skip to content

Commit cc97f7d

Browse files
Syrex-oatinux
andauthored
feat: add Shopify Customer Account API OAuth provider (#470)
Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: Sébastien Chopin <[email protected]>
1 parent 685e0b3 commit cc97f7d

File tree

7 files changed

+207
-1
lines changed

7 files changed

+207
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ It can also be set using environment variables:
242242
- Polar
243243
- Salesforce
244244
- Seznam
245+
- Shopify Customer
245246
- Slack
246247
- Spotify
247248
- Steam

playground/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ NUXT_OAUTH_LIVECHAT_CLIENT_SECRET=
129129
NUXT_OAUTH_SALESFORCE_CLIENT_ID=
130130
NUXT_OAUTH_SALESFORCE_CLIENT_SECRET=
131131
NUXT_OAUTH_SALESFORCE_REDIRECT_URL=
132+
#Shopify Customer
133+
NUXT_OAUTH_SHOPIFY_CUSTOMER_SHOP_DOMAIN=
134+
NUXT_OAUTH_SHOPIFY_CUSTOMER_CLIENT_ID=
135+
NUXT_OAUTH_SHOPIFY_CUSTOMER_REDIRECT_URL=
132136
#Slack
133137
NUXT_OAUTH_SLACK_CLIENT_ID=
134138
NUXT_OAUTH_SLACK_CLIENT_SECRET=

playground/app/pages/index.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,12 @@ const providers = computed(() =>
275275
disabled: Boolean(user.value?.ory),
276276
icon: 'i-custom-ory',
277277
},
278+
{
279+
title: user.value?.shopifyCustomer || 'Shopify Customer',
280+
to: '/auth/shopifyCustomer',
281+
disabled: Boolean(user.value?.shopifyCustomer),
282+
icon: 'i-simple-icons-shopify',
283+
},
278284
].map(p => ({
279285
...p,
280286
prefetch: false,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default defineOAuthShopifyCustomerEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
firstName: user?.firstName,
6+
lastName: user?.lastName,
7+
email: user?.emailAddress?.emailAddress,
8+
},
9+
loggedInAt: Date.now(),
10+
})
11+
12+
return sendRedirect(event, '/')
13+
},
14+
})

src/module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,5 +523,12 @@ export default defineNuxtModule<ModuleOptions>({
523523
tokenURL: '',
524524
userURL: '',
525525
})
526+
// Shopify Customer
527+
runtimeConfig.oauth.shopifyCustomer = defu(runtimeConfig.oauth.shopifyCustomer, {
528+
shopDomain: '',
529+
clientId: '',
530+
redirectURL: '',
531+
scope: [],
532+
})
526533
},
527534
})
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import type { H3Event } from 'h3'
2+
import { createError, eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleMissingConfiguration, handlePkceVerifier, handleState, requestAccessToken } from '../utils'
6+
import { useRuntimeConfig } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
interface ShopifyCustomer {
10+
customer: {
11+
firstName: string | null
12+
lastName: string | null
13+
emailAddress: {
14+
emailAddress: string
15+
}
16+
}
17+
}
18+
19+
interface AccessTokenResponse {
20+
access_token: string
21+
expires_in: number
22+
id_token: string
23+
refresh_token: string
24+
error?: string
25+
}
26+
27+
interface CustomerDiscoveryResponse {
28+
issuer: string
29+
token_endpoint: string
30+
authorization_endpoint: string
31+
end_session_endpoint: string
32+
}
33+
34+
interface CustomerApiDiscoveryResponse {
35+
graphql_api: string
36+
mcp_api: string
37+
}
38+
39+
export interface OAuthShopifyCustomerConfig {
40+
/**
41+
* Shopify shop domain ID
42+
* @default process.env.NUXT_OAUTH_SHOPIFY_CUSTOMER_SHOP_DOMAIN
43+
* @example 123.myshopify.com
44+
*/
45+
shopDomain?: string
46+
47+
/**
48+
* Shopify Customer Client ID
49+
* @default process.env.NUXT_OAUTH_SHOPIFY_CUSTOMER_CLIENT_ID
50+
*/
51+
clientId?: string
52+
53+
/**
54+
* Shopify Customer OAuth Scope
55+
* @default ['openid', 'email', 'customer-account-api:full']
56+
* @example ['openid', 'email', 'customer-account-api:full']
57+
*/
58+
scope?: string[]
59+
60+
/**
61+
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
62+
* @default process.env.NUXT_OAUTH_SHOPIFY_CUSTOMER_REDIRECT_URL or current URL
63+
*/
64+
redirectURL?: string
65+
}
66+
67+
export function defineOAuthShopifyCustomerEventHandler({
68+
config,
69+
onSuccess,
70+
onError,
71+
}: OAuthConfig<OAuthShopifyCustomerConfig>) {
72+
return eventHandler(async (event: H3Event) => {
73+
config = defu(config, useRuntimeConfig(event).oauth?.shopifyCustomer, {}) as OAuthShopifyCustomerConfig
74+
75+
const query = getQuery<{ code?: string, state?: string }>(event)
76+
77+
if (!config.clientId || !config.shopDomain) {
78+
return handleMissingConfiguration(event, 'spotify', ['clientId', 'shopDomain'], onError)
79+
}
80+
81+
// Create pkce verifier
82+
const verifier = await handlePkceVerifier(event)
83+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
84+
85+
const discoveryResponse: CustomerDiscoveryResponse | null = await $fetch(`https://${config.shopDomain}/.well-known/openid-configuration`)
86+
.then(d => d as CustomerDiscoveryResponse)
87+
.catch(() => null)
88+
if (!discoveryResponse?.issuer) {
89+
const error = createError({
90+
statusCode: 400,
91+
message: 'Getting Shopify discovery endpoint failed.',
92+
})
93+
if (!onError) throw error
94+
return onError(event, error)
95+
}
96+
97+
const state = await handleState(event)
98+
99+
if (!query.code) {
100+
// guarantee uniqueness of the scope
101+
config.scope = config.scope && config.scope.length > 0 ? config.scope : ['openid', 'email', 'customer-account-api:full']
102+
config.scope = [...new Set(config.scope)]
103+
104+
// Redirect to Shopify Login page
105+
return sendRedirect(
106+
event,
107+
withQuery(discoveryResponse.authorization_endpoint, {
108+
response_type: 'code',
109+
client_id: config.clientId,
110+
redirect_uri: redirectURL,
111+
scope: config.scope.join(' '),
112+
state,
113+
code_challenge: verifier.code_challenge,
114+
code_challenge_method: verifier.code_challenge_method,
115+
}),
116+
)
117+
}
118+
119+
const tokens: AccessTokenResponse = await requestAccessToken(discoveryResponse.token_endpoint, {
120+
body: {
121+
grant_type: 'authorization_code',
122+
client_id: config.clientId,
123+
redirect_uri: redirectURL,
124+
code: query.code as string,
125+
code_verifier: verifier.code_verifier,
126+
},
127+
}).catch(() => ({ error: 'failed' }))
128+
129+
if (tokens.error) {
130+
return handleAccessTokenErrorResponse(event, 'shopifyCustomer', tokens, onError)
131+
}
132+
133+
// get api
134+
const apiDiscoveryUrl: CustomerApiDiscoveryResponse | null = await $fetch(`https://${config.shopDomain}/.well-known/customer-account-api`)
135+
.then(d => d as CustomerApiDiscoveryResponse)
136+
.catch(() => null)
137+
138+
if (!apiDiscoveryUrl?.graphql_api) {
139+
const error = createError({
140+
statusCode: 400,
141+
message: 'Getting Shopify api endpoints failed.',
142+
})
143+
if (!onError) throw error
144+
return onError(event, error)
145+
}
146+
147+
const user: ShopifyCustomer | null = await $fetch(apiDiscoveryUrl.graphql_api, {
148+
method: 'POST',
149+
headers: {
150+
'Content-Type': 'application/json',
151+
'Authorization': tokens.access_token,
152+
},
153+
body: JSON.stringify({
154+
operationName: 'getCustomer',
155+
query: 'query { customer { firstName lastName emailAddress { emailAddress }}}',
156+
}),
157+
}).then(d => (d as { data: ShopifyCustomer }).data)
158+
.catch(() => null)
159+
160+
if (!user || !user.customer) {
161+
const error = createError({
162+
statusCode: 400,
163+
message: 'Getting Shopify Customer failed.',
164+
})
165+
if (!onError) throw error
166+
return onError(event, error)
167+
}
168+
169+
return onSuccess(event, {
170+
tokens,
171+
user: user.customer,
172+
})
173+
})
174+
}

src/runtime/types/oauth-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3'
22

33
export type ATProtoProvider = 'bluesky'
44

5-
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 & {})
5+
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 & {})
66

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

0 commit comments

Comments
 (0)