diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 1f7cdc81659..44f40080f38 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class SessionController < ApplicationController - before_action :check_local_login_allowed, only: %i[create forgot_password] + before_action :check_local_login_allowed, + only: %i[create forgot_password passkey_challenge passkey_login] before_action :rate_limit_login, only: %i[create email_login] skip_before_action :redirect_to_login_if_required skip_before_action :preload_json, @@ -332,6 +333,34 @@ class SessionController < ApplicationController end end + def passkey_challenge + render json: DiscourseWebauthn.stage_challenge(current_user, secure_session) + end + + def passkey_login + raise Discourse::NotFound unless SiteSetting.experimental_passkeys + + params.require(:publicKeyCredential) + + security_key = + ::DiscourseWebauthn::AuthenticationService.new( + nil, + params[:publicKeyCredential], + session: secure_session, + factor_type: UserSecurityKey.factor_types[:first_factor], + ).authenticate_security_key + + user = User.where(id: security_key.user_id, active: true).first + + if user.email_confirmed? + login(user, false) + else + not_activated(user) + end + rescue ::DiscourseWebauthn::SecurityKeyError => err + render_json_error(err.message, status: 401) + end + def email_login_info token = params[:token] matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login]) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0b1f1e62b50..1392384f66a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -17,6 +17,8 @@ class UsersController < ApplicationController enable_second_factor_totp disable_second_factor list_second_factors + confirm_session + trusted_session update_second_factor create_second_factor_backup select_avatar @@ -24,6 +26,10 @@ class UsersController < ApplicationController revoke_auth_token register_second_factor_security_key create_second_factor_security_key + create_passkey + register_passkey + rename_passkey + delete_passkey feature_topic clear_featured_topic bookmarks @@ -60,7 +66,7 @@ class UsersController < ApplicationController user_menu_messages ] - before_action :second_factor_check_confirmed_password, + before_action :check_confirmed_session, only: %i[ create_second_factor_totp enable_second_factor_totp @@ -69,6 +75,8 @@ class UsersController < ApplicationController create_second_factor_backup register_second_factor_security_key create_second_factor_security_key + register_passkey + delete_passkey ] before_action :respond_to_suspicious_request, only: [:create] @@ -1490,28 +1498,34 @@ class UsersController < ApplicationController end end + def confirm_session + # TODO(pmusaraj): add support for confirming via passkey, 2FA + params.require(:password) + + if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins + raise Discourse::NotFound + end + + if confirm_secure_session + render json: success_json + else + render json: failed_json.merge(error: I18n.t("login.incorrect_password")) + end + end + + def trusted_session + render json: secure_session_confirmed? ? success_json : failed_json + end + def list_second_factors if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins raise Discourse::NotFound end - unless params[:password].empty? - RateLimiter.new( - nil, - "login-hr-#{request.remote_ip}", - SiteSetting.max_logins_per_ip_per_hour, - 1.hour, - ).performed! - RateLimiter.new( - nil, - "login-min-#{request.remote_ip}", - SiteSetting.max_logins_per_ip_per_minute, - 1.minute, - ).performed! - unless current_user.confirm_password?(params[:password]) + if params[:password].present? + if !confirm_secure_session return render json: failed_json.merge(error: I18n.t("login.incorrect_password")) end - confirm_secure_session end if secure_session_confirmed? @@ -1589,6 +1603,62 @@ class UsersController < ApplicationController render json: failed_json.merge(error: err.message) end + def create_passkey + raise Discourse::NotFound unless SiteSetting.experimental_passkeys + + challenge_session = DiscourseWebauthn.stage_challenge(current_user, secure_session) + render json: + success_json.merge( + challenge: challenge_session.challenge, + rp_id: DiscourseWebauthn.rp_id, + rp_name: DiscourseWebauthn.rp_name, + supported_algorithms: ::DiscourseWebauthn::SUPPORTED_ALGORITHMS, + user_secure_id: current_user.create_or_fetch_secure_identifier, + existing_passkey_credential_ids: current_user.passkey_credential_ids, + ) + end + + def register_passkey + raise Discourse::NotFound unless SiteSetting.experimental_passkeys + + params.require(:name) + params.require(:attestation) + params.require(:clientData) + + key = + ::DiscourseWebauthn::RegistrationService.new( + current_user, + params, + session: secure_session, + factor_type: UserSecurityKey.factor_types[:first_factor], + ).register_security_key + + render json: success_json.merge(id: key.id, name: key.name) + rescue ::DiscourseWebauthn::SecurityKeyError => err + render_json_error(err.message, status: 401) + end + + def delete_passkey + raise Discourse::NotFound unless SiteSetting.experimental_passkeys + + current_user.security_keys.find_by(id: params[:id].to_i)&.destroy! + + render json: success_json + end + + def rename_passkey + raise Discourse::NotFound unless SiteSetting.experimental_passkeys + + params.require(:id) + params.require(:name) + + passkey = current_user.security_keys.find_by(id: params[:id].to_i) + raise Discourse::InvalidParameters.new(:id) unless passkey + + passkey.update!(name: params[:name]) + render json: success_json + end + def update_security_key user_security_key = current_user.security_keys.find_by(id: params[:id].to_i) raise Discourse::InvalidParameters unless user_security_key @@ -1671,7 +1741,7 @@ class UsersController < ApplicationController render json: success_json end - def second_factor_check_confirmed_password + def check_confirmed_session if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins raise Discourse::NotFound end @@ -2100,6 +2170,20 @@ class UsersController < ApplicationController end def confirm_secure_session + RateLimiter.new( + nil, + "login-hr-#{request.remote_ip}", + SiteSetting.max_logins_per_ip_per_hour, + 1.hour, + ).performed! + RateLimiter.new( + nil, + "login-min-#{request.remote_ip}", + SiteSetting.max_logins_per_ip_per_minute, + 1.minute, + ).performed! + return false if !current_user.confirm_password?(params[:password]) + secure_session["confirmed-password-#{current_user.id}"] = "true" end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 2da59c7e787..38e21e9ac73 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -59,6 +59,7 @@ class UserSerializer < UserCardSerializer :can_change_website, :can_change_tracking_preferences, :user_api_keys, + :user_passkeys, :user_auth_tokens, :user_notification_schedule, :use_logo_small_as_avatar, @@ -164,6 +165,18 @@ class UserSerializer < UserCardSerializer ) end + def user_passkeys + UserSecurityKey + .where(user_id: object.id, factor_type: UserSecurityKey.factor_types[:first_factor]) + .map do |usk| + { id: usk.id, name: usk.name, last_used: usk.last_used, created_at: usk.created_at } + end + end + + def include_user_passkeys? + SiteSetting.experimental_passkeys? + end + def bio_raw object.user_profile.bio_raw end diff --git a/config/routes.rb b/config/routes.rb index 5e680586795..0931b0538a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -429,6 +429,8 @@ Discourse::Application.routes.draw do if Rails.env.test? post "session/2fa/test-action" => "session#test_second_factor_restricted_route" end + get "session/passkey/challenge" => "session#passkey_challenge" + post "session/passkey/auth" => "session#passkey_login" get "session/scopes" => "session#scopes" get "composer/mentions" => "composer#mentions" get "composer_messages" => "composer_messages#index" @@ -466,6 +468,9 @@ Discourse::Application.routes.draw do end end + get "#{root_path}/trusted-session" => "users#trusted_session" + post "#{root_path}/confirm-session" => "users#confirm_session" + post "#{root_path}/second_factors" => "users#list_second_factors" put "#{root_path}/second_factor" => "users#update_second_factor" @@ -480,6 +485,11 @@ Discourse::Application.routes.draw do put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup" + post "#{root_path}/create_passkey" => "users#create_passkey" + post "#{root_path}/register_passkey" => "users#register_passkey" + put "#{root_path}/rename_passkey/:id" => "users#rename_passkey" + delete "#{root_path}/delete_passkey/:id" => "users#delete_passkey" + put "#{root_path}/update-activation-email" => "users#update_activation_email" post "#{root_path}/email-login" => "users#email_login" get "#{root_path}/admin-login" => "users#admin_login" diff --git a/config/site_settings.yml b/config/site_settings.yml index 0156656b768..bcec4218bf0 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2158,6 +2158,10 @@ developer: experimental_topics_filter: client: true default: false + experimental_passkeys: + client: true + default: false + hidden: true experimental_search_menu_groups: type: group_list list_type: compact diff --git a/lib/discourse_webauthn/authentication_service.rb b/lib/discourse_webauthn/authentication_service.rb index 15ed1236979..b6eabef1ab7 100644 --- a/lib/discourse_webauthn/authentication_service.rb +++ b/lib/discourse_webauthn/authentication_service.rb @@ -33,7 +33,7 @@ module DiscourseWebauthn # verify that response.userHandle is present. Verify that the user account identified by response.userHandle # contains a credential record whose id equals credential.rawId if @factor_type == UserSecurityKey.factor_types[:first_factor] && - Base64.decode64(@params[:userHandle]) != @current_user.secure_identifier + Base64.decode64(@params[:userHandle]) != security_key.user.secure_identifier raise(OwnershipError, I18n.t("webauthn.validation.ownership_error")) end diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index c9056efe849..095ae4e9634 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -117,6 +117,7 @@ RSpec.describe SessionController do [user_security_key.credential_id], ) secure_session = SecureSession.new(session["secure_session_id"]) + expect(response_body_parsed["challenge"]).to eq( DiscourseWebauthn.challenge(user, secure_session), ) @@ -3009,6 +3010,146 @@ RSpec.describe SessionController do end end + describe "#passkey_challenge" do + it "returns a challenge for an anonymous user" do + get "/session/passkey/challenge.json" + expect(response.status).to eq(200) + expect(response.parsed_body["challenge"]).not_to eq(nil) + end + + it "returns a challenge for an authenticated user" do + sign_in(user) + get "/session/passkey/challenge.json" + expect(response.status).to eq(200) + expect(response.parsed_body["challenge"]).not_to eq(nil) + end + + it "reset challenge on subsequent calls" do + get "/session/passkey/challenge.json" + expect(response.status).to eq(200) + challenge1 = response.parsed_body["challenge"] + + get "/session/passkey/challenge.json" + expect(response.parsed_body["challenge"]).not_to eq(challenge1) + end + + it "fails if local logins are not allowed" do + SiteSetting.enable_local_logins = false + + get "/session/passkey/challenge.json" + expect(response.status).to eq(403) + end + end + + describe "#passkey_login" do + it "returns 404 if feature is not enabled" do + SiteSetting.experimental_passkeys = false + + post "/session/passkey/auth.json" + expect(response.status).to eq(404) + end + + context "when experimental_passkeys is enabled" do + before { SiteSetting.experimental_passkeys = true } + + it "fails if public key param is missing" do + post "/session/passkey/auth.json" + expect(response.status).to eq(400) + + json = response.parsed_body + expect(json["errors"][0]).to include("param is missing") + expect(json["errors"][0]).to include("publicKeyCredential") + end + + it "fails on malformed credentials" do + post "/session/passkey/auth.json", params: { publicKeyCredential: "someboringstring" } + expect(response.status).to eq(401) + + json = response.parsed_body + expect(json["errors"][0]).to eq( + I18n.t("webauthn.validation.malformed_public_key_credential_error"), + ) + end + + it "fails on invalid credentials" do + post "/session/passkey/auth.json", + params: { + # creds are well-formed but security key is not registered + publicKeyCredential: { + signature: + "MEYCIQDYtbfkTGHOfizXHBHltn5KOq1eC3EM6Uq4peZ0L+3wMwIhAMgzm88qOOZ7SPYh5M6zvKMjVsUAne7n9RKdN/4Bb6z8", + clientData: + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiWmpJMk16UmxNMlV3TkRSaFl6QmhNemczTURjMlpUaGhaR1l5T1dGaU5qSXpNamMxWmpCaU9EVmxNVFUzTURaaVpEaGpNVEUwTVdJeU1qRXkiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2x9", + authenticatorData: "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAA==", + credentialId: "humAArAAAiZZuwFE/F9Gi4BAVTsRL/FowuzQsYTPKIk=", + }, + } + + expect(response.status).to eq(401) + json = response.parsed_body + expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.not_found_error")) + end + + context "when user has a valid registered passkey" do + let!(:passkey) do + Fabricate( + :user_security_key, + credential_id: valid_passkey_data[:credential_id], + public_key: valid_passkey_data[:public_key], + user: user, + factor_type: UserSecurityKey.factor_types[:first_factor], + last_used: nil, + name: "A key", + ) + end + + it "fails if local logins are not allowed" do + SiteSetting.enable_local_logins = false + + post "/session/passkey/auth.json", + params: { + publicKeyCredential: valid_passkey_auth_data, + } + expect(response.status).to eq(403) + end + + it "fails when the key is registered to another user" do + simulate_localhost_passkey_challenge + user.activate + user.create_or_fetch_secure_identifier + post "/session/passkey/auth.json", + params: { + publicKeyCredential: + valid_passkey_auth_data.merge( + { userHandle: Base64.strict_encode64(SecureRandom.hex(20)) }, + ), + } + expect(response.status).to eq(401) + json = response.parsed_body + expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.ownership_error")) + expect(session[:current_user_id]).to eq(nil) + end + + it "logs the user in" do + simulate_localhost_passkey_challenge + user.activate + user.create_or_fetch_secure_identifier + post "/session/passkey/auth.json", + params: { + publicKeyCredential: + valid_passkey_auth_data.merge( + { userHandle: Base64.strict_encode64(user.secure_identifier) }, + ), + } + expect(response.status).to eq(200) + expect(response.parsed_body["error"]).not_to be_present + + expect(session[:current_user_id]).to eq(user.id) + end + end + end + end + describe "#scopes" do context "when not a valid api request" do it "returns 404" do diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 179b897d88b..868fde5826d 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -5862,6 +5862,7 @@ RSpec.describe UsersController do sign_in(user1) stub_secure_session_confirmed end + context "when user has a registered totp and security key" do before do _totp_second_factor = Fabricate(:user_second_factor_totp, user: user1) @@ -5899,6 +5900,194 @@ RSpec.describe UsersController do end end + describe "#create_passkey" do + before do + SiteSetting.experimental_passkeys = true + stub_secure_session_confirmed + end + + it "fails if user is not logged in" do + post "/u/create_passkey.json" + + expect(response.status).to eq(403) + end + + it "stores the challenge in the session and returns challenge data, user id, and supported algorithms" do + sign_in(user1) + post "/u/create_passkey.json" + + secure_session = read_secure_session + response_parsed = response.parsed_body + expect(response_parsed["challenge"]).to eq(DiscourseWebauthn.challenge(user1, secure_session)) + expect(response_parsed["rp_id"]).to eq(DiscourseWebauthn.rp_id) + expect(response_parsed["rp_name"]).to eq(DiscourseWebauthn.rp_name) + expect(response_parsed["user_secure_id"]).to eq(user1.reload.secure_identifier) + expect(response_parsed["supported_algorithms"]).to eq( + ::DiscourseWebauthn::SUPPORTED_ALGORITHMS, + ) + end + + context "when user has a passkey" do + fab!(:user_security_key) { Fabricate(:passkey_with_random_credential, user: user1) } + + it "returns existing active credentials" do + sign_in(user1) + post "/u/create_passkey.json" + + response_parsed = response.parsed_body + expect(response_parsed["existing_passkey_credential_ids"]).to eq( + [user_security_key.credential_id], + ) + end + end + end + + describe "#rename_passkey" do + before { SiteSetting.experimental_passkeys = true } + + it "fails if no user is logged in" do + put "/u/rename_passkey/NONE.json" + + expect(response.status).to eq(403) + end + + it "fails if no name parameter is provided" do + sign_in(user1) + put "/u/rename_passkey/ID.json" + + expect(response.status).to eq(400) + expect(response.parsed_body["errors"][0]).to eq( + "param is missing or the value is empty: name", + ) + end + + it "fails if key is invalid" do + sign_in(user1) + put "/u/rename_passkey/ID.json", params: { name: "new name" } + + expect(response.status).to eq(400) + expect(response.parsed_body["errors"][0]).to include( + "You supplied invalid parameters to the request: id", + ) + end + + context "with an existing passkey" do + fab!(:passkey) do + Fabricate(:passkey_with_random_credential, user: user1, name: "original name") + end + + it "renames the key" do + sign_in(user1) + put "/u/rename_passkey/#{passkey.id}.json", params: { name: "new name" } + response_parsed = response.parsed_body + + expect(response.status).to eq(200) + expect(passkey.reload.name).to eq("new name") + end + + it "does not let an admin delete a passkey associated with user1" do + sign_in(admin) + + put "/u/rename_passkey/#{passkey.id}.json", params: { name: "new name" } + + expect(passkey.reload.name).to eq("original name") + end + end + end + + describe "#delete_passkey" do + before { SiteSetting.experimental_passkeys = true } + fab!(:passkey) { Fabricate(:passkey_with_random_credential, user: user1) } + + it "fails if user does not have a confirmed session" do + sign_in(user1) + delete "/u/delete_passkey/#{passkey.id}.json" + expect(response.status).to eq(403) + end + + context "with a confirmed session" do + before { stub_secure_session_confirmed } + + it "fails if user is not logged in" do + delete "/u/delete_passkey/#{passkey.id}.json" + expect(response.status).to eq(403) + end + + it "deletes the key" do + sign_in(user1) + delete "/u/delete_passkey/#{passkey.id}.json" + expect(response.status).to eq(200) + expect(user1.passkey_credential_ids).to eq([]) + end + + it "does not let an admin delete a passkey associated with user1" do + sign_in(admin) + delete "/u/delete_passkey/#{passkey.id}.json" + expect(response.status).to eq(200) + + expect(user1.passkey_credential_ids[0]).to eq(passkey.credential_id) + end + end + end + + describe "#register_passkey" do + before { SiteSetting.experimental_passkeys = true } + + it "fails if user is not logged in" do + stub_secure_session_confirmed + post "/u/register_passkey.json" + + expect(response.status).to eq(403) + end + + it "fails if session is not confirmed" do + sign_in(user1) + post "/u/register_passkey.json" + expect(response.status).to eq(403) + end + + context "with a valid key" do + let(:attestation) do + "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAICRXq4sFZ9XpWZOzfJ8EguJmoEPMzNVyFMUWQfT5u1QzpQECAyYgASFYILjOiAHAwNrXkCk/tmyYRiE87QyV/15wUvhcXhr1JfwtIlggClQywgQvSxTsqV/FSK0cNHTTmuwfzzREqE6eLDmPxmI=" + end + let(:valid_client_param) { passkey_client_data_param("webauthn.create") } + let(:invalid_client_param) { passkey_client_data_param("webauthn.get") } + + before do + sign_in(user1) + stub_secure_session_confirmed + simulate_localhost_passkey_challenge + end + + it "registers the passkey" do + post "/u/register_passkey.json", + params: { + name: "My Passkey", + attestation: attestation, + clientData: Base64.encode64(valid_client_param.to_json), + } + + expect(response.status).to eq(200) + expect(response.parsed_body["name"]).to eq("My Passkey") + expect(user1.passkey_credential_ids).to eq([valid_passkey_data[:credential_id]]) + end + + it "does not register a passkey with the wrong webauthn type" do + post "/u/register_passkey.json", + params: { + name: "My Passkey", + attestation: attestation, + clientData: Base64.encode64(invalid_client_param.to_json), + } + + expect(response.status).to eq(401) + expect(response.parsed_body["errors"][0]).to eq( + I18n.t("webauthn.validation.invalid_type_error"), + ) + end + end + end + describe "#revoke_account" do it "errors for unauthorised users" do post "/u/#{user1.username}/preferences/revoke-account.json", @@ -6139,6 +6328,88 @@ RSpec.describe UsersController do end end + describe "#confirm_session" do + let(:user) { user1 } + let(:password) { "test" } + + before { sign_in(user) } + + context "when SSO is enabled" do + before do + SiteSetting.discourse_connect_url = "https://discourse.test/sso" + SiteSetting.enable_discourse_connect = true + end + + it "does not allow access" do + post "/u/confirm-session.json", params: { password: password } + expect(response.status).to eq(404) + end + end + + context "when local logins are not enabled" do + before { SiteSetting.enable_local_logins = false } + + it "does not allow access" do + post "/u/confirm-session.json", params: { password: password } + expect(response.status).to eq(404) + end + end + + context "when the site settings allow second factors" do + before do + SiteSetting.enable_local_logins = true + SiteSetting.enable_discourse_connect = false + end + + context "when the password is wrong" do + it "returns incorrect password response" do + post "/u/confirm-session.json", params: { password: password } + + expect(response.status).to eq(200) + expect(response.parsed_body["error"]).to eq("Incorrect password") + end + end + + context "when the password is correct" do + fab!(:user2) { Fabricate(:user, password: "8555039dd212cc66ec68") } + + it "returns a successful response" do + sign_in(user2) + post "/u/confirm-session.json", params: { password: "8555039dd212cc66ec68" } + expect(response.status).to eq(200) + expect(response.parsed_body["error"]).to eq(nil) + end + end + end + end + + describe "#trusted_session" do + it "returns 403 for anons" do + get "/u/trusted-session.json" + expect(response.status).to eq(403) + end + + it "resopnds with a 'failed' result by default" do + sign_in(user1) + + get "/u/trusted-session.json" + expect(response.status).to eq(200) + expect(response.parsed_body["failed"]).to eq("FAILED") + end + + it "response with 'success' on a confirmed session" do + user2 = Fabricate(:user, password: "8555039dd212cc66ec68") + sign_in(user2) + + post "/u/confirm-session.json", params: { password: "8555039dd212cc66ec68" } + expect(response.status).to eq(200) + + get "/u/trusted-session.json" + expect(response.status).to eq(200) + expect(response.parsed_body["success"]).to eq("OK") + end + end + describe "#feature_topic" do fab!(:topic) { Fabricate(:topic) } fab!(:other_topic) { Fabricate(:topic) } diff --git a/spec/serializers/user_serializer_spec.rb b/spec/serializers/user_serializer_spec.rb index bfa4ebde243..6d74f3b680d 100644 --- a/spec/serializers/user_serializer_spec.rb +++ b/spec/serializers/user_serializer_spec.rb @@ -440,6 +440,27 @@ RSpec.describe UserSerializer do end end + context "with user_passkeys" do + fab!(:user) { Fabricate(:user) } + fab!(:passkey) { Fabricate(:passkey_with_random_credential, user: user) } + + it "does not include them if feature is disabled" do + json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json + + expect(json[:user_passkeys]).to eq(nil) + end + + it "includes passkeys if feature is enabled" do + SiteSetting.experimental_passkeys = true + + json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json + + expect(json[:user_passkeys][0][:id]).to eq(passkey.id) + expect(json[:user_passkeys][0][:name]).to eq(passkey.name) + expect(json[:user_passkeys][0][:last_used]).to eq(passkey.last_used) + end + end + context "for user sidebar attributes" do include_examples "User Sidebar Serializer Attributes", described_class