mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
This commit is contained in:
1 parent
7b5bc130d8
commit
8da7583ec6
27 files changed
+878
-76
No files matched your search
+43
-29
@@ -32,7 +32,14 @@ export default function LoginPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [mode, setMode] = useState<"passkey" | "password">("passkey");
|
||||
// The SSO callback sends a browser here when its email matches an account
|
||||
// that exists but is not linked yet: the person signs in to that account
|
||||
// once with its own credential, which links it. Under an SSO-only policy
|
||||
// this is the one case the password form is shown for.
|
||||
const linkPending = searchParams.get("error") === "oidc_link_required";
|
||||
const [mode, setMode] = useState<"passkey" | "password">(
|
||||
linkPending ? "password" : "passkey",
|
||||
);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
@@ -43,6 +50,7 @@ export default function LoginPage() {
|
||||
oidc_no_email: "auth.oidc.errorNoEmail",
|
||||
oidc_email_unverified: "auth.oidc.errorEmailUnverified",
|
||||
oidc_identity_conflict: "auth.oidc.errorIdentityConflict",
|
||||
oidc_link_required: "auth.oidc.errorLinkRequired",
|
||||
oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled",
|
||||
};
|
||||
return t(key[oidcError] ?? "auth.oidc.errorFailed");
|
||||
@@ -394,33 +402,37 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!oidcStatus?.only && (
|
||||
{(!oidcStatus?.only || linkPending) && (
|
||||
<>
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
|
||||
minimum) on mobile. Login is the most-tapped flow on a
|
||||
fresh install. */}
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
{!oidcStatus?.only && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
@@ -488,13 +500,15 @@ export default function LoginPage() {
|
||||
)}
|
||||
{t("auth.login")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
{!oidcStatus?.only && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user