feat(api): /v1/auth/session – växla Firebase-ID-token mot intern session (AUTH-3)
This commit is contained in:
+136
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user