Skip to content

Commit 8245f38

Browse files
committed
feat: add SessionError with userId and sessionId for debugging
Add custom error classes matching authkit-session pattern: - AuthKitError base class with optional data field - SessionError with userId/sessionId context for debugging - getSessionErrorContext helper to extract context from sessions Update session refresh failure to include user/session context.
1 parent 29b3c78 commit 8245f38

File tree

4 files changed

+313
-3
lines changed

4 files changed

+313
-3
lines changed

src/errors.spec.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { AuthKitError, SessionError, getSessionErrorContext } from './errors.js';
2+
import type { Session } from './interfaces.js';
3+
import type { User } from '@workos-inc/node';
4+
5+
describe('AuthKitError', () => {
6+
it('creates error with message', () => {
7+
const error = new AuthKitError('Test error');
8+
9+
expect(error.message).toBe('Test error');
10+
expect(error.name).toBe('AuthKitError');
11+
expect(error).toBeInstanceOf(Error);
12+
});
13+
14+
it('creates error with cause', () => {
15+
const originalError = new Error('Original error');
16+
const error = new AuthKitError('Test error', originalError);
17+
18+
expect(error.cause).toBe(originalError);
19+
});
20+
21+
it('creates error with data', () => {
22+
const data = { userId: '123', action: 'login' };
23+
const error = new AuthKitError('Test error', undefined, data);
24+
25+
expect(error.data).toEqual(data);
26+
});
27+
28+
it('creates error with cause and data', () => {
29+
const originalError = new Error('Original error');
30+
const data = { userId: '123' };
31+
const error = new AuthKitError('Test error', originalError, data);
32+
33+
expect(error.cause).toBe(originalError);
34+
expect(error.data).toEqual(data);
35+
});
36+
});
37+
38+
describe('SessionError', () => {
39+
it('creates error with correct name', () => {
40+
const error = new SessionError('Session failed');
41+
42+
expect(error.name).toBe('SessionError');
43+
expect(error.message).toBe('Session failed');
44+
expect(error).toBeInstanceOf(AuthKitError);
45+
expect(error).toBeInstanceOf(Error);
46+
});
47+
48+
it('creates error with cause', () => {
49+
const originalError = new Error('Network error');
50+
const error = new SessionError('Session failed', originalError);
51+
52+
expect(error.cause).toBe(originalError);
53+
});
54+
55+
it('creates error with userId and sessionId', () => {
56+
const error = new SessionError('Session failed', undefined, {
57+
userId: 'user_123',
58+
sessionId: 'session_456',
59+
});
60+
61+
expect(error.userId).toBe('user_123');
62+
expect(error.sessionId).toBe('session_456');
63+
});
64+
65+
it('creates error with cause and context', () => {
66+
const originalError = new Error('Network error');
67+
const error = new SessionError('Session failed', originalError, {
68+
userId: 'user_123',
69+
sessionId: 'session_456',
70+
});
71+
72+
expect(error.cause).toBe(originalError);
73+
expect(error.userId).toBe('user_123');
74+
expect(error.sessionId).toBe('session_456');
75+
});
76+
77+
it('creates error with partial context (userId only)', () => {
78+
const error = new SessionError('Session failed', undefined, {
79+
userId: 'user_123',
80+
});
81+
82+
expect(error.userId).toBe('user_123');
83+
expect(error.sessionId).toBeUndefined();
84+
});
85+
86+
it('creates error with partial context (sessionId only)', () => {
87+
const error = new SessionError('Session failed', undefined, {
88+
sessionId: 'session_456',
89+
});
90+
91+
expect(error.userId).toBeUndefined();
92+
expect(error.sessionId).toBe('session_456');
93+
});
94+
95+
it('has undefined properties when no context provided', () => {
96+
const error = new SessionError('Session failed');
97+
98+
expect(error.userId).toBeUndefined();
99+
expect(error.sessionId).toBeUndefined();
100+
});
101+
});
102+
103+
describe('error inheritance', () => {
104+
it('maintains proper inheritance chain', () => {
105+
const sessionError = new SessionError('test');
106+
107+
expect(sessionError).toBeInstanceOf(SessionError);
108+
expect(sessionError).toBeInstanceOf(AuthKitError);
109+
expect(sessionError).toBeInstanceOf(Error);
110+
});
111+
112+
it('can be caught as AuthKitError', () => {
113+
expect(() => {
114+
throw new SessionError('test');
115+
}).toThrow(AuthKitError);
116+
});
117+
});
118+
119+
describe('getSessionErrorContext', () => {
120+
// Helper to create a valid JWT for testing
121+
function createTestJwt(payload: Record<string, unknown>): string {
122+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
123+
const payloadStr = btoa(JSON.stringify(payload));
124+
const signature = 'test-signature';
125+
return `${header}.${payloadStr}.${signature}`;
126+
}
127+
128+
it('returns empty object for null session', () => {
129+
const context = getSessionErrorContext(null);
130+
expect(context).toEqual({});
131+
});
132+
133+
it('returns empty object for undefined session', () => {
134+
const context = getSessionErrorContext(undefined);
135+
expect(context).toEqual({});
136+
});
137+
138+
it('extracts userId from session.user.id', () => {
139+
const session: Session = {
140+
accessToken: createTestJwt({ sid: 'session_123' }),
141+
refreshToken: 'refresh_token',
142+
user: {
143+
id: 'user_456',
144+
145+
emailVerified: true,
146+
profilePictureUrl: null,
147+
firstName: null,
148+
lastName: null,
149+
createdAt: '2024-01-01',
150+
updatedAt: '2024-01-01',
151+
object: 'user',
152+
} as User,
153+
};
154+
155+
const context = getSessionErrorContext(session);
156+
expect(context.userId).toBe('user_456');
157+
});
158+
159+
it('extracts sessionId from JWT sid claim', () => {
160+
const session: Session = {
161+
accessToken: createTestJwt({ sid: 'session_789' }),
162+
refreshToken: 'refresh_token',
163+
user: {
164+
id: 'user_123',
165+
166+
emailVerified: true,
167+
profilePictureUrl: null,
168+
firstName: null,
169+
lastName: null,
170+
createdAt: '2024-01-01',
171+
updatedAt: '2024-01-01',
172+
object: 'user',
173+
} as User,
174+
};
175+
176+
const context = getSessionErrorContext(session);
177+
expect(context.sessionId).toBe('session_789');
178+
});
179+
180+
it('extracts both userId and sessionId', () => {
181+
const session: Session = {
182+
accessToken: createTestJwt({ sid: 'session_abc' }),
183+
refreshToken: 'refresh_token',
184+
user: {
185+
id: 'user_xyz',
186+
187+
emailVerified: true,
188+
profilePictureUrl: null,
189+
firstName: null,
190+
lastName: null,
191+
createdAt: '2024-01-01',
192+
updatedAt: '2024-01-01',
193+
object: 'user',
194+
} as User,
195+
};
196+
197+
const context = getSessionErrorContext(session);
198+
expect(context.userId).toBe('user_xyz');
199+
expect(context.sessionId).toBe('session_abc');
200+
});
201+
202+
it('handles invalid JWT gracefully', () => {
203+
const session: Session = {
204+
accessToken: 'invalid-jwt',
205+
refreshToken: 'refresh_token',
206+
user: {
207+
id: 'user_123',
208+
209+
emailVerified: true,
210+
profilePictureUrl: null,
211+
firstName: null,
212+
lastName: null,
213+
createdAt: '2024-01-01',
214+
updatedAt: '2024-01-01',
215+
object: 'user',
216+
} as User,
217+
};
218+
219+
const context = getSessionErrorContext(session);
220+
expect(context.userId).toBe('user_123');
221+
expect(context.sessionId).toBeUndefined();
222+
});
223+
224+
it('handles missing user gracefully', () => {
225+
const session = {
226+
accessToken: createTestJwt({ sid: 'session_123' }),
227+
refreshToken: 'refresh_token',
228+
user: undefined,
229+
} as unknown as Session;
230+
231+
const context = getSessionErrorContext(session);
232+
expect(context.userId).toBeUndefined();
233+
expect(context.sessionId).toBe('session_123');
234+
});
235+
});

src/errors.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Session } from './interfaces.js';
2+
import { decodeJwt } from './jwt.js';
3+
4+
/**
5+
* Base error class for AuthKit errors.
6+
* Matches the pattern used in @workos-inc/authkit-session.
7+
*/
8+
export class AuthKitError extends Error {
9+
data?: Record<string, unknown>;
10+
11+
constructor(message: string, cause?: unknown, data?: Record<string, unknown>) {
12+
super(message);
13+
this.name = 'AuthKitError';
14+
this.cause = cause;
15+
this.data = data;
16+
}
17+
}
18+
19+
export interface SessionErrorContext {
20+
userId?: string;
21+
sessionId?: string;
22+
}
23+
24+
/**
25+
* Error class for session-related errors that includes user and session context.
26+
* Useful for debugging authentication issues.
27+
*/
28+
export class SessionError extends AuthKitError {
29+
readonly userId?: string;
30+
readonly sessionId?: string;
31+
32+
constructor(message: string, cause?: unknown, context?: SessionErrorContext) {
33+
super(message, cause);
34+
this.name = 'SessionError';
35+
this.userId = context?.userId;
36+
this.sessionId = context?.sessionId;
37+
}
38+
}
39+
40+
/**
41+
* Extracts error context (userId, sessionId) from a session.
42+
* Safely handles missing or invalid data.
43+
*/
44+
export function getSessionErrorContext(session?: Session | null): SessionErrorContext {
45+
if (!session) {
46+
return {};
47+
}
48+
49+
const context: SessionErrorContext = {};
50+
51+
// Extract userId from session.user.id
52+
if (session.user?.id) {
53+
context.userId = session.user.id;
54+
}
55+
56+
// Extract sessionId from JWT sid claim
57+
if (session.accessToken) {
58+
try {
59+
const { payload } = decodeJwt(session.accessToken);
60+
if (payload.sid) {
61+
context.sessionId = payload.sid;
62+
}
63+
} catch {
64+
// Ignore JWT decode errors - just return without sessionId
65+
}
66+
}
67+
68+
return context;
69+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
22
import { handleAuth } from './authkit-callback-route.js';
3+
import { AuthKitError, SessionError } from './errors.js';
34
import { authkit, authkitMiddleware } from './middleware.js';
45
import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
56
import { validateApiKey } from './validate-api-key.js';
@@ -8,6 +9,8 @@ import { getWorkOS } from './workos.js';
89
export * from './interfaces.js';
910

1011
export {
12+
AuthKitError,
13+
SessionError,
1114
authkit,
1215
authkitMiddleware,
1316
getSignInUrl,

src/session.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { redirect } from 'next/navigation';
77
import { NextRequest, NextResponse } from 'next/server';
88
import { getCookieOptions, getJwtCookie } from './cookie.js';
99
import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js';
10+
import { SessionError, getSessionErrorContext } from './errors.js';
1011
import { getAuthorizationUrl } from './get-authorization-url.js';
1112
import {
1213
AccessToken,
@@ -406,9 +407,11 @@ async function refreshSession({
406407
organizationId: nextOrganizationId ?? organizationIdFromAccessToken,
407408
});
408409
} catch (error) {
409-
throw new Error(`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, {
410-
cause: error,
411-
});
410+
throw new SessionError(
411+
`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`,
412+
error,
413+
getSessionErrorContext(session),
414+
);
412415
}
413416

414417
const headersList = await headers();

0 commit comments

Comments
 (0)