diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 3148e48..703d493 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -71,7 +71,9 @@ const configSchema = z.object({ SMTP_USER: z.string().default(""), SMTP_PASS: z.string().default(""), MAIL_FROM: z.string().default(""), - APPLE_BUNDLE_ID: z.string().default(""), + /** Firebase Admin service-account (hela JSON:en base64-kodad). Tom = Firebase-session av. Lokalt via .env, prod via SSM. */ + FIREBASE_SERVICE_ACCOUNT_B64: z.string().optional(), + APPLE_BUNDLE_ID: z.string().default(""), GOOGLE_PACKAGE_NAME: z.string().default(""), }); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 38ea7b4..2f40d33 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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