Skip to content

Commit

Permalink
feat(clerk-auth): Migrate to Clerk Core v2 (#465)
Browse files Browse the repository at this point in the history
* chore(valibot-validator): Replace `jsonT` in tests

* fix(valibot-validator): Handle optional schema

* test(valibot-validator): Update tests

* chore(valibot-validator): Add changeset

* chore(valibot-validator): Fix formatting

* remove old changeset

* feat(clerk-auth): Migrate to Clerk Core v2

* chore: Add changeset

* fix: Add back tsup devDep

* chore: Fix lockfile

* chore: infer clerkAuth type instead of importing it directly

* chore: refactor redirect handling

* chore: remove unnecessary `rimraf` devDep

* chore: rewrite clerk devDeps versions

* chore: fix peerDeps

* chore: update lockfile

* drop `@clerk/shared` peerDep
  • Loading branch information
MonsterDeveloper authored Apr 25, 2024
1 parent 173e4ef commit 1823a28
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 236 deletions.
5 changes: 5 additions & 0 deletions .changeset/lazy-pumpkins-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/clerk-auth': major
---

Migrate to Clerk Core v2
4 changes: 0 additions & 4 deletions packages/clerk-auth/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,4 @@ module.exports = {
testMatch: ['**/test/**/*.+(ts|tsx|js)', '**/src/**/(*.)+(spec|test).+(ts|tsx|js)'],
transform: { '^.+\\.m?tsx?$': 'ts-jest' },
testPathIgnorePatterns: ['/node_modules/', '/jest/'],
moduleNameMapper: {
'#crypto': '@clerk/backend/dist/runtime/node/crypto.js',
'#fetch': '@clerk/backend/dist/runtime/node/fetch.js',
},
}
7 changes: 5 additions & 2 deletions packages/clerk-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,19 @@
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"@clerk/backend": ">=0.30.0 <1",
"@clerk/backend": "^1.0.0",
"hono": ">=3.*"
},
"devDependencies": {
"@clerk/backend": "^0.30.1",
"@clerk/backend": "^1.0.0",
"@types/react": "^18",
"hono": "^3.11.7",
"jest": "^29.7.0",
"node-fetch-native": "^1.4.0",
"react": "^18.2.0",
"tsup": "^8.0.1"
},
"engines": {
"node": ">=16.x.x"
}
}
50 changes: 17 additions & 33 deletions packages/clerk-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { ClerkOptions } from '@clerk/backend'
import { Clerk, createIsomorphicRequest, constants } from '@clerk/backend'
import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend'
import type { Context, MiddlewareHandler } from 'hono'
import { env } from 'hono/adapter'

type ClerkAuth = Awaited<ReturnType<ReturnType<typeof Clerk>['authenticateRequest']>>['toAuth']
type ClerkAuth = ReturnType<Awaited<ReturnType<ClerkClient['authenticateRequest']>>['toAuth']>

declare module 'hono' {
interface ContextVariableMap {
clerk: ReturnType<typeof Clerk>
clerkAuth: ReturnType<ClerkAuth>
clerk: ClerkClient
clerkAuth: ClerkAuth
}
}

Expand All @@ -21,7 +20,6 @@ type ClerkEnv = {
CLERK_PUBLISHABLE_KEY: string
CLERK_API_URL: string
CLERK_API_VERSION: string
CLERK_FRONTEND_API: string
}

export const clerkMiddleware = (options?: ClerkOptions): MiddlewareHandler => {
Expand All @@ -30,10 +28,9 @@ export const clerkMiddleware = (options?: ClerkOptions): MiddlewareHandler => {
const { secretKey, publishableKey, apiUrl, apiVersion, ...rest } = options || {
secretKey: clerkEnv.CLERK_SECRET_KEY || '',
publishableKey: clerkEnv.CLERK_PUBLISHABLE_KEY || '',
apiUrl: clerkEnv.CLERK_API_URL || 'https://api.clerk.dev',
apiVersion: clerkEnv.CLERK_API_VERSION || 'v1',
apiUrl: clerkEnv.CLERK_API_URL,
apiVersion: clerkEnv.CLERK_API_VERSION,
}
const frontendApi = clerkEnv.CLERK_FRONTEND_API || ''
if (!secretKey) {
throw new Error('Missing Clerk Secret key')
}
Expand All @@ -42,43 +39,30 @@ export const clerkMiddleware = (options?: ClerkOptions): MiddlewareHandler => {
throw new Error('Missing Clerk Publishable key')
}

const clerkClient = Clerk({
const clerkClient = createClerkClient({
...rest,
apiUrl,
apiVersion,
secretKey,
publishableKey,
})

const requestState = await clerkClient.authenticateRequest({
const requestState = await clerkClient.authenticateRequest(c.req.raw, {
...rest,
secretKey,
publishableKey,
request: createIsomorphicRequest((Request) => {
return new Request(c.req.url, {
method: c.req.method,
headers: c.req.raw.headers,
})
}),
})

// Interstitial cases
if (requestState.isUnknown) {
c.header(constants.Headers.AuthReason, requestState.reason)
c.header(constants.Headers.AuthMessage, requestState.message)
return c.body(null, 401)
}

if (requestState.isInterstitial) {
const interstitialHtmlPage = clerkClient.localInterstitial({
publishableKey,
frontendApi,
})

c.header(constants.Headers.AuthReason, requestState.reason)
c.header(constants.Headers.AuthMessage, requestState.message)
if (requestState.headers) {
requestState.headers.forEach((value, key) => c.res.headers.append(key, value))

return c.html(interstitialHtmlPage, 401)
const locationHeader = requestState.headers.get('location')

if (locationHeader) {
return c.redirect(locationHeader, 307)
} else if (requestState.status === 'handshake') {
throw new Error('Clerk: unexpected handshake without redirect')
}
}

c.set('clerkAuth', requestState.toAuth())
Expand Down
99 changes: 31 additions & 68 deletions packages/clerk-auth/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,13 @@ const EnvVariables = {
}

const authenticateRequestMock = jest.fn()
const localInterstitialMock = jest.fn()

jest.mock('@clerk/backend', () => {
return {
...jest.requireActual('@clerk/backend'),
Clerk: () => {
createClerkClient: () => {
return {
authenticateRequest: (...args: any) => authenticateRequestMock(...args),
localInterstitial: (...args: any) => localInterstitialMock(...args),
}
},
}
Expand All @@ -36,10 +34,8 @@ describe('clerkMiddleware()', () => {
})

test('handles signin with Authorization Bearer', async () => {
authenticateRequestMock.mockResolvedValue({
isUnknown: false,
isInterstitial: false,
isSignedIn: true,
authenticateRequestMock.mockResolvedValueOnce({
headers: new Headers(),
toAuth: () => 'mockedAuth',
})
const app = new Hono()
Expand Down Expand Up @@ -67,20 +63,17 @@ describe('clerkMiddleware()', () => {

expect(response.status).toEqual(200)
expect(await response.json()).toEqual({ auth: 'mockedAuth' })
expect(authenticateRequestMock).toBeCalledWith(
expect(authenticateRequestMock).toHaveBeenCalledWith(
expect.any(Request),
expect.objectContaining({
secretKey: EnvVariables.CLERK_SECRET_KEY,
publishableKey: EnvVariables.CLERK_PUBLISHABLE_KEY,
request: expect.any(Request),
})
}),
)
})

test('handles signin with cookie', async () => {
authenticateRequestMock.mockResolvedValue({
isUnknown: false,
isInterstitial: false,
isSignedIn: true,
authenticateRequestMock.mockResolvedValueOnce({
headers: new Headers(),
toAuth: () => 'mockedAuth',
})
const app = new Hono()
Expand Down Expand Up @@ -108,22 +101,25 @@ describe('clerkMiddleware()', () => {

expect(response.status).toEqual(200)
expect(await response.json()).toEqual({ auth: 'mockedAuth' })
expect(authenticateRequestMock).toBeCalledWith(
expect(authenticateRequestMock).toHaveBeenCalledWith(
expect.any(Request),
expect.objectContaining({
secretKey: EnvVariables.CLERK_SECRET_KEY,
publishableKey: EnvVariables.CLERK_PUBLISHABLE_KEY,
request: expect.any(Request),
})
}),
)
})

test('handles unknown case by terminating the request with empty response and 401 http code', async () => {
authenticateRequestMock.mockResolvedValue({
isUnknown: true,
isInterstitial: false,
isSignedIn: false,
test('handles handshake case by redirecting the request to fapi', async () => {
authenticateRequestMock.mockResolvedValueOnce({
status: 'handshake',
reason: 'auth-reason',
message: 'auth-message',
headers: new Headers({
location: 'https://fapi.example.com/v1/clients/handshake',
'x-clerk-auth-message': 'auth-message',
'x-clerk-auth-reason': 'auth-reason',
'x-clerk-auth-status': 'handshake',
}),
toAuth: () => 'mockedAuth',
})
const app = new Hono()
Expand All @@ -142,50 +138,18 @@ describe('clerkMiddleware()', () => {

const response = await app.request(req)

expect(response.status).toEqual(401)
expect(response.headers.get('x-clerk-auth-reason')).toEqual('auth-reason')
expect(response.headers.get('x-clerk-auth-message')).toEqual('auth-message')
expect(await response.text()).toEqual('')
})

test('handles interstitial case by terminating the request with interstitial html page and 401 http code', async () => {
authenticateRequestMock.mockResolvedValue({
isUnknown: false,
isInterstitial: true,
isSignedIn: false,
reason: 'auth-reason',
message: 'auth-message',
toAuth: () => 'mockedAuth',
expect(response.status).toEqual(307)
expect(Object.fromEntries(response.headers.entries())).toMatchObject({
location: 'https://fapi.example.com/v1/clients/handshake',
'x-clerk-auth-status': 'handshake',
'x-clerk-auth-reason': 'auth-reason',
'x-clerk-auth-message': 'auth-message',
})
localInterstitialMock.mockReturnValue('<html><body>Interstitial</body></html>')
const app = new Hono()
app.use('*', clerkMiddleware())

app.get('/', (ctx) => {
const auth = getAuth(ctx)
return ctx.json({ auth })
})

const req = new Request('http://localhost/', {
headers: {
cookie: '_gcl_au=value1; ko_id=value2; __session=deadbeef; __client_uat=1675692233',
},
})

const response = await app.request(req)

expect(response.status).toEqual(401)
expect(response.headers.get('content-type')).toMatch('text/html')
expect(response.headers.get('x-clerk-auth-reason')).toEqual('auth-reason')
expect(response.headers.get('x-clerk-auth-message')).toEqual('auth-message')
expect(await response.text()).toEqual('<html><body>Interstitial</body></html>')
})

test('handles signout case by populating the req.auth', async () => {
authenticateRequestMock.mockResolvedValue({
isUnknown: false,
isInterstitial: false,
isSignedIn: false,
authenticateRequestMock.mockResolvedValueOnce({
headers: new Headers(),
toAuth: () => 'mockedAuth',
})
const app = new Hono()
Expand All @@ -206,12 +170,11 @@ describe('clerkMiddleware()', () => {

expect(response.status).toEqual(200)
expect(await response.json()).toEqual({ auth: 'mockedAuth' })
expect(authenticateRequestMock).toBeCalledWith(
expect(authenticateRequestMock).toHaveBeenCalledWith(
expect.any(Request),
expect.objectContaining({
secretKey: EnvVariables.CLERK_SECRET_KEY,
publishableKey: EnvVariables.CLERK_PUBLISHABLE_KEY,
request: expect.any(Request),
})
}),
)
})
})
Loading

0 comments on commit 1823a28

Please sign in to comment.