Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions __tests__/routes/betterAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,91 @@ describe('betterAuth routes', () => {
});
});
});

describe('error response logging', () => {
it('should log error details for failed sign-up requests', async () => {
const errorBody = { code: 'BAD_REQUEST', message: 'Failed to create user' };
const getBetterAuthSpy = jest
.spyOn(betterAuthModule, 'getBetterAuth')
.mockReturnValue({
handler: async () => {
return new Response(JSON.stringify(errorBody), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
},
api: {
getSession: async () => null,
setPassword: async () => ({ status: true }),
},
} as ReturnType<typeof betterAuthModule.getBetterAuth>);

const res = await request(app.server)
.post('/auth/sign-up/email')
.send({
name: 'Test User',
email: 'test@example.com',
password: 'secret123',
username: 'testuser',
});

expect(res.status).toBe(400);
expect(res.body).toEqual(errorBody);

getBetterAuthSpy.mockRestore();
});

it('should not log for non-monitored paths', async () => {
const getBetterAuthSpy = jest
.spyOn(betterAuthModule, 'getBetterAuth')
.mockReturnValue({
handler: async () => {
return new Response(JSON.stringify({ error: 'not found' }), {
status: 404,
});
},
api: {
getSession: async () => null,
setPassword: async () => ({ status: true }),
},
} as ReturnType<typeof betterAuthModule.getBetterAuth>);

const res = await request(app.server).get('/auth/some-other-path');

expect(res.status).toBe(404);

getBetterAuthSpy.mockRestore();
});

it('should sanitize sensitive fields from request body in logs', async () => {
const getBetterAuthSpy = jest
.spyOn(betterAuthModule, 'getBetterAuth')
.mockReturnValue({
handler: async () => {
return new Response(
JSON.stringify({ code: 'BAD_REQUEST', message: 'error' }),
{ status: 400 },
);
},
api: {
getSession: async () => null,
setPassword: async () => ({ status: true }),
},
} as ReturnType<typeof betterAuthModule.getBetterAuth>);

const res = await request(app.server)
.post('/auth/sign-up/email')
.send({
name: 'Test',
email: 'test@test.com',
password: 'should-be-stripped',
turnstileToken: 'should-be-stripped',
username: 'visible',
});

// Response is still returned correctly
expect(res.status).toBe(400);

getBetterAuthSpy.mockRestore();
});
});
91 changes: 90 additions & 1 deletion src/betterAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,24 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => {
basePath: '/auth',
secret: process.env.BETTER_AUTH_SECRET ?? '',
trustedOrigins,
logger: {
level: 'error',
log: (level, message, ...args) => {
const extra =
args.length === 1 && args[0] instanceof Error
? { err: args[0].message, stack: args[0].stack }
: args.length > 0
? { betterAuthArgs: args }
: undefined;
const logFn =
level === 'error'
? logger.error.bind(logger)
: level === 'warn'
? logger.warn.bind(logger)
: logger.info.bind(logger);
logFn({ betterAuth: true, ...extra }, `[BetterAuth] ${message}`);
},
},
secondaryStorage: {
get: (key) => singleRedisClient.get(`ba:${key}`),
set: (key, value, ttl) =>
Expand All @@ -318,6 +336,55 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => {
};
}
}),
after: createAuthMiddleware(async (ctx) => {
const hookContext = ctx as BetterAuthHookContext & {
responseHeader?: Headers;
response?: unknown;
returned?: unknown;
};
const signUpPaths = [signUpEmailPath, '/sign-in/social'];
if (
hookContext.path &&
signUpPaths.some((p) => hookContext.path?.startsWith(p))
) {
// Better Auth resolves errors internally and returns them as JSON
// responses. Inspect the returned value to log the underlying cause
// which is otherwise lost by the time it reaches the client.
const returned = hookContext.returned as
| { status?: number; body?: Record<string, unknown> }
| undefined;
if (returned?.body) {
const body = returned.body as Record<string, unknown>;
const hasError =
body.code === 'INTERNAL_SERVER_ERROR' ||
body.code === 'BAD_REQUEST' ||
(typeof body.message === 'string' &&
body.message.toLowerCase().includes('failed'));
if (hasError) {
logger.warn(
{
betterAuthHook: {
path: hookContext.path,
responseStatus: returned.status,
errorCode: body.code,
errorMessage: body.message,
errorBody: body,
requestEmail:
typeof hookContext.body?.email === 'string'
? hookContext.body.email
: undefined,
requestUsername:
typeof hookContext.body?.username === 'string'
? hookContext.body.username
: undefined,
},
},
`BetterAuth hook: error on ${hookContext.path} — ${body.code}: ${body.message}`,
);
}
}
}
}),
},
advanced: {
cookiePrefix: 'daily',
Expand Down Expand Up @@ -412,12 +479,34 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => {
const cookieHeader =
hookCtx?.request?.headers?.get('cookie') ?? '';
const trackingId = parseTrackingIdFromCookieHeader(cookieHeader);

logger.info(
{
betterAuthUserCreate: {
hasTrackingId: !!trackingId,
email: user.email,
username: (user as Record<string, unknown>).username,
name: user.name,
ip:
hookCtx?.request?.headers
?.get('x-forwarded-for')
?.split(',')[0]
?.trim() ?? undefined,
},
},
'BetterAuth: user create hook — before insert',
);

if (trackingId) {
return { data: { id: trackingId } };
}
} catch (err) {
logger.error(
{ err: err instanceof Error ? err.message : String(err) },
{
err: err instanceof Error ? err.message : String(err),
userId: (user as Record<string, unknown>).id,
email: user.email,
},
'Failed to extract tracking ID for new user',
);
}
Expand Down
88 changes: 88 additions & 0 deletions src/routes/betterAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,88 @@ const formatError = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
const internalAuthenticationError = 'Internal authentication error';

/** Auth paths where failed responses should be logged with full detail. */
const MONITORED_AUTH_PATHS = [
'/auth/sign-up/email',
'/auth/sign-in/email',
'/auth/sign-in/social',
];

const isMonitoredPath = (url: string): boolean =>
MONITORED_AUTH_PATHS.some((p) => url.includes(p));

/**
* Safely extract a JSON body from a request string without exposing secrets.
* Strips password and token fields.
*/
const sanitizeBody = (raw: string | undefined): Record<string, unknown> | undefined => {
if (!raw) {
return undefined;
}

try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const sanitized = { ...parsed };
delete sanitized.password;
delete sanitized.turnstileToken;
delete sanitized['x-captcha-response'];
delete sanitized.idToken;
delete sanitized.token;
return sanitized;
} catch {
return undefined;
}
};

/**
* Parse a Better Auth error response body. Returns the parsed object or
* undefined when the body cannot be read (e.g. stream already consumed).
*/
const parseBetterAuthErrorBody = async (
response: Response,
): Promise<Record<string, unknown> | undefined> => {
try {
const text = await response.clone().text();
if (!text) {
return undefined;
}
return JSON.parse(text) as Record<string, unknown>;
} catch {
return undefined;
}
};

/**
* Log detailed context when Better Auth returns an error response for a
* monitored endpoint (sign-up, sign-in). This is critical for diagnosing
* opaque errors like "Failed to create user" where the underlying cause
* is swallowed by the auth library.
*/
const logBetterAuthErrorResponse = async (
request: FastifyRequest,
response: Response,
requestBody: string | undefined,
): Promise<void> => {
const errorBody = await parseBetterAuthErrorBody(response);

request.log.warn(
{
betterAuth: {
status: response.status,
errorBody,
requestPath: request.url,
requestMethod: request.method,
requestBodySanitized: sanitizeBody(requestBody),
ip:
request.headers['x-forwarded-for']?.toString().split(',')[0]?.trim() ??
request.ip,
userAgent: request.headers['user-agent'],
},
},
`BetterAuth error response ${response.status} on ${request.url}`,
);
};

const toRequestBody = (request: FastifyRequest): string | undefined => {
if (request.method === 'GET' || request.method === 'HEAD') {
return undefined;
Expand Down Expand Up @@ -142,6 +224,12 @@ const betterAuthRoute = async (fastify: FastifyInstance): Promise<void> => {
reply,
body,
});

// Log detailed context for error responses on monitored auth paths
if (response.status >= 400 && isMonitoredPath(request.url)) {
await logBetterAuthErrorResponse(request, response, body);
}

return sendBetterAuthResponse(reply, response);
} catch (error) {
return sendBetterAuthError(
Expand Down
Loading