kotlinoauthspotifyktorjetbrains-compose

Using Ktor for Spotify PCKE Authorization


I'm building my first desktop application using JetBrains Compose & Ktor. I want to connect to the Spotify API using the Spotify Auth PCKE extension. The flow should be

  1. Send a Get request for auth to accounts.spotify.com/authorize
  2. If the user not auth'd -> launch a webpage for the user to sign-in
  3. Spotify sends a callback to a url provided in the initial request.
  4. Recieve a Token from Spotify

I'm facing an issue when launching the Webpage for user sign-in and getting the response on the URL I've provided. I'm launching the webpage using the desktop's default browse and opening a websocket that should be listening to "http://localhost:8888/callback)." I noticed when I interact with the launched webpage, the url shows the response that Spotify sent back ("http://localhost:8888/callback?error=access_denied&state=initial" but my websocket code is never called. Is this an issue with how I'm launching the browser or the websocket... or am I going about this wrong in general?

class SpotifyClient {
    private val client: HttpClient = HttpClient(CIO) {
        followRedirects = true
        install(WebSockets) {
            contentConverter = KotlinxWebsocketSerializationConverter(Json)
        }
        handleSpotifyAccountAuthResponse()
    }



    private val clientId = "<Removed for Post>"


    suspend fun authorizeSpotifyClient(): HttpResponse {

        val withContext = withContext(Dispatchers.IO) {

            val response: HttpResponse =
                client.get(NetworkConstants.BASE_URL_SPOTIFY_ACCOUNTS + NetworkConstants.PATH_SPOTIFY_AUTH) {
                    header("Location", NetworkConstants.BASE_URL_SPOTIFY_AUTH_REDIRECT_URI)
                    parameter("client_id", clientId)
                    parameter("response_type", "code")
                    parameter("redirect_uri", NetworkConstants.BASE_URL_SPOTIFY_AUTH_REDIRECT_URI)
                    parameter("state", ClientStates.INITIAL.value)
                    parameter("show_dialog", false)
                    parameter("code_challenge_method", PCKE_CODE_CHALLENGE_METHOD)
                    parameter("code_challenge", generatePCKECodeChallenge())
                    parameter(
                        "scope",
                        NetworkUtils.getSpotifyScopes(
                            ImmutableList.of(
                                SpotifyScopes.USER_READ_PLAYBACK_STATE,
                                SpotifyScopes.USER_READ_CURRENTLY_PLAYING
                            )
                        )
                    )

                }
            println("Auth Spotify API Response $response")
            println("Auth Spotify API Response Code ${response.status}")
            client.close()
            return@withContext response
        }
        accountAuthRedirectWebsocket()
        return withContext
    }

    private fun HttpClientConfig<CIOEngineConfig>.handleSpotifyAccountAuthResponse() {
        HttpResponseValidator {

            validateResponse { response ->
                handleValidAccountResponse(response)
            }

            handleResponseExceptionWithRequest { exception, request ->

            }
        }
    }

    private fun handleValidAccountResponse(response: HttpResponse) {
        if (response.status.value == 200) { // success
            val responseUrl = response.call.request.url
            if (responseUrl.toString().contains("continue")) {
                println("Needs a redirect, ${responseUrl.toString()}")
                openWebpage(responseUrl.toURI())
            }

        }
    }



    private fun openWebpage(uri: URI?): Boolean {
        val desktop = if (Desktop.isDesktopSupported()) Desktop.getDesktop() else null
        if (desktop != null && desktop.isSupported(Desktop.Action.BROWSE)) {
            try {
                desktop.browse(uri)

                return true
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
        return false
    }

    private suspend fun accountAuthRedirectWebsocket(){
        client.webSocket(method = HttpMethod.Get, host = "localhost", port = 8888, path = "/customer/1") {
            println("Got a response in the socket")
        }
    }

Solution

  • To receive the callback I needed to implement a local server like below. Using the WebSocket was the wrong approach in this case and didn't provide the functionality I was expecting.

    object SpotifyServer {
    
        suspend fun initServer() {
            embeddedServer(CIO,
                host = "127.0.0.1",
                port = 8080,
                configure = {}) {
                routing {
                    get("/callback") {
                            call.respondText("Got a callback response")
    
                    }
                }
            }.start(wait = true)
        }
    }