Skip to content

Commit fc69822

Browse files
committed
fetch TOTP secret dynamically, 1.2.4
1 parent 03a194d commit fc69822

File tree

6 files changed

+218
-60
lines changed

6 files changed

+218
-60
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44
}
55

66
group 'de.labystudio'
7-
version '1.2.3'
7+
version '1.2.4'
88

99
compileJava {
1010
sourceCompatibility = '1.8'

src/main/java/de/labystudio/spotifyapi/open/OpenSpotifyAPI.java

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import de.labystudio.spotifyapi.model.Track;
77
import de.labystudio.spotifyapi.open.model.AccessTokenResponse;
88
import de.labystudio.spotifyapi.open.model.track.OpenTrack;
9-
import de.labystudio.spotifyapi.open.util.TOTP;
9+
import de.labystudio.spotifyapi.open.totp.Secret;
10+
import de.labystudio.spotifyapi.open.totp.SecretFetcher;
11+
import de.labystudio.spotifyapi.open.totp.TOTP;
1012

1113
import javax.imageio.ImageIO;
1214
import javax.net.ssl.HttpsURLConnection;
@@ -30,15 +32,13 @@
3032
*/
3133
public class OpenSpotifyAPI {
3234

33-
private static final Gson GSON = new Gson();
35+
public static final Gson GSON = new Gson();
3436

35-
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/71.0.3578." + (int) (Math.random() * 90);
36-
private static final String URL_API_GEN_ACCESS_TOKEN = "https://open.spotify.com/api/token?reason=%s&productType=web-player&totp=%s&totpVer=5";
37+
public static final String USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537." + (int) (Math.random() * 90);
38+
public static final String URL_API_GEN_ACCESS_TOKEN = "https://open.spotify.com/api/token?reason=%s&productType=web-player&totp=%s&totpServer=%s&totpVer=%s";
3739

38-
private static final String URL_API_TRACKS = "https://api.spotify.com/v1/tracks/%s";
39-
private static final String URL_API_SERVER_TIME = "https://open.spotify.com/api/server-time";
40-
41-
public static final int[] TOTP_SECRET = {12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54};
40+
public static final String URL_API_TRACKS = "https://api.spotify.com/v1/tracks/%s";
41+
public static final String URL_API_SERVER_TIME = "https://open.spotify.com/api/server-time";
4242

4343
private final Executor executor = Executors.newSingleThreadExecutor();
4444

@@ -72,8 +72,8 @@ public long requestServerTime() throws IOException {
7272
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
7373
conn.setRequestMethod("GET");
7474
conn.setRequestProperty("Host", "open.spotify.com");
75-
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
76-
conn.setRequestProperty("accept", "*/*");
75+
conn.setRequestProperty("User-Agent", USER_AGENT);
76+
conn.setRequestProperty("Accept", "application/json");
7777

7878
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
7979
String response = reader.readLine();
@@ -83,46 +83,19 @@ public long requestServerTime() throws IOException {
8383
return obj.get("serverTime").getAsLong();
8484
}
8585

86-
/**
87-
* Generate time-based one time password
88-
*
89-
* @param serverTime server time in seconds
90-
* @return 6 digits one time password
91-
*/
92-
public String generateTotp(long serverTime) {
93-
// Convert secret numbers to xor results
94-
StringBuilder xorResults = new StringBuilder();
95-
for (int i = 0; i < TOTP_SECRET.length; i++) {
96-
int result = TOTP_SECRET[i] ^ (i % 33 + 9);
97-
xorResults.append(result);
98-
}
99-
100-
// Convert xor results to hex
101-
StringBuilder hexResult = new StringBuilder();
102-
for (int i = 0; i < xorResults.length(); i++) {
103-
hexResult.append(String.format("%02x", (int) xorResults.charAt(i)));
104-
}
105-
106-
// Convert hex to byte array
107-
byte[] byteArray = new byte[hexResult.length() / 2];
108-
for (int i = 0; i < hexResult.length(); i += 2) {
109-
int byteValue = Integer.parseInt(hexResult.substring(i, i + 2), 16);
110-
byteArray[i / 2] = (byte) byteValue;
111-
}
112-
return TOTP.generateOtp(byteArray, serverTime, 30, 6);
113-
}
114-
11586
/**
11687
* Generate an access token for the open spotify api
11788
*/
11889
private AccessTokenResponse generateAccessToken() throws IOException {
90+
SecretFetcher secretFetcher = new SecretFetcher();
91+
Secret secret = secretFetcher.fetchLatest();
11992
long serverTime = this.requestServerTime();
120-
String totp = this.generateTotp(serverTime);
93+
String totp = TOTP.generateOtp(secret.toBytes(), serverTime, 30, 6);
12194

122-
AccessTokenResponse response = this.getToken("transport", totp);
95+
AccessTokenResponse response = this.getToken("transport", totp, secret.getVersion());
12396

12497
if (!this.hasValidAccessToken(response)) {
125-
response = this.getToken("init", totp);
98+
response = this.getToken("init", totp, secret.getVersion());
12699
}
127100

128101
if (!this.hasValidAccessToken(response)) {
@@ -135,13 +108,14 @@ private AccessTokenResponse generateAccessToken() throws IOException {
135108
/**
136109
* Retrieve access token using totp
137110
*/
138-
private AccessTokenResponse getToken(String mode, String totp) throws IOException {
111+
private AccessTokenResponse getToken(String mode, String totp, int version) throws IOException {
139112
// Open connection
140-
String url = String.format(URL_API_GEN_ACCESS_TOKEN, mode, totp);
113+
String url = String.format(URL_API_GEN_ACCESS_TOKEN, mode, totp, totp, version);
141114
HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
142115
connection.addRequestProperty("User-Agent", USER_AGENT);
143116
connection.addRequestProperty("referer", "https://open.spotify.com/");
144117
connection.addRequestProperty("app-platform", "WebPlayer");
118+
connection.setRequestProperty("Accept", "application/json");
145119

146120
int code = connection.getResponseCode();
147121
if (code != HttpURLConnection.HTTP_OK) {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package de.labystudio.spotifyapi.open.totp;
2+
3+
public class Secret {
4+
5+
private final int[] secret;
6+
private final int version;
7+
8+
public Secret(int[] secret, int version) {
9+
this.secret = secret;
10+
this.version = version;
11+
}
12+
13+
public byte[] toBytes() {
14+
// Convert secret numbers to xor results
15+
StringBuilder xorResults = new StringBuilder();
16+
for (int i = 0; i < this.secret.length; i++) {
17+
int result = this.secret[i] ^ (i % 33 + 9);
18+
xorResults.append(result);
19+
}
20+
21+
// Convert xor results to hex
22+
StringBuilder hexResult = new StringBuilder();
23+
for (int i = 0; i < xorResults.length(); i++) {
24+
hexResult.append(String.format("%02x", (int) xorResults.charAt(i)));
25+
}
26+
27+
// Convert hex to byte array
28+
byte[] byteArray = new byte[hexResult.length() / 2];
29+
for (int i = 0; i < hexResult.length(); i += 2) {
30+
int byteValue = Integer.parseInt(hexResult.substring(i, i + 2), 16);
31+
byteArray[i / 2] = (byte) byteValue;
32+
}
33+
return byteArray;
34+
}
35+
36+
public int getVersion() {
37+
return this.version;
38+
}
39+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package de.labystudio.spotifyapi.open.totp;
2+
3+
import com.google.gson.JsonElement;
4+
import com.google.gson.JsonObject;
5+
6+
import java.io.BufferedReader;
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.io.InputStreamReader;
10+
import java.net.HttpURLConnection;
11+
import java.net.URL;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.regex.Matcher;
14+
import java.util.regex.Pattern;
15+
import java.util.zip.GZIPInputStream;
16+
17+
import static de.labystudio.spotifyapi.open.OpenSpotifyAPI.GSON;
18+
import static de.labystudio.spotifyapi.open.OpenSpotifyAPI.USER_AGENT;
19+
20+
public class SecretFetcher {
21+
22+
public static final String URL_OPEN_SPOTIFY_WEB_APP = "https://open.spotify.com/";
23+
24+
private static final Pattern WEB_PLAYER_JS_REGEX = Pattern.compile(
25+
"<script\\s+src=\"https://[^/]+(?:/[^/]+)*/web-player/web-player\\.[a-z0-9]+\\.js\"></script>"
26+
);
27+
28+
public Secret fetchLatest() throws IOException {
29+
Secret[] secrets = this.fetchSecrets();
30+
31+
int latestVersion = 0;
32+
Secret latestSecret = null;
33+
34+
// Find the latest secret based on version
35+
for (Secret secret : secrets) {
36+
if (secret.getVersion() > latestVersion) {
37+
latestVersion = secret.getVersion();
38+
latestSecret = secret;
39+
}
40+
}
41+
42+
if (latestSecret == null) {
43+
throw new IOException("No secrets found");
44+
}
45+
return latestSecret;
46+
}
47+
48+
public Secret[] fetchSecrets() throws IOException {
49+
String url = this.fetchWebPlayerJsUrl();
50+
JsonObject secretStorage = this.fetchSecretStorage(url);
51+
if (secretStorage == null || !secretStorage.has("secrets")) {
52+
throw new IOException("No secrets found in secret storage");
53+
}
54+
55+
// Parse the secrets array from the JSON object
56+
String secretsJson = secretStorage.get("secrets").toString();
57+
Secret[] secrets = GSON.fromJson(secretsJson, Secret[].class);
58+
if (secrets == null || secrets.length == 0) {
59+
throw new IOException("No secrets found in the JSON response");
60+
}
61+
62+
return secrets;
63+
}
64+
65+
private String fetchWebPlayerJsUrl() throws IOException {
66+
String html = this.fetchUrl(URL_OPEN_SPOTIFY_WEB_APP);
67+
68+
// Use regex to find the web player JS URL
69+
Matcher matcher = WEB_PLAYER_JS_REGEX.matcher(html);
70+
if (matcher.find()) {
71+
String[] segments = matcher.group(0).split("\"");
72+
return segments[1]; // Extract the URL from the script tag
73+
}
74+
75+
throw new IOException("Web player JS URL not found");
76+
}
77+
78+
private JsonObject fetchSecretStorage(String jsUrl) throws IOException {
79+
String jsContent = this.fetchUrl(jsUrl);
80+
81+
82+
int pos = 0;
83+
while ((pos = jsContent.indexOf('{', pos)) != -1) {
84+
try {
85+
JsonElement candidateJson = this.extractJsonObject(jsContent, pos);
86+
if (candidateJson.isJsonObject() && this.containsSecretAndVersion(candidateJson)) {
87+
return candidateJson.getAsJsonObject();
88+
}
89+
} catch (Exception e) {
90+
// Ignore parse exceptions and try next '{'
91+
}
92+
pos++;
93+
}
94+
throw new IOException("No JSON object with 'secret' and 'version' keys found in: " + jsUrl);
95+
}
96+
97+
private String fetchUrl(String urlString) throws IOException {
98+
URL url = new URL(urlString);
99+
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
100+
connection.setRequestMethod("GET");
101+
connection.setRequestProperty("User-Agent", USER_AGENT);
102+
connection.addRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;application/json");
103+
connection.setRequestProperty("Accept-Encoding", "gzip, deflate");
104+
105+
InputStream inputStream;
106+
String encoding = connection.getContentEncoding();
107+
108+
if ("gzip".equalsIgnoreCase(encoding)) {
109+
inputStream = new GZIPInputStream(connection.getInputStream());
110+
} else {
111+
inputStream = connection.getInputStream();
112+
}
113+
114+
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
115+
StringBuilder response = new StringBuilder();
116+
String line;
117+
while ((line = reader.readLine()) != null) {
118+
response.append(line);
119+
}
120+
return response.toString();
121+
}
122+
}
123+
124+
private boolean containsSecretAndVersion(JsonElement element) {
125+
if (element.isJsonObject()) {
126+
JsonObject obj = element.getAsJsonObject();
127+
if (obj.has("secret") && obj.has("version")) {
128+
return true;
129+
}
130+
// Recursively check nested objects
131+
for (String key : obj.keySet()) {
132+
if (this.containsSecretAndVersion(obj.get(key))) {
133+
return true;
134+
}
135+
}
136+
} else if (element.isJsonArray()) {
137+
for (JsonElement el : element.getAsJsonArray()) {
138+
if (this.containsSecretAndVersion(el)) return true;
139+
}
140+
}
141+
return false;
142+
}
143+
144+
private JsonElement extractJsonObject(String text, int startIndex) throws IOException {
145+
int braceCount = 0;
146+
for (int i = startIndex; i < text.length(); i++) {
147+
char c = text.charAt(i);
148+
if (c == '{') braceCount++;
149+
else if (c == '}') braceCount--;
150+
151+
if (braceCount == 0) {
152+
return GSON.fromJson(text.substring(startIndex, i + 1), JsonElement.class);
153+
}
154+
}
155+
throw new IOException("Unbalanced braces in JSON");
156+
}
157+
158+
159+
}

src/main/java/de/labystudio/spotifyapi/open/util/TOTP.java renamed to src/main/java/de/labystudio/spotifyapi/open/totp/TOTP.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package de.labystudio.spotifyapi.open.util;
1+
package de.labystudio.spotifyapi.open.totp;
22

33
import javax.crypto.Mac;
44
import javax.crypto.spec.SecretKeySpec;

src/test/java/TOTPTest.java

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)