feat(api): /v1/auth/session – växla Firebase-ID-token mot intern session (AUTH-3)
This commit is contained in:
@@ -71,7 +71,9 @@ const configSchema = z.object({
|
|||||||
SMTP_USER: z.string().default(""),
|
SMTP_USER: z.string().default(""),
|
||||||
SMTP_PASS: z.string().default(""),
|
SMTP_PASS: z.string().default(""),
|
||||||
MAIL_FROM: 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(""),
|
GOOGLE_PACKAGE_NAME: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+136
-1
@@ -20,6 +20,33 @@ import { errors, parse } from "../lib/errors.js";
|
|||||||
import { hashPassword, verifyPassword } from "../lib/passwords.js";
|
import { hashPassword, verifyPassword } from "../lib/passwords.js";
|
||||||
import { verifyTotp } from "../lib/totp.js";
|
import { verifyTotp } from "../lib/totp.js";
|
||||||
import { audit, sha256 } from "../lib/helpers.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.
|
* 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 input = parse(loginInputSchema, req.body);
|
||||||
|
|
||||||
const [user] = await app.db
|
const [user] = await app.db
|
||||||
|
|||||||
Reference in New Issue
Block a user