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) + } }