import { generateCodeChallenge, generateCodeVerifier, generateState } from "@/lib/auth/pkce"; import type { TokenSet } from "@/lib/types/model"; const AUTHORIZE_URL = "https://login.tidal.com/authorize"; const TOKEN_URL = "https://auth.tidal.com/v1/oauth2/token"; // Deliberately omits r_usr/w_usr — confirmed via live testing that a THIRD_PARTY // app does NOT need them; only the resource-specific scopes below (each of which // must also be individually approved in the app's developer.tidal.com dashboard). export const TIDAL_SCOPES = [ "collection.read", "collection.write", "playlists.read", "playlists.write", "user.read", "search.read", ].join(" "); function clientId(): string { const id = process.env.TIDAL_CLIENT_ID; if (!id) throw new Error("TIDAL_CLIENT_ID is not set"); return id; } export function buildTidalAuthUrl( redirectUri: string, scope: string = TIDAL_SCOPES ): { url: string; codeVerifier: string; state: string; scope: string; } { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); const params = new URLSearchParams({ response_type: "code", client_id: clientId(), scope, redirect_uri: redirectUri, state, code_challenge_method: "S256", code_challenge: codeChallenge, }); return { url: `${AUTHORIZE_URL}?${params.toString()}`, codeVerifier, state, scope }; } export interface TidalTokenResponse { access_token: string; refresh_token?: string; expires_in: number; scope: string; token_type: string; } function toTokenSet(data: TidalTokenResponse, fallbackRefreshToken?: string): TokenSet { return { accessToken: data.access_token, refreshToken: data.refresh_token ?? fallbackRefreshToken, expiresAt: Date.now() + data.expires_in * 1000, scope: data.scope, }; } export async function exchangeTidalCode( code: string, codeVerifier: string, redirectUri: string ): Promise { const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId(), code_verifier: codeVerifier, }); const res = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const text = await res.text(); if (!res.ok) { throw new Error(`Tidal token exchange failed: ${res.status} ${text}`); } return toTokenSet(JSON.parse(text) as TidalTokenResponse); } export async function refreshTidalToken(tokens: TokenSet): Promise { if (!tokens.refreshToken) { throw new Error("No refresh token available for Tidal account"); } const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refreshToken, client_id: clientId(), }); const res = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const text = await res.text(); if (!res.ok) { throw new Error(`Tidal token refresh failed: ${res.status} ${text}`); } return toTokenSet(JSON.parse(text) as TidalTokenResponse, tokens.refreshToken); }