feat: Stytch Authentication middleware (#1186)

* feat: Stytch Authentication middlewares

* Add changeset

* Better README

* Refresh yarn.lock

* fix: Remove unused dev deps, linter

* README and light renaming

* Remove ci-stytch-auth workflow

* rerun prettier

* add troubleshooting to readme
pull/1224/head
Max Gerber 2025-06-13 13:48:00 -07:00 committed by GitHub
parent 44b1c24b95
commit ede1aaff4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2019 additions and 1 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/stytch-auth': minor
---
Initial release of @hono/stytch-auth package

View File

@ -0,0 +1,398 @@
# Stytch Auth Middleware for Hono
[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=stytch-auth)](https://codecov.io/github/honojs/middleware)
A third-party [Stytch](https://stytch.com) authentication middleware for [Hono](https://github.com/honojs/hono). Supports both [Consumer](https://stytch.com/b2c) and [B2B](https://stytch.com/b2b) authentication with flexible configuration options.
> 💡 This package works with [Stytch Frontend SDKs](https://stytch.com/docs/guides/sessions/frontend-guide) and validates sessions they create. By default, it reads JWTs from the `stytch_session_jwt` cookie. See the [Session JWTs guide](https://stytch.com/docs/guides/sessions/using-jwts) for more details.
## Quick Start
The fastest way to get started with Consumer authentication:
```ts
import { Hono } from 'hono'
import { Consumer } from '@hono/stytch-auth'
const app = new Hono()
// Authenticate all routes
app.use('*', Consumer.authenticateSessionLocal())
app.get('/', (c) => {
const session = Consumer.getStytchSession(c)
return c.json({ message: `Hello ${session.user_id}!` })
})
export default app
```
## Installation
```bash
npm install hono @hono/stytch-auth stytch
```
## Configuration
Set these environment variables before using the middleware:
```bash
STYTCH_PROJECT_ID=project-live-xxx-xxx-xxx
STYTCH_PROJECT_SECRET=secret-live-xxx-xxx-xxx
```
## Table of Contents
- [Consumer Authentication](#consumer-authentication)
- [Basic Session Auth](#basic-consumer-session-auth)
- [OAuth Bearer Token Auth](#consumer-oauth-auth)
- [B2B Authentication](#b2b-authentication)
- [Basic Session Auth](#basic-b2b-session-auth)
- [OAuth Bearer Token Auth](#b2b-oauth-auth)
- [API Reference](#api-reference)
- [Advanced Usage](#advanced-usage)
- [Custom Configuration](#custom-configuration)
- [Error Handling](#custom-error-handling)
- [Troubleshooting](#troubleshooting)
## Consumer Authentication
### Basic Consumer Session Auth
**Local Authentication** (JWT validation only - fastest):
```ts
import { Consumer } from '@hono/stytch-auth'
// Validates JWT locally using cached JWKS
app.use('*', Consumer.authenticateSessionLocal())
app.get('/profile', (c) => {
const session = Consumer.getStytchSession(c)
return c.json({ sessionId: session.session_id })
})
```
**Remote Authentication** (validates with Stytch servers - more data):
```ts
import { Consumer } from '@hono/stytch-auth'
// Always calls Stytch servers, returns user data
app.use('*', Consumer.authenticateSessionRemote())
app.get('/profile', (c) => {
const session = Consumer.getStytchSession(c)
const user = Consumer.getStytchUser(c)
return c.json({
sessionId: session.session_id,
userId: user.user_id,
})
})
```
### Consumer OAuth Auth
```ts
import { Consumer } from '@hono/stytch-auth'
app.use('*', Consumer.authenticateOAuthToken())
app.get('/api/data', (c) => {
const { claims, token } = Consumer.getOAuthData(c)
return c.json({
subject: claims.subject,
hasValidToken: !!token,
})
})
```
## B2B Authentication
### Basic B2B Session Auth
**Local Authentication:**
```ts
import { B2B } from '@hono/stytch-auth'
app.use('*', B2B.authenticateSessionLocal())
app.get('/dashboard', (c) => {
const session = B2B.getStytchSession(c)
return c.json({
sessionId: session.member_session_id,
orgId: session.organization_id,
})
})
```
**Remote Authentication:**
```ts
import { B2B } from '@hono/stytch-auth'
app.use('*', B2B.authenticateSessionRemote())
app.get('/dashboard', (c) => {
const session = B2B.getStytchSession(c)
const member = B2B.getStytchMember(c)
const organization = B2B.getStytchOrganization(c)
return c.json({
sessionId: session.member_session_id,
memberEmail: member.email_address,
orgName: organization.organization_name,
})
})
```
### B2B OAuth Auth
```ts
import { B2B } from '@hono/stytch-auth'
app.use('*', B2B.authenticateOAuthToken())
app.get('/api/org-data', (c) => {
const { claims, token } = B2B.getOAuthData(c)
return c.json({
subject: claims.subject,
hasValidToken: !!token,
})
})
```
### B2B Organization Access
After B2B remote authentication, you get access to organization data:
```ts
app.use('*', B2B.authenticateSessionRemote())
app.get('/org-settings', (c) => {
const organization = B2B.getStytchOrganization(c)
return c.json({
orgId: organization.organization_id,
orgName: organization.organization_name,
// ... other organization fields
})
})
```
## API Reference
### Consumer Methods
| Method | Description | Returns |
| ------------------------------------------- | ------------------------------- | ------------------------------------------------ |
| `Consumer.getClient(c)` | Get Consumer Stytch client | `Client` |
| `Consumer.authenticateSessionLocal(opts?)` | JWT-only auth middleware | `MiddlewareHandler` |
| `Consumer.authenticateSessionRemote(opts?)` | Remote session auth middleware | `MiddlewareHandler` |
| `Consumer.authenticateOAuthToken(opts?)` | OAuth bearer token middleware | `MiddlewareHandler` |
| `Consumer.getStytchSession(c)` | Get session from context | `Session` |
| `Consumer.getStytchUser(c)` | Get user from context\* | `User` |
| `Consumer.getOAuthData(c)` | Get OAuth data from context\*\* | `{ claims: ConsumerTokenClaims, token: string }` |
\*Only available after `authenticateSessionRemote`
\*\*Only available after `authenticateOAuthToken`
### B2B Methods
| Method | Description | Returns |
| -------------------------------------- | ----------------------------------- | ------------------------------------------- |
| `B2B.getClient(c)` | Get B2B Stytch client | `B2BClient` |
| `B2B.authenticateSessionLocal(opts?)` | JWT-only auth middleware | `MiddlewareHandler` |
| `B2B.authenticateSessionRemote(opts?)` | Remote session auth middleware | `MiddlewareHandler` |
| `B2B.authenticateOAuthToken(opts?)` | B2B OAuth bearer token middleware | `MiddlewareHandler` |
| `B2B.getStytchSession(c)` | Get B2B session from context | `MemberSession` |
| `B2B.getStytchMember(c)` | Get member from context\* | `Member` |
| `B2B.getStytchOrganization(c)` | Get organization from context\* | `Organization` |
| `B2B.getOAuthData(c)` | Get B2B OAuth data from context\*\* | `{ claims: B2BTokenClaims, token: string }` |
\*Only available after `authenticateSessionRemote`
\*\*Only available after `authenticateOAuthToken`
### Configuration Options
#### Session Authentication Options
```ts
{
getCredential?: (c: Context) => { session_jwt: string } | { session_token: string },
maxTokenAgeSeconds?: number, // For local auth only
onError?: (c: Context, error: Error) => Response | void
}
```
#### OAuth Authentication Options
```ts
{
getCredential?: (c: Context) => { access_token: string },
onError?: (c: Context, error: Error) => void
}
```
## Advanced Usage
### Accessing Raw Stytch Clients
For advanced operations, access the underlying Stytch clients:
```ts
// Consumer operations
app.get('/advanced-user-ops', async (c) => {
const stytchClient = Consumer.getClient(c)
const { user } = await stytchClient.users.get({ user_id: 'user-123' })
return c.json({ user })
})
// B2B operations
app.get('/advanced-org-ops', async (c) => {
const stytchClient = B2B.getClient(c)
const { organization } = await stytchClient.organizations.get({
organization_id: 'org-123',
})
return c.json({ organization })
})
```
### Custom Configuration
**Custom Cookie Name:**
```ts
import { getCookie } from 'hono/cookie'
app.use(
'*',
Consumer.authenticateSessionLocal({
getCredential: (c) => ({
session_jwt: getCookie(c, 'my_session_cookie') ?? '',
}),
})
)
```
**Custom Token Age:**
```ts
app.use(
'*',
Consumer.authenticateSessionLocal({
maxTokenAgeSeconds: 60, // 1 minute
})
)
```
### Custom Error Handling
**Redirect to Login:**
```ts
app.use(
'*',
Consumer.authenticateSessionLocal({
onError: (c, error) => {
return c.redirect('/login')
},
})
)
```
**Custom Error Response:**
```ts
import { HTTPException } from 'hono/http-exception'
app.use(
'*',
Consumer.authenticateOAuthToken({
onError: (c, error) => {
const errorResponse = new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="api", error="invalid_token"',
},
})
throw new HTTPException(401, { res: errorResponse })
},
})
)
```
### Multiple Authentication Methods
You can use different auth methods for different routes:
```ts
const app = new Hono()
// Public routes (no auth)
app.get('/health', (c) => c.json({ status: 'ok' }))
// Less-sensitive routes use local authentication against cached JWT / JWKS
app.get('/profile', Consumer.authenticateSessionLocal(), (c) => {
const session = Consumer.getStytchSession(c)
return c.json({ session })
})
// More sensitive routes use remote authentication
app.get('/paymentinfo', Consumer.authenticateSessionRemote(), (c) => {
const member = B2B.getStytchMember(c)
const organization = B2B.getStytchOrganization(c)
return c.json({ member, organization })
})
// OAuth API routes
app.use('/api/*', Consumer.authenticateOAuthToken())
app.get('/api/data', (c) => {
const { claims } = Consumer.getOAuthData(c)
return c.json({ subject: claims.subject })
})
```
### Custom Credential Extraction Examples
**From Authorization Header:**
```ts
getCredential: (c) => ({
session_jwt: c.req.header('Authorization')?.replace('Bearer ', '') ?? '',
})
```
**From Query Parameter:**
```ts
getCredential: (c) => ({
session_jwt: c.req.query('token') ?? '',
})
```
**From Custom Header:**
```ts
getCredential: (c) => ({
access_token: c.req.header('X-API-Token') ?? '',
})
```
## Troubleshooting
### Cloudflare Worker Cache Field issues
```
The 'cache' field on 'RequestInitializerDict' is not implemented.
```
In Cloudflare Workers the worker `compatibility_date` must be set to `2024-11-11` or later in the `wranger.jsonc`.
Workers with earlier compatibility dates can set the `cache_option_enabled` flag detailed
[here](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-cache-no-store-http-standard-api).
---
For more information, visit the [Stytch documentation](https://stytch.com/docs) or the [Hono documentation](https://hono.dev/).

View File

@ -0,0 +1,57 @@
{
"name": "@hono/stytch-auth",
"version": "0.0.0",
"description": "A third-party Stytch auth middleware for Hono",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup ./src/index.ts",
"prepack": "yarn build",
"publint": "attw --pack && publint",
"typecheck": "tsc -b tsconfig.json",
"test": "vitest"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/honojs/middleware.git",
"directory": "packages/stytch-auth"
},
"homepage": "https://github.com/honojs/middleware",
"peerDependencies": {
"hono": ">=3.*",
"stytch": "^12.19.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.4",
"publint": "^0.3.9",
"stytch": "^12.19.0",
"tsup": "^8.4.0",
"typescript": "^5.8.2",
"vitest": "^3.0.8"
},
"engines": {
"node": ">=22.x.x"
}
}

View File

@ -0,0 +1,950 @@
import { Hono } from 'hono'
import { getCookie } from 'hono/cookie'
import { HTTPException } from 'hono/http-exception'
import type { Mock } from 'vitest'
import { Consumer, B2B } from '.'
type sessionMock = {
authenticate: Mock
authenticateJwt: Mock
}
type oauthMock = {
introspectTokenLocal: Mock
}
type b2bSessionMock = {
authenticate: Mock
authenticateJwt: Mock
}
type b2bIdpMock = {
introspectTokenLocal: Mock
}
vi.mock(import('stytch'), async (importOriginal) => {
const original = await importOriginal()
const ConsumerSessions: sessionMock = {
authenticate: vi.fn(),
authenticateJwt: vi.fn(),
}
const ConsumerOAuth: oauthMock = {
introspectTokenLocal: vi.fn(),
}
const B2BSessions: b2bSessionMock = {
authenticate: vi.fn(),
authenticateJwt: vi.fn(),
}
const B2BIdp: b2bIdpMock = {
introspectTokenLocal: vi.fn(),
}
vi.stubGlobal('__stytchConsumersessionMock', ConsumerSessions)
vi.stubGlobal('__stytchConsumerOAuthMock', ConsumerOAuth)
vi.stubGlobal('__stytchB2BsessionMock', B2BSessions)
vi.stubGlobal('__stytchB2BIdpMock', B2BIdp)
// Forcing the mocked class constructor to be a real type causes tsc to crash
// e.g. same error as https://github.com/microsoft/TypeScript/issues/52952 but probably different bug
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ConsumerClient: any = vi.fn(function (this: any, { project_id, secret }) {
this.project_id = project_id
this.secret = secret
this.sessions = ConsumerSessions
this.idp = ConsumerOAuth
})
// Forcing the mocked class constructor to be a real type causes tsc to crash
// e.g. same error as https://github.com/microsoft/TypeScript/issues/52952 but probably different bug
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const B2BClientMock: any = vi.fn(function (this: any, { project_id, secret }) {
this.project_id = project_id
this.secret = secret
this.sessions = B2BSessions
this.idp = B2BIdp
})
return {
...original,
Client: ConsumerClient,
B2BClient: B2BClientMock,
}
})
describe('Consumer', () => {
//@ts-expect-error set on globalThis in vi.mock
const sessionMock = __stytchConsumersessionMock as sessionMock
//@ts-expect-error set on globalThis in vi.mock
const oauthMock = __stytchConsumerOAuthMock as oauthMock
beforeEach(() => {
vi.stubEnv('STYTCH_PROJECT_ID', 'project-test-xxxxx')
vi.stubEnv('STYTCH_PROJECT_SECRET', 'secret-key-test-xxxxx')
})
describe('getClient', () => {
test('Instantiates client from ctx for handlers to use', async () => {
const app = new Hono()
const req = new Request('http://localhost/')
app.get('/', (ctx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = Consumer.getClient(ctx) as any
return ctx.json({ project_id: client.project_id, secret: client.secret })
})
const response = await app.request(req)
expect(response.status).toEqual(200)
expect(await response.json()).toEqual({
project_id: 'project-test-xxxxx',
secret: 'secret-key-test-xxxxx',
})
})
})
describe('authenticateSessionLocal', () => {
it('authenticates with default cookie, stores session, and retrieves via getStytchSession', async () => {
const mockSession = { session_id: 'session_123', user_id: 'user_123' }
sessionMock.authenticateJwt.mockResolvedValue({ session: mockSession })
const app = new Hono()
app.use('*', Consumer.authenticateSessionLocal())
app.get('/', (c) => {
const session = Consumer.getStytchSession(c)
return c.json({ session_id: session.session_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=jwt_token_123' },
})
const response = await app.request(req)
expect(sessionMock.authenticateJwt).toHaveBeenCalledWith({
session_jwt: 'jwt_token_123',
max_token_age_seconds: undefined,
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ session_id: 'session_123' })
})
it('uses custom getCredential with custom cookie name', async () => {
const mockSession = { session_id: 'session_456', user_id: 'user_456' }
sessionMock.authenticateJwt.mockResolvedValue({ session: mockSession })
const app = new Hono()
app.use(
'*',
Consumer.authenticateSessionLocal({
getCredential: (c) => ({ session_jwt: getCookie(c, 'custom_jwt_cookie') ?? '' }),
})
)
app.get('/', (c) => {
const session = Consumer.getStytchSession(c)
return c.json({ session_id: session.session_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'custom_jwt_cookie=custom_jwt_token' },
})
const response = await app.request(req)
expect(sessionMock.authenticateJwt).toHaveBeenCalledWith({
session_jwt: 'custom_jwt_token',
max_token_age_seconds: undefined,
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ session_id: 'session_456' })
})
it('passes maxTokenAgeSeconds to authenticateJwt', async () => {
const mockSession = { session_id: 'session_789', user_id: 'user_789' }
sessionMock.authenticateJwt.mockResolvedValue({ session: mockSession })
const app = new Hono()
app.use('*', Consumer.authenticateSessionLocal({ maxTokenAgeSeconds: 3600 }))
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=jwt_token_789' },
})
await app.request(req)
expect(sessionMock.authenticateJwt).toHaveBeenCalledWith({
session_jwt: 'jwt_token_789',
max_token_age_seconds: 3600,
})
})
})
describe('authenticateSessionRemote', () => {
it('authenticates with default cookie, stores session and user, and retrieves via getStytchSession/getStytchUser', async () => {
const mockSession = { session_id: 'session_remote_123', user_id: 'user_remote_123' }
const mockUser = { user_id: 'user_remote_123', name: { first_name: 'John' } }
sessionMock.authenticate.mockResolvedValue({ session: mockSession, user: mockUser })
const app = new Hono()
app.use('*', Consumer.authenticateSessionRemote())
app.get('/', (c) => {
const session = Consumer.getStytchSession(c)
const user = Consumer.getStytchUser(c)
return c.json({ session_id: session.session_id, user_id: user.user_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=jwt_token_remote_123' },
})
const response = await app.request(req)
expect(sessionMock.authenticate).toHaveBeenCalledWith({
session_jwt: 'jwt_token_remote_123',
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
session_id: 'session_remote_123',
user_id: 'user_remote_123',
})
})
it('uses custom getCredential with session_token', async () => {
const mockSession = { session_id: 'session_token_456', user_id: 'user_token_456' }
const mockUser = { user_id: 'user_token_456', name: { first_name: 'Jane' } }
sessionMock.authenticate.mockResolvedValue({ session: mockSession, user: mockUser })
const app = new Hono()
app.use(
'*',
Consumer.authenticateSessionRemote({
getCredential: (c) => ({ session_token: getCookie(c, 'stytch_session_token') ?? '' }),
})
)
app.get('/', (c) => {
const user = Consumer.getStytchUser(c)
return c.json({ user_id: user.user_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_token=session_token_value' },
})
const response = await app.request(req)
expect(sessionMock.authenticate).toHaveBeenCalledWith({
session_token: 'session_token_value',
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ user_id: 'user_token_456' })
})
})
describe('authenticateSessionLocal onError', () => {
it('calls onError callback when JWT authentication fails', async () => {
sessionMock.authenticateJwt.mockRejectedValue(new Error('Invalid JWT'))
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
Consumer.authenticateSessionLocal({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=invalid_jwt' },
})
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('uses onError return value when provided', async () => {
sessionMock.authenticateJwt.mockRejectedValue(new Error('Invalid JWT'))
const app = new Hono()
app.use(
'*',
Consumer.authenticateSessionLocal({
onError: (c) => {
return c.redirect('/login')
},
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=invalid_jwt' },
})
const response = await app.request(req)
expect(response.status).toBe(302)
expect(response.headers.get('Location')).toBe('/login')
})
})
describe('authenticateSessionRemote onError', () => {
it('calls onError callback when remote authentication fails', async () => {
sessionMock.authenticate.mockRejectedValue(new Error('Session expired'))
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
Consumer.authenticateSessionRemote({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=expired_jwt' },
})
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('demonstrates onError with WWW-Authenticate header', async () => {
sessionMock.authenticate.mockRejectedValue(new Error('Session expired'))
const app = new Hono()
app.use(
'*',
Consumer.authenticateSessionRemote({
onError: () => {
const errorResponse = new Response('Session expired', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="app"',
},
})
throw new HTTPException(401, { res: errorResponse })
},
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=expired_jwt' },
})
const response = await app.request(req)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="app"')
})
})
describe('getStytchSession', () => {
it('throws error when no session in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => Consumer.getStytchSession(c)).toThrow(
'No session in context. Was Consumer.authenticateSessionLocal or Consumer.authenticateSessionRemote called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
describe('getStytchUser', () => {
it('throws error when no user in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => Consumer.getStytchUser(c)).toThrow(
'No user in context. Was Consumer.authenticateSessionRemote called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
describe('authenticateOAuthToken', () => {
it('authenticates with bearer token and stores subject and token', async () => {
const mockClaims = { subject: 'user_oauth_123' }
oauthMock.introspectTokenLocal.mockResolvedValue(mockClaims)
const app = new Hono()
app.use('*', Consumer.authenticateOAuthToken())
app.get('/', (c) => {
const oauthData = Consumer.getOAuthData(c)
return c.json({ subject: oauthData.claims.subject, hasToken: !!oauthData.token })
})
const req = new Request('http://localhost/', {
headers: { Authorization: 'Bearer oauth_token_123' },
})
const response = await app.request(req)
expect(oauthMock.introspectTokenLocal).toHaveBeenCalledWith('oauth_token_123')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ subject: 'user_oauth_123', hasToken: true })
})
it('calls onError callback when no Authorization header', async () => {
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
Consumer.authenticateOAuthToken({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/')
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('calls onError callback when token introspection fails', async () => {
oauthMock.introspectTokenLocal.mockRejectedValue(new Error('Invalid token'))
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
Consumer.authenticateOAuthToken({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Authorization: 'Bearer invalid_token' },
})
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('demonstrates using onError to set WWW-Authenticate header', async () => {
const app = new Hono()
app.use(
'*',
Consumer.authenticateOAuthToken({
onError: () => {
const errorResponse = new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="api", error="invalid_token"',
},
})
throw new HTTPException(401, { res: errorResponse })
},
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/')
const response = await app.request(req)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer realm="api", error="invalid_token"'
)
})
it('returns 401 when Authorization header does not start with Bearer', async () => {
const app = new Hono()
app.use('*', Consumer.authenticateOAuthToken())
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
})
const response = await app.request(req)
expect(response.status).toBe(401)
})
})
describe('getOAuthData', () => {
it('throws error when no OAuth data in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => Consumer.getOAuthData(c)).toThrow(
'No OAuth data in context. Was Consumer.authenticateOAuthToken called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
})
describe('B2B', () => {
//@ts-expect-error set on globalThis in vi.mock
const b2bSessionMock = __stytchB2BsessionMock as b2bSessionMock
//@ts-expect-error set on globalThis in vi.mock
const b2bIdpMock = __stytchB2BIdpMock as b2bIdpMock
beforeEach(() => {
vi.stubEnv('STYTCH_PROJECT_ID', 'project-test-b2b-xxxxx')
vi.stubEnv('STYTCH_PROJECT_SECRET', 'secret-key-test-b2b-xxxxx')
})
describe('getClient', () => {
test('Instantiates B2B client from ctx for handlers to use', async () => {
const app = new Hono()
const req = new Request('http://localhost/')
app.get('/', (ctx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = B2B.getClient(ctx) as any
return ctx.json({ project_id: client.project_id, secret: client.secret })
})
const response = await app.request(req)
expect(response.status).toEqual(200)
expect(await response.json()).toEqual({
project_id: 'project-test-b2b-xxxxx',
secret: 'secret-key-test-b2b-xxxxx',
})
})
})
describe('authenticateSessionLocal', () => {
it('authenticates with default cookie, stores session, and retrieves via getStytchSession', async () => {
const mockSession = { member_session_id: 'b2b_session_123', organization_id: 'org_123' }
b2bSessionMock.authenticateJwt.mockResolvedValue({ member_session: mockSession })
const app = new Hono()
app.use('*', B2B.authenticateSessionLocal())
app.get('/', (c) => {
const session = B2B.getStytchSession(c)
return c.json({ session_id: session.member_session_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=b2b_jwt_token_123' },
})
const response = await app.request(req)
expect(b2bSessionMock.authenticateJwt).toHaveBeenCalledWith({
session_jwt: 'b2b_jwt_token_123',
max_token_age_seconds: undefined,
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ session_id: 'b2b_session_123' })
})
it('uses custom getCredential with custom cookie name', async () => {
const mockSession = { member_session_id: 'b2b_session_456', organization_id: 'org_456' }
b2bSessionMock.authenticateJwt.mockResolvedValue({ member_session: mockSession })
const app = new Hono()
app.use(
'*',
B2B.authenticateSessionLocal({
getCredential: (c) => ({ session_jwt: getCookie(c, 'custom_b2b_jwt_cookie') ?? '' }),
})
)
app.get('/', (c) => {
const session = B2B.getStytchSession(c)
return c.json({ session_id: session.member_session_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'custom_b2b_jwt_cookie=custom_b2b_jwt_token' },
})
const response = await app.request(req)
expect(b2bSessionMock.authenticateJwt).toHaveBeenCalledWith({
session_jwt: 'custom_b2b_jwt_token',
max_token_age_seconds: undefined,
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ session_id: 'b2b_session_456' })
})
it('passes maxTokenAgeSeconds to authenticateJwt', async () => {
const mockSession = { member_session_id: 'b2b_session_789', organization_id: 'org_789' }
b2bSessionMock.authenticateJwt.mockResolvedValue({ member_session: mockSession })
const app = new Hono()
app.use('*', B2B.authenticateSessionLocal({ maxTokenAgeSeconds: 3600 }))
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=b2b_jwt_token_789' },
})
await app.request(req)
expect(b2bSessionMock.authenticateJwt).toHaveBeenCalledWith({
session_jwt: 'b2b_jwt_token_789',
max_token_age_seconds: 3600,
})
})
})
describe('authenticateSessionRemote', () => {
it('authenticates with default cookie, stores session and member, and retrieves via getStytchSession/getStytchMember', async () => {
const mockSession = {
member_session_id: 'b2b_session_remote_123',
organization_id: 'org_remote_123',
}
const mockMember = { member_id: 'member_remote_123', email_address: 'john@company.com' }
b2bSessionMock.authenticate.mockResolvedValue({
member_session: mockSession,
member: mockMember,
})
const app = new Hono()
app.use('*', B2B.authenticateSessionRemote())
app.get('/', (c) => {
const session = B2B.getStytchSession(c)
const member = B2B.getStytchMember(c)
return c.json({ session_id: session.member_session_id, member_id: member.member_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=b2b_jwt_token_remote_123' },
})
const response = await app.request(req)
expect(b2bSessionMock.authenticate).toHaveBeenCalledWith({
session_jwt: 'b2b_jwt_token_remote_123',
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
session_id: 'b2b_session_remote_123',
member_id: 'member_remote_123',
})
})
it('uses custom getCredential with session_token', async () => {
const mockSession = {
member_session_id: 'b2b_session_token_456',
organization_id: 'org_token_456',
}
const mockMember = { member_id: 'member_token_456', email_address: 'jane@company.com' }
b2bSessionMock.authenticate.mockResolvedValue({
member_session: mockSession,
member: mockMember,
})
const app = new Hono()
app.use(
'*',
B2B.authenticateSessionRemote({
getCredential: (c) => ({ session_token: getCookie(c, 'stytch_b2b_session_token') ?? '' }),
})
)
app.get('/', (c) => {
const member = B2B.getStytchMember(c)
return c.json({ member_id: member.member_id })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_b2b_session_token=b2b_session_token_value' },
})
const response = await app.request(req)
expect(b2bSessionMock.authenticate).toHaveBeenCalledWith({
session_token: 'b2b_session_token_value',
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ member_id: 'member_token_456' })
})
})
describe('B2B authenticateSessionLocal onError', () => {
it('calls onError callback when B2B JWT authentication fails', async () => {
b2bSessionMock.authenticateJwt.mockRejectedValue(new Error('Invalid B2B JWT'))
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
B2B.authenticateSessionLocal({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=invalid_b2b_jwt' },
})
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('uses onError return value for B2B redirect', async () => {
b2bSessionMock.authenticateJwt.mockRejectedValue(new Error('Invalid B2B JWT'))
const app = new Hono()
app.use(
'*',
B2B.authenticateSessionLocal({
onError: (c) => {
return c.redirect('/b2b/login')
},
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=invalid_b2b_jwt' },
})
const response = await app.request(req)
expect(response.status).toBe(302)
expect(response.headers.get('Location')).toBe('/b2b/login')
})
})
describe('B2B authenticateSessionRemote onError', () => {
it('calls onError callback when B2B remote authentication fails', async () => {
b2bSessionMock.authenticate.mockRejectedValue(new Error('B2B Session expired'))
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
B2B.authenticateSessionRemote({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=expired_b2b_jwt' },
})
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('demonstrates B2B onError with WWW-Authenticate header', async () => {
b2bSessionMock.authenticate.mockRejectedValue(new Error('B2B Session expired'))
const app = new Hono()
app.use(
'*',
B2B.authenticateSessionRemote({
onError: () => {
const errorResponse = new Response('B2B Session expired', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="b2b-app"',
},
})
throw new HTTPException(401, { res: errorResponse })
},
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=expired_b2b_jwt' },
})
const response = await app.request(req)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="b2b-app"')
})
})
describe('B2B Organization Support', () => {
it('stores organization in context during remote authentication', async () => {
const mockSession = {
member_session_id: 'b2b_session_org_123',
organization_id: 'org_org_123',
}
const mockMember = { member_id: 'member_org_123', email_address: 'john@company.com' }
const mockOrganization = { organization_id: 'org_org_123', organization_name: 'Test Org' }
b2bSessionMock.authenticate.mockResolvedValue({
member_session: mockSession,
member: mockMember,
organization: mockOrganization,
})
const app = new Hono()
app.use('*', B2B.authenticateSessionRemote())
app.get('/', (c) => {
const organization = B2B.getStytchOrganization(c)
return c.json({ organization_name: organization.organization_name })
})
const req = new Request('http://localhost/', {
headers: { Cookie: 'stytch_session_jwt=b2b_jwt_org_token' },
})
const response = await app.request(req)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ organization_name: 'Test Org' })
})
})
describe('getStytchOrganization', () => {
it('throws error when no organization in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => B2B.getStytchOrganization(c)).toThrow(
'No organization in context. Was B2B.authenticateSessionRemote called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
describe('getStytchSession', () => {
it('throws error when no session in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => B2B.getStytchSession(c)).toThrow(
'No session in context. Was B2B.authenticateSessionLocal or B2B.authenticateSessionRemote called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
describe('getStytchMember', () => {
it('throws error when no member in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => B2B.getStytchMember(c)).toThrow(
'No member in context. Was B2B.authenticateSessionRemote called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
describe('authenticateOAuthToken', () => {
it('authenticates with bearer token and stores subject and token', async () => {
const mockClaims = { subject: 'b2b_user_oauth_123' }
b2bIdpMock.introspectTokenLocal.mockResolvedValue(mockClaims)
const app = new Hono()
app.use('*', B2B.authenticateOAuthToken())
app.get('/', (c) => {
const oauthData = B2B.getOAuthData(c)
return c.json({ subject: oauthData.claims.subject, hasToken: !!oauthData.token })
})
const req = new Request('http://localhost/', {
headers: { Authorization: 'Bearer b2b_oauth_token_123' },
})
const response = await app.request(req)
expect(b2bIdpMock.introspectTokenLocal).toHaveBeenCalledWith('b2b_oauth_token_123')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ subject: 'b2b_user_oauth_123', hasToken: true })
})
it('calls onError callback when no Authorization header', async () => {
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
B2B.authenticateOAuthToken({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/')
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('calls onError callback when token introspection fails', async () => {
b2bIdpMock.introspectTokenLocal.mockRejectedValue(new Error('Invalid B2B token'))
const mockOnError = vi.fn()
const app = new Hono()
app.use(
'*',
B2B.authenticateOAuthToken({
onError: mockOnError,
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Authorization: 'Bearer invalid_b2b_token' },
})
const response = await app.request(req)
expect(mockOnError).toHaveBeenCalled()
expect(response.status).toBe(401)
})
it('demonstrates using onError to set WWW-Authenticate header for B2B', async () => {
const app = new Hono()
app.use(
'*',
B2B.authenticateOAuthToken({
onError: () => {
const errorResponse = new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer realm="b2b-api", error="invalid_token"',
},
})
throw new HTTPException(401, { res: errorResponse })
},
})
)
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/')
const response = await app.request(req)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer realm="b2b-api", error="invalid_token"'
)
})
it('returns 401 when Authorization header does not start with Bearer', async () => {
const app = new Hono()
app.use('*', B2B.authenticateOAuthToken())
app.get('/', (c) => c.json({ ok: true }))
const req = new Request('http://localhost/', {
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
})
const response = await app.request(req)
expect(response.status).toBe(401)
})
})
describe('getOAuthData', () => {
it('throws error when no B2B OAuth data in context', () => {
const app = new Hono()
app.get('/', (c) => {
expect(() => B2B.getOAuthData(c)).toThrow(
'No B2B OAuth data in context. Was B2B.authenticateOAuthToken called?'
)
return c.json({ ok: true })
})
const req = new Request('http://localhost/')
app.request(req)
})
})
})

View File

@ -0,0 +1,528 @@
import type { Context, MiddlewareHandler } from 'hono'
import { env } from 'hono/adapter'
import { getCookie } from 'hono/cookie'
import { HTTPException } from 'hono/http-exception'
import type { Session, User, MemberSession, Member, Organization } from 'stytch'
import { Client, B2BClient } from 'stytch'
/**
* Environment variables required for Stytch configuration
*/
type StytchEnv = {
/** The Stytch project ID */
STYTCH_PROJECT_ID: string
/** The Stytch project secret */
STYTCH_PROJECT_SECRET: string
}
type ConsumerTokenClaims = Awaited<ReturnType<Client['idp']['introspectTokenLocal']>>
type B2BTokenClaims = Awaited<ReturnType<B2BClient['idp']['introspectTokenLocal']>>
/**
* Configuration options for Consumer local session authentication
*/
type LocalMiddlewareOpts = {
/** Maximum age of the JWT token in seconds */
maxTokenAgeSeconds?: number
/**
* Custom function to extract session JWT from the request context.
* @example
* // Read from a custom cookie name
* getCredential: (c) => ({ session_jwt: getCookie(c, 'my_custom_jwt') ?? '' })
*
* @example
* // Read from Authorization header
* getCredential: (c) => ({ session_jwt: c.req.header('Authorization')?.replace('Bearer ', '') ?? '' })
*
* @example
* // Read from custom header
* getCredential: (c) => ({ session_jwt: c.req.header('X-Session-JWT') ?? '' })
*/
getCredential?: (c: Context) => { session_jwt: string }
/**
* Custom error handler for authentication failures.
* @example
* // Redirect to login page
* onError: (c, error) => {
* return c.redirect('/login')
* }
*
* @example
* // Return custom error response
* onError: (c, error) => {
* const errorResponse = new Response('Session expired', {
* status: 401,
* headers: { 'WWW-Authenticate': 'Bearer realm="app"' }
* })
* throw new HTTPException(401, { res: errorResponse })
* }
*/
onError?: (c: Context, error: Error) => Response | void
}
/**
* Configuration options for Consumer remote session authentication
*/
type OnlineMiddlewareOpts = {
/**
* Custom function to extract session credentials from the request context.
* Can return either a JWT or session token for flexibility.
* @example
* // Read JWT from custom cookie
* getCredential: (c) => ({ session_jwt: getCookie(c, 'custom_jwt_cookie') ?? '' })
*
* @example
* // Read opaque session token instead of JWT
* getCredential: (c) => ({ session_token: getCookie(c, 'stytch_session_token') ?? '' })
*
* @example
* // Read from custom header
* getCredential: (c) => ({ session_jwt: c.req.header('X-Session-JWT') ?? '' })
*/
getCredential?: (c: Context) => { session_jwt: string } | { session_token: string }
/**
* Custom error handler for authentication failures.
* @example
* // Redirect to login page
* onError: (c, error) => {
* return c.redirect('/login')
* }
*
* @example
* // Return custom error response
* onError: (c, error) => {
* const errorResponse = new Response('Session expired', {
* status: 401,
* headers: { 'WWW-Authenticate': 'Bearer realm="app"' }
* })
* throw new HTTPException(401, { res: errorResponse })
* }
*/
onError?: (c: Context, error: Error) => Response | void
}
/**
* Configuration options for OAuth2 bearer token authentication
*/
type OAuthMiddlewareOpts = {
/**
* Custom function to extract bearer token from the request context.
* @example
* // Read from custom header instead of Authorization
* getCredential: (c) => ({ access_token: c.req.header('X-API-Token') ?? '' })
*
* @example
* // Read from cookie
* getCredential: (c) => ({ access_token: getCookie(c, 'oauth_token') ?? '' })
*
* @example
* // Read from query parameter
* getCredential: (c) => ({ access_token: c.req.query('access_token') ?? '' })
*/
getCredential?: (c: Context) => { access_token: string }
/**
* Custom error handler for OAuth authentication failures.
* @example
* // Set WWW-Authenticate header
* onError: (c, error) => {
* const errorResponse = new Response('Unauthorized', {
* status: 401,
* headers: { 'WWW-Authenticate': 'Bearer realm="api", error="invalid_token"' }
* })
* throw new HTTPException(401, { res: errorResponse })
* }
*/
onError?: (c: Context, error: Error) => void
}
/**
* Configuration options for B2B OAuth2 bearer token authentication
*/
type B2BOAuthMiddlewareOpts = {
/**
* Custom function to extract bearer token from the request context.
* @example
* // Read from B2B-specific API key header
* getCredential: (c) => ({ access_token: c.req.header('X-B2B-API-Key') ?? '' })
*
* @example
* // Read from organization-scoped cookie
* getCredential: (c) => ({ access_token: getCookie(c, 'b2b_oauth_token') ?? '' })
*
* @example
* // Read from custom Authorization scheme
* getCredential: (c) => ({ access_token: c.req.header('Authorization')?.replace('B2B-Bearer ', '') ?? '' })
*/
getCredential?: (c: Context) => { access_token: string }
/**
* Custom error handler for B2B OAuth authentication failures.
* @example
* // Set WWW-Authenticate header for B2B
* onError: (c, error) => {
* const errorResponse = new Response('Unauthorized', {
* status: 401,
* headers: { 'WWW-Authenticate': 'Bearer realm="b2b-api", error="invalid_token"' }
* })
* throw new HTTPException(401, { res: errorResponse })
* }
*/
onError?: (c: Context, error: Error) => void
}
/**
* Default credential extractor for session JWT from standard cookie
*/
const defaultSessionCredential = (c: Context) => ({
session_jwt: getCookie(c, 'stytch_session_jwt') ?? '',
})
/**
* Cache for Consumer Stytch client instances keyed by project ID
*/
const consumerClients: Record<string, Client> = {}
/**
* Gets or creates a Consumer Stytch client instance for the given context
* @param c - The Hono request context
* @returns A Consumer Stytch client instance
*/
const getConsumerClient = (c: Context) => {
const stytchEnv = env<StytchEnv>(c)
consumerClients[stytchEnv.STYTCH_PROJECT_ID] =
consumerClients[stytchEnv.STYTCH_PROJECT_ID] ||
new Client({
project_id: stytchEnv.STYTCH_PROJECT_ID,
secret: stytchEnv.STYTCH_PROJECT_SECRET,
})
return consumerClients[stytchEnv.STYTCH_PROJECT_ID]
}
/**
* Cache for B2B Stytch client instances keyed by project ID
*/
const b2bClients: Record<string, B2BClient> = {}
/**
* Gets or creates a B2B Stytch client instance for the given context
* @param c - The Hono request context
* @returns A B2B Stytch client instance
*/
const getB2BClient = (c: Context) => {
const stytchEnv = env<StytchEnv>(c)
b2bClients[stytchEnv.STYTCH_PROJECT_ID] =
b2bClients[stytchEnv.STYTCH_PROJECT_ID] ||
new B2BClient({
project_id: stytchEnv.STYTCH_PROJECT_ID,
secret: stytchEnv.STYTCH_PROJECT_SECRET,
})
return b2bClients[stytchEnv.STYTCH_PROJECT_ID]
}
/**
* Consumer Stytch authentication utilities for Hono middleware
*/
export const Consumer = {
/**
* Gets the Consumer Stytch client instance for the given context
* @param c - The Hono request context
* @returns Consumer Stytch client instance
*/
getClient: (c: Context) => getConsumerClient(c),
/**
* Creates middleware for local session authentication using JWT validation only
* @param opts - Optional configuration for local authentication
* @returns Hono middleware handler
*/
authenticateSessionLocal:
(opts?: LocalMiddlewareOpts): MiddlewareHandler =>
async (c, next) => {
const stytchClient = Consumer.getClient(c)
const getCredential = opts?.getCredential ?? defaultSessionCredential
try {
const { session } = await stytchClient.sessions.authenticateJwt({
...getCredential(c),
max_token_age_seconds: opts?.maxTokenAgeSeconds,
})
c.set('stytchSession', session)
} catch (error) {
if (opts?.onError) {
const result = opts.onError(c, error as Error)
if (result) return result
}
throw new HTTPException(401, { message: 'Unauthenticated' })
}
await next()
},
/**
* Creates middleware for remote session authentication that validates with Stytch servers
* @param opts - Optional configuration for remote authentication
* @returns Hono middleware handler
*/
authenticateSessionRemote:
(opts?: OnlineMiddlewareOpts): MiddlewareHandler =>
async (c, next) => {
const stytchClient = Consumer.getClient(c)
const getCredential = opts?.getCredential ?? defaultSessionCredential
try {
const { user, session } = await stytchClient.sessions.authenticate({
...getCredential(c),
})
c.set('stytchSession', session)
c.set('stytchUser', user)
} catch (error) {
if (opts?.onError) {
const result = opts.onError(c, error as Error)
if (result) return result
}
throw new HTTPException(401, { message: 'Unauthenticated' })
}
await next()
},
/**
* Retrieves the authenticated Consumer session from the request context
* @param c - The Hono request context
* @returns The Consumer session object
* @throws Error if no session is found in context
*/
getStytchSession: (c: Context): Session => {
const session = c.get('stytchSession')
if (!session) {
throw Error(
'No session in context. Was Consumer.authenticateSessionLocal or Consumer.authenticateSessionRemote called?'
)
}
return session
},
/**
* Retrieves the authenticated Consumer user from the request context
* @param c - The Hono request context
* @returns The Consumer user object
* @throws Error if no user is found in context (only available after remote authentication)
*/
getStytchUser: (c: Context): User => {
const user = c.get('stytchUser')
if (!user) {
throw Error('No user in context. Was Consumer.authenticateSessionRemote called?')
}
return user
},
/**
* Creates middleware for OAuth2 bearer token authentication
* @param opts - Optional configuration for OAuth authentication
* @returns Hono middleware handler
*/
authenticateOAuthToken:
(opts?: OAuthMiddlewareOpts): MiddlewareHandler =>
async (c, next) => {
const stytchClient = Consumer.getClient(c)
try {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
throw new Error('Missing or invalid access token')
}
const bearerToken = authHeader.substring(7)
const claims = await stytchClient.idp.introspectTokenLocal(bearerToken)
c.set('stytchOAuthClaims', claims)
c.set('stytchOAuthToken', bearerToken)
} catch (error) {
if (opts?.onError) {
opts.onError(c, error as Error)
}
throw new HTTPException(401, { message: 'Unauthenticated' })
}
await next()
},
/**
* Retrieves the OAuth data from the request context
* @param c - The Hono request context
* @returns Object containing OAuth token response and access token
* @throws Error if no OAuth data is found in context
*/
getOAuthData: (c: Context): { claims: ConsumerTokenClaims; token: string } => {
const claims = c.get('stytchOAuthClaims')
const token = c.get('stytchOAuthToken')
if (!claims || !token) {
throw Error('No OAuth data in context. Was Consumer.authenticateOAuthToken called?')
}
return { claims, token }
},
}
/**
* B2B Stytch authentication utilities for Hono middleware
*/
export const B2B = {
/**
* Gets the B2B Stytch client instance for the given context
* @param c - The Hono request context
* @returns B2B Stytch client instance
*/
getClient: (c: Context) => getB2BClient(c),
/**
* Creates middleware for local B2B session authentication using JWT validation only
* @param opts - Optional configuration for local authentication
* @returns Hono middleware handler
*/
authenticateSessionLocal:
(opts?: LocalMiddlewareOpts): MiddlewareHandler =>
async (c, next) => {
const stytchClient = B2B.getClient(c)
const getCredential = opts?.getCredential ?? defaultSessionCredential
try {
const { member_session } = await stytchClient.sessions.authenticateJwt({
...getCredential(c),
max_token_age_seconds: opts?.maxTokenAgeSeconds,
})
c.set('stytchB2BSession', member_session)
} catch (error) {
if (opts?.onError) {
const result = opts.onError(c, error as Error)
if (result) return result
}
throw new HTTPException(401, { message: 'Unauthenticated' })
}
await next()
},
/**
* Creates middleware for remote B2B session authentication that validates with Stytch servers
* @param opts - Optional configuration for remote authentication
* @returns Hono middleware handler
*/
authenticateSessionRemote:
(opts?: OnlineMiddlewareOpts): MiddlewareHandler =>
async (c, next) => {
const stytchClient = B2B.getClient(c)
const getCredential = opts?.getCredential ?? defaultSessionCredential
try {
const { member, member_session, organization } = await stytchClient.sessions.authenticate({
...getCredential(c),
})
c.set('stytchB2BSession', member_session)
c.set('stytchB2BMember', member)
c.set('stytchB2BOrganization', organization)
} catch (error) {
if (opts?.onError) {
const result = opts.onError(c, error as Error)
if (result) return result
}
throw new HTTPException(401, { message: 'Unauthenticated' })
}
await next()
},
/**
* Retrieves the authenticated B2B member session from the request context
* @param c - The Hono request context
* @returns The B2B member session object
* @throws Error if no session is found in context
*/
getStytchSession: (c: Context): MemberSession => {
const session = c.get('stytchB2BSession')
if (!session) {
throw Error(
'No session in context. Was B2B.authenticateSessionLocal or B2B.authenticateSessionRemote called?'
)
}
return session
},
/**
* Retrieves the authenticated B2B member from the request context
* @param c - The Hono request context
* @returns The B2B member object
* @throws Error if no member is found in context (only available after remote authentication)
*/
getStytchMember: (c: Context): Member => {
const member = c.get('stytchB2BMember')
if (!member) {
throw Error('No member in context. Was B2B.authenticateSessionRemote called?')
}
return member
},
/**
* Retrieves the authenticated B2B organization from the request context
* @param c - The Hono request context
* @returns The B2B organization object
* @throws Error if no organization is found in context (only available after remote authentication)
*/
getStytchOrganization: (c: Context): Organization => {
const organization = c.get('stytchB2BOrganization')
if (!organization) {
throw Error('No organization in context. Was B2B.authenticateSessionRemote called?')
}
return organization
},
/**
* Creates middleware for B2B OAuth2 bearer token authentication
* @param opts - Optional configuration for OAuth authentication
* @returns Hono middleware handler
*/
authenticateOAuthToken:
(opts?: B2BOAuthMiddlewareOpts): MiddlewareHandler =>
async (c, next) => {
const stytchClient = B2B.getClient(c)
try {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
throw new Error('Missing or invalid access token')
}
const bearerToken = authHeader.substring(7)
const claims = await stytchClient.idp.introspectTokenLocal(bearerToken)
c.set('stytchB2BOAuthClaims', claims)
c.set('stytchB2BOAuthToken', bearerToken)
} catch (error) {
if (opts?.onError) {
opts.onError(c, error as Error)
}
throw new HTTPException(401, { message: 'Unauthenticated' })
}
await next()
},
/**
* Retrieves the B2B OAuth data from the request context
* @param c - The Hono request context
* @returns Object containing OAuth token response and access token
* @throws Error if no OAuth data is found in context
*/
getOAuthData: (c: Context): { claims: B2BTokenClaims; token: string } => {
const claims = c.get('stytchB2BOAuthClaims')
const token = c.get('stytchB2BOAuthToken')
if (!claims || !token) {
throw Error('No B2B OAuth data in context. Was B2B.authenticateOAuthToken called?')
}
return { claims, token }
},
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo",
"emitDeclarationOnly": false
},
"include": ["src/**/*.ts"],
"exclude": ["**/*.test.ts"],
"references": []
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc/packages/stytch-auth",
"types": ["vitest/globals"]
},
"include": ["**/*.test.ts", "vitest.config.ts"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

View File

@ -0,0 +1,9 @@
import { defineProject } from 'vitest/config'
export default defineProject({
test: {
globals: true,
restoreMocks: true,
unstubEnvs: true,
},
})

View File

@ -2262,6 +2262,22 @@ __metadata:
languageName: unknown
linkType: soft
"@hono/stytch-auth@workspace:packages/stytch-auth":
version: 0.0.0-use.local
resolution: "@hono/stytch-auth@workspace:packages/stytch-auth"
dependencies:
"@arethetypeswrong/cli": "npm:^0.17.4"
publint: "npm:^0.3.9"
stytch: "npm:^12.19.0"
tsup: "npm:^8.4.0"
typescript: "npm:^5.8.2"
vitest: "npm:^3.0.8"
peerDependencies:
hono: ">=3.*"
stytch: ^12.19.0
languageName: unknown
linkType: soft
"@hono/swagger-editor@workspace:packages/swagger-editor":
version: 0.0.0-use.local
resolution: "@hono/swagger-editor@workspace:packages/swagger-editor"
@ -8974,7 +8990,7 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^5.1.3":
"jose@npm:^5.1.3, jose@npm:^5.6.3":
version: 5.10.0
resolution: "jose@npm:5.10.0"
checksum: 10c0/e20d9fc58d7e402f2e5f04e824b8897d5579aae60e64cb88ebdea1395311c24537bf4892f7de413fab1acf11e922797fb1b42269bc8fc65089a3749265ccb7b0
@ -13371,6 +13387,16 @@ __metadata:
languageName: node
linkType: hard
"stytch@npm:^12.19.0":
version: 12.19.0
resolution: "stytch@npm:12.19.0"
dependencies:
jose: "npm:^5.6.3"
undici: "npm:^6.19.5"
checksum: b6bc10f8b2e6424d2fb76fd428ccd03ed55090485c249e621a931013c761af953ed1380f90274dd679642d7f47cd40f049c315d96e120e7d11545ec502b1cd86
languageName: node
linkType: hard
"sucrase@npm:^3.35.0":
version: 3.35.0
resolution: "sucrase@npm:3.35.0"
@ -14104,6 +14130,13 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^6.19.5":
version: 6.21.3
resolution: "undici@npm:6.21.3"
checksum: 294da109853fad7a6ef5a172ad0ca3fb3f1f60cf34703d062a5ec967daf69ad8c03b52e6d536c5cba3bb65615769bf08e5b30798915cbccdddaca01045173dda
languageName: node
linkType: hard
"unenv@npm:2.0.0-rc.14":
version: 2.0.0-rc.14
resolution: "unenv@npm:2.0.0-rc.14"