[web] Auth cleanup - Part 1/x (#1820)

In preparation for adding steam support
This commit is contained in:
Manav Rathi 2024-05-23 13:42:34 +05:30 committed by GitHub
commit a7e96d055c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 621 additions and 664 deletions

View file

@ -1,23 +0,0 @@
import { Button } from "@mui/material";
import { t } from "i18next";
export const AuthFooter = () => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
<a
href="https://github.com/ente-io/ente/tree/main/auth#-download"
download
>
<Button color="accent">{t("DOWNLOAD")}</Button>
</a>
</div>
);
};

View file

@ -1,35 +0,0 @@
import { HorizontalFlex } from "@ente/shared/components/Container";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import NavbarBase from "@ente/shared/components/Navbar/base";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
import MoreHoriz from "@mui/icons-material/MoreHoriz";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import React from "react";
export default function AuthNavbar() {
const { isMobile, logout } = React.useContext(AppContext);
return (
<NavbarBase isMobile={isMobile}>
<HorizontalFlex flex={1} justifyContent={"center"}>
<EnteLogo />
</HorizontalFlex>
<HorizontalFlex position={"absolute"} right="24px">
<OverflowMenu
ariaControls={"auth-options"}
triggerButtonIcon={<MoreHoriz />}
>
<OverflowMenuOption
color="critical"
startIcon={<LogoutOutlined />}
onClick={logout}
>
{t("LOGOUT")}
</OverflowMenuOption>
</OverflowMenu>
</HorizontalFlex>
</NavbarBase>
);
}

View file

@ -1,237 +0,0 @@
import { ButtonBase, Snackbar } from "@mui/material";
import { t } from "i18next";
import { HOTP, TOTP } from "otpauth";
import { useEffect, useState } from "react";
import { Code } from "types/code";
import TimerProgress from "./TimerProgress";
const TOTPDisplay = ({ issuer, account, code, nextCode, period }) => {
return (
<div
style={{
backgroundColor: "rgba(40, 40, 40, 0.6)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<TimerProgress period={period ?? Code.defaultPeriod} />
<div
style={{
padding: "12px 20px 0px 20px",
display: "flex",
alignItems: "flex-start",
minWidth: "320px",
minHeight: "120px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
minWidth: "200px",
}}
>
<p
style={{
fontWeight: "bold",
margin: "0px",
fontSize: "14px",
textAlign: "left",
}}
>
{issuer}
</p>
<p
style={{
marginTop: "0px",
marginBottom: "8px",
textAlign: "left",
fontSize: "12px",
maxWidth: "200px",
minHeight: "16px",
color: "grey",
}}
>
{account}
</p>
<p
style={{
margin: "0px",
marginBottom: "1rem",
fontSize: "24px",
fontWeight: "bold",
textAlign: "left",
}}
>
{code}
</p>
</div>
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
minWidth: "120px",
textAlign: "right",
marginTop: "auto",
marginBottom: "1rem",
}}
>
<p
style={{
fontWeight: "bold",
marginBottom: "0px",
fontSize: "10px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{t("AUTH_NEXT")}
</p>
<p
style={{
fontSize: "14px",
fontWeight: "bold",
marginBottom: "0px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{nextCode}
</p>
</div>
</div>
</div>
);
};
function BadCodeInfo({ codeInfo, codeErr }) {
const [showRawData, setShowRawData] = useState(false);
return (
<div className="code-info">
<div>{codeInfo.title}</div>
<div>{codeErr}</div>
<div>
{showRawData ? (
<div onClick={() => setShowRawData(false)}>
{codeInfo.rawData ?? "no raw data"}
</div>
) : (
<div onClick={() => setShowRawData(true)}>Show rawData</div>
)}
</div>
</div>
);
}
interface OTPDisplayProps {
codeInfo: Code;
}
const OTPDisplay = (props: OTPDisplayProps) => {
const { codeInfo } = props;
const [code, setCode] = useState("");
const [nextCode, setNextCode] = useState("");
const [codeErr, setCodeErr] = useState("");
const [hasCopied, setHasCopied] = useState(false);
const generateCodes = () => {
try {
const currentTime = new Date().getTime();
if (codeInfo.type.toLowerCase() === "totp") {
const totp = new TOTP({
secret: codeInfo.secret,
algorithm: codeInfo.algorithm ?? Code.defaultAlgo,
period: codeInfo.period ?? Code.defaultPeriod,
digits: codeInfo.digits ?? Code.defaultDigits,
});
setCode(totp.generate());
setNextCode(
totp.generate({
timestamp: currentTime + codeInfo.period * 1000,
}),
);
} else if (codeInfo.type.toLowerCase() === "hotp") {
const hotp = new HOTP({
secret: codeInfo.secret,
counter: 0,
algorithm: codeInfo.algorithm,
});
setCode(hotp.generate());
setNextCode(hotp.generate({ counter: 1 }));
}
} catch (err) {
setCodeErr(err.message);
}
};
const copyCode = () => {
navigator.clipboard.writeText(code);
setHasCopied(true);
setTimeout(() => {
setHasCopied(false);
}, 2000);
};
useEffect(() => {
// this is to set the initial code and nextCode on component mount
generateCodes();
const codeType = codeInfo.type;
const codePeriodInMs = codeInfo.period * 1000;
const timeToNextCode =
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
const intervalId = null;
// wait until we are at the start of the next code period,
// and then start the interval loop
setTimeout(() => {
// we need to call generateCodes() once before the interval loop
// to set the initial code and nextCode
generateCodes();
codeType.toLowerCase() === "totp" ||
codeType.toLowerCase() === "hotp"
? setInterval(() => {
generateCodes();
}, codePeriodInMs)
: null;
}, timeToNextCode);
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [codeInfo]);
return (
<div style={{ padding: "8px" }}>
{codeErr === "" ? (
<ButtonBase
component="div"
onClick={() => {
copyCode();
}}
>
<TOTPDisplay
period={codeInfo.period}
issuer={codeInfo.issuer}
account={codeInfo.account}
code={code}
nextCode={nextCode}
/>
<Snackbar
open={hasCopied}
message="Code copied to clipboard"
/>
</ButtonBase>
) : (
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
)}
</div>
);
};
export default OTPDisplay;

View file

@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
const TimerProgress = ({ period }) => {
const [progress, setProgress] = useState(0);
const [ticker, setTicker] = useState(null);
const microSecondsInPeriod = period * 1000000;
const startTicker = () => {
const ticker = setInterval(() => {
updateTimeRemaining();
}, 10);
setTicker(ticker);
};
const updateTimeRemaining = () => {
const timeRemaining =
microSecondsInPeriod -
((new Date().getTime() * 1000) % microSecondsInPeriod);
setProgress(timeRemaining / microSecondsInPeriod);
};
useEffect(() => {
startTicker();
return () => clearInterval(ticker);
}, []);
const color = progress > 0.4 ? "green" : "orange";
return (
<div
style={{
borderTopLeftRadius: "3px",
width: `${progress * 100}%`,
height: "3px",
backgroundColor: color,
}}
/>
);
};
export default TimerProgress;

View file

@ -0,0 +1,449 @@
import {
HorizontalFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import NavbarBase from "@ente/shared/components/Navbar/base";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
import { CustomError } from "@ente/shared/error";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import LogoutOutlined from "@mui/icons-material/LogoutOutlined";
import MoreHoriz from "@mui/icons-material/MoreHoriz";
import { Button, ButtonBase, Snackbar, TextField } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import { HOTP, TOTP } from "otpauth";
import { AppContext } from "pages/_app";
import React, { useContext, useEffect, useState } from "react";
import { Code } from "services/code";
import { getAuthCodes } from "services/remote";
const AuthenticatorCodesPage = () => {
const appContext = useContext(AppContext);
const router = useRouter();
const [codes, setCodes] = useState([]);
const [hasFetched, setHasFetched] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const fetchCodes = async () => {
try {
const res = await getAuthCodes();
setCodes(res);
} catch (err) {
if (err.message === CustomError.KEY_MISSING) {
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH);
router.push(PAGES.ROOT);
} else {
// do not log errors
}
}
setHasFetched(true);
};
fetchCodes();
appContext.showNavBar(false);
}, []);
const filteredCodes = codes.filter(
(secret) =>
(secret.issuer ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
(secret.account ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
if (!hasFetched) {
return (
<>
<VerticallyCentered>
<EnteSpinner></EnteSpinner>
</VerticallyCentered>
</>
);
}
return (
<>
<AuthNavbar />
<div
style={{
maxWidth: "800px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
}}
>
<div style={{ marginBottom: "1rem" }} />
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
<></>
) : (
<TextField
id="search"
name="search"
label={t("SEARCH")}
onChange={(e) => setSearchTerm(e.target.value)}
variant="filled"
style={{ width: "350px" }}
value={searchTerm}
autoFocus
/>
)}
<div style={{ marginBottom: "1rem" }} />
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{filteredCodes.length === 0 ? (
<div
style={{
alignItems: "center",
display: "flex",
textAlign: "center",
marginTop: "32px",
}}
>
{searchTerm.length !== 0 ? (
<p>{t("NO_RESULTS")}</p>
) : (
<div />
)}
</div>
) : (
filteredCodes.map((code) => (
<CodeDisplay codeInfo={code} key={code.id} />
))
)}
</div>
<div style={{ marginBottom: "2rem" }} />
<AuthFooter />
<div style={{ marginBottom: "4rem" }} />
</div>
</>
);
};
export default AuthenticatorCodesPage;
const AuthNavbar: React.FC = () => {
const { isMobile, logout } = useContext(AppContext);
return (
<NavbarBase isMobile={isMobile}>
<HorizontalFlex flex={1} justifyContent={"center"}>
<EnteLogo />
</HorizontalFlex>
<HorizontalFlex position={"absolute"} right="24px">
<OverflowMenu
ariaControls={"auth-options"}
triggerButtonIcon={<MoreHoriz />}
>
<OverflowMenuOption
color="critical"
startIcon={<LogoutOutlined />}
onClick={logout}
>
{t("LOGOUT")}
</OverflowMenuOption>
</OverflowMenu>
</HorizontalFlex>
</NavbarBase>
);
};
interface CodeDisplay {
codeInfo: Code;
}
const CodeDisplay: React.FC<CodeDisplay> = ({ codeInfo }) => {
const [otp, setOTP] = useState("");
const [nextOTP, setNextOTP] = useState("");
const [codeErr, setCodeErr] = useState("");
const [hasCopied, setHasCopied] = useState(false);
const generateCodes = () => {
try {
const currentTime = new Date().getTime();
if (codeInfo.type === "totp") {
const totp = new TOTP({
secret: codeInfo.secret,
algorithm: codeInfo.algorithm,
period: codeInfo.period,
digits: codeInfo.digits,
});
setOTP(totp.generate());
setNextOTP(
totp.generate({
timestamp: currentTime + codeInfo.period * 1000,
}),
);
} else if (codeInfo.type === "hotp") {
const hotp = new HOTP({
secret: codeInfo.secret,
counter: 0,
algorithm: codeInfo.algorithm,
});
setOTP(hotp.generate());
setNextOTP(hotp.generate({ counter: 1 }));
}
} catch (err) {
setCodeErr(err.message);
}
};
const copyCode = () => {
navigator.clipboard.writeText(otp);
setHasCopied(true);
setTimeout(() => {
setHasCopied(false);
}, 2000);
};
useEffect(() => {
// this is to set the initial code and nextCode on component mount
generateCodes();
const codeType = codeInfo.type;
const codePeriodInMs = codeInfo.period * 1000;
const timeToNextCode =
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
const intervalId = null;
// wait until we are at the start of the next code period,
// and then start the interval loop
setTimeout(() => {
// we need to call generateCodes() once before the interval loop
// to set the initial code and nextCode
generateCodes();
codeType.toLowerCase() === "totp" ||
codeType.toLowerCase() === "hotp"
? setInterval(() => {
generateCodes();
}, codePeriodInMs)
: null;
}, timeToNextCode);
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [codeInfo]);
return (
<div style={{ padding: "8px" }}>
{codeErr === "" ? (
<ButtonBase
component="div"
onClick={() => {
copyCode();
}}
>
<OTPDisplay code={codeInfo} otp={otp} nextOTP={nextOTP} />
<Snackbar
open={hasCopied}
message="Code copied to clipboard"
/>
</ButtonBase>
) : (
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
)}
</div>
);
};
interface OTPDisplayProps {
code: Code;
otp: string;
nextOTP: string;
}
const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
return (
<div
style={{
backgroundColor: "rgba(40, 40, 40, 0.6)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<TimerProgress period={code.period} />
<div
style={{
padding: "12px 20px 0px 20px",
display: "flex",
alignItems: "flex-start",
minWidth: "320px",
minHeight: "120px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
minWidth: "200px",
}}
>
<p
style={{
fontWeight: "bold",
margin: "0px",
fontSize: "14px",
textAlign: "left",
}}
>
{code.issuer}
</p>
<p
style={{
marginTop: "0px",
marginBottom: "8px",
textAlign: "left",
fontSize: "12px",
maxWidth: "200px",
minHeight: "16px",
color: "grey",
}}
>
{code.account}
</p>
<p
style={{
margin: "0px",
marginBottom: "1rem",
fontSize: "24px",
fontWeight: "bold",
textAlign: "left",
}}
>
{otp}
</p>
</div>
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
minWidth: "120px",
textAlign: "right",
marginTop: "auto",
marginBottom: "1rem",
}}
>
<p
style={{
fontWeight: "bold",
marginBottom: "0px",
fontSize: "10px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{t("AUTH_NEXT")}
</p>
<p
style={{
fontSize: "14px",
fontWeight: "bold",
marginBottom: "0px",
marginTop: "auto",
textAlign: "right",
color: "grey",
}}
>
{nextOTP}
</p>
</div>
</div>
</div>
);
};
interface TimerProgressProps {
period: number;
}
const TimerProgress: React.FC<TimerProgressProps> = ({ period }) => {
const [progress, setProgress] = useState(0);
const microSecondsInPeriod = period * 1000000;
useEffect(() => {
const updateTimeRemaining = () => {
const timeRemaining =
microSecondsInPeriod -
((new Date().getTime() * 1000) % microSecondsInPeriod);
setProgress(timeRemaining / microSecondsInPeriod);
};
const ticker = setInterval(() => {
updateTimeRemaining();
}, 10);
return () => clearInterval(ticker);
}, []);
const color = progress > 0.4 ? "green" : "orange";
return (
<div
style={{
borderTopLeftRadius: "3px",
width: `${progress * 100}%`,
height: "3px",
backgroundColor: color,
}}
/>
);
};
function BadCodeInfo({ codeInfo, codeErr }) {
const [showRawData, setShowRawData] = useState(false);
return (
<div className="code-info">
<div>{codeInfo.title}</div>
<div>{codeErr}</div>
<div>
{showRawData ? (
<div onClick={() => setShowRawData(false)}>
{codeInfo.uriString ?? "(no raw data)"}
</div>
) : (
<div onClick={() => setShowRawData(true)}>Show rawData</div>
)}
</div>
</div>
);
}
const AuthFooter: React.FC = () => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
<a
href="https://github.com/ente-io/ente/tree/main/auth#-download"
download
>
<Button color="accent">{t("DOWNLOAD")}</Button>
</a>
</div>
);
};

View file

@ -1,129 +0,0 @@
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
import { CustomError } from "@ente/shared/error";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import { TextField } from "@mui/material";
import { AuthFooter } from "components/AuthFooter";
import AuthNavbar from "components/Navbar";
import OTPDisplay from "components/OTPDisplay";
import { t } from "i18next";
import { useRouter } from "next/router";
import { AppContext } from "pages/_app";
import { useContext, useEffect, useState } from "react";
import { getAuthCodes } from "services";
const AuthenticatorCodesPage = () => {
const appContext = useContext(AppContext);
const router = useRouter();
const [codes, setCodes] = useState([]);
const [hasFetched, setHasFetched] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const fetchCodes = async () => {
try {
const res = await getAuthCodes();
setCodes(res);
} catch (err) {
if (err.message === CustomError.KEY_MISSING) {
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH);
router.push(PAGES.ROOT);
} else {
// do not log errors
}
}
setHasFetched(true);
};
fetchCodes();
appContext.showNavBar(false);
}, []);
const filteredCodes = codes.filter(
(secret) =>
(secret.issuer ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
(secret.account ?? "")
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
if (!hasFetched) {
return (
<>
<VerticallyCentered>
<EnteSpinner></EnteSpinner>
</VerticallyCentered>
</>
);
}
return (
<>
<AuthNavbar />
<div
style={{
maxWidth: "800px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
}}
>
<div style={{ marginBottom: "1rem" }} />
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
<></>
) : (
<TextField
id="search"
name="search"
label={t("SEARCH")}
onChange={(e) => setSearchTerm(e.target.value)}
variant="filled"
style={{ width: "350px" }}
value={searchTerm}
autoFocus
/>
)}
<div style={{ marginBottom: "1rem" }} />
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{filteredCodes.length === 0 ? (
<div
style={{
alignItems: "center",
display: "flex",
textAlign: "center",
marginTop: "32px",
}}
>
{searchTerm.length !== 0 ? (
<p>{t("NO_RESULTS")}</p>
) : (
<div />
)}
</div>
) : (
filteredCodes.map((code) => (
<OTPDisplay codeInfo={code} key={code.id} />
))
)}
</div>
<div style={{ marginBottom: "2rem" }} />
<AuthFooter />
<div style={{ marginBottom: "4rem" }} />
</div>
</>
);
};
export default AuthenticatorCodesPage;

View file

@ -1,7 +1,7 @@
import TwoFactorVerifyPage from "@ente/accounts/pages/two-factor/verify";
import { APPS } from "@ente/shared/apps/constants";
import { useContext } from "react";
import { AppContext } from "../../_app";
import { AppContext } from "../_app";
export default function TwoFactorVerify() {
const appContext = useContext(AppContext);

View file

@ -0,0 +1,154 @@
import { URI } from "vscode-uri";
/**
* A parsed representation of an xOTP code URI.
*
* This is all the data we need to drive a OTP generator.
*/
export interface Code {
/** The uniquue id for the corresponding auth entity. */
id?: String;
/** The type of the code. */
type: "totp" | "hotp";
/** The user's account or email for which this code is used. */
account: string;
/** The name of the entity that issued this code. */
issuer: string;
/** Number of digits in the code. */
digits: number;
/**
* The time period (in seconds) for which a single OTP generated from this
* code remains valid.
*/
period: number;
/** The secret that is used to drive the OTP generator. */
secret: string;
/** The (hashing) algorithim used by the OTP generator. */
algorithm: "sha1" | "sha256" | "sha512";
/** The original string from which this code was generated. */
uriString?: string;
}
/**
* Convert a OTP code URI into its parse representation, a {@link Code}.
*
* @param id A unique ID of this code within the auth app.
*
* @param uriString A string specifying how to generate a TOTP/HOTP/Steam OTP
* code. These strings are of the form:
*
* - (TOTP)
* otpauth://totp/account:user@example.org?algorithm=SHA1&digits=6&issuer=issuer&period=30&secret=ALPHANUM
*/
export const codeFromURIString = (id: string, uriString: string): Code => {
let santizedRawData = uriString
.replace(/\+/g, "%2B")
.replace(/:/g, "%3A")
.replaceAll("\r", "");
if (santizedRawData.startsWith('"')) {
santizedRawData = santizedRawData.substring(1);
}
if (santizedRawData.endsWith('"')) {
santizedRawData = santizedRawData.substring(
0,
santizedRawData.length - 1,
);
}
const uriParams = {};
const searchParamsString =
decodeURIComponent(santizedRawData).split("?")[1];
searchParamsString.split("&").forEach((pair) => {
const [key, value] = pair.split("=");
uriParams[key] = value;
});
const uri = URI.parse(santizedRawData);
let uriPath = decodeURIComponent(uri.path);
if (uriPath.startsWith("/otpauth://") || uriPath.startsWith("otpauth://")) {
uriPath = uriPath.split("otpauth://")[1];
} else if (uriPath.startsWith("otpauth%3A//")) {
uriPath = uriPath.split("otpauth%3A//")[1];
}
return {
id,
type: _getType(uriPath),
account: _getAccount(uriPath),
issuer: _getIssuer(uriPath, uriParams),
digits: parseDigits(uriParams),
period: parsePeriod(uriParams),
secret: getSanitizedSecret(uriParams),
algorithm: parseAlgorithm(uriParams),
uriString,
};
};
const _getAccount = (uriPath: string): string => {
try {
const path = decodeURIComponent(uriPath);
if (path.includes(":")) {
return path.split(":")[1];
} else if (path.includes("/")) {
return path.split("/")[1];
}
} catch (e) {
return "";
}
};
const _getIssuer = (uriPath: string, uriParams: { get?: any }): string => {
try {
if (uriParams["issuer"] !== undefined) {
let issuer = uriParams["issuer"];
// This is to handle bug in the ente auth app
if (issuer.endsWith("period")) {
issuer = issuer.substring(0, issuer.length - 6);
}
return issuer;
}
let path = decodeURIComponent(uriPath);
if (path.startsWith("totp/") || path.startsWith("hotp/")) {
path = path.substring(5);
}
if (path.includes(":")) {
return path.split(":")[0];
} else if (path.includes("-")) {
return path.split("-")[0];
}
return path;
} catch (e) {
return "";
}
};
const parseDigits = (uriParams): number =>
parseInt(uriParams["digits"] ?? "", 10) || 6;
const parsePeriod = (uriParams): number =>
parseInt(uriParams["period"] ?? "", 10) || 30;
const parseAlgorithm = (uriParams): Code["algorithm"] => {
switch (uriParams["algorithm"]?.toLowerCase()) {
case "sha256":
return "sha256";
case "sha512":
return "sha512";
default:
return "sha1";
}
};
const _getType = (uriPath: string): Code["type"] => {
const oauthType = uriPath.split("/")[0].substring(0);
if (oauthType.toLowerCase() === "totp") {
return "totp";
} else if (oauthType.toLowerCase() === "hotp") {
return "hotp";
}
throw new Error(`Unsupported format with host ${oauthType}`);
};
const getSanitizedSecret = (uriParams): string => {
return uriParams["secret"].replace(/ /g, "").toUpperCase();
};

View file

@ -6,10 +6,10 @@ import { getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getActualKey } from "@ente/shared/user";
import { HttpStatusCode } from "axios";
import { AuthEntity, AuthKey } from "types/api";
import { Code } from "types/code";
import { codeFromURIString, type Code } from "services/code";
const ENDPOINT = getEndpoint();
export const getAuthCodes = async (): Promise<Code[]> => {
const masterKey = await getActualKey();
try {
@ -33,7 +33,7 @@ export const getAuthCodes = async (): Promise<Code[]> => {
entity.header,
authenticatorKey,
);
return Code.fromRawData(entity.id, decryptedCode);
return codeFromURIString(entity.id, decryptedCode);
} catch (e) {
log.error(`failed to parse codeId = ${entity.id}`);
return null;
@ -65,6 +65,20 @@ export const getAuthCodes = async (): Promise<Code[]> => {
}
};
interface AuthEntity {
id: string;
encryptedData: string | null;
header: string | null;
isDeleted: boolean;
createdAt: number;
updatedAt: number;
}
interface AuthKey {
encryptedKey: string;
header: string;
}
export const getAuthKey = async (): Promise<AuthKey> => {
try {
const resp = await HTTPService.get(

View file

@ -1,13 +0,0 @@
export interface AuthEntity {
id: string;
encryptedData: string | null;
header: string | null;
isDeleted: boolean;
createdAt: number;
updatedAt: number;
}
export interface AuthKey {
encryptedKey: string;
header: string;
}

View file

@ -1,182 +0,0 @@
import { URI } from "vscode-uri";
type Type = "totp" | "TOTP" | "hotp" | "HOTP";
type AlgorithmType =
| "sha1"
| "SHA1"
| "sha256"
| "SHA256"
| "sha512"
| "SHA512";
export class Code {
static readonly defaultDigits = 6;
static readonly defaultAlgo = "sha1";
static readonly defaultPeriod = 30;
// id for the corresponding auth entity
id?: String;
account: string;
issuer: string;
digits?: number;
period: number;
secret: string;
algorithm: AlgorithmType;
type: Type;
rawData?: string;
constructor(
account: string,
issuer: string,
digits: number | undefined,
period: number,
secret: string,
algorithm: AlgorithmType,
type: Type,
rawData?: string,
id?: string,
) {
this.account = account;
this.issuer = issuer;
this.digits = digits;
this.period = period;
this.secret = secret;
this.algorithm = algorithm;
this.type = type;
this.rawData = rawData;
this.id = id;
}
static fromRawData(id: string, rawData: string): Code {
let santizedRawData = rawData
.replace(/\+/g, "%2B")
.replace(/:/g, "%3A")
.replaceAll("\r", "");
if (santizedRawData.startsWith('"')) {
santizedRawData = santizedRawData.substring(1);
}
if (santizedRawData.endsWith('"')) {
santizedRawData = santizedRawData.substring(
0,
santizedRawData.length - 1,
);
}
const uriParams = {};
const searchParamsString =
decodeURIComponent(santizedRawData).split("?")[1];
searchParamsString.split("&").forEach((pair) => {
const [key, value] = pair.split("=");
uriParams[key] = value;
});
const uri = URI.parse(santizedRawData);
let uriPath = decodeURIComponent(uri.path);
if (
uriPath.startsWith("/otpauth://") ||
uriPath.startsWith("otpauth://")
) {
uriPath = uriPath.split("otpauth://")[1];
} else if (uriPath.startsWith("otpauth%3A//")) {
uriPath = uriPath.split("otpauth%3A//")[1];
}
return new Code(
Code._getAccount(uriPath),
Code._getIssuer(uriPath, uriParams),
Code._getDigits(uriParams),
Code._getPeriod(uriParams),
Code.getSanitizedSecret(uriParams),
Code._getAlgorithm(uriParams),
Code._getType(uriPath),
rawData,
id,
);
}
private static _getAccount(uriPath: string): string {
try {
const path = decodeURIComponent(uriPath);
if (path.includes(":")) {
return path.split(":")[1];
} else if (path.includes("/")) {
return path.split("/")[1];
}
} catch (e) {
return "";
}
}
private static _getIssuer(
uriPath: string,
uriParams: { get?: any },
): string {
try {
if (uriParams["issuer"] !== undefined) {
let issuer = uriParams["issuer"];
// This is to handle bug in the ente auth app
if (issuer.endsWith("period")) {
issuer = issuer.substring(0, issuer.length - 6);
}
return issuer;
}
let path = decodeURIComponent(uriPath);
if (path.startsWith("totp/") || path.startsWith("hotp/")) {
path = path.substring(5);
}
if (path.includes(":")) {
return path.split(":")[0];
} else if (path.includes("-")) {
return path.split("-")[0];
}
return path;
} catch (e) {
return "";
}
}
private static _getDigits(uriParams): number {
try {
return parseInt(uriParams["digits"], 10) || Code.defaultDigits;
} catch (e) {
return Code.defaultDigits;
}
}
private static _getPeriod(uriParams): number {
try {
return parseInt(uriParams["period"], 10) || Code.defaultPeriod;
} catch (e) {
return Code.defaultPeriod;
}
}
private static _getAlgorithm(uriParams): AlgorithmType {
try {
const algorithm = uriParams["algorithm"].toLowerCase();
if (algorithm === "sha256") {
return algorithm;
} else if (algorithm === "sha512") {
return algorithm;
}
} catch (e) {
// nothing
}
return "sha1";
}
private static _getType(uriPath: string): Type {
const oauthType = uriPath.split("/")[0].substring(0);
if (oauthType.toLowerCase() === "totp") {
return "totp";
} else if (oauthType.toLowerCase() === "hotp") {
return "hotp";
}
throw new Error(`Unsupported format with host ${oauthType}`);
}
static getSanitizedSecret(uriParams): string {
return uriParams["secret"].replace(/ /g, "").toUpperCase();
}
}