feat(FN-2521): add remote auth handoff endpoints and login URL flow
- Add remote auth token primitives plus login-url generation for short-lived phone auth handoff - Expose public handoff API endpoint and wire remote auth handling into dashboard server routes - Tighten remote access settings update typing to satisfy typecheck and preserve API isolation behavior - Add comprehensive dashboard tests for remote-auth helpers, route behavior, and server integration - Document the remote login-url and phone auth handoff contract in architecture docs
This commit is contained in:
166
packages/dashboard/src/remote-auth.ts
Normal file
166
packages/dashboard/src/remote-auth.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { ProjectSettings } from "@fusion/core";
|
||||
|
||||
export type RemoteTokenValidationStatus = "valid" | "missing" | "invalid" | "expired" | "disabled";
|
||||
export type RemoteTokenType = "persistent" | "short-lived";
|
||||
|
||||
type RemoteAccessSettings = NonNullable<ProjectSettings["remoteAccess"]>;
|
||||
|
||||
interface ShortLivedTokenEntry {
|
||||
expiresAtMs: number;
|
||||
issuedAtMs: number;
|
||||
}
|
||||
|
||||
export interface RemoteTokenValidationResult {
|
||||
status: RemoteTokenValidationStatus;
|
||||
tokenType?: RemoteTokenType;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface IssueRemoteTokenResult {
|
||||
token: string;
|
||||
tokenType: RemoteTokenType;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORT_LIVED_TTL_MS = 900_000;
|
||||
const MIN_SHORT_LIVED_TTL_MS = 60_000;
|
||||
const MAX_SHORT_LIVED_TTL_MS = 86_400_000;
|
||||
|
||||
const shortLivedTokens = new Map<string, ShortLivedTokenEntry>();
|
||||
|
||||
export function constantTimeEqual(provided: string, expected: string): boolean {
|
||||
const expectedBuffer = Buffer.from(expected, "utf8");
|
||||
if (provided.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const providedBuffer = Buffer.from(provided, "utf8");
|
||||
if (providedBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRemoteToken(): string {
|
||||
return `frt_${randomBytes(20).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function maskRemoteToken(token: string): string {
|
||||
if (token.length <= 8) {
|
||||
return "********";
|
||||
}
|
||||
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
function resolveEffectiveShortLivedTtlMs(settings: RemoteAccessSettings): number {
|
||||
const configured = Number(settings.tokenStrategy.shortLived.ttlMs ?? DEFAULT_SHORT_LIVED_TTL_MS);
|
||||
if (!Number.isFinite(configured)) {
|
||||
return DEFAULT_SHORT_LIVED_TTL_MS;
|
||||
}
|
||||
|
||||
const maxConfigured = Number(settings.tokenStrategy.shortLived.maxTtlMs ?? MAX_SHORT_LIVED_TTL_MS);
|
||||
const boundedMax = Number.isFinite(maxConfigured)
|
||||
? Math.min(Math.max(maxConfigured, MIN_SHORT_LIVED_TTL_MS), MAX_SHORT_LIVED_TTL_MS)
|
||||
: MAX_SHORT_LIVED_TTL_MS;
|
||||
|
||||
return Math.min(Math.max(configured, MIN_SHORT_LIVED_TTL_MS), boundedMax);
|
||||
}
|
||||
|
||||
export function issueRemoteAuthToken(
|
||||
mode: RemoteTokenType,
|
||||
settings: RemoteAccessSettings,
|
||||
nowMs: number = Date.now(),
|
||||
): IssueRemoteTokenResult {
|
||||
purgeExpiredRemoteShortLivedTokens(nowMs);
|
||||
if (mode === "persistent") {
|
||||
const persistentToken = settings.tokenStrategy.persistent.token;
|
||||
if (!settings.tokenStrategy.persistent.enabled || !persistentToken) {
|
||||
throw new Error("Persistent remote token is not configured");
|
||||
}
|
||||
|
||||
return {
|
||||
token: persistentToken,
|
||||
tokenType: "persistent",
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.tokenStrategy.shortLived.enabled) {
|
||||
throw new Error("Short-lived token mode is disabled");
|
||||
}
|
||||
|
||||
const ttlMs = resolveEffectiveShortLivedTtlMs(settings);
|
||||
const token = generateRemoteToken();
|
||||
const expiresAtMs = nowMs + ttlMs;
|
||||
|
||||
shortLivedTokens.set(token, { expiresAtMs, issuedAtMs: nowMs });
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenType: "short-lived",
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRemoteAccessTokenStrategyEnabled(settings: RemoteAccessSettings): boolean {
|
||||
return settings.tokenStrategy.persistent.enabled || settings.tokenStrategy.shortLived.enabled;
|
||||
}
|
||||
|
||||
export function validateRemoteAuthToken(
|
||||
rt: string | null | undefined,
|
||||
settings: RemoteAccessSettings,
|
||||
nowMs: number = Date.now(),
|
||||
): RemoteTokenValidationResult {
|
||||
if (!settings.enabled || !isRemoteAccessTokenStrategyEnabled(settings)) {
|
||||
return { status: "disabled" };
|
||||
}
|
||||
|
||||
if (!rt) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
const persistent = settings.tokenStrategy.persistent;
|
||||
if (persistent.enabled && persistent.token && constantTimeEqual(rt, persistent.token)) {
|
||||
return { status: "valid", tokenType: "persistent" };
|
||||
}
|
||||
|
||||
const shortLived = settings.tokenStrategy.shortLived;
|
||||
const issued = shortLivedTokens.get(rt);
|
||||
if (shortLived.enabled && issued) {
|
||||
const ttlMs = resolveEffectiveShortLivedTtlMs(settings);
|
||||
const configuredExpiryMs = issued.issuedAtMs + ttlMs;
|
||||
const effectiveExpiryMs = Math.min(issued.expiresAtMs, configuredExpiryMs);
|
||||
if (nowMs > effectiveExpiryMs) {
|
||||
shortLivedTokens.delete(rt);
|
||||
return {
|
||||
status: "expired",
|
||||
tokenType: "short-lived",
|
||||
expiresAt: new Date(effectiveExpiryMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "valid",
|
||||
tokenType: "short-lived",
|
||||
expiresAt: new Date(effectiveExpiryMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
export function purgeExpiredRemoteShortLivedTokens(nowMs: number = Date.now()): void {
|
||||
for (const [token, entry] of shortLivedTokens.entries()) {
|
||||
if (entry.expiresAtMs <= nowMs) {
|
||||
shortLivedTokens.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetRemoteAuthStateForTests(): void {
|
||||
shortLivedTokens.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user