@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 +37 -0
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +15 -1
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +7 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +37 -4
- package/build/auth/error.d.ts +1 -1
- package/build/auth/error.d.ts.map +1 -1
- package/build/auth/error.js.map +1 -1
- package/build/auth/index.js +1 -1
- package/build/auth/index.js.map +1 -1
- package/build/internal/native-errors.d.ts +0 -2
- package/build/internal/native-errors.d.ts.map +1 -1
- package/build/internal/native-errors.js +5 -8
- package/build/internal/native-errors.js.map +1 -1
- package/ios/ExpoSpotifySDKModule.swift +26 -15
- package/ios/SpotifyAuthCoordinator.swift +23 -101
- package/ios/SpotifyAuthErrorMapping.swift +259 -0
- package/ios/SpotifyError.swift +82 -0
- package/ios/SpotifyTokenRefreshClient.swift +15 -4
- package/package.json +1 -1
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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>,
|
package/build/auth/error.d.ts
CHANGED
|
@@ -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"}
|
package/build/auth/error.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;
|
|
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"]}
|
package/build/auth/index.js
CHANGED
|
@@ -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",
|
package/build/auth/index.js.map
CHANGED
|
@@ -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;
|
|
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,
|
|
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;
|
|
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.
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
13
|
-
case .network:
|
|
14
|
-
case .http:
|
|
15
|
-
case .
|
|
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.
|
|
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",
|