@wwdrew/expo-spotify-sdk
Version:
Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication and App Remote playback control
145 lines • 5.52 kB
JavaScript
import { Platform } from "expo-modules-core";
import ExpoSpotifySDKModule from "../ExpoSpotifySDKModule";
import { AuthError } from "./error";
import { createNativeErrorRethrow } from "../internal/native-errors";
export { AuthError } from "./error";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
const ANDROID_TOKEN_FLOW_WARNING = "[expo-spotify-sdk] You are using Auth.authenticate on Android without a " +
"tokenSwapURL. The Spotify Android SDK does NOT return a refresh token or " +
"the actual granted scopes through this path; see the README's " +
"'Android implicit (TOKEN) flow is not recommended' section.";
let warnedAboutAndroidTokenFlow = false;
const rethrowAsAuthError = createNativeErrorRethrow({
ErrorClass: AuthError,
unknownCode: "UNKNOWN",
validCodes: new Set([
"USER_CANCELLED",
"AUTH_IN_PROGRESS",
"INVALID_CONFIG",
"NETWORK_ERROR",
"TOKEN_SWAP_FAILED",
"TOKEN_SWAP_PARSE_ERROR",
"REFRESH_TOKEN_EXPIRED",
"SPOTIFY_NOT_INSTALLED",
"AUTH_ERROR",
"UNKNOWN",
]),
});
function normaliseSession(raw) {
if (!raw || typeof raw !== "object") {
throw new AuthError("UNKNOWN", "Native module returned a non-object session");
}
const r = raw;
const accessToken = r.accessToken;
if (typeof accessToken !== "string" || accessToken.length === 0) {
throw new AuthError("UNKNOWN", "Session is missing accessToken");
}
const expirationDate = r.expirationDate;
if (typeof expirationDate !== "number") {
throw new AuthError("UNKNOWN", "Session is missing expirationDate");
}
const refreshTokenRaw = r.refreshToken;
const refreshToken = typeof refreshTokenRaw === "string" && refreshTokenRaw.length > 0
? refreshTokenRaw
: null;
const scopesRaw = r.scopes;
const scopes = Array.isArray(scopesRaw)
? scopesRaw.filter((s) => typeof s === "string")
: [];
return { accessToken, refreshToken, expirationDate, scopes };
}
// ---------------------------------------------------------------------------
// Auth namespace
// ---------------------------------------------------------------------------
/**
* Spotify Auth namespace. Handles OAuth authentication and session lifecycle.
*
* @example
* ```ts
* import { Auth } from "@wwdrew/expo-spotify-sdk";
*
* const session = await Auth.authenticate({ scopes: ["streaming"] });
* ```
*/
export const Auth = {
/**
* Returns `true` if the Spotify app is installed on the device.
*/
isAvailable() {
return ExpoSpotifySDKModule.isAvailable();
},
/**
* Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};
* rejects with an {@link AuthError} carrying a `code`.
*/
authenticate(config) {
if (!config.scopes || config.scopes.length === 0) {
return Promise.reject(new AuthError("INVALID_CONFIG", "scopes must contain at least one entry"));
}
if (Platform.OS === "android" &&
!config.tokenSwapURL &&
!warnedAboutAndroidTokenFlow) {
warnedAboutAndroidTokenFlow = true;
console.warn(ANDROID_TOKEN_FLOW_WARNING);
}
return ExpoSpotifySDKModule.authenticateAsync(config)
.then(normaliseSession)
.catch(rethrowAsAuthError);
},
/**
* Exchanges a refresh token for a new access token via your token refresh
* server. Resolves with a fresh {@link SpotifySession}; rejects with an
* {@link AuthError}.
*/
refresh(config) {
if (!config.refreshToken) {
return Promise.reject(new AuthError("INVALID_CONFIG", "refreshToken is required"));
}
if (!config.tokenRefreshURL) {
return Promise.reject(new AuthError("INVALID_CONFIG", "tokenRefreshURL is required"));
}
return ExpoSpotifySDKModule.refreshSessionAsync(config)
.then(normaliseSession)
.catch(rethrowAsAuthError);
},
/**
* Forcibly cancel any in-flight `Auth.authenticate()` call. No-op on
* Android (the Android coordinator self-cleans via structured concurrency).
*
* Use before `Auth.authenticate()` to defensively clear any leaked iOS
* coordinator state (the `SPTSessionManager` delegate callbacks are not
* guaranteed to fire).
*/
cancelPending() {
if (Platform.OS !== "ios") {
return Promise.resolve();
}
return ExpoSpotifySDKModule.cancelPendingAuthAsync();
},
/**
* Subscribes to session lifecycle events.
*
* Events fire for every `Auth.authenticate()` and `Auth.refresh()` call,
* regardless of whether the call was awaited. Useful for persisting tokens
* in a central store without coupling the store to the call sites.
*
* Returns a `Subscription` — call `.remove()` to unsubscribe.
*
* @example
* ```ts
* const sub = Auth.addListener("sessionChange", (event) => {
* if (event.type === "didInitiate" || event.type === "didRenew") {
* store.setSession(event.session);
* }
* });
* // later:
* sub.remove();
* ```
*/
addListener(event, listener) {
return ExpoSpotifySDKModule.addListener("onSessionChange", listener);
},
};
//# sourceMappingURL=index.js.map