Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 77 additions & 4 deletions apps/gateway/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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.
Expand All @@ -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 ──────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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}"))?;
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/icons/spotify.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/web/src/lib/apps/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,6 +36,7 @@ export const apps: AppDefinition[] = [
googleSlides,
googleTasks,
resend,
spotify,
];

export const getApp = (id: string): AppDefinition | undefined =>
Expand Down
168 changes: 168 additions & 0 deletions apps/web/src/lib/apps/spotify.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<string, unknown> | 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",
},
},
};
Loading