diff --git a/apps/gateway/src/apps.rs b/apps/gateway/src/apps.rs index 2028ce5..d7c3fcd 100644 --- a/apps/gateway/src/apps.rs +++ b/apps/gateway/src/apps.rs @@ -29,6 +29,17 @@ struct HostRule { strategy: AuthStrategy, } +/// How to send client credentials when refreshing an access token (RFC 6749 §2.3.1). +#[derive(Debug, Clone, Copy)] +pub(crate) enum ClientAuthMethod { + /// `client_id` and `client_secret` in the form body (`client_secret_post`). + /// Used by Google. + Body, + /// `Authorization: Basic base64(client_id:client_secret)` (`client_secret_basic`). + /// Used by Spotify. + Basic, +} + /// Configuration for refreshing expired OAuth tokens. pub(crate) struct RefreshConfig { /// Token endpoint URL (e.g., `https://oauth2.googleapis.com/token`). @@ -37,6 +48,8 @@ pub(crate) struct RefreshConfig { pub client_id_env: &'static str, /// Env var for the OAuth client secret. pub client_secret_env: &'static str, + /// How to transmit the client credentials to the token endpoint. + pub client_auth: ClientAuthMethod, } /// An app provider definition with its host rules. @@ -52,6 +65,16 @@ static GOOGLE_REFRESH: RefreshConfig = RefreshConfig { token_url: "https://oauth2.googleapis.com/token", client_id_env: "GOOGLE_CLIENT_ID", client_secret_env: "GOOGLE_CLIENT_SECRET", + client_auth: ClientAuthMethod::Body, +}; + +/// Refresh config for the Spotify Web API. +/// Spotify's `/api/token` endpoint requires HTTP Basic auth for client credentials. +static SPOTIFY_REFRESH: RefreshConfig = RefreshConfig { + token_url: "https://accounts.spotify.com/api/token", + client_id_env: "SPOTIFY_CLIENT_ID", + client_secret_env: "SPOTIFY_CLIENT_SECRET", + client_auth: ClientAuthMethod::Basic, }; // ── Provider registry ────────────────────────────────────────────────── @@ -254,6 +277,16 @@ static APP_PROVIDERS: &[AppProvider] = &[ }], refresh: None, }, + AppProvider { + provider: "spotify", + display_name: "Spotify", + host_rules: &[HostRule { + host: "api.spotify.com", + path_prefix: None, + strategy: AuthStrategy::Bearer, + }], + refresh: Some(&SPOTIFY_REFRESH), + }, ]; // ── Public API ───────────────────────────────────────────────────────── @@ -417,14 +450,21 @@ pub(crate) async fn refresh_access_token( .map_err(|_| anyhow::anyhow!("{} env var not set", config.client_secret_env))?, }; - let resp = reqwest::Client::new() - .post(config.token_url) - .form(&[ + let req = reqwest::Client::new().post(config.token_url); + let req = match config.client_auth { + ClientAuthMethod::Body => req.form(&[ ("client_id", client_id.as_str()), ("client_secret", client_secret.as_str()), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), - ]) + ]), + ClientAuthMethod::Basic => req.basic_auth(&client_id, Some(&client_secret)).form(&[ + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]), + }; + + let resp = req .send() .await .map_err(|e| anyhow::anyhow!("refresh request failed: {e}"))?; @@ -728,6 +768,39 @@ mod tests { ); } + // ── Spotify ─────────────────────────────────────────────────────── + + #[test] + fn providers_for_spotify_host() { + assert_eq!(providers_for_host("api.spotify.com"), vec!["spotify"]); + } + + #[test] + fn spotify_api_uses_bearer() { + let injections = build_app_injections("spotify", "api.spotify.com", "BQB_test"); + assert_eq!(injections.len(), 1); + assert_eq!( + injections[0], + Injection::SetHeader { + name: "authorization".to_string(), + value: "Bearer BQB_test".to_string(), + } + ); + } + + #[test] + fn spotify_refresh_uses_basic_auth() { + let cfg = refresh_config("spotify").expect("spotify has refresh config"); + assert!(matches!(cfg.client_auth, ClientAuthMethod::Basic)); + assert_eq!(cfg.token_url, "https://accounts.spotify.com/api/token"); + } + + #[test] + fn google_refresh_uses_body_auth() { + let cfg = refresh_config("google-drive").expect("google-drive has refresh config"); + assert!(matches!(cfg.client_auth, ClientAuthMethod::Body)); + } + // ── Edge cases ─────────────────────────────────────────────────── #[test] diff --git a/apps/web/public/icons/spotify.svg b/apps/web/public/icons/spotify.svg new file mode 100644 index 0000000..8909f71 --- /dev/null +++ b/apps/web/public/icons/spotify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/lib/apps/registry.ts b/apps/web/src/lib/apps/registry.ts index fa112fd..7b37c99 100644 --- a/apps/web/src/lib/apps/registry.ts +++ b/apps/web/src/lib/apps/registry.ts @@ -16,6 +16,7 @@ import { googleSheets } from "./google-sheets"; import { googleSlides } from "./google-slides"; import { googleTasks } from "./google-tasks"; import { resend } from "./resend"; +import { spotify } from "./spotify"; export const apps: AppDefinition[] = [ github, @@ -35,6 +36,7 @@ export const apps: AppDefinition[] = [ googleSlides, googleTasks, resend, + spotify, ]; export const getApp = (id: string): AppDefinition | undefined => diff --git a/apps/web/src/lib/apps/spotify.ts b/apps/web/src/lib/apps/spotify.ts new file mode 100644 index 0000000..43c338e --- /dev/null +++ b/apps/web/src/lib/apps/spotify.ts @@ -0,0 +1,168 @@ +import type { AppDefinition } from "./types"; + +export const spotify: AppDefinition = { + id: "spotify", + name: "Spotify", + icon: "/icons/spotify.svg", + description: "Access playlists, library, top tracks, and listening history.", + connectionMethod: { + type: "oauth", + defaultScopes: [ + "user-read-email", + "user-read-private", + "user-library-read", + "playlist-read-private", + "playlist-read-collaborative", + "user-top-read", + "user-read-recently-played", + ], + permissions: [ + { + scope: "user-read-email", + name: "Email address", + description: "View your email address", + access: "read", + }, + { + scope: "user-read-private", + name: "Profile", + description: "Name, country, and product tier", + access: "read", + }, + { + scope: "user-library-read", + name: "Library", + description: "Saved tracks, albums, and episodes", + access: "read", + }, + { + scope: "playlist-read-private", + name: "Private playlists", + description: "View your private playlists", + access: "read", + }, + { + scope: "playlist-read-collaborative", + name: "Collaborative playlists", + description: "View playlists you collaborate on", + access: "read", + }, + { + scope: "user-top-read", + name: "Top artists & tracks", + description: "View your top artists and tracks", + access: "read", + }, + { + scope: "user-read-recently-played", + name: "Listening history", + description: "View your recently played tracks", + access: "read", + }, + ], + buildAuthUrl: ({ clientId, redirectUri, scopes, state }) => { + const url = new URL("https://accounts.spotify.com/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("state", state); + return url.toString(); + }, + exchangeCode: async ({ code, clientId, clientSecret, redirectUri }) => { + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString( + "base64", + ); + const tokenRes = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basic}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + }), + }); + + if (!tokenRes.ok) { + throw new Error( + `Spotify token exchange failed: ${tokenRes.status} ${tokenRes.statusText}`, + ); + } + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; + error?: string; + error_description?: string; + }; + + if (tokenData.error || !tokenData.access_token) { + throw new Error( + tokenData.error_description ?? "Failed to exchange code for token", + ); + } + + const expiresAt = tokenData.expires_in + ? Math.floor(Date.now() / 1000) + tokenData.expires_in + : undefined; + + const credentials: Record = { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + token_type: tokenData.token_type, + expires_at: expiresAt, + }; + + // Spotify returns scopes space-separated. + const scopes = tokenData.scope?.split(" ").filter(Boolean) ?? []; + + let metadata: Record | undefined; + const userRes = await fetch("https://api.spotify.com/v1/me", { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + + if (userRes.ok) { + const user = (await userRes.json()) as { + id?: string; + email?: string; + display_name?: string; + images?: { url?: string }[]; + }; + metadata = { + username: user.id, + email: user.email, + name: user.display_name, + avatarUrl: user.images?.[0]?.url, + }; + } + + return { credentials, scopes, metadata }; + }, + }, + available: true, + configurable: { + fields: [ + { + name: "clientId", + label: "Client ID", + placeholder: "e.g. 1a2b3c4d5e6f7g8h9i0j", + }, + { + name: "clientSecret", + label: "Client Secret", + placeholder: "Spotify app client secret", + secret: true, + }, + ], + envDefaults: { + clientId: "SPOTIFY_CLIENT_ID", + clientSecret: "SPOTIFY_CLIENT_SECRET", + }, + }, +};