honojs-middleware/packages/stytch-auth/src/index.ts

533 lines
17 KiB
TypeScript

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<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,
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<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,
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 }
},
}