Skip to content

Commit dc3f5c8

Browse files
committed
support pkce
1 parent f623419 commit dc3f5c8

File tree

6 files changed

+184
-75
lines changed

6 files changed

+184
-75
lines changed

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ kotlin {
176176
api("io.ktor:ktor-client-js:$ktorVersion")
177177
api(npm("abort-controller", "3.0.0"))
178178
api(npm("node-fetch", "2.6.0"))
179-
179+
api(npm("btoa", "1.2.1"))
180180
compileOnly(kotlin("stdlib-js"))
181181
}
182182
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.adamratzman.spotify
2+
3+
import android.util.Base64
4+
import java.security.MessageDigest
5+
6+
actual fun getSpotifyPkceCodeChallenge(codeVerifier: String): String {
7+
val sha256 = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
8+
return Base64.encodeToString(sha256, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
9+
}

src/commonMain/kotlin/com.adamratzman.spotify/Builder.kt

Lines changed: 144 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ import kotlinx.serialization.json.Json
3131
* @param state This provides protection against attacks such as cross-site request forgery.
3232
*/
3333
fun getSpotifyAuthorizationUrl(
34-
vararg scopes: SpotifyScope,
35-
clientId: String,
36-
redirectUri: String,
37-
isImplicitGrantFlow: Boolean = false,
38-
shouldShowDialog: Boolean = false,
39-
state: String? = null
34+
vararg scopes: SpotifyScope,
35+
clientId: String,
36+
redirectUri: String,
37+
isImplicitGrantFlow: Boolean = false,
38+
shouldShowDialog: Boolean = false,
39+
state: String? = null
4040
): String {
4141
return SpotifyApi.getAuthUrlFull(
4242
*scopes,
@@ -48,6 +48,38 @@ fun getSpotifyAuthorizationUrl(
4848
)
4949
}
5050

51+
/**
52+
* Get the PKCE authorization url for the provided [clientId] and [redirectUri] application settings, when attempting to authorize with
53+
* specified [scopes]
54+
*
55+
* @param scopes Spotify scopes the api instance should be able to access for the user
56+
* @param clientId Spotify [client id](https://developer.spotify.com/documentation/general/guides/app-settings/)
57+
* @param redirectUri Spotify [redirect uri](https://developer.spotify.com/documentation/general/guides/app-settings/)
58+
* @param state This provides protection against attacks such as cross-site request forgery.
59+
* @param codeChallenge In order to generate the code challenge, your app should hash the code verifier using the SHA256 algorithm.
60+
* Then, base64url encode the hash that you generated.
61+
*/
62+
fun getPkceAuthorizationUrl(
63+
vararg scopes: SpotifyScope,
64+
clientId: String,
65+
redirectUri: String,
66+
codeChallenge: String,
67+
state: String? = null
68+
): String {
69+
return SpotifyApi.getPkceAuthUrlFull(
70+
*scopes,
71+
clientId = clientId,
72+
redirectUri = redirectUri,
73+
codeChallenge = codeChallenge,
74+
state = state
75+
)
76+
}
77+
78+
/**
79+
* A utility to get the pkce code challenge for a corresponding code verifier. Only available on JVM/Android
80+
*/
81+
expect fun getSpotifyPkceCodeChallenge(codeVerifier: String): String
82+
5183
// ==============================================
5284

5385
// Implicit grant builder
@@ -192,10 +224,10 @@ fun spotifyAppApi(block: SpotifyAppApiBuilder.() -> Unit) = SpotifyAppApiBuilder
192224
* @return Configurable [SpotifyClientApiBuilder] that, when built, creates a new [SpotifyClientApi]
193225
*/
194226
fun spotifyClientApi(
195-
clientId: String,
196-
clientSecret: String,
197-
redirectUri: String,
198-
block: SpotifyClientApiBuilder.() -> Unit
227+
clientId: String,
228+
clientSecret: String,
229+
redirectUri: String,
230+
block: SpotifyClientApiBuilder.() -> Unit
199231
) = SpotifyClientApiBuilder().apply(block).apply {
200232
credentials {
201233
this.clientId = clientId
@@ -216,10 +248,10 @@ fun spotifyClientApi(
216248
* @return Configurable [SpotifyClientApiBuilder] that, when built, creates a new [SpotifyClientApi]
217249
*/
218250
fun spotifyClientApi(
219-
clientId: String?,
220-
clientSecret: String?,
221-
redirectUri: String?,
222-
apiToken: Token
251+
clientId: String?,
252+
clientSecret: String?,
253+
redirectUri: String?,
254+
apiToken: Token
223255
) = SpotifyClientApiBuilder().apply {
224256
credentials {
225257
this.clientId = clientId
@@ -249,12 +281,12 @@ fun spotifyClientApi(
249281
* @return Configurable [SpotifyClientApiBuilder] that, when built, creates a new [SpotifyClientApi]
250282
*/
251283
fun spotifyClientApi(
252-
clientId: String,
253-
clientSecret: String,
254-
redirectUri: String,
255-
authorization: SpotifyUserAuthorization,
256-
options: SpotifyApiOptions? = null,
257-
block: SpotifyClientApiBuilder.() -> Unit = {}
284+
clientId: String,
285+
clientSecret: String,
286+
redirectUri: String,
287+
authorization: SpotifyUserAuthorization,
288+
options: SpotifyApiOptions? = null,
289+
block: SpotifyClientApiBuilder.() -> Unit = {}
258290
) = SpotifyClientApiBuilder().apply(block).apply {
259291
credentials {
260292
this.clientId = clientId
@@ -281,9 +313,9 @@ fun spotifyClientApi(block: SpotifyClientApiBuilder.() -> Unit) = SpotifyClientA
281313
* Spotify API builder
282314
*/
283315
class SpotifyApiBuilder(
284-
private var clientId: String?,
285-
private var clientSecret: String?,
286-
private var redirectUri: String?
316+
private var clientId: String?,
317+
private var clientSecret: String?,
318+
private var redirectUri: String?
287319
) {
288320
/**
289321
* Allows you to authenticate a [SpotifyClientApi] with an authorization code
@@ -561,9 +593,9 @@ interface ISpotifyClientApiBuilder : ISpotifyApiBuilder<SpotifyClientApi, Spotif
561593
* [SpotifyClientApi] builder for api creation using client authorization
562594
*/
563595
class SpotifyClientApiBuilder(
564-
override var credentials: SpotifyCredentials = SpotifyCredentialsBuilder().build(),
565-
override var authorization: SpotifyUserAuthorization = SpotifyUserAuthorizationBuilder().build(),
566-
override var options: SpotifyApiOptions = SpotifyApiOptionsBuilder().build()
596+
override var credentials: SpotifyCredentials = SpotifyCredentialsBuilder().build(),
597+
override var authorization: SpotifyUserAuthorization = SpotifyUserAuthorizationBuilder().build(),
598+
override var options: SpotifyApiOptions = SpotifyApiOptionsBuilder().build()
567599
) : ISpotifyClientApiBuilder {
568600
override fun getAuthorizationUrl(vararg scopes: SpotifyScope, state: String?): String {
569601
require(credentials.redirectUri != null && credentials.clientId != null) { "You didn't specify a redirect uri or client id in the credentials block!" }
@@ -583,7 +615,7 @@ class SpotifyClientApiBuilder(
583615
// either application credentials, or a token is required
584616
require((clientId != null && clientSecret != null && redirectUri != null) || authorization.token != null || authorization.tokenString != null) { "You need to specify a valid clientId, clientSecret, and redirectUri in the credentials block!" }
585617
return when {
586-
authorization.authorizationCode != null -> try {
618+
authorization.authorizationCode != null && authorization.pkceCodeVerifier == null -> try {
587619
require(clientId != null && clientSecret != null && redirectUri != null) { "You need to specify a valid clientId, clientSecret, and redirectUri in the credentials block!" }
588620

589621
val response = executeTokenRequest(
@@ -617,7 +649,50 @@ class SpotifyClientApiBuilder(
617649
options.allowBulkRequests,
618650
options.requestTimeoutMillis,
619651
options.json,
620-
options.refreshTokenProducer
652+
options.refreshTokenProducer,
653+
options.usesPkceAuth
654+
)
655+
} catch (e: CancellationException) {
656+
throw e
657+
} catch (e: Exception) {
658+
throw SpotifyException.AuthenticationException("Invalid credentials provided in the login process (clientId=$clientId, clientSecret=$clientSecret, authCode=${authorization.authorizationCode})", e)
659+
}
660+
authorization.authorizationCode != null && authorization.pkceCodeVerifier != null -> try {
661+
require(clientId != null && redirectUri != null) { "You need to specify a valid clientId, clientSecret, and redirectUri in the credentials block!" }
662+
663+
val response = HttpConnection(
664+
"https://accounts.spotify.com/api/token",
665+
HttpRequestMethod.POST,
666+
mapOf(
667+
"grant_type" to "authorization_code",
668+
"code" to authorization.authorizationCode,
669+
"redirect_uri" to redirectUri,
670+
"client_id" to clientId,
671+
"code_verifier" to authorization.pkceCodeVerifier
672+
),
673+
null,
674+
"application/x-www-form-urlencoded",
675+
listOf(),
676+
null
677+
).execute()
678+
679+
SpotifyClientApi(
680+
clientId,
681+
clientSecret,
682+
redirectUri,
683+
response.body.toObject(Token.serializer(), null, options.json),
684+
options.useCache,
685+
options.cacheLimit,
686+
options.automaticRefresh,
687+
options.retryWhenRateLimited,
688+
options.enableLogger,
689+
options.testTokenValidity,
690+
options.defaultLimit,
691+
options.allowBulkRequests,
692+
options.requestTimeoutMillis,
693+
options.json,
694+
options.refreshTokenProducer,
695+
options.usesPkceAuth
621696
)
622697
} catch (e: CancellationException) {
623698
throw e
@@ -639,7 +714,8 @@ class SpotifyClientApiBuilder(
639714
options.allowBulkRequests,
640715
options.requestTimeoutMillis,
641716
options.json,
642-
options.refreshTokenProducer
717+
options.refreshTokenProducer,
718+
options.usesPkceAuth
643719
)
644720
authorization.tokenString != null -> SpotifyClientApi(
645721
clientId,
@@ -662,7 +738,8 @@ class SpotifyClientApiBuilder(
662738
options.allowBulkRequests,
663739
options.requestTimeoutMillis,
664740
options.json,
665-
options.refreshTokenProducer
741+
options.refreshTokenProducer,
742+
options.usesPkceAuth
666743
)
667744
else -> throw IllegalArgumentException(
668745
"At least one of: authorizationCode, tokenString, or token must be provided " +
@@ -681,9 +758,9 @@ interface ISpotifyAppApiBuilder : ISpotifyApiBuilder<SpotifyAppApi, SpotifyAppAp
681758
* [SpotifyAppApi] builder for api creation using client authorization
682759
*/
683760
class SpotifyAppApiBuilder(
684-
override var credentials: SpotifyCredentials = SpotifyCredentialsBuilder().build(),
685-
override var authorization: SpotifyUserAuthorization = SpotifyUserAuthorizationBuilder().build(),
686-
override var options: SpotifyApiOptions = SpotifyApiOptionsBuilder().build()
761+
override var credentials: SpotifyCredentials = SpotifyCredentialsBuilder().build(),
762+
override var authorization: SpotifyUserAuthorization = SpotifyUserAuthorizationBuilder().build(),
763+
override var options: SpotifyApiOptions = SpotifyApiOptionsBuilder().build()
687764
) : ISpotifyAppApiBuilder {
688765
/**
689766
* Build a public [SpotifyAppApi] using the provided credentials
@@ -806,10 +883,10 @@ data class SpotifyCredentials(val clientId: String?, val clientSecret: String?,
806883
* limited time constraint on these before the API automatically refreshes them
807884
*/
808885
class SpotifyUserAuthorizationBuilder(
809-
var authorizationCode: String? = null,
810-
var tokenString: String? = null,
811-
var token: Token? = null,
812-
var refreshTokenString: String? = null
886+
var authorizationCode: String? = null,
887+
var tokenString: String? = null,
888+
var token: Token? = null,
889+
var refreshTokenString: String? = null
813890
) {
814891
fun build() = SpotifyUserAuthorization(authorizationCode, tokenString, token, refreshTokenString)
815892
}
@@ -824,12 +901,14 @@ class SpotifyUserAuthorizationBuilder(
824901
* will be your **access** token. If you're building [SpotifyApi], it will be your **refresh** token. There is a *very*
825902
* limited time constraint on these before the API automatically refreshes them
826903
* @property refreshTokenString Refresh token, given as a string, to be exchanged to Spotify for a new token
904+
* @property pkceCodeVerifier The code verifier generated that the client authenticated with (using its code challenge)
827905
*/
828906
data class SpotifyUserAuthorization(
829-
var authorizationCode: String? = null,
830-
var tokenString: String? = null,
831-
var token: Token? = null,
832-
var refreshTokenString: String? = null
907+
var authorizationCode: String? = null,
908+
var tokenString: String? = null,
909+
var token: Token? = null,
910+
var refreshTokenString: String? = null,
911+
var pkceCodeVerifier: String? = null
833912
)
834913

835914
/**
@@ -850,18 +929,18 @@ data class SpotifyUserAuthorization(
850929
*
851930
*/
852931
class SpotifyApiOptionsBuilder(
853-
var useCache: Boolean = true,
854-
var cacheLimit: Int? = 200,
855-
var automaticRefresh: Boolean = true,
856-
var retryWhenRateLimited: Boolean = true,
857-
var enableLogger: Boolean = true,
858-
var testTokenValidity: Boolean = false,
859-
var enableAllOptions: Boolean = false,
860-
var defaultLimit: Int = 50,
861-
var allowBulkRequests: Boolean = true,
862-
var requestTimeoutMillis: Long? = null,
863-
var json: Json = nonstrictJson,
864-
var refreshTokenProducer: (suspend (SpotifyApi<*, *>) -> Token)? = null
932+
var useCache: Boolean = true,
933+
var cacheLimit: Int? = 200,
934+
var automaticRefresh: Boolean = true,
935+
var retryWhenRateLimited: Boolean = true,
936+
var enableLogger: Boolean = true,
937+
var testTokenValidity: Boolean = false,
938+
var enableAllOptions: Boolean = false,
939+
var defaultLimit: Int = 50,
940+
var allowBulkRequests: Boolean = true,
941+
var requestTimeoutMillis: Long? = null,
942+
var json: Json = nonstrictJson,
943+
var refreshTokenProducer: (suspend (SpotifyApi<*, *>) -> Token)? = null
865944
) {
866945
fun build() =
867946
if (enableAllOptions)
@@ -912,17 +991,18 @@ class SpotifyApiOptionsBuilder(
912991
*/
913992

914993
data class SpotifyApiOptions(
915-
var useCache: Boolean,
916-
var cacheLimit: Int?,
917-
var automaticRefresh: Boolean,
918-
var retryWhenRateLimited: Boolean,
919-
var enableLogger: Boolean,
920-
var testTokenValidity: Boolean,
921-
var defaultLimit: Int,
922-
var allowBulkRequests: Boolean,
923-
var requestTimeoutMillis: Long?,
924-
var json: Json,
925-
var refreshTokenProducer: (suspend (SpotifyApi<*, *>) -> Token)?
994+
var useCache: Boolean,
995+
var cacheLimit: Int?,
996+
var automaticRefresh: Boolean,
997+
var retryWhenRateLimited: Boolean,
998+
var enableLogger: Boolean,
999+
var testTokenValidity: Boolean,
1000+
var defaultLimit: Int,
1001+
var allowBulkRequests: Boolean,
1002+
var requestTimeoutMillis: Long?,
1003+
var json: Json,
1004+
var refreshTokenProducer: (suspend (SpotifyApi<*, *>) -> Token)?,
1005+
val usesPkceAuth: Boolean = false
9261006
)
9271007

9281008
@Deprecated("Name has been replaced by `options`", ReplaceWith("SpotifyApiOptions"))

0 commit comments

Comments
 (0)