From a7583c439dc67fe3f4a03d8a7e2ec0c960be0aeb Mon Sep 17 00:00:00 2001 From: Blayne Bayer Date: Fri, 17 May 2024 10:39:01 -0500 Subject: [PATCH 1/2] Make Post register prompt, auto login with passkey, and show a login with passkey button configurable through environment variables --- .env.example | 6 +- packages/frontend/src/views/Login/Login.tsx | 422 +++++++++++------- .../frontend/src/views/Register/Register.tsx | 145 +++--- 3 files changed, 355 insertions(+), 218 deletions(-) diff --git a/.env.example b/.env.example index 43ec470..dcca18e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ PASSAGE_SERVER_PORT=3001 PASSAGE_APP_ID= -PASSAGE_API_KEY= \ No newline at end of file +PASSAGE_API_KEY= +PASSAGE_DISABLE_PASSKEY_PROMPT_AFTER_REGISTER=false +PASSAGE_DISABLE_AUTO_LOGIN=false +PASSAGE_LOGIN_WITH_PASSKEY_BUTTON=false + diff --git a/packages/frontend/src/views/Login/Login.tsx b/packages/frontend/src/views/Login/Login.tsx index 717fe00..e557cd2 100644 --- a/packages/frontend/src/views/Login/Login.tsx +++ b/packages/frontend/src/views/Login/Login.tsx @@ -2,190 +2,284 @@ import { FormEvent, ReactElement, useEffect, useRef, useState } from "react"; import { serverURL } from "../../utils/serverURL"; import { PassageFlex } from "@passageidentity/passage-flex-js"; import { Link, useNavigate } from "react-router-dom"; -import { Input, Button, Card, CardHeader, CardBody, CardFooter } from "@nextui-org/react"; +import { + Input, + Button, + Card, + CardHeader, + CardBody, + CardFooter, +} from "@nextui-org/react"; import { AddPasskey } from "../../components/AddPasskey/AddPasskey"; const passage = new PassageFlex(import.meta.env.PASSAGE_APP_ID); enum LoginState { - Initial, - Password, - Passkey, - AddPasskey, + Initial, + Password, + Passkey, + AddPasskey, } interface ILoginProps { - onLogin: () => Promise; + onLogin: () => Promise; } export function Login(props: ILoginProps): ReactElement { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const navigate = useNavigate(); - const transactionID = useRef(''); - const skippedPasskey = useRef(false); - const [error, setError] = useState(''); - - const [loginState, setLoginState] = useState(LoginState.Initial); - - const enterPassword = (password: string) =>{ - setError(''); - setPassword(password); - } + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const navigate = useNavigate(); + const transactionID = useRef(""); + const skippedPasskey = useRef(false); + const [error, setError] = useState(""); - const enterUsername = (username: string) =>{ - setError(''); - setUsername(username); - } + const [loginState, setLoginState] = useState(LoginState.Initial); - const getTransaction = async () => { - const res = await fetch(`${serverURL}/auth/passkey/login`, { - method: 'POST', - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({username: username}), - }); - if(res.ok){ - transactionID.current = (await res.json()).transactionId; - } else { - if(res.status === 404){ - throw new Error('User does not exist'); - } - transactionID.current = ''; - } + const showLoginWithPassageButton = + import.meta.env.PASSAGE_LOGIN_WITH_PASSKEY_BUTTON === "true"; + + const isDisableAutoLogin = + import.meta.env.PASSAGE_DISABLE_AUTO_LOGIN === "true"; + + const enterPassword = (password: string) => { + setError(""); + setPassword(password); + }; + + const enterUsername = (username: string) => { + setError(""); + setUsername(username); + }; + + const getTransaction = async () => { + const res = await fetch(`${serverURL}/auth/passkey/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username: username }), + }); + if (res.ok) { + transactionID.current = (await res.json()).transactionId; + } else { + if (res.status === 404) { + throw new Error("User does not exist"); + } + transactionID.current = ""; } + }; - const verifyNonce = async (nonce: string) => { - const res = await fetch(`${serverURL}/auth/passkey/verify`, { - method: 'POST', - headers: { - "Content-Type": "application/json", - }, - credentials: 'include', - body: JSON.stringify({nonce: nonce}), - }); - if(res.ok){ - await props.onLogin(); - navigate('/profile') - } + const verifyNonce = async (nonce: string) => { + const res = await fetch(`${serverURL}/auth/passkey/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ nonce: nonce }), + }); + if (res.ok) { + await props.onLogin(); + navigate("/profile"); } - const loginWithPassword = async (event: FormEvent) => { - event.preventDefault(); - const res = await fetch(`${serverURL}/auth/password/login`, { - method: 'POST', - headers: { - "Content-Type": "application/json", - }, - credentials: 'include', - body: JSON.stringify({username: username, password: password}), - }); - if(res.ok){ - if(!skippedPasskey.current && await passage.passkey.canAuthenticateWithPasskey()){ - setLoginState(LoginState.AddPasskey); - return; - } - await props.onLogin(); - navigate('/profile') - } else { - setError('Invalid password'); - } + }; + const loginWithPassword = async (event: FormEvent) => { + event.preventDefault(); + const res = await fetch(`${serverURL}/auth/password/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ username: username, password: password }), + }); + if (res.ok) { + if ( + !skippedPasskey.current && + (await passage.passkey.canAuthenticateWithPasskey()) + ) { + setLoginState(LoginState.AddPasskey); + return; + } + await props.onLogin(); + navigate("/profile"); + } else { + setError("Invalid password"); } - const loginWithPasskey = async () => { - setError(''); - try { - if(transactionID.current === ''){ - await getTransaction(); - } - //@ts-ignore - const nonce = await passage.passkey.authenticate({transactionId: transactionID.current}); - await verifyNonce(nonce); - } catch { - setError('Failed to login with passkey'); - transactionID.current = ''; - } + }; + const loginWithPasskey = async () => { + setError(""); + try { + if (transactionID.current === "") { + await getTransaction(); + } + //@ts-ignore + const nonce = await passage.passkey.authenticate({ + transactionId: transactionID.current, + }); + await verifyNonce(nonce); + } catch { + setError("Failed to login with passkey"); + transactionID.current = ""; } + }; - const checkPasskey = async () =>{ - if(!await passage.passkey.canAuthenticateWithPasskey()){ - setLoginState(LoginState.Password); - return; - } - try { - await getTransaction(); - } catch { - setError('User does not exist'); - return; - } - if(transactionID.current !== ''){ - setLoginState(LoginState.Passkey); - } else { - setLoginState(LoginState.Password); - } + const checkPasskey = async () => { + if (!(await passage.passkey.canAuthenticateWithPasskey())) { + setLoginState(LoginState.Password); + return; } + try { + await getTransaction(); + } catch { + setError("User does not exist"); + return; + } + if (transactionID.current !== "") { + setLoginState(LoginState.Passkey); + } else { + setLoginState(LoginState.Password); + } + }; - const usePassword = () =>{ - skippedPasskey.current = true; - setLoginState(LoginState.Password); + const usePassword = () => { + skippedPasskey.current = true; + setLoginState(LoginState.Password); + }; + + useEffect(() => { + if (!isDisableAutoLogin) { + passage.passkey + .authenticate({ isConditionalMediation: true }) + .then((nonce) => { + verifyNonce(nonce); + }); } + }, []); - useEffect(()=>{ - passage.passkey.authenticate({isConditionalMediation: true}).then((nonce)=>{ - verifyNonce(nonce); - }) - }, []) - - const initialState = ( - <> -

Login

- - - - - -
Don't have an account? Register here.
-
- - ); - - const passkeyState = ( - <> -

Login with Passkey

- -

- Passkeys are a simple and more secure alternative to - passwords. -
-
- Log in with the method you already use to unlock your device. Learn more → -

- {!!error &&

{error}

} -
- - - - - - ); - - const passwordState = ( - <> -

Login with Password

- - - - - - - - ); - - return ( - - {loginState === LoginState.Initial && initialState} - {loginState === LoginState.Passkey && passkeyState} - {loginState === LoginState.Password && passwordState} - {loginState === LoginState.AddPasskey && } - - ) -} \ No newline at end of file + const initialState = ( + <> + +

Login

+
+ + + + +
+ + {showLoginWithPassageButton && ( + + )} +
+
+ Don't have an account?{" "} + + Register here. + +
+
+ + ); + + const passkeyState = ( + <> + +

Login with Passkey

+
+ +

+ Passkeys are a simple and more secure alternative to passwords. +
+
+ Log in with the method you already use to unlock your device.{" "} + + Learn more → + +

+ {!!error &&

{error}

} +
+ + + + + + ); + + const passwordState = ( + <> + +

Login with Password

+
+ + + + + + + + ); + + return ( + + {loginState === LoginState.Initial && initialState} + {loginState === LoginState.Passkey && passkeyState} + {loginState === LoginState.Password && passwordState} + {loginState === LoginState.AddPasskey && } + + ); +} diff --git a/packages/frontend/src/views/Register/Register.tsx b/packages/frontend/src/views/Register/Register.tsx index 463c5cf..10bc2f9 100644 --- a/packages/frontend/src/views/Register/Register.tsx +++ b/packages/frontend/src/views/Register/Register.tsx @@ -1,73 +1,112 @@ import { ReactElement, useState } from "react"; import { serverURL } from "../../utils/serverURL"; import { Link, useNavigate } from "react-router-dom"; -import { Input, Button, Card, CardHeader, CardBody, CardFooter } from "@nextui-org/react"; +import { + Input, + Button, + Card, + CardHeader, + CardBody, + CardFooter, +} from "@nextui-org/react"; import { PassageFlex } from "@passageidentity/passage-flex-js"; import { AddPasskey } from "../../components/AddPasskey/AddPasskey"; const passage = new PassageFlex(import.meta.env.PASSAGE_APP_ID); enum RegisterState { - Initial, - AddPasskey + Initial, + AddPasskey, } interface IRegisterProps { - onRegister: () => Promise; + onRegister: () => Promise; } - export function Register(props: IRegisterProps): ReactElement { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const navigate = useNavigate(); - const [error, setError] = useState(''); + const isDisablePasskeyPrompt = + import.meta.env.PASSAGE_DISABLE_PASSKEY_PROMPT_AFTER_REGISTER === "true"; + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const navigate = useNavigate(); + const [error, setError] = useState(""); - const enterUsername = (username: string) =>{ - setError(''); - setUsername(username); - } + const enterUsername = (username: string) => { + setError(""); + setUsername(username); + }; - const [registerState, setRegisterState] = useState(RegisterState.Initial); + const [registerState, setRegisterState] = useState( + RegisterState.Initial + ); - const register = async () => { - const res = await fetch(`${serverURL}/auth/password/register`, { - method: 'POST', - headers: { - "Content-Type": "application/json", - }, - credentials: 'include', - body: JSON.stringify({username: username, password: password}) - }); - if(res.ok){ - await props.onRegister(); - if(await passage.passkey.canRegisterPasskey()){ - setRegisterState(RegisterState.AddPasskey); - return; - } - navigate('/profile'); - } else { - setError('User already exists'); - } + const register = async () => { + const res = await fetch(`${serverURL}/auth/password/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ username: username, password: password }), + }); + if (res.ok) { + await props.onRegister(); + if ( + !isDisablePasskeyPrompt && + (await passage.passkey.canRegisterPasskey()) + ) { + setRegisterState(RegisterState.AddPasskey); + return; + } + navigate("/profile"); + } else { + setError("User already exists"); } + }; - const initialState = ( - <> -

Register

- - - - - - -
Already have an account? Login here.
-
- - ); - return ( - - {registerState === RegisterState.Initial && initialState} - {registerState === RegisterState.AddPasskey && } - - ) -} \ No newline at end of file + const initialState = ( + <> + +

Register

+
+ + + + + + +
+ Already have an account?{" "} + + Login here. + +
+
+ + ); + return ( + + {registerState === RegisterState.Initial && initialState} + {registerState === RegisterState.AddPasskey && } + + ); +} From a0d17740143c9c08d54be8e3b0f3b48c6e9a1acf Mon Sep 17 00:00:00 2001 From: Blayne Bayer Date: Mon, 20 May 2024 13:26:11 -0500 Subject: [PATCH 2/2] address pr comments --- packages/frontend/src/views/Login/Login.tsx | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/views/Login/Login.tsx b/packages/frontend/src/views/Login/Login.tsx index e557cd2..93d2a0a 100644 --- a/packages/frontend/src/views/Login/Login.tsx +++ b/packages/frontend/src/views/Login/Login.tsx @@ -28,6 +28,8 @@ interface ILoginProps { export function Login(props: ILoginProps): ReactElement { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [canAuthenticateWithPasskey, setCanAuthenticateWithPasskey] = + useState(false); const navigate = useNavigate(); const transactionID = useRef(""); const skippedPasskey = useRef(false); @@ -35,6 +37,9 @@ export function Login(props: ILoginProps): ReactElement { const [loginState, setLoginState] = useState(LoginState.Initial); + const isDisablePasskeyPrompt = + import.meta.env.PASSAGE_DISABLE_PASSKEY_PROMPT_AFTER_REGISTER === "true"; + const showLoginWithPassageButton = import.meta.env.PASSAGE_LOGIN_WITH_PASSKEY_BUTTON === "true"; @@ -96,6 +101,7 @@ export function Login(props: ILoginProps): ReactElement { if (res.ok) { if ( !skippedPasskey.current && + !isDisablePasskeyPrompt && (await passage.passkey.canAuthenticateWithPasskey()) ) { setLoginState(LoginState.AddPasskey); @@ -124,6 +130,12 @@ export function Login(props: ILoginProps): ReactElement { } }; + const loginWithDiscoverable = async () => { + passage.passkey.authenticate().then((nonce) => { + verifyNonce(nonce); + }); + }; + const checkPasskey = async () => { if (!(await passage.passkey.canAuthenticateWithPasskey())) { setLoginState(LoginState.Password); @@ -157,6 +169,15 @@ export function Login(props: ILoginProps): ReactElement { } }, []); + useEffect(() => { + const updateCanAuthenticateWithPasskey = async () => { + const canAuthenticate = + await passage.passkey.canAuthenticateWithPasskey(); + setCanAuthenticateWithPasskey(canAuthenticate); + }; + updateCanAuthenticateWithPasskey(); + }, []); + const initialState = ( <> @@ -185,13 +206,13 @@ export function Login(props: ILoginProps): ReactElement { > Continue - {showLoginWithPassageButton && ( + {showLoginWithPassageButton && canAuthenticateWithPasskey && (