Skip to content
Merged
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
11 changes: 10 additions & 1 deletion src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,21 @@ export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,

trustedOrigins: (_request?: Request) => {
trustedOrigins: (request?: Request) => {
const webOrigin = getWebOrigin(env.FRONTEND_URL);
const origins = [webOrigin];

if (env.NODE_ENV === 'production') {
origins.push('https://*.fluent.bible');
}

if (request && isMobileRequest(request.headers as Headers)) {
const mobileOrigin = request.headers.get('origin');
if (mobileOrigin) {
origins.push(mobileOrigin);
}
}

return origins;
},

Expand Down
9 changes: 8 additions & 1 deletion src/middlewares/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ export async function authenticate(c: Context<AppBindings>, next: Next) {
const hasBearerToken = typeof authHeader === 'string' && authHeader.startsWith('Bearer ');

try {
let sessionHeaders = c.req.raw.headers;
if (hasBearerToken) {
const stripped = new Headers(c.req.raw.headers);
stripped.delete('cookie');
sessionHeaders = stripped;
}

const session = await auth.api.getSession({
headers: c.req.raw.headers,
headers: sessionHeaders,
});

if (!session) {
Expand Down
126 changes: 85 additions & 41 deletions src/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,78 @@ import { auth } from '@/lib/auth';

import { server } from './server';

// ─── Module Mocks ────────────────────────────────────────────────────────────
// ─── Hoisted mocks (evaluated before vi.mock factories) ───────────────────────
// vi.mock() calls are hoisted to the top of the file by Vitest, so any
// variables they reference must also be hoisted via vi.hoisted().

const { mockDb, mockDbQueryBuilder } = vi.hoisted(() => {
const mockDbQueryBuilder = (returnValue: unknown[]) => {
const builder: Record<string, ReturnType<typeof vi.fn>> = {};
builder.from = vi.fn(() => builder);
builder.where = vi.fn(() => builder);
builder.limit = vi.fn(() => Promise.resolve(returnValue));
builder.values = vi.fn(() => Promise.resolve());
return builder;
};

const mockDb = {
select: vi.fn(),
delete: vi.fn(),
insert: vi.fn(),
};

return { mockDb, mockDbQueryBuilder };
});

// ─── Module Mocks ─────────────────────────────────────────────────────────────

vi.mock('@/lib/auth', () => ({
auth: {
// auth.api.getSession is used by the authenticate middleware — keep it available.
api: {
getSession: vi.fn(),
},
api: { getSession: vi.fn() },
handler: vi.fn(),
},
}));

vi.mock('@/db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
},
vi.mock('@/db', () => ({ db: mockDb }));

vi.mock('@/db/schema', () => ({
authSession: { token: 'token', id: 'id', userId: 'userId' },
authAuditLog: {},
}));

vi.mock('@/lib/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('server Route Handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
});

// ─── POST /api/auth/forget-password ──────────────────────────────────────
// ─── POST /api/auth/forget-password ────────────────────────────────────────

describe('pOST /api/auth/forget-password', () => {
it('should proxy to auth.handler with path rewritten to /api/auth/request-password-reset', async () => {
const mockResult = { success: true };
(auth.handler as any).mockResolvedValue(
new Response(JSON.stringify(mockResult), {
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);

const res = await server.request('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-client-type': 'mobile',
},
headers: { 'Content-Type': 'application/json', 'x-client-type': 'mobile' },
body: JSON.stringify({ email: 'test@example.com' }),
});

expect(res.status).toBe(200);
expect(auth.handler).toHaveBeenCalledOnce();

// Verify the request was rewritten to the BetterAuth-native path so
// rate limiting and other middleware in auth.handler are applied.
const proxiedRequest: Request = (auth.handler as any).mock.calls[0][0];
expect(new URL(proxiedRequest.url).pathname).toBe('/api/auth/request-password-reset');

// Original headers (including custom ones) must be forwarded.
expect(proxiedRequest.headers.get('x-client-type')).toBe('mobile');
});

Expand All @@ -83,7 +94,7 @@ describe('server Route Handlers', () => {
});
});

// ─── POST /api/auth/password/set ─────────────────────────────────────────
// ─── POST /api/auth/password/set ───────────────────────────────────────────

describe('pOST /api/auth/password/set', () => {
it('should proxy to auth.handler with path rewritten to /api/auth/set-password', async () => {
Expand All @@ -96,10 +107,7 @@ describe('server Route Handlers', () => {

const res = await server.request('/api/auth/password/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({ newPassword: 'hunter2' }),
});

Expand All @@ -112,25 +120,61 @@ describe('server Route Handlers', () => {
});
});

// ─── POST /api/auth/sign-out ──────────────────────────────────────────────
// ─── POST /api/auth/sign-out ────────────────────────────────────────────────

describe('pOST /api/auth/sign-out', () => {
it('should delegate to auth.handler so Set-Cookie headers are forwarded to the client', async () => {
(auth.handler as any).mockResolvedValue(
new Response(JSON.stringify({ success: true }), { status: 200 })
);
it('bearer sign-out: deletes session from DB and returns { success: true } without calling auth.handler', async () => {
const sessionRow = { id: 'sess-abc', userId: 'user-123' };
mockDb.select.mockReturnValue(mockDbQueryBuilder([sessionRow]));
mockDb.delete.mockReturnValue(mockDbQueryBuilder([]));
mockDb.insert.mockReturnValue(mockDbQueryBuilder([]));

const res = await server.request('/api/auth/sign-out', {
method: 'POST',
headers: { Authorization: 'Bearer valid-token-123' },
});

expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });

// Must NOT fall through to BetterAuth's cookie-based handler
expect(auth.handler).not.toHaveBeenCalled();
// DB lookup and delete must have been initiated
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.delete).toHaveBeenCalled();
});

it('bearer sign-out: returns { success: true } even when token is not in DB (idempotent)', async () => {
// Token not found — empty result set
mockDb.select.mockReturnValue(mockDbQueryBuilder([]));

const res = await server.request('/api/auth/sign-out', {
method: 'POST',
headers: {
Authorization: 'Bearer token-123',
},
headers: { Authorization: 'Bearer already-expired-token' },
});

expect(res.status).toBe(200);
// auth.handler must be called (not auth.api.signOut) so BetterAuth can
// return the full response including the Set-Cookie header for web clients.
expect(auth.handler).toHaveBeenCalled();
expect(await res.json()).toEqual({ success: true });

// No session found → delete must not have been called
expect(mockDb.delete).not.toHaveBeenCalled();
expect(auth.handler).not.toHaveBeenCalled();
});

it('cookie sign-out: delegates to auth.handler so Set-Cookie headers are forwarded to the client', async () => {
(auth.handler as any).mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Set-Cookie': 'session=; Max-Age=0; Path=/' },
})
);

// No Authorization header → cookie-based path
const res = await server.request('/api/auth/sign-out', { method: 'POST' });

expect(res.status).toBe(200);
// Must delegate to BetterAuth so it can clear the session cookie
expect(auth.handler).toHaveBeenCalledOnce();
});
});
});
33 changes: 31 additions & 2 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,37 @@ export function createServer() {
});

// POST /api/auth/sign-out
app.post('/api/auth/sign-out', (c) => {
return auth.handler(c.req.raw);
app.post('/api/auth/sign-out', async (c) => {
const authHeader = c.req.header('Authorization');
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();

const [session] = await db
.select({ id: schema.authSession.id, userId: schema.authSession.userId })
.from(schema.authSession)
.where(eq(schema.authSession.token, token))
.limit(1);

if (session) {
await db.delete(schema.authSession).where(eq(schema.authSession.id, session.id));
try {
await db.insert(schema.authAuditLog).values({
userId: session.userId,
event: 'session.deleted',
ipAddress: c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? null,
userAgent: c.req.header('user-agent') ?? null,
metadata: { sessionId: session.id, via: 'mobile-bearer' },
});
} catch (auditErr) {
logger.error('Failed to log audit event: session.deleted (mobile)', { error: auditErr });
}
}

return c.json({ success: true });
}

// Cookie-based sign-out — delegate to BetterAuth
return proxyToAuth(c, '/api/auth/sign-out');
});

app.all('/api/auth/*', (c) => {
Expand Down
Loading