Files
HealthLog/src/app/api/user/profile/route.ts
T
Marc-André Bombeck 8da7583ec6 fix(auth): link single sign-on to an existing account only after its own sign-in
The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.

The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.

Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
2026-09-26 16:10:32 +02:00

150 lines
5.3 KiB
TypeScript

/**
* iOS-friendly user profile endpoint.
*
* GET → flattened profile fields (camelCase) for the native client.
* Aliased over the same data exposed by `/api/auth/me`.
* PATCH → delegates to the shared `applyProfileUpdate` helper used by
* `/api/auth/profile` PUT — same validation, same audit log.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { authorizeSensitiveChange } from "@/lib/auth/existing-factor-proof";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "user.profile.get" } });
// Overlap the profile-row read with the module-map resolve (a separate
// memoised Postgres round-trip) — both feed the same response and have
// no ordering dependency.
const [dbUser, modules] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
username: true,
displayName: true,
email: true,
dateOfBirth: true,
gender: true,
heightCm: true,
locale: true,
timezone: true,
timeFormat: true,
dateFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
insurerIkNumber: true,
insuranceNumberEncrypted: true,
},
}),
// v1.18.0 — resolved module enable/disable map, identical to the
// projection `GET /api/auth/me` returns. The iOS app reads this alias
// endpoint, so it must carry the same server-authoritative flags; a
// `false` key means the module surface disappears end-to-end.
resolveModuleMap(user.id),
]);
// v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s
// the profile fetch.
let insuranceNumber: string | null = null;
if (dbUser?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(dbUser.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
return apiSuccess({
username: dbUser?.username ?? user.username,
displayName: dbUser?.displayName ?? null,
email: dbUser?.email ?? null,
dateOfBirth: dbUser?.dateOfBirth?.toISOString() ?? null,
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
// The zone the server cuts this account's days in, exactly as
// `/api/auth/me` reports it: the stored zone, or the instance default
// when the stored value is unusable. Never the raw column.
timezone: await resolveStoredTimezone(dbUser?.timezone),
timeFormat: dbUser?.timeFormat ?? "AUTO",
dateFormat: dbUser?.dateFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
fullName: dbUser?.fullName ?? null,
insurerName: dbUser?.insurerName ?? null,
insurerIkNumber: dbUser?.insurerIkNumber ?? null,
insuranceNumber,
// v1.18.0 — module enable/disable map, matching the /api/auth/me
// projection exactly so the native client gets module flags from the
// endpoint it actually reads.
modules,
});
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const auth = await requireAuth();
const { user } = auth;
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const ip = getClientIp(request);
const result = await applyProfileUpdate(user.id, body, ip, {
// A new email address needs a fresh proof: the address is what single
// sign-on links an existing account by.
authorizeEmailChange: (currentPassword) =>
authorizeSensitiveChange({
user,
cookieSessionId: auth.authMethod === "cookie" ? auth.session.id : null,
currentPassword,
ipAddress: ip,
stage: "email_change",
}),
});
if (!result.ok) {
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
return result.issues
? apiValidationError(result.message, result.issues, result.status, meta)
: apiError(result.message, result.status, meta);
}
annotate({ action: { name: "user.profile.update" } });
return apiSuccess({
username: result.user.username,
displayName: result.user.displayName,
email: result.user.email,
dateOfBirth: result.user.dateOfBirth?.toISOString() ?? null,
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: await resolveStoredTimezone(result.user.timezone),
timeFormat: result.user.timeFormat,
dateFormat: result.user.dateFormat,
moodReminderEnabled: result.user.moodReminderEnabled,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
insurerIkNumber: result.user.insurerIkNumber,
hasInsuranceNumber: result.user.hasInsuranceNumber,
...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}),
});
});