Skip to content

Commit d44cbcc

Browse files
committed
Add OAuth authentication support
1 parent d0ff31b commit d44cbcc

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

  • lib/src/main/java/xyz/gianlu/librespot/core
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+
}

0 commit comments

Comments
 (0)