Skip to content

Commit ea70fbc

Browse files
rollersteaamJohnnyCrazy
authored andcommitted
Secure, user-friendly and feature-rich authorization method (#286)
* Add new auth method and api factory for the auth method * Add process documentation * Solve random crash issues related to token collection * Add customizable html responses, add more process control * Add show dialog support, fix nulled html response * Fix false trigger spam of access expiry event * Add auto-retry GetToken, add fire auth fail on timeout * Improve auto-refresh and refresh auth token validity checks * Add ability to change number of get token retries * Add get web API request cancelling * Solve compiler warning * Remove comment links, rename secure auth for clarity * Rename secure auth for clarity in the docs * Improve token swap usage example in docs * Abstract the exchange server doc info for clarity * Fix simontaen SpotifyTokenSwap link * Change access expiry timing to be skippable * Adapt TokenSwap for new convention, separate autorefresh and timer options
1 parent 278927f commit ea70fbc

3 files changed

Lines changed: 617 additions & 8 deletions

File tree

SpotifyAPI.Docs/docs/SpotifyWebAPI/auth.md

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ After you created your Application, you will have following important values:
1212
>**Client_Secret**: Never use this in one of your client-side apps!! Keep it secret!
1313
>**Redirect URIs**: Add "http://localhost", if you want full support for this API
1414
15-
Now you can start with the User-authentication, Spotify provides 3 ways:
15+
Now you can start with the user-authentication, Spotify provides 3 ways (4 if you consider different implementations):
1616

1717
* [ImplicitGrantAuth](/SpotifyWebAPI/auth#implicitgrantauth)
1818

19-
* [AutorizationCodeAuth](/SpotifyWebAPI/auth#autorizationcodeauth)
19+
* [TokenSwapAuth](/SpotifyWebAPI/auth#tokenswapauth) (**Recommended**, server-side code mandatory, most secure method. The necessary code is shown here so you do not have to code it yourself.)
2020

21-
* [ClientCredentialsAuth](/SpotifyWebAPI/auth#clientcredentialsauth)
21+
* [AutorizationCodeAuth](/SpotifyWebAPI/auth#autorizationcodeauth) (Not recommended, server-side code needed, else it's unsecure)
22+
23+
* [ClientCredentialsAuth](/SpotifyWebAPI/auth#clientcredentialsauth) (Not recommended, server-side code needed, else it's unsecure)
2224

2325
## Notes
2426

@@ -29,10 +31,10 @@ Overview:
2931

3032
After implementing one of the provided auth-methods, you can start doing requests with the token you get from one of the auth-methods.
3133

32-
##ImplicitGrantAuth
34+
## ImplicitGrantAuth
3335

34-
With this approach, you directly get a Token object after the user authed your application.
35-
You won't be able to refresh the token. If you want to use the internal Http server, make sure the redirect URI is in your spotify application redirects.
36+
This way is **recommended** and the only auth-process which does not need a server-side exchange of keys. With this approach, you directly get a Token object after the user authed your application.
37+
You won't be able to refresh the token. If you want to use the internal Http server, please add "http://localhost" to your application redirects.
3638

3739
More info: [here](https://developer.spotify.com/documentation/general/guides/authorization-guide/#implicit-grant-flow)
3840

@@ -52,7 +54,115 @@ static async void Main(string[] args)
5254
}
5355
```
5456

55-
##AutorizationCodeAuth
57+
## TokenSwapAuth
58+
59+
This way uses server-side code or at least access to an exchange server, otherwise, compared to other
60+
methods, it is impossible to use.
61+
62+
With this approach, you provide the URI/URL to your desired exchange server to perform all necessary
63+
requests to Spotify, as well as requests that return back to the "server URI".
64+
65+
The exchange server **must** be able to:
66+
67+
* Return the authorization code from Spotify API authenticate page via GET request to the "server URI".
68+
* Request the token response object via POST to the Spotify API token page.
69+
* Request a refreshed token response object via POST to the Spotify API token page.
70+
71+
**The good news is that you do not need to code it yourself.**
72+
73+
The advantages of this method are that the client ID and redirect URI are very well hidden and almost unexposed, but more importantly, your client secret is **never** exposed and is completely hidden compared to other methods (excluding [ImplicitGrantAuth](/SpotifyWebAPI/auth#implicitgrantauth)
74+
as it does not deal with a client secret). This means
75+
your Spotify app **cannot** be spoofed by a malicious third party.
76+
77+
### Using TokenSwapWebAPIFactory
78+
The TokenSwapWebAPIFactory will create and configure a SpotifyWebAPI object for you.
79+
80+
It does this through the method GetWebApiAsync **asynchronously**, which means it will not halt execution of your program while obtaining it for you. If you would like to halt execution, which is **synchronous**, use `GetWebApiAsync().Result` without using **await**.
81+
82+
```c#
83+
TokenSwapWebAPIFactory webApiFactory;
84+
SpotifyWebAPI spotify;
85+
86+
// You should store a reference to WebAPIFactory if you are using AutoRefresh or want to manually refresh it later on. New WebAPIFactory objects cannot refresh SpotifyWebAPI object that they did not give to you.
87+
webApiFactory = new TokenSwapWebAPIFactory("INSERT LINK TO YOUR index.php HERE")
88+
{
89+
Scope = Scope.UserReadPrivate | Scope.UserReadEmail | Scope.PlaylistReadPrivate | Scope.UserLibraryRead | Scope.UserReadPrivate | Scope.UserFollowRead | Scope.UserReadBirthdate | Scope.UserTopRead | Scope.PlaylistReadCollaborative | Scope.UserReadRecentlyPlayed | Scope.UserReadPlaybackState | Scope.UserModifyPlaybackState | Scope.PlaylistModifyPublic,
90+
AutoRefresh = true
91+
};
92+
// You may want to react to being able to use the Spotify service.
93+
// webApiFactory.OnAuthSuccess += (sender, e) => authorized = true;
94+
// You may want to react to your user's access expiring.
95+
// webApiFactory.OnAccessTokenExpired += (sender, e) => authorized = false;
96+
97+
try
98+
{
99+
spotify = await webApiFactory.GetWebApiAsync();
100+
// Synchronous way:
101+
// spotify = webApiFactory.GetWebApiAsync().Result;
102+
}
103+
catch (Exception ex)
104+
{
105+
// Example way to handle error reporting gracefully with your SpotifyWebAPI wrapper
106+
// UpdateStatus($"Spotify failed to load: {ex.Message}");
107+
}
108+
```
109+
110+
### Using TokenSwapAuth
111+
Since the TokenSwapWebAPIFactory not only simplifies the whole process but offers additional functionality too
112+
(such as AutoRefresh and AuthSuccess AuthFailure events), use of this way is very verbose and is only
113+
recommended if you are having issues with TokenSwapWebAPIFactory or need access to the tokens.
114+
115+
```c#
116+
TokenSwapAuth auth = new TokenSwapAuth(
117+
exchangeServerUri: "INSERT LINK TO YOUR index.php HERE",
118+
serverUri: "http://localhost:4002",
119+
scope: Scope.UserReadPrivate | Scope.UserReadEmail | Scope.PlaylistReadPrivate | Scope.UserLibraryRead | Scope.UserReadPrivate | Scope.UserFollowRead | Scope.UserReadBirthdate | Scope.UserTopRead | Scope.PlaylistReadCollaborative | Scope.UserReadRecentlyPlayed | Scope.UserReadPlaybackState | Scope.UserModifyPlaybackState | Scope.PlaylistModifyPublic
120+
);
121+
auth.AuthReceived += async (sender, response) =>
122+
{
123+
lastToken = await auth.ExchangeCodeAsync(response.Code);
124+
125+
spotify = new SpotifyWebAPI()
126+
{
127+
TokenType = lastToken.TokenType,
128+
AccessToken = lastToken.AccessToken
129+
};
130+
131+
authenticated = true;
132+
auth.Stop();
133+
};
134+
auth.OnAccessTokenExpired += async (sender, e) => spotify.AccessToken = (await auth.RefreshAuthAsync(lastToken.RefreshToken)).AccessToken;
135+
auth.Start();
136+
auth.OpenBrowser();
137+
```
138+
139+
### Token Swap Endpoint
140+
To keep your client secret completely secure and your client ID and redirect URI as secure as possible, use of a web server (such as a php website) is required.
141+
142+
To use this method, an external HTTP Server (that you may need to create) needs to be able to supply the following HTTP Endpoints to your application:
143+
144+
`/swap` - Swaps out an `authorization_code` with an `access_token` and `refresh_token` - The following parameters are required in the JSON POST Body:
145+
- `grant_type` (set to `"authorization_code"`)
146+
- `code` (the `authorization_code`)
147+
- `redirect_uri`
148+
- - **Important** The page that the redirect URI links to must return the authorization code json to your `serverUri` (default is 'http://localhost:4002') but to the folder 'auth', like this: 'http://localhost:4002/auth'.
149+
150+
`/refresh` - Refreshes an `access_token` - The following parameters are required in the JSON POST Body:
151+
- `grant_type` (set to `"refresh_token"`)
152+
- `refresh_token`
153+
154+
The following open-source token swap endpoint code can be used for your website:
155+
- [rollersteaam/spotify-token-swap-php](https://github.com/rollersteaam/spotify-token-swap-php)
156+
- [simontaen/SpotifyTokenSwap](https://github.com/simontaen/SpotifyTokenSwap)
157+
158+
#### Remarks
159+
It should be noted that GitHub Pages does not support hosting php scripts. Hosting php scripts through it will cause the php to render as plain HTML, potentially compromising your client secret while doing absolutely nothing.
160+
161+
Be sure you have whitelisted your redirect uri in the Spotify Developer Dashboard otherwise the authorization will always fail.
162+
163+
If you did not use the WebAPIFactory or you provided a `serverUri` different from its default, you must make sure your redirect uri's script at your endpoint will properly redirect to your `serverUri` (such as changing the areas which refer to `localhost:4002` if you had changed `serverUri` from its default), otherwise it will never reach your new `serverUri`.
164+
165+
## AutorizationCodeAuth
56166

57167
This way is **not recommended** and requires server-side code to run securely.
58168
With this approach, you first get a code which you need to trade against the access-token.
@@ -79,7 +189,7 @@ static async void Main(string[] args)
79189
}
80190
```
81191

82-
##ClientCredentialsAuth
192+
## ClientCredentialsAuth
83193

84194
With this approach, you make a POST Request with a base64 encoded string (consists of ClientId + ClientSecret). You will directly get the token (Without a local HTTP Server), but it will expire and can't be refreshed.
85195
If you want to use it securely, you would need to do it all server-side.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using SpotifyAPI.Web.Enums;
7+
using Unosquare.Labs.EmbedIO;
8+
using Unosquare.Labs.EmbedIO.Constants;
9+
using Unosquare.Labs.EmbedIO.Modules;
10+
using SpotifyAPI.Web.Models;
11+
using Newtonsoft.Json;
12+
#if NETSTANDARD2_0
13+
using System.Net.Http;
14+
#endif
15+
#if NET46
16+
using System.Net.Http;
17+
using HttpListenerContext = Unosquare.Net.HttpListenerContext;
18+
#endif
19+
20+
namespace SpotifyAPI.Web.Auth
21+
{
22+
/// <summary>
23+
/// <para>
24+
/// A version of <see cref="AuthorizationCodeAuth"/> that does not store your client secret, client ID or redirect URI, enforcing a secure authorization flow. Requires an exchange server that will return the authorization code to its callback server via GET request.
25+
/// </para>
26+
/// <para>
27+
/// It's recommended that you use <see cref="TokenSwapWebAPIFactory"/> if you would like to use the TokenSwap method.
28+
/// </para>
29+
/// </summary>
30+
public class TokenSwapAuth : SpotifyAuthServer<AuthorizationCode>
31+
{
32+
string exchangeServerUri;
33+
34+
/// <summary>
35+
/// The HTML to respond with when the callback server (serverUri) is reached. The default value will close the window on arrival.
36+
/// </summary>
37+
public string HtmlResponse { get; set; } = "<script>window.close();</script>";
38+
/// <summary>
39+
/// If true, will time how long it takes for access to expire. On expiry, the <see cref="OnAccessTokenExpired"/> event fires.
40+
/// </summary>
41+
public bool TimeAccessExpiry { get; set; }
42+
43+
/// <param name="exchangeServerUri">The URI to an exchange server that will perform the key exchange.</param>
44+
/// <param name="serverUri">The URI to host the server at that your exchange server should return the authorization code to by GET request. (e.g. http://localhost:4002)</param>
45+
/// <param name="scope"></param>
46+
/// <param name="state">Stating none will randomly generate a state parameter.</param>
47+
/// <param name="htmlResponse">The HTML to respond with when the callback server (serverUri) is reached. The default value will close the window on arrival.</param>
48+
public TokenSwapAuth(string exchangeServerUri, string serverUri, Scope scope = Scope.None, string state = "", string htmlResponse = "") : base("code", "", "", serverUri, scope, state)
49+
{
50+
if (!string.IsNullOrEmpty(htmlResponse))
51+
{
52+
HtmlResponse = htmlResponse;
53+
}
54+
55+
this.exchangeServerUri = exchangeServerUri;
56+
}
57+
58+
protected override void AdaptWebServer(WebServer webServer)
59+
{
60+
webServer.Module<WebApiModule>().RegisterController<TokenSwapAuthController>();
61+
}
62+
63+
public override string GetUri()
64+
{
65+
StringBuilder builder = new StringBuilder(exchangeServerUri);
66+
builder.Append("?");
67+
builder.Append("response_type=code");
68+
builder.Append("&state=" + State);
69+
builder.Append("&scope=" + Scope.GetStringAttribute(" "));
70+
builder.Append("&show_dialog=" + ShowDialog);
71+
return Uri.EscapeUriString(builder.ToString());
72+
}
73+
74+
static readonly HttpClient httpClient = new HttpClient();
75+
76+
/// <summary>
77+
/// The maximum amount of times to retry getting a token.
78+
/// <para/>
79+
/// A token get is attempted every time you <see cref="RefreshAuthAsync(string)"/> and <see cref="ExchangeCodeAsync(string)"/>.
80+
/// </summary>
81+
public int MaxGetTokenRetries { get; set; } = 10;
82+
83+
/// <summary>
84+
/// Creates a HTTP request to obtain a token object.<para/>
85+
/// Parameter grantType can only be "refresh_token" or "authorization_code". authorizationCode and refreshToken are not mandatory, but at least one must be provided for your desired grant_type request otherwise an invalid response will be given and an exception is likely to be thrown.
86+
/// <para>
87+
/// Will re-attempt on error, on null or on no access token <see cref="MaxGetTokenRetries"/> times before finally returning null.
88+
/// </para>
89+
/// </summary>
90+
/// <param name="grantType">Can only be "refresh_token" or "authorization_code".</param>
91+
/// <param name="authorizationCode">This needs to be defined if "grantType" is "authorization_code".</param>
92+
/// <param name="refreshToken">This needs to be defined if "grantType" is "refresh_token".</param>
93+
/// <param name="currentRetries">Does not need to be defined. Used internally for retry attempt recursion.</param>
94+
/// <returns>Attempts to return a full <see cref="Token"/>, but after retry attempts, may return a <see cref="Token"/> with no <see cref="Token.AccessToken"/>, or null.</returns>
95+
async Task<Token> GetToken(string grantType, string authorizationCode = "", string refreshToken = "", int currentRetries = 0)
96+
{
97+
var content = new FormUrlEncodedContent(new Dictionary<string, string>
98+
{
99+
{ "grant_type", grantType },
100+
{ "code", authorizationCode },
101+
{ "refresh_token", refreshToken }
102+
});
103+
104+
try
105+
{
106+
var siteResponse = await httpClient.PostAsync(exchangeServerUri, content);
107+
Token token = JsonConvert.DeserializeObject<Token>(await siteResponse.Content.ReadAsStringAsync());
108+
// Don't need to check if it was null - if it is, it will resort to the catch block.
109+
if (!token.HasError() && !string.IsNullOrEmpty(token.AccessToken))
110+
{
111+
return token;
112+
}
113+
}
114+
catch { }
115+
116+
if (currentRetries >= MaxGetTokenRetries)
117+
{
118+
return null;
119+
}
120+
else
121+
{
122+
currentRetries++;
123+
// The reason I chose to implement the retries system this way is because a static or instance
124+
// variable keeping track would inhibit parallelism i.e. using this function on multiple threads/tasks.
125+
// It's not clear why someone would like to do that, but it's better to cater for all kinds of uses.
126+
return await GetToken(grantType, authorizationCode, refreshToken, currentRetries);
127+
}
128+
}
129+
130+
System.Timers.Timer accessTokenExpireTimer;
131+
/// <summary>
132+
/// When Spotify authorization has expired. Will only trigger if <see cref="TimeAccessExpiry"/> is true.
133+
/// </summary>
134+
public event EventHandler OnAccessTokenExpired;
135+
136+
/// <summary>
137+
/// If <see cref="TimeAccessExpiry"/> is true, sets a timer for how long access will take to expire.
138+
/// </summary>
139+
/// <param name="token"></param>
140+
void SetAccessExpireTimer(Token token)
141+
{
142+
if (!TimeAccessExpiry) return;
143+
144+
if (accessTokenExpireTimer != null)
145+
{
146+
accessTokenExpireTimer.Stop();
147+
accessTokenExpireTimer.Dispose();
148+
}
149+
150+
accessTokenExpireTimer = new System.Timers.Timer
151+
{
152+
Enabled = true,
153+
Interval = token.ExpiresIn * 1000,
154+
AutoReset = false
155+
};
156+
accessTokenExpireTimer.Elapsed += (sender, e) => OnAccessTokenExpired?.Invoke(this, EventArgs.Empty);
157+
}
158+
159+
/// <summary>
160+
/// Uses the authorization code to silently (doesn't open a browser) obtain both an access token and refresh token, where the refresh token would be required for you to use <see cref="RefreshAuthAsync(string)"/>.
161+
/// </summary>
162+
/// <param name="authorizationCode"></param>
163+
/// <returns></returns>
164+
public async Task<Token> ExchangeCodeAsync(string authorizationCode)
165+
{
166+
Token token = await GetToken("authorization_code", authorizationCode: authorizationCode);
167+
if (token != null && !token.HasError() && !string.IsNullOrEmpty(token.AccessToken))
168+
{
169+
SetAccessExpireTimer(token);
170+
}
171+
return token;
172+
}
173+
174+
/// <summary>
175+
/// Uses the refresh token to silently (doesn't open a browser) obtain a fresh access token, no refresh token is given however (as it does not change).
176+
/// </summary>
177+
/// <param name="refreshToken"></param>
178+
/// <returns></returns>
179+
public async Task<Token> RefreshAuthAsync(string refreshToken)
180+
{
181+
Token token = await GetToken("refresh_token", refreshToken: refreshToken);
182+
if (token != null && !token.HasError() && !string.IsNullOrEmpty(token.AccessToken))
183+
{
184+
SetAccessExpireTimer(token);
185+
}
186+
return token;
187+
}
188+
}
189+
190+
internal class TokenSwapAuthController : WebApiController
191+
{
192+
public TokenSwapAuthController(IHttpContext context) : base(context)
193+
{
194+
}
195+
196+
[WebApiHandler(HttpVerbs.Get, "/auth")]
197+
public Task<bool> GetAuth()
198+
{
199+
string state = Request.QueryString["state"];
200+
SpotifyAuthServer<AuthorizationCode> auth = TokenSwapAuth.GetByState(state);
201+
202+
string code = null;
203+
string error = Request.QueryString["error"];
204+
if (error == null)
205+
{
206+
code = Request.QueryString["code"];
207+
}
208+
209+
Task.Factory.StartNew(() => auth?.TriggerAuth(new AuthorizationCode
210+
{
211+
Code = code,
212+
Error = error
213+
}));
214+
return this.StringResponseAsync(((TokenSwapAuth)auth).HtmlResponse);
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)