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 readmepull/1224/head
parent
44b1c24b95
commit
ede1aaff4f
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/stytch-auth': minor
|
||||
---
|
||||
|
||||
Initial release of @hono/stytch-auth package
|
|
@ -0,0 +1,398 @@
|
|||
# Stytch Auth Middleware for Hono
|
||||
|
||||
[](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/).
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 }
|
||||
},
|
||||
}
|
|
@ -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": []
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { defineProject } from 'vitest/config'
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
globals: true,
|
||||
restoreMocks: true,
|
||||
unstubEnvs: true,
|
||||
},
|
||||
})
|
35
yarn.lock
35
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue