diff --git a/specs/components/upload/types.d.ts b/specs/components/upload/types.d.ts index ddf796b5..cd9b7bce 100644 --- a/specs/components/upload/types.d.ts +++ b/specs/components/upload/types.d.ts @@ -19,7 +19,7 @@ export type UploadActions = { */ createSource(args: { scheme: string; - accessToken: string; + refreshToken: string; directoryPath: string; }): Promise; diff --git a/src/components/input/dropbox/common.js b/src/components/input/dropbox/common.js index 6fedd764..da27fdbb 100644 --- a/src/components/input/dropbox/common.js +++ b/src/components/input/dropbox/common.js @@ -3,16 +3,162 @@ import QS from "query-string"; import { cachedConsult, isAudioFile } from "~/components/input/common.js"; import { safeDecodeURIComponent } from "~/common/utils.js"; -import { SCHEME } from "./constants.js"; +import { + ACCESS_TOKEN_REFRESH_MARGIN_MS, + DEFAULT_APP_KEY, + SCHEME, +} from "./constants.js"; /** * @import { Track } from "~/definitions/types.d.ts" + * @import { ConsultResult } from "@specs/components/input/types.d.ts" */ /** - * @typedef {{ accessToken: string; directoryPath: string }} Account + * @typedef {{ refreshToken: string; directoryPath: string }} Account */ +//////////////////////////////////////////// +// PKCE +//////////////////////////////////////////// + +/** + * Base64url-encode raw bytes (no padding), as required by PKCE. + * + * @param {Uint8Array} bytes + */ +function base64url(bytes) { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** + * Generates a PKCE code verifier and its S256 challenge. + * + * The verifier is a random string the client keeps secret; the challenge + * is `base64url(SHA-256(verifier))` and is sent to the authorization + * server during the authorize request. When exchanging the code, the + * verifier proves the client is the same one that started the flow. + * + * @returns {Promise<{ verifier: string; challenge: string }>} + */ +export async function generatePKCEPair() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const verifier = base64url(bytes); + + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const challenge = base64url(new Uint8Array(digest)); + + return { verifier, challenge }; +} + +//////////////////////////////////////////// +// TOKEN EXCHANGE & REFRESH +//////////////////////////////////////////// + +/** + * Exchange an authorization code for a refresh token (and short-lived + * access token) using the PKCE code verifier. + * + * @param {string} code - The authorization code returned by Dropbox. + * @param {string} codeVerifier - The PKCE code verifier stored during authorize(). + * @param {string} [redirectUri] - The redirect URI registered with Dropbox. + * @param {string} [appKey] - The Dropbox app key. + * @returns {Promise<{ refreshToken: string; accessToken: string } | null>} + */ +export async function exchangeCode(code, codeVerifier, redirectUri, appKey = DEFAULT_APP_KEY) { + const params = new URLSearchParams({ + code, + grant_type: "authorization_code", + client_id: appKey, + redirect_uri: redirectUri ?? (location.origin + "/oauth/callback/"), + code_verifier: codeVerifier, + }); + + const resp = await fetch("https://api.dropboxapi.com/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + + if (!resp.ok) return null; + + /** @type {{ access_token: string; refresh_token: string }} */ + const data = await resp.json(); + return { refreshToken: data.refresh_token, accessToken: data.access_token }; +} + +/** + * In-memory cache of access tokens keyed by refresh token. + * Each entry stores the token and its expiry timestamp. + * + * @type {Map | null }>} + */ +const accessTokenCache = new Map(); + +/** + * Exchanges a refresh token for a fresh short-lived access token. + * + * Results are cached and reused until close to expiry. Concurrent calls + * for the same refresh token share a single network request. + * + * @param {string} refreshToken + * @param {string} [appKey] + * @returns {Promise} The access token, or null if the refresh failed. + */ +export function getAccessToken(refreshToken, appKey = DEFAULT_APP_KEY) { + const now = Date.now(); + const cached = accessTokenCache.get(refreshToken); + + // Return cached token if still valid (with a safety margin). + if (cached && cached.expiresAt > now + ACCESS_TOKEN_REFRESH_MARGIN_MS) { + return Promise.resolve(cached.accessToken); + } + + // If a refresh is already in flight for this token, piggyback on it. + if (cached?.inflight) { + return cached.inflight; + } + + const inflight = (async () => { + try { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: appKey, + }); + + const resp = await fetch("https://api.dropboxapi.com/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + + if (!resp.ok) return null; + + /** @type {{ access_token: string; expires_in: number }} */ + const data = await resp.json(); + const accessToken = data.access_token; + const expiresAt = now + data.expires_in * 1000; + + accessTokenCache.set(refreshToken, { accessToken, expiresAt, inflight: null }); + return accessToken; + } catch { + return null; + } + })(); + + if (cached) { + cached.inflight = inflight; + } else { + accessTokenCache.set(refreshToken, { accessToken: "", expiresAt: 0, inflight }); + } + + return inflight; +} + //////////////////////////////////////////// // URI //////////////////////////////////////////// @@ -24,7 +170,7 @@ import { SCHEME } from "./constants.js"; export function buildURI(account, filePath) { return URI.serialize({ scheme: SCHEME, - userinfo: encodeURIComponent(account.accessToken), + userinfo: encodeURIComponent(account.refreshToken), host: "dropbox.com", path: filePath || "/", query: QS.stringify({ dir: account.directoryPath || "/" }), @@ -33,19 +179,19 @@ export function buildURI(account, filePath) { /** * @param {string} uriString - * @returns {{ accessToken: string; path: string; directoryPath: string } | undefined} + * @returns {{ refreshToken: string; path: string; directoryPath: string } | undefined} */ export function parseURI(uriString) { const uri = URI.parse(uriString); if (uri.scheme !== SCHEME) return undefined; if (!uri.userinfo) return undefined; - const accessToken = decodeURIComponent(uri.userinfo); + const refreshToken = decodeURIComponent(uri.userinfo); const path = safeDecodeURIComponent(uri.path || "/"); const qs = QS.parse(uri.query || ""); const directoryPath = typeof qs.dir === "string" ? safeDecodeURIComponent(qs.dir) : "/"; - return { accessToken, path, directoryPath }; + return { refreshToken, path, directoryPath }; } //////////////////////////////////////////// @@ -56,7 +202,7 @@ export function parseURI(uriString) { * @param {Account} account */ export function accountId(account) { - return `${account.accessToken}:${account.directoryPath}`; + return `${account.refreshToken}:${account.directoryPath}`; } /** @@ -74,7 +220,7 @@ export function accountsFromTracks(tracks) { const id = accountId(parsed); if (acc[id]) return; - acc[id] = { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }; + acc[id] = { refreshToken: parsed.refreshToken, directoryPath: parsed.directoryPath }; }); return acc; @@ -98,7 +244,7 @@ export function groupTracksByAccount(tracks) { acc[id].tracks.push(track); } else { acc[id] = { - account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, + account: { refreshToken: parsed.refreshToken, directoryPath: parsed.directoryPath }, tracks: [track], }; } @@ -125,7 +271,7 @@ export function groupUrisByAccount(uris) { acc[id].uris.push(uri); } else { acc[id] = { - account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, + account: { refreshToken: parsed.refreshToken, directoryPath: parsed.directoryPath }, uris: [uri], }; } @@ -139,11 +285,14 @@ export function groupUrisByAccount(uris) { //////////////////////////////////////////// /** - * @param {string} accessToken + * @param {string} refreshToken * @param {string} directoryPath * @returns {Promise | null>} */ -export async function listFiles(accessToken, directoryPath) { +export async function listFiles(refreshToken, directoryPath) { + const accessToken = await getAccessToken(refreshToken); + if (!accessToken) return null; + const apiPath = directoryPath === "/" ? "" : directoryPath; const headers = { "Authorization": `Bearer ${accessToken}`, @@ -184,11 +333,14 @@ export async function listFiles(accessToken, directoryPath) { } /** - * @param {string} accessToken + * @param {string} refreshToken * @param {string} filePath * @returns {Promise} */ -export async function getTemporaryLink(accessToken, filePath) { +export async function getTemporaryLink(refreshToken, filePath) { + const accessToken = await getAccessToken(refreshToken); + if (!accessToken) return null; + const resp = await fetch( "https://api.dropboxapi.com/2/files/get_temporary_link", { @@ -209,10 +361,13 @@ export async function getTemporaryLink(accessToken, filePath) { } /** - * @param {string} accessToken - * @returns {Promise} + * @param {string} refreshToken + * @returns {Promise} */ -export async function checkAccess(accessToken) { +export async function checkAccess(refreshToken) { + const accessToken = await getAccessToken(refreshToken); + if (!accessToken) return "no"; + try { const resp = await fetch( "https://api.dropboxapi.com/2/users/get_current_account", diff --git a/src/components/input/dropbox/constants.js b/src/components/input/dropbox/constants.js index f32a70a2..4e76e478 100644 --- a/src/components/input/dropbox/constants.js +++ b/src/components/input/dropbox/constants.js @@ -1,2 +1,15 @@ export const SCHEME = "dropbox"; export const DEFAULT_APP_KEY = "kwsydtrzban41zr"; + +/** + * Dropbox short-lived access tokens last ~4 hours. We refresh a bit + * early (5 minutes before expiry) to avoid edge-case failures where + * the token expires between the refresh check and the API call. + */ +export const ACCESS_TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; + +/** + * localStorage key for the PKCE code verifier, stored during the + * authorization-code-with-PKCE OAuth flow and consumed by the callback. + */ +export const PKCE_VERIFIER_KEY = "oauth/callback/dropbox/code_verifier"; diff --git a/src/components/input/dropbox/element.js b/src/components/input/dropbox/element.js index 31469611..a4d40f55 100644 --- a/src/components/input/dropbox/element.js +++ b/src/components/input/dropbox/element.js @@ -1,6 +1,6 @@ import { defineElement, DiffuseElement } from "~/common/element.js"; -import { DEFAULT_APP_KEY, SCHEME } from "./constants.js"; -import { accountsFromTracks, buildURI } from "./common.js"; +import { DEFAULT_APP_KEY, PKCE_VERIFIER_KEY, SCHEME } from "./constants.js"; +import { accountsFromTracks, buildURI, generatePKCEPair } from "./common.js"; /** * @import {InputActions, InputSchemeProvider} from "@specs/components/input/types.d.ts" @@ -54,13 +54,22 @@ class DropboxInput extends DiffuseElement { // 🛠️ - authorize() { + async authorize() { localStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); + // Use the authorization-code flow with PKCE so we receive a + // long-lived refresh token that can automatically renew the + // short-lived access token (4 h) without user interaction. + const { verifier, challenge } = await generatePKCEPair(); + localStorage.setItem(PKCE_VERIFIER_KEY, verifier); + const params = new URLSearchParams({ - response_type: "token", + response_type: "code", client_id: this.appKey, redirect_uri: location.origin + "/oauth/callback/", + token_access_type: "offline", + code_challenge: challenge, + code_challenge_method: "S256", }); location.assign(`https://www.dropbox.com/oauth2/authorize?${params}`); diff --git a/src/components/input/dropbox/worker.js b/src/components/input/dropbox/worker.js index 067c5ae0..351ab3c9 100644 --- a/src/components/input/dropbox/worker.js +++ b/src/components/input/dropbox/worker.js @@ -44,7 +44,7 @@ export async function consult(fileUriOrScheme) { const parsed = parseURI(fileUriOrScheme); if (!parsed) return { supported: true, consult: "undetermined" }; - const accessible = await checkAccessCached(parsed.accessToken); + const accessible = await checkAccessCached(parsed.refreshToken); return { supported: true, consult: accessible }; } @@ -76,7 +76,7 @@ export async function groupConsult(uris) { const promises = Object.entries(groups).map( async ([id, { account, uris }]) => { - const available = await checkAccessCached(account.accessToken); + const available = await checkAccessCached(account.refreshToken); /** @type {ConsultGrouping} */ const grouping = available === "yes" @@ -111,7 +111,7 @@ export async function list(cachedTracks = []) { const promises = Object.values(accounts).map(async (account) => { const id = accountId(account); - const files = await listFiles(account.accessToken, account.directoryPath); + const files = await listFiles(account.refreshToken, account.directoryPath); if (!files) { const existing = cachedTracks.find((t) => { @@ -174,7 +174,7 @@ export async function resolve({ uri }) { const parsed = parseURI(uri); if (!parsed || parsed.path === "/") return undefined; - const link = await getTemporaryLink(parsed.accessToken, parsed.path); + const link = await getTemporaryLink(parsed.refreshToken, parsed.path); if (!link) return undefined; // Dropbox temporary links expire after 4 hours diff --git a/src/components/upload/dropbox/common.js b/src/components/upload/dropbox/common.js index e54dad6c..8cfd0f57 100644 --- a/src/components/upload/dropbox/common.js +++ b/src/components/upload/dropbox/common.js @@ -1,4 +1,4 @@ -import { parseURI } from "~/components/input/dropbox/common.js"; +import { parseURI, getAccessToken } from "~/components/input/dropbox/common.js"; /** * @import { Account } from "~/components/input/dropbox/common.js" @@ -12,12 +12,17 @@ import { parseURI } from "~/components/input/dropbox/common.js"; * Upload a file to Dropbox. Uses the content-upload endpoint with the * file's bytes as the request body. * - * @param {string} accessToken + * @param {string} refreshToken * @param {string} destinationPath Full Dropbox path (e.g. "/Music/song.mp3"). * @param {File} file * @returns {Promise<{ path_lower: string; name: string } | null>} The uploaded file metadata, or null on failure. */ -export async function uploadFile(accessToken, destinationPath, file) { +export async function uploadFile(refreshToken, destinationPath, file) { + const accessToken = await getAccessToken(refreshToken); + if (!accessToken) { + throw new Error("Dropbox access token could not be refreshed. Please reconnect."); + } + const resp = await fetch( "https://content.dropboxapi.com/2/files/upload", { @@ -55,11 +60,16 @@ export async function uploadFile(accessToken, destinationPath, file) { /** * Delete a file from Dropbox. * - * @param {string} accessToken + * @param {string} refreshToken * @param {string} filePath Full Dropbox path (e.g. "/Music/song.mp3"). * @returns {Promise} */ -export async function deleteFile(accessToken, filePath) { +export async function deleteFile(refreshToken, filePath) { + const accessToken = await getAccessToken(refreshToken); + if (!accessToken) { + throw new Error("Dropbox access token could not be refreshed. Please reconnect."); + } + const resp = await fetch( "https://api.dropboxapi.com/2/files/delete_v2", { @@ -120,5 +130,5 @@ export function accountFromURI(uri) { if (!parsed) { throw new Error(`Invalid Dropbox URI: ${uri}`); } - return { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }; + return { refreshToken: parsed.refreshToken, directoryPath: parsed.directoryPath }; } diff --git a/src/components/upload/dropbox/element.js b/src/components/upload/dropbox/element.js index 97dca617..326802d8 100644 --- a/src/components/upload/dropbox/element.js +++ b/src/components/upload/dropbox/element.js @@ -1,5 +1,6 @@ import { defineElement, DiffuseElement } from "~/common/element.js"; -import { DEFAULT_APP_KEY, SCHEME } from "~/components/input/dropbox/constants.js"; +import { DEFAULT_APP_KEY, PKCE_VERIFIER_KEY, SCHEME } from "~/components/input/dropbox/constants.js"; +import { generatePKCEPair } from "~/components/input/dropbox/common.js"; /** * @import {UploadActions, UploadSchemeProvider} from "@specs/components/upload/types.d.ts" @@ -50,13 +51,19 @@ class DropboxUpload extends DiffuseElement { // 🛠️ - authorize() { + async authorize() { localStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); + const { verifier, challenge } = await generatePKCEPair(); + localStorage.setItem(PKCE_VERIFIER_KEY, verifier); + const params = new URLSearchParams({ - response_type: "token", + response_type: "code", client_id: this.appKey, redirect_uri: location.origin + "/oauth/callback/", + token_access_type: "offline", + code_challenge: challenge, + code_challenge_method: "S256", }); location.assign(`https://www.dropbox.com/oauth2/authorize?${params}`); diff --git a/src/components/upload/dropbox/worker.js b/src/components/upload/dropbox/worker.js index f636bad2..1ded33ce 100644 --- a/src/components/upload/dropbox/worker.js +++ b/src/components/upload/dropbox/worker.js @@ -31,7 +31,7 @@ export async function consult(fileUriOrScheme) { const parsed = parseURI(fileUriOrScheme); if (!parsed) return { supported: true, consult: "undetermined" }; - const accessible = await checkAccessCached(parsed.accessToken); + const accessible = await checkAccessCached(parsed.refreshToken); return { supported: true, consult: accessible }; } @@ -43,7 +43,7 @@ export async function upload({ file, uri, path }) { const destinationPath = resolveDestinationPath(account, file, path); const uploaded = await uploadFile( - account.accessToken, + account.refreshToken, destinationPath, file, ); @@ -64,14 +64,14 @@ export async function deleteFn(uri) { throw new Error(`Invalid Dropbox file URI: ${uri}`); } - await deleteFile(parsed.accessToken, parsed.path); + await deleteFile(parsed.refreshToken, parsed.path); } /** * @type {Actions['createSource']} */ -export async function createSource({ accessToken, directoryPath }) { - const uri = buildURI({ accessToken, directoryPath }); +export async function createSource({ refreshToken, directoryPath }) { + const uri = buildURI({ refreshToken, directoryPath }); const now = new Date().toISOString(); return { $type: "sh.diffuse.output.track", diff --git a/src/facets/connect/dropbox/index.inline.js b/src/facets/connect/dropbox/index.inline.js index 6b71535f..a30acb0c 100644 --- a/src/facets/connect/dropbox/index.inline.js +++ b/src/facets/connect/dropbox/index.inline.js @@ -37,7 +37,7 @@ await Promise.all([ //////////////////////////////////////////// const hashParams = new URLSearchParams(location.hash.slice(1)); -let currentToken = hashParams.get("access_token"); +let currentToken = hashParams.get("refresh_token"); if (currentToken) { history.replaceState({}, "", location.pathname + location.search); @@ -153,7 +153,7 @@ document.querySelector("#dropbox-add-btn")?.addEventListener( const rawDir = dirInput?.value?.trim() || "/"; const directoryPath = rawDir.startsWith("/") ? rawDir : "/" + rawDir; - const account = { accessToken: currentToken, directoryPath }; + const account = { refreshToken: currentToken, directoryPath }; const uri = buildURI(account); const now = new Date().toISOString(); diff --git a/src/facets/data/file-manager/index.inline.js b/src/facets/data/file-manager/index.inline.js index 846ad82b..b19749d8 100644 --- a/src/facets/data/file-manager/index.inline.js +++ b/src/facets/data/file-manager/index.inline.js @@ -322,7 +322,7 @@ stopSyncBtn?.addEventListener("click", () => stopSyncing()); //////////////////////////////////////////// // Detect the OAuth callback: if `?uploading=` is in the URL and -// `#access_token=...` is in the hash, we just returned from the OAuth +// `#refresh_token=...` is in the hash, we just returned from the OAuth // provider. Clean the URL (remove only the `uploading` param and the hash, // preserving any other query parameters the loader needs) and resume the // upload flow. @@ -332,16 +332,16 @@ stopSyncBtn?.addEventListener("click", () => stopSyncing()); if (uploadingScheme) { const hashParams = new URLSearchParams(url.hash.slice(1)); - const accessToken = hashParams.get("access_token"); + const refreshToken = hashParams.get("refresh_token"); // Clean URL: remove the `uploading` param and the hash, keep everything else. url.searchParams.delete("uploading"); url.hash = ""; history.replaceState({}, "", url); - if (accessToken) { + if (refreshToken) { // Don't await — let the page render while the upload runs. - resumeUpload(uploadingScheme, accessToken); + resumeUpload(uploadingScheme, refreshToken); } else { setError("Authorization failed. Please try again."); } @@ -786,15 +786,15 @@ async function startUpload(scheme, uploadElement) { } /** - * Resumes the sync flow after returning from the OAuth redirect. The access + * Resumes the sync flow after returning from the OAuth redirect. The refresh * token is in the URL hash; we use it to build a placeholder track (via the * upload component's `createSource`), upload files to that account, then save * the placeholder track so the matching input component lists the files. * * @param {string} scheme - * @param {string} accessToken + * @param {string} refreshToken */ -async function resumeUpload(scheme, accessToken) { +async function resumeUpload(scheme, refreshToken) { setError(null); uploadState.value = "uploading"; @@ -820,7 +820,7 @@ async function resumeUpload(scheme, accessToken) { // component by scheme. const placeholderTrack = await uploadConfigurator.createSource({ scheme, - accessToken, + refreshToken, directoryPath: UPLOAD_DIRECTORY, }); const uri = placeholderTrack.uri; diff --git a/src/oauth/callback/index.js b/src/oauth/callback/index.js index 959d1256..a0eec57b 100644 --- a/src/oauth/callback/index.js +++ b/src/oauth/callback/index.js @@ -1,5 +1,37 @@ +import { DEFAULT_APP_KEY, PKCE_VERIFIER_KEY } from "~/components/input/dropbox/constants.js"; +import { exchangeCode } from "~/components/input/dropbox/common.js"; + const prefix = "oauth/callback"; const redirect_path = localStorage.getItem(`${prefix}/redirect_path`) ?? "/"; localStorage.removeItem(`${prefix}/redirect_path`); -location.assign(`${redirect_path}${location.hash}`); + +// Dropbox uses the authorization-code flow with PKCE, so the `code` arrives +// as a query parameter (?code=...). Other providers (ATProto, Last.fm) use +// response_mode=fragment and arrive in the hash. We detect the Dropbox flow +// by the presence of a stored PKCE verifier and exchange the code here so +// the redirect target receives a `#refresh_token=...` hash — keeping the +// consuming facets simple. +const code = new URLSearchParams(location.search).get("code"); +const dropboxVerifier = localStorage.getItem(PKCE_VERIFIER_KEY); + +if (code && dropboxVerifier) { + localStorage.removeItem(PKCE_VERIFIER_KEY); + + const result = await exchangeCode( + code, + dropboxVerifier, + location.origin + "/oauth/callback/", + DEFAULT_APP_KEY, + ); + + if (result) { + const hashParams = new URLSearchParams(); + hashParams.set("refresh_token", result.refreshToken); + location.assign(`${redirect_path}#${hashParams.toString()}`); + } else { + location.assign(`${redirect_path}#error=dropbox_auth_failed`); + } +} else { + location.assign(`${redirect_path}${location.hash}`); +} diff --git a/tests/components/input/dropbox/integration.ts b/tests/components/input/dropbox/integration.ts index f5a2ecbb..c9bf7ee7 100644 --- a/tests/components/input/dropbox/integration.ts +++ b/tests/components/input/dropbox/integration.ts @@ -10,6 +10,11 @@ import { buildURI, parseURI } from "~/components/input/dropbox/common.js"; // The dropbox worker hardcodes `https://api.dropboxapi.com/...` URLs, so we // monkey-patch `globalThis.fetch` before each test to redirect those calls // to our in-process mock server. +// +// With the refresh-token flow, every API call first exchanges the refresh +// token for a short-lived access token via POST /oauth2/token. The mock +// server returns `access-token` for `valid-refresh-token` and 401 for +// everything else, then validates `Bearer access-token` on the API calls. const FILES = [ { ".tag": "file", name: "song1.mp3", path_lower: "/music/song1.mp3" }, @@ -26,10 +31,42 @@ beforeAll(async () => { const started = await mockServer((req, url) => { const p = url.pathname; + // POST /oauth2/token — exchange refresh token for access token + if (p === "/oauth2/token" && req.method === "POST") { + return req.text().then((text) => { + const params = new URLSearchParams(text); + const grantType = params.get("grant_type"); + const refreshToken = params.get("refresh_token") ?? params.get("code"); + + if (grantType === "refresh_token" && refreshToken === "valid-refresh-token") { + return new Response(JSON.stringify({ + access_token: "access-token", + expires_in: 14400, + token_type: "bearer", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + + // Code exchange (used by exchangeCode, not directly by the worker) + if (grantType === "authorization_code" && refreshToken === "valid-code") { + return new Response(JSON.stringify({ + access_token: "access-token", + refresh_token: "valid-refresh-token", + expires_in: 14400, + token_type: "bearer", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }); + } + // GET /2/users/get_current_account — validates the access token if (p === "/2/users/get_current_account") { const auth = req.headers.get("authorization"); - if (auth === "Bearer valid-token") { + if (auth === "Bearer access-token") { return new Response(JSON.stringify({ account_id: "dbid:123" }), { status: 200, headers: { "content-type": "application/json" }, @@ -41,7 +78,7 @@ beforeAll(async () => { // POST /2/files/list_folder — list files in a directory if (p === "/2/files/list_folder") { const auth = req.headers.get("authorization"); - if (auth !== "Bearer valid-token") { + if (auth !== "Bearer access-token") { return new Response("", { status: 401 }); } return new Response( @@ -65,7 +102,7 @@ beforeAll(async () => { // POST /2/files/get_temporary_link — get a temporary download link if (p === "/2/files/get_temporary_link") { const auth = req.headers.get("authorization"); - if (auth !== "Bearer valid-token") { + if (auth !== "Bearer access-token") { return new Response("", { status: 401 }); } return new Response( @@ -107,26 +144,26 @@ afterAll(async () => { }); describe("components/input/dropbox (integration)", () => { - it("consult returns true for a valid access token", async () => { - const uri = buildURI({ accessToken: "valid-token", directoryPath: "/" }, "/"); + it("consult returns true for a valid refresh token", async () => { + const uri = buildURI({ refreshToken: "valid-refresh-token", directoryPath: "/" }, "/"); const result = await Worker.consult(uri); expect(result.supported).toBe(true); if (result.supported) { - expect(result.consult).toBe(true); + expect(result.consult).toBe("yes"); } }); - it("consult returns false for an invalid access token", async () => { - const uri = buildURI({ accessToken: "invalid-token", directoryPath: "/" }, "/"); + it("consult returns false for an invalid refresh token", async () => { + const uri = buildURI({ refreshToken: "invalid-refresh-token", directoryPath: "/" }, "/"); const result = await Worker.consult(uri); expect(result.supported).toBe(true); if (result.supported) { - expect(result.consult).toBe(false); + expect(result.consult).toBe("no"); } }); it("list returns audio files from Dropbox", async () => { - const account = { accessToken: "valid-token", directoryPath: "/" }; + const account = { refreshToken: "valid-refresh-token", directoryPath: "/" }; const uri = buildURI(account, "/"); const tracks = await Worker.list([{ $type: "sh.diffuse.output.track", @@ -146,7 +183,7 @@ describe("components/input/dropbox (integration)", () => { }); it("list returns placeholder when API returns error", async () => { - const account = { accessToken: "bad-token", directoryPath: "/" }; + const account = { refreshToken: "bad-refresh-token", directoryPath: "/" }; const uri = buildURI(account, "/"); const tracks = await Worker.list([{ $type: "sh.diffuse.output.track", @@ -162,7 +199,7 @@ describe("components/input/dropbox (integration)", () => { it("resolve returns a temporary link URL", async () => { const uri = buildURI( - { accessToken: "valid-token", directoryPath: "/" }, + { refreshToken: "valid-refresh-token", directoryPath: "/" }, "/music/song1.mp3", ); const result = await Worker.resolve({ uri }); @@ -179,7 +216,7 @@ describe("components/input/dropbox (integration)", () => { it("resolve returns undefined for root path", async () => { const uri = buildURI( - { accessToken: "valid-token", directoryPath: "/" }, + { refreshToken: "valid-refresh-token", directoryPath: "/" }, "/", ); const result = await Worker.resolve({ uri }); @@ -188,30 +225,30 @@ describe("components/input/dropbox (integration)", () => { it("groupConsult reports available for a valid token", async () => { const uri = buildURI( - { accessToken: "valid-token", directoryPath: "/" }, + { refreshToken: "valid-refresh-token", directoryPath: "/" }, "/music/song1.mp3", ); const result = await Worker.groupConsult([uri]); const keys = Object.keys(result); expect(keys.length).toBe(1); - expect(result[keys[0]].available).toBe(true); + expect(result[keys[0]].available).toBe("yes"); }); it("groupConsult reports unavailable for an invalid token", async () => { const uri = buildURI( - { accessToken: "invalid-token", directoryPath: "/" }, + { refreshToken: "invalid-refresh-token", directoryPath: "/" }, "/music/song1.mp3", ); const result = await Worker.groupConsult([uri]); const keys = Object.keys(result); expect(keys.length).toBe(1); - expect(result[keys[0]].available).toBe(false); + expect(result[keys[0]].available).toBe("no"); }); it("detach with scheme removes all dropbox tracks", async () => { const tracks: Track[] = [ - { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ accessToken: "t1", directoryPath: "/" }, "/a.mp3") }, - { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ accessToken: "t1", directoryPath: "/" }, "/b.mp3") }, + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ refreshToken: "t1", directoryPath: "/" }, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ refreshToken: "t1", directoryPath: "/" }, "/b.mp3") }, ]; const remaining = await Worker.detach({ fileUriOrScheme: "dropbox", tracks }); expect(remaining.length).toBe(0); @@ -219,11 +256,11 @@ describe("components/input/dropbox (integration)", () => { it("detach with a specific account URI removes only that account's tracks", async () => { const tracks: Track[] = [ - { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ accessToken: "token-a", directoryPath: "/" }, "/a.mp3") }, - { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ accessToken: "token-b", directoryPath: "/" }, "/b.mp3") }, + { $type: "sh.diffuse.output.track", id: "1", uri: buildURI({ refreshToken: "token-a", directoryPath: "/" }, "/a.mp3") }, + { $type: "sh.diffuse.output.track", id: "2", uri: buildURI({ refreshToken: "token-b", directoryPath: "/" }, "/b.mp3") }, ]; const remaining = await Worker.detach({ - fileUriOrScheme: buildURI({ accessToken: "token-a", directoryPath: "/" }, "/a.mp3"), + fileUriOrScheme: buildURI({ refreshToken: "token-a", directoryPath: "/" }, "/a.mp3"), tracks, }); expect(remaining.length).toBe(1);