feat(api): /v1/auth/session – växla Firebase-ID-token mot intern session (AUTH-3)

This commit is contained in:
Sven (AAMOS AI)
2026-08-15 20:22:09 +07:00
parent f417902ac9
commit 8f61ef1592
2 changed files with 139 additions and 2 deletions
+136 -1
View File
@@ -20,6 +20,33 @@ import { errors, parse } from "../lib/errors.js";
import { hashPassword, verifyPassword } from "../lib/passwords.js";
import { verifyTotp } from "../lib/totp.js";
import { audit, sha256 } from "../lib/helpers.js";
import { z } from "zod";
import { createFirebaseTokenVerifier, type TokenVerifier } from "../lib/token-verifier.js";
/** Body-schema för Firebase-sessionväxling. */
const firebaseSessionInputSchema = z.object({
idToken: z.string().min(1),
locale: z.string().optional(),
});
/** Lat-initierad Firebase-verifierare (byggd ur base64-env). Cache per process. */
let firebaseVerifier: TokenVerifier | null = null;
function getFirebaseVerifier(app: FastifyInstance): TokenVerifier {
if (firebaseVerifier) return firebaseVerifier;
const b64 = app.config.FIREBASE_SERVICE_ACCOUNT_B64;
if (!b64) throw errors.internal("Firebase är inte konfigurerat (FIREBASE_SERVICE_ACCOUNT_B64 saknas).");
const sa = JSON.parse(Buffer.from(b64, "base64").toString("utf8")) as {
project_id: string;
client_email: string;
private_key: string;
};
firebaseVerifier = createFirebaseTokenVerifier({
projectId: sa.project_id,
clientEmail: sa.client_email,
privateKey: sa.private_key,
});
return firebaseVerifier;
}
/**
* Auth: registrering, inloggning, roterande refresh-tokens, utloggning.
@@ -100,7 +127,115 @@ export async function authRoutes(app: FastifyInstance) {
});
});
app.post("/v1/auth/login", strictLimit, async (req, reply) => {
// Firebase-sessionväxling: verifiera Firebase-ID-token -> find-or-create user -> intern session.
// Firebase äger kundinloggningen; detta är enda kopplingen till våra interna tokens.
app.post("/v1/auth/session", strictLimit, async (req, reply) => {
const input = parse(firebaseSessionInputSchema, req.body);
const identity = await getFirebaseVerifier(app)
.verify(input.idToken)
.catch(() => {
throw errors.unauthorized("Ogiltig Firebase-token.");
});
// 1) Redan kopplad via firebaseUid?
let [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.firebaseUid, identity.uid), isNull(schema.users.deletedAt)))
.limit(1);
// 2) Annars: länka befintligt konto på VERIFIERAD e-post (annars neka undviker kontokapning).
if (!user && identity.email) {
const [byEmail] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.email, identity.email), isNull(schema.users.deletedAt)))
.limit(1);
if (byEmail) {
if (!identity.emailVerified) throw errors.forbidden("E-postadressen måste vara verifierad.");
if (byEmail.firebaseUid && byEmail.firebaseUid !== identity.uid)
throw errors.conflict("Kontot är redan kopplat till en annan inloggning.");
[user] = await app.db
.update(schema.users)
.set({ firebaseUid: identity.uid, emailVerifiedAt: byEmail.emailVerifiedAt ?? new Date() })
.where(eq(schema.users.id, byEmail.id))
.returning();
}
}
// 3) Skapa nytt konto (inget internt lösenord Firebase äger inloggningen).
if (!user) {
if (!identity.email) throw errors.badRequest("Firebase-token saknar e-postadress.");
const locale = input.locale ?? "sv-SE";
const displayName = identity.name ?? identity.email.split("@")[0] ?? "Användare";
[user] = await app.db
.insert(schema.users)
.values({
email: identity.email,
displayName,
locale,
firebaseUid: identity.uid,
emailVerifiedAt: identity.emailVerified ? new Date() : null,
})
.returning();
if (!user) throw errors.internal();
await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing();
await app.db
.insert(schema.userHealthProfiles)
.values({ userId: user.id })
.onConflictDoNothing();
const consentNow = new Date();
await app.db
.insert(schema.userConsents)
.values({
userId: user.id,
kind: "product_analytics",
status: "granted",
grantedAt: consentNow,
updatedAt: consentNow,
})
.onConflictDoNothing();
const regionFromLocale = locale.split("-")[1]?.toUpperCase();
const localeDefaults = regionFromLocale
? localeDefaultsForRegion(regionFromLocale)
: DEFAULT_LOCALE_PREFERENCES;
await app.db
.insert(schema.userLocalePreferences)
.values({
userId: user.id,
languageTag: locale,
regionCode: localeDefaults.regionCode,
timeZone: localeDefaults.timeZone,
measurementSystem: localeDefaults.measurementSystem,
temperatureUnit: localeDefaults.temperatureUnit,
currencyCode: localeDefaults.currencyCode,
})
.onConflictDoNothing();
await audit(app.db, {
actorUserId: user.id,
action: "auth.firebase_provision",
ip: req.ip,
correlationId: req.correlationId,
});
}
if (!user) throw errors.internal();
await audit(app.db, {
actorUserId: user.id,
action: "auth.firebase_session",
ip: req.ip,
correlationId: req.correlationId,
});
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip);
return reply.send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
app.post("/v1/auth/login", strictLimit, async (req, reply) => {
const input = parse(loginInputSchema, req.body);
const [user] = await app.db