Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/src/pages/spotify-auth.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<template>
<!-- TODO: loading spinner or something here ? -->
<div />
</template>

<script>
import {
getAndClearSpotifyOauthState,
getAndClearSpotifyOauthRedirect,
} from '~/util/localStorage';

export default {
async mounted() {
const state = this.$route.query.state;
const code = this.$route.query.code;
const error = this.$route.query.error;

const savedState = getAndClearSpotifyOauthState();
const redirect = getAndClearSpotifyOauthRedirect() || '/';

const doRedirect = () => this.$router.replace(redirect);

if (state !== savedState) {
this.showError('Invalid Oauth state');
return doRedirect();
}

if (!redirect) {
this.showError('Invalid Oauth token');
return doRedirect();
}

if (error) {
this.showError('Oauth error');
return doRedirect();
}

let resp;
try {
resp = await this.redeemCode(code);
} catch (err) {
this.showError('Error redeeming code');
return doRedirect();
}
localStorage.setItem('spotifyRefreshToken', resp.refreshToken);
localStorage.setItem('spotifyAccessToken', resp.accessToken);
localStorage.setItem('spotifyExpiresIn', resp.expiresIn);
this.$store.dispatch('updateStreamingService', 'spotify');
},

async redeemCode(code) {
const resp = await this.$axios({
method: 'post',
url: '/api/spotify-token/swap',
data: { code },
});
return resp.data;
},

showError(error) {
// remove error from path to prevent re-triggering
this.$router.replace(this.$route.path);

if (error === 'nonPremium') {
this.$store.commit(
'showErrorModal',
"Error connecting Spotify: You must have a premium (paid) Spotify account to stream to it from Jam Buds.\n\nSorry, I don't make the rules :("
);
} else {
this.$store.commit(
'showErrorModal',
`Error connecting Spotify: ${error || '(unknown)'}`
);
}
},
};
</script>
32 changes: 32 additions & 0 deletions app/src/util/localStorage.js
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -32,21 +40,21 @@ class SpotifyAuthService(
"user-read-private"
).joinToString(",")

val authorizationCodeUriRequest = createSpotifyApi().authorizationCodeUri()
val authorizationCodeUriRequest = createSpotifyApi(isMobile = false).authorizationCodeUri()
.state(stateToken)
.scope(scopes)
.build()

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
Expand All @@ -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()
Expand Down
34 changes: 32 additions & 2 deletions rhiannon/src/main/kotlin/club/jambuds/web/SpotifyAuthRoutes.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
}