From ede1aaff4fb4de931f5ded309cc590615a97caec Mon Sep 17 00:00:00 2001 From: Max Gerber <89937743+max-stytch@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:48:00 -0700 Subject: [PATCH] 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 --- .changeset/tidy-pots-count.md | 5 + packages/stytch-auth/README.md | 398 ++++++++++ packages/stytch-auth/package.json | 57 ++ packages/stytch-auth/src/index.test.ts | 950 +++++++++++++++++++++++ packages/stytch-auth/src/index.ts | 528 +++++++++++++ packages/stytch-auth/tsconfig.build.json | 12 + packages/stytch-auth/tsconfig.json | 13 + packages/stytch-auth/tsconfig.spec.json | 13 + packages/stytch-auth/vitest.config.ts | 9 + yarn.lock | 35 +- 10 files changed, 2019 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-pots-count.md create mode 100644 packages/stytch-auth/README.md create mode 100644 packages/stytch-auth/package.json create mode 100644 packages/stytch-auth/src/index.test.ts create mode 100644 packages/stytch-auth/src/index.ts create mode 100644 packages/stytch-auth/tsconfig.build.json create mode 100644 packages/stytch-auth/tsconfig.json create mode 100644 packages/stytch-auth/tsconfig.spec.json create mode 100644 packages/stytch-auth/vitest.config.ts diff --git a/.changeset/tidy-pots-count.md b/.changeset/tidy-pots-count.md new file mode 100644 index 00000000..8277ea22 --- /dev/null +++ b/.changeset/tidy-pots-count.md @@ -0,0 +1,5 @@ +--- +'@hono/stytch-auth': minor +--- + +Initial release of @hono/stytch-auth package diff --git a/packages/stytch-auth/README.md b/packages/stytch-auth/README.md new file mode 100644 index 00000000..eb6abdeb --- /dev/null +++ b/packages/stytch-auth/README.md @@ -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/). diff --git a/packages/stytch-auth/package.json b/packages/stytch-auth/package.json new file mode 100644 index 00000000..05800104 --- /dev/null +++ b/packages/stytch-auth/package.json @@ -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" + } +} diff --git a/packages/stytch-auth/src/index.test.ts b/packages/stytch-auth/src/index.test.ts new file mode 100644 index 00000000..07804fb3 --- /dev/null +++ b/packages/stytch-auth/src/index.test.ts @@ -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) + }) + }) +}) diff --git a/packages/stytch-auth/src/index.ts b/packages/stytch-auth/src/index.ts new file mode 100644 index 00000000..8a77db79 --- /dev/null +++ b/packages/stytch-auth/src/index.ts @@ -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> +type B2BTokenClaims = Awaited> + +/** + * 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 = {} + +/** + * 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(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 = {} + +/** + * 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(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 } + }, +} diff --git a/packages/stytch-auth/tsconfig.build.json b/packages/stytch-auth/tsconfig.build.json new file mode 100644 index 00000000..ccc2f65a --- /dev/null +++ b/packages/stytch-auth/tsconfig.build.json @@ -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": [] +} diff --git a/packages/stytch-auth/tsconfig.json b/packages/stytch-auth/tsconfig.json new file mode 100644 index 00000000..d4d0929e --- /dev/null +++ b/packages/stytch-auth/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/stytch-auth/tsconfig.spec.json b/packages/stytch-auth/tsconfig.spec.json new file mode 100644 index 00000000..b32f0f52 --- /dev/null +++ b/packages/stytch-auth/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" + } + ] +} diff --git a/packages/stytch-auth/vitest.config.ts b/packages/stytch-auth/vitest.config.ts new file mode 100644 index 00000000..5ddee383 --- /dev/null +++ b/packages/stytch-auth/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + globals: true, + restoreMocks: true, + unstubEnvs: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index 8151bc69..abcca025 100644 --- a/yarn.lock +++ b/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"