From ca97f1c3ace2be5108138e84429895b7014487ab Mon Sep 17 00:00:00 2001 From: chris Date: Sun, 4 Oct 2026 07:12:19 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/api/.env.example | 7 ++ apps/api/src/env.ts | 7 ++ apps/api/src/lib/appleMusic.ts | 71 +++++++++++ apps/api/src/providers/appleMusic.ts | 70 +++++++++++ apps/api/src/providers/index.ts | 2 + apps/api/src/providers/lastfm.ts | 23 ++++ apps/api/src/routes/connections.ts | 97 ++++++++++++++- apps/api/test/apple-music.test.ts | 169 +++++++++++++++++++++++++++ apps/api/test/oauth.test.ts | 87 ++++++++++++++ apps/api/vitest.config.ts | 13 ++- packages/shared/src/client.ts | 5 +- 11 files changed, 545 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/lib/appleMusic.ts create mode 100644 apps/api/src/providers/appleMusic.ts create mode 100644 apps/api/test/apple-music.test.ts create mode 100644 apps/api/test/oauth.test.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 27277c0..be20176 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -15,9 +15,16 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= # https://www.last.fm/api/account/create 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. ODESLI_API_KEY= # Stable identifier for this app in Plex's auth flow PLEX_CLIENT_ID=musicshare-dev # Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment. 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= diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 100d77e..ce0f413 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -23,7 +23,13 @@ const schema = z.object({ SPOTIFY_CLIENT_ID: optional, SPOTIFY_CLIENT_SECRET: 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, + /** 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. */ 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 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); diff --git a/apps/api/src/lib/appleMusic.ts b/apps/api/src/lib/appleMusic.ts new file mode 100644 index 0000000..f5b5131 --- /dev/null +++ b/apps/api/src/lib/appleMusic.ts @@ -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(path: string, musicUserToken: string): Promise { + 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; +}; diff --git a/apps/api/src/providers/appleMusic.ts b/apps/api/src/providers/appleMusic.ts new file mode 100644 index 0000000..40c6ba3 --- /dev/null +++ b/apps/api/src/providers/appleMusic.ts @@ -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 }; + }, +}; diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts index c87dd78..fcff07d 100644 --- a/apps/api/src/providers/index.ts +++ b/apps/api/src/providers/index.ts @@ -1,4 +1,5 @@ import type { NowPlayingProvider } from '@musicshare/shared'; +import { appleMusicProvider } from './appleMusic'; import { jellyfinProvider } from './jellyfin'; import { lastfmProvider } from './lastfm'; import { listenbrainzProvider } from './listenbrainz'; @@ -13,6 +14,7 @@ export const pollingProviders: Partial) => `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, 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> { try { const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username })); diff --git a/apps/api/src/routes/connections.ts b/apps/api/src/routes/connections.ts index 1cb4ecd..69b30c8 100644 --- a/apps/api/src/routes/connections.ts +++ b/apps/api/src/routes/connections.ts @@ -4,15 +4,17 @@ import { z } from 'zod'; import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared'; import { db, schema } from '../db'; import type { User } from '../db/schema'; -import { env, spotifyConfigured } from '../env'; +import { appleMusicConfigured, env, spotifyConfigured } from '../env'; import { requireUser } from '../lib/auth'; import { encrypt, randomToken } from '../lib/crypto'; import { entitlementsFor } from '../lib/entitlements'; import { HttpError } from '../lib/http'; import { redis } from '../lib/redis'; +import { AppleUserTokenError, appleDeveloperToken, recentlyPlayed, storefront } from '../lib/appleMusic'; import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf'; 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 * as plex from '../providers/plex'; import * as spotify from '../providers/spotify'; @@ -80,7 +82,17 @@ export async function connectionRoutes(app: FastifyInstance) { return reply.redirect(returnTo.toString()); } 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({ userId, 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 ---- app.post('/connections/lastfm', async (req) => { diff --git a/apps/api/test/apple-music.test.ts b/apps/api/test/apple-music.test.ts new file mode 100644 index 0000000..7ccf54e --- /dev/null +++ b/apps/api/test/apple-music.test.ts @@ -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 = {}) => ({ + 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 }[] = []; + vi.stubGlobal( + 'fetch', + routeFetch([ + [ + /api\.music\.apple\.com\/v1\/me\/storefront/, + (url, init) => (calls.push({ url, headers: init?.headers as Record }), 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 }), 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); + }); +}); diff --git a/apps/api/test/oauth.test.ts b/apps/api/test/oauth.test.ts new file mode 100644 index 0000000..a7291a9 --- /dev/null +++ b/apps/api/test/oauth.test.ts @@ -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); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 098794c..dad88e7 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,5 +1,9 @@ +import { generateKeyPairSync } from 'node:crypto'; 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 * 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', ALLOW_PRIVATE_SERVER_URLS: 'false', LASTFM_API_KEY: 'test-lastfm-key', - SPOTIFY_CLIENT_ID: '', - SPOTIFY_CLIENT_SECRET: '', + LASTFM_SHARED_SECRET: 'test-lastfm-secret', + SPOTIFY_CLIENT_ID: 'test-spotify-id', + SPOTIFY_CLIENT_SECRET: 'test-spotify-secret', 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({ diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index 8fb771c..1d1e12f 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -75,10 +75,13 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), connections: () => request('/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 }), connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) => request(`/connections/${provider}`, { body: { username } }), + appleMusicDeveloperToken: () => request<{ token: string }>('/connections/apple-music/developer-token'), + connectAppleMusic: (musicUserToken: string) => + request('/connections/apple-music', { body: { musicUserToken } }), connectJellyfin: (input: { serverUrl: string; username: string; password: string }) => request('/connections/jellyfin', { body: input }), disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),