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 /** The Stytch Project domain */ STYTCH_DOMAIN?: 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, custom_base_url: stytchEnv.STYTCH_DOMAIN, }) 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, custom_base_url: stytchEnv.STYTCH_DOMAIN, }) 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 } }, }