294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
import type { Cookies } from '@sveltejs/kit';
|
|
import { dev } from '$app/environment';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
|
|
const TOKEN_URL = 'https://api.beatleader.com/oauth2/token';
|
|
|
|
const ACCESS_COOKIE = 'bl_access_token';
|
|
const REFRESH_COOKIE = 'bl_refresh_token';
|
|
const EXPIRES_COOKIE = 'bl_expires_at';
|
|
const SESSION_COOKIE = 'bl_session_cookie';
|
|
const STATE_COOKIE = 'bl_oauth_state';
|
|
const REDIRECT_COOKIE = 'bl_redirect_to';
|
|
|
|
// Persistent storage locations
|
|
const DATA_DIR = '.data';
|
|
const CREDS_FILE = 'beatleader_oauth.json';
|
|
const TOKENS_FILE = 'beatleader_tokens.json';
|
|
|
|
function cookieOptions() {
|
|
return {
|
|
path: '/',
|
|
httpOnly: true as const,
|
|
sameSite: 'lax' as const,
|
|
secure: dev ? false : true
|
|
};
|
|
}
|
|
|
|
export function createOAuthState(): string {
|
|
// Use crypto.randomUUID for sufficiently random state
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
export function setStateCookie(cookies: Cookies, state: string): void {
|
|
cookies.set(STATE_COOKIE, state, { ...cookieOptions(), maxAge: 600 }); // 10 min
|
|
}
|
|
|
|
export function consumeAndValidateState(cookies: Cookies, received: string | null): boolean {
|
|
const stored = cookies.get(STATE_COOKIE);
|
|
cookies.delete(STATE_COOKIE, cookieOptions());
|
|
return Boolean(stored && received && stored === received);
|
|
}
|
|
|
|
export function setRedirectCookie(cookies: Cookies, redirectTo: string | null | undefined): void {
|
|
if (!redirectTo) return;
|
|
try {
|
|
const url = new URL(redirectTo, 'http://dummy');
|
|
const value = url.pathname + (url.search || '') + (url.hash || '');
|
|
cookies.set(REDIRECT_COOKIE, value, { ...cookieOptions(), maxAge: 600 });
|
|
} catch {
|
|
// ignore invalid redirect
|
|
}
|
|
}
|
|
|
|
export function consumeRedirectCookie(cookies: Cookies): string | null {
|
|
const value = cookies.get(REDIRECT_COOKIE) ?? null;
|
|
cookies.delete(REDIRECT_COOKIE, cookieOptions());
|
|
return value;
|
|
}
|
|
|
|
export function setTokens(
|
|
cookies: Cookies,
|
|
tokenData: { access_token: string; refresh_token?: string; expires_in?: number }
|
|
): void {
|
|
const expiresAt = Date.now() + ((tokenData.expires_in ?? 3600) * 1000);
|
|
cookies.set(ACCESS_COOKIE, tokenData.access_token, { ...cookieOptions(), maxAge: tokenData.expires_in ?? 3600 });
|
|
if (tokenData.refresh_token) {
|
|
// 30 days by default for refresh; adjust as needed
|
|
cookies.set(REFRESH_COOKIE, tokenData.refresh_token, { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
|
}
|
|
cookies.set(EXPIRES_COOKIE, String(expiresAt), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
|
|
|
// Persist token data to disk for reuse across restarts (single-user assumption)
|
|
try {
|
|
writeJsonPersistent(TOKENS_FILE, {
|
|
access_token: tokenData.access_token,
|
|
refresh_token: tokenData.refresh_token ?? null,
|
|
expires_at: expiresAt
|
|
});
|
|
} catch {
|
|
// ignore persistence errors
|
|
}
|
|
}
|
|
|
|
export function clearTokens(cookies: Cookies): void {
|
|
cookies.delete(ACCESS_COOKIE, cookieOptions());
|
|
cookies.delete(REFRESH_COOKIE, cookieOptions());
|
|
cookies.delete(EXPIRES_COOKIE, cookieOptions());
|
|
try {
|
|
deletePersistent(TOKENS_FILE);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist BeatLeader website session cookies captured from upstream Set-Cookie headers.
|
|
* Only cookie name=value pairs are stored (attributes are discarded). Stored both in httpOnly cookie and on disk.
|
|
*/
|
|
export function setBeatLeaderSessionFromSetCookieHeaders(cookies: Cookies, setCookieHeaders: string[] | undefined | null): void {
|
|
if (!setCookieHeaders || setCookieHeaders.length === 0) return;
|
|
const nameValuePairs: string[] = [];
|
|
for (const raw of setCookieHeaders) {
|
|
const firstSemi = raw.indexOf(';');
|
|
const pair = firstSemi === -1 ? raw.trim() : raw.slice(0, firstSemi).trim();
|
|
if (!pair) continue;
|
|
// Filter out obviously invalid pairs
|
|
if (!pair.includes('=')) continue;
|
|
const [name, value] = pair.split('=');
|
|
if (!name || value === undefined) continue;
|
|
nameValuePairs.push(`${name}=${value}`);
|
|
}
|
|
if (nameValuePairs.length === 0) return;
|
|
try {
|
|
cookies.set(SESSION_COOKIE, JSON.stringify(nameValuePairs), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
|
} catch {}
|
|
try {
|
|
writeJsonPersistent('beatleader_session.json', { cookies: nameValuePairs });
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Returns a Cookie header string ("a=b; c=d") suitable for upstream requests, if a BL session is stored.
|
|
*/
|
|
export function getBeatLeaderSessionCookieHeader(cookies: Cookies): string | null {
|
|
try {
|
|
const raw = cookies.get(SESSION_COOKIE);
|
|
if (raw) {
|
|
const arr = JSON.parse(raw) as string[];
|
|
if (Array.isArray(arr) && arr.length > 0) return arr.join('; ');
|
|
}
|
|
} catch {}
|
|
try {
|
|
const persisted = readJsonPersistent<{ cookies?: string[] }>('beatleader_session.json');
|
|
if (persisted?.cookies && persisted.cookies.length > 0) return persisted.cookies.join('; ');
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
export function clearBeatLeaderSession(cookies: Cookies): void {
|
|
cookies.delete(SESSION_COOKIE, cookieOptions());
|
|
try {
|
|
deletePersistent('beatleader_session.json');
|
|
} catch {}
|
|
}
|
|
|
|
export function getAccessTokenFromCookies(cookies: Cookies): { token: string | null; expiresAt: number | null } {
|
|
const token = cookies.get(ACCESS_COOKIE) ?? null;
|
|
const expiresRaw = cookies.get(EXPIRES_COOKIE);
|
|
const expiresAt = expiresRaw ? Number(expiresRaw) : null;
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
export async function refreshAccessToken(cookies: Cookies): Promise<string | null> {
|
|
const refreshToken = cookies.get(REFRESH_COOKIE);
|
|
let tokenToUse: string | undefined = refreshToken ?? undefined;
|
|
if (!tokenToUse) {
|
|
// Try persistent token store
|
|
const persisted = readJsonPersistent<{ refresh_token?: string | null }>(TOKENS_FILE);
|
|
tokenToUse = (persisted?.refresh_token ?? undefined) as string | undefined;
|
|
}
|
|
if (!tokenToUse) return null;
|
|
|
|
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
|
const clientId = creds?.client_id;
|
|
const clientSecret = creds?.client_secret;
|
|
if (!clientId || !clientSecret) return null;
|
|
|
|
const res = await fetch(TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
refresh_token: tokenToUse
|
|
})
|
|
});
|
|
|
|
if (!res.ok) {
|
|
clearTokens(cookies);
|
|
return null;
|
|
}
|
|
|
|
const tokenData = await res.json();
|
|
setTokens(cookies, tokenData);
|
|
return tokenData.access_token as string;
|
|
}
|
|
|
|
export async function getValidAccessToken(cookies: Cookies): Promise<string | null> {
|
|
const { token, expiresAt } = getAccessTokenFromCookies(cookies);
|
|
if (token && expiresAt && Date.now() < expiresAt - 10_000) {
|
|
return token; // still valid (with 10s skew)
|
|
}
|
|
// Try refresh
|
|
const refreshed = await refreshAccessToken(cookies);
|
|
if (refreshed) return refreshed;
|
|
|
|
// As last resort, if we have a persisted access token that may still be valid, use it
|
|
const persisted = readJsonPersistent<{ access_token?: string | null; expires_at?: number | null }>(TOKENS_FILE);
|
|
if (persisted?.access_token && persisted?.expires_at && Date.now() < (persisted.expires_at - 10_000)) {
|
|
cookies.set(ACCESS_COOKIE, persisted.access_token, { ...cookieOptions(), maxAge: Math.floor((persisted.expires_at - Date.now()) / 1000) });
|
|
cookies.set(EXPIRES_COOKIE, String(persisted.expires_at), { ...cookieOptions(), maxAge: 30 * 24 * 3600 });
|
|
return persisted.access_token;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function buildAuthorizeUrl(origin: string, scopes: string[]): URL {
|
|
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
|
const clientId = creds?.client_id;
|
|
if (!clientId) throw new Error('BeatLeader OAuth is not configured. Visit /tools/beatleader-oauth to set it up.');
|
|
const redirectUri = `${origin}/auth/beatleader/callback`;
|
|
const url = new URL('https://api.beatleader.com/oauth2/authorize');
|
|
url.searchParams.set('client_id', clientId);
|
|
// BeatLeader expects scopes without the "scp:" prefix in the authorization request
|
|
const requestedScopes = scopes.map(s => s.replace(/^scp:/, '')).join(' ');
|
|
url.searchParams.set('scope', requestedScopes);
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('redirect_uri', redirectUri);
|
|
return url;
|
|
}
|
|
|
|
export async function exchangeCodeForTokens(origin: string, code: string): Promise<{ access_token: string; refresh_token?: string; expires_in?: number } | null> {
|
|
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
|
const clientId = creds?.client_id;
|
|
const clientSecret = creds?.client_secret;
|
|
if (!clientId || !clientSecret) return null;
|
|
const redirectUri = `${origin}/auth/beatleader/callback`;
|
|
|
|
const res = await fetch(TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code,
|
|
redirect_uri: redirectUri
|
|
})
|
|
});
|
|
|
|
if (!res.ok) return null;
|
|
return (await res.json()) as any;
|
|
}
|
|
|
|
// Persistent storage helpers (single-user local setup)
|
|
function ensureDataDir(): void {
|
|
try {
|
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function resolvePath(name: string): string {
|
|
return path.join(process.cwd(), DATA_DIR, name);
|
|
}
|
|
|
|
function writeJsonPersistent<T extends object>(name: string, data: T): void {
|
|
ensureDataDir();
|
|
fs.writeFileSync(resolvePath(name), JSON.stringify(data, null, 2), 'utf-8');
|
|
}
|
|
|
|
function readJsonPersistent<T>(name: string): T | null {
|
|
try {
|
|
const p = resolvePath(name);
|
|
if (!fs.existsSync(p)) return null;
|
|
const raw = fs.readFileSync(p, 'utf-8');
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function deletePersistent(name: string): void {
|
|
try {
|
|
const p = resolvePath(name);
|
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
export function storeOAuthCredentials(input: { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] }): void {
|
|
writeJsonPersistent(CREDS_FILE, input);
|
|
}
|
|
|
|
export function readOAuthCredentials(): { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] } | null {
|
|
return readJsonPersistent(CREDS_FILE);
|
|
}
|
|
|
|
|