diff --git a/web/apps/auth/src/pages/auth.tsx b/web/apps/auth/src/pages/auth.tsx index f8b160af52..55f8ccd4c1 100644 --- a/web/apps/auth/src/pages/auth.tsx +++ b/web/apps/auth/src/pages/auth.tsx @@ -180,13 +180,13 @@ const CodeDisplay: React.FC = ({ code, timeOffset }) => { const regen = useCallback(() => { try { - const [m, n] = generateOTPs(code); + const [m, n] = generateOTPs(code, timeOffset); setOTP(m); setNextOTP(n); } catch (e) { setErrorMessage(e instanceof Error ? e.message : String(e)); } - }, [code]); + }, [code, timeOffset]); const copyCode = () => void navigator.clipboard.writeText(otp).then(() => { @@ -327,7 +327,7 @@ const CodeValidityBar: React.FC = ({ code.type == "hotp" ? undefined : setInterval(advance, 10); return () => ticker && clearInterval(ticker); - }, [code]); + }, [code, timeOffset]); const progressColor = progress > 0.4 diff --git a/web/apps/auth/src/services/code.ts b/web/apps/auth/src/services/code.ts index c312285705..2d557dc549 100644 --- a/web/apps/auth/src/services/code.ts +++ b/web/apps/auth/src/services/code.ts @@ -256,12 +256,19 @@ const parseCodeDisplay = (url: URL): CodeDisplay | undefined => { * * @param code The parsed code data, including the secret and code type. * + * @param timeOffset A millisecond delta that should be applied to Date.now when + * deriving the OTP. + * * @returns a pair of OTPs, the current one and the next one, using the given * {@link code}. */ -export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => { +export const generateOTPs = ( + code: Code, + timeOffset: number, +): [otp: string, nextOTP: string] => { let otp: string; let nextOTP: string; + const timestamp = Date.now() + timeOffset; switch (code.type) { case "totp": { const totp = new TOTP({ @@ -270,9 +277,9 @@ export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => { period: code.period, digits: code.length, }); - otp = totp.generate(); + otp = totp.generate({ timestamp }); nextOTP = totp.generate({ - timestamp: Date.now() + code.period * 1000, + timestamp: timestamp + code.period * 1000, }); break; } @@ -291,9 +298,9 @@ export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => { case "steam": { const steam = new Steam({ secret: code.secret }); - otp = steam.generate(); + otp = steam.generate({ timestamp }); nextOTP = steam.generate({ - timestamp: Date.now() + code.period * 1000, + timestamp: timestamp + code.period * 1000, }); break; } diff --git a/web/apps/auth/src/services/remote.ts b/web/apps/auth/src/services/remote.ts index 05d84c1375..35ed191b8c 100644 --- a/web/apps/auth/src/services/remote.ts +++ b/web/apps/auth/src/services/remote.ts @@ -14,8 +14,8 @@ import { z } from "zod"; export interface AuthCodesAndTimeOffset { codes: Code[]; /** - * An optional and approximate correction which should be applied to the - * current client's time when deriving TOTPs. + * An optional and approximate correction (milliseconds) which should be + * applied to the current client's time when deriving TOTPs. */ timeOffset?: number; } @@ -112,7 +112,14 @@ const RemoteAuthenticatorEntityChange = z.object({ }); const AuthenticatorEntityDiffResponse = z.object({ + /** + * Changes to entities. + */ diff: z.array(RemoteAuthenticatorEntityChange), + /** + * An optional epoch microseconds indicating the remote time when it + * generated the response. + */ timestamp: z.number().nullish().transform(nullToUndefined), }); @@ -122,8 +129,8 @@ export interface AuthenticatorEntityDiffResult { */ entities: AuthenticatorEntity[]; /** - * An optional and approximate offset by which the time on the current client - * is out of sync. + * An optional and approximate offset (in milliseconds) by which the time on + * the current client is out of sync. * * This offset is computed by calculated by comparing the timestamp when the * remote generated the response to the time we received it. As such @@ -174,10 +181,17 @@ export const authenticatorEntityDiff = async ( headers: await authenticatedRequestHeaders(), }); ensureOk(res); + const { diff, timestamp } = AuthenticatorEntityDiffResponse.parse( await res.json(), ); - if (timestamp) timeOffset = Date.now() - timestamp; + + if (timestamp) { + // - timestamp is in epoch microseconds. + // - Date.now and timeOffset are in epoch milliseconds. + timeOffset = Date.now() - Math.floor(timestamp / 1e3); + } + if (diff.length == 0) break; for (const change of diff) {