feat: 增加passkey支持 (#270)

* chore: add .pnpm-store to .gitignore

* feat: implement passkey authentication and management features

- Added passkey login and registration endpoints to the auth service.
- Introduced PasskeyManagement component for user passkey management.
- Updated localization files to include passkey-related strings in English and Chinese.
- Created utility functions for handling passkey operations in WebAuthn.
- Adjusted login page to support passkey login flow.
- Modified configuration for local development server.

* feat: enhance passkey management with modal input and localization updates

- Added a modal for entering passkey names during registration in the PasskeyManagement component.
- Updated English and Chinese localization files to include new strings for passkey name input.
- Improved user experience by validating passkey name input before registration.

* feat: enhance passkey login experience with localization updates

- Added new localization strings for passkey authentication errors and status messages in English and Chinese.
- Updated the login page to utilize localized strings for improved user feedback during passkey login attempts.

* feat: add name field to UserPasskey interface for enhanced user identification

* feat: integrate passkey support across login and settings

- Added passkey option to login configuration and updated related components to conditionally render passkey login button.
- Enhanced MyAccount and Logins components to manage passkey settings and display passkey management options based on configuration.
- Updated English and Chinese localization files to include new strings for passkey functionality.

* feat: added server version check for passkey
This commit is contained in:
haorwen
2025-12-12 21:25:49 +08:00
committed by GitHub
parent 344ad200df
commit 210a34b33c
16 changed files with 578 additions and 8 deletions
+1
View File
@@ -4,6 +4,7 @@
/node_modules
/.pnp
.pnp.js
.pnpm-store
# testing
/coverage
+6 -1
View File
@@ -142,6 +142,11 @@
"patchedDependencies": {
"slate-react": "patches/slate-react.patch",
"@toast-ui/editor": "patches/@toast-ui__editor.patch"
}
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js-pure",
"protobufjs"
]
}
}
+6
View File
@@ -6,6 +6,12 @@
"metamask": "Sign in with MetaMask",
"password": "Sign in with Password",
"oidc": "Sign in with OIDC",
"passkey": "Sign in with Passkey",
"passkey_authenticating": "Authenticating...",
"passkey_error_not_supported": "Your browser doesn't support Passkey",
"passkey_error_cancelled": "User cancelled or timeout",
"passkey_error_no_passkey": "No passkey found for this account",
"passkey_error_failed": "Failed to login with passkey",
"no_account": "Don't have an account?"
},
"reg": {
+21
View File
@@ -285,11 +285,32 @@
"github_desc": "Allows members login with GitHub.",
"metamask": "Metamask",
"metamask_desc": "Allows members login with Metamask.",
"passkey": "Passkey",
"passkey_desc": "Allows members login with Passkey.",
"oidc": "OIDC",
"oidc_desc": "Allows members login with OIDC.",
"oidc_custom": "Custom",
"more_details": "Need more detail? See our <0>doc</0>."
},
"passkey": {
"title": "Passkeys",
"desc": "Manage your passkeys for passwordless login",
"add": "Add Passkey",
"adding": "Registering...",
"delete": "Delete",
"delete_confirm": "Are you sure you want to delete this passkey?",
"no_passkeys": "No passkeys registered yet",
"created": "Created",
"last_used": "Last used",
"success_add": "Passkey registered successfully",
"success_delete": "Passkey deleted successfully",
"error_add": "Failed to register passkey",
"error_delete": "Failed to delete passkey",
"error_not_supported": "Your browser doesn't support Passkey",
"error_cancelled": "User cancelled or timeout",
"error_exists": "Passkey already exists",
"enter_name": "Enter a name for this passkey"
},
"widget": {
"tip": "Extending VoceChat by embedding the vocechat widget SDK!",
"code": "Code Example",
+6
View File
@@ -6,6 +6,12 @@
"metamask": "MetaMask登录",
"password": "账号密码登录",
"oidc": "OIDC登录",
"passkey": "使用通行密钥登录",
"passkey_authenticating": "认证中...",
"passkey_error_not_supported": "您的浏览器不支持通行密钥",
"passkey_error_cancelled": "用户取消或超时",
"passkey_error_no_passkey": "未找到此账号的通行密钥",
"passkey_error_failed": "通行密钥登录失败",
"no_account": "没有账号?"
},
"reg": {
+21
View File
@@ -285,11 +285,32 @@
"github_desc": "允许用户使用 GitHub 登录",
"metamask": "Metamask",
"metamask_desc": "允许用户使用 Metamask 登录",
"passkey": "通行密钥",
"passkey_desc": "允许用户使用通行密钥登录",
"oidc": "OIDC",
"oidc_desc": "允许用户使用 OIDC 登录",
"oidc_custom": "自定义",
"more_details": "更多详情,移步 <0> 参考文档 </0> ."
},
"passkey": {
"title": "通行密钥",
"desc": "管理您的通行密钥以实现无密码登录",
"add": "添加通行密钥",
"adding": "注册中...",
"delete": "删除",
"delete_confirm": "确定要删除此通行密钥吗?",
"no_passkeys": "尚未注册通行密钥",
"created": "创建时间",
"last_used": "上次使用",
"success_add": "通行密钥注册成功",
"success_delete": "通行密钥删除成功",
"error_add": "通行密钥注册失败",
"error_delete": "通行密钥删除失败",
"error_not_supported": "您的浏览器不支持通行密钥",
"error_cancelled": "用户取消或超时",
"error_exists": "通行密钥已存在",
"enter_name": "请输入通行密钥名称"
},
"widget": {
"tip": "将挂件 SDK 内嵌到网页,实现 VoceChat 扩展!",
"code": "代码举例",
+1 -1
View File
@@ -12,7 +12,7 @@ const prices: Price[] = [
},
];
const official_dev = `https://dev.voce.chat`;
const local_dev = `http://localhost:3333`;
const local_dev = `http://localhost:3000`;
// const local_dev = `https://dev.voce.chat`;
const dev_origin = process.env.REACT_APP_OFFICIAL_DEMO ? official_dev : local_dev;
+89 -1
View File
@@ -212,6 +212,87 @@ export const authApi = createApi({
url: `/user/delete`,
method: "DELETE"
})
}),
// Passkey endpoints
passkeyRegisterStart: builder.mutation<
import("@/types/auth").PasskeyRegisterStartResponse,
import("@/types/auth").PasskeyRegisterStartRequest
>({
query: (data) => ({
url: "token/passkey/register/start",
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: data
})
}),
passkeyRegisterFinish: builder.mutation<
{ success: boolean; message: string },
import("@/types/auth").PasskeyRegisterFinishRequest
>({
query: (data) => ({
url: "token/passkey/register/finish",
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: data
})
}),
passkeyLoginStart: builder.mutation<
import("@/types/auth").PasskeyLoginStartResponse,
import("@/types/auth").PasskeyLoginStartRequest
>({
query: (data) => ({
url: "token/passkey/auth/start",
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: data
})
}),
passkeyLoginFinish: builder.mutation<AuthData, import("@/types/auth").PasskeyLoginFinishRequest>({
query: (data) => ({
url: "token/passkey/auth/finish",
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: data
}),
transformResponse: (data: AuthData) => {
const { avatar_updated_at } = data.user;
return {
...data,
avatar:
avatar_updated_at == 0
? ""
: `${BASE_URL}/resource/avatar?uid=${data.user.uid}&t=${avatar_updated_at}`
};
},
async onQueryStarted(params, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
if (data) {
dispatch(setAuthData(data));
dispatch(setReady(false));
dispatch(updateSSEStatus("disconnected"));
}
} catch {
console.log("passkey login error");
}
}
}),
getUserPasskeys: builder.query<import("@/types/auth").UserPasskey[], void>({
query: () => ({ url: "token/passkey/list" })
}),
deletePasskey: builder.mutation<void, string>({
query: (credentialId) => ({
url: `token/passkey/${encodeURIComponent(credentialId)}`,
method: "DELETE"
})
})
})
});
@@ -234,5 +315,12 @@ export const {
useCheckMagicTokenValidMutation,
useUpdatePasswordMutation,
useRegisterMutation,
useLazyDeleteCurrentAccountQuery
useLazyDeleteCurrentAccountQuery,
usePasskeyRegisterStartMutation,
usePasskeyRegisterFinishMutation,
usePasskeyLoginStartMutation,
usePasskeyLoginFinishMutation,
useGetUserPasskeysQuery,
useLazyGetUserPasskeysQuery,
useDeletePasskeyMutation
} = authApi;
+7
View File
@@ -29,6 +29,8 @@ const whiteList = [
"sendMessageByBot",
"getAgoraVoicingList",
"preCheckFileFromUrl",
"passkeyLoginStart",
"passkeyLoginFinish",
];
const whiteList401 = ["getAgoraVoicingList", "getAgoraChannels"];
const errorWhiteList = [
@@ -141,6 +143,11 @@ const baseQueryWithTokenCheck = async (args: any, api: any, extraOptions: any) =
toast.error("File size too large");
}
break;
case 415:
{
toast.error("Unsupported Media Type");
}
break;
case 451:
{
// 证书错误
+112
View File
@@ -0,0 +1,112 @@
// Passkey utility functions for WebAuthn
// Base64 转 ArrayBuffer
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
// ArrayBuffer 转 Base64
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 检查浏览器是否支持 WebAuthn
export function isWebAuthnSupported(): boolean {
return window.PublicKeyCredential !== undefined && navigator.credentials !== undefined;
}
// 检查是否有平台认证器可用
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
if (!isWebAuthnSupported()) return false;
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
// 开始 Passkey 注册流程
export async function startPasskeyRegistration(options: any) {
if (!isWebAuthnSupported()) {
throw new Error('WebAuthn not supported');
}
// 确保创建 Discoverable Credential
const authenticatorSelection = {
...(options.publicKey.authenticatorSelection || {}),
residentKey: "required" as ResidentKeyRequirement,
requireResidentKey: true,
userVerification: options.publicKey.authenticatorSelection?.userVerification || "preferred" as UserVerificationRequirement
};
const credential = await navigator.credentials.create({
publicKey: {
...options.publicKey,
challenge: base64ToArrayBuffer(options.publicKey.challenge),
user: {
...options.publicKey.user,
id: base64ToArrayBuffer(options.publicKey.user.id)
},
authenticatorSelection
}
}) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to create credential');
}
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64(response.clientDataJSON),
attestationObject: arrayBufferToBase64(response.attestationObject)
},
type: credential.type
};
}
// 开始 Passkey 登录流程
export async function startPasskeyLogin(options: any) {
if (!isWebAuthnSupported()) {
throw new Error('WebAuthn not supported');
}
const credential = await navigator.credentials.get({
publicKey: {
...options.publicKey,
challenge: base64ToArrayBuffer(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64ToArrayBuffer(cred.id)
}))
}
}) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to get credential');
}
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64(response.clientDataJSON),
authenticatorData: arrayBufferToBase64(response.authenticatorData),
signature: arrayBufferToBase64(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64(response.userHandle) : null
},
type: credential.type
};
}
+48 -2
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
import BASE_URL from "@/app/config";
import { useLoginMutation } from "@/app/services/auth";
import { useLoginMutation, usePasskeyLoginStartMutation, usePasskeyLoginFinishMutation } from "@/app/services/auth";
import { useGetLoginConfigQuery, useGetSMTPStatusQuery } from "@/app/services/server";
import { useAppSelector } from "@/app/store";
import Divider from "@/components/Divider";
@@ -18,6 +18,8 @@ import SocialLoginButtons from "./SocialLoginButtons";
import { shallowEqual } from "react-redux";
import SelectLanguage from "../../components/Language";
import Downloads from "../../components/Downloads";
import { startPasskeyLogin, isWebAuthnSupported } from "@/passkey";
import ServerVersionChecker from "@/components/ServerVersionChecker";
const defaultInput = {
email: "",
@@ -32,6 +34,9 @@ export default function LoginPage() {
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
const [emailInputted, setEmailInputted] = useState(false);
const [input, setInput] = useState(defaultInput);
const [passkeyLoginStart] = usePasskeyLoginStartMutation();
const [passkeyLoginFinish] = usePasskeyLoginFinishMutation();
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
useEffect(() => {
const query = new URLSearchParams(location.search);
@@ -122,10 +127,40 @@ export default function LoginPage() {
const handleBack = () => {
setEmailInputted(false);
};
const handlePasskeyLogin = async () => {
if (!isWebAuthnSupported()) {
toast.error(t("login.passkey_error_not_supported"));
return;
}
setIsPasskeyLoading(true);
try {
const { challenge_id, options } = await passkeyLoginStart({}).unwrap();
const credential = await startPasskeyLogin(options);
await passkeyLoginFinish({ challenge_id, authentication: credential }).unwrap();
toast.success(ct("tip.login"));
} catch (error: any) {
if (error.name === 'NotAllowedError') {
toast.error(t("login.passkey_error_cancelled"));
} else if (error.status === 404) {
toast.error(t("login.passkey_error_no_passkey"));
} else {
toast.error(t("login.passkey_error_failed"));
}
console.error("Passkey login error:", error);
} finally {
setIsPasskeyLoading(false);
}
};
const { email, password } = input;
if (!loginConfigSuccess) return null;
const { magic_link, who_can_sign_up: whoCanSignUp } = loginConfig;
const { magic_link, who_can_sign_up: whoCanSignUp, passkey } = loginConfig;
const enableMagicLink = enableSMTP && magic_link;
const hideSocials = (enableMagicLink && emailInputted) || whoCanSignUp == "InvitationOnly";
@@ -198,6 +233,17 @@ export default function LoginPage() {
</form>
<Divider content="OR" />
<div className="socials flex flex-col gap-3">
{passkey && (
<ServerVersionChecker empty version="0.5.5">
<Button
onClick={handlePasskeyLogin}
disabled={isPasskeyLoading || isLoading}
className="w-full"
>
{isPasskeyLoading ? t("login.passkey_authenticating") : t("login.passkey")}
</Button>
</ServerVersionChecker>
)}
{emailInputted && <MagicLinkLogin email={input.email} />}
{!hideSocials && <SocialLoginButtons />}
</div>
+13
View File
@@ -3,13 +3,16 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useUpdateAvatarMutation } from "@/app/services/user";
import { useGetLoginConfigQuery } from "@/app/services/server";
import { useAppSelector } from "@/app/store";
import AvatarUploader from "@/components/AvatarUploader";
import Button from "@/components/styled/Button";
import ProfileBasicEditModal from "./ProfileBasicEditModal";
import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal";
import UpdatePasswordModal from "./UpdatePasswordModal";
import PasskeyManagement from "./PasskeyManagement";
import { shallowEqual } from "react-redux";
import ServerVersionChecker from "@/components/ServerVersionChecker";
type EditField = "name" | "email" | "";
export default function MyAccount() {
@@ -19,6 +22,7 @@ export default function MyAccount() {
const [editModal, setEditModal] = useState<EditField>("");
const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false);
const [uploadAvatar, { isSuccess: uploadSuccess }] = useUpdateAvatarMutation();
const { data: loginConfig } = useGetLoginConfigQuery();
const EditModalInfo = {
name: {
label: t("username"),
@@ -95,6 +99,15 @@ export default function MyAccount() {
<Button onClick={togglePasswordModal}>{ct("action.edit")}</Button>
</div>
</div>
{loginConfig?.passkey && (
<ServerVersionChecker empty version="0.5.5">
<div className="w-full md:w-[512px] md:p-6 md:bg-gray-100 md:dark:bg-gray-800 md:rounded-2xl">
<PasskeyManagement />
</div>
</ServerVersionChecker>
)}
{/* uid 1 是初始账户,不能删 */}
{uid != 1 && (
<Button className="danger" onClick={toggleRemoveAccountModalVisible}>
+166
View File
@@ -0,0 +1,166 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
useGetUserPasskeysQuery,
usePasskeyRegisterStartMutation,
usePasskeyRegisterFinishMutation,
useDeletePasskeyMutation
} from "@/app/services/auth";
import { startPasskeyRegistration, isWebAuthnSupported } from "@/passkey";
import Button from "@/components/styled/Button";
import Input from "@/components/styled/Input";
import Modal from "@/components/Modal";
import StyledModal from "@/components/styled/Modal";
import { useAppSelector } from "@/app/store";
export default function PasskeyManagement() {
const { t } = useTranslation("setting");
const { t: ct } = useTranslation();
const [isRegistering, setIsRegistering] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const [showModal, setShowModal] = useState(false);
const { data: passkeys, refetch } = useGetUserPasskeysQuery();
const [registerStart] = usePasskeyRegisterStartMutation();
const [registerFinish] = usePasskeyRegisterFinishMutation();
const [deletePasskey] = useDeletePasskeyMutation();
const user = useAppSelector((store) => store.authData.user);
const handleAddPasskey = async () => {
if (!isWebAuthnSupported()) {
toast.error(t("passkey.error_not_supported"));
return;
}
if (!user?.name) {
toast.error("User information not available");
return;
}
if (!passkeyName.trim()) {
toast.error(t("passkey.enter_name") || "Please enter a name for this passkey");
return;
}
setIsRegistering(true);
setShowModal(false);
try {
const { challenge_id, options } = await registerStart({ name: user.name }).unwrap();
const credential = await startPasskeyRegistration(options);
await registerFinish({
challenge_id,
attestation: credential,
name: passkeyName.trim()
}).unwrap();
toast.success(t("passkey.success_add"));
setPasskeyName("");
refetch();
} catch (error: any) {
if (error.name === 'NotAllowedError') {
toast.error(t("passkey.error_cancelled"));
} else if (error.name === 'InvalidStateError') {
toast.error(t("passkey.error_exists"));
} else {
toast.error(t("passkey.error_add"));
}
console.error("Passkey registration error:", error);
} finally {
setIsRegistering(false);
}
};
const handleDeletePasskey = async (credentialId: string) => {
if (!confirm(t("passkey.delete_confirm"))) {
return;
}
try {
await deletePasskey(credentialId).unwrap();
toast.success(t("passkey.success_delete"));
refetch();
} catch (error) {
toast.error(t("passkey.error_delete"));
console.error("Passkey deletion error:", error);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t("passkey.title")}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("passkey.desc")}
</p>
</div>
<Button onClick={() => setShowModal(true)} disabled={isRegistering}>
{isRegistering ? t("passkey.adding") : t("passkey.add")}
</Button>
</div>
<div className="space-y-2">
{passkeys && passkeys.length > 0 ? (
passkeys.map((passkey) => (
<div
key={passkey.credential_id}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-800 dark:text-white">
{passkey.name}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t("passkey.created")}: {new Date(passkey.created_at).toLocaleDateString()}
{passkey.last_used_at &&
`${t("passkey.last_used")}: ${new Date(passkey.last_used_at).toLocaleDateString()}`
}
</span>
</div>
<Button
className="danger"
onClick={() => handleDeletePasskey(passkey.credential_id)}
>
{t("passkey.delete")}
</Button>
</div>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("passkey.no_passkeys")}
</div>
)}
</div>
{showModal && (
<Modal id="modal-modal">
<StyledModal
title={t("passkey.add")}
description={t("passkey.enter_name") || "Enter a name for this passkey"}
buttons={
<>
<Button className="cancel" onClick={() => { setShowModal(false); setPasskeyName(""); }}>
{ct("action.cancel")}
</Button>
<Button onClick={handleAddPasskey} disabled={isRegistering}>
{isRegistering ? t("passkey.adding") : ct("action.done")}
</Button>
</>
}
>
<Input
autoFocus
placeholder={t("passkey.enter_name") || "Passkey name"}
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</StyledModal>
</Modal>
)}
</div>
);
}
+19 -2
View File
@@ -12,6 +12,7 @@ import useGithubAuthConfig from "@/hooks/useGithubAuthConfig";
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
import IssuerList from "./IssuerList";
import Tooltip from "./Tooltip";
import ServerVersionChecker from "@/components/ServerVersionChecker";
export default function Logins() {
const { t } = useTranslation("setting", { keyPrefix: "login" });
@@ -59,7 +60,7 @@ export default function Logins() {
}
};
const handleToggle = (
val: Partial<Pick<LoginConfig, "github" | "google" | "password" | "magic_link" | "metamask">>
val: Partial<Pick<LoginConfig, "github" | "google" | "password" | "magic_link" | "metamask" | "passkey">>
) => {
setValues((prev) => {
if (!prev) return prev;
@@ -67,7 +68,7 @@ export default function Logins() {
});
};
if (!values) return null;
const { google, magic_link, github, metamask, password, oidc = [] } = values as LoginConfig;
const { google, magic_link, github, metamask, password, passkey, oidc = [] } = values as LoginConfig;
const valuesChanged = clientIdChanged || changed || githubChanged;
return (
@@ -170,6 +171,22 @@ export default function Logins() {
></Toggle>
</div>
</div>
<ServerVersionChecker empty version="0.5.5">
<div className="input">
<div className="row">
<div className="title">
<div className="txt">
<Label>{t("passkey")}</Label>
</div>
<span className="desc dark:!text-gray-400">{t("passkey_desc")}</span>
</div>
<Toggle
onClick={handleToggle.bind(null, { passkey: !passkey })}
checked={passkey}
></Toggle>
</div>
</div>
</ServerVersionChecker>
<div className="input">
<div className="row">
<div className="title">
+61 -1
View File
@@ -53,6 +53,11 @@ export type ThirdPartyCredential = {
key: string;
type: "thirdparty";
};
export type PasskeyCredential = {
challenge_id: string;
credential: PublicKeyCredentialWithResponse;
type: "passkey";
};
export type LoginCredential =
| PasswordCredential
| OIDCCredential
@@ -60,7 +65,8 @@ export type LoginCredential =
| GithubCredential
| GoogleCredential
| ThirdPartyCredential
| MagicLinkCredential;
| MagicLinkCredential
| PasskeyCredential;
export type CredentialResponse = {
password: boolean;
@@ -73,3 +79,57 @@ export interface OIDCConfig {
favicon: string;
domain: string;
}
// Passkey types
export interface PublicKeyCredentialWithResponse {
id: string;
rawId: string;
response: {
clientDataJSON: string;
attestationObject?: string;
authenticatorData?: string;
signature?: string;
userHandle?: string | null;
};
type: string;
}
export interface PasskeyRegisterStartRequest {
name: string;
}
export interface PasskeyRegisterStartResponse {
challenge_id: string;
options: {
publicKey: PublicKeyCredentialCreationOptions;
};
}
export interface PasskeyRegisterFinishRequest {
challenge_id: string;
credential: PublicKeyCredentialWithResponse;
name: string;
}
export interface PasskeyLoginStartRequest {
}
export interface PasskeyLoginStartResponse {
challenge_id: string;
options: {
publicKey: PublicKeyCredentialRequestOptions;
};
}
export interface PasskeyLoginFinishRequest {
challenge_id: string;
authentication: PublicKeyCredentialWithResponse;
}
export interface UserPasskey {
id: number;
credential_id: string;
name: string;
created_at: string;
last_used_at?: string;
}
+1
View File
@@ -97,6 +97,7 @@ export interface LoginConfig {
oidc: OIDCSetting[];
metamask: boolean;
third_party: boolean;
passkey?: boolean;
}
export interface LicenseResponse {
domains: string[];