// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 import { appPrefix, gen, website } from "../../../helpers" import { routes as react } from "../../../helpers/react" import { routes as express } from "../../../helpers/express" context("2FA WebAuthn", () => { beforeEach(() => { cy.task("resetCRI", {}) }) ;[ { login: express.login, settings: express.settings, base: express.base, app: "express" as "express", profile: "mfa", }, { login: react.login, settings: react.settings, base: react.base, app: "react" as "react", profile: "spa", }, ].forEach(({ settings, login, profile, app, base }) => { describe(`for app ${app}`, () => { before(() => { cy.useConfigProfile(profile) cy.proxy(app) }) let email = gen.email() let password = gen.password() beforeEach(() => { cy.clearAllCookies() cy.useConfig((builder) => builder.disableCodeMfa()) email = gen.email() password = gen.password() cy.registerApi({ email, password, fields: { "traits.website": website }, }) cy.login({ email, password }) cy.longPrivilegedSessionTime() cy.task("sendCRI", { query: "WebAuthn.disable", opts: {}, }) }) it("should be able to identify if the authenticator is wrong", () => { cy.visit(settings) // Set up virtual authenticator cy.task("sendCRI", { query: "WebAuthn.enable", opts: {}, }).then(() => { cy.task("sendCRI", { query: "WebAuthn.addVirtualAuthenticator", opts: { options: { protocol: "ctap2", transport: "usb", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }, }).then((addResult) => { cy.get( appPrefix(app) + '[name="webauthn_register_displayname"]', ).type("key1") cy.clickWebAuthButton("register") cy.get('*[name="webauthn_remove"]').should("have.length", 1) cy.task("sendCRI", { query: "WebAuthn.removeVirtualAuthenticator", opts: addResult, }).then(() => { cy.visit(login + "?aal=aal2&refresh=true") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.clickWebAuthButton("login") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.getSession({ expectAal: "aal2", expectMethods: ["password", "webauthn"], }) cy.task("sendCRI", { query: "WebAuthn.addVirtualAuthenticator", opts: { options: { protocol: "ctap2", transport: "usb", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }, }).then((addResult) => { cy.visit(login + "?aal=aal2&refresh=true") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.clickWebAuthButton("login") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.getSession({ expectAal: "aal2", expectMethods: ["password", "webauthn"], }) }) }) }) }) }) it("should be able to link multiple authenticators", () => { cy.visit(settings) cy.task("sendCRI", { query: "WebAuthn.enable", opts: {}, }).then(() => { // Register the first credential with the first virtual authenticator. cy.task("sendCRI", { query: "WebAuthn.addVirtualAuthenticator", opts: { options: { protocol: "ctap2", transport: "usb", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }, }).then((firstAuthenticator) => { cy.get('*[name="webauthn_register_displayname"]').type("key1") cy.clickWebAuthButton("register") cy.get('*[name="webauthn_remove"]').should("have.length", 1) // Swap in a second authenticator for the next credential. The // settings flow lists the first credential in excludeCredentials; // with both authenticators attached, create() would race the // excluded authenticator's InvalidStateError against the new one, so // remove the first before registering on the second. The identity // keeps both credentials server-side either way. cy.task("sendCRI", { query: "WebAuthn.removeVirtualAuthenticator", opts: firstAuthenticator, }) cy.task("sendCRI", { query: "WebAuthn.addVirtualAuthenticator", opts: { options: { protocol: "ctap2", transport: "usb", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }, }).then(() => { cy.get('*[name="webauthn_register_displayname"]').type("key2") cy.clickWebAuthButton("register") cy.get('*[name="webauthn_remove"]').should("have.length", 2) cy.visit(login + "?aal=aal2&refresh=true") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.get('*[name="webauthn_login_trigger"]').should( "have.length", 1, ) cy.clickWebAuthButton("login") }) }) }) }) it("should not register the same authenticator twice", () => { cy.visit(settings) cy.task("sendCRI", { query: "WebAuthn.enable", opts: {}, }).then(() => { cy.task("sendCRI", { query: "WebAuthn.addVirtualAuthenticator", opts: { options: { protocol: "ctap2", transport: "usb", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }, }).then(() => { cy.get('*[name="webauthn_register_displayname"]').type("key1") cy.clickWebAuthButton("register") cy.get('*[name="webauthn_remove"]').should("have.length", 1) // Registering again with the same authenticator must be rejected: // the settings flow lists the existing credential in // excludeCredentials, so the browser refuses to create a duplicate // and the credential count stays at one. cy.get('*[name="webauthn_register_displayname"]').type("key2") cy.clickWebAuthButton("register") cy.get('*[name="webauthn_remove"]').should("have.length", 1) }) }) }) it("should be not be able to link provider if webauth is not enabled", () => { cy.visit(settings) cy.get('*[name="webauthn_register_displayname"]').type("my-key") cy.clickWebAuthButton("register") cy.get('*[name="webauthn_remove"]').should("not.exist") }) it("should be able to link a webauthn provider", () => { cy.visit(settings) // Set up virtual authenticator cy.task("sendCRI", { query: "WebAuthn.enable", opts: {}, }).then(() => { cy.task("sendCRI", { query: "WebAuthn.addVirtualAuthenticator", opts: { options: { protocol: "ctap2", transport: "usb", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, }, }, }).then((addResult) => { // Signing up without a display name causes an error cy.get('*[name="webauthn_remove"]').should("not.exist") cy.clickWebAuthButton("register") cy.get('[data-testid="ui/message/4000002"]').should( "contain.text", "Property webauthn_register_displayname is missing.", ) // Setting up with key works cy.get('*[name="webauthn_register_displayname"]').type("my-key") // We need a workaround here. So first we click, then we submit cy.clickWebAuthButton("register") cy.expectSettingsSaved() cy.get('*[name="webauthn_remove"]').should("exist") // Login without refresh cy.login({ email, password }) cy.visit(login + "?aal=aal2") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.get('*[name="webauthn_login_trigger"]').should("have.length", 1) cy.clickWebAuthButton("login") cy.location().should((loc) => { expect(loc.href).to.not.include("/login") }) cy.getSession({ expectAal: "aal2", expectMethods: ["password", "webauthn"], }) // Login with refresh cy.visit(login + "?aal=aal2&refresh=true") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.get('*[name="webauthn_login_trigger"]').should("have.length", 1) cy.clickWebAuthButton("login") cy.location().should((loc) => { expect(loc.href).to.not.include("/login") }) cy.getSession({ expectAal: "aal2", expectMethods: ["password", "webauthn", "webauthn"], }) cy.visit(settings) cy.get('*[name="webauthn_remove"]').click() cy.get('*[name="webauthn_remove"]').should("not.exist") cy.visit(login + "?aal=aal2&refresh=true") cy.location().should((loc) => { expect(loc.href).to.include("/login") }) cy.get('button[name="webauthn_login_trigger"]').should("not.exist") cy.get('[data-testid="ui/message/1010003"]').should("exist") }) }) }) }) }) })