diff --git a/SpotifyAPI.Docs/.prettierrc.json b/SpotifyAPI.Docs/.prettierrc.json new file mode 100644 index 00000000..f1f48f70 --- /dev/null +++ b/SpotifyAPI.Docs/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "endOfLine": "auto" +} diff --git a/SpotifyAPI.Docs/docs/auth_introduction.md b/SpotifyAPI.Docs/docs/auth_introduction.md index 4652d7f4..dac18163 100644 --- a/SpotifyAPI.Docs/docs/auth_introduction.md +++ b/SpotifyAPI.Docs/docs/auth_introduction.md @@ -7,16 +7,17 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; Spotify does not allow unauthorized access to the api. Thus, you need an access token to make requets. This access token can be gathered via multiple schemes, all following the OAuth2 spec. Since it's important to choose the correct scheme for your usecase, make sure you have a grasp of the following terminology/docs: -* OAuth2 -* [Spotify Authorization Flows](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow) +- OAuth2 +- [Spotify Authorization Flows](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow) Since every auth flow also needs an application in the [spotify dashboard](https://developer.spotify.com/dashboard/), make sure you have the necessary values (like `Client Id` and `Client Secret`). Then, continue with the docs of the specific auth flows: -* [Client Credentials](client_credentials.md) -* [Implicit Grant](implicit_grant.md) -* [Authorization Code](authorization_code.md) -* [Token Swap](token_swap.md) +- [Client Credentials](client_credentials.md) +- [Implicit Grant](implicit_grant.md) +- [Authorization Code](authorization_code.md) +- [PKCE](pkce.md) +- [(Token Swap)](token_swap.md) auth comparison diff --git a/SpotifyAPI.Docs/docs/pkce.md b/SpotifyAPI.Docs/docs/pkce.md new file mode 100644 index 00000000..206bafea --- /dev/null +++ b/SpotifyAPI.Docs/docs/pkce.md @@ -0,0 +1,77 @@ +--- +id: pkce +title: PKCE +--- + +> The authorization code flow with PKCE is the best option for mobile and desktop applications where it is unsafe to store your client secret. It provides your app with an access token that can be refreshed. For further information about this flow, see IETF RFC-7636. + +## Generating Challenge & Verifier + +For every authentation request, a verify code and its challenge code needs to be generated. The class `PKCEUtil` can be used to generate those, either with random generated or self supplied values: + +```csharp +// Generates a secure random verifier of length 100 and its challenge +var (verifier, challenge) = PKCEUtil.GenerateCodes(); + +// Generates a secure random verifier of length 120 and its challenge +var (verifier, challenge) = PKCEUtil.GenerateCodes(120); + +// Returns the passed string and its challenge (Make sure it's random and long enough) +var (verifier, challenge) = PKCEUtil.GenerateCodes("YourSecureRandomString"); +``` + +## Generating Login URI + +Like most auth flows, you'll need to redirect your user to spotify's servers so he is able to grant access to your application: + +```csharp +// Make sure "http://localhost:5000/callback" is in your applications redirect URIs! +var loginRequest = new LoginRequest( + new Uri("http://localhost:5000/callback"), + "YourClientId", + LoginRequest.ResponseType.Code +) +{ + CodeChallengeMethod = "S256", + CodeChallenge = challenge, + Scope = new[] { Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative } +}; +var uri = loginRequest.ToUri(); +// Redirect user to uri via your favorite web-server or open a local browser window +``` + +When the user is redirected to the generated uri, he will have to login with his spotify account and confirm, that your application wants to access his user data. Once confirmed, he will be redirect to `http://localhost:5000/callback` and a `code` parameter is attached to the query. The redirect URI can also contain a custom protocol paired with UWP App Custom Protocol handler. This received `code` has to be exchanged for an `access_token` and `refresh_token`: + +```csharp +// This method should be called from your web-server when the user visits "http://localhost:5000/callback" +public Task GetCallback(string code) +{ + // Note that we use the verifier calculated above! + var initialResponse = await new OAuthClient().RequestToken( + new PKCETokenRequest("ClientId", code, "http://localhost:5000", verifier) + ); + + var spotify = new SpotifyClient(initialResponse.AccessToken); + // Also important for later: response.RefreshToken +} +``` + +With PKCE you can also refresh tokens once they're expired: + +```csharp +var newResponse = await new OAuthClient().RequestToken( + new PKCETokenRefreshRequest("ClientId", initialResponse.RefreshToken) +); + +var spotify = new SpotifyClient(newResponse.AccessToken); +``` + +If you do not want to take care of manually refreshing tokens, you can use `PKCEAuthenticator`: + +```csharp +var authenticator = new PKCEAuthenticator(clientId, initialResponse); + +var config = SpotifyClientConfig.CreateDefault() + .WithAuthenticator(authenticator); +var spotify = new SpotifyClient(config); +``` diff --git a/SpotifyAPI.Docs/package.json b/SpotifyAPI.Docs/package.json index 3c1d3ae9..b06b5e68 100644 --- a/SpotifyAPI.Docs/package.json +++ b/SpotifyAPI.Docs/package.json @@ -8,6 +8,11 @@ "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy" }, + "importSort": { + ".js, .jsx, .ts, .tsx": { + "style": "module" + } + }, "dependencies": { "@docusaurus/core": "^2.0.0-alpha.56", "@docusaurus/preset-classic": "^2.0.0-alpha.56", @@ -27,5 +32,10 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "import-sort-style-module": "^6.0.0", + "prettier": "^2.0.5", + "prettier-plugin-import-sort": "^0.0.4" } } diff --git a/SpotifyAPI.Docs/sidebars.js b/SpotifyAPI.Docs/sidebars.js index 1eb3704f..2341d2fd 100644 --- a/SpotifyAPI.Docs/sidebars.js +++ b/SpotifyAPI.Docs/sidebars.js @@ -25,6 +25,7 @@ module.exports = { 'client_credentials', 'implicit_grant', 'authorization_code', + 'pkce', 'token_swap' ] }, diff --git a/SpotifyAPI.Docs/src/install_instructions.js b/SpotifyAPI.Docs/src/install_instructions.js index e31522f7..c91118a6 100644 --- a/SpotifyAPI.Docs/src/install_instructions.js +++ b/SpotifyAPI.Docs/src/install_instructions.js @@ -1,55 +1,55 @@ -import React from "react"; -import CodeBlock from '@theme/CodeBlock' -import Tabs from '@theme/Tabs' -import TabItem from '@theme/TabItem' +import CodeBlock from '@theme/CodeBlock'; +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; +import React from 'react'; // Will be removed after beta releases const VERSION = '6.0.0-beta.9'; -const installCodeNuget = - `Install-Package SpotifyAPI.Web -Version ${VERSION} +const installCodeNuget = `Install-Package SpotifyAPI.Web -Version ${VERSION} # Optional Auth module, which includes an embedded HTTP Server for OAuth2 Install-Package SpotifyAPI.Web.Auth -Version ${VERSION} `; -const installReference = - ` +const installReference = ` `; -const installCodeCLI = - `dotnet add package SpotifyAPI.Web --version ${VERSION} +const installCodeCLI = `dotnet add package SpotifyAPI.Web --version ${VERSION} # Optional Auth module, which includes an embedded HTTP Server for OAuth2 dotnet add package SpotifyAPI.Web.Auth --version ${VERSION} `; const InstallInstructions = () => { - return (
- - - - {installCodeCLI} - - - - - {installCodeNuget} - - - - - {installReference} - - - -
); -} + return ( +
+ + + + {installCodeCLI} + + + + + {installCodeNuget} + + + + + {installReference} + + + +
+ ); +}; export default InstallInstructions; diff --git a/SpotifyAPI.Docs/src/pages/index.js b/SpotifyAPI.Docs/src/pages/index.js index 538b37ba..3e15ffa7 100644 --- a/SpotifyAPI.Docs/src/pages/index.js +++ b/SpotifyAPI.Docs/src/pages/index.js @@ -1,18 +1,18 @@ -import React from 'react'; -import classnames from 'classnames'; -import Layout from '@theme/Layout'; -import CodeBlock from '@theme/CodeBlock' -import Tabs from '@theme/Tabs' -import TabItem from '@theme/TabItem' import Link from '@docusaurus/Link'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useBaseUrl from '@docusaurus/useBaseUrl'; -import styles from './styles.module.css'; -import GitHubButton from 'react-github-btn' -import InstallInstructions from '../install_instructions'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import CodeBlock from '@theme/CodeBlock'; +import Layout from '@theme/Layout'; +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; +import classnames from 'classnames'; +import React from 'react'; +import GitHubButton from 'react-github-btn'; -const exampleCode = - `var spotify = new SpotifyClient("YourAccessToken"); +import InstallInstructions from '../install_instructions'; +import styles from './styles.module.css'; + +const exampleCode = `var spotify = new SpotifyClient("YourAccessToken"); var me = await spotify.UserProfile.Current(); Console.WriteLine($"Hello there {me.DisplayName}"); @@ -30,7 +30,9 @@ const features = [ imageUrl: 'img/undraw_preferences_uuo2.svg', description: () => ( <> - SpotifyAPI-NET allows you to quickly integrate with Spotify's Web API by supplying sane configuration defaults from the start. Later on, behaviour can be customized using extensive configuration possibilities. + SpotifyAPI-NET allows you to quickly integrate with Spotify's Web API by supplying sane + configuration defaults from the start. Later on, behaviour can be customized using extensive configuration + possibilities. ), }, @@ -39,7 +41,8 @@ const features = [ imageUrl: 'img/undraw_project_completed_w0oq.svg', description: () => ( <> - The Spotify Web API consists of over 74 API calls. SpotifyAPI-NET provides fully typed requests/responses for all of them. + The Spotify Web API consists of over 74 API calls. SpotifyAPI-NET provides fully typed + requests/responses for all of them. ), }, @@ -48,7 +51,8 @@ const features = [ imageUrl: 'img/undraw_Devices_e67q.svg', description: () => ( <> - With the support of .NET Standard 2.X, SpotifyAPI-NET runs on many platforms, including .NET Core, UWP and Xamarin.Forms (Windows, Android, iOS and Mac) + With the support of .NET Standard 2.X, SpotifyAPI-NET runs on many platforms, including .NET Core, + UWP and Xamarin.Forms (Windows, Android, iOS and Mac) ), }, @@ -57,7 +61,8 @@ const features = [ imageUrl: 'img/undraw_QA_engineers_dg5p.svg', description: () => ( <> - SpotifyAPI-NET is built on a modular structure, which allows easy testing through mocks and stubs. Learn more by visiting the Testing Guide + SpotifyAPI-NET is built on a modular structure, which allows easy testing through mocks and stubs. + Learn more by visiting the Testing Guide ), }, @@ -82,9 +87,7 @@ function Home() { const context = useDocusaurusContext(); const { siteConfig = {} } = context; return ( - +
@@ -98,29 +101,31 @@ function Home() { data-icon="octicon-star" data-size="large" data-show-count="true" - aria-label="Star JohnnyCrazy/SpotifyAPI-NET on GitHub">Star + aria-label="Star JohnnyCrazy/SpotifyAPI-NET on GitHub" + > + Star +
Nuget - {' '} + src="https://img.shields.io/nuget/vpre/SpotifyAPI.Web?label=SpotifyAPI.Web&style=flat-square" + > + {' '} Nuget - + src="https://img.shields.io/nuget/vpre/SpotifyAPI.Web.Auth?label=SpotifyAPI.Web.Auth&style=flat-square" + >

{siteConfig.tagline}

+ className={classnames('button button--outline button--secondary button--lg', styles.getStarted)} + to={useBaseUrl('docs/next/introduction')} + > Get Started
diff --git a/SpotifyAPI.Docs/static/img/auth_comparison.png b/SpotifyAPI.Docs/static/img/auth_comparison.png index 60553ef5..9cfe917e 100644 Binary files a/SpotifyAPI.Docs/static/img/auth_comparison.png and b/SpotifyAPI.Docs/static/img/auth_comparison.png differ diff --git a/SpotifyAPI.Docs/yarn.lock b/SpotifyAPI.Docs/yarn.lock index d283da97..95f94002 100644 --- a/SpotifyAPI.Docs/yarn.lock +++ b/SpotifyAPI.Docs/yarn.lock @@ -9,6 +9,13 @@ dependencies: "@babel/highlight" "^7.8.3" +"@babel/code-frame@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/compat-data@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.9.6.tgz#3f604c40e420131affe6f2c8052e9a275ae2049b" @@ -40,6 +47,28 @@ semver "^5.4.1" source-map "^0.5.0" +"@babel/core@^7.2.2": + version "7.11.1" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643" + integrity sha512-XqF7F6FWQdKGGWAzGELL+aCO1p+lRY5Tj5/tbT3St1G8NaH70jhhDIKknIZaDans0OQBG5wRAldROLHSt44BgQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.11.0" + "@babel/helper-module-transforms" "^7.11.0" + "@babel/helpers" "^7.10.4" + "@babel/parser" "^7.11.1" + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.11.0" + "@babel/types" "^7.11.0" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.1" + json5 "^2.1.2" + lodash "^4.17.19" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + "@babel/core@^7.7.5", "@babel/core@^7.9.0": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.9.6.tgz#d9aa1f580abf3b2286ef40b6904d390904c63376" @@ -62,6 +91,15 @@ semver "^5.4.1" source-map "^0.5.0" +"@babel/generator@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.0.tgz#4b90c78d8c12825024568cbe83ee6c9af193585c" + integrity sha512-fEm3Uzw7Mc9Xi//qU20cBKatTfs2aOtKqmvy/Vm7RkJEGFQ4xc9myCfbXxqK//ZS8MR/ciOHw6meGASJuKmDfQ== + dependencies: + "@babel/types" "^7.11.0" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/generator@^7.9.0", "@babel/generator@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.9.6.tgz#5408c82ac5de98cda0d77d8124e99fa1f2170a43" @@ -153,6 +191,15 @@ "@babel/traverse" "^7.8.3" "@babel/types" "^7.8.3" +"@babel/helper-function-name@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" + integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== + dependencies: + "@babel/helper-get-function-arity" "^7.10.4" + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + "@babel/helper-function-name@^7.8.3", "@babel/helper-function-name@^7.9.5": version "7.9.5" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz#2b53820d35275120e1874a82e5aabe1376920a5c" @@ -162,6 +209,13 @@ "@babel/template" "^7.8.3" "@babel/types" "^7.9.5" +"@babel/helper-get-function-arity@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" + integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== + dependencies: + "@babel/types" "^7.10.4" + "@babel/helper-get-function-arity@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5" @@ -176,6 +230,13 @@ dependencies: "@babel/types" "^7.8.3" +"@babel/helper-member-expression-to-functions@^7.10.4": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df" + integrity sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q== + dependencies: + "@babel/types" "^7.11.0" + "@babel/helper-member-expression-to-functions@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c" @@ -183,6 +244,13 @@ dependencies: "@babel/types" "^7.8.3" +"@babel/helper-module-imports@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" + integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw== + dependencies: + "@babel/types" "^7.10.4" + "@babel/helper-module-imports@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498" @@ -190,6 +258,19 @@ dependencies: "@babel/types" "^7.8.3" +"@babel/helper-module-transforms@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" + integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== + dependencies: + "@babel/helper-module-imports" "^7.10.4" + "@babel/helper-replace-supers" "^7.10.4" + "@babel/helper-simple-access" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/template" "^7.10.4" + "@babel/types" "^7.11.0" + lodash "^4.17.19" + "@babel/helper-module-transforms@^7.9.0": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz#43b34dfe15961918707d247327431388e9fe96e5" @@ -203,6 +284,13 @@ "@babel/types" "^7.9.0" lodash "^4.17.13" +"@babel/helper-optimise-call-expression@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" + integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== + dependencies: + "@babel/types" "^7.10.4" + "@babel/helper-optimise-call-expression@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9" @@ -233,6 +321,16 @@ "@babel/traverse" "^7.8.3" "@babel/types" "^7.8.3" +"@babel/helper-replace-supers@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf" + integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.10.4" + "@babel/helper-optimise-call-expression" "^7.10.4" + "@babel/traverse" "^7.10.4" + "@babel/types" "^7.10.4" + "@babel/helper-replace-supers@^7.8.3", "@babel/helper-replace-supers@^7.8.6", "@babel/helper-replace-supers@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.9.6.tgz#03149d7e6a5586ab6764996cd31d6981a17e1444" @@ -243,6 +341,14 @@ "@babel/traverse" "^7.9.6" "@babel/types" "^7.9.6" +"@babel/helper-simple-access@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" + integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw== + dependencies: + "@babel/template" "^7.10.4" + "@babel/types" "^7.10.4" + "@babel/helper-simple-access@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae" @@ -251,6 +357,13 @@ "@babel/template" "^7.8.3" "@babel/types" "^7.8.3" +"@babel/helper-split-export-declaration@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" + integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== + dependencies: + "@babel/types" "^7.11.0" + "@babel/helper-split-export-declaration@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9" @@ -258,6 +371,11 @@ dependencies: "@babel/types" "^7.8.3" +"@babel/helper-validator-identifier@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" + integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== + "@babel/helper-validator-identifier@^7.9.0", "@babel/helper-validator-identifier@^7.9.5": version "7.9.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" @@ -273,6 +391,15 @@ "@babel/traverse" "^7.8.3" "@babel/types" "^7.8.3" +"@babel/helpers@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044" + integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA== + dependencies: + "@babel/template" "^7.10.4" + "@babel/traverse" "^7.10.4" + "@babel/types" "^7.10.4" + "@babel/helpers@^7.9.0", "@babel/helpers@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.9.6.tgz#092c774743471d0bb6c7de3ad465ab3d3486d580" @@ -282,6 +409,15 @@ "@babel/traverse" "^7.9.6" "@babel/types" "^7.9.6" +"@babel/highlight@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" + integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/highlight@^7.8.3": version "7.9.0" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.9.0.tgz#4e9b45ccb82b79607271b2979ad82c7b68163079" @@ -291,6 +427,11 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/parser@^7.0.0-beta.54", "@babel/parser@^7.10.4", "@babel/parser@^7.11.0", "@babel/parser@^7.11.1": + version "7.11.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9" + integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA== + "@babel/parser@^7.8.6", "@babel/parser@^7.9.0", "@babel/parser@^7.9.4", "@babel/parser@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.9.6.tgz#3b1bbb30dabe600cd72db58720998376ff653bc7" @@ -872,6 +1013,15 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/template@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" + integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/parser" "^7.10.4" + "@babel/types" "^7.10.4" + "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -881,6 +1031,21 @@ "@babel/parser" "^7.8.6" "@babel/types" "^7.8.6" +"@babel/traverse@^7.0.0-beta.54", "@babel/traverse@^7.10.4", "@babel/traverse@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.0.tgz#9b996ce1b98f53f7c3e4175115605d56ed07dd24" + integrity sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/generator" "^7.11.0" + "@babel/helper-function-name" "^7.10.4" + "@babel/helper-split-export-declaration" "^7.11.0" + "@babel/parser" "^7.11.0" + "@babel/types" "^7.11.0" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + "@babel/traverse@^7.8.3", "@babel/traverse@^7.9.0", "@babel/traverse@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.9.6.tgz#5540d7577697bf619cc57b92aa0f1c231a94f442" @@ -896,6 +1061,15 @@ globals "^11.1.0" lodash "^4.17.13" +"@babel/types@^7.0.0-beta.54", "@babel/types@^7.10.4", "@babel/types@^7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.0.tgz#2ae6bf1ba9ae8c3c43824e5861269871b206e90d" + integrity sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA== + dependencies: + "@babel/helper-validator-identifier" "^7.10.4" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + "@babel/types@^7.4.4", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5", "@babel/types@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.6.tgz#2c5502b427251e9de1bd2dff95add646d95cc9f7" @@ -2206,6 +2380,11 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" +builtin-modules@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484" + integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw== + builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" @@ -2808,7 +2987,7 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cosmiconfig@^5.0.0: +cosmiconfig@^5.0.0, cosmiconfig@^5.0.5: version "5.2.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== @@ -3255,6 +3434,11 @@ detab@2.0.3, detab@^2.0.0: dependencies: repeat-string "^1.5.4" +detect-newline@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + detect-node@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" @@ -3943,6 +4127,16 @@ find-cache-dir@^3.0.0, find-cache-dir@^3.3.1: make-dir "^3.0.2" pkg-dir "^4.1.0" +find-line-column@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/find-line-column/-/find-line-column-0.5.2.tgz#db00238ff868551a182e74a103416d295a98c8ca" + integrity sha1-2wAjj/hoVRoYLnShA0FtKVqYyMo= + +find-root@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== + find-up@4.1.0, find-up@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -4767,6 +4961,60 @@ import-local@^2.0.0: pkg-dir "^3.0.0" resolve-cwd "^2.0.0" +import-sort-config@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort-config/-/import-sort-config-6.0.0.tgz#7313775b761eb479ab2d383945ecb15c008763b8" + integrity sha512-FJpF2F3+30JXqH1rJKeajxoSCHCueai3/0ntDN4y3GJL5pjnLDt/VjCy5FzjH7u0NHnllL/zVEf1wfmsVxJlPQ== + dependencies: + cosmiconfig "^5.0.5" + find-root "^1.0.0" + minimatch "^3.0.4" + resolve-from "^4.0.0" + +import-sort-parser-babylon@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort-parser-babylon/-/import-sort-parser-babylon-6.0.0.tgz#e1a4c28e0794ad7d9ff36cd045559d8ca8c38be7" + integrity sha512-NyShTiNhTh4Vy7kJUVe6CuvOaQAzzfSIT72wtp3CzGjz8bHjNj59DCAjncuviicmDOgVAgmLuSh1WMcLYAMWGg== + dependencies: + "@babel/core" "^7.2.2" + "@babel/parser" "^7.0.0-beta.54" + "@babel/traverse" "^7.0.0-beta.54" + "@babel/types" "^7.0.0-beta.54" + find-line-column "^0.5.2" + +import-sort-parser-typescript@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort-parser-typescript/-/import-sort-parser-typescript-6.0.0.tgz#98e73cef9e077d073e798722ed59e215b51c17e2" + integrity sha512-pgxnr3I156DonupQriNsgDb2zJN9TxrqCCIN1rwT/6SDO1rkJb+a0fjqshCjlgacTSA92oPAp1eAwmQUeZi3dw== + dependencies: + typescript "^3.2.4" + +import-sort-parser@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort-parser/-/import-sort-parser-6.0.0.tgz#0d901f264d98ed7caaae71f66128a686f828f2f4" + integrity sha512-H5L+d6HnqHvThB0GmAA3/43Sv74oCwL0iMk3/ixOv0LRJ69rCyHXeG/+UadMHrD2FefEmgPIWboEPAG7gsQrkA== + +import-sort-style-module@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort-style-module/-/import-sort-style-module-6.0.0.tgz#3149df4785bae804ed32630634ed49e405fa7cad" + integrity sha512-Oxd256EVt6TAgawhIDuKnNHWumzHMHFWhVncBBvlHVnx69B4GP/Gu4Xo+gjxtqSEKEvam5ajUkNvnsXLDMDjKg== + +import-sort-style@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort-style/-/import-sort-style-6.0.0.tgz#088523f056e5064c34a6426f4733674d81b42e6a" + integrity sha512-z0H5PKs7YoDeKxNYXv2AA1mjjZFY07fjeNCXUdTM3ymJtWeeEoTm8CQkFm2l+KPZoMczIvdwzJpWkkOamBnsPw== + +import-sort@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/import-sort/-/import-sort-6.0.0.tgz#48ba2a7b53f2566ca1dd004327ea271321ad64ff" + integrity sha512-XUwSQMGAGmcW/wfshFE0gXgb1NPF6ibbQD6wDr3KRDykZf/lZj0jf58Bwa02xNb8EE59oz7etFe9OHnJocUW5Q== + dependencies: + detect-newline "^2.1.0" + import-sort-parser "^6.0.0" + import-sort-style "^6.0.0" + is-builtin-module "^3.0.0" + resolve "^1.8.1" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -4955,6 +5203,13 @@ is-buffer@^2.0.0: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== +is-builtin-module@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.0.0.tgz#137d3d2425023a19a660fb9dd6ddfabe52c03466" + integrity sha512-/93sDihsAD652hrMEbJGbMAVBf1qc96kyThHQ0CAOONHaE3aROLpTjDe4WQ5aoC5ITHFxEq1z8XqSU7km+8amw== + dependencies: + builtin-modules "^3.0.0" + is-callable@^1.1.4, is-callable@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" @@ -5631,6 +5886,11 @@ lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== +lodash@^4.17.19: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + loglevel@^1.6.8: version "1.6.8" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" @@ -7316,6 +7576,21 @@ prepend-http@^1.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= +prettier-plugin-import-sort@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/prettier-plugin-import-sort/-/prettier-plugin-import-sort-0.0.4.tgz#29fb80669a1a7cef5fb841933b7aaf80f5e80cb4" + integrity sha512-KyPKnWbFER9NJk+qidbGYSPO82wF1OSNdnZGtVSBG6Te0ZAWOvZ5fDeTaaHczG8UIaePgSNE1H1DoUMAykjsEg== + dependencies: + import-sort "^6.0.0" + import-sort-config "^6.0.0" + import-sort-parser-babylon "^6.0.0" + import-sort-parser-typescript "^6.0.0" + +prettier@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4" + integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg== + pretty-error@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" @@ -8983,6 +9258,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript@^3.2.4: + version "3.9.7" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" + integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== + unherit@^1.0.4: version "1.1.3" resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" diff --git a/SpotifyAPI.Web.Examples/Example.CLI.PersistentConfig/Program.cs b/SpotifyAPI.Web.Examples/Example.CLI.PersistentConfig/Program.cs index d3aca141..6ddd5813 100644 --- a/SpotifyAPI.Web.Examples/Example.CLI.PersistentConfig/Program.cs +++ b/SpotifyAPI.Web.Examples/Example.CLI.PersistentConfig/Program.cs @@ -2,11 +2,11 @@ using System.IO; using System.Threading.Tasks; using System; using SpotifyAPI.Web.Auth; -using SpotifyAPI.Web.Http; using SpotifyAPI.Web; using System.Collections.Generic; using Newtonsoft.Json; using static SpotifyAPI.Web.Scopes; +using Swan.Logging; namespace Example.CLI.PersistentConfig { @@ -18,15 +18,18 @@ namespace Example.CLI.PersistentConfig { private const string CredentialsPath = "credentials.json"; private static readonly string? clientId = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_ID"); - private static readonly string? clientSecret = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_SECRET"); private static readonly EmbedIOAuthServer _server = new EmbedIOAuthServer(new Uri("http://localhost:5000/callback"), 5000); + private static void Exiting() => Console.CursorVisible = true; public static async Task Main() { - if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + // This is a bug in the SWAN Logging library, need this hack to bring back the cursor + AppDomain.CurrentDomain.ProcessExit += (sender, e) => Exiting(); + + if (string.IsNullOrEmpty(clientId)) { throw new NullReferenceException( - "Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET via environment variables before starting the program" + "Please set SPOTIFY_CLIENT_ID via environment variables before starting the program" ); } @@ -46,9 +49,9 @@ namespace Example.CLI.PersistentConfig private static async Task Start() { var json = await File.ReadAllTextAsync(CredentialsPath); - var token = JsonConvert.DeserializeObject(json); + var token = JsonConvert.DeserializeObject(json); - var authenticator = new AuthorizationCodeAuthenticator(clientId!, clientSecret!, token); + var authenticator = new PKCEAuthenticator(clientId!, token); authenticator.TokenRefreshed += (sender, token) => File.WriteAllText(CredentialsPath, JsonConvert.SerializeObject(token)); var config = SpotifyClientConfig.CreateDefault() @@ -68,12 +71,25 @@ namespace Example.CLI.PersistentConfig private static async Task StartAuthentication() { + var (verifier, challenge) = PKCEUtil.GenerateCodes(); + await _server.Start(); - _server.AuthorizationCodeReceived += OnAuthorizationCodeReceived; + _server.AuthorizationCodeReceived += async (sender, response) => + { + await _server.Stop(); + PKCETokenResponse token = await new OAuthClient().RequestToken( + new PKCETokenRequest(clientId!, response.Code, _server.BaseUri, verifier) + ); + + await File.WriteAllTextAsync(CredentialsPath, JsonConvert.SerializeObject(token)); + await Start(); + }; var request = new LoginRequest(_server.BaseUri, clientId!, LoginRequest.ResponseType.Code) { - Scope = new List { UserReadEmail, UserReadPrivate, PlaylistReadPrivate } + CodeChallenge = challenge, + CodeChallengeMethod = "S256", + Scope = new List { UserReadEmail, UserReadPrivate, PlaylistReadPrivate, PlaylistReadCollaborative } }; Uri uri = request.ToUri(); @@ -86,16 +102,5 @@ namespace Example.CLI.PersistentConfig Console.WriteLine("Unable to open URL, manually open: {0}", uri); } } - - private static async Task OnAuthorizationCodeReceived(object sender, AuthorizationCodeResponse response) - { - await _server.Stop(); - AuthorizationCodeTokenResponse token = await new OAuthClient().RequestToken( - new AuthorizationCodeTokenRequest(clientId!, clientSecret!, response.Code, _server.BaseUri) - ); - - await File.WriteAllTextAsync(CredentialsPath, JsonConvert.SerializeObject(token)); - await Start(); - } } } diff --git a/SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs b/SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs new file mode 100644 index 00000000..096efb6e --- /dev/null +++ b/SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs @@ -0,0 +1,44 @@ +using System.Text; +using NUnit.Framework; +using SpotifyAPI.Web; + +namespace SpotifyAPI.Web.Tests +{ + [TestFixture] + public class Base64UtilTest + { + [Test] + public void Base64UrlDecode_Works() + { + var encoded = "SGVsbG9Xb3JsZA"; + + Assert.AreEqual("HelloWorld", Encoding.UTF8.GetString(Base64Util.UrlDecode(encoded))); + } + + [Test] + public void Base64UrlEncode_Works() + { + var decoded = "HelloWorld"; + + Assert.AreEqual("SGVsbG9Xb3JsZA", Base64Util.UrlEncode(Encoding.UTF8.GetBytes(decoded))); + } + + [Test] + public void Base64UrlEncode_WorksSpecialChars() + { + var bytes = new byte[] { 0x04, 0x9f, 0x9c, 0xff, 0x3f, 0x0a }; + + // normal base64: BJ+c/z8K + Assert.AreEqual("BJ-c_z8K", Base64Util.UrlEncode(bytes)); + } + + [Test] + public void Base64UrlDecode_WorksSpecialChars() + { + var bytes = new byte[] { 0x04, 0x9f, 0x9c, 0xff, 0x3f, 0x0a }; + + // normal base64: BJ+c/z8K + Assert.AreEqual(bytes, Base64Util.UrlDecode("BJ-c_z8K")); + } + } +} diff --git a/SpotifyAPI.Web/Assembly.cs b/SpotifyAPI.Web/Assembly.cs new file mode 100644 index 00000000..6e71e55c --- /dev/null +++ b/SpotifyAPI.Web/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SpotifyAPI.Web.Tests")] diff --git a/SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs b/SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs new file mode 100644 index 00000000..4cfb99d6 --- /dev/null +++ b/SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; +using SpotifyAPI.Web.Http; + +namespace SpotifyAPI.Web +{ + /// + /// This Authenticator requests new credentials token on demand and stores them into memory. + /// It is unable to query user specifc details. + /// + public class PKCEAuthenticator : IAuthenticator + { + /// + /// Initiate a new instance. The token will be refreshed once it expires. + /// The initialToken will be updated with the new values on refresh! + /// + public PKCEAuthenticator(string clientId, PKCETokenResponse initialToken) + { + Ensure.ArgumentNotNull(clientId, nameof(clientId)); + Ensure.ArgumentNotNull(initialToken, nameof(initialToken)); + + InitialToken = initialToken; + ClientId = clientId; + } + + /// + /// This event is called once a new refreshed token was aquired + /// + public event EventHandler? TokenRefreshed; + + + /// + /// The ClientID, defined in a spotify application in your Spotify Developer Dashboard + /// + public string ClientId { get; } + + /// + /// The inital token passed to the authenticator. Fields will be updated on refresh. + /// + /// + public PKCETokenResponse InitialToken { get; } + + public async Task Apply(IRequest request, IAPIConnector apiConnector) + { + Ensure.ArgumentNotNull(request, nameof(request)); + + if (InitialToken.IsExpired) + { + var tokenRequest = new PKCETokenRefreshRequest(ClientId, InitialToken.RefreshToken); + var refreshedToken = await OAuthClient.RequestToken(tokenRequest, apiConnector).ConfigureAwait(false); + + InitialToken.AccessToken = refreshedToken.AccessToken; + InitialToken.CreatedAt = refreshedToken.CreatedAt; + InitialToken.ExpiresIn = refreshedToken.ExpiresIn; + InitialToken.Scope = refreshedToken.Scope; + InitialToken.TokenType = refreshedToken.TokenType; + InitialToken.RefreshToken = refreshedToken.RefreshToken; + + TokenRefreshed?.Invoke(this, InitialToken); + } + + request.Headers["Authorization"] = $"{InitialToken.TokenType} {InitialToken.AccessToken}"; + } + } +} diff --git a/SpotifyAPI.Web/Clients/OAuthClient.cs b/SpotifyAPI.Web/Clients/OAuthClient.cs index e687d461..dd2b77c4 100644 --- a/SpotifyAPI.Web/Clients/OAuthClient.cs +++ b/SpotifyAPI.Web/Clients/OAuthClient.cs @@ -15,6 +15,32 @@ namespace SpotifyAPI.Web [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062")] public OAuthClient(SpotifyClientConfig config) : base(ValidateConfig(config)) { } + /// + /// Requests a new token using pkce flow + /// + /// The request-model which contains required and optional parameters. + /// + /// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce + /// + /// 1 + public Task RequestToken(PKCETokenRequest request) + { + return RequestToken(request, API); + } + + /// + /// Refreshes a token using pkce flow + /// + /// The request-model which contains required and optional parameters. + /// + /// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce + /// + /// 1 + public Task RequestToken(PKCETokenRefreshRequest request) + { + return RequestToken(request, API); + } + /// /// Requests a new token using client_ids and client_secrets. /// If the token is expired, simply call the funtion again to get a new token @@ -81,6 +107,38 @@ namespace SpotifyAPI.Web return RequestToken(request, API); } + public static Task RequestToken(PKCETokenRequest request, IAPIConnector apiConnector) + { + Ensure.ArgumentNotNull(request, nameof(request)); + Ensure.ArgumentNotNull(apiConnector, nameof(apiConnector)); + + var form = new List> + { + new KeyValuePair("client_id", request.ClientId), + new KeyValuePair("grant_type", "authorization_code"), + new KeyValuePair("code", request.Code), + new KeyValuePair("redirect_uri", request.RedirectUri.ToString()), + new KeyValuePair("code_verifier", request.CodeVerifier), + }; + + return SendOAuthRequest(apiConnector, form, null, null); + } + + public static Task RequestToken(PKCETokenRefreshRequest request, IAPIConnector apiConnector) + { + Ensure.ArgumentNotNull(request, nameof(request)); + Ensure.ArgumentNotNull(apiConnector, nameof(apiConnector)); + + var form = new List> + { + new KeyValuePair("client_id", request.ClientId), + new KeyValuePair("grant_type", "refresh_token"), + new KeyValuePair("refresh_token", request.RefreshToken), + }; + + return SendOAuthRequest(apiConnector, form, null, null); + } + public static Task RequestToken( TokenSwapRefreshRequest request, IAPIConnector apiConnector ) @@ -169,8 +227,8 @@ namespace SpotifyAPI.Web private static Task SendOAuthRequest( IAPIConnector apiConnector, List> form, - string clientId, - string clientSecret) + string? clientId, + string? clientSecret) { var headers = BuildAuthHeader(clientId, clientSecret); #pragma warning disable CA2000 @@ -178,8 +236,13 @@ namespace SpotifyAPI.Web #pragma warning restore CA2000 } - private static Dictionary BuildAuthHeader(string clientId, string clientSecret) + private static Dictionary BuildAuthHeader(string? clientId, string? clientSecret) { + if (clientId == null || clientSecret == null) + { + return new Dictionary(); + } + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); return new Dictionary { diff --git a/SpotifyAPI.Web/Models/Request/LoginRequest.cs b/SpotifyAPI.Web/Models/Request/LoginRequest.cs index 4ba13fca..32944d61 100644 --- a/SpotifyAPI.Web/Models/Request/LoginRequest.cs +++ b/SpotifyAPI.Web/Models/Request/LoginRequest.cs @@ -24,6 +24,8 @@ namespace SpotifyAPI.Web public string? State { get; set; } public ICollection? Scope { get; set; } public bool? ShowDialog { get; set; } + public string? CodeChallengeMethod { get; set; } + public string? CodeChallenge { get; set; } public Uri ToUri() { @@ -43,6 +45,14 @@ namespace SpotifyAPI.Web { builder.Append($"&show_dialog={ShowDialog.Value}"); } + if (CodeChallenge != null) + { + builder.Append($"&code_challenge={CodeChallenge}"); + } + if (CodeChallengeMethod != null) + { + builder.Append($"&code_challenge_method={CodeChallengeMethod}"); + } return new Uri(builder.ToString()); } diff --git a/SpotifyAPI.Web/Models/Request/PKCETokenRefreshRequest.cs b/SpotifyAPI.Web/Models/Request/PKCETokenRefreshRequest.cs new file mode 100644 index 00000000..5cfe7541 --- /dev/null +++ b/SpotifyAPI.Web/Models/Request/PKCETokenRefreshRequest.cs @@ -0,0 +1,33 @@ +using System; + +namespace SpotifyAPI.Web +{ + public class PKCETokenRefreshRequest + { + /// + /// Request model for refreshing a access token via PKCE Token + /// + /// The Client ID of your Spotify Application (See Spotify Dev Dashboard). + /// The received refresh token. Expires after one refresh + public PKCETokenRefreshRequest(string clientId, string refreshToken) + { + Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId)); + Ensure.ArgumentNotNullOrEmptyString(refreshToken, nameof(refreshToken)); + + ClientId = clientId; + RefreshToken = refreshToken; + } + + /// + /// The Client ID of your Spotify Application (See Spotify Dev Dashboard). + /// + /// + public string ClientId { get; } + + /// + /// The received refresh token. + /// + /// + public string RefreshToken { get; } + } +} diff --git a/SpotifyAPI.Web/Models/Request/PKCETokenRequest.cs b/SpotifyAPI.Web/Models/Request/PKCETokenRequest.cs new file mode 100644 index 00000000..00a0a57b --- /dev/null +++ b/SpotifyAPI.Web/Models/Request/PKCETokenRequest.cs @@ -0,0 +1,55 @@ +using System; + +namespace SpotifyAPI.Web +{ + public class PKCETokenRequest + { + /// + /// + /// + /// The Client ID of your Spotify Application (See Spotify Dev Dashboard). + /// The code received from the spotify response. + /// The redirectUri which was used to initiate the authentication. + /// + /// The value of this parameter must match the value of the code_verifier + /// that your app generated in step 1. + /// + public PKCETokenRequest(string clientId, string code, Uri redirectUri, string codeVerifier) + { + Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId)); + Ensure.ArgumentNotNullOrEmptyString(code, nameof(code)); + Ensure.ArgumentNotNullOrEmptyString(codeVerifier, nameof(codeVerifier)); + Ensure.ArgumentNotNull(redirectUri, nameof(redirectUri)); + + ClientId = clientId; + CodeVerifier = codeVerifier; + Code = code; + RedirectUri = redirectUri; + } + + /// + /// The Client ID of your Spotify Application (See Spotify Dev Dashboard). + /// + /// + public string ClientId { get; } + + /// + /// The value of this parameter must match the value of the code_verifier + /// that your app generated in step 1. + /// + /// + public string CodeVerifier { get; } + + /// + /// The code received from the spotify response. + /// + /// + public string Code { get; } + + /// + /// The redirectUri which was used to initiate the authentication. + /// + /// + public Uri RedirectUri { get; } + } +} diff --git a/SpotifyAPI.Web/Models/Response/PKCETokenResponse.cs b/SpotifyAPI.Web/Models/Response/PKCETokenResponse.cs new file mode 100644 index 00000000..c3fdf291 --- /dev/null +++ b/SpotifyAPI.Web/Models/Response/PKCETokenResponse.cs @@ -0,0 +1,21 @@ +using System; + +namespace SpotifyAPI.Web +{ + public class PKCETokenResponse + { + public string AccessToken { get; set; } = default!; + public string TokenType { get; set; } = default!; + public int ExpiresIn { get; set; } + public string Scope { get; set; } = default!; + public string RefreshToken { get; set; } = default!; + + /// + /// Auto-Initalized to UTC Now + /// + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public bool IsExpired { get => CreatedAt.AddSeconds(ExpiresIn) <= DateTime.UtcNow; } + } +} diff --git a/SpotifyAPI.Web/Util/Base64Util.cs b/SpotifyAPI.Web/Util/Base64Util.cs new file mode 100644 index 00000000..c2eb5a71 --- /dev/null +++ b/SpotifyAPI.Web/Util/Base64Util.cs @@ -0,0 +1,136 @@ + +using System; +using System.Globalization; + +namespace SpotifyAPI.Web +{ + internal class Base64Util + { + internal const string WebEncoders_InvalidCountOffsetOrLength = "Invalid {0}, {1} or {2} length."; + internal const string WebEncoders_MalformedInput = "Malformed input: {0} is an invalid input length."; + + public static string UrlEncode(byte[] input) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + // Special-case empty input + if (input.Length == 0) + { + return string.Empty; + } + + var buffer = new char[GetArraySizeRequiredToEncode(input.Length)]; + var numBase64Chars = Convert.ToBase64CharArray(input, 0, input.Length, buffer, 0); + + // Fix up '+' -> '-' and '/' -> '_'. Drop padding characters. + for (var i = 0; i < numBase64Chars; i++) + { + var ch = buffer[i]; + if (ch == '+') + { + buffer[i] = '-'; + } + else if (ch == '/') + { + buffer[i] = '_'; + } + else if (ch == '=') + { + return new string(buffer, startIndex: 0, length: i); + } + } + + return new string(buffer, startIndex: 0, length: numBase64Chars); + } + + public static byte[] UrlDecode(string input) + { + var buffer = new char[GetArraySizeRequiredToDecode(input.Length)]; + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + // Assumption: input is base64url encoded without padding and contains no whitespace. + + var paddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(input.Length); + var arraySizeRequired = checked(input.Length + paddingCharsToAdd); + + // Copy input into buffer, fixing up '-' -> '+' and '_' -> '/'. + var i = 0; + for (var j = 0; i < input.Length; i++, j++) + { + var ch = input[j]; + if (ch == '-') + { + buffer[i] = '+'; + } + else if (ch == '_') + { + buffer[i] = '/'; + } + else + { + buffer[i] = ch; + } + } + + // Add the padding characters back. + for (; paddingCharsToAdd > 0; i++, paddingCharsToAdd--) + { + buffer[i] = '='; + } + + // Decode. + // If the caller provided invalid base64 chars, they'll be caught here. + return Convert.FromBase64CharArray(buffer, 0, arraySizeRequired); + } + + private static int GetArraySizeRequiredToEncode(int count) + { + var numWholeOrPartialInputBlocks = checked(count + 2) / 3; + return checked(numWholeOrPartialInputBlocks * 4); + } + + private static int GetArraySizeRequiredToDecode(int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (count == 0) + { + return 0; + } + + var numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); + + return checked(count + numPaddingCharsToAdd); + } + + private static int GetNumBase64PaddingCharsToAddForDecode(int inputLength) + { + switch (inputLength % 4) + { + case 0: + return 0; + case 2: + return 2; + case 3: + return 1; + default: + throw new FormatException( + string.Format( + CultureInfo.CurrentCulture, + WebEncoders_MalformedInput, + inputLength)); + } + } + } + + +} diff --git a/SpotifyAPI.Web/Util/PKCEUtil.cs b/SpotifyAPI.Web/Util/PKCEUtil.cs new file mode 100644 index 00000000..73ec6b6f --- /dev/null +++ b/SpotifyAPI.Web/Util/PKCEUtil.cs @@ -0,0 +1,73 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace SpotifyAPI.Web +{ + public static class PKCEUtil + { + private const int VERIFY_MIN_LENGTH = 43; + private const int VERIFY_MAX_LENGTH = 128; + private const int VERIFY_DEFAULT_LENGTH = 100; + + /// + /// Generate a verifier and challenge pair using RNGCryptoServiceProvider + /// + /// The length of the generated verifier + /// + public static (string verifier, string challenge) GenerateCodes(int length = VERIFY_DEFAULT_LENGTH) + { + if (length < VERIFY_MIN_LENGTH || length > VERIFY_MAX_LENGTH) + { + throw new ArgumentException( + $"length must be between {VERIFY_MIN_LENGTH} and {VERIFY_MAX_LENGTH}", + nameof(length) + ); + } + + var verifier = GenerateRandomURLSafeString(length); + return GenerateCodes(verifier); + } + + /// + /// Return the paseed verifier and its challenge + /// + /// A secure random generated verifier + /// + public static (string verifier, string challenge) GenerateCodes(string verifier) + { + Ensure.ArgumentNotNull(verifier, nameof(verifier)); + + if (verifier.Length < VERIFY_MIN_LENGTH || verifier.Length > VERIFY_MAX_LENGTH) + { + throw new ArgumentException( + $"length must be between {VERIFY_MIN_LENGTH} and {VERIFY_MAX_LENGTH}", + nameof(verifier) + ); + } + + var challenge = Base64Util.UrlEncode(ComputeSHA256(verifier)); + return (verifier, challenge); + } + + private static string GenerateRandomURLSafeString(int length) + { + using (var rng = new RNGCryptoServiceProvider()) + { + var bit_count = length * 6; + var byte_count = (bit_count + 7) / 8; // rounded up + var bytes = new byte[byte_count]; + rng.GetBytes(bytes); + return Base64Util.UrlEncode(bytes); + } + } + + private static byte[] ComputeSHA256(string value) + { + using (var hash = SHA256.Create()) + { + return hash.ComputeHash(Encoding.UTF8.GetBytes(value)); + } + } + } +}