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