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",
+ },
+ },
+};