Makelore 2.0 initial clean snapshot
This commit is contained in:
197
electron/services/works-square-session.ts
Normal file
197
electron/services/works-square-session.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NIANCODE_AUTH_CONFIG } from '../api/auth-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||
|
||||
export type WorksSquareSessionInput = {
|
||||
accessToken: string;
|
||||
refreshToken?: string | null;
|
||||
tokenType?: string | null;
|
||||
expiresAt?: number | null;
|
||||
};
|
||||
|
||||
export type WorksSquareTokenPayload = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
token_type?: unknown;
|
||||
expires_in?: unknown;
|
||||
};
|
||||
|
||||
export type WorksSquareSessionSnapshot = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
tokenType: string;
|
||||
expiresAt: number | null;
|
||||
};
|
||||
|
||||
export type WorksSquareSessionListener = (session: WorksSquareSessionSnapshot | null) => void;
|
||||
|
||||
let currentSession: WorksSquareSessionSnapshot | null = null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
const sessionListeners = new Set<WorksSquareSessionListener>();
|
||||
|
||||
function notifySessionListeners(): void {
|
||||
const snapshot = currentSession ? { ...currentSession } : null;
|
||||
for (const listener of sessionListeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch {
|
||||
// Session observers must not affect authentication state changes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function expiresAtFromExpiresIn(expiresIn: unknown, nowMs = Date.now()): number | null {
|
||||
const seconds = typeof expiresIn === 'number'
|
||||
? expiresIn
|
||||
: (typeof expiresIn === 'string' ? Number(expiresIn) : NaN);
|
||||
return Number.isFinite(seconds) && seconds > 0 ? nowMs + seconds * 1000 : null;
|
||||
}
|
||||
|
||||
function createBasicAuthHeader(clientId: string, clientSecret: string): string {
|
||||
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
function parseTokenPayload(
|
||||
payload: WorksSquareTokenPayload,
|
||||
fallbackRefreshToken: string | null,
|
||||
nowMs = Date.now(),
|
||||
): WorksSquareSessionSnapshot {
|
||||
const accessToken = asString(payload.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error('Auth refresh response did not include access_token');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asString(payload.refresh_token) ?? fallbackRefreshToken,
|
||||
tokenType: asString(payload.token_type) ?? 'Bearer',
|
||||
expiresAt: expiresAtFromExpiresIn(payload.expires_in, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeWorksSquareSession(input: WorksSquareSessionInput): void {
|
||||
const accessToken = input.accessToken.trim();
|
||||
if (!accessToken) {
|
||||
clearWorksSquareSession();
|
||||
return;
|
||||
}
|
||||
|
||||
currentSession = {
|
||||
accessToken,
|
||||
refreshToken: input.refreshToken?.trim() || null,
|
||||
tokenType: input.tokenType?.trim() || 'Bearer',
|
||||
expiresAt: typeof input.expiresAt === 'number' && Number.isFinite(input.expiresAt)
|
||||
? input.expiresAt
|
||||
: null,
|
||||
};
|
||||
notifySessionListeners();
|
||||
}
|
||||
|
||||
export function storeWorksSquareSessionFromTokenPayload(
|
||||
payload: WorksSquareTokenPayload,
|
||||
fallbackRefreshToken: string | null = null,
|
||||
nowMs = Date.now(),
|
||||
): WorksSquareSessionSnapshot {
|
||||
const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs);
|
||||
currentSession = session;
|
||||
notifySessionListeners();
|
||||
return session;
|
||||
}
|
||||
|
||||
export function clearWorksSquareSession(): void {
|
||||
currentSession = null;
|
||||
refreshPromise = null;
|
||||
notifySessionListeners();
|
||||
}
|
||||
|
||||
export function subscribeWorksSquareSession(listener: WorksSquareSessionListener): () => void {
|
||||
sessionListeners.add(listener);
|
||||
return () => {
|
||||
sessionListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorksSquareSessionSnapshot(): WorksSquareSessionSnapshot | null {
|
||||
return currentSession ? { ...currentSession } : null;
|
||||
}
|
||||
|
||||
async function refreshWorksSquareSession(
|
||||
options: { fetchImpl?: typeof fetch; nowMs?: number } = {},
|
||||
): Promise<string | null> {
|
||||
const session = currentSession;
|
||||
if (!session?.refreshToken) return null;
|
||||
|
||||
const fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: session.refreshToken,
|
||||
});
|
||||
const response = await fetchImpl(`${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: createBasicAuthHeader(
|
||||
NIANCODE_AUTH_CONFIG.clientId,
|
||||
NIANCODE_AUTH_CONFIG.clientSecret,
|
||||
),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('[works-square-session] Refresh failed', { status: response.status });
|
||||
clearWorksSquareSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
clearWorksSquareSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextSession = storeWorksSquareSessionFromTokenPayload(
|
||||
payload as WorksSquareTokenPayload,
|
||||
session.refreshToken,
|
||||
nowMs,
|
||||
);
|
||||
return nextSession.accessToken;
|
||||
}
|
||||
|
||||
export async function getValidWorksSquareAccessToken(
|
||||
options: { fetchImpl?: typeof fetch; nowMs?: number } = {},
|
||||
): Promise<string | null> {
|
||||
const session = currentSession;
|
||||
if (!session) return null;
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const nearExpiry = Boolean(
|
||||
session.expiresAt
|
||||
&& session.expiresAt <= nowMs + TOKEN_REFRESH_SKEW_MS,
|
||||
);
|
||||
if (!nearExpiry) return session.accessToken;
|
||||
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshWorksSquareSession(options).finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return await refreshPromise;
|
||||
}
|
||||
Reference in New Issue
Block a user