diff --git a/src/backend/auth/login-pipeline.ts b/src/backend/auth/login-pipeline.ts index 2d29a1351..7ed3dffe7 100644 --- a/src/backend/auth/login-pipeline.ts +++ b/src/backend/auth/login-pipeline.ts @@ -171,6 +171,27 @@ async function isTrustedDevice(req: Request, userId: string): Promise { type DeviceType = ReturnType["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, + 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 { 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) { diff --git a/src/backend/tests/auth/login-pipeline.test.ts b/src/backend/tests/auth/login-pipeline.test.ts index 55b1742cb..c74588791 100644 --- a/src/backend/tests/auth/login-pipeline.test.ts +++ b/src/backend/tests/auth/login-pipeline.test.ts @@ -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", diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 4774a6010..7600cc496 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -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), ); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 697ca244a..897aff74e 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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",