Skip to content

Commit b9ea44c

Browse files
committed
feat: add api client auth hooks
Signed-off-by: Frederik Bußmann <frederik@bussmann.io>
1 parent d09d283 commit b9ea44c

11 files changed

Lines changed: 309 additions & 69 deletions

File tree

docs/content/4.going-further/1.hooks.md

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,66 @@ export default defineNitroPlugin((app) => {
149149
```
150150
::
151151

152+
### Customer account auth hooks
153+
154+
The customer account authentication flow runs entirely on the server, so these hooks are only available in a Nitro plugin.
155+
Use them to customize the OAuth request, run side effects on login/logout, or observe token refreshes.
156+
157+
```ts [~/server/plugins/customer-account-auth.ts]
158+
export default defineNitroPlugin((app) => {
159+
app.hooks.hook('customer-account:auth:authorize', ({ params }) => {
160+
// Mutate the authorization request, e.g. request a localized login screen
161+
params.locale = 'en'
162+
})
163+
164+
app.hooks.hook('customer-account:auth:success', ({ user, tokens }) => {
165+
// React to a successful login (merge the guest cart, track analytics, ...)
166+
console.log('Customer logged in:', user.email)
167+
})
168+
169+
app.hooks.hook('customer-account:auth:refresh', ({ tokens }) => {
170+
console.log('Access token refreshed, expires at:', tokens.expiresAt)
171+
})
172+
173+
app.hooks.hook('customer-account:auth:logout', ({ user }) => {
174+
console.log('Customer logged out:', user?.email)
175+
})
176+
177+
app.hooks.hook('customer-account:auth:error', ({ error }) => {
178+
console.error('Customer account OAuth failed:', error)
179+
})
180+
})
181+
```
182+
183+
### Admin auth hooks
184+
185+
The admin API authenticates with a machine-to-machine OAuth flow (client credentials, with refresh token rotation), so these hooks are only available in a Nitro plugin.
186+
Use them to customize the token request, or to observe when an access token is issued, refreshed, or fails to issue.
187+
188+
These hooks only fire when the module obtains a token on your behalf. They are skipped when a static `accessToken` is configured.
189+
190+
```ts [~/server/plugins/admin-auth.ts]
191+
export default defineNitroPlugin((app) => {
192+
app.hooks.hook('admin:auth:request', ({ params }) => {
193+
// Mutate the token request body before it is sent
194+
params.scope = 'my-custom-scope'
195+
})
196+
197+
app.hooks.hook('admin:auth:success', ({ token }) => {
198+
// React to a freshly issued access token (client credentials grant)
199+
console.log('Admin access token obtained, expires at:', token.expiresAt)
200+
})
201+
202+
app.hooks.hook('admin:auth:refresh', ({ token }) => {
203+
console.log('Admin access token refreshed, expires at:', token.expiresAt)
204+
})
205+
206+
app.hooks.hook('admin:auth:error', ({ error }) => {
207+
console.error('Admin token request failed:', error)
208+
})
209+
})
210+
```
211+
152212
## Hooks reference
153213

154214
Hooks allows you to hook into the different stages of the module lifecycle.
@@ -185,20 +245,29 @@ Hooks allows you to hook into the different stages of the module lifecycle.
185245

186246
### Server Hooks (Runtime)
187247

188-
| Hook | Arguments | Environments | Description |
189-
| ----------------------------------- | :--------------------------------: | :----------: | ------------------------------------------------------------- |
190-
| `storefront:client:configure` | `config` | Server | Called before the storefront client is created |
191-
| `storefront:client:create` | `client` | Server | Called after the storefront client is created |
192-
| `storefront:client:request` | `operation`, `options` | Server | Called before the storefront client sends a request |
193-
| `storefront:client:response` | `response`, `operation`, `options` | Server | Called after the storefront client receives a response |
194-
| `storefront:client:errors` | `errors` | Server | Called when a storefront client request contains errors |
195-
| `customer-account:client:configure` | `config` | Server | Called before the customer account client is created |
196-
| `customer-account:client:create` | `client` | Server | Called after the customer account client is created |
197-
| `customer-account:client:request` | `operation`, `options` | Server | Called before the customer account client sends a request |
198-
| `customer-account:client:response` | `response`, `operation`, `options` | Server | Called after the customer account client receives a response |
199-
| `customer-account:client:errors` | `errors` | Server | Called when a customer account client request contains errors |
200-
| `admin:client:configure` | `config` | Server | Called before the admin client is created |
201-
| `admin:client:create` | `client` | Server | Called after the admin client is created |
202-
| `admin:client:request` | `operation`, `options` | Server | Called before the admin client sends a request |
203-
| `admin:client:response` | `response`, `operation`, `options` | Server | Called after the admin client receives a response |
204-
| `admin:client:errors` | `errors` | Server | Called when an admin client request contains errors |
248+
| Hook | Arguments | Environments | Description |
249+
| ----------------------------------- | :--------------------------------: | :----------: | ------------------------------------------------------------------------------- |
250+
| `storefront:client:configure` | `config` | Server | Called before the storefront client is created |
251+
| `storefront:client:create` | `client` | Server | Called after the storefront client is created |
252+
| `storefront:client:request` | `operation`, `options` | Server | Called before the storefront client sends a request |
253+
| `storefront:client:response` | `response`, `operation`, `options` | Server | Called after the storefront client receives a response |
254+
| `storefront:client:errors` | `errors` | Server | Called when a storefront client request contains errors |
255+
| `customer-account:client:configure` | `config` | Server | Called before the customer account client is created |
256+
| `customer-account:client:create` | `client` | Server | Called after the customer account client is created |
257+
| `customer-account:client:request` | `operation`, `options` | Server | Called before the customer account client sends a request |
258+
| `customer-account:client:response` | `response`, `operation`, `options` | Server | Called after the customer account client receives a response |
259+
| `customer-account:client:errors` | `errors` | Server | Called when a customer account client request contains errors |
260+
| `customer-account:auth:authorize` | `params` | Server | Called before redirecting to Shopify to start the OAuth flow (mutable `params`) |
261+
| `customer-account:auth:success` | `user`, `tokens` | Server | Called after a successful login, before the session is persisted |
262+
| `customer-account:auth:refresh` | `tokens` | Server | Called after the customer account access token is refreshed |
263+
| `customer-account:auth:logout` | `user`, `idToken` | Server | Called before the session is cleared on logout |
264+
| `customer-account:auth:error` | `error` | Server | Called when the customer account OAuth flow fails |
265+
| `admin:auth:request` | `params` | Server | Called before the admin access token request is sent (mutable `params`) |
266+
| `admin:auth:success` | `token` | Server | Called after an admin access token is obtained (client credentials grant) |
267+
| `admin:auth:refresh` | `token` | Server | Called after the admin access token is refreshed (refresh token grant) |
268+
| `admin:auth:error` | `error` | Server | Called when the admin access token request fails |
269+
| `admin:client:configure` | `config` | Server | Called before the admin client is created |
270+
| `admin:client:create` | `client` | Server | Called after the admin client is created |
271+
| `admin:client:request` | `operation`, `options` | Server | Called before the admin client sends a request |
272+
| `admin:client:response` | `response`, `operation`, `options` | Server | Called after the admin client receives a response |
273+
| `admin:client:errors` | `errors` | Server | Called when an admin client request contains errors |

playgrounds/playground-v4/server/plugins/server.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,54 @@ export default defineNitroPlugin((nitroApp) => {
2424
})
2525

2626
nitroApp.hooks.hook('admin:client:create', ({ client: _client }) => {
27-
console.log('[shopify] storefront client created')
27+
console.log('[shopify] admin client created')
2828
})
2929

3030
nitroApp.hooks.hook('admin:client:request', ({ operation: _operation, options: _options }) => {
31-
console.log('[shopify] storefront client request sent')
31+
console.log('[shopify] admin client request sent')
3232
})
3333

3434
nitroApp.hooks.hook('admin:client:response', ({ response: _response, operation: _operation, options: _options }) => {
35-
console.log('[shopify] storefront client received response')
35+
console.log('[shopify] admin client received response')
3636
})
3737

3838
nitroApp.hooks.hook('admin:client:errors', ({ errors: _errors }) => {
39-
console.log('[shopify] storefront client received errors')
39+
console.log('[shopify] admin client received errors')
40+
})
41+
42+
nitroApp.hooks.hook('customer-account:auth:authorize', ({ params }) => {
43+
params.locale = 'en'
44+
})
45+
46+
nitroApp.hooks.hook('customer-account:auth:success', ({ user, tokens: _tokens }) => {
47+
console.log('[shopify] customer account login', user.email)
48+
})
49+
50+
nitroApp.hooks.hook('customer-account:auth:refresh', ({ tokens }) => {
51+
console.log('[shopify] customer account token refreshed', tokens.expiresAt)
52+
})
53+
54+
nitroApp.hooks.hook('customer-account:auth:logout', ({ user, idToken: _idToken }) => {
55+
console.log('[shopify] customer account logout', user?.email)
56+
})
57+
58+
nitroApp.hooks.hook('customer-account:auth:error', ({ error }) => {
59+
console.error('[shopify] customer account auth error', error)
60+
})
61+
62+
nitroApp.hooks.hook('admin:auth:request', ({ params }) => {
63+
console.log('[shopify] admin auth request', params.grant_type)
64+
})
65+
66+
nitroApp.hooks.hook('admin:auth:success', ({ token }) => {
67+
console.log('[shopify] admin access token obtained', token.expiresAt)
68+
})
69+
70+
nitroApp.hooks.hook('admin:auth:refresh', ({ token }) => {
71+
console.log('[shopify] admin access token refreshed', token.expiresAt)
72+
})
73+
74+
nitroApp.hooks.hook('admin:auth:error', ({ error }) => {
75+
console.error('[shopify] admin auth error', error)
4076
})
4177
})

src/runtime/server/api/auth/customer-account/callback.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { createError, defineEventHandler, deleteCookie, getCookie, getQuery, getRequestURL, sendRedirect, setCookie } from 'h3'
2+
import { useNitroApp } from 'nitropack/runtime'
23
import { useRuntimeConfig } from '#imports'
34
import { joinURL, withQuery } from 'ufo'
45

56
import { createStoreDomain } from '../../../../utils/client'
67
import { createBridgeNonce } from '../../../utils/customer-account/bridge'
78
import { setCustomerAccountSession } from '../../../utils/customer-account/session'
89
import {
10+
buildAuthorizationParams,
911
buildAuthorizationURL,
1012
exchangeAuthorizationCode,
1113
fetchCustomerIdentity,
@@ -51,6 +53,8 @@ export default defineEventHandler(async (event) => {
5153

5254
const configuration = await getOpenIdConfiguration(createStoreDomain(_shopify.name))
5355

56+
const nitroApp = useNitroApp()
57+
5458
const query = getQuery(event)
5559

5660
// First leg: no authorization code yet, so we start the OAuth flow.
@@ -69,13 +73,17 @@ export default defineEventHandler(async (event) => {
6973
codeChallenge = await generateCodeChallenge(codeVerifier)
7074
}
7175

72-
return sendRedirect(event, buildAuthorizationURL(configuration, {
76+
const params = buildAuthorizationParams({
7377
clientId: customerAccount.clientId,
7478
redirectUri,
7579
scope: customerAccount.scope,
7680
state,
7781
codeChallenge,
78-
}))
82+
})
83+
84+
await nitroApp.hooks.callHook('customer-account:auth:authorize', { params })
85+
86+
return sendRedirect(event, buildAuthorizationURL(configuration, params))
7987
}
8088

8189
// Second leg: Shopify redirected back with an authorization code.
@@ -111,6 +119,8 @@ export default defineEventHandler(async (event) => {
111119
expiresAt: Date.now() + (tokens.expires_in ?? 7200) * 1000,
112120
}
113121

122+
await nitroApp.hooks.callHook('customer-account:auth:success', { user, tokens: tokenSet })
123+
114124
const tunnelURL = customerAccount.dev?.tunnelURL
115125
const bridgeURL = customerAccount.dev?.bridgeURL
116126
const isTunnelHost = import.meta.dev && tunnelURL?.length && bridgeURL?.length && requestURL.toString().includes(tunnelURL)
@@ -133,6 +143,8 @@ export default defineEventHandler(async (event) => {
133143
catch (error) {
134144
console.error('[shopify] Customer account OAuth error:', error)
135145

146+
await nitroApp.hooks.callHook('customer-account:auth:error', { error })
147+
136148
return sendRedirect(event, withQuery(customerAccount.redirectURL, { customer_account_error: '1' }))
137149
}
138150
})

src/runtime/server/api/auth/customer-account/logout.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { createError, defineEventHandler, getRequestHeader, getRequestURL, sendRedirect } from 'h3'
2+
import { useNitroApp } from 'nitropack/runtime'
23
import { useRuntimeConfig } from '#imports'
34
import { joinURL } from 'ufo'
45

56
import { createStoreDomain } from '../../../../utils/client'
6-
import { clearCustomerAccountSession, getCustomerAccountTokens } from '../../../utils/customer-account/session'
7+
import { clearCustomerAccountSession, getCustomerAccountSession, getCustomerAccountTokens } from '../../../utils/customer-account/session'
78
import { buildLogoutURL, getOpenIdConfiguration } from '../../../utils/customer-account/oauth'
89

910
export default defineEventHandler(async (event) => {
@@ -21,9 +22,12 @@ export default defineEventHandler(async (event) => {
2122
throw createError({ statusCode: 403, statusMessage: '[shopify] Cross-site logout is not allowed' })
2223
}
2324

25+
const { user } = await getCustomerAccountSession(event)
2426
const tokens = await getCustomerAccountTokens(event)
2527
const idToken = tokens?.idToken
2628

29+
await useNitroApp().hooks.callHook('customer-account:auth:logout', { user, idToken })
30+
2731
await clearCustomerAccountSession(event)
2832

2933
if (!import.meta.dev && idToken) {

src/runtime/server/utils/admin/auth.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,25 @@
11
import type { AdminOperations } from '../../../../clients/admin'
22

33
import type { ShopifyApiClient, ShopifyConfig } from '../../../../types'
4+
import type { AdminTokenSet } from './types'
45

56
import { createError } from 'h3'
67

78
import { createStoreDomain } from '../../../utils/client'
89

910
type AdminConfig = NonNullable<ShopifyConfig['clients']['admin']>
1011

11-
type StoredToken = {
12-
accessToken: string
13-
expiresAt: number
14-
refreshToken?: string
15-
}
16-
17-
let pendingAccessTokenRequest: Promise<StoredToken> | undefined
12+
let pendingAccessTokenRequest: Promise<AdminTokenSet> | undefined
1813

1914
async function getTokenStorage(config: AdminConfig) {
2015
const storageBase = typeof config.tokenStorage === 'string'
2116
? config.tokenStorage
2217
: 'admin-token'
2318

24-
return (await import('nitropack/runtime')).useStorage<StoredToken>(storageBase)
19+
return (await import('nitropack/runtime')).useStorage<AdminTokenSet>(storageBase)
2520
}
2621

27-
function isTokenExpired(token: StoredToken): boolean {
22+
function isTokenExpired(token: AdminTokenSet): boolean {
2823
if (!token.expiresAt) return false
2924

3025
// Refresh 5 minutes before expiry
@@ -34,7 +29,7 @@ function isTokenExpired(token: StoredToken): boolean {
3429
async function fetchAccessToken(
3530
storeDomain: string,
3631
params: Record<string, string>,
37-
): Promise<StoredToken> {
32+
): Promise<AdminTokenSet> {
3833
const url = `${storeDomain}/admin/oauth/access_token`
3934

4035
const response = await globalThis.fetch(url, {
@@ -105,25 +100,45 @@ export async function getAdminAccessToken(shopName: string, config: AdminConfig,
105100

106101
const storeDomain = createStoreDomain(shopName)
107102

108-
pendingAccessTokenRequest = fetchAccessToken(storeDomain, storedToken?.refreshToken
103+
const isRefresh = !!storedToken?.refreshToken
104+
105+
const params: Record<string, string> = isRefresh
109106
? {
110107
grant_type: 'refresh_token',
111108
client_id: clientId,
112109
client_secret: clientSecret,
113-
refresh_token: storedToken.refreshToken,
110+
refresh_token: storedToken!.refreshToken!,
114111
}
115112
: {
116113
grant_type: 'client_credentials',
117114
client_id: clientId,
118115
client_secret: clientSecret,
119-
},
120-
).then(async (newToken) => {
121-
if (store) {
122-
await getTokenStorage(config).then(storage => storage.setItem('token', newToken))
116+
}
117+
118+
pendingAccessTokenRequest = (async () => {
119+
const nitroApp = await import('nitropack/runtime')
120+
.then(({ useNitroApp }) => useNitroApp())
121+
.catch(() => undefined)
122+
123+
try {
124+
await nitroApp?.hooks.callHook('admin:auth:request', { params })
125+
126+
const newToken = await fetchAccessToken(storeDomain, params)
127+
128+
if (store) {
129+
await getTokenStorage(config).then(storage => storage.setItem('token', newToken))
130+
}
131+
132+
await nitroApp?.hooks.callHook(isRefresh ? 'admin:auth:refresh' : 'admin:auth:success', { token: newToken })
133+
134+
return newToken
123135
}
136+
catch (error) {
137+
await nitroApp?.hooks.callHook('admin:auth:error', { error })
124138

125-
return newToken
126-
}).finally(() => {
139+
throw error
140+
}
141+
})().finally(() => {
127142
pendingAccessTokenRequest = undefined
128143
})
129144

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type { AdminTokenSet } from '../../../../types'

src/runtime/server/utils/customer-account/auth.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ShopifyApiClient } from '../../../../types'
55
import type { CustomerAccountSessionData, CustomerAccountTokenSet } from './session'
66

77
import { createError, getSession } from 'h3'
8+
import { useNitroApp } from 'nitropack/runtime'
89
import { useRuntimeConfig } from '#imports'
910

1011
import { createStoreDomain } from '../../../utils/client'
@@ -72,6 +73,8 @@ export async function getValidCustomerAccessToken(event: H3Event): Promise<strin
7273

7374
await storage.setItem(id, next)
7475

76+
await useNitroApp().hooks.callHook('customer-account:auth:refresh', { tokens: next })
77+
7578
return next
7679
})
7780
.finally(() => pendingRefreshRequests.delete(id))

0 commit comments

Comments
 (0)