From b920ab5afb2af9861ef9c8634cc92270947af761 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 23 Dec 2025 14:55:38 -0600 Subject: [PATCH] feat: add TokenRefreshError with userId and sessionId for debugging Add custom error classes matching authkit-session pattern: - AuthKitError base class with optional data field - TokenRefreshError with userId/sessionId context for debugging - getSessionErrorContext helper to extract context from sessions Update session refresh failure to include user/session context. --- src/errors.spec.ts | 108 +++++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 46 +++++++++++++++++++ src/index.ts | 3 ++ src/session.ts | 9 ++-- 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 src/errors.spec.ts create mode 100644 src/errors.ts diff --git a/src/errors.spec.ts b/src/errors.spec.ts new file mode 100644 index 0000000..061a8b9 --- /dev/null +++ b/src/errors.spec.ts @@ -0,0 +1,108 @@ +import { AuthKitError, TokenRefreshError, getSessionErrorContext } from './errors.js'; +import type { Session } from './interfaces.js'; +import type { User } from '@workos-inc/node'; + +describe('AuthKitError', () => { + it('creates error with message', () => { + const error = new AuthKitError('Test error'); + + expect(error.message).toBe('Test error'); + expect(error.name).toBe('AuthKitError'); + expect(error).toBeInstanceOf(Error); + }); + + it('creates error with cause and data', () => { + const originalError = new Error('Original error'); + const data = { userId: '123' }; + const error = new AuthKitError('Test error', originalError, data); + + expect(error.cause).toBe(originalError); + expect(error.data).toEqual(data); + }); +}); + +describe('TokenRefreshError', () => { + it('creates error with correct name and inheritance', () => { + const error = new TokenRefreshError('Refresh failed'); + + expect(error.name).toBe('TokenRefreshError'); + expect(error.message).toBe('Refresh failed'); + expect(error).toBeInstanceOf(AuthKitError); + expect(error).toBeInstanceOf(Error); + }); + + it('creates error with cause and context', () => { + const originalError = new Error('Network error'); + const error = new TokenRefreshError('Refresh failed', originalError, { + userId: 'user_123', + sessionId: 'session_456', + }); + + expect(error.cause).toBe(originalError); + expect(error.userId).toBe('user_123'); + expect(error.sessionId).toBe('session_456'); + }); + + it('has undefined properties when no context provided', () => { + const error = new TokenRefreshError('Refresh failed'); + + expect(error.userId).toBeUndefined(); + expect(error.sessionId).toBeUndefined(); + }); +}); + +describe('getSessionErrorContext', () => { + function createTestJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payloadStr = btoa(JSON.stringify(payload)); + return `${header}.${payloadStr}.test-signature`; + } + + it('returns empty object for missing session', () => { + expect(getSessionErrorContext(null)).toEqual({}); + expect(getSessionErrorContext(undefined)).toEqual({}); + }); + + it('extracts userId and sessionId from access token', () => { + const session: Session = { + accessToken: createTestJwt({ sub: 'user_456', sid: 'session_123' }), + refreshToken: 'refresh_token', + user: { + id: 'user_456', + email: 'test@example.com', + emailVerified: true, + profilePictureUrl: null, + firstName: null, + lastName: null, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + object: 'user', + } as User, + }; + + const context = getSessionErrorContext(session); + expect(context.userId).toBe('user_456'); + expect(context.sessionId).toBe('session_123'); + }); + + it('returns empty object for invalid JWT', () => { + const session: Session = { + accessToken: 'invalid-jwt', + refreshToken: 'refresh_token', + user: { + id: 'user_123', + email: 'test@example.com', + emailVerified: true, + profilePictureUrl: null, + firstName: null, + lastName: null, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + object: 'user', + } as User, + }; + + const context = getSessionErrorContext(session); + expect(context).toEqual({}); + }); +}); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..17a7183 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,46 @@ +import type { Session } from './interfaces.js'; +import { decodeJwt } from './jwt.js'; + +export class AuthKitError extends Error { + data?: Record; + + constructor(message: string, cause?: unknown, data?: Record) { + super(message); + this.name = 'AuthKitError'; + this.cause = cause; + this.data = data; + } +} + +export interface TokenRefreshErrorContext { + userId?: string; + sessionId?: string; +} + +export class TokenRefreshError extends AuthKitError { + readonly userId?: string; + readonly sessionId?: string; + + constructor(message: string, cause?: unknown, context?: TokenRefreshErrorContext) { + super(message, cause); + this.name = 'TokenRefreshError'; + this.userId = context?.userId; + this.sessionId = context?.sessionId; + } +} + +export function getSessionErrorContext(session?: Session | null): TokenRefreshErrorContext { + if (!session?.accessToken) { + return {}; + } + + try { + const { payload } = decodeJwt(session.accessToken); + return { + userId: payload.sub, + sessionId: payload.sid, + }; + } catch { + return {}; + } +} diff --git a/src/index.ts b/src/index.ts index 7f76f6d..1a5c53b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js'; import { handleAuth } from './authkit-callback-route.js'; +import { AuthKitError, TokenRefreshError } from './errors.js'; import { authkit, authkitMiddleware } from './middleware.js'; import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js'; import { validateApiKey } from './validate-api-key.js'; @@ -8,6 +9,8 @@ import { getWorkOS } from './workos.js'; export * from './interfaces.js'; export { + AuthKitError, + TokenRefreshError, authkit, authkitMiddleware, getSignInUrl, diff --git a/src/session.ts b/src/session.ts index 9513891..8e61e56 100644 --- a/src/session.ts +++ b/src/session.ts @@ -7,6 +7,7 @@ import { redirect } from 'next/navigation'; import { NextRequest, NextResponse } from 'next/server'; import { getCookieOptions, getJwtCookie } from './cookie.js'; import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js'; +import { TokenRefreshError, getSessionErrorContext } from './errors.js'; import { getAuthorizationUrl } from './get-authorization-url.js'; import { AccessToken, @@ -406,9 +407,11 @@ async function refreshSession({ organizationId: nextOrganizationId ?? organizationIdFromAccessToken, }); } catch (error) { - throw new Error(`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, { - cause: error, - }); + throw new TokenRefreshError( + `Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, + error, + getSessionErrorContext(session), + ); } const headersList = await headers();