@wwdrew/expo-spotify-sdk 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -308,6 +308,35 @@ function NowPlaying() {
308
308
 
309
309
  Omit `tokenSwapURL` / `tokenRefreshURL` to use the implicit TOKEN flow (iOS only for refresh; not recommended on Android — see below). For local development without a swap server, auth can still succeed on iOS; production apps should use the code + swap flow.
310
310
 
311
+ ### Refreshing the session and handling token expiry
312
+
313
+ Use `Auth.refresh()` to exchange a stored `refreshToken` for a fresh access token via your `tokenRefreshURL`.
314
+
315
+ > **Spotify policy (from July 20, 2026):** refresh tokens expire **six months** after they are issued. Once expired, Spotify returns an `invalid_grant` error and the token can no longer be used — the user must sign in again. (Only user tokens are affected; Client Credentials flows are not.)
316
+
317
+ Handle a failed refresh by **discarding the stored token and re-running `Auth.authenticate()`** — do **not** retry the refresh. When your swap server forwards Spotify's response verbatim (see [Token swap server](./docs/guides/token-swap-server.md)), an expired or revoked refresh token surfaces as `AuthError` with the dedicated code `REFRESH_TOKEN_EXPIRED`:
318
+
319
+ ```ts
320
+ async function restoreSession(refreshToken: string) {
321
+ try {
322
+ return await Auth.refresh({
323
+ refreshToken,
324
+ tokenRefreshURL: "https://your-server.example.com/refresh",
325
+ });
326
+ } catch (e) {
327
+ if (e instanceof AuthError && e.code === "REFRESH_TOKEN_EXPIRED") {
328
+ // Token expired or revoked: discard it and send the user through
329
+ // sign-in again. Never retry the refresh.
330
+ await clearStoredSession();
331
+ return login(); // your Auth.authenticate() wrapper
332
+ }
333
+ throw e; // NETWORK_ERROR / TOKEN_SWAP_FAILED etc. — transient; handle/retry as appropriate
334
+ }
335
+ }
336
+ ```
337
+
338
+ Test this reauthorization path before July 20, 2026 to avoid disruption for existing users.
339
+
311
340
  ### Check account tier (Web API)
312
341
 
313
342
  App Remote does not expose Premium status. Call Spotify's Web API with the access token from `Auth.authenticate()`:
@@ -406,6 +435,7 @@ Need the deprecated shims temporarily? Pin **`1.x`**: `npm install @wwdrew/expo-
406
435
  | --- | --- |
407
436
  | [API reference](./docs/api-reference.md) | All namespaces, methods, hooks |
408
437
  | [Error codes](./docs/error-codes.md) | Per-namespace codes with when/what-to-do |
438
+ | [Auth error mapping](./docs/auth-error-mapping.md) | iOS/Android auth → JS mapping matrix and parity checklist |
409
439
  | [App Remote error mapping](./docs/app-remote-error-mapping.md) | iOS/Android native → JS mapping matrix |
410
440
  | [Platform differences](./docs/guides/platform-differences.md) | iOS vs Android parity |
411
441
  | [Native SDK distribution](./docs/guides/native-sdk-distribution.md) | How iOS/Android binaries are fetched, packaged, and bumped |
@@ -427,6 +457,13 @@ Ensure your app's URL scheme is registered in Xcode under **Info → URL Types**
427
457
 
428
458
  On iOS this can also be a stuck state when Spotify never redirects back. Call `Auth.cancelPending()` before retrying.
429
459
 
460
+ **iOS: errors report `UNKNOWN` with the correct message (Expo SDK ≤ 56)**
461
+ On iOS before Expo SDK 57, `expo-modules-core` dropped the structured error `code` when an async function rejected — the message survived but the code did not — so a real code like `USER_CANCELLED` reached JS as `UNKNOWN: Authentication was cancelled by the user`. This is a runtime/bridge limitation, **not** a misclassification: the native module emits the correct code, and the `Auth.addListener("sessionChange", …)` `didFail` event reports it correctly regardless of runtime.
462
+
463
+ Fixed in **Expo SDK 57** ([expo/expo#47259](https://github.com/expo/expo/pull/47259)). On SDK 57+ `Auth.authenticate()` rejects with the accurate `AuthError.code` and no action is needed.
464
+
465
+ _Optional — for accurate `code`s on Expo SDK 56 and earlier:_ apply the change from that PR to your installed `expo-modules-core` with [`patch-package`](https://github.com/ds300/patch-package). It routes thrown `Exception`s through the code-preserving conversion in `JavaScriptPromise.reject` instead of stringifying them. If you'd rather not patch, read the code from the `sessionChange` `didFail` event, which already carries the correct `code`.
466
+
430
467
  **App Remote: `CONNECTION_FAILED` / `Connection refused` (iOS code 61)**
431
468
  The Spotify app is installed but its App Remote transport is not accepting connections. Common causes:
432
469
 
@@ -8,7 +8,7 @@ import expo.modules.kotlin.functions.Coroutine
8
8
  import expo.modules.kotlin.modules.Module
9
9
  import expo.modules.kotlin.modules.ModuleDefinition
10
10
 
11
- private const val SDK_VERSION = "2.2.2" // x-release-please-version
11
+ private const val SDK_VERSION = "2.3.0" // x-release-please-version
12
12
  private const val EVENT_SESSION_CHANGE = "onSessionChange"
13
13
  private const val EVENT_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
14
14
  private const val EVENT_CONNECTION_ERROR = "onConnectionError"
@@ -165,6 +165,13 @@ class ExpoSpotifySDKModule : Module() {
165
165
  } catch (e: expo.modules.kotlin.exception.CodedException) {
166
166
  emitSessionError(e.code ?: "UNKNOWN", e.localizedMessage ?: e.code ?: "Unknown error")
167
167
  throw e
168
+ } catch (e: Exception) {
169
+ val wrapped = UnknownSpotifyException(
170
+ "Unexpected authentication error: ${e.message ?: e::class.java.simpleName}",
171
+ e,
172
+ )
173
+ emitSessionError(wrapped.code ?: "UNKNOWN", wrapped.localizedMessage ?: "Unknown error")
174
+ throw wrapped
168
175
  }
169
176
  }
170
177
 
@@ -187,6 +194,13 @@ class ExpoSpotifySDKModule : Module() {
187
194
  } catch (e: expo.modules.kotlin.exception.CodedException) {
188
195
  emitSessionError(e.code ?: "UNKNOWN", e.localizedMessage ?: e.code ?: "Unknown error")
189
196
  throw e
197
+ } catch (e: Exception) {
198
+ val wrapped = UnknownSpotifyException(
199
+ "Unexpected token refresh error: ${e.message ?: e::class.java.simpleName}",
200
+ e,
201
+ )
202
+ emitSessionError(wrapped.code ?: "UNKNOWN", wrapped.localizedMessage ?: "Unknown error")
203
+ throw wrapped
190
204
  }
191
205
  }
192
206
 
@@ -24,6 +24,13 @@ class TokenSwapFailedException(status: Int, body: String?) :
24
24
  class TokenSwapParseException(message: String, cause: Throwable? = null) :
25
25
  CodedException("TOKEN_SWAP_PARSE_ERROR", message, cause)
26
26
 
27
+ class RefreshTokenExpiredException :
28
+ CodedException(
29
+ "REFRESH_TOKEN_EXPIRED",
30
+ "The refresh token is no longer valid (expired or revoked). The user must sign in again.",
31
+ null,
32
+ )
33
+
27
34
  class SpotifyNotInstalledException(message: String = "The Spotify app is not installed on this device") :
28
35
  CodedException("SPOTIFY_NOT_INSTALLED", message, null)
29
36
 
@@ -4,6 +4,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
4
4
  import okhttp3.Call
5
5
  import okhttp3.Callback
6
6
  import okhttp3.FormBody
7
+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
7
8
  import okhttp3.OkHttpClient
8
9
  import okhttp3.Request
9
10
  import okhttp3.Response
@@ -43,11 +44,13 @@ class SpotifyTokenSwapClient(
43
44
  tokenSwapURL: String,
44
45
  requestedScopes: List<String>,
45
46
  ): SpotifySessionPayload {
47
+ val url = tokenSwapURL.toHttpUrlOrNull()
48
+ ?: throw InvalidConfigException("Invalid token swap URL: $tokenSwapURL")
46
49
  val body = FormBody.Builder()
47
50
  .add("code", code)
48
51
  .build()
49
52
  val request = Request.Builder()
50
- .url(tokenSwapURL)
53
+ .url(url)
51
54
  .header("User-Agent", userAgent)
52
55
  .post(body)
53
56
  .build()
@@ -60,15 +63,17 @@ class SpotifyTokenSwapClient(
60
63
  tokenRefreshURL: String,
61
64
  previousScopes: List<String>,
62
65
  ): SpotifySessionPayload {
66
+ val url = tokenRefreshURL.toHttpUrlOrNull()
67
+ ?: throw InvalidConfigException("Invalid token refresh URL: $tokenRefreshURL")
63
68
  val body = FormBody.Builder()
64
69
  .add("refresh_token", refreshToken)
65
70
  .build()
66
71
  val request = Request.Builder()
67
- .url(tokenRefreshURL)
72
+ .url(url)
68
73
  .header("User-Agent", userAgent)
69
74
  .post(body)
70
75
  .build()
71
- val json = executeJson(request)
76
+ val json = executeJson(request, invalidGrantIsExpiredToken = true)
72
77
  return parseSessionPayload(
73
78
  json,
74
79
  fallbackScopes = previousScopes,
@@ -76,7 +81,10 @@ class SpotifyTokenSwapClient(
76
81
  )
77
82
  }
78
83
 
79
- private suspend fun executeJson(request: Request): JSONObject {
84
+ private suspend fun executeJson(
85
+ request: Request,
86
+ invalidGrantIsExpiredToken: Boolean = false,
87
+ ): JSONObject {
80
88
  val response = try {
81
89
  client.executeAsync(request)
82
90
  } catch (e: IOException) {
@@ -85,6 +93,15 @@ class SpotifyTokenSwapClient(
85
93
  response.use { res ->
86
94
  val raw = res.body?.string()
87
95
  if (!res.isSuccessful) {
96
+ // On refresh, Spotify returns `invalid_grant` (HTTP 400) when the
97
+ // refresh token has expired or been revoked — surface a dedicated code
98
+ // so callers can route to sign-in. On the swap path the same body means
99
+ // a bad authorization code, so this remap is scoped to refresh only.
100
+ // Match the structured OAuth `error` field (RFC 6749) on a 400 rather
101
+ // than a raw-body substring, so unrelated payloads can't misfire.
102
+ if (invalidGrantIsExpiredToken && res.code == 400 && isInvalidGrant(raw)) {
103
+ throw RefreshTokenExpiredException()
104
+ }
88
105
  throw TokenSwapFailedException(res.code, raw)
89
106
  }
90
107
  if (raw.isNullOrBlank()) {
@@ -98,6 +115,22 @@ class SpotifyTokenSwapClient(
98
115
  }
99
116
  }
100
117
 
118
+ /**
119
+ * True when an OAuth error response body declares `"error": "invalid_grant"`
120
+ * (RFC 6749 §5.2). Parses the JSON and checks the structured field rather than
121
+ * scanning the raw text, so the token value or other fields can't trigger a
122
+ * false positive. A non-JSON or differently-shaped body is treated as not an
123
+ * `invalid_grant`.
124
+ */
125
+ private fun isInvalidGrant(raw: String?): Boolean {
126
+ if (raw.isNullOrBlank()) return false
127
+ return try {
128
+ JSONObject(raw).optString("error").equals("invalid_grant", ignoreCase = true)
129
+ } catch (_: JSONException) {
130
+ false
131
+ }
132
+ }
133
+
101
134
  private fun parseSessionPayload(
102
135
  json: JSONObject,
103
136
  fallbackScopes: List<String>,
@@ -1,5 +1,5 @@
1
1
  import { SpotifyError } from "../error";
2
- export type AuthErrorCode = "USER_CANCELLED" | "AUTH_IN_PROGRESS" | "INVALID_CONFIG" | "NETWORK_ERROR" | "TOKEN_SWAP_FAILED" | "TOKEN_SWAP_PARSE_ERROR" | "SPOTIFY_NOT_INSTALLED" | "AUTH_ERROR" | "UNKNOWN";
2
+ export type AuthErrorCode = "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";
3
3
  export declare class AuthError extends SpotifyError {
4
4
  readonly namespace: "Auth";
5
5
  readonly code: AuthErrorCode;
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,MAAM,MAAM,aAAa,GACrB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd,qBAAa,SAAU,SAAQ,YAAY;IACzC,QAAQ,CAAC,SAAS,EAAG,MAAM,CAAU;IACrC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAEjB,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM;CAIjD"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,MAAM,MAAM,aAAa,GACrB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd,qBAAa,SAAU,SAAQ,YAAY;IACzC,QAAQ,CAAC,SAAS,EAAG,MAAM,CAAU;IACrC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAEjB,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM;CAIjD"}
@@ -1 +1 @@
1
- {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAaxC,MAAM,OAAO,SAAU,SAAQ,YAAY;IAChC,SAAS,GAAG,MAAe,CAAC;IAC5B,IAAI,CAAgB;IAE7B,YAAY,IAAmB,EAAE,OAAe;QAC9C,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["import { SpotifyError } from \"../error\";\n\nexport type AuthErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\nexport class AuthError extends SpotifyError {\n readonly namespace = \"Auth\" as const;\n readonly code: AuthErrorCode;\n\n constructor(code: AuthErrorCode, message: string) {\n super(message);\n this.code = code;\n }\n}\n"]}
1
+ {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAcxC,MAAM,OAAO,SAAU,SAAQ,YAAY;IAChC,SAAS,GAAG,MAAe,CAAC;IAC5B,IAAI,CAAgB;IAE7B,YAAY,IAAmB,EAAE,OAAe;QAC9C,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["import { SpotifyError } from \"../error\";\n\nexport type AuthErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"REFRESH_TOKEN_EXPIRED\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\nexport class AuthError extends SpotifyError {\n readonly namespace = \"Auth\" as const;\n readonly code: AuthErrorCode;\n\n constructor(code: AuthErrorCode, message: string) {\n super(message);\n this.code = code;\n }\n}\n"]}
@@ -14,7 +14,6 @@ let warnedAboutAndroidTokenFlow = false;
14
14
  const rethrowAsAuthError = createNativeErrorRethrow({
15
15
  ErrorClass: AuthError,
16
16
  unknownCode: "UNKNOWN",
17
- legacyCodePrefix: true,
18
17
  validCodes: new Set([
19
18
  "USER_CANCELLED",
20
19
  "AUTH_IN_PROGRESS",
@@ -22,6 +21,7 @@ const rethrowAsAuthError = createNativeErrorRethrow({
22
21
  "NETWORK_ERROR",
23
22
  "TOKEN_SWAP_FAILED",
24
23
  "TOKEN_SWAP_PARSE_ERROR",
24
+ "REFRESH_TOKEN_EXPIRED",
25
25
  "SPOTIFY_NOT_INSTALLED",
26
26
  "AUTH_ERROR",
27
27
  "UNKNOWN",
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\nimport { Platform } from \"expo-modules-core\";\n\nimport ExpoSpotifySDKModule from \"../ExpoSpotifySDKModule\";\nimport { AuthError, type AuthErrorCode } from \"./error\";\nimport { createNativeErrorRethrow } from \"../internal/native-errors\";\n\nexport type { AuthErrorCode } from \"./error\";\nexport { AuthError } from \"./error\";\n\n// ---------------------------------------------------------------------------\n// Auth-specific types\n// ---------------------------------------------------------------------------\n\nexport interface SpotifySession {\n /** OAuth access token. */\n accessToken: string;\n /**\n * OAuth refresh token. `null` on Android when no `tokenSwapURL` is provided\n * (the Spotify Android SDK does not expose a refresh token for implicit\n * grants).\n */\n refreshToken: string | null;\n /** Expiration timestamp as Unix epoch milliseconds. */\n expirationDate: number;\n /** Scopes the access token was granted (or requested, on Android implicit). */\n scopes: SpotifyScope[];\n}\n\nexport type SpotifyScope =\n | \"ugc-image-upload\"\n | \"user-read-playback-state\"\n | \"user-modify-playback-state\"\n | \"user-read-currently-playing\"\n | \"app-remote-control\"\n | \"streaming\"\n | \"playlist-read-private\"\n | \"playlist-read-collaborative\"\n | \"playlist-modify-private\"\n | \"playlist-modify-public\"\n | \"user-follow-modify\"\n | \"user-follow-read\"\n | \"user-top-read\"\n | \"user-read-recently-played\"\n | \"user-library-modify\"\n | \"user-library-read\"\n | \"user-read-email\"\n | \"user-read-private\";\n\nexport interface AuthenticateConfig {\n /** OAuth scopes to request. Must contain at least one entry. */\n scopes: SpotifyScope[];\n /**\n * If supplied, requests an authorization code rather than an implicit\n * token, then POSTs the code to this URL to exchange it for tokens.\n * **Required on Android** to receive a usable `refreshToken`.\n */\n tokenSwapURL?: string;\n /**\n * Used by the iOS SDK to refresh access tokens automatically, and by\n * `Auth.refresh()` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when the\n * user already has an active session. Defaults to `false`.\n */\n showDialog?: boolean;\n}\n\nexport interface RefreshConfig {\n /** The refresh token from a previous `Auth.authenticate()` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n /**\n * Scopes that were granted by the previous session. Used as a fallback when\n * the refresh response omits the `scope` field.\n */\n scopes?: SpotifyScope[];\n}\n\n/**\n * Payload delivered to `Auth.addListener(\"sessionChange\", ...)` subscribers.\n */\nexport type SessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: AuthErrorCode; message: string } };\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst ANDROID_TOKEN_FLOW_WARNING =\n \"[expo-spotify-sdk] You are using Auth.authenticate on Android without a \" +\n \"tokenSwapURL. The Spotify Android SDK does NOT return a refresh token or \" +\n \"the actual granted scopes through this path; see the README's \" +\n \"'Android implicit (TOKEN) flow is not recommended' section.\";\n\nlet warnedAboutAndroidTokenFlow = false;\n\nconst rethrowAsAuthError = createNativeErrorRethrow({\n ErrorClass: AuthError,\n unknownCode: \"UNKNOWN\",\n legacyCodePrefix: true,\n validCodes: new Set<AuthErrorCode>([\n \"USER_CANCELLED\",\n \"AUTH_IN_PROGRESS\",\n \"INVALID_CONFIG\",\n \"NETWORK_ERROR\",\n \"TOKEN_SWAP_FAILED\",\n \"TOKEN_SWAP_PARSE_ERROR\",\n \"SPOTIFY_NOT_INSTALLED\",\n \"AUTH_ERROR\",\n \"UNKNOWN\",\n ]),\n});\n\nfunction normaliseSession(raw: unknown): SpotifySession {\n if (!raw || typeof raw !== \"object\") {\n throw new AuthError(\n \"UNKNOWN\",\n \"Native module returned a non-object session\",\n );\n }\n const r = raw as Record<string, unknown>;\n const accessToken = r.accessToken;\n if (typeof accessToken !== \"string\" || accessToken.length === 0) {\n throw new AuthError(\"UNKNOWN\", \"Session is missing accessToken\");\n }\n const expirationDate = r.expirationDate;\n if (typeof expirationDate !== \"number\") {\n throw new AuthError(\"UNKNOWN\", \"Session is missing expirationDate\");\n }\n const refreshTokenRaw = r.refreshToken;\n const refreshToken =\n typeof refreshTokenRaw === \"string\" && refreshTokenRaw.length > 0\n ? refreshTokenRaw\n : null;\n const scopesRaw = r.scopes;\n const scopes = Array.isArray(scopesRaw)\n ? (scopesRaw.filter((s) => typeof s === \"string\") as SpotifyScope[])\n : [];\n return { accessToken, refreshToken, expirationDate, scopes };\n}\n\n// ---------------------------------------------------------------------------\n// Auth namespace\n// ---------------------------------------------------------------------------\n\n/**\n * Spotify Auth namespace. Handles OAuth authentication and session lifecycle.\n *\n * @example\n * ```ts\n * import { Auth } from \"@wwdrew/expo-spotify-sdk\";\n *\n * const session = await Auth.authenticate({ scopes: [\"streaming\"] });\n * ```\n */\nexport const Auth = {\n /**\n * Returns `true` if the Spotify app is installed on the device.\n */\n isAvailable(): boolean {\n return ExpoSpotifySDKModule.isAvailable();\n },\n\n /**\n * Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};\n * rejects with an {@link AuthError} carrying a `code`.\n */\n authenticate(config: AuthenticateConfig): Promise<SpotifySession> {\n if (!config.scopes || config.scopes.length === 0) {\n return Promise.reject(\n new AuthError(\n \"INVALID_CONFIG\",\n \"scopes must contain at least one entry\",\n ),\n );\n }\n if (\n Platform.OS === \"android\" &&\n !config.tokenSwapURL &&\n !warnedAboutAndroidTokenFlow\n ) {\n warnedAboutAndroidTokenFlow = true;\n console.warn(ANDROID_TOKEN_FLOW_WARNING);\n }\n return ExpoSpotifySDKModule.authenticateAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Exchanges a refresh token for a new access token via your token refresh\n * server. Resolves with a fresh {@link SpotifySession}; rejects with an\n * {@link AuthError}.\n */\n refresh(config: RefreshConfig): Promise<SpotifySession> {\n if (!config.refreshToken) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"refreshToken is required\"),\n );\n }\n if (!config.tokenRefreshURL) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"tokenRefreshURL is required\"),\n );\n }\n return ExpoSpotifySDKModule.refreshSessionAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Forcibly cancel any in-flight `Auth.authenticate()` call. No-op on\n * Android (the Android coordinator self-cleans via structured concurrency).\n *\n * Use before `Auth.authenticate()` to defensively clear any leaked iOS\n * coordinator state (the `SPTSessionManager` delegate callbacks are not\n * guaranteed to fire).\n */\n cancelPending(): Promise<void> {\n if (Platform.OS !== \"ios\") {\n return Promise.resolve();\n }\n return ExpoSpotifySDKModule.cancelPendingAuthAsync();\n },\n\n /**\n * Subscribes to session lifecycle events.\n *\n * Events fire for every `Auth.authenticate()` and `Auth.refresh()` call,\n * regardless of whether the call was awaited. Useful for persisting tokens\n * in a central store without coupling the store to the call sites.\n *\n * Returns a `Subscription` — call `.remove()` to unsubscribe.\n *\n * @example\n * ```ts\n * const sub = Auth.addListener(\"sessionChange\", (event) => {\n * if (event.type === \"didInitiate\" || event.type === \"didRenew\") {\n * store.setSession(event.session);\n * }\n * });\n * // later:\n * sub.remove();\n * ```\n */\n addListener(\n event: \"sessionChange\",\n listener: (event: SessionChangeEvent) => void,\n ): EventSubscription {\n return ExpoSpotifySDKModule.addListener(\n \"onSessionChange\",\n listener,\n ) as EventSubscription;\n },\n} as const;\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\nimport { Platform } from \"expo-modules-core\";\n\nimport ExpoSpotifySDKModule from \"../ExpoSpotifySDKModule\";\nimport { AuthError, type AuthErrorCode } from \"./error\";\nimport { createNativeErrorRethrow } from \"../internal/native-errors\";\n\nexport type { AuthErrorCode } from \"./error\";\nexport { AuthError } from \"./error\";\n\n// ---------------------------------------------------------------------------\n// Auth-specific types\n// ---------------------------------------------------------------------------\n\nexport interface SpotifySession {\n /** OAuth access token. */\n accessToken: string;\n /**\n * OAuth refresh token. `null` on Android when no `tokenSwapURL` is provided\n * (the Spotify Android SDK does not expose a refresh token for implicit\n * grants).\n */\n refreshToken: string | null;\n /** Expiration timestamp as Unix epoch milliseconds. */\n expirationDate: number;\n /** Scopes the access token was granted (or requested, on Android implicit). */\n scopes: SpotifyScope[];\n}\n\nexport type SpotifyScope =\n | \"ugc-image-upload\"\n | \"user-read-playback-state\"\n | \"user-modify-playback-state\"\n | \"user-read-currently-playing\"\n | \"app-remote-control\"\n | \"streaming\"\n | \"playlist-read-private\"\n | \"playlist-read-collaborative\"\n | \"playlist-modify-private\"\n | \"playlist-modify-public\"\n | \"user-follow-modify\"\n | \"user-follow-read\"\n | \"user-top-read\"\n | \"user-read-recently-played\"\n | \"user-library-modify\"\n | \"user-library-read\"\n | \"user-read-email\"\n | \"user-read-private\";\n\nexport interface AuthenticateConfig {\n /** OAuth scopes to request. Must contain at least one entry. */\n scopes: SpotifyScope[];\n /**\n * If supplied, requests an authorization code rather than an implicit\n * token, then POSTs the code to this URL to exchange it for tokens.\n * **Required on Android** to receive a usable `refreshToken`.\n */\n tokenSwapURL?: string;\n /**\n * Used by the iOS SDK to refresh access tokens automatically, and by\n * `Auth.refresh()` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when the\n * user already has an active session. Defaults to `false`.\n */\n showDialog?: boolean;\n}\n\nexport interface RefreshConfig {\n /** The refresh token from a previous `Auth.authenticate()` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n /**\n * Scopes that were granted by the previous session. Used as a fallback when\n * the refresh response omits the `scope` field.\n */\n scopes?: SpotifyScope[];\n}\n\n/**\n * Payload delivered to `Auth.addListener(\"sessionChange\", ...)` subscribers.\n */\nexport type SessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: AuthErrorCode; message: string } };\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst ANDROID_TOKEN_FLOW_WARNING =\n \"[expo-spotify-sdk] You are using Auth.authenticate on Android without a \" +\n \"tokenSwapURL. The Spotify Android SDK does NOT return a refresh token or \" +\n \"the actual granted scopes through this path; see the README's \" +\n \"'Android implicit (TOKEN) flow is not recommended' section.\";\n\nlet warnedAboutAndroidTokenFlow = false;\n\nconst rethrowAsAuthError = createNativeErrorRethrow({\n ErrorClass: AuthError,\n unknownCode: \"UNKNOWN\",\n validCodes: new Set<AuthErrorCode>([\n \"USER_CANCELLED\",\n \"AUTH_IN_PROGRESS\",\n \"INVALID_CONFIG\",\n \"NETWORK_ERROR\",\n \"TOKEN_SWAP_FAILED\",\n \"TOKEN_SWAP_PARSE_ERROR\",\n \"REFRESH_TOKEN_EXPIRED\",\n \"SPOTIFY_NOT_INSTALLED\",\n \"AUTH_ERROR\",\n \"UNKNOWN\",\n ]),\n});\n\nfunction normaliseSession(raw: unknown): SpotifySession {\n if (!raw || typeof raw !== \"object\") {\n throw new AuthError(\n \"UNKNOWN\",\n \"Native module returned a non-object session\",\n );\n }\n const r = raw as Record<string, unknown>;\n const accessToken = r.accessToken;\n if (typeof accessToken !== \"string\" || accessToken.length === 0) {\n throw new AuthError(\"UNKNOWN\", \"Session is missing accessToken\");\n }\n const expirationDate = r.expirationDate;\n if (typeof expirationDate !== \"number\") {\n throw new AuthError(\"UNKNOWN\", \"Session is missing expirationDate\");\n }\n const refreshTokenRaw = r.refreshToken;\n const refreshToken =\n typeof refreshTokenRaw === \"string\" && refreshTokenRaw.length > 0\n ? refreshTokenRaw\n : null;\n const scopesRaw = r.scopes;\n const scopes = Array.isArray(scopesRaw)\n ? (scopesRaw.filter((s) => typeof s === \"string\") as SpotifyScope[])\n : [];\n return { accessToken, refreshToken, expirationDate, scopes };\n}\n\n// ---------------------------------------------------------------------------\n// Auth namespace\n// ---------------------------------------------------------------------------\n\n/**\n * Spotify Auth namespace. Handles OAuth authentication and session lifecycle.\n *\n * @example\n * ```ts\n * import { Auth } from \"@wwdrew/expo-spotify-sdk\";\n *\n * const session = await Auth.authenticate({ scopes: [\"streaming\"] });\n * ```\n */\nexport const Auth = {\n /**\n * Returns `true` if the Spotify app is installed on the device.\n */\n isAvailable(): boolean {\n return ExpoSpotifySDKModule.isAvailable();\n },\n\n /**\n * Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};\n * rejects with an {@link AuthError} carrying a `code`.\n */\n authenticate(config: AuthenticateConfig): Promise<SpotifySession> {\n if (!config.scopes || config.scopes.length === 0) {\n return Promise.reject(\n new AuthError(\n \"INVALID_CONFIG\",\n \"scopes must contain at least one entry\",\n ),\n );\n }\n if (\n Platform.OS === \"android\" &&\n !config.tokenSwapURL &&\n !warnedAboutAndroidTokenFlow\n ) {\n warnedAboutAndroidTokenFlow = true;\n console.warn(ANDROID_TOKEN_FLOW_WARNING);\n }\n return ExpoSpotifySDKModule.authenticateAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Exchanges a refresh token for a new access token via your token refresh\n * server. Resolves with a fresh {@link SpotifySession}; rejects with an\n * {@link AuthError}.\n */\n refresh(config: RefreshConfig): Promise<SpotifySession> {\n if (!config.refreshToken) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"refreshToken is required\"),\n );\n }\n if (!config.tokenRefreshURL) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"tokenRefreshURL is required\"),\n );\n }\n return ExpoSpotifySDKModule.refreshSessionAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Forcibly cancel any in-flight `Auth.authenticate()` call. No-op on\n * Android (the Android coordinator self-cleans via structured concurrency).\n *\n * Use before `Auth.authenticate()` to defensively clear any leaked iOS\n * coordinator state (the `SPTSessionManager` delegate callbacks are not\n * guaranteed to fire).\n */\n cancelPending(): Promise<void> {\n if (Platform.OS !== \"ios\") {\n return Promise.resolve();\n }\n return ExpoSpotifySDKModule.cancelPendingAuthAsync();\n },\n\n /**\n * Subscribes to session lifecycle events.\n *\n * Events fire for every `Auth.authenticate()` and `Auth.refresh()` call,\n * regardless of whether the call was awaited. Useful for persisting tokens\n * in a central store without coupling the store to the call sites.\n *\n * Returns a `Subscription` — call `.remove()` to unsubscribe.\n *\n * @example\n * ```ts\n * const sub = Auth.addListener(\"sessionChange\", (event) => {\n * if (event.type === \"didInitiate\" || event.type === \"didRenew\") {\n * store.setSession(event.session);\n * }\n * });\n * // later:\n * sub.remove();\n * ```\n */\n addListener(\n event: \"sessionChange\",\n listener: (event: SessionChangeEvent) => void,\n ): EventSubscription {\n return ExpoSpotifySDKModule.addListener(\n \"onSessionChange\",\n listener,\n ) as EventSubscription;\n },\n} as const;\n"]}
@@ -4,8 +4,6 @@ export interface NativeErrorRethrowOptions<C extends string, E extends SpotifyEr
4
4
  }> {
5
5
  ErrorClass: new (code: C, message: string) => E;
6
6
  validCodes: ReadonlySet<C>;
7
- /** Parse legacy `"CODE: message"` prefixes embedded in native error reasons. */
8
- legacyCodePrefix?: boolean;
9
7
  unknownCode: C;
10
8
  }
11
9
  export declare function createNativeErrorRethrow<C extends string, E extends SpotifyError & {
@@ -1 +1 @@
1
- {"version":3,"file":"native-errors.d.ts","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAU7C,MAAM,WAAW,yBAAyB,CACxC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE;IAEpC,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC,CAAC;IAChD,UAAU,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IAC3B,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,WAAW,EAAE,CAAC,CAAC;CAChB;AAED,wBAAgB,wBAAwB,CACtC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE,EACpC,OAAO,EAAE,yBAAyB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,CAqBnE"}
1
+ {"version":3,"file":"native-errors.d.ts","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAS7C,MAAM,WAAW,yBAAyB,CACxC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE;IAEpC,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC,CAAC;IAChD,UAAU,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IAC3B,WAAW,EAAE,CAAC,CAAC;CAChB;AAED,wBAAgB,wBAAwB,CACtC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE,EACpC,OAAO,EAAE,yBAAyB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,CAmBnE"}
@@ -1,26 +1,23 @@
1
1
  const CAUSE_SEPARATOR = "→ Caused by: ";
2
- const LEGACY_CODE_PREFIX_RE = /^([A-Z_][A-Z0-9_]*):\s*(.*)$/s;
3
2
  function unwrapReason(message) {
4
3
  const idx = message.lastIndexOf(CAUSE_SEPARATOR);
5
4
  return idx === -1 ? message : message.slice(idx + CAUSE_SEPARATOR.length);
6
5
  }
7
6
  export function createNativeErrorRethrow(options) {
8
- const { ErrorClass, validCodes, legacyCodePrefix, unknownCode } = options;
7
+ const { ErrorClass, validCodes, unknownCode } = options;
9
8
  return function rethrowNativeError(err) {
10
9
  if (err instanceof ErrorClass)
11
10
  throw err;
12
11
  if (err instanceof Error) {
13
12
  const reason = unwrapReason(err.message);
13
+ // `err.code` is the structured code from the native `Exception`. On iOS
14
+ // before Expo SDK 57 the async-rejection bridge dropped it (the message
15
+ // survived), surfacing as `unknownCode` with the correct message; fixed
16
+ // in Expo SDK 57 (expo/expo#47259), which preserves the code.
14
17
  const maybeCode = err.code;
15
18
  if (maybeCode && validCodes.has(maybeCode)) {
16
19
  throw new ErrorClass(maybeCode, reason);
17
20
  }
18
- if (legacyCodePrefix) {
19
- const match = reason.match(LEGACY_CODE_PREFIX_RE);
20
- if (match && validCodes.has(match[1])) {
21
- throw new ErrorClass(match[1], match[2]);
22
- }
23
- }
24
21
  throw new ErrorClass(unknownCode, reason);
25
22
  }
26
23
  throw new ErrorClass(unknownCode, String(err));
@@ -1 +1 @@
1
- {"version":3,"file":"native-errors.js","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAEA,MAAM,eAAe,GAAG,eAAe,CAAC;AACxC,MAAM,qBAAqB,GAAG,+BAA+B,CAAC;AAE9D,SAAS,YAAY,CAAC,OAAe;IACnC,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC;IACjD,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;AAC5E,CAAC;AAaD,MAAM,UAAU,wBAAwB,CAGtC,OAAwC;IACxC,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;IAE1E,OAAO,SAAS,kBAAkB,CAAC,GAAY;QAC7C,IAAI,GAAG,YAAY,UAAU;YAAE,MAAM,GAAG,CAAC;QACzC,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACzC,MAAM,SAAS,GAAI,GAAiC,CAAC,IAAI,CAAC;YAC1D,IAAI,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAc,CAAC,EAAE,CAAC;gBAChD,MAAM,IAAI,UAAU,CAAC,SAAc,EAAE,MAAM,CAAC,CAAC;YAC/C,CAAC;YACD,IAAI,gBAAgB,EAAE,CAAC;gBACrB,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;gBAClD,IAAI,KAAK,IAAI,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAM,CAAC,EAAE,CAAC;oBAC3C,MAAM,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC,CAAM,EAAE,KAAK,CAAC,CAAC,CAAE,CAAC,CAAC;gBACjD,CAAC;YACH,CAAC;YACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QAC5C,CAAC;QACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import type { SpotifyError } from \"../error\";\n\nconst CAUSE_SEPARATOR = \"→ Caused by: \";\nconst LEGACY_CODE_PREFIX_RE = /^([A-Z_][A-Z0-9_]*):\\s*(.*)$/s;\n\nfunction unwrapReason(message: string): string {\n const idx = message.lastIndexOf(CAUSE_SEPARATOR);\n return idx === -1 ? message : message.slice(idx + CAUSE_SEPARATOR.length);\n}\n\nexport interface NativeErrorRethrowOptions<\n C extends string,\n E extends SpotifyError & { code: C },\n> {\n ErrorClass: new (code: C, message: string) => E;\n validCodes: ReadonlySet<C>;\n /** Parse legacy `\"CODE: message\"` prefixes embedded in native error reasons. */\n legacyCodePrefix?: boolean;\n unknownCode: C;\n}\n\nexport function createNativeErrorRethrow<\n C extends string,\n E extends SpotifyError & { code: C },\n>(options: NativeErrorRethrowOptions<C, E>): (err: unknown) => never {\n const { ErrorClass, validCodes, legacyCodePrefix, unknownCode } = options;\n\n return function rethrowNativeError(err: unknown): never {\n if (err instanceof ErrorClass) throw err;\n if (err instanceof Error) {\n const reason = unwrapReason(err.message);\n const maybeCode = (err as Error & { code?: string }).code;\n if (maybeCode && validCodes.has(maybeCode as C)) {\n throw new ErrorClass(maybeCode as C, reason);\n }\n if (legacyCodePrefix) {\n const match = reason.match(LEGACY_CODE_PREFIX_RE);\n if (match && validCodes.has(match[1] as C)) {\n throw new ErrorClass(match[1] as C, match[2]!);\n }\n }\n throw new ErrorClass(unknownCode, reason);\n }\n throw new ErrorClass(unknownCode, String(err));\n };\n}\n"]}
1
+ {"version":3,"file":"native-errors.js","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAEA,MAAM,eAAe,GAAG,eAAe,CAAC;AAExC,SAAS,YAAY,CAAC,OAAe;IACnC,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC;IACjD,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;AAC5E,CAAC;AAWD,MAAM,UAAU,wBAAwB,CAGtC,OAAwC;IACxC,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;IAExD,OAAO,SAAS,kBAAkB,CAAC,GAAY;QAC7C,IAAI,GAAG,YAAY,UAAU;YAAE,MAAM,GAAG,CAAC;QACzC,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACzC,wEAAwE;YACxE,wEAAwE;YACxE,wEAAwE;YACxE,8DAA8D;YAC9D,MAAM,SAAS,GAAI,GAAiC,CAAC,IAAI,CAAC;YAC1D,IAAI,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAc,CAAC,EAAE,CAAC;gBAChD,MAAM,IAAI,UAAU,CAAC,SAAc,EAAE,MAAM,CAAC,CAAC;YAC/C,CAAC;YACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QAC5C,CAAC;QACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import type { SpotifyError } from \"../error\";\n\nconst CAUSE_SEPARATOR = \"→ Caused by: \";\n\nfunction unwrapReason(message: string): string {\n const idx = message.lastIndexOf(CAUSE_SEPARATOR);\n return idx === -1 ? message : message.slice(idx + CAUSE_SEPARATOR.length);\n}\n\nexport interface NativeErrorRethrowOptions<\n C extends string,\n E extends SpotifyError & { code: C },\n> {\n ErrorClass: new (code: C, message: string) => E;\n validCodes: ReadonlySet<C>;\n unknownCode: C;\n}\n\nexport function createNativeErrorRethrow<\n C extends string,\n E extends SpotifyError & { code: C },\n>(options: NativeErrorRethrowOptions<C, E>): (err: unknown) => never {\n const { ErrorClass, validCodes, unknownCode } = options;\n\n return function rethrowNativeError(err: unknown): never {\n if (err instanceof ErrorClass) throw err;\n if (err instanceof Error) {\n const reason = unwrapReason(err.message);\n // `err.code` is the structured code from the native `Exception`. On iOS\n // before Expo SDK 57 the async-rejection bridge dropped it (the message\n // survived), surfacing as `unknownCode` with the correct message; fixed\n // in Expo SDK 57 (expo/expo#47259), which preserves the code.\n const maybeCode = (err as Error & { code?: string }).code;\n if (maybeCode && validCodes.has(maybeCode as C)) {\n throw new ErrorClass(maybeCode as C, reason);\n }\n throw new ErrorClass(unknownCode, reason);\n }\n throw new ErrorClass(unknownCode, String(err));\n };\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import SpotifyiOS
3
3
 
4
- private let SDK_VERSION = "2.2.2" // x-release-please-version
4
+ private let SDK_VERSION = "2.3.0" // x-release-please-version
5
5
  private let EVENT_SESSION_CHANGE = "onSessionChange"
6
6
  private let EVENT_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
7
7
  private let EVENT_CONNECTION_ERROR = "onConnectionError"
@@ -72,12 +72,18 @@ public class ExpoSpotifySDKModule: Module {
72
72
  let map = self.sessionToMap(session)
73
73
  self.sendEvent(EVENT_SESSION_CHANGE, ["type": "didInitiate", "session": map])
74
74
  return map
75
- } catch let error as SpotifyError {
76
- self.sendEvent(EVENT_SESSION_CHANGE, [
77
- "type": "didFail",
78
- "error": ["code": error.code, "message": error.message],
79
- ])
80
- throw SpotifyAuthException(error)
75
+ } catch {
76
+ // Already-typed `SpotifyError`s pass through; anything else is a raw
77
+ // failure the SDK surfaced without routing through the delegate (e.g. a
78
+ // user-cancelled web auth). `classify` is the same entry point the
79
+ // delegate uses, so both paths map identical errors to identical codes.
80
+ let mapped = error as? SpotifyError
81
+ ?? SpotifyAuthErrorMapping.classify(
82
+ error,
83
+ context: .init(tokenSwapConfigured: config.tokenSwapURL.flatMap(URL.init) != nil)
84
+ )
85
+ self.emitDidFail(mapped)
86
+ throw SpotifyAuthException(mapped)
81
87
  }
82
88
  }
83
89
 
@@ -109,16 +115,10 @@ public class ExpoSpotifySDKModule: Module {
109
115
  self.sendEvent(EVENT_SESSION_CHANGE, ["type": "didRenew", "session": map])
110
116
  return map
111
117
  } catch let error as SpotifyError {
112
- self.sendEvent(EVENT_SESSION_CHANGE, [
113
- "type": "didFail",
114
- "error": ["code": error.code, "message": error.message],
115
- ])
118
+ self.emitDidFail(error)
116
119
  throw SpotifyAuthException(error)
117
120
  } catch let error as SpotifyRefreshError {
118
- self.sendEvent(EVENT_SESSION_CHANGE, [
119
- "type": "didFail",
120
- "error": ["code": error.code, "message": error.message],
121
- ])
121
+ self.emitDidFail(code: error.code, message: error.message)
122
122
  throw SpotifyRefreshException(error)
123
123
  }
124
124
  }
@@ -325,6 +325,17 @@ public class ExpoSpotifySDKModule: Module {
325
325
 
326
326
  // MARK: — Helpers
327
327
 
328
+ private func emitDidFail(_ error: SpotifyError) {
329
+ emitDidFail(code: error.code, message: error.message)
330
+ }
331
+
332
+ private func emitDidFail(code: String, message: String) {
333
+ sendEvent(EVENT_SESSION_CHANGE, [
334
+ "type": "didFail",
335
+ "error": ["code": code, "message": message],
336
+ ])
337
+ }
338
+
328
339
  private func sessionToMap(_ session: SPTSession) -> [String: Any?] {
329
340
  return [
330
341
  "accessToken": session.accessToken,
@@ -3,69 +3,6 @@ import Foundation
3
3
  import SpotifyiOS
4
4
  import UIKit
5
5
 
6
- /// Public, structured errors thrown by the coordinator. Each case carries the
7
- /// JS-facing error code in `code`, mirroring the Android `CodedException`
8
- /// taxonomy.
9
- enum SpotifyError: Error {
10
- case invalidConfiguration(String)
11
- case authInProgress
12
- case userCancelled
13
- case spotifyNotInstalled
14
- case underlying(message: String, cause: Error)
15
-
16
- var code: String {
17
- switch self {
18
- case .invalidConfiguration: return "INVALID_CONFIG"
19
- case .authInProgress: return "AUTH_IN_PROGRESS"
20
- case .userCancelled: return "USER_CANCELLED"
21
- case .spotifyNotInstalled: return "SPOTIFY_NOT_INSTALLED"
22
- case .underlying: return "UNKNOWN"
23
- }
24
- }
25
-
26
- var message: String {
27
- switch self {
28
- case .invalidConfiguration(let m): return m
29
- case .authInProgress: return "Another authentication request is already in progress"
30
- case .userCancelled: return "Authentication was cancelled by the user"
31
- case .spotifyNotInstalled: return "The Spotify app is not installed on this device"
32
- case .underlying(let message, _): return message
33
- }
34
- }
35
-
36
- /// The original error that caused this failure, if any. Surfaced as the
37
- /// JS-facing exception's `cause` so debugging / Sentry breadcrumbs keep the
38
- /// full chain rather than collapsing into "undefined reason".
39
- var underlyingCause: Error? {
40
- switch self {
41
- case .underlying(_, let cause): return cause
42
- default: return nil
43
- }
44
- }
45
- }
46
-
47
- /// `Exception` subclass that projects a `SpotifyError`'s `code` and `message`
48
- /// through `expo-modules-core`'s exception bridge so JS receives the structured
49
- /// code and a meaningful reason — not the default "undefined reason" placeholder.
50
- ///
51
- /// Without this wrapper an `AsyncFunction` rejection collapses to
52
- /// `FunctionCallException` → `cause.reason = "undefined reason"` because the
53
- /// previously-used `GenericException<String>` does not override `reason`.
54
- final class SpotifyAuthException: Exception, @unchecked Sendable {
55
- private let spotifyCode: String
56
- private let spotifyMessage: String
57
-
58
- init(_ error: SpotifyError, file: String = #fileID, line: UInt = #line, function: String = #function) {
59
- self.spotifyCode = error.code
60
- self.spotifyMessage = error.message
61
- super.init(file: file, line: line, function: function)
62
- self.cause = error.underlyingCause
63
- }
64
-
65
- override var code: String { spotifyCode }
66
- override var reason: String { spotifyMessage }
67
- }
68
-
69
6
  /// `actor` ensures `pending` is mutated only on the actor's serial executor;
70
7
  /// no manual locks needed.
71
8
  actor SpotifyAuthCoordinator {
@@ -123,6 +60,10 @@ actor SpotifyAuthCoordinator {
123
60
  // mutating it here takes effect for the upcoming initiateSession call.
124
61
  sptConfiguration.tokenSwapURL = tokenSwapURL
125
62
  sptConfiguration.tokenRefreshURL = tokenRefreshURL
63
+ bridge.updateAuthContext(
64
+ tokenSwapURL: tokenSwapURL,
65
+ tokenRefreshURL: tokenRefreshURL
66
+ )
126
67
  // alwaysShowAuthorizationDialog is a property on SPTSessionManager, not an
127
68
  // SPTAuthorizationOptions flag (the options enum has no such case).
128
69
  sessionManager.alwaysShowAuthorizationDialog = showDialog
@@ -146,6 +87,7 @@ actor SpotifyAuthCoordinator {
146
87
  func deliver(_ result: Result<SPTSession, Error>) {
147
88
  let cont = pending
148
89
  pending = nil
90
+ bridge.clearAuthContext()
149
91
  cont?.resume(with: result)
150
92
  }
151
93
 
@@ -168,7 +110,21 @@ actor SpotifyAuthCoordinator {
168
110
  /// can't satisfy directly. The bridge is a tiny NSObject that hops the call
169
111
  /// onto the actor's executor.
170
112
  final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
113
+ private struct AuthContext {
114
+ let tokenSwapURL: URL?
115
+ let tokenRefreshURL: URL?
116
+ }
117
+
171
118
  weak var coordinator: SpotifyAuthCoordinator?
119
+ private var authContext: AuthContext?
120
+
121
+ func updateAuthContext(tokenSwapURL: URL?, tokenRefreshURL: URL?) {
122
+ authContext = AuthContext(tokenSwapURL: tokenSwapURL, tokenRefreshURL: tokenRefreshURL)
123
+ }
124
+
125
+ func clearAuthContext() {
126
+ authContext = nil
127
+ }
172
128
 
173
129
  func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
174
130
  NSLog(
@@ -182,8 +138,11 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
182
138
  }
183
139
 
184
140
  func sessionManager(manager _: SPTSessionManager, didFailWith error: Error) {
185
- let mapped: Error = mapSDKError(error)
186
141
  NSLog("[ExpoSpotifySDK] didFailWithError %@", String(describing: error))
142
+ let mapped = SpotifyAuthErrorMapping.classify(
143
+ error,
144
+ context: .init(tokenSwapConfigured: authContext?.tokenSwapURL != nil)
145
+ )
187
146
  Task { await coordinator?.deliver(.failure(mapped)) }
188
147
  }
189
148
 
@@ -195,41 +154,4 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
195
154
  )
196
155
  Task { await coordinator?.deliver(.success(session)) }
197
156
  }
198
-
199
- /// Translate a few well-known SDK error shapes into our taxonomy.
200
- private func mapSDKError(_ error: Error) -> Error {
201
- let nsError = error as NSError
202
- let description = nsError.localizedDescription.lowercased()
203
- if description.contains("cancel") {
204
- return SpotifyError.userCancelled
205
- }
206
- // Keep the original NSError as `cause` so the structured underlying-chain
207
- // is preserved (Sentry, debug breadcrumbs); the rendered string goes to
208
- // `message` so JS callers get a single human-readable line.
209
- return SpotifyError.underlying(message: describeError(nsError), cause: nsError)
210
- }
211
-
212
- /// Build a diagnostic string from an NSError that includes the domain,
213
- /// code, localized description, and the full chain of underlying errors.
214
- /// Used because `SPTError` (and many `URLSession` errors it wraps) often
215
- /// have an empty `localizedDescription`, surfacing as "undefined reason"
216
- /// in JS without this expansion.
217
- private func describeError(_ error: NSError) -> String {
218
- var parts: [String] = []
219
- parts.append("\(error.domain) code \(error.code)")
220
- let desc = error.localizedDescription
221
- if !desc.isEmpty {
222
- parts.append("\"\(desc)\"")
223
- }
224
- var ui = error.userInfo
225
- ui.removeValue(forKey: NSUnderlyingErrorKey)
226
- ui.removeValue(forKey: NSLocalizedDescriptionKey)
227
- if !ui.isEmpty {
228
- parts.append("userInfo=\(ui)")
229
- }
230
- if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
231
- parts.append("→ underlying: \(describeError(underlying))")
232
- }
233
- return parts.joined(separator: " ")
234
- }
235
157
  }
@@ -0,0 +1,259 @@
1
+ import AuthenticationServices
2
+ import Foundation
3
+
4
+ /// Maps a raw authentication `NSError` into the structured `SpotifyError`
5
+ /// taxonomy.
6
+ ///
7
+ /// This is the **single** canonical entry point for auth-error classification.
8
+ /// Both the `SPTSessionManager` delegate failure callback and the module-level
9
+ /// top-level `catch` route through `classify(_:context:)`, so a given raw error
10
+ /// yields the same `SpotifyError` regardless of which path delivers it. (The
11
+ /// Spotify iOS SDK does not always route cancellations through the delegate, so
12
+ /// the same error can arrive via either path.)
13
+ ///
14
+ /// Keep aligned with [docs/auth-error-mapping.md](../docs/auth-error-mapping.md).
15
+ enum SpotifyAuthErrorMapping {
16
+ /// Signals the classifier needs that aren't derivable from the error itself.
17
+ /// `tokenSwapConfigured` lets ambiguous SDK failures be treated as
18
+ /// token-swap-class errors when the caller supplied a token swap URL.
19
+ struct Context {
20
+ var tokenSwapConfigured: Bool = false
21
+ }
22
+
23
+ /// Walk the error once, then apply rules in priority order: typed/structural
24
+ /// signals first (cancellation domain/code, URL transport errors, parsed HTTP
25
+ /// status), then best-effort message-text heuristics, then `UNKNOWN`.
26
+ static func classify(_ error: Error, context: Context = Context()) -> SpotifyError {
27
+ let chain = nsErrorChain(error)
28
+ let root = chain[0]
29
+ // `detail` keeps raw `userInfo` for the in-process classification heuristics
30
+ // only; `redactedDetail` (keys-only `userInfo`) is the sole variant that
31
+ // leaves this process via NSLog or JS-visible error messages.
32
+ let detail = describeError(root)
33
+ let redactedDetail = describeError(root, redactUserInfo: true)
34
+ let result = classify(
35
+ chain: chain,
36
+ root: root,
37
+ detail: detail,
38
+ redactedDetail: redactedDetail,
39
+ context: context
40
+ )
41
+
42
+ NSLog(
43
+ "[ExpoSpotifySDK] classifyAuthError classified=%@ domain=%@ code=%d detail=%@",
44
+ result.code,
45
+ root.domain,
46
+ root.code,
47
+ redactedDetail
48
+ )
49
+ return result
50
+ }
51
+
52
+ private static func classify(
53
+ chain: [NSError],
54
+ root: NSError,
55
+ detail: String,
56
+ redactedDetail: String,
57
+ context: Context
58
+ ) -> SpotifyError {
59
+ // 1. User cancellation — a typed domain/code anywhere in the chain, or
60
+ // best-effort cancel text within a known auth/browser domain.
61
+ if chain.contains(where: { isKnownCancellationCode($0) || messageLooksCancelled($0) }) {
62
+ return .userCancelled
63
+ }
64
+
65
+ // 2. Transport failure anywhere in the chain (cancellation already handled).
66
+ if chain.contains(where: { $0.domain == NSURLErrorDomain && $0.code != NSURLErrorCancelled }) {
67
+ return .networkError(
68
+ message: "Network error during Spotify authentication: \(redactedDetail)",
69
+ cause: root
70
+ )
71
+ }
72
+
73
+ let lower = detail.lowercased()
74
+
75
+ // 3. OAuth-style rejection text — evaluated before the token-swap gate so
76
+ // these are never misclassified as token-swap errors.
77
+ if lower.contains("access_denied") || lower.contains("invalid_scope") ||
78
+ lower.contains("invalid_client") || lower.contains("authorization") ||
79
+ lower.contains("oauth") || lower.contains("spotify account") {
80
+ return .authError(message: "Spotify authorization failed: \(redactedDetail)", cause: root)
81
+ }
82
+
83
+ if let status = extractHTTPStatusCode(from: detail), status == 401 || status == 403 {
84
+ return .authError(
85
+ message: "Spotify authorization failed (HTTP \(status)): \(redactedDetail)",
86
+ cause: root
87
+ )
88
+ }
89
+
90
+ // 4. Token-swap-class failure — when a swap URL was configured, or the text
91
+ // explicitly references the token endpoint.
92
+ if context.tokenSwapConfigured || lower.contains("token swap") ||
93
+ lower.contains("token endpoint") || lower.contains("token exchange") {
94
+ if let status = extractHTTPStatusCode(from: detail) {
95
+ return .tokenSwapFailed(
96
+ status: status,
97
+ message: "Token swap server returned HTTP \(status): \(redactedDetail)",
98
+ cause: root
99
+ )
100
+ }
101
+ if lower.contains("parse") || lower.contains("json") ||
102
+ lower.contains("decode") || lower.contains("malformed") {
103
+ return .tokenSwapParseError(
104
+ message: "Token swap response was invalid: \(redactedDetail)",
105
+ cause: root
106
+ )
107
+ }
108
+ return .tokenSwapFailed(status: nil, message: "Token swap failed: \(redactedDetail)", cause: root)
109
+ }
110
+
111
+ // 5. Fallback. Keep the original NSError as `cause` so the structured
112
+ // underlying-chain is preserved (Sentry, debug breadcrumbs).
113
+ return .underlying(message: redactedDetail, cause: root)
114
+ }
115
+
116
+ // MARK: — Chain traversal
117
+
118
+ /// The error plus its `NSUnderlyingErrorKey` ancestry, root first. The chain
119
+ /// is effectively a singly-linked list; the `seen` set only guards against a
120
+ /// pathological cycle. Every rule above consumes this single walk instead of
121
+ /// re-implementing the traversal.
122
+ private static func nsErrorChain(_ error: Error) -> [NSError] {
123
+ var chain: [NSError] = []
124
+ var seen = Set<ObjectIdentifier>()
125
+ var current: NSError? = error as NSError
126
+ while let node = current, seen.insert(ObjectIdentifier(node)).inserted {
127
+ chain.append(node)
128
+ current = node.userInfo[NSUnderlyingErrorKey] as? NSError
129
+ }
130
+ return chain
131
+ }
132
+
133
+ // MARK: — Typed cancellation signals (locale-independent)
134
+
135
+ /// Recognise a single error's well-known cancellation domain/code. These are
136
+ /// stable identifiers and integer codes, so they hold across every locale.
137
+ private static func isKnownCancellationCode(_ error: NSError) -> Bool {
138
+ if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
139
+ return true
140
+ }
141
+
142
+ if #available(iOS 12.0, *),
143
+ error.domain == ASWebAuthenticationSessionErrorDomain,
144
+ error.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
145
+ return true
146
+ }
147
+
148
+ if error.domain == "SFAuthenticationErrorDomain" && error.code == 1 {
149
+ return true
150
+ }
151
+
152
+ // The Spotify iOS auth/login SDK reports user cancellation under its own
153
+ // login domain with code 1. This domain contains "spotify" but not "auth",
154
+ // so the fuzzy text matching below would not catch it either.
155
+ if error.domain == "com.spotify.sdk.login" && error.code == 1 {
156
+ return true
157
+ }
158
+
159
+ return false
160
+ }
161
+
162
+ // MARK: — Best-effort text heuristics (locale-dependent fallback)
163
+
164
+ /// Last-resort cancellation detection for errors that lack a recognised
165
+ /// domain/code. Matches localized message text, so it is English-biased by
166
+ /// nature — kept deliberately narrow (auth/browser domains only) so unrelated
167
+ /// transport/backend failures are never mapped to `USER_CANCELLED`.
168
+ private static func messageLooksCancelled(_ error: NSError) -> Bool {
169
+ guard isLikelyAuthCancellationDomain(error.domain) else {
170
+ return false
171
+ }
172
+
173
+ let cancelKeywords = ["cancel", "canceled", "cancelled"]
174
+ let negativeKeywords = [
175
+ "timed out", "timeout", "network", "offline", "unreachable",
176
+ "server", "unauthorized", "forbidden", "invalid"
177
+ ]
178
+ let combined = [
179
+ error.localizedDescription,
180
+ error.localizedFailureReason ?? "",
181
+ error.userInfo[NSLocalizedDescriptionKey] as? String ?? "",
182
+ error.userInfo[NSLocalizedFailureReasonErrorKey] as? String ?? ""
183
+ ].joined(separator: " ").lowercased()
184
+
185
+ if negativeKeywords.contains(where: { combined.contains($0) }) {
186
+ return false
187
+ }
188
+ return cancelKeywords.contains(where: { combined.contains($0) })
189
+ }
190
+
191
+ private static func isLikelyAuthCancellationDomain(_ domain: String) -> Bool {
192
+ if domain == ASWebAuthenticationSessionErrorDomain || domain == "SFAuthenticationErrorDomain" {
193
+ return true
194
+ }
195
+ // SPT wrappers commonly bubble auth-web errors under their own namespace.
196
+ let lower = domain.lowercased()
197
+ return lower.contains("spotify") && lower.contains("auth")
198
+ }
199
+
200
+ // MARK: — Diagnostics
201
+
202
+ /// Build a diagnostic string from an NSError that includes the domain, code,
203
+ /// localized description, and the full chain of underlying errors. Needed
204
+ /// because `SPTError` (and many `URLSession` errors it wraps) often have an
205
+ /// empty `localizedDescription`, surfacing as "undefined reason" in JS
206
+ /// without this expansion.
207
+ ///
208
+ /// When `redactUserInfo` is `true`, arbitrary `userInfo` *values* are dropped
209
+ /// in favour of a sorted key list. `userInfo` can carry secrets (tokens,
210
+ /// auth codes, raw request/response bodies), so any string that is logged or
211
+ /// returned to JS must use the redacted form; the unredacted form is for the
212
+ /// in-process classification heuristics only.
213
+ private static func describeError(_ error: NSError, redactUserInfo: Bool = false) -> String {
214
+ var parts: [String] = ["\(error.domain) code \(error.code)"]
215
+ let desc = error.localizedDescription
216
+ if !desc.isEmpty {
217
+ parts.append("\"\(desc)\"")
218
+ }
219
+ var ui = error.userInfo
220
+ ui.removeValue(forKey: NSUnderlyingErrorKey)
221
+ ui.removeValue(forKey: NSLocalizedDescriptionKey)
222
+ if !ui.isEmpty {
223
+ if redactUserInfo {
224
+ let keys = ui.keys.sorted().joined(separator: ", ")
225
+ parts.append("userInfoKeys=[\(keys)]")
226
+ } else {
227
+ parts.append("userInfo=\(ui)")
228
+ }
229
+ }
230
+ if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
231
+ parts.append("→ underlying: \(describeError(underlying, redactUserInfo: redactUserInfo))")
232
+ }
233
+ return parts.joined(separator: " ")
234
+ }
235
+
236
+ private static func extractHTTPStatusCode(from message: String) -> Int? {
237
+ let patterns = [
238
+ #"http\s+([1-5][0-9]{2})"#,
239
+ #"status(?:\s+code)?[:=\s]+([1-5][0-9]{2})"#
240
+ ]
241
+
242
+ for pattern in patterns {
243
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
244
+ continue
245
+ }
246
+ let range = NSRange(message.startIndex..<message.endIndex, in: message)
247
+ guard let match = regex.firstMatch(in: message, options: [], range: range),
248
+ match.numberOfRanges > 1,
249
+ let codeRange = Range(match.range(at: 1), in: message)
250
+ else {
251
+ continue
252
+ }
253
+ if let code = Int(message[codeRange]) {
254
+ return code
255
+ }
256
+ }
257
+ return nil
258
+ }
259
+ }
@@ -0,0 +1,82 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ /// Public, structured errors thrown by the auth coordinator. Each case carries
5
+ /// the JS-facing error code in `code`, mirroring the Android `CodedException`
6
+ /// taxonomy. Raw `NSError`s are turned into these by
7
+ /// `SpotifyAuthErrorMapping.classify(_:context:)`.
8
+ enum SpotifyError: Error {
9
+ case invalidConfiguration(String)
10
+ case authInProgress
11
+ case userCancelled
12
+ case spotifyNotInstalled
13
+ case networkError(message: String, cause: Error)
14
+ case tokenSwapFailed(status: Int?, message: String, cause: Error)
15
+ case tokenSwapParseError(message: String, cause: Error)
16
+ case authError(message: String, cause: Error)
17
+ case underlying(message: String, cause: Error)
18
+
19
+ var code: String {
20
+ switch self {
21
+ case .invalidConfiguration: return "INVALID_CONFIG"
22
+ case .authInProgress: return "AUTH_IN_PROGRESS"
23
+ case .userCancelled: return "USER_CANCELLED"
24
+ case .spotifyNotInstalled: return "SPOTIFY_NOT_INSTALLED"
25
+ case .networkError: return "NETWORK_ERROR"
26
+ case .tokenSwapFailed: return "TOKEN_SWAP_FAILED"
27
+ case .tokenSwapParseError: return "TOKEN_SWAP_PARSE_ERROR"
28
+ case .authError: return "AUTH_ERROR"
29
+ case .underlying: return "UNKNOWN"
30
+ }
31
+ }
32
+
33
+ var message: String {
34
+ switch self {
35
+ case .invalidConfiguration(let m): return m
36
+ case .authInProgress: return "Another authentication request is already in progress"
37
+ case .userCancelled: return "Authentication was cancelled by the user"
38
+ case .spotifyNotInstalled: return "The Spotify app is not installed on this device"
39
+ case .networkError(let message, _): return message
40
+ case .tokenSwapFailed(_, let message, _): return message
41
+ case .tokenSwapParseError(let message, _): return message
42
+ case .authError(let message, _): return message
43
+ case .underlying(let message, _): return message
44
+ }
45
+ }
46
+
47
+ /// The original error that caused this failure, if any. Surfaced as the
48
+ /// JS-facing exception's `cause` so debugging / Sentry breadcrumbs keep the
49
+ /// full chain rather than collapsing into "undefined reason".
50
+ var underlyingCause: Error? {
51
+ switch self {
52
+ case .networkError(_, let cause): return cause
53
+ case .tokenSwapFailed(_, _, let cause): return cause
54
+ case .tokenSwapParseError(_, let cause): return cause
55
+ case .authError(_, let cause): return cause
56
+ case .underlying(_, let cause): return cause
57
+ default: return nil
58
+ }
59
+ }
60
+ }
61
+
62
+ /// `Exception` subclass that projects a `SpotifyError`'s `code` and `message`
63
+ /// through `expo-modules-core`'s exception bridge so JS receives the structured
64
+ /// code and a meaningful reason — not the default "undefined reason" placeholder.
65
+ ///
66
+ /// Without this wrapper an `AsyncFunction` rejection collapses to
67
+ /// `FunctionCallException` → `cause.reason = "undefined reason"` because the
68
+ /// previously-used `GenericException<String>` does not override `reason`.
69
+ final class SpotifyAuthException: Exception, @unchecked Sendable {
70
+ private let spotifyCode: String
71
+ private let spotifyMessage: String
72
+
73
+ init(_ error: SpotifyError, file: String = #fileID, line: UInt = #line, function: String = #function) {
74
+ self.spotifyCode = error.code
75
+ self.spotifyMessage = error.message
76
+ super.init(file: file, line: line, function: function)
77
+ self.cause = error.underlyingCause
78
+ }
79
+
80
+ override var code: String { spotifyCode }
81
+ override var reason: String { spotifyMessage }
82
+ }
@@ -5,14 +5,16 @@ enum SpotifyRefreshError: Error {
5
5
  case invalidURL(String)
6
6
  case network(Error)
7
7
  case http(status: Int, body: String?)
8
+ case refreshTokenExpired
8
9
  case parse(String)
9
10
 
10
11
  var code: String {
11
12
  switch self {
12
- case .invalidURL: return "INVALID_CONFIG"
13
- case .network: return "NETWORK_ERROR"
14
- case .http: return "TOKEN_SWAP_FAILED"
15
- case .parse: return "TOKEN_SWAP_PARSE_ERROR"
13
+ case .invalidURL: return "INVALID_CONFIG"
14
+ case .network: return "NETWORK_ERROR"
15
+ case .http: return "TOKEN_SWAP_FAILED"
16
+ case .refreshTokenExpired: return "REFRESH_TOKEN_EXPIRED"
17
+ case .parse: return "TOKEN_SWAP_PARSE_ERROR"
16
18
  }
17
19
  }
18
20
 
@@ -25,6 +27,8 @@ enum SpotifyRefreshError: Error {
25
27
  case .http(let status, let body):
26
28
  let trimmed = body.map { String($0.prefix(512)) } ?? ""
27
29
  return "Token refresh server returned HTTP \(status)\(trimmed.isEmpty ? "" : ": \(trimmed)")"
30
+ case .refreshTokenExpired:
31
+ return "The refresh token is no longer valid (expired or revoked). The user must sign in again."
28
32
  case .parse(let m):
29
33
  return m
30
34
  }
@@ -106,6 +110,13 @@ struct SpotifyTokenRefreshClient {
106
110
  }
107
111
  guard (200..<300).contains(http.statusCode) else {
108
112
  let body = String(data: data, encoding: .utf8)
113
+ // Spotify returns `invalid_grant` (HTTP 400) when the refresh token has
114
+ // expired or been revoked — the user must re-authenticate. Surface this
115
+ // as a dedicated code rather than a generic token-swap failure so callers
116
+ // can branch to sign-in without inspecting the message string.
117
+ if let body, body.range(of: "invalid_grant", options: .caseInsensitive) != nil {
118
+ throw SpotifyRefreshError.refreshTokenExpired
119
+ }
109
120
  throw SpotifyRefreshError.http(status: http.statusCode, body: body)
110
121
  }
111
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwdrew/expo-spotify-sdk",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication and App Remote playback control",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",