From 08b7890ed0fadc072052958945ac64e784232ac5 Mon Sep 17 00:00:00 2001 From: devgianlu Date: Thu, 6 Jan 2022 17:11:21 +0100 Subject: [PATCH] Support client-token header in API requests --- .../xyz/gianlu/librespot/core/Session.java | 14 +- .../gianlu/librespot/dealer/ApiClient.java | 60 ++++++++- lib/src/main/proto/client_token.proto | 125 ++++++++++++++++++ lib/src/main/proto/connectivity.proto | 51 +++++++ .../librespot/player/FileConfiguration.java | 10 +- player/src/main/resources/default.toml | 1 + 6 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 lib/src/main/proto/client_token.proto create mode 100644 lib/src/main/proto/connectivity.proto diff --git a/lib/src/main/java/xyz/gianlu/librespot/core/Session.java b/lib/src/main/java/xyz/gianlu/librespot/core/Session.java index c232f35a..5c269b90 100644 --- a/lib/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/lib/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 devgianlu + * Copyright 2022 devgianlu * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -826,6 +826,7 @@ private Inner(@NotNull Connect.DeviceType deviceType, @NotNull String deviceName public static abstract class AbsBuilder { protected final Configuration conf; protected String deviceId = null; + protected String clientToken = null; protected String deviceName = "librespot-java"; protected Connect.DeviceType deviceType = Connect.DeviceType.COMPUTER; protected String preferredLocale = "en"; @@ -874,6 +875,16 @@ public T setDeviceId(@Nullable String deviceId) { return (T) this; } + /** + * Sets the client token. If empty, it will be retrieved. + * + * @param token A 168 bytes Base64 encoded string + */ + public T setClientToken(@Nullable String token) { + this.clientToken = token; + return (T) this; + } + /** * Sets the device type. * @@ -1034,6 +1045,7 @@ public Session create() throws IOException, GeneralSecurityException, SpotifyAut Session session = new Session(new Inner(deviceType, deviceName, deviceId, preferredLocale, conf)); session.connect(); session.authenticate(loginCredentials); + session.api().setClientToken(clientToken); return session; } } diff --git a/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java b/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java index 3e1fac63..cb83b825 100644 --- a/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java +++ b/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 devgianlu + * Copyright 2022 devgianlu * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,8 @@ package xyz.gianlu.librespot.dealer; import com.google.protobuf.Message; +import com.spotify.clienttoken.data.v0.Connectivity; +import com.spotify.clienttoken.http.v0.ClientToken; import com.spotify.connectstate.Connect; import com.spotify.extendedmetadata.ExtendedMetadata; import com.spotify.metadata.Metadata; @@ -26,9 +28,10 @@ import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import xyz.gianlu.librespot.core.ApResolver; +import xyz.gianlu.librespot.Version; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.mercury.MercuryRequests; import xyz.gianlu.librespot.metadata.*; import java.io.IOException; @@ -43,6 +46,7 @@ public final class ApiClient { private static final Logger LOGGER = LoggerFactory.getLogger(ApiClient.class); private final Session session; private final String baseUrl; + private String clientToken = null; public ApiClient(@NotNull Session session) { this.session = session; @@ -54,7 +58,7 @@ public static RequestBody protoBody(@NotNull Message msg) { return new RequestBody() { @Override public MediaType contentType() { - return MediaType.get("application/protobuf"); + return MediaType.get("application/x-protobuf"); } @Override @@ -66,10 +70,17 @@ public void writeTo(@NotNull BufferedSink sink) throws IOException { @NotNull private Request buildRequest(@NotNull String method, @NotNull String suffix, @Nullable Headers headers, @Nullable RequestBody body) throws IOException, MercuryClient.MercuryException { + if (clientToken == null) { + ClientToken.ClientTokenResponse resp = clientToken(); + clientToken = resp.getGrantedToken().getToken(); + LOGGER.debug("Updated client token: {}", clientToken); + } + Request.Builder request = new Request.Builder(); request.method(method, body); if (headers != null) request.headers(headers); request.addHeader("Authorization", "Bearer " + session.tokens().get("playlist-read")); + request.addHeader("client-token", clientToken); request.url(baseUrl + suffix); return request.build(); } @@ -201,6 +212,49 @@ public ExtendedMetadata.BatchedExtensionResponse getExtendedMetadata(@NotNull Ex } } + @NotNull + private ClientToken.ClientTokenResponse clientToken() throws IOException { + ClientToken.ClientTokenRequest protoReq = ClientToken.ClientTokenRequest.newBuilder() + .setRequestType(ClientToken.ClientTokenRequestType.REQUEST_CLIENT_DATA_REQUEST) + .setClientData(ClientToken.ClientDataRequest.newBuilder() + .setClientId(MercuryRequests.KEYMASTER_CLIENT_ID) + .setClientVersion(Version.versionNumber()) + .setConnectivitySdkData(Connectivity.ConnectivitySdkData.newBuilder() + .setDeviceId(session.deviceId()) + .setPlatformSpecificData(Connectivity.PlatformSpecificData.newBuilder() + .setWindows(Connectivity.NativeWindowsData.newBuilder() + .setSomething1(10) + .setSomething3(21370) + .setSomething4(2) + .setSomething6(9) + .setSomething7(332) + .setSomething8(34404) + .setSomething10(true) + .build()) + .build()) + .build()) + .build()) + .build(); + + Request.Builder req = new Request.Builder() + .url("https://clienttoken.spotify.com/v1/clienttoken") + .header("Accept", "application/x-protobuf") + .header("Content-Encoding", "") + .post(protoBody(protoReq)); + + try (Response resp = session.client().newCall(req.build()).execute()) { + StatusCodeException.checkStatus(resp); + + ResponseBody body = resp.body(); + if (body == null) throw new IOException(); + return ClientToken.ClientTokenResponse.parseFrom(body.byteStream()); + } + } + + public void setClientToken(@Nullable String clientToken) { + this.clientToken = clientToken; + } + public static class StatusCodeException extends IOException { public final int code; diff --git a/lib/src/main/proto/client_token.proto b/lib/src/main/proto/client_token.proto new file mode 100644 index 00000000..049380ad --- /dev/null +++ b/lib/src/main/proto/client_token.proto @@ -0,0 +1,125 @@ +syntax = "proto3"; + +package spotify.clienttoken.http.v0; + +import "connectivity.proto"; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.clienttoken.http.v0"; + +message ClientTokenRequest { + ClientTokenRequestType request_type = 1; + + oneof request { + ClientDataRequest client_data = 2; + ChallengeAnswersRequest challenge_answers = 3; + } +} + +message ClientDataRequest { + string client_version = 1; + string client_id = 2; + + oneof data { + data.v0.ConnectivitySdkData connectivity_sdk_data = 3; + } +} + +message ChallengeAnswersRequest { + string state = 1; + repeated ChallengeAnswer answers = 2; +} + +message ClientTokenResponse { + ClientTokenResponseType response_type = 1; + + oneof response { + GrantedTokenResponse granted_token = 2; + ChallengesResponse challenges = 3; + } +} + +message TokenDomain { + string domain = 1; +} + +message GrantedTokenResponse { + string token = 1; + int32 expires_after_seconds = 2; + int32 refresh_after_seconds = 3; + repeated TokenDomain domains = 4; +} + +message ChallengesResponse { + string state = 1; + repeated Challenge challenges = 2; +} + +message ClientSecretParameters { + string salt = 1; +} + +message EvaluateJSParameters { + string code = 1; + repeated string libraries = 2; +} + +message HashCashParameters { + int32 length = 1; + string prefix = 2; +} + +message Challenge { + ChallengeType type = 1; + + oneof parameters { + ClientSecretParameters client_secret_parameters = 2; + EvaluateJSParameters evaluate_js_parameters = 3; + HashCashParameters evaluate_hashcash_parameters = 4; + } +} + +message ClientSecretHMACAnswer { + string hmac = 1; +} + +message EvaluateJSAnswer { + string result = 1; +} + +message HashCashAnswer { + string suffix = 1; +} + +message ChallengeAnswer { + ChallengeType ChallengeType = 1; + + oneof answer { + ClientSecretHMACAnswer client_secret = 2; + EvaluateJSAnswer evaluate_js = 3; + HashCashAnswer hash_cash = 4; + } +} + +message ClientTokenBadRequest { + string message = 1; +} + +enum ClientTokenRequestType { + REQUEST_UNKNOWN = 0; + REQUEST_CLIENT_DATA_REQUEST = 1; + REQUEST_CHALLENGE_ANSWERS_REQUEST = 2; +} + +enum ClientTokenResponseType { + RESPONSE_UNKNOWN = 0; + RESPONSE_GRANTED_TOKEN_RESPONSE = 1; + RESPONSE_CHALLENGES_RESPONSE = 2; +} + +enum ChallengeType { + CHALLENGE_UNKNOWN = 0; + CHALLENGE_CLIENT_SECRET_HMAC = 1; + CHALLENGE_EVALUATE_JS = 2; + CHALLENGE_HASH_CASH = 3; +} \ No newline at end of file diff --git a/lib/src/main/proto/connectivity.proto b/lib/src/main/proto/connectivity.proto new file mode 100644 index 00000000..e26c2e45 --- /dev/null +++ b/lib/src/main/proto/connectivity.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package spotify.clienttoken.data.v0; + +option optimize_for = CODE_SIZE; +option java_package = "com.spotify.clienttoken.data.v0"; + +message ConnectivitySdkData { + PlatformSpecificData platform_specific_data = 1; + string device_id = 2; +} + +message PlatformSpecificData { + oneof data { + NativeAndroidData android = 1; + NativeIOSData ios = 2; + NativeWindowsData windows = 4; + } +} + +message NativeAndroidData { + int32 major_sdk_version = 1; + int32 minor_sdk_version = 2; + int32 patch_sdk_version = 3; + uint32 api_version = 4; + Screen screen_dimensions = 5; +} + +message NativeIOSData { + int32 user_interface_idiom = 1; + bool target_iphone_simulator = 2; + string hw_machine = 3; + string system_version = 4; + string simulator_model_identifier = 5; +} + +message NativeWindowsData { + int32 something1 = 1; + int32 something3 = 3; + int32 something4 = 4; + int32 something6 = 6; + int32 something7 = 7; + int32 something8 = 8; + bool something10 = 10; +} + +message Screen { + int32 width = 1; + int32 height = 2; + int32 density = 3; +} \ No newline at end of file diff --git a/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java b/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java index e0cedc19..5fac3a73 100644 --- a/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 devgianlu + * Copyright 2022 devgianlu * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -279,6 +279,12 @@ private String deviceId() { return val == null || val.isEmpty() ? null : val; } + @Nullable + private String clientToken() { + String val = config.get("clientToken"); + return val == null || val.isEmpty() ? null : val; + } + @NotNull private String deviceName() { return config.get("deviceName"); @@ -363,6 +369,7 @@ public ZeroconfServer.Builder initZeroconfBuilder() { .setPreferredLocale(preferredLocale()) .setDeviceType(deviceType()) .setDeviceName(deviceName()) + .setClientToken(clientToken()) .setDeviceId(deviceId()) .setListenPort(config.get("zeroconf.listenPort")); @@ -378,6 +385,7 @@ public Session.Builder initSessionBuilder() throws IOException, GeneralSecurityE .setPreferredLocale(preferredLocale()) .setDeviceType(deviceType()) .setDeviceName(deviceName()) + .setClientToken(clientToken()) .setDeviceId(deviceId()); switch (authStrategy()) { diff --git a/player/src/main/resources/default.toml b/player/src/main/resources/default.toml index 7309d05d..5ea2f7ef 100644 --- a/player/src/main/resources/default.toml +++ b/player/src/main/resources/default.toml @@ -1,4 +1,5 @@ deviceId = "" ### Device ID (40 chars, leave empty for random) ### +clientToken = "" ### Client Token (168 bytes Base64 encoded) ### deviceName = "librespot-java" ### Device name ### deviceType = "COMPUTER" ### Device type (COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB, AUDIO_DONGLE, GAME_CONSOLE, CAST_VIDEO, CAST_AUDIO, AUTOMOBILE, WEARABLE, UNKNOWN_SPOTIFY, CAR_THING, UNKNOWN) ### preferredLocale = "en" ### Preferred locale ###