Skip to content

Commit 5644306

Browse files
authored
Merge pull request #931 from 0xf4b1/oauth-support
Add support for OAuth authentication strategy
2 parents d0ff31b + e35fb02 commit 5644306

File tree

4 files changed

+175
-2
lines changed

4 files changed

+175
-2
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package xyz.gianlu.librespot.core;
2+
3+
import com.google.gson.JsonObject;
4+
import com.google.gson.JsonParser;
5+
import com.google.protobuf.ByteString;
6+
import com.spotify.Authentication;
7+
import com.sun.net.httpserver.HttpServer;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
11+
import java.io.*;
12+
import java.net.*;
13+
import java.nio.charset.StandardCharsets;
14+
import java.security.MessageDigest;
15+
import java.security.NoSuchAlgorithmException;
16+
import java.security.SecureRandom;
17+
import java.util.Base64;
18+
19+
public class OAuth implements Closeable {
20+
private static final Logger LOGGER = LoggerFactory.getLogger(OAuth.class);
21+
private static final String SPOTIFY_AUTH = "https://accounts.spotify.com/authorize?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&scope=%s";
22+
private static final String[] SCOPES = new String[]{"app-remote-control", "playlist-modify", "playlist-modify-private", "playlist-modify-public", "playlist-read", "playlist-read-collaborative", "playlist-read-private", "streaming", "ugc-image-upload", "user-follow-modify", "user-follow-read", "user-library-modify", "user-library-read", "user-modify", "user-modify-playback-state", "user-modify-private", "user-personalized", "user-read-birthdate", "user-read-currently-playing", "user-read-email", "user-read-play-history", "user-read-playback-position", "user-read-playback-state", "user-read-private", "user-read-recently-played", "user-top-read"};
23+
private static final URL SPOTIFY_TOKEN;
24+
25+
static {
26+
try {
27+
SPOTIFY_TOKEN = new URL("https://accounts.spotify.com/api/token");
28+
} catch (MalformedURLException e) {
29+
throw new IllegalArgumentException(e);
30+
}
31+
}
32+
33+
private static final String SPOTIFY_TOKEN_DATA = "grant_type=authorization_code&client_id=%s&redirect_uri=%s&code=%s&code_verifier=%s";
34+
35+
private final String clientId;
36+
private final String redirectUrl;
37+
private final SecureRandom random = new SecureRandom();
38+
private final Object credentialsLock = new Object();
39+
40+
private String codeVerifier;
41+
private String code;
42+
private String token;
43+
private HttpServer server;
44+
45+
46+
public OAuth(String clientId, String redirectUrl) {
47+
this.clientId = clientId;
48+
this.redirectUrl = redirectUrl;
49+
}
50+
51+
private String generateCodeVerifier() {
52+
final String possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
53+
StringBuilder sb = new StringBuilder();
54+
for (int i = 0; i < 128; i++) {
55+
sb.append(possible.charAt(random.nextInt(possible.length())));
56+
}
57+
return sb.toString();
58+
}
59+
60+
private String generateCodeChallenge(String codeVerifier) {
61+
final MessageDigest digest;
62+
try {
63+
digest = MessageDigest.getInstance("SHA-256");
64+
} catch (NoSuchAlgorithmException e) {
65+
throw new RuntimeException(e);
66+
}
67+
byte[] hashed = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
68+
return new String(Base64.getEncoder().encode(hashed))
69+
.replace("=", "")
70+
.replace("+", "-")
71+
.replace("/", "_");
72+
}
73+
74+
public String getAuthUrl() {
75+
codeVerifier = generateCodeVerifier();
76+
return String.format(SPOTIFY_AUTH, clientId, redirectUrl, generateCodeChallenge(codeVerifier), String.join("+", SCOPES));
77+
}
78+
79+
public void setCode(String code) {
80+
this.code = code;
81+
}
82+
83+
public void requestToken() throws IOException {
84+
if (code == null) {
85+
throw new IllegalStateException("You need to provide code before!");
86+
}
87+
HttpURLConnection conn = (HttpURLConnection) SPOTIFY_TOKEN.openConnection();
88+
conn.setDoOutput(true);
89+
conn.setRequestMethod("POST");
90+
conn.getOutputStream().write(String.format(SPOTIFY_TOKEN_DATA, clientId, redirectUrl, code, codeVerifier).getBytes());
91+
if (conn.getResponseCode() != 200) {
92+
throw new IllegalStateException(String.format("Received status code %d: %s", conn.getResponseCode(), conn.getErrorStream().toString()));
93+
}
94+
try (Reader reader = new InputStreamReader(conn.getInputStream())) {
95+
conn.connect();
96+
JsonObject obj = JsonParser.parseReader(reader).getAsJsonObject();
97+
token = obj.get("access_token").getAsString();
98+
} finally {
99+
conn.disconnect();
100+
}
101+
}
102+
103+
public Authentication.LoginCredentials getCredentials() {
104+
if (token == null) {
105+
throw new IllegalStateException("You need to request token before!");
106+
}
107+
return Authentication.LoginCredentials.newBuilder()
108+
.setTyp(Authentication.AuthenticationType.AUTHENTICATION_SPOTIFY_TOKEN)
109+
.setAuthData(ByteString.copyFromUtf8(token))
110+
.build();
111+
}
112+
113+
public void runCallbackServer() throws IOException {
114+
URL url = new URL(redirectUrl);
115+
server = HttpServer.create(new InetSocketAddress(url.getHost(), url.getPort()), 0);
116+
server.createContext("/login", exchange -> {
117+
String response = "librespot-java received callback";
118+
exchange.sendResponseHeaders(200, response.length());
119+
OutputStream os = exchange.getResponseBody();
120+
os.write(response.getBytes());
121+
os.close();
122+
String query = exchange.getRequestURI().getQuery();
123+
setCode(query.substring(query.indexOf('=') + 1));
124+
synchronized (credentialsLock) {
125+
credentialsLock.notifyAll();
126+
}
127+
});
128+
server.start();
129+
LOGGER.info("OAuth: Waiting for callback on {}", server.getAddress());
130+
}
131+
132+
public Authentication.LoginCredentials flow() throws IOException, InterruptedException {
133+
LOGGER.info("OAuth: Visit in your browser and log in: {} ", getAuthUrl());
134+
runCallbackServer();
135+
synchronized (credentialsLock) {
136+
credentialsLock.wait();
137+
}
138+
requestToken();
139+
return getCredentials();
140+
}
141+
142+
@Override
143+
public void close() throws IOException {
144+
if (server != null)
145+
server.stop(0);
146+
}
147+
}

lib/src/main/java/xyz/gianlu/librespot/core/Session.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
import java.util.concurrent.*;
7272
import java.util.concurrent.atomic.AtomicBoolean;
7373

74+
import static xyz.gianlu.librespot.mercury.MercuryRequests.KEYMASTER_CLIENT_ID;
75+
7476
/**
7577
* @author Gianlu
7678
*/
@@ -341,6 +343,9 @@ private void connect() throws IOException, GeneralSecurityException, SpotifyAuth
341343
private void authenticate(@NotNull Authentication.LoginCredentials credentials) throws IOException, GeneralSecurityException, SpotifyAuthenticationException, MercuryClient.MercuryException {
342344
authenticatePartial(credentials, false);
343345

346+
if (credentials.getTyp() == Authentication.AuthenticationType.AUTHENTICATION_SPOTIFY_TOKEN)
347+
reconnect();
348+
344349
synchronized (authLock) {
345350
mercuryClient = new MercuryClient(this);
346351
tokenProvider = new TokenProvider(this);
@@ -998,6 +1003,21 @@ public Builder stored(@NotNull File storedCredentials) throws IOException {
9981003
return this;
9991004
}
10001005

1006+
/**
1007+
* Authenticates via OAuth flow, will prompt to open a link in the browser. This locks until completion.
1008+
*/
1009+
public Builder oauth() throws IOException {
1010+
if (conf.storeCredentials && conf.storedCredentialsFile.exists())
1011+
return stored();
1012+
1013+
try (OAuth oauth = new OAuth(KEYMASTER_CLIENT_ID, "http://127.0.0.1:5588/login")) {
1014+
loginCredentials = oauth.flow();
1015+
} catch (InterruptedException ignored) {
1016+
}
1017+
1018+
return this;
1019+
}
1020+
10011021
/**
10021022
* Authenticates with your Facebook account, will prompt to open a link in the browser. This locks until completion.
10031023
*/
@@ -1031,8 +1051,11 @@ public Builder blob(@NotNull String username, byte[] blob) throws GeneralSecurit
10311051
*
10321052
* @param username Your Spotify username
10331053
* @param password Your Spotify password
1054+
*
1055+
* @deprecated Use OAuth instead
10341056
*/
10351057
@NotNull
1058+
@Deprecated
10361059
public Builder userPass(@NotNull String username, @NotNull String password) {
10371060
loginCredentials = Authentication.LoginCredentials.newBuilder()
10381061
.setUsername(username)

player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,9 @@ public Session.Builder initSessionBuilder() throws IOException, GeneralSecurityE
402402
case STORED:
403403
builder.stored();
404404
break;
405+
case OAUTH:
406+
builder.oauth();
407+
break;
405408
case ZEROCONF:
406409
default:
407410
throw new IllegalArgumentException(authStrategy().name());
@@ -458,7 +461,7 @@ public PlayerConfiguration toPlayer() {
458461
}
459462

460463
public enum AuthStrategy {
461-
FACEBOOK, BLOB, USER_PASS, ZEROCONF, STORED
464+
FACEBOOK, BLOB, USER_PASS, ZEROCONF, STORED, OAUTH
462465
}
463466

464467
private final static class PropertiesFormat implements ConfigFormat<Config> {

player/src/main/resources/default.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ preferredLocale = "en" ### Preferred locale ###
66
logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ###
77

88
[auth] ### Authentication ###
9-
strategy = "ZEROCONF" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED)
9+
strategy = "ZEROCONF" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED, OAUTH)
1010
username = "" # Spotify username (BLOB, USER_PASS only)
1111
password = "" # Spotify password (USER_PASS only)
1212
blob = "" # Spotify authentication blob Base64-encoded (BLOB only)

0 commit comments

Comments
 (0)