diff --git a/app/src/pages/spotify-auth.vue b/app/src/pages/spotify-auth.vue
new file mode 100644
index 00000000..7da8c37d
--- /dev/null
+++ b/app/src/pages/spotify-auth.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/util/localStorage.js b/app/src/util/localStorage.js
new file mode 100644
index 00000000..c7c2c038
--- /dev/null
+++ b/app/src/util/localStorage.js
@@ -0,0 +1,32 @@
+const SPOTIFY_OAUTH_STATE_KEY = 'spotifyOauthState';
+const SPOTIFY_OAUTH_REDIRECT_KEY = 'spotifyOauthRedirect';
+
+// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
+function randomCryptoString() {
+ const validChars =
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ let array = new Uint8Array(40);
+ window.crypto.getRandomValues(array);
+ array = array.map((x) => validChars.charCodeAt(x % validChars.length));
+ const randomState = String.fromCharCode.apply(null, array);
+ return randomState;
+}
+
+export function getAndClearSpotifyOauthState() {
+ const state = localStorage.getItem(SPOTIFY_OAUTH_STATE_KEY);
+ localStorage.removeItem(SPOTIFY_OAUTH_STATE_KEY);
+ return state;
+}
+export function getAndClearSpotifyOauthRedirect() {
+ const redirect = localStorage.getItem(SPOTIFY_OAUTH_REDIRECT_KEY);
+ localStorage.removeItem(SPOTIFY_OAUTH_REDIRECT_KEY);
+ return redirect;
+}
+export function createAndSetSpotifyOauthState() {
+ const state = randomCryptoString();
+ localStorage.setItem(SPOTIFY_OAUTH_STATE_KEY, state);
+ return state;
+}
+export function setSpotifyOauthRedirect(redirect) {
+ localStorage.setItem(SPOTIFY_OAUTH_REDIRECT_KEY, redirect);
+}
diff --git a/rhiannon/src/main/kotlin/club/jambuds/responses/RefreshSpotifyTokenResponse.kt b/rhiannon/src/main/kotlin/club/jambuds/responses/RefreshSpotifyTokenResponse.kt
new file mode 100644
index 00000000..57054714
--- /dev/null
+++ b/rhiannon/src/main/kotlin/club/jambuds/responses/RefreshSpotifyTokenResponse.kt
@@ -0,0 +1,12 @@
+package club.jambuds.responses
+
+import com.google.gson.annotations.Expose
+import com.google.gson.annotations.SerializedName
+
+// https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/
+data class RefreshSpotifyTokenResponse(
+ @SerializedName("access_token")
+ @Expose val accessToken: String,
+ @SerializedName("expires_in")
+ @Expose val expiresIn: Int
+)
diff --git a/rhiannon/src/main/kotlin/club/jambuds/responses/SwapSpotifyTokenResponse.kt b/rhiannon/src/main/kotlin/club/jambuds/responses/SwapSpotifyTokenResponse.kt
new file mode 100644
index 00000000..7ad09fcb
--- /dev/null
+++ b/rhiannon/src/main/kotlin/club/jambuds/responses/SwapSpotifyTokenResponse.kt
@@ -0,0 +1,14 @@
+package club.jambuds.responses
+
+import com.google.gson.annotations.Expose
+import com.google.gson.annotations.SerializedName
+
+// https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/
+data class SwapSpotifyTokenResponse(
+ @SerializedName("refresh_token")
+ @Expose val refreshToken: String,
+ @SerializedName("access_token")
+ @Expose val accessToken: String,
+ @SerializedName("expires_in")
+ @Expose val expiresIn: Int
+)
diff --git a/rhiannon/src/main/kotlin/club/jambuds/service/SpotifyAuthService.kt b/rhiannon/src/main/kotlin/club/jambuds/service/SpotifyAuthService.kt
index 97bbf367..3d64e484 100644
--- a/rhiannon/src/main/kotlin/club/jambuds/service/SpotifyAuthService.kt
+++ b/rhiannon/src/main/kotlin/club/jambuds/service/SpotifyAuthService.kt
@@ -14,11 +14,19 @@ class SpotifyAuthService(
private val clientId: String,
private val clientSecret: String
) {
- private fun createSpotifyApi(): SpotifyApi {
+ private val mobileRedirectUri = SpotifyHttpManager.makeUri("jambuds://spotify-auth-callback")
+ private val webRedirectUri = SpotifyHttpManager.makeUri("$appUrl/auth/spotify-connect/cb")
+
+ private fun createSpotifyApi(isMobile: Boolean): SpotifyApi {
+ val redirectUri = if (isMobile) {
+ mobileRedirectUri
+ } else {
+ webRedirectUri
+ }
return SpotifyApi.Builder()
.setClientId(clientId)
.setClientSecret(clientSecret)
- .setRedirectUri(SpotifyHttpManager.makeUri("$appUrl/auth/spotify-connect/cb"))
+ .setRedirectUri(redirectUri)
.build()
}
@@ -32,7 +40,7 @@ class SpotifyAuthService(
"user-read-private"
).joinToString(",")
- val authorizationCodeUriRequest = createSpotifyApi().authorizationCodeUri()
+ val authorizationCodeUriRequest = createSpotifyApi(isMobile = false).authorizationCodeUri()
.state(stateToken)
.scope(scopes)
.build()
@@ -40,13 +48,13 @@ class SpotifyAuthService(
return authorizationCodeUriRequest.execute()
}
- fun redeemCallbackCode(code: String): AuthorizationCodeCredentials {
- val authorizationCodeRequest = createSpotifyApi().authorizationCode(code).build()
+ fun redeemCallbackCode(code: String, isMobile: Boolean): AuthorizationCodeCredentials {
+ val authorizationCodeRequest = createSpotifyApi(isMobile).authorizationCode(code).build()
return authorizationCodeRequest.execute()
}
fun validateUserCanPlayback(credentials: AuthorizationCodeCredentials): Boolean {
- val spotifyApi = createSpotifyApi()
+ val spotifyApi = createSpotifyApi(isMobile = false)
spotifyApi.accessToken = credentials.accessToken
val resp = spotifyApi.currentUsersProfile.build().execute()
return resp.product == ProductType.PREMIUM
@@ -57,8 +65,11 @@ class SpotifyAuthService(
?: throw IllegalStateException("No redirect path found for state $stateToken")
}
- fun getRefreshedCredentials(spotifyRefreshToken: String): AuthorizationCodeCredentials? {
- val spotifyApi = createSpotifyApi()
+ fun getRefreshedCredentials(
+ spotifyRefreshToken: String,
+ isMobile: Boolean
+ ): AuthorizationCodeCredentials? {
+ val spotifyApi = createSpotifyApi(isMobile = isMobile)
val req = spotifyApi.authorizationCodeRefresh()
.refresh_token(spotifyRefreshToken)
.build()
diff --git a/rhiannon/src/main/kotlin/club/jambuds/web/SpotifyAuthRoutes.kt b/rhiannon/src/main/kotlin/club/jambuds/web/SpotifyAuthRoutes.kt
index e94b0b2b..48471773 100644
--- a/rhiannon/src/main/kotlin/club/jambuds/web/SpotifyAuthRoutes.kt
+++ b/rhiannon/src/main/kotlin/club/jambuds/web/SpotifyAuthRoutes.kt
@@ -1,10 +1,13 @@
package club.jambuds.web
+import club.jambuds.responses.RefreshSpotifyTokenResponse
import club.jambuds.responses.SpotifyTokenResponse
+import club.jambuds.responses.SwapSpotifyTokenResponse
import club.jambuds.service.SpotifyAuthService
import com.wrapper.spotify.model_objects.credentials.AuthorizationCodeCredentials
import io.javalin.apibuilder.ApiBuilder
import io.javalin.http.Context
+import io.javalin.http.UnauthorizedResponse
import org.eclipse.jetty.http.HttpCookie.SAME_SITE_STRICT_COMMENT
import java.time.Instant
import javax.servlet.http.Cookie
@@ -15,6 +18,10 @@ class SpotifyAuthRoutes(private val spotifyAuthService: SpotifyAuthService) {
ApiBuilder.get("/auth/spotify-connect/cb", this::spotifyAuthCallback)
ApiBuilder.get("/api/spotify-token", this::getSpotifyToken)
ApiBuilder.delete("/api/spotify-token", this::deleteSpotifyToken)
+
+ // mobile
+ ApiBuilder.post("/api/spotify-token/swap", this::swapSpotifyToken)
+ ApiBuilder.post("/api/spotify-token/refresh", this::refreshSpotifyToken)
}
private fun redirectToSpotifyAuth(ctx: Context) {
@@ -41,7 +48,7 @@ class SpotifyAuthRoutes(private val spotifyAuthService: SpotifyAuthService) {
throw IllegalStateException("Missing one of code or error in spotify callback")
}
- val credentials = spotifyAuthService.redeemCallbackCode(code)
+ val credentials = spotifyAuthService.redeemCallbackCode(code, isMobile = false)
val canPlayback = spotifyAuthService.validateUserCanPlayback(credentials)
if (!canPlayback) {
@@ -82,7 +89,8 @@ class SpotifyAuthRoutes(private val spotifyAuthService: SpotifyAuthService) {
return
}
- val credentials = spotifyAuthService.getRefreshedCredentials(spotifyRefreshToken)
+ val credentials =
+ spotifyAuthService.getRefreshedCredentials(spotifyRefreshToken, isMobile = false)
if (credentials == null) {
val resp = SpotifyTokenResponse(
@@ -146,4 +154,26 @@ class SpotifyAuthRoutes(private val spotifyAuthService: SpotifyAuthService) {
ctx.cookie(cookie)
}
}
+
+ private fun swapSpotifyToken(ctx: Context) {
+ val code = ctx.formParam("code", String::class.java).get()
+ val credentials = spotifyAuthService.redeemCallbackCode(code, isMobile = true)
+ val resp = SwapSpotifyTokenResponse(
+ refreshToken = credentials.refreshToken,
+ accessToken = credentials.accessToken,
+ expiresIn = credentials.expiresIn
+ )
+ ctx.json(resp)
+ }
+
+ private fun refreshSpotifyToken(ctx: Context) {
+ val refreshToken = ctx.formParam("refresh_token", String::class.java).get()
+ val credentials = spotifyAuthService.getRefreshedCredentials(refreshToken, isMobile = true)
+ ?: throw UnauthorizedResponse("Refresh token was revoked")
+ val resp = RefreshSpotifyTokenResponse(
+ accessToken = credentials.accessToken,
+ expiresIn = credentials.expiresIn
+ )
+ ctx.json(resp)
+ }
}