mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 23:16:41 +02:00
Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint
in /api/v1/auth/login by switching the cookie name from
`mana.session_token` to `__Secure-mana.session_token` for production.
That was necessary but not sufficient: Better Auth's session cookie
value isn't just the raw session token, it's `<token>.<HMAC>` where
the HMAC is derived from the better-auth secret. Reconstructing the
cookie from auth.api.signInEmail's JSON response only gave us the raw
token, so /api/auth/token's get-session middleware still couldn't
validate it and the JWT mint kept silently failing.
Real fix: do the sign-in via auth.handler (the HTTP path) rather than
auth.api.signInEmail (the SDK path). The handler returns a real fetch
Response with a Set-Cookie header containing the fully signed cookie
envelope. We capture that header verbatim and forward it as the cookie
on the /api/auth/token request, which now passes validation and mints
the JWT correctly.
Verified end-to-end on auth.mana.how:
$ curl -X POST https://auth.mana.how/api/v1/auth/login \
-d '{"email":"...","password":"..."}'
{
"user": {...},
"token": "<session token>",
"accessToken": "eyJhbGciOiJFZERTQSI...", ← real JWT now
"refreshToken": "<session token>"
}
Side benefits:
- The email-not-verified path is now handled by checking
signInResponse.status === 403 directly, no more catching APIError
with the comment-noted async-stream footgun.
- X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter
and our security log see the real client IP.
- The leftover catch block now only handles unexpected exceptions
(network errors etc); the FORBIDDEN-checking logic in it is dead but
harmless and left in for defense in depth.
400 lines
13 KiB
TypeScript
400 lines
13 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 {
|
|
// Sign in via Better Auth's HTTP handler so we get back a real
|
|
// Response with Set-Cookie. The auth.api.signInEmail() SDK call
|
|
// only returns the body and we'd lose the signed cookie envelope
|
|
// that /api/auth/token needs to validate the session — the cookie
|
|
// value is `<sessionToken>.<HMAC>`, not just the raw session token,
|
|
// so reconstructing it from the API response doesn't work.
|
|
const signInResponse = await auth.handler(
|
|
new Request(new URL('/api/auth/sign-in/email', config.baseUrl), {
|
|
method: 'POST',
|
|
headers: new Headers({
|
|
'Content-Type': 'application/json',
|
|
// Forward original X-Forwarded-For so Better Auth's rate
|
|
// limiting and our security log see the right IP.
|
|
...(c.req.header('x-forwarded-for')
|
|
? { 'X-Forwarded-For': c.req.header('x-forwarded-for') as string }
|
|
: {}),
|
|
}),
|
|
body: JSON.stringify({ email: body.email, password: body.password }),
|
|
})
|
|
);
|
|
|
|
if (!signInResponse.ok) {
|
|
// Better Auth returns 403 with FORBIDDEN for unverified emails.
|
|
if (signInResponse.status === 403) {
|
|
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);
|
|
}
|
|
|
|
const response = (await signInResponse.json()) as {
|
|
user?: { id: string };
|
|
token?: string;
|
|
redirect?: boolean;
|
|
};
|
|
|
|
if (response?.user?.id) {
|
|
security.logEvent({ userId: response.user.id, eventType: 'LOGIN_SUCCESS', ipAddress: ip });
|
|
lockout.clearAttempts(body.email);
|
|
}
|
|
|
|
// Capture the signed session cookie that Better Auth set on the
|
|
// sign-in response and forward it verbatim to /api/auth/token to
|
|
// mint a JWT. This is the only path that produces a cookie value
|
|
// with a valid HMAC signature.
|
|
const setCookie = signInResponse.headers.get('set-cookie');
|
|
if (setCookie) {
|
|
const tokenResponse = await auth.handler(
|
|
new Request(new URL('/api/auth/token', config.baseUrl), {
|
|
method: 'GET',
|
|
headers: new Headers({ cookie: setCookie }),
|
|
})
|
|
);
|
|
|
|
if (tokenResponse.ok) {
|
|
const tokenData = (await tokenResponse.json()) as { token: string };
|
|
return c.json({
|
|
...response,
|
|
accessToken: tokenData.token,
|
|
refreshToken: response.token,
|
|
});
|
|
}
|
|
}
|
|
|
|
// JWT mint failed (or no Set-Cookie came back). Still return the
|
|
// sign-in body so the client at least sees the user object.
|
|
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;
|
|
}
|