fix: sso only accounts cannot sign in with a passkey or other local method

This commit is contained in:
LukeGus committed 2026-10-09 11:14:56 -05:00
1 parent 2e3c242e34
commit 2ba11652eb
4 files changed
+67

No files matched your search

+22
View File
@@ -171,6 +171,27 @@ async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
/**
* An account that only signs in through SSO or LDAP must keep doing so. A
* passkey or any other local method would skip the identity provider, so a
* user disabled there could still get in.
*/
export function assertMethodAllowedFor(
user: Pick<UserRecord, "isExternal" | "passwordHash">,
identity: VerifiedIdentity,
methodId: string,
): void {
if (identity.kind === "external" || !user.isExternal) return;
if (user.passwordHash && user.passwordHash.trim() !== "") return;
ensureCoreLoginProviders();
if (getLoginMethod(methodId)?.external) return;
throw new LoginMethodError(
"This account signs in through its identity provider",
403,
"external_login_required",
);
}
async function resolveUser(
identity: VerifiedIdentity,
deviceType: DeviceType,
@@ -253,6 +274,7 @@ export async function runLogin(
): Promise<LoginResult> {
const deviceType = parseUserAgent(req).type;
const user = await resolveUser(identity, deviceType);
assertMethodAllowedFor(user, identity, context.methodId);
await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) {
@@ -527,6 +527,46 @@ describe("external identities", () => {
});
afterEach(() => disposeOidc());
function addSsoUser(passwordHash = "") {
addUser({ passwordHash });
h.state.identities.push({
userId: "u1",
providerId: "3",
subject: "sub-1",
} as never);
}
it("refuses a passkey sign in for an account that only uses SSO", async () => {
addSsoUser();
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
),
).rejects.toMatchObject({ status: 403, code: "external_login_required" });
});
it("still lets an SSO account with a password use a passkey", async () => {
addSsoUser(bcrypt.hashSync(PASSWORD, 4));
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
});
it("lets an external method sign an SSO account in by user id", async () => {
addSsoUser();
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("session");
});
it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
+4
View File
@@ -725,6 +725,10 @@ export function Auth({ onLogin }: AuthProps) {
toast.error(t("auth.secondFactorUnavailable"));
return;
}
if (error?.response?.data?.code === "external_login_required") {
toast.error(t("auth.externalLoginRequired"));
return;
}
toast.error(
error?.response?.data?.error || error?.message || t(fallbackKey),
);
+1
View File
@@ -1027,6 +1027,7 @@
"loadingServer": "Loading server...",
"password": "Password",
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
"externalLoginRequired": "This account signs in through single sign-on. Use that button to sign in.",
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
"serverAuthentication": "Server sign-in",
"minChars": "min. {{min}} characters",