managarten/services/mana-auth/src/routes/auth.ts
Till JS 55cc75e7d3 fix(mana-auth): /api/v1/auth/login uses wrong cookie name in production
The custom /api/v1/auth/login route signs the user in via the
better-auth SDK (auth.api.signInEmail) and then forges a request to
/api/auth/token to mint a JWT, passing the session token as a synthetic
cookie header.

The cookie name was hardcoded as `mana.session_token=...`, but in
production better-auth issues the session cookie with the __Secure-
prefix (because secure: true is enabled). Get-session middleware on the
/api/auth/token side couldn't find the session under the unprefixed
name, so it returned 401 silently. Result: tokenResponse.ok was false,
the route fell through, and the response had no `accessToken` field at
all — only the bare { token, user, redirect } from signInEmail.

The frontend in @mana/shared-auth then picked this up as
`data.accessToken === undefined` and stored undefined as the JWT, while
the parallel /api/auth/sign-in/email call masked the visible damage by
setting the SSO cookie. So login *appeared* to work in the browser
(cookie present, session worked) but the JWT path was always broken.

Fix: pick the cookie name based on config.nodeEnv. In production use
__Secure-mana.session_token, in development use mana.session_token (no
__Secure- prefix because secure: false in dev).

Verified end-to-end on auth.mana.how:
  POST /api/v1/auth/login → response now includes accessToken (a real
  JWT, EdDSA, with sub/email/role/sid/tier/iss/aud claims), refreshToken
  (the session token), plus the original signInEmail fields.

The other /api/auth/get-session call sites in this file forward the
incoming request headers verbatim, so they preserve whatever real cookie
the browser sent and don't have this bug.
2026-04-08 16:20:18 +02:00

369 lines
12 KiB
TypeScript

/**
* Auth routes — Custom endpoints wrapping Better Auth
*
* Adds business logic (security events, lockout, credit init)
* around Better Auth's native sign-in/sign-up.
*/
import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
import type { BetterAuthInstance } from '../auth/better-auth.config';
import type { SecurityEventsService, AccountLockoutService } from '../services/security';
import type { SignupLimitService } from '../services/signup-limit';
import type { Config } from '../config';
import { sourceAppStore, passwordResetRedirectStore } from '../auth/stores';
export function createAuthRoutes(
auth: BetterAuthInstance,
config: Config,
security: SecurityEventsService,
lockout: AccountLockoutService,
signupLimit: SignupLimitService
) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
// ─── Registration ────────────────────────────────────────
// ─── Signup Status (public) ─────────────────────────────
app.get('/signup-status', async (c) => {
const status = await signupLimit.getStatus();
return c.json(status);
});
app.post('/register', async (c) => {
const body = await c.req.json();
// Check daily signup limit
const limitCheck = await signupLimit.checkLimit();
if (!limitCheck.allowed) {
return c.json(
{
error: 'Registration limit reached',
message: 'Das tägliche Registrierungslimit ist erreicht. Versuche es morgen wieder.',
spotsRemaining: 0,
resetsAt: limitCheck.resetsAt,
},
429
);
}
// Store source app URL for email verification redirect
if (body.sourceAppUrl && body.email) {
sourceAppStore.set(body.email, body.sourceAppUrl);
}
let response;
try {
response = await auth.api.signUpEmail({
body: {
email: body.email,
password: body.password,
name: body.name || body.email.split('@')[0],
},
headers: c.req.raw.headers,
});
} catch (error) {
const isUserExists =
(error as any)?.body?.code === 'USER_ALREADY_EXISTS' ||
(error as any)?.status === 'UNPROCESSABLE_ENTITY';
if (isUserExists) {
return c.json({ error: 'Email already registered', code: 'EMAIL_ALREADY_REGISTERED' }, 409);
}
throw error;
}
if (response?.user?.id) {
security.logEvent({ userId: response.user.id, eventType: 'REGISTER' });
// Init credits (fire-and-forget)
fetch(`${config.manaCreditsUrl}/api/v1/internal/credits/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Service-Key': config.serviceKey },
body: JSON.stringify({ userId: response.user.id }),
}).catch(() => {});
// Redeem pending gifts
fetch(`${config.manaCreditsUrl}/api/v1/internal/gifts/redeem-pending`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Service-Key': config.serviceKey },
body: JSON.stringify({ userId: response.user.id, email: body.email }),
}).catch(() => {});
}
return c.json(response);
});
// ─── Login ───────────────────────────────────────────────
app.post('/login', async (c) => {
const body = await c.req.json();
// Check lockout
const lockoutStatus = await lockout.checkLockout(body.email);
if (lockoutStatus.locked) {
return c.json(
{ error: 'Account locked', remainingSeconds: lockoutStatus.remainingSeconds },
429
);
}
const ip = c.req.header('x-forwarded-for') || 'unknown';
try {
const response = await auth.api.signInEmail({
body: { email: body.email, password: body.password },
headers: c.req.raw.headers,
});
if (response?.user?.id) {
security.logEvent({ userId: response.user.id, eventType: 'LOGIN_SUCCESS', ipAddress: ip });
lockout.clearAttempts(body.email);
}
// signInEmail returns { token (session token), user, redirect }
// Use the session token to call Better Auth's JWT /token endpoint.
//
// In production Better Auth issues the session cookie with the
// __Secure- prefix (because secure: true is set), so we have to
// pass that exact cookie name back when forging the request to
// /api/auth/token. Without the prefix the get-session middleware
// can't find the session and the JWT mint silently fails — the
// route falls through and returns a response without accessToken.
const sessionToken = response?.token;
if (sessionToken) {
const cookieName =
config.nodeEnv === 'production' ? '__Secure-mana.session_token' : 'mana.session_token';
const tokenResponse = await auth.handler(
new Request(new URL('/api/auth/token', config.baseUrl), {
method: 'GET',
headers: new Headers({ cookie: `${cookieName}=${sessionToken}` }),
})
);
if (tokenResponse.ok) {
const tokenData = await tokenResponse.json();
return c.json({
...response,
accessToken: tokenData.token,
refreshToken: sessionToken,
});
}
}
return c.json(response);
} catch (error) {
// Better Auth throws APIError with status="FORBIDDEN" for unverified emails.
// Do NOT access error.body — it may be an async stream that triggers unhandled
// promise rejections when the request body contains special characters (e.g. !).
const isEmailNotVerified = (error as any)?.status === 'FORBIDDEN';
if (isEmailNotVerified) {
return c.json({ error: 'Email not verified', code: 'EMAIL_NOT_VERIFIED' }, 403);
}
security.logEvent({
eventType: 'LOGIN_FAILURE',
ipAddress: ip,
metadata: { email: body.email },
});
lockout.recordAttempt(body.email, false, ip);
return c.json({ error: 'Invalid credentials' }, 401);
}
});
// ─── Session → JWT Token Exchange ───────────────────────
// Used by SSO (trySSO) and after 2FA verification to get JWT from session cookie
app.post('/session-to-token', async (c) => {
// First verify the session is valid
const sessionResponse = await auth.handler(
new Request(new URL('/api/auth/get-session', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
if (!sessionResponse.ok) {
return c.json({ error: 'No valid session' }, 401);
}
const sessionData = await sessionResponse.json();
if (!sessionData?.session?.token) {
return c.json({ error: 'No valid session' }, 401);
}
// Generate JWT from the session
const tokenResponse = await auth.handler(
new Request(new URL('/api/auth/token', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
if (!tokenResponse.ok) {
return c.json({ error: 'Token generation failed' }, 500);
}
const tokenData = await tokenResponse.json();
return c.json({
accessToken: tokenData.token,
// Session token serves as refresh mechanism via session cookie
refreshToken: sessionData.session.token,
});
});
// ─── Token Validation ────────────────────────────────────
app.post('/validate', async (c) => {
const { token } = await c.req.json();
if (!token) return c.json({ valid: false }, 400);
try {
const { jwtVerify, createRemoteJWKSet } = await import('jose');
const jwks = createRemoteJWKSet(new URL('/api/auth/jwks', config.baseUrl));
const { payload } = await jwtVerify(token, jwks, {
issuer: config.baseUrl,
audience: 'mana',
});
return c.json({ valid: true, payload });
} catch {
return c.json({ valid: false }, 401);
}
});
// ─── Session & Logout ────────────────────────────────────
app.post('/logout', async (c) => {
return auth.handler(
new Request(new URL('/api/auth/sign-out', config.baseUrl), {
method: 'POST',
headers: c.req.raw.headers,
})
);
});
app.get('/session', async (c) => {
return auth.handler(
new Request(new URL('/api/auth/get-session', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
});
app.post('/refresh', async (c) => {
// Generate a fresh JWT from the session cookie
const tokenResponse = await auth.handler(
new Request(new URL('/api/auth/token', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
if (!tokenResponse.ok) {
return c.json({ error: 'Session expired' }, 401);
}
const tokenData = await tokenResponse.json();
// Also get session data for the refresh token
const sessionResponse = await auth.handler(
new Request(new URL('/api/auth/get-session', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
const sessionData = sessionResponse.ok ? await sessionResponse.json() : null;
return c.json({
accessToken: tokenData.token,
refreshToken: sessionData?.session?.token,
});
});
// ─── Password Management ─────────────────────────────────
app.post('/forgot-password', async (c) => {
const body = await c.req.json();
if (body.redirectTo && body.email) {
passwordResetRedirectStore.set(body.email, body.redirectTo);
}
await auth.api.forgetPassword({ body: { email: body.email, redirectTo: body.redirectTo } });
security.logEvent({ eventType: 'PASSWORD_RESET_REQUESTED', metadata: { email: body.email } });
return c.json({ success: true });
});
app.post('/reset-password', async (c) => {
const body = await c.req.json();
await auth.api.resetPassword({ body: { newPassword: body.newPassword, token: body.token } });
security.logEvent({ eventType: 'PASSWORD_RESET_COMPLETED' });
return c.json({ success: true });
});
app.post('/resend-verification', async (c) => {
const body = await c.req.json();
if (body.sourceAppUrl && body.email) {
sourceAppStore.set(body.email, body.sourceAppUrl);
}
await auth.api.sendVerificationEmail({ body: { email: body.email } });
return c.json({ success: true });
});
// ─── Profile ─────────────────────────────────────────────
app.get('/profile', async (c) => {
return auth.handler(
new Request(new URL('/api/auth/get-session', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
});
app.post('/profile', async (c) => {
const body = await c.req.json();
const result = await auth.api.updateUser({ body, headers: c.req.raw.headers });
security.logEvent({ eventType: 'PROFILE_UPDATED' });
return c.json(result);
});
app.post('/change-password', async (c) => {
const body = await c.req.json();
await auth.api.changePassword({
body: { currentPassword: body.currentPassword, newPassword: body.newPassword },
headers: c.req.raw.headers,
});
security.logEvent({ eventType: 'PASSWORD_CHANGED' });
return c.json({ success: true });
});
app.delete('/account', async (c) => {
const body = await c.req.json();
await auth.api.deleteUser({
body: { password: body.password },
headers: c.req.raw.headers,
});
security.logEvent({ eventType: 'ACCOUNT_DELETED' });
return c.json({ success: true });
});
// ─── Security Events ─────────────────────────────────────
app.get('/security-events', async (c) => {
const user = c.get('user');
const events = await security.getUserEvents(user.userId);
return c.json(events);
});
// ─── JWKS ────────────────────────────────────────────────
app.get('/jwks', async (c) => {
return auth.handler(
new Request(new URL('/api/auth/jwks', config.baseUrl), {
method: 'GET',
headers: c.req.raw.headers,
})
);
});
return app;
}