Add Apple Music, Last.fm sign-in, and a fallback for Spotify's account cap
- Apple Music: sign MusicKit developer tokens (ES256 via node:crypto),
accept the Music User Token from the iOS app, and poll recently played.
Apple has no now-playing API or play timestamps, so a new top song is
shown as playing for its length; connecting records a baseline so an
old song isn't announced as current
- Last.fm web auth ("Sign in with Last.fm") instead of typing a
username; session key stored encrypted
- Spotify accounts rejected by the 5-user development cap are sent back
with error=spotify_limited instead of a 500, so apps can offer
Spotify via Last.fm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -15,9 +15,16 @@ SPOTIFY_CLIENT_ID=
|
|||||||
SPOTIFY_CLIENT_SECRET=
|
SPOTIFY_CLIENT_SECRET=
|
||||||
# https://www.last.fm/api/account/create
|
# https://www.last.fm/api/account/create
|
||||||
LASTFM_API_KEY=
|
LASTFM_API_KEY=
|
||||||
|
# Shared secret from the same Last.fm API account; enables one-tap "Sign in with Last.fm"
|
||||||
|
LASTFM_SHARED_SECRET=
|
||||||
# song.link API key (request at https://odesli.co). Enables YouTube Music/TIDAL links; without it those fall back to search.
|
# song.link API key (request at https://odesli.co). Enables YouTube Music/TIDAL links; without it those fall back to search.
|
||||||
ODESLI_API_KEY=
|
ODESLI_API_KEY=
|
||||||
# Stable identifier for this app in Plex's auth flow
|
# Stable identifier for this app in Plex's auth flow
|
||||||
PLEX_CLIENT_ID=musicshare-dev
|
PLEX_CLIENT_ID=musicshare-dev
|
||||||
# Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment.
|
# Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment.
|
||||||
ALLOW_PRIVATE_SERVER_URLS=false
|
ALLOW_PRIVATE_SERVER_URLS=false
|
||||||
|
# Apple Music: Apple Developer → Certificates, IDs & Profiles → Keys → new key with MusicKit enabled.
|
||||||
|
# Paste the .p8 contents with newlines as \n.
|
||||||
|
APPLE_TEAM_ID=
|
||||||
|
APPLE_MUSICKIT_KEY_ID=
|
||||||
|
APPLE_MUSICKIT_PRIVATE_KEY=
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ const schema = z.object({
|
|||||||
SPOTIFY_CLIENT_ID: optional,
|
SPOTIFY_CLIENT_ID: optional,
|
||||||
SPOTIFY_CLIENT_SECRET: optional,
|
SPOTIFY_CLIENT_SECRET: optional,
|
||||||
LASTFM_API_KEY: optional,
|
LASTFM_API_KEY: optional,
|
||||||
|
/** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */
|
||||||
|
LASTFM_SHARED_SECRET: optional,
|
||||||
ODESLI_API_KEY: optional,
|
ODESLI_API_KEY: optional,
|
||||||
|
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
|
||||||
|
APPLE_TEAM_ID: optional,
|
||||||
|
APPLE_MUSICKIT_KEY_ID: optional,
|
||||||
|
APPLE_MUSICKIT_PRIVATE_KEY: optional,
|
||||||
/** Identifies this app to Plex. Keep it stable: changing it invalidates users' Plex authorizations. */
|
/** Identifies this app to Plex. Keep it stable: changing it invalidates users' Plex authorizations. */
|
||||||
PLEX_CLIENT_ID: z.string().default('musicshare-dev'),
|
PLEX_CLIENT_ID: z.string().default('musicshare-dev'),
|
||||||
/**
|
/**
|
||||||
@@ -40,3 +46,4 @@ const schema = z.object({
|
|||||||
export const env = schema.parse(process.env);
|
export const env = schema.parse(process.env);
|
||||||
|
|
||||||
export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET);
|
export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET);
|
||||||
|
export const appleMusicConfigured = Boolean(env.APPLE_TEAM_ID && env.APPLE_MUSICKIT_KEY_ID && env.APPLE_MUSICKIT_PRIVATE_KEY);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Apple Music API access. Requests need two tokens: a developer token (a JWT
|
||||||
|
* we sign with the MusicKit key) and the user's Music User Token, which the
|
||||||
|
* iOS app obtains from the system "Allow access to Apple Music" prompt.
|
||||||
|
*/
|
||||||
|
import { createPrivateKey, sign } from 'node:crypto';
|
||||||
|
import { env } from '../env';
|
||||||
|
import { HttpError } from './http';
|
||||||
|
|
||||||
|
const API = 'https://api.music.apple.com/v1';
|
||||||
|
/** Apple allows up to ~6 months; shorter limits the damage if a token leaks. */
|
||||||
|
const TOKEN_TTL_SECONDS = 30 * 86_400;
|
||||||
|
|
||||||
|
let cached: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
const b64url = (input: Buffer | string) => Buffer.from(input).toString('base64url');
|
||||||
|
|
||||||
|
/** Signed ES256 developer token, cached until a day before it expires. */
|
||||||
|
export function appleDeveloperToken(now = Date.now()): string {
|
||||||
|
if (cached && cached.expiresAt - now > 86_400_000) return cached.token;
|
||||||
|
if (!env.APPLE_TEAM_ID || !env.APPLE_MUSICKIT_KEY_ID || !env.APPLE_MUSICKIT_PRIVATE_KEY) {
|
||||||
|
throw new HttpError(501, 'Apple Music is not configured on this server');
|
||||||
|
}
|
||||||
|
const iat = Math.floor(now / 1000);
|
||||||
|
const header = b64url(JSON.stringify({ alg: 'ES256', kid: env.APPLE_MUSICKIT_KEY_ID }));
|
||||||
|
const payload = b64url(JSON.stringify({ iss: env.APPLE_TEAM_ID, iat, exp: iat + TOKEN_TTL_SECONDS }));
|
||||||
|
const key = createPrivateKey(env.APPLE_MUSICKIT_PRIVATE_KEY.replace(/\\n/g, '\n'));
|
||||||
|
// JWS wants the raw r||s signature, not DER.
|
||||||
|
const signature = sign('sha256', Buffer.from(`${header}.${payload}`), { key, dsaEncoding: 'ieee-p1363' });
|
||||||
|
const token = `${header}.${payload}.${b64url(signature)}`;
|
||||||
|
cached = { token, expiresAt: (iat + TOKEN_TTL_SECONDS) * 1000 };
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when Apple rejects the Music User Token (expired after ~6 months, or access revoked). */
|
||||||
|
export class AppleUserTokenError extends Error {}
|
||||||
|
|
||||||
|
export async function appleMusicGet<T>(path: string, musicUserToken: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${appleDeveloperToken()}`, 'Music-User-Token': musicUserToken },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) throw new AppleUserTokenError(`Apple Music ${res.status}`);
|
||||||
|
if (!res.ok) throw new HttpError(res.status, `Apple Music ${res.status}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleSong {
|
||||||
|
id: string;
|
||||||
|
type: string; // songs | library-songs | music-videos ...
|
||||||
|
attributes?: {
|
||||||
|
name: string;
|
||||||
|
artistName: string;
|
||||||
|
albumName?: string;
|
||||||
|
durationInMillis?: number;
|
||||||
|
isrc?: string;
|
||||||
|
url?: string;
|
||||||
|
artwork?: { url: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recentlyPlayed = (musicUserToken: string, limit = 1) =>
|
||||||
|
appleMusicGet<{ data: AppleSong[] }>(`/me/recent/played/tracks?limit=${limit}`, musicUserToken);
|
||||||
|
|
||||||
|
export const storefront = (musicUserToken: string) =>
|
||||||
|
appleMusicGet<{ data: { id: string; attributes?: { name: string } }[] }>('/me/storefront', musicUserToken);
|
||||||
|
|
||||||
|
/** Test hook: forget the cached developer token. */
|
||||||
|
export const resetAppleTokenCache = () => {
|
||||||
|
cached = null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Apple Music has no "now playing" endpoint, only recently played (no
|
||||||
|
* timestamps). We remember the newest item; when it changes, that song is
|
||||||
|
* shown as "listening now" for its duration. The connection starts from a
|
||||||
|
* baseline so an old last-played song isn't announced as current.
|
||||||
|
* The iOS app adds exact live updates while it's open (device reports).
|
||||||
|
*/
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { Connection } from '../db/schema';
|
||||||
|
import { AppleUserTokenError, recentlyPlayed, type AppleSong } from '../lib/appleMusic';
|
||||||
|
import { decrypt } from '../lib/crypto';
|
||||||
|
import { ProviderAuthError, type IncomingTrack, type PollingProvider } from './types';
|
||||||
|
|
||||||
|
export interface AppleMusicMeta {
|
||||||
|
storefront?: string;
|
||||||
|
lastSeenId?: string | null;
|
||||||
|
lastSeenAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without a duration, assume a typical song length. */
|
||||||
|
const DEFAULT_DURATION_MS = 4 * 60_000;
|
||||||
|
const GRACE_MS = 30_000;
|
||||||
|
|
||||||
|
export function toIncomingTrack(song: AppleSong): IncomingTrack | null {
|
||||||
|
const a = song.attributes;
|
||||||
|
if (!a?.name) return null;
|
||||||
|
const isCatalog = song.type === 'songs';
|
||||||
|
return {
|
||||||
|
title: a.name,
|
||||||
|
artist: a.artistName || 'Unknown artist',
|
||||||
|
album: a.albumName ?? null,
|
||||||
|
durationMs: a.durationInMillis ?? null,
|
||||||
|
isrc: a.isrc ?? null,
|
||||||
|
artworkUrl: a.artwork?.url.replace('{w}', '600').replace('{h}', '600') ?? null,
|
||||||
|
// Library uploads have no catalog page to link to.
|
||||||
|
links: isCatalog && a.url ? { apple_music: { id: song.id, url: a.url } } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appleMusicProvider: PollingProvider = {
|
||||||
|
id: 'apple_music',
|
||||||
|
async poll(conn: Connection) {
|
||||||
|
const meta = conn.meta as AppleMusicMeta;
|
||||||
|
let latest: AppleSong | undefined;
|
||||||
|
try {
|
||||||
|
latest = (await recentlyPlayed(decrypt(conn.accessTokenEnc!))).data[0];
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppleUserTokenError) throw new ProviderAuthError('Apple Music access expired or was revoked');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!latest) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (latest.id !== meta.lastSeenId) {
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({ meta: { ...meta, lastSeenId: latest.id, lastSeenAt: now } })
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
const track = toIncomingTrack(latest);
|
||||||
|
return track ? { track, isPlaying: true, progressMs: null } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same song as last time: keep it "playing" until it would have finished.
|
||||||
|
const track = toIncomingTrack(latest);
|
||||||
|
const duration = latest.attributes?.durationInMillis ?? DEFAULT_DURATION_MS;
|
||||||
|
if (!track || !meta.lastSeenAt || now - meta.lastSeenAt > duration + GRACE_MS) return null;
|
||||||
|
return { track, isPlaying: true, progressMs: now - meta.lastSeenAt };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NowPlayingProvider } from '@musicshare/shared';
|
import type { NowPlayingProvider } from '@musicshare/shared';
|
||||||
|
import { appleMusicProvider } from './appleMusic';
|
||||||
import { jellyfinProvider } from './jellyfin';
|
import { jellyfinProvider } from './jellyfin';
|
||||||
import { lastfmProvider } from './lastfm';
|
import { lastfmProvider } from './lastfm';
|
||||||
import { listenbrainzProvider } from './listenbrainz';
|
import { listenbrainzProvider } from './listenbrainz';
|
||||||
@@ -13,6 +14,7 @@ export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvide
|
|||||||
listenbrainz: listenbrainzProvider,
|
listenbrainz: listenbrainzProvider,
|
||||||
plex: plexProvider,
|
plex: plexProvider,
|
||||||
jellyfin: jellyfinProvider,
|
jellyfin: jellyfinProvider,
|
||||||
|
apple_music: appleMusicProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POLLED_PROVIDERS = Object.keys(pollingProviders) as NowPlayingProvider[];
|
export const POLLED_PROVIDERS = Object.keys(pollingProviders) as NowPlayingProvider[];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import { HttpError, fetchJson } from '../lib/http';
|
import { HttpError, fetchJson } from '../lib/http';
|
||||||
import { ProviderAuthError, type PollingProvider } from './types';
|
import { ProviderAuthError, type PollingProvider } from './types';
|
||||||
@@ -13,6 +14,28 @@ interface LastfmTrack {
|
|||||||
const api = (params: Record<string, string>) =>
|
const api = (params: Record<string, string>) =>
|
||||||
`https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_key: env.LASTFM_API_KEY!, format: 'json' })}`;
|
`https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_key: env.LASTFM_API_KEY!, format: 'json' })}`;
|
||||||
|
|
||||||
|
/** Last.fm web auth: send the user here; Last.fm redirects back to `callbackUrl` with ?token=…/&token=… */
|
||||||
|
export const lastfmAuthUrl = (callbackUrl: string) =>
|
||||||
|
`https://www.last.fm/api/auth/?${new URLSearchParams({ api_key: env.LASTFM_API_KEY!, cb: callbackUrl })}`;
|
||||||
|
|
||||||
|
/** Signature rules: params sorted by name, concatenated as name+value, shared secret appended, md5. */
|
||||||
|
export function lastfmSignature(params: Record<string, string>, secret = env.LASTFM_SHARED_SECRET!): string {
|
||||||
|
const base = Object.keys(params)
|
||||||
|
.sort()
|
||||||
|
.map((k) => k + params[k])
|
||||||
|
.join('');
|
||||||
|
return createHash('md5').update(base + secret, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exchange the callback token for a session (username + session key). */
|
||||||
|
export async function lastfmSession(token: string) {
|
||||||
|
const params = { api_key: env.LASTFM_API_KEY!, method: 'auth.getSession', token };
|
||||||
|
const url = `https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_sig: lastfmSignature(params), format: 'json' })}`;
|
||||||
|
const res = await fetchJson<{ session?: { name: string; key: string } }>(url);
|
||||||
|
if (!res?.session) throw new ProviderAuthError('Last.fm sign-in failed');
|
||||||
|
return res.session;
|
||||||
|
}
|
||||||
|
|
||||||
export async function lastfmUserExists(username: string): Promise<{ name: string } | null> {
|
export async function lastfmUserExists(username: string): Promise<{ name: string } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username }));
|
const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username }));
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import { z } from 'zod';
|
|||||||
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
|
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
|
||||||
import { db, schema } from '../db';
|
import { db, schema } from '../db';
|
||||||
import type { User } from '../db/schema';
|
import type { User } from '../db/schema';
|
||||||
import { env, spotifyConfigured } from '../env';
|
import { appleMusicConfigured, env, spotifyConfigured } from '../env';
|
||||||
import { requireUser } from '../lib/auth';
|
import { requireUser } from '../lib/auth';
|
||||||
import { encrypt, randomToken } from '../lib/crypto';
|
import { encrypt, randomToken } from '../lib/crypto';
|
||||||
import { entitlementsFor } from '../lib/entitlements';
|
import { entitlementsFor } from '../lib/entitlements';
|
||||||
import { HttpError } from '../lib/http';
|
import { HttpError } from '../lib/http';
|
||||||
import { redis } from '../lib/redis';
|
import { redis } from '../lib/redis';
|
||||||
|
import { AppleUserTokenError, appleDeveloperToken, recentlyPlayed, storefront } from '../lib/appleMusic';
|
||||||
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
|
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
|
||||||
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
|
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
|
||||||
import { lastfmUserExists } from '../providers/lastfm';
|
import { lastfmAuthUrl, lastfmSession, lastfmUserExists } from '../providers/lastfm';
|
||||||
|
import { ProviderAuthError } from '../providers/types';
|
||||||
import { listenbrainzUserExists } from '../providers/listenbrainz';
|
import { listenbrainzUserExists } from '../providers/listenbrainz';
|
||||||
import * as plex from '../providers/plex';
|
import * as plex from '../providers/plex';
|
||||||
import * as spotify from '../providers/spotify';
|
import * as spotify from '../providers/spotify';
|
||||||
@@ -80,7 +82,17 @@ export async function connectionRoutes(app: FastifyInstance) {
|
|||||||
return reply.redirect(returnTo.toString());
|
return reply.redirect(returnTo.toString());
|
||||||
}
|
}
|
||||||
const tok = await spotify.exchangeCode(req.query.code);
|
const tok = await spotify.exchangeCode(req.query.code);
|
||||||
const profile = await spotify.getProfile(tok.access_token);
|
let profile;
|
||||||
|
try {
|
||||||
|
profile = await spotify.getProfile(tok.access_token);
|
||||||
|
} catch (err) {
|
||||||
|
// Development-mode apps reject accounts not on the allowlist with 403.
|
||||||
|
if (err instanceof HttpError && err.status === 403) {
|
||||||
|
returnTo.searchParams.set('error', 'spotify_limited');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
await upsertConnection({
|
await upsertConnection({
|
||||||
userId,
|
userId,
|
||||||
provider: 'spotify',
|
provider: 'spotify',
|
||||||
@@ -169,6 +181,85 @@ export async function connectionRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Apple Music (MusicKit on iOS) ----
|
||||||
|
|
||||||
|
/** The iOS app needs a developer token to ask the system for the user's Music User Token. */
|
||||||
|
app.get('/connections/apple-music/developer-token', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
|
||||||
|
requireUser(req);
|
||||||
|
if (!appleMusicConfigured) throw new HttpError(501, 'Apple Music is not configured on this server');
|
||||||
|
return { token: appleDeveloperToken() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/connections/apple-music', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
if (!appleMusicConfigured) throw new HttpError(501, 'Apple Music is not configured on this server');
|
||||||
|
await assertCanConnect(user, 'apple_music');
|
||||||
|
const { musicUserToken } = z.object({ musicUserToken: z.string().min(20).max(4096) }).parse(req.body);
|
||||||
|
|
||||||
|
let region: { id: string; attributes?: { name: string } } | undefined;
|
||||||
|
let latest: string | null = null;
|
||||||
|
try {
|
||||||
|
region = (await storefront(musicUserToken)).data[0];
|
||||||
|
// Baseline: whatever was played last is history, not "listening now".
|
||||||
|
latest = (await recentlyPlayed(musicUserToken)).data[0]?.id ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppleUserTokenError) throw new HttpError(401, 'Apple Music rejected the authorization. Try again.');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return toConnectionDTO(
|
||||||
|
await upsertConnection({
|
||||||
|
userId: user.id,
|
||||||
|
provider: 'apple_music',
|
||||||
|
externalUserId: 'me',
|
||||||
|
displayName: region?.attributes?.name ? `Apple Music (${region.attributes.name})` : 'Apple Music',
|
||||||
|
accessTokenEnc: encrypt(musicUserToken),
|
||||||
|
// lastSeenAt 0: the baseline song is history, so it never counts as "listening now".
|
||||||
|
meta: { storefront: region?.id, lastSeenId: latest, lastSeenAt: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Last.fm (web auth): also the scalable route for Spotify, via Spotify's Last.fm link ----
|
||||||
|
|
||||||
|
app.post('/connections/lastfm/authorize', async (req) => {
|
||||||
|
const user = requireUser(req);
|
||||||
|
if (!env.LASTFM_API_KEY || !env.LASTFM_SHARED_SECRET) throw new HttpError(501, 'Last.fm sign-in is not configured on this server');
|
||||||
|
await assertCanConnect(user, 'lastfm');
|
||||||
|
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
|
||||||
|
const state = randomToken(24);
|
||||||
|
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 900);
|
||||||
|
return { url: lastfmAuthUrl(`${env.API_PUBLIC_URL}/connections/lastfm/callback?state=${state}`) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { state?: string; token?: string } }>('/connections/lastfm/callback', async (req, reply) => {
|
||||||
|
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
|
||||||
|
if (!raw) throw new HttpError(400, 'Invalid or expired Last.fm sign-in');
|
||||||
|
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
|
||||||
|
const returnTo = new URL(state.returnTo);
|
||||||
|
if (!req.query.token) {
|
||||||
|
returnTo.searchParams.set('error', 'Last.fm sign-in was cancelled');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
}
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await lastfmSession(req.query.token);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ProviderAuthError) && !(err instanceof HttpError)) throw err;
|
||||||
|
returnTo.searchParams.set('error', 'Last.fm sign-in failed');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
}
|
||||||
|
await upsertConnection({
|
||||||
|
userId,
|
||||||
|
provider: 'lastfm',
|
||||||
|
externalUserId: session.name,
|
||||||
|
displayName: session.name,
|
||||||
|
// Not needed for public listening history; kept for private profiles and future features.
|
||||||
|
accessTokenEnc: encrypt(session.key),
|
||||||
|
});
|
||||||
|
returnTo.searchParams.set('connected', 'lastfm');
|
||||||
|
return reply.redirect(returnTo.toString());
|
||||||
|
});
|
||||||
|
|
||||||
// ---- username-based scrobblers ----
|
// ---- username-based scrobblers ----
|
||||||
|
|
||||||
app.post('/connections/lastfm', async (req) => {
|
app.post('/connections/lastfm', async (req) => {
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { createPublicKey, verify } from 'node:crypto';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { db, schema } from '../src/db';
|
||||||
|
import { appleDeveloperToken, resetAppleTokenCache } from '../src/lib/appleMusic';
|
||||||
|
import { appleMusicProvider } from '../src/providers/appleMusic';
|
||||||
|
import { ProviderAuthError } from '../src/providers/types';
|
||||||
|
import { bearer, createUser, json, routeFetch, useTestApp } from './helpers';
|
||||||
|
|
||||||
|
const app = useTestApp();
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const song = (id: string, name: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
id,
|
||||||
|
type: 'songs',
|
||||||
|
attributes: {
|
||||||
|
name,
|
||||||
|
artistName: 'Joy Division',
|
||||||
|
albumName: 'Closer',
|
||||||
|
durationInMillis: 240_000,
|
||||||
|
isrc: 'GBAAA8000001',
|
||||||
|
url: `https://music.apple.com/us/album/closer/1?i=${id}`,
|
||||||
|
artwork: { url: 'https://is1-ssl.mzstatic.com/image/thumb/x/{w}x{h}bb.jpg' },
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubApple(recent: () => unknown[], status = 200) {
|
||||||
|
const calls: { url: string; headers: Record<string, string> }[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([
|
||||||
|
[
|
||||||
|
/api\.music\.apple\.com\/v1\/me\/storefront/,
|
||||||
|
(url, init) => (calls.push({ url, headers: init?.headers as Record<string, string> }), json({ data: [{ id: 'us', attributes: { name: 'United States' } }] }, status)),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/api\.music\.apple\.com\/v1\/me\/recent\/played\/tracks/,
|
||||||
|
(url, init) => (calls.push({ url, headers: init?.headers as Record<string, string> }), json({ data: recent() }, status)),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(token: string) {
|
||||||
|
return app().inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/connections/apple-music',
|
||||||
|
headers: bearer(token),
|
||||||
|
payload: { musicUserToken: 'music-user-token-from-ios-0123456789' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Apple Music developer token', () => {
|
||||||
|
it('is an ES256 JWT signed with the MusicKit key', () => {
|
||||||
|
resetAppleTokenCache();
|
||||||
|
const [header, payload, signature] = appleDeveloperToken().split('.');
|
||||||
|
expect(JSON.parse(Buffer.from(header!, 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEYID12345' });
|
||||||
|
const claims = JSON.parse(Buffer.from(payload!, 'base64url').toString());
|
||||||
|
expect(claims.iss).toBe('TEAMID1234');
|
||||||
|
expect(claims.exp - claims.iat).toBeLessThanOrEqual(15_777_000); // Apple's 6-month maximum
|
||||||
|
const ok = verify(
|
||||||
|
'sha256',
|
||||||
|
Buffer.from(`${header}.${payload}`),
|
||||||
|
{ key: createPublicKey(process.env.APPLE_TEST_PUBLIC_KEY!), dsaEncoding: 'ieee-p1363' },
|
||||||
|
Buffer.from(signature!, 'base64url'),
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is cached rather than re-signed on every request', () => {
|
||||||
|
resetAppleTokenCache();
|
||||||
|
expect(appleDeveloperToken()).toBe(appleDeveloperToken());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is only handed to signed-in users', async () => {
|
||||||
|
expect((await app().inject({ url: '/connections/apple-music/developer-token' })).statusCode).toBe(401);
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
const res = await app().inject({ url: '/connections/apple-music/developer-token', headers: bearer(token) });
|
||||||
|
expect(res.json().token.split('.')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connecting Apple Music', () => {
|
||||||
|
it('validates the token, stores it encrypted, and baselines the last played song', async () => {
|
||||||
|
const calls = stubApple(() => [song('100', 'Atmosphere')]);
|
||||||
|
const { token, user } = await createUser(app(), 'alice');
|
||||||
|
const res = await connect(token);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ provider: 'apple_music', displayName: 'Apple Music (United States)', status: 'active' });
|
||||||
|
expect(calls[0]!.headers['Music-User-Token']).toBe('music-user-token-from-ios-0123456789');
|
||||||
|
expect(calls[0]!.headers.Authorization).toMatch(/^Bearer ey/);
|
||||||
|
|
||||||
|
const [row] = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id));
|
||||||
|
expect(row!.accessTokenEnc).not.toContain('music-user-token');
|
||||||
|
expect(row!.meta).toMatchObject({ storefront: 'us', lastSeenId: '100' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token Apple refuses', async () => {
|
||||||
|
stubApple(() => [], 401);
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
expect((await connect(token)).statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Apple Music polling', () => {
|
||||||
|
async function connected(recent: { current: object[] }) {
|
||||||
|
stubApple(() => recent.current);
|
||||||
|
const { token, user } = await createUser(app(), 'alice');
|
||||||
|
await connect(token);
|
||||||
|
const load = async () => (await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id)))[0]!;
|
||||||
|
return { load, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("doesn't announce the song that was already last-played when connecting", async () => {
|
||||||
|
const recent: { current: object[] } = { current: [song('100', 'Atmosphere')] };
|
||||||
|
const { load } = await connected(recent);
|
||||||
|
expect(await appleMusicProvider.poll(await load())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a newly played song with its Apple Music link, ISRC and artwork', async () => {
|
||||||
|
const recent: { current: object[] } = { current: [song('100', 'Atmosphere')] };
|
||||||
|
const { load } = await connected(recent);
|
||||||
|
recent.current = [song('200', 'Decades')];
|
||||||
|
const result = await appleMusicProvider.poll(await load());
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
isPlaying: true,
|
||||||
|
track: {
|
||||||
|
title: 'Decades',
|
||||||
|
artist: 'Joy Division',
|
||||||
|
isrc: 'GBAAA8000001',
|
||||||
|
durationMs: 240_000,
|
||||||
|
artworkUrl: 'https://is1-ssl.mzstatic.com/image/thumb/x/600x600bb.jpg',
|
||||||
|
links: { apple_music: { id: '200', url: 'https://music.apple.com/us/album/closer/1?i=200' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps it playing for the song length, then stops', async () => {
|
||||||
|
const recent: { current: object[] } = { current: [song('100', 'Atmosphere')] };
|
||||||
|
const { load } = await connected(recent);
|
||||||
|
recent.current = [song('200', 'Decades')];
|
||||||
|
await appleMusicProvider.poll(await load());
|
||||||
|
expect((await appleMusicProvider.poll(await load()))?.isPlaying).toBe(true);
|
||||||
|
|
||||||
|
const conn = await load();
|
||||||
|
await db
|
||||||
|
.update(schema.connections)
|
||||||
|
.set({ meta: { ...(conn.meta as object), lastSeenAt: Date.now() - 240_000 - 31_000 } })
|
||||||
|
.where(eq(schema.connections.id, conn.id));
|
||||||
|
expect(await appleMusicProvider.poll(await load())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('library uploads play without a store link', async () => {
|
||||||
|
const recent: { current: object[] } = { current: [song('100', 'Atmosphere')] };
|
||||||
|
const { load } = await connected(recent);
|
||||||
|
recent.current = [{ id: 'i.abc', type: 'library-songs', attributes: { name: 'Home Demo', artistName: 'Me' } }];
|
||||||
|
const result = await appleMusicProvider.poll(await load());
|
||||||
|
expect(result?.track).toMatchObject({ title: 'Home Demo', links: undefined, isrc: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an expired Music User Token as needing reconnect', async () => {
|
||||||
|
const recent: { current: object[] } = { current: [song('100', 'Atmosphere')] };
|
||||||
|
const { load } = await connected(recent);
|
||||||
|
stubApple(() => [], 403);
|
||||||
|
await expect(appleMusicProvider.poll(await load())).rejects.toBeInstanceOf(ProviderAuthError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { db, schema } from '../src/db';
|
||||||
|
import { decrypt } from '../src/lib/crypto';
|
||||||
|
import { lastfmSignature } from '../src/providers/lastfm';
|
||||||
|
import { bearer, createUser, json, routeFetch, useTestApp } from './helpers';
|
||||||
|
|
||||||
|
const app = useTestApp();
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const stateFrom = (url: string) => decodeURIComponent(url).match(/state=([^&]+)/)![1]!;
|
||||||
|
|
||||||
|
describe('Last.fm sign-in', () => {
|
||||||
|
it('signs requests per the Last.fm rules: sorted name+value pairs, secret appended, md5', () => {
|
||||||
|
const expected = createHash('md5').update('api_keykmethodmtokent' + 's').digest('hex');
|
||||||
|
expect(lastfmSignature({ token: 't', method: 'm', api_key: 'k' }, 's')).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the web auth flow and stores the username and session key', async () => {
|
||||||
|
let sessionUrl = '';
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([[/auth\.getSession/, (url) => ((sessionUrl = url), json({ session: { name: 'RJ', key: 'sk-123', subscriber: 0 } }))]]),
|
||||||
|
);
|
||||||
|
const { token, user } = await createUser(app(), 'alice');
|
||||||
|
const start = await app().inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/connections/lastfm/authorize',
|
||||||
|
headers: bearer(token),
|
||||||
|
payload: { client: 'mobile', returnTo: 'musicshare://settings' },
|
||||||
|
});
|
||||||
|
const authUrl = new URL(start.json().url);
|
||||||
|
expect(authUrl.host).toBe('www.last.fm');
|
||||||
|
expect(authUrl.searchParams.get('api_key')).toBe('test-lastfm-key');
|
||||||
|
const cb = new URL(authUrl.searchParams.get('cb')!);
|
||||||
|
expect(cb.href).toMatch(/^https:\/\/api\.test\/connections\/lastfm\/callback\?state=/);
|
||||||
|
|
||||||
|
const done = await app().inject({ url: `/connections/lastfm/callback?state=${stateFrom(cb.href)}&token=lfm-token` });
|
||||||
|
expect(done.statusCode).toBe(302);
|
||||||
|
expect(done.headers.location).toBe('musicshare://settings?connected=lastfm');
|
||||||
|
|
||||||
|
const params = new URL(sessionUrl).searchParams;
|
||||||
|
expect(params.get('api_sig')).toBe(
|
||||||
|
lastfmSignature({ api_key: 'test-lastfm-key', method: 'auth.getSession', token: 'lfm-token' }, 'test-lastfm-secret'),
|
||||||
|
);
|
||||||
|
const [row] = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id));
|
||||||
|
expect(row).toMatchObject({ provider: 'lastfm', externalUserId: 'RJ' });
|
||||||
|
expect(decrypt(row!.accessTokenEnc!)).toBe('sk-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the app with an error if the user cancels', async () => {
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
const start = await app().inject({ method: 'POST', url: '/connections/lastfm/authorize', headers: bearer(token), payload: { client: 'web' } });
|
||||||
|
const state = stateFrom(new URL(start.json().url).searchParams.get('cb')!);
|
||||||
|
const done = await app().inject({ url: `/connections/lastfm/callback?state=${state}` });
|
||||||
|
expect(done.headers.location).toMatch(/^http:\/\/web\.test\/settings\?error=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects replayed or forged states', async () => {
|
||||||
|
expect((await app().inject({ url: '/connections/lastfm/callback?state=forged&token=x' })).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Spotify sign-in during the limited beta', () => {
|
||||||
|
it('sends users who are not on the allowlist back with a friendly error instead of failing', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([
|
||||||
|
[/accounts\.spotify\.com\/api\/token/, () => json({ access_token: 'a', refresh_token: 'r', expires_in: 3600 })],
|
||||||
|
[/api\.spotify\.com\/v1\/me$/, () => json({ error: { status: 403, message: 'User not registered in the Developer Dashboard' } }, 403)],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const { token } = await createUser(app(), 'alice');
|
||||||
|
const start = await app().inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/connections/spotify/authorize',
|
||||||
|
headers: bearer(token),
|
||||||
|
payload: { client: 'mobile', returnTo: 'musicshare://settings' },
|
||||||
|
});
|
||||||
|
const state = new URL(start.json().url).searchParams.get('state');
|
||||||
|
const done = await app().inject({ url: `/connections/spotify/callback?state=${state}&code=abc` });
|
||||||
|
expect(done.statusCode).toBe(302);
|
||||||
|
expect(done.headers.location).toBe('musicshare://settings?error=spotify_limited');
|
||||||
|
expect(await db.select().from(schema.connections)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { generateKeyPairSync } from 'node:crypto';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
// Throwaway MusicKit-style key so Apple developer tokens can be signed and verified in tests.
|
||||||
|
const appleKey = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests run against a dedicated Postgres database and Redis DB index, never
|
* Tests run against a dedicated Postgres database and Redis DB index, never
|
||||||
* the dev ones (test/helpers.ts refuses to wipe anything else). Override with
|
* the dev ones (test/helpers.ts refuses to wipe anything else). Override with
|
||||||
@@ -14,9 +18,14 @@ const testEnv = {
|
|||||||
API_PUBLIC_URL: 'https://api.test',
|
API_PUBLIC_URL: 'https://api.test',
|
||||||
ALLOW_PRIVATE_SERVER_URLS: 'false',
|
ALLOW_PRIVATE_SERVER_URLS: 'false',
|
||||||
LASTFM_API_KEY: 'test-lastfm-key',
|
LASTFM_API_KEY: 'test-lastfm-key',
|
||||||
SPOTIFY_CLIENT_ID: '',
|
LASTFM_SHARED_SECRET: 'test-lastfm-secret',
|
||||||
SPOTIFY_CLIENT_SECRET: '',
|
SPOTIFY_CLIENT_ID: 'test-spotify-id',
|
||||||
|
SPOTIFY_CLIENT_SECRET: 'test-spotify-secret',
|
||||||
ODESLI_API_KEY: '',
|
ODESLI_API_KEY: '',
|
||||||
|
APPLE_TEAM_ID: 'TEAMID1234',
|
||||||
|
APPLE_MUSICKIT_KEY_ID: 'KEYID12345',
|
||||||
|
APPLE_MUSICKIT_PRIVATE_KEY: appleKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString().replace(/\n/g, '\\n'),
|
||||||
|
APPLE_TEST_PUBLIC_KEY: appleKey.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -75,10 +75,13 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
|||||||
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
||||||
|
|
||||||
connections: () => request<ConnectionDTO[]>('/connections'),
|
connections: () => request<ConnectionDTO[]>('/connections'),
|
||||||
authorizeUrl: (provider: 'spotify' | 'plex', body: { client: 'web' | 'mobile'; returnTo?: string }) =>
|
authorizeUrl: (provider: 'spotify' | 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) =>
|
||||||
request<{ url: string }>(`/connections/${provider}/authorize`, { body }),
|
request<{ url: string }>(`/connections/${provider}/authorize`, { body }),
|
||||||
connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) =>
|
connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) =>
|
||||||
request<ConnectionDTO>(`/connections/${provider}`, { body: { username } }),
|
request<ConnectionDTO>(`/connections/${provider}`, { body: { username } }),
|
||||||
|
appleMusicDeveloperToken: () => request<{ token: string }>('/connections/apple-music/developer-token'),
|
||||||
|
connectAppleMusic: (musicUserToken: string) =>
|
||||||
|
request<ConnectionDTO>('/connections/apple-music', { body: { musicUserToken } }),
|
||||||
connectJellyfin: (input: { serverUrl: string; username: string; password: string }) =>
|
connectJellyfin: (input: { serverUrl: string; username: string; password: string }) =>
|
||||||
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
|
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
|
||||||
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
|
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user