├── .cargo └── config.toml ├── .github ├── renovate.json └── workflows │ ├── audit.yml │ ├── ci.yml │ └── gh-pages.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── deny.toml ├── docs ├── README.md └── index.html ├── examples ├── app_access_token.rs ├── auth_flow.rs ├── device_code_flow.rs ├── mock_app.rs ├── mock_user.rs └── user_token.rs ├── release.toml ├── rustfmt.toml ├── src ├── client.rs ├── id.rs ├── lib.rs ├── scopes.rs ├── scopes │ └── validator.rs ├── tokens.rs ├── tokens │ ├── app_access_token.rs │ ├── errors.rs │ └── user_token.rs └── types.rs └── xtask ├── Cargo.toml └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | xtask = "run --package xtask --" 3 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>twitch-rs/.github:renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Audit 2 | env: 3 | MSRV: 1.71.1 4 | 5 | on: 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | push: 9 | branches: [main] 10 | schedule: 11 | - cron: "0 0 * * *" 12 | merge_group: 13 | types: [checks_requested] 14 | 15 | jobs: 16 | audit: 17 | needs: [cargo-deny] # security-audit, 18 | runs-on: ubuntu-latest 19 | if: always() 20 | steps: 21 | - run: jq --exit-status 'all(.result == "success")' <<< '${{ toJson(needs) }}' 22 | - name: Done 23 | run: exit 0 24 | cargo-deny: 25 | name: Cargo Deny 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | checks: 30 | - advisories 31 | - bans 32 | - licenses 33 | - sources 34 | steps: 35 | - uses: actions/checkout@v4 36 | with: 37 | submodules: recursive 38 | - uses: EmbarkStudios/cargo-deny-action@v2 39 | with: 40 | rust-version: ${{ env.MSRV }} 41 | command: check ${{ matrix.checks }} -s 42 | arguments: --all-features 43 | log-level: warn 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | env: 3 | CI_TWITCH_OAUTH2_FEATURES: "all mock_api" 4 | MSRV: 1.71.1 5 | on: 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | push: 9 | branches: [main] 10 | merge_group: 11 | types: [checks_requested] 12 | jobs: 13 | ci: 14 | name: CI 15 | needs: [test, fmt, clippy, docs, release, wasm-build] 16 | runs-on: ubuntu-latest 17 | if: always() 18 | steps: 19 | - run: jq --exit-status 'all(.result == "success")' <<< '${{ toJson(needs) }}' 20 | - name: Done 21 | run: exit 0 22 | release: 23 | name: Release 24 | runs-on: ubuntu-latest 25 | needs: [test, fmt, clippy, docs, wasm-build] 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | submodules: recursive 30 | fetch-depth: 0 # fetch tags for publish 31 | # ssh-key: "${{ secrets.COMMIT_KEY }}" # use deploy key to trigger workflow on tag 32 | - uses: Swatinem/rust-cache@v2 33 | - run: cargo xtask release 34 | env: 35 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 37 | test: 38 | name: Tests 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | os: [windows-latest, ubuntu-latest] 43 | rust: ["", nightly] 44 | runs-on: ${{ matrix.os }} 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | - name: Install rust 49 | uses: dtolnay/rust-toolchain@master 50 | with: 51 | toolchain: ${{ matrix.rust || env.MSRV }} 52 | - uses: Swatinem/rust-cache@v2 53 | - name: Test twitch_oauth2 54 | run: cargo test --all-targets --features "${{ env.CI_TWITCH_OAUTH2_FEATURES }}" ${{matrix.rust == 'nightly' && '--workspace'}} 55 | fmt: 56 | name: Rustfmt 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: dtolnay/rust-toolchain@nightly 61 | with: 62 | components: rustfmt 63 | - name: Run fmt --all -- --check 64 | run: cargo fmt --all -- --check 65 | prettier: 66 | name: Prettier 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v4 70 | - name: Check formatting with Prettier 71 | uses: actionsx/prettier@v3 72 | with: 73 | args: -c . 74 | clippy: 75 | name: Clippy 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: dtolnay/rust-toolchain@nightly 80 | with: 81 | components: clippy 82 | - uses: Swatinem/rust-cache@v2 83 | - name: Run clippy 84 | run: cargo clippy --locked 85 | - name: Run clippy --all-targets --all-features --workspace 86 | run: cargo clippy --locked --all-targets --all-features --workspace 87 | docs: 88 | name: Docs 89 | runs-on: ubuntu-latest 90 | steps: 91 | - uses: actions/checkout@v4 92 | - uses: dtolnay/rust-toolchain@nightly 93 | - uses: Swatinem/rust-cache@v2 94 | # We do the following to make sure docs.rs can document properly without anything broken, and that docs are working. 95 | - name: Run doc tests 96 | run: cargo test --doc --all-features 97 | - name: Check twitch_oauth2 docs 98 | run: cargo xtask doc 99 | wasm-build: 100 | name: WASM Build 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: Checkout 104 | uses: actions/checkout@v4 105 | - name: Install rust 106 | uses: dtolnay/rust-toolchain@master 107 | with: 108 | toolchain: ${{ env.MSRV }} 109 | targets: wasm32-unknown-emscripten 110 | - uses: Swatinem/rust-cache@v2 111 | - name: Test twitch_oauth2 112 | run: cargo build --target wasm32-unknown-emscripten 113 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: dtolnay/rust-toolchain@nightly 12 | - uses: Swatinem/rust-cache@v2 13 | - name: build twitch_oauth2 docs 14 | run: cargo xtask doc 15 | - name: move index.html 16 | run: cp ./docs/index.html ./target/extra/doc/index.html 17 | - name: Deploy 18 | uses: peaceiris/actions-gh-pages@v4 19 | with: 20 | github_token: ${{ secrets.GITHUB_TOKEN }} 21 | publish_dir: ./target/extra/doc 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target/ 2 | **/*.rs.bk 3 | **/*.rsx 4 | **/*.db 5 | **/.env 6 | **/*.iml 7 | **/.idea/ 8 | **/.vscode/*.* 9 | **/logfile* 10 | **/*.old 11 | **/*.log 12 | coverage_reports/ 13 | /cargo-timing*.html 14 | **/.cargo 15 | **/.* 16 | !.github/ 17 | !.vscode/ 18 | !.vscode/settings.json -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.checkOnSave.allTargets": false, 3 | "rust-analyzer.checkOnSave.features": ["all"], 4 | "rust-analyzer.checkOnSave.enable": true, 5 | "rust-analyzer.cargo.features": ["all", "mock_api"], 6 | "rust-analyzer.procMacro.attributes.enable": true 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | 4 | 5 | ## [Unreleased] - ReleaseDate 6 | 7 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.15.2...Unreleased) 8 | 9 | ### Added 10 | 11 | - Added `Hash` derive to `Scopes`. 12 | 13 | ### Changed 14 | 15 | - Send refresh-token request in HTTP POST body 16 | 17 | ## [v0.15.2] - 2025-03-05 18 | 19 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.15.1...v0.15.2) 20 | 21 | ### Added 22 | 23 | - Added a way to see what scopes are missing in a `validator!()` with `Validator::missing`. 24 | - Added pretty printing for `validator!()`. 25 | - Added `UserToken::from_refresh_token` and `UserToken::from_existing_or_refresh_token` 26 | 27 | ## [v0.15.1] - 2025-01-12 28 | 29 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.15.0...v0.15.1) 30 | 31 | ### Added 32 | 33 | - Added `user:read:whispers` scope. 34 | 35 | ## [v0.15.0] - 2025-01-11 36 | 37 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.14.0...v0.15.0) 38 | 39 | ### Breaking 40 | 41 | - MSRV bumped to `1.71.1` 42 | - Fixed a typo in `ModeratorManageGuestStart` (now: `ModeratorManageGuestStar`) 43 | - `RefreshToken::refresh_token` takes an optional secret to allow public clients to refresh tokens. 44 | 45 | ### Added 46 | 47 | - Added support for Device Code Flow with `DeviceUserTokenBuilder` 48 | 49 | ### Fixed 50 | 51 | - `AppAccessToken` and `UserToken` now return the correct duration in `expires_in` after refreshing. 52 | - It's now possible to refresh a token without a client secret if the token supports it (e.g public client type). 53 | 54 | ## [v0.14.0] - 2024-09-23 55 | 56 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.13.0...v0.14.0) 57 | 58 | ### Breaking 59 | 60 | - MSRV bumped to `1.70.0` 61 | 62 | ### Added 63 | 64 | - Added new scopes `channel:manage:guest_star`, `channel:read:guest_star`, `moderator:manage:guest_star`, `moderator:manage:unban_requests`, `moderator:manage:warnings`, `moderator:read:banned_users`, `moderator:read:chat_messages`, `moderator:read:guest_star`, `moderator:read:moderators`, `moderator:read:suspicious_users`, `moderator:read:unban_requests`, `moderator:read:vips`, `moderator:read:warnings`, `user:read:emotes` 65 | 66 | ### Fixed 67 | 68 | - Removed unused generic in `AppAccessToken::from_existing` 69 | 70 | ## [v0.13.0] - 2024-04-04 71 | 72 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.9...v0.13.0) 73 | 74 | ### Changed 75 | 76 | - **BREAKING:** Updated `http` to `1.1.0` and `reqwest` to `0.12.2` 77 | 78 | ## [v0.12.9] - 2024-01-27 79 | 80 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.8...v0.12.9) 81 | 82 | ### Added 83 | 84 | - Added new scope `user:write:chat` 85 | 86 | ## [v0.12.8] - 2024-01-09 87 | 88 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.7...v0.12.8) 89 | 90 | ### Added 91 | 92 | - Added new scope `user:read:moderated_channels` 93 | 94 | ## [v0.12.7] - 2023-10-23 95 | 96 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.6...v0.12.7) 97 | 98 | ### Added 99 | 100 | - Added new scopes `channel:bot`, `user:bot`, `user:read:chat`, `channel:manage:ads` and `channel:read:ads`. 101 | 102 | ## [v0.12.6] - 2023-09-17 103 | 104 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.5...v0.12.6) 105 | 106 | ## [v0.12.5] - 2023-09-17 107 | 108 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.4...v0.12.5) 109 | 110 | ## [v0.12.4] - 2023-05-29 111 | 112 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.3...v0.12.4) 113 | 114 | ## [v0.12.3] - 2023-05-28 115 | 116 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.2...v0.12.3) 117 | 118 | ## [v0.12.2] - 2023-05-06 119 | 120 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.1...v0.12.2) 121 | 122 | ### Changed 123 | 124 | - Made `validator!()` work so that it returns an empty validator which matches anything. 125 | 126 | ## [v0.12.1] - 2023-05-06 127 | 128 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.12.0...v0.12.1) 129 | 130 | ### Added 131 | 132 | - Added `Validator` and `validator!` for validating tokens. 133 | - Added new function `UserToken::from_token` for creating a token with only a access token available. 134 | 135 | ## [v0.12.0] - 2023-05-01 136 | 137 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.11.1...v0.12.0) 138 | 139 | ### Added 140 | 141 | - Added `moderator:read:followers` scope 142 | - Added `Scope::all_slice`, `Scope::as_static_str` to do const operations 143 | - Added `ValidationError::InvalidToken` to signify a invalid response for that specific token type 144 | 145 | ### Changed 146 | 147 | - Made `Scope::description` const 148 | - Made error enums non exhaustive 149 | - Marked `user:edit:follows` as deprecated 150 | 151 | ### Removed 152 | 153 | - Removed `ValidationError::NoLogin`, replaced with `ValidationError::InvalidToken` 154 | 155 | ## [v0.11.1] - 2023-02-01 156 | 157 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.11.0...v0.11.1) 158 | 159 | ### Added 160 | 161 | - Added scopes for shoutouts 162 | 163 | ### Changed 164 | 165 | - Marked `channel_subscriptions` as deprecated. 166 | 167 | ## [v0.11.0] - 2023-01-24 168 | 169 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.10.0...v0.11.0) 170 | 171 | ### Breaking 172 | 173 | - Updated `twitch_types` to `0.4.0` 174 | - MSRV bumped to `1.66.1` 175 | 176 | ## [v0.10.0] - 2022-12-19 177 | 178 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.9.2...v0.10.0) 179 | 180 | ### Breaking 181 | 182 | - Changed `Client` trait to not be specified over a lifetime. Fixes an issue where &'1 Thing<'static> where: Thing<'static> would wrongly lower '1 to be specific. See https://github.com/twitch-rs/twitch_api/issues/236 183 | 184 | ## [v0.9.2] - 2022-12-04 185 | 186 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.9.1...v0.9.2) 187 | 188 | ## [v0.9.1] - 2022-12-03 189 | 190 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.9.0...v0.9.1) 191 | 192 | ### Added 193 | 194 | - Added new scopes `moderator:read:chatters`, `moderator:read:shield_mode`, `moderator:manage:shield_mode` 195 | 196 | ## [v0.9.0] - 2022-10-15 197 | 198 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.8.0...v0.9.0) 199 | 200 | ### Breaking 201 | 202 | - Added new feature flag `client` that enables client specific functions. Without this feature, 203 | `twitch_oauth2` will only provide non-async functions and 204 | provide library users functions that returns `http::Request`s and consume `http::Response`s. 205 | - `ValidatedToken::expires_in` is now an `Option`. 206 | 207 | ## [v0.8.0] - 2022-08-27 208 | 209 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.7.1...v0.8.0) 210 | 211 | ### Breaking 212 | 213 | - Bumped `aliri_braid` to `0.2`, this change means that the `new` method on the types in `types` only take an owned string now 214 | - `AccessToken::new`, `ClientId::new`, `ClientSecret::new`, `CsrfToken::new` and `RefreshToken::new` now take a `String` instead of `impl Into` 215 | 216 | ## [v0.7.1] - 2022-08-27 217 | 218 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.7.0...v0.7.1) 219 | 220 | ### Changed 221 | 222 | - Organization moved to `twitch-rs` 223 | 224 | ### Added 225 | 226 | - Added scopes `channel:manage:raids`, `channel:manage:moderators`, `channel:manage:vips`, `channel:read:charity`, 227 | `channel:read:vips`, `moderator:manage:announcements`, `moderator:manage:chat_messages`, `user:manage:chat_color` and 228 | `user:manage:whispers` 229 | 230 | ## [v0.7.0] - 2022-05-08 231 | 232 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.6.1...v0.7.0) 233 | 234 | ### Breaking changes 235 | 236 | - switch to [`twitch_types`](https://crates.io/crates/twitch_types) for `UserId` and `Nickname`/`UserName` 237 | - bump MSRV to 1.60, also changes the feature names for clients to their simpler variant `surf` and `client` 238 | 239 | ## [v0.6.1] - 2021-11-23 240 | 241 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.6.0...v0.6.1) 242 | 243 | ### Added 244 | 245 | - Added new scopes `moderator:manage:automod_settings`, `moderator:manage:banned_users`, 246 | `moderator:manage:blocked_terms`, `moderator:manage:chat_settings`, `moderator:read:automod_settings`, 247 | `moderator:read:blocked_terms` and `moderator:read:chat_settings` 248 | 249 | ## [v0.6.0] - 2021-09-27 250 | 251 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.5.2...v0.6.0) 252 | 253 | ### Breaking changes 254 | 255 | - All types associated with tokens are now defined in this crate. This is a consequence of the `oauth2` dependency being removed from tree. 256 | Additionally, as another consequence, clients are now able to be specified as a `for<'a> &'a T where T: Client<'a>`, meaning `twitch_api` can use its clients as an interface to token requests, 257 | and clients can persist instead of being rebuilt every call. Care should be taken when making clients, as SSRF and similar attacks are possible with improper client configurations. 258 | 259 | ### Added 260 | 261 | - Added types/braids `ClientId`, `ClientSecret`, `AccessToken`, `RefreshToken` and `CsrfToken`. 262 | - Added way to interact with the Twitch-CLI [mock API](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) using environment variables. 263 | See static variables `AUTH_URL`, `TOKEN_URL`, `VALIDATE_URL` and `REVOKE_URL` for more information. 264 | - Added `impl Borrow for Scope`, meaning it can be used in places it couldn't be used before. Primarily, it allows the following code to work: 265 | ```rust 266 | let scopes = vec![Scope::ChatEdit, Scope::ChatRead]; 267 | let space_separated_scope: String = scopes.as_slice().join(" "); 268 | ``` 269 | - Added scope `channel:read:goals` 270 | 271 | ### Changed 272 | 273 | - Requests to `id.twitch.tv` now follow the documentation, instead of following a subset of the RFC for oauth2. 274 | - URLs are now initialized lazily and specified as `url::Url`s. 275 | 276 | ### Removed 277 | 278 | - Removed `oauth2` dependency. 279 | 280 | ## [v0.5.2] - 2021-06-18 281 | 282 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.5.1...v0.5.2) 283 | 284 | ### Added 285 | 286 | - Added new scope `channel:manage:schedule` 287 | 288 | ## [v0.5.1] - 2021-05-16 289 | 290 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v0.5.0...v0.5.1) 291 | 292 | ### Added 293 | 294 | - Added new scopes `channel:manage:polls`, `channel:manage:predictions`, `channel:read:polls`, `channel:read:predictions`, and `moderator:manage:automod`, 295 | - Added function `Scope::description` to get the description of the scope 296 | 297 | ## [v0.5.0] - 2021-05-08 298 | 299 | [Commits](https://github.com/twitch-rs/twitch_oauth2/compare/49a083ceda6768cc52a1f8f1714bb7f942f24c01...v0.5.0) 300 | 301 | ### Added 302 | 303 | - Made crate runtime agnostic with custom clients. 304 | - Updated deps. 305 | - Add an extra (optional) client secret field to `UserToken::from_existing` (thanks [Dinnerbone](https://github.com/Dinnerbone)) 306 | - Added `channel:manage:redemptions`, `channel:read:editors`, `channel:manage:videos`, `user:read:blocked_users`, `user:manage:blocked_users`, `user:read:subscriptions` and `user:read:follows` 307 | - Implemented [OAuth Authorization Code Flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow) with `UserTokenBuilder` 308 | - Added a way to suggest or infer that an user token is never expiring, making `is_elapsed` return false and `expires_in` a bogus (max) duration. 309 | 310 | ### Changed 311 | 312 | - MSRV: 1.51 313 | - Made scope take `Cow<&'static str>` 314 | - Made fields `access_token`, `refresh_token`, `user_id` and `login` `pub` on `UserToken` and `AppAccessToken` (where applicable) 315 | - Fixed wrong scope `user:read:stream_key` -> `channel:read:stream_key` 316 | - BREAKING: changed `TwitchToken::expires` -> `TwitchToken::expires_in` to calculate current lifetime of token 317 | 318 | ## End of Changelog 319 | 320 | Changelog starts on v0.5.0 321 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "twitch_oauth2" 3 | version = "0.15.2" 4 | edition = "2021" 5 | repository = "https://github.com/twitch-rs/twitch_oauth2" 6 | license = "MIT OR Apache-2.0" 7 | description = "Oauth2 for Twitch endpoints" 8 | keywords = ["oauth", "twitch", "async", "asynchronous"] 9 | documentation = "https://docs.rs/twitch_oauth2/0.15.2" 10 | readme = "README.md" 11 | include = [ 12 | "src/*", 13 | "./Cargo.toml", 14 | "examples/*", 15 | "./README.md", 16 | "CHANGELOG.md", 17 | "LICENSE*", 18 | ] 19 | rust-version = "1.71.1" 20 | 21 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 22 | [features] 23 | default = [] 24 | client = ["dep:async-trait"] 25 | reqwest = ["dep:reqwest", "client"] 26 | surf_client_curl = ["surf", "surf/curl-client"] 27 | surf = ["dep:surf", "dep:http-types", "http-types?/hyperium_http", "client"] 28 | mock_api = [] 29 | all = ["surf_client_curl", "reqwest"] 30 | 31 | [dependencies] 32 | thiserror = "1.0.40" 33 | displaydoc = "0.2.5" 34 | serde = { version = "1.0.163" } 35 | serde_derive = { version = "1.0.163" } 36 | serde_json = "1.0.96" 37 | async-trait = { version = "0.1.68", optional = true } 38 | http = "1.1.0" 39 | surf = { version = "2.3.2", optional = true, default-features = false } 40 | reqwest = { version = "0.12.2", optional = true, default-features = false } 41 | http-types = { version = "2.12.0", optional = true } 42 | once_cell = "1.19.0" 43 | aliri_braid = "0.4.0" 44 | url = "2.5.4" 45 | base64 = "0.22.0" 46 | rand = "0.8.5" 47 | twitch_types = { version = "0.4.3", features = ["serde"] } 48 | 49 | [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] 50 | web-time = { version = "1.1.0" } 51 | 52 | [dev-dependencies] 53 | tokio = { version = "1.40.0", features = [ 54 | "rt-multi-thread", 55 | "macros", 56 | "test-util", 57 | ] } 58 | dotenv = "0.15.0" 59 | anyhow = "1.0.71" 60 | reqwest = "0.12.2" 61 | surf = "2.3.2" 62 | rpassword = "7.3.1" 63 | 64 | [workspace] 65 | members = ["xtask"] 66 | 67 | [[example]] 68 | name = "user_token" 69 | path = "examples/user_token.rs" 70 | required-features = ["reqwest"] 71 | 72 | [[example]] 73 | name = "app_access_token" 74 | path = "examples/app_access_token.rs" 75 | required-features = ["reqwest"] 76 | 77 | [[example]] 78 | name = "auth_flow" 79 | path = "examples/auth_flow.rs" 80 | required-features = ["reqwest"] 81 | 82 | 83 | [[example]] 84 | name = "mock_app" 85 | path = "examples/mock_app.rs" 86 | required-features = ["reqwest", "mock_api"] 87 | 88 | [[example]] 89 | name = "mock_user" 90 | path = "examples/mock_user.rs" 91 | required-features = ["reqwest", "mock_api"] 92 | 93 | [[example]] 94 | name = "device_code_flow" 95 | path = "examples/device_code_flow.rs" 96 | required-features = ["reqwest", "client"] 97 | 98 | [package.metadata.docs.rs] 99 | features = ["all", "mock_api"] 100 | rustc-args = ["--cfg", "nightly"] 101 | rustdoc-args = ["--cfg", "nightly"] 102 | cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] 103 | 104 | [lints.rust] 105 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(_internal_never)', 'cfg(nightly)'] } 106 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitch OAuth2 | OAuth2 for Twitch endpoints 2 | 3 | [![github]](https://github.com/twitch-rs/twitch_oauth2) [![crates-io]](https://crates.io/crates/twitch_oauth2) [![docs-rs]](https://docs.rs/twitch_oauth2/0.15.2/twitch_oauth2) 4 | 5 | [github]: https://img.shields.io/badge/github-twitch--rs/twitch__oauth2-8da0cb?style=for-the-badge&labelColor=555555&logo=github" 6 | [crates-io]: https://img.shields.io/crates/v/twitch_oauth2.svg?style=for-the-badge&color=fc8d62&logo=rust" 7 | [docs-rs]: https://img.shields.io/badge/docs.rs-twitch__oauth2-66c2a5?style=for-the-badge&labelColor=555555&logoColor=white&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K" 8 | 9 | See [documentation](https://docs.rs/twitch_oauth2) for more info. 10 | 11 | You can see current unpublished docs here: [![local-docs]](https://twitch-rs.github.io/twitch_oauth2/twitch_oauth2) 12 | 13 | See [examples](./examples) for examples. 14 | 15 | This is a library to interface with [Twitch Authentication](https://dev.twitch.tv/docs/authentication). 16 | 17 | See also 18 | 19 |
License
20 | 21 | 22 | Licensed under either of Apache License, Version 23 | 2.0 or MIT license at your option. 24 | 25 | 26 |
27 | 28 | 29 | Unless you explicitly state otherwise, any contribution intentionally submitted 30 | for inclusion in this crate by you, as defined in the Apache-2.0 license, shall 31 | be dual licensed as above, without any additional terms or conditions. 32 | 33 | 34 | [local-docs]: https://img.shields.io/github/actions/workflow/status/twitch-rs/twitch_oauth2/gh-pages.yml?label=dev%20docs&style=flat-square&event=push 35 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | # The path where the advisory database is cloned/fetched into 3 | db-path = "~/.cargo/advisory-db" 4 | # The url of the advisory database to use 5 | db-urls = ["https://github.com/rustsec/advisory-db"] 6 | # The lint level for crates that have been yanked from their source registry 7 | yanked = "warn" 8 | # A list of advisory IDs to ignore. Note that ignored advisories will still 9 | # output a note when they are encountered. 10 | ignore = [ 11 | # https://rustsec.org/advisories/RUSTSEC-2021-0141 12 | # `dotenv` is unmaintained 13 | # It's only used as a dev-dependency (TODO: use dotenvy) 14 | "RUSTSEC-2021-0141", 15 | 16 | # https://rustsec.org/advisories/RUSTSEC-2020-0056 17 | # The author of the `stdweb` crate is unresponsive 18 | # Dependent on via surf (and http-types) 19 | "RUSTSEC-2020-0056", 20 | # https://rustsec.org/advisories/RUSTSEC-2021-0064 21 | # There will be no further releases of `cpuid-bool` 22 | # Dependent on via surf (and http-types) 23 | "RUSTSEC-2021-0064", 24 | # https://rustsec.org/advisories/RUSTSEC-2021-0059 25 | # `aesni` has been merged into the `aes` crate 26 | # Dependent on via surf (and http-types) 27 | "RUSTSEC-2021-0059", 28 | # https://rustsec.org/advisories/RUSTSEC-2021-0060 29 | # `aes-soft` has been merged into the `aes` crate 30 | # Dependent on via surf (and http-types) 31 | "RUSTSEC-2021-0060", 32 | # https://rustsec.org/advisories/RUSTSEC-2024-0384 33 | # `instant` is unmaintained 34 | # Dependent on via surf (and http-types) 35 | "RUSTSEC-2024-0384", 36 | ] 37 | 38 | # This section is considered when running `cargo deny check licenses` 39 | # More documentation for the licenses section can be found here: 40 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 41 | [licenses] 42 | # List of explictly allowed licenses 43 | # See https://spdx.org/licenses/ for list of possible licenses 44 | # [possible values: any SPDX 3.7 short identifier (+ optional exception)]. 45 | allow = [ 46 | "MIT", 47 | "Apache-2.0", 48 | "Apache-2.0 WITH LLVM-exception", 49 | "BSD-3-Clause", 50 | "MPL-2.0", # Considered fair 51 | "ISC", 52 | "OpenSSL", 53 | "Unicode-DFS-2016", 54 | "Unicode-3.0", 55 | ] 56 | # The confidence threshold for detecting a license from license text. 57 | # The higher the value, the more closely the license text must be to the 58 | # canonical license text of a valid SPDX license file. 59 | # [possible values: any between 0.0 and 1.0]. 60 | confidence-threshold = 0.8 61 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 62 | # aren't accepted for every possible crate as with the normal allow list 63 | exceptions = [ 64 | # Each entry is the crate and version constraint, and its specific allow 65 | # list 66 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 67 | ] 68 | 69 | # Some crates don't have (easily) machine readable licensing information, 70 | # adding a clarification entry for it allows you to manually specify the 71 | # licensing information 72 | [[licenses.clarify]] 73 | # The name of the crate the clarification applies to 74 | name = "ring" 75 | # The optional version constraint for the crate 76 | version = "0.16.15" 77 | # The SPDX expression for the license requirements of the crate 78 | expression = "MIT AND ISC AND OpenSSL" 79 | # One or more files in the crate's source used as the "source of truth" for 80 | # the license expression. If the contents match, the clarification will be used 81 | # when running the license check, otherwise the clarification will be ignored 82 | # and the crate will be checked normally, which may produce warnings or errors 83 | # depending on the rest of your configuration 84 | license-files = [ 85 | # Each entry is a crate relative path, and the (opaque) hash of its contents 86 | { path = "LICENSE", hash = 0xbd0eed23 }, 87 | ] 88 | 89 | [licenses.private] 90 | # If true, ignores workspace crates that aren't published, or are only 91 | # published to private registries 92 | ignore = true 93 | # One or more private registries that you might publish crates to, if a crate 94 | # is only published to private registries, and ignore is true, the crate will 95 | # not have its license(s) checked 96 | registries = [ 97 | #"https://sekretz.com/registry 98 | ] 99 | 100 | # This section is considered when running `cargo deny check bans`. 101 | # More documentation about the 'bans' section can be found here: 102 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 103 | [bans] 104 | # Lint level for when multiple versions of the same crate are detected 105 | multiple-versions = "warn" 106 | # Lint level for when a crate version requirement is `*` 107 | wildcards = "deny" 108 | # The graph highlighting used when creating dotgraphs for crates 109 | # with multiple versions 110 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 111 | # * simplest-path - The path to the version with the fewest edges is highlighted 112 | # * all - Both lowest-version and simplest-path are used 113 | highlight = "all" 114 | # List of crates that are allowed. Use with care! 115 | allow = [ 116 | #{ name = "ansi_term", version = "=0.11.0" }, 117 | ] 118 | # List of crates to deny 119 | deny = [ 120 | # Each entry the name of a crate and a version range. If version is 121 | # not specified, all versions will be matched. 122 | #{ name = "ansi_term", version = "=0.11.0" }, 123 | # 124 | # Wrapper crates can optionally be specified to allow the crate when it 125 | # is a direct dependency of the otherwise banned crate 126 | #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, 127 | ] 128 | # Certain crates/versions that will be skipped when doing duplicate detection. 129 | skip = [ 130 | #{ name = "ansi_term", version = "=0.11.0" }, 131 | ] 132 | # Similarly to `skip` allows you to skip certain crates during duplicate 133 | # detection. Unlike skip, it also includes the entire tree of transitive 134 | # dependencies starting at the specified crate, up to a certain depth, which is 135 | # by default infinite 136 | skip-tree = [ 137 | #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, 138 | ] 139 | 140 | # This section is considered when running `cargo deny check sources`. 141 | # More documentation about the 'sources' section can be found here: 142 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 143 | [sources] 144 | # List of URLs for allowed crate registries. Defaults to the crates.io index 145 | # if not specified. If it is specified but empty, no registries are allowed. 146 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 147 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | See [documentation](https://docs.rs/twitch_oauth2) for documentation. 2 | 3 | You can see current unpublished docs here: [![local-docs]](https://twitch-rs.github.io/twitch_oauth2/twitch_oauth2) 4 | 5 | [local-docs]: https://img.shields.io/github/workflow/status/twitch-rs/twitch_oauth2/github%20pages/main?label=docs&style=flat-square&event=push 6 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | Page Redirection 10 | 11 | 12 | If you are not redirected automatically, follow this 13 | link to the documentation. 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/app_access_token.rs: -------------------------------------------------------------------------------- 1 | //! Example of how to create a app access token using client credentials 2 | use twitch_oauth2::TwitchToken; 3 | 4 | #[tokio::main] 5 | async fn main() -> anyhow::Result<()> { 6 | let _ = dotenv::dotenv(); // Eat error 7 | let mut args = std::env::args().skip(1); 8 | 9 | // Setup the http client to use with the library. 10 | let reqwest = reqwest::Client::builder() 11 | .redirect(reqwest::redirect::Policy::none()) 12 | .build()?; 13 | 14 | // Grab the client id, convert to a `ClientId` with the `new` method. 15 | let client_id = get_env_or_arg("TWITCH_CLIENT_ID", &mut args) 16 | .map(twitch_oauth2::ClientId::new) 17 | .expect("Please set env: TWITCH_CLIENT_ID or pass client id as an argument"); 18 | 19 | // Grab the client secret, convert to a `ClientSecret` with the `new` method. 20 | let client_secret = get_env_or_arg("TWITCH_CLIENT_SECRET", &mut args) 21 | .map(twitch_oauth2::ClientSecret::new) 22 | .expect("Please set env: TWITCH_CLIENT_SECRET or pass client secret as an argument"); 23 | 24 | // Get the app access token 25 | let token = twitch_oauth2::AppAccessToken::get_app_access_token( 26 | &reqwest, 27 | client_id, 28 | client_secret, 29 | vec![], 30 | ) 31 | .await?; 32 | 33 | println!("{:?}", token); 34 | dbg!(token.is_elapsed()); 35 | Ok(()) 36 | } 37 | 38 | fn get_env_or_arg(env: &str, args: &mut impl Iterator) -> Option { 39 | std::env::var(env).ok().or_else(|| args.next()) 40 | } 41 | -------------------------------------------------------------------------------- /examples/auth_flow.rs: -------------------------------------------------------------------------------- 1 | //! This is an example of the Authorization code grant flow using `twitch_oauth2` 2 | //! 3 | //! See https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow 4 | //! 5 | //! See also the `device_code_flow` example for possibly easier integration. 6 | 7 | use anyhow::Context; 8 | use twitch_oauth2::tokens::UserTokenBuilder; 9 | 10 | #[tokio::main] 11 | async fn main() -> anyhow::Result<()> { 12 | let _ = dotenv::dotenv(); // Eat error 13 | let mut args = std::env::args().skip(1); 14 | 15 | // Setup the http client to use with the library. 16 | let reqwest = reqwest::Client::builder() 17 | .redirect(reqwest::redirect::Policy::none()) 18 | .build()?; 19 | 20 | // Grab the client id, convert to a `ClientId` with the `new` method. 21 | let client_id = get_env_or_arg("TWITCH_CLIENT_ID", &mut args) 22 | .map(twitch_oauth2::ClientId::new) 23 | .context("Please set env: TWITCH_CLIENT_ID or pass as first argument")?; 24 | 25 | // Grab the client secret, convert to a `ClientSecret` with the `new` method. 26 | let client_secret = get_env_or_arg("TWITCH_CLIENT_SECRET", &mut args) 27 | .map(twitch_oauth2::ClientSecret::new) 28 | .context("Please set env: TWITCH_CLIENT_SECRET or pass as second argument")?; 29 | 30 | // Grab the redirect URL, this has to be set verbatim in the developer console: https://dev.twitch.tv/console/apps/ 31 | let redirect_url = get_env_or_arg("TWITCH_REDIRECT_URL", &mut args) 32 | .map(|r| twitch_oauth2::url::Url::parse(&r)) 33 | .context("Please set env: TWITCH_REDIRECT_URL or pass as third argument")??; 34 | 35 | // Create the builder! 36 | let mut builder = 37 | UserTokenBuilder::new(client_id, client_secret, redirect_url).force_verify(true); 38 | 39 | // Generate the URL, this is the url that the user should visit to authenticate. 40 | let (url, _) = builder.generate_url(); 41 | 42 | println!("Go to this page: {}", url); 43 | 44 | let input = rpassword::prompt_password( 45 | "Paste in the resulting adress after authenticating (input hidden): ", 46 | )?; 47 | 48 | let u = twitch_oauth2::url::Url::parse(&input).context("when parsing the input as a URL")?; 49 | 50 | // Grab the query parameters "state" and "code" from the url the user was redirected to. 51 | let map: std::collections::HashMap<_, _> = u.query_pairs().collect(); 52 | 53 | match (map.get("state"), map.get("code")) { 54 | (Some(state), Some(code)) => { 55 | // Finish the builder with `get_user_token` 56 | let token = builder.get_user_token(&reqwest, state, code).await?; 57 | println!("Got token: {:?}", token); 58 | } 59 | _ => match (map.get("error"), map.get("error_description")) { 60 | (std::option::Option::Some(error), std::option::Option::Some(error_description)) => { 61 | anyhow::bail!( 62 | "twitch errored with error: {} - {}", 63 | error, 64 | error_description 65 | ); 66 | } 67 | _ => anyhow::bail!("invalid url passed"), 68 | }, 69 | } 70 | Ok(()) 71 | } 72 | 73 | fn get_env_or_arg(env: &str, args: &mut impl Iterator) -> Option { 74 | std::env::var(env).ok().or_else(|| args.next()) 75 | } 76 | -------------------------------------------------------------------------------- /examples/device_code_flow.rs: -------------------------------------------------------------------------------- 1 | //! Example of how to create a user token using device code flow. 2 | //! The device code flow can be used on confidential and public clients. 3 | use twitch_oauth2::{DeviceUserTokenBuilder, TwitchToken, UserToken}; 4 | 5 | #[tokio::main] 6 | async fn main() -> anyhow::Result<()> { 7 | let _ = dotenv::dotenv(); // Eat error 8 | let mut args = std::env::args().skip(1); 9 | 10 | // Setup the http client to use with the library. 11 | let reqwest = reqwest::Client::builder() 12 | .redirect(reqwest::redirect::Policy::none()) 13 | .build()?; 14 | 15 | // Grab the client id, convert to a `ClientId` with the `new` method. 16 | let client_id = get_env_or_arg("TWITCH_CLIENT_ID", &mut args) 17 | .map(twitch_oauth2::ClientId::new) 18 | .expect("Please set env: TWITCH_CLIENT_ID or pass client id as an argument"); 19 | 20 | // Create the builder! 21 | let mut builder = DeviceUserTokenBuilder::new(client_id, Default::default()); 22 | 23 | // Start the device code flow. This will return a code that the user must enter on Twitch 24 | let code = builder.start(&reqwest).await?; 25 | 26 | println!("Please go to {0}", code.verification_uri); 27 | println!( 28 | "Waiting for user to authorize, time left: {0}", 29 | code.expires_in 30 | ); 31 | 32 | // Finish the auth with wait_for_code, this will return a token if the user has authorized the app 33 | let mut token = builder.wait_for_code(&reqwest, tokio::time::sleep).await?; 34 | 35 | println!("token: {:?}\nTrying to refresh the token", token); 36 | // we can also refresh this token, even without a client secret 37 | // if the application was created as a public client type in the twitch dashboard this will work, 38 | // if the application is a confidential client type, this refresh will fail because it needs the client secret. 39 | token.refresh_token(&reqwest).await?; 40 | println!("refreshed token: {:?}", token); 41 | Ok(()) 42 | } 43 | 44 | fn get_env_or_arg(env: &str, args: &mut impl Iterator) -> Option { 45 | std::env::var(env).ok().or_else(|| args.next()) 46 | } 47 | -------------------------------------------------------------------------------- /examples/mock_app.rs: -------------------------------------------------------------------------------- 1 | //! Example of how to create a mock app access token 2 | #[tokio::main] 3 | async fn main() -> anyhow::Result<()> { 4 | let _ = dotenv::dotenv(); // Eat error 5 | let mut args = std::env::args().skip(1); 6 | 7 | let reqwest = reqwest::Client::builder() 8 | .redirect(reqwest::redirect::Policy::none()) 9 | .build()?; 10 | 11 | get_env_or_arg("TWITCH_OAUTH2_URL", &mut args) 12 | .map(|t| std::env::set_var("TWITCH_OAUTH2_URL", &t)) 13 | .expect("Please set env: TWITCH_OAUTH2_URL or pass url as first argument"); 14 | 15 | let client_id = get_env_or_arg("MOCK_CLIENT_ID", &mut args) 16 | .map(twitch_oauth2::ClientId::new) 17 | .expect("Please set env: MOCK_CLIENT_ID or pass client id as an argument"); 18 | 19 | let client_secret = get_env_or_arg("MOCK_CLIENT_SECRET", &mut args) 20 | .map(twitch_oauth2::ClientSecret::new) 21 | .expect("Please set env: MOCK_CLIENT_SECRET or pass client secret as an argument"); 22 | 23 | // Getting an app access token from twitch-cli mock is almost exactly the same as in production, just using a different url. 24 | let token = twitch_oauth2::AppAccessToken::get_app_access_token( 25 | &reqwest, 26 | client_id, 27 | client_secret, 28 | vec![], 29 | ) 30 | .await?; 31 | println!( 32 | "token retrieved: {} - {:?}", 33 | token.access_token.secret(), 34 | token 35 | ); 36 | Ok(()) 37 | } 38 | 39 | fn get_env_or_arg(env: &str, args: &mut impl Iterator) -> Option { 40 | std::env::var(env).ok().or_else(|| args.next()) 41 | } 42 | -------------------------------------------------------------------------------- /examples/mock_user.rs: -------------------------------------------------------------------------------- 1 | //! Example of how to create a mock user token 2 | #[tokio::main] 3 | async fn main() -> anyhow::Result<()> { 4 | let _ = dotenv::dotenv(); // Eat error 5 | let mut args = std::env::args().skip(1); 6 | 7 | // Setup the http client to use with the library. 8 | let reqwest = reqwest::Client::builder() 9 | .redirect(reqwest::redirect::Policy::none()) 10 | .build()?; 11 | 12 | get_env_or_arg("TWITCH_OAUTH2_URL", &mut args) 13 | .map(|t| std::env::set_var("TWITCH_OAUTH2_URL", &t)) 14 | .expect("Please set env: TWITCH_OAUTH2_URL or pass url as first argument"); 15 | 16 | let client_id = get_env_or_arg("MOCK_CLIENT_ID", &mut args) 17 | .map(twitch_oauth2::ClientId::new) 18 | .expect("Please set env: MOCK_CLIENT_ID or pass client id as an argument"); 19 | 20 | let client_secret = get_env_or_arg("MOCK_CLIENT_SECRET", &mut args) 21 | .map(twitch_oauth2::ClientSecret::new) 22 | .expect("Please set env: MOCK_CLIENT_SECRET or pass client secret as an argument"); 23 | 24 | let user_id = get_env_or_arg("MOCK_USER_ID", &mut args) 25 | .expect("Please set env: MOCK_USER_ID or pass user_id as an argument"); 26 | 27 | // Using a mock token from twitch-cli mock is very similar to using a regular token, however you need to call `UserToken::mock_token` instead of `UserToken::from_existing` etc. 28 | let token = 29 | twitch_oauth2::UserToken::mock_token(&reqwest, client_id, client_secret, user_id, vec![]) 30 | .await?; 31 | println!( 32 | "token retrieved: {} - {:?}", 33 | token.access_token.secret(), 34 | token 35 | ); 36 | Ok(()) 37 | } 38 | 39 | fn get_env_or_arg(env: &str, args: &mut impl Iterator) -> Option { 40 | std::env::var(env).ok().or_else(|| args.next()) 41 | } 42 | -------------------------------------------------------------------------------- /examples/user_token.rs: -------------------------------------------------------------------------------- 1 | //! Example of how to create a UserToken from an existing token. 2 | //! 3 | //! See the auth_flow example for how to create a token from scratch. 4 | use twitch_oauth2::TwitchToken; 5 | 6 | #[tokio::main] 7 | async fn main() -> anyhow::Result<()> { 8 | let _ = dotenv::dotenv(); // Eat error 9 | let mut args = std::env::args().skip(1); 10 | 11 | // Setup the http client to use with the library. 12 | let reqwest = reqwest::Client::builder() 13 | .redirect(reqwest::redirect::Policy::none()) 14 | .build()?; 15 | 16 | // Grab the token, convert to a `AccessToken` with the `new` method. 17 | let user_token = get_env_or_arg("TWITCH_TOKEN", &mut args) 18 | .map(twitch_oauth2::AccessToken::new) 19 | .expect("Please set env: TWITCH_TOKEN or pass token as first argument"); 20 | 21 | // Grab refresh token, not necessarily required. 22 | let refresh_token = 23 | get_env_or_arg("TWITCH_REFRESH_TOKEN", &mut args).map(twitch_oauth2::RefreshToken::new); 24 | 25 | // Grab the client secret, not necessarily required, unless you have a refresh token and want to refresh the token with `UserToken::refresh`. 26 | let client_secret = 27 | get_env_or_arg("TWITCH_CLIENT_SECRET", &mut args).map(twitch_oauth2::ClientSecret::new); 28 | 29 | let token = 30 | twitch_oauth2::UserToken::from_existing(&reqwest, user_token, refresh_token, client_secret) 31 | .await?; 32 | 33 | println!("{:?}", token); 34 | dbg!(token.is_elapsed()); 35 | Ok(()) 36 | } 37 | 38 | fn get_env_or_arg(env: &str, args: &mut impl Iterator) -> Option { 39 | std::env::var(env).ok().or_else(|| args.next()) 40 | } 41 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | pre-release-commit-message = "release {{crate_name}} {{version}}" 2 | tag = false 3 | push = false 4 | publish = false 5 | enable-features = ["all", "unsupported"] 6 | consolidate-commits = false 7 | pre-release-replacements = [ 8 | {file="CHANGELOG.md", search="Unreleased", replace="v{{version}}", prerelease=false}, 9 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", prerelease=false}, 10 | {file="CHANGELOG.md", search="", replace="\n\n## [Unreleased] - ReleaseDate\n\n[Commits](https://github.com/twitch-rs/twitch_oauth2/compare/v{{version}}...Unreleased)", prerelease=false}, 11 | {file="README.md", search="twitch_oauth2/[a-z0-9\\.-]+/twitch_oauth2", replace="{{crate_name}}/{{version}}/{{crate_name}}", prerelease=true}, 12 | {file="src/lib.rs", search="version = \"[a-z0-9\\.-]+\" }", replace="version = \"{{version}}\" }", prerelease=true}, 13 | {file="Cargo.toml", search="https://docs.rs/twitch_oauth2/[a-z0-9\\.-]+", replace="https://docs.rs/{{crate_name}}/{{version}}", prerelease=true}, 14 | ] -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | condense_wildcard_suffixes = true 2 | brace_style = "PreferSameLine" 3 | fn_single_line = true 4 | where_single_line = true 5 | use_field_init_shorthand = true 6 | reorder_impl_items = true 7 | edition = "2018" 8 | newline_style = "Unix" 9 | format_code_in_doc_comments = true 10 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | //! Provides different http clients 2 | 3 | // This module is heavily inspired (read: copied) by twitch_api2::client. 4 | 5 | use std::error::Error; 6 | use std::future::Future; 7 | 8 | /// The User-Agent `product` of this crate. 9 | pub static TWITCH_OAUTH2_USER_AGENT: &str = 10 | concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); 11 | 12 | /// A boxed future, mimics `futures::future::BoxFuture` 13 | type BoxedFuture<'a, T> = std::pin::Pin + Send + 'a>>; 14 | 15 | /// A client that can do OAUTH2 requests 16 | pub trait Client: Sync + Send { 17 | /// Error returned by the client 18 | type Error: Error + Send + Sync + 'static; 19 | /// Send a request 20 | fn req( 21 | &self, 22 | request: http::Request>, 23 | ) -> BoxedFuture<'_, Result>, ::Error>>; 24 | } 25 | 26 | #[doc(hidden)] 27 | #[derive(Debug, thiserror::Error, Clone)] 28 | #[error("this client does not do anything, only used for documentation test that only checks code integrity")] 29 | pub struct DummyClient; 30 | 31 | #[cfg(feature = "reqwest")] 32 | impl Client for DummyClient { 33 | type Error = DummyClient; 34 | 35 | fn req( 36 | &self, 37 | _: http::Request>, 38 | ) -> BoxedFuture<'_, Result>, Self::Error>> { 39 | Box::pin(async move { Err(self.clone()) }) 40 | } 41 | } 42 | #[cfg(feature = "reqwest")] 43 | use reqwest::Client as ReqwestClient; 44 | 45 | #[cfg(feature = "reqwest")] 46 | impl Client for ReqwestClient { 47 | type Error = reqwest::Error; 48 | 49 | fn req( 50 | &self, 51 | request: http::Request>, 52 | ) -> BoxedFuture<'_, Result>, Self::Error>> { 53 | // Reqwest plays really nice here and has a try_from on `http::Request` -> `reqwest::Request` 54 | let req = match reqwest::Request::try_from(request) { 55 | Ok(req) => req, 56 | Err(e) => return Box::pin(async { Err(e) }), 57 | }; 58 | // We need to "call" the execute outside the async closure to not capture self. 59 | let fut = self.execute(req); 60 | Box::pin(async move { 61 | // Await the request and translate to `http::Response` 62 | let mut response = fut.await?; 63 | let mut result = http::Response::builder().status(response.status()); 64 | let headers = result 65 | .headers_mut() 66 | // This should not fail, we just created the response. 67 | .expect("expected to get headers mut when building response"); 68 | std::mem::swap(headers, response.headers_mut()); 69 | let result = result.version(response.version()); 70 | Ok(result 71 | .body(response.bytes().await?.as_ref().to_vec()) 72 | .expect("mismatch reqwest -> http conversion should not fail")) 73 | }) 74 | } 75 | } 76 | 77 | #[cfg(feature = "surf")] 78 | use surf::Client as SurfClient; 79 | 80 | /// Possible errors from [`Client::req()`] when using the [surf](https://crates.io/crates/surf) client 81 | #[cfg(feature = "surf")] 82 | #[derive(Debug, displaydoc::Display, thiserror::Error)] 83 | pub enum SurfError { 84 | /// surf failed to do the request: {0} 85 | Surf(surf::Error), 86 | /// could not construct header value 87 | InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), 88 | /// could not construct header name 89 | InvalidHeaderName(#[from] http::header::InvalidHeaderName), 90 | /// uri could not be translated into an url. 91 | UrlError(#[from] url::ParseError), 92 | } 93 | 94 | // same as in twitch_api/src/client/surf_impl.rs 95 | #[cfg(feature = "surf")] 96 | fn http1_to_surf(m: &http::Method) -> surf::http::Method { 97 | match *m { 98 | http::Method::GET => surf::http::Method::Get, 99 | http::Method::CONNECT => http_types::Method::Connect, 100 | http::Method::DELETE => http_types::Method::Delete, 101 | http::Method::HEAD => http_types::Method::Head, 102 | http::Method::OPTIONS => http_types::Method::Options, 103 | http::Method::PATCH => http_types::Method::Patch, 104 | http::Method::POST => http_types::Method::Post, 105 | http::Method::PUT => http_types::Method::Put, 106 | http::Method::TRACE => http_types::Method::Trace, 107 | _ => unimplemented!(), 108 | } 109 | } 110 | 111 | #[cfg(feature = "surf")] 112 | impl Client for SurfClient { 113 | type Error = SurfError; 114 | 115 | fn req( 116 | &self, 117 | request: http::Request>, 118 | ) -> BoxedFuture<'_, Result>, Self::Error>> { 119 | // First we translate the `http::Request` method and uri into types that surf understands. 120 | 121 | let method = http1_to_surf(request.method()); 122 | 123 | let url = match url::Url::parse(&request.uri().to_string()) { 124 | Ok(url) => url, 125 | Err(err) => return Box::pin(async move { Err(err.into()) }), 126 | }; 127 | // Construct the request 128 | let mut req = surf::Request::new(method, url); 129 | 130 | // move the headers into the surf request 131 | for (name, value) in request.headers().iter() { 132 | let value = 133 | match surf::http::headers::HeaderValue::from_bytes(value.as_bytes().to_vec()) 134 | .map_err(SurfError::Surf) 135 | { 136 | Ok(val) => val, 137 | Err(err) => return Box::pin(async { Err(err) }), 138 | }; 139 | req.append_header(name.as_str(), value); 140 | } 141 | 142 | // assembly the request, now we can send that to our `surf::Client` 143 | req.body_bytes(request.body()); 144 | 145 | let client = self.clone(); 146 | Box::pin(async move { 147 | // Send the request and translate the response into a `http::Response` 148 | let mut response = client.send(req).await.map_err(SurfError::Surf)?; 149 | let mut result = http::Response::builder().status( 150 | http::StatusCode::from_u16(response.status().into()) 151 | .expect("http_types::StatusCode only contains valid status codes"), 152 | ); 153 | 154 | let mut response_headers: http::header::HeaderMap = response 155 | .iter() 156 | .map(|(k, v)| { 157 | Ok(( 158 | http::header::HeaderName::from_bytes(k.as_str().as_bytes())?, 159 | http::HeaderValue::from_str(v.as_str())?, 160 | )) 161 | }) 162 | .collect::>()?; 163 | 164 | let _ = std::mem::replace(&mut result.headers_mut(), Some(&mut response_headers)); 165 | let result = if let Some(v) = response.version() { 166 | result.version(match v { 167 | surf::http::Version::Http0_9 => http::Version::HTTP_09, 168 | surf::http::Version::Http1_0 => http::Version::HTTP_10, 169 | surf::http::Version::Http1_1 => http::Version::HTTP_11, 170 | surf::http::Version::Http2_0 => http::Version::HTTP_2, 171 | surf::http::Version::Http3_0 => http::Version::HTTP_3, 172 | // TODO: Log this somewhere... 173 | _ => http::Version::HTTP_3, 174 | }) 175 | } else { 176 | result 177 | }; 178 | Ok(result 179 | .body(response.body_bytes().await.map_err(SurfError::Surf)?) 180 | .expect("mismatch surf -> http conversion should not fail")) 181 | }) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/id.rs: -------------------------------------------------------------------------------- 1 | //! Representation of oauth2 flow in `id.twitch.tv` 2 | 3 | use serde_derive::{Deserialize, Serialize}; 4 | 5 | use crate::{AccessToken, RequestParseError}; 6 | use std::time::Duration; 7 | /// Twitch's representation of the oauth flow. 8 | /// 9 | /// Retrieve with 10 | /// 11 | /// * [`UserTokenBuilder::get_user_token_request`](crate::tokens::UserTokenBuilder::get_user_token_request) 12 | /// * [`AppAccessToken::::get_app_access_token_request`](crate::tokens::AppAccessToken::get_app_access_token_request) 13 | #[derive(Clone, Debug, Deserialize, Serialize)] 14 | pub struct TwitchTokenResponse { 15 | /// Access token 16 | pub access_token: AccessToken, 17 | /// Time (in seconds) until token expires 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub expires_in: Option, 20 | /// Token that can be used to refresh 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub refresh_token: Option, 23 | /// Scopes attached to token 24 | #[serde(rename = "scope", deserialize_with = "scope::deserialize")] 25 | #[serde(default)] 26 | pub scopes: Option>, 27 | } 28 | 29 | impl TwitchTokenResponse { 30 | /// Create a [TwitchTokenResponse] from a [http::Response] 31 | pub fn from_response>( 32 | response: &http::Response, 33 | ) -> Result { 34 | crate::parse_response(response) 35 | } 36 | 37 | /// Get the access token from this response 38 | pub fn access_token(&self) -> &crate::AccessTokenRef { &self.access_token } 39 | 40 | /// Get the expires in from this response 41 | pub fn expires_in(&self) -> Option { self.expires_in.map(Duration::from_secs) } 42 | 43 | /// Get the refresh token from this response 44 | pub fn refresh_token(&self) -> Option<&crate::RefreshTokenRef> { self.refresh_token.as_deref() } 45 | 46 | /// Get the scopes from this response 47 | pub fn scopes(&self) -> Option<&[crate::Scope]> { self.scopes.as_deref() } 48 | } 49 | 50 | /// Twitch's representation of the oauth flow for errors 51 | #[derive(Clone, Debug, Deserialize, Serialize, thiserror::Error)] 52 | pub struct TwitchTokenErrorResponse { 53 | /// Status code of error 54 | #[serde(with = "status_code")] 55 | pub status: http::StatusCode, 56 | /// Message attached to error 57 | pub message: String, 58 | /// Description of the error message. 59 | pub error: Option, 60 | } 61 | 62 | impl std::fmt::Display for TwitchTokenErrorResponse { 63 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 64 | write!( 65 | f, 66 | "{error} - {message}", 67 | error = self 68 | .error 69 | .as_deref() 70 | .unwrap_or_else(|| self.status.canonical_reason().unwrap_or("Error")), 71 | message = self.message 72 | ) 73 | } 74 | } 75 | /// Response from the device code flow 76 | #[derive(Clone, Debug, Deserialize, Serialize)] 77 | pub struct DeviceCodeResponse { 78 | /// The identifier for a given user. 79 | pub device_code: String, 80 | /// Time until the code is no longer valid 81 | pub expires_in: u64, 82 | /// Time until another valid code can be requested 83 | pub interval: u64, 84 | /// The code that the user will use to authenticate 85 | pub user_code: String, 86 | /// The address you will send users to, to authenticate 87 | pub verification_uri: String, 88 | } 89 | 90 | #[doc(hidden)] 91 | pub mod status_code { 92 | use http::StatusCode; 93 | use serde::{ 94 | de::{Deserialize, Error, Unexpected}, 95 | Deserializer, Serializer, 96 | }; 97 | 98 | pub fn deserialize<'de, D>(de: D) -> Result 99 | where D: Deserializer<'de> { 100 | let code: u16 = Deserialize::deserialize(de)?; 101 | match StatusCode::from_u16(code) { 102 | Ok(code) => Ok(code), 103 | Err(_) => Err(Error::invalid_value( 104 | Unexpected::Unsigned(code as u64), 105 | &"a value between 100 and 600", 106 | )), 107 | } 108 | } 109 | 110 | pub fn serialize(status: &StatusCode, ser: S) -> Result 111 | where S: Serializer { 112 | ser.serialize_u16(status.as_u16()) 113 | } 114 | } 115 | 116 | #[doc(hidden)] 117 | pub mod scope { 118 | use serde::{de::Deserialize, Deserializer}; 119 | 120 | pub fn deserialize<'de, D>(de: D) -> Result>, D::Error> 121 | where D: Deserializer<'de> { 122 | let scopes: Option> = Deserialize::deserialize(de)?; 123 | if let Some(scopes) = scopes { 124 | match scopes { 125 | scopes if scopes.is_empty() || scopes.len() > 1 => Ok(Some(scopes)), 126 | scopes if scopes.len() == 1 && scopes.first().unwrap().as_str() == "" => Ok(None), 127 | _ => Ok(Some(scopes)), 128 | } 129 | } else { 130 | Ok(None) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(unknown_lints, renamed_and_removed_lints)] 2 | #![deny(missing_docs, broken_intra_doc_links)] // This will be weird until 1.52, see https://github.com/rust-lang/rust/pull/80527 3 | #![cfg_attr(nightly, deny(rustdoc::broken_intra_doc_links))] 4 | #![cfg_attr(nightly, feature(doc_cfg))] 5 | #![cfg_attr(nightly, feature(doc_auto_cfg))] 6 | //! [![github]](https://github.com/twitch-rs/twitch_oauth2) [![crates-io]](https://crates.io/crates/twitch_oauth2) [![docs-rs]](https://docs.rs/twitch_oauth2/0.8.0/twitch_oauth2) 7 | //! 8 | //! [github]: https://img.shields.io/badge/github-twitch--rs/twitch__oauth2-8da0cb?style=for-the-badge&labelColor=555555&logo=github" 9 | //! [crates-io]: https://img.shields.io/crates/v/twitch_oauth2.svg?style=for-the-badge&color=fc8d62&logo=rust" 10 | //! [docs-rs]: https://img.shields.io/badge/docs.rs-twitch__oauth2-66c2a5?style=for-the-badge&labelColor=555555&logoColor=white&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K" 11 | //! 12 | //!
13 | //! 14 | //!
OAuth2 for Twitch endpoints
15 | //! 16 | //! ```rust,no_run 17 | //! use twitch_oauth2::{tokens::errors::ValidationError, AccessToken, TwitchToken, UserToken}; 18 | //! // Make sure you enable the feature "reqwest" for twitch_oauth2 if you want to use reqwest 19 | //! # async {let client = twitch_oauth2::client::DummyClient; stringify!( 20 | //! let client = reqwest::Client::builder() 21 | //! .redirect(reqwest::redirect::Policy::none()) 22 | //! .build()?; 23 | //! # ); 24 | //! let token = AccessToken::new("sometokenherewhichisvalidornot".to_string()); 25 | //! let token = UserToken::from_token(&client, token).await?; 26 | //! println!("token: {:?}", token.token()); // prints `[redacted access token]` 27 | //! # Ok::<(), Box>(())}; 28 | //! ``` 29 | //! 30 | //! # About 31 | //! 32 | //! ## Scopes 33 | //! 34 | //! The library contains all known twitch oauth2 scopes in [`Scope`]. 35 | //! 36 | //! ## User Access Tokens 37 | //! 38 | //! For most basic use cases with user authorization, [`UserToken::from_token`] will be your main way 39 | //! to create user tokens in this library. 40 | //! 41 | //! Things like [`UserTokenBuilder`] can be used to create a token from scratch, via the [OAuth authorization code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow) 42 | //! You can also use the newer [OAuth device code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow) with [`DeviceUserTokenBuilder`]. 43 | //! 44 | //! ## App access token 45 | //! 46 | //! Similar to [`UserToken`], a token with authorization as the twitch application can be created with 47 | //! [`AppAccessToken::get_app_access_token`]. 48 | //! 49 | //! ## HTTP Requests 50 | //! 51 | //! To enable client features with a supported http library, enable the http library feature in `twitch_oauth2`, like `twitch_oauth2 = { features = ["reqwest"], version = "0.15.2" }`. 52 | //! If you're using [twitch_api](https://crates.io/crates/twitch_api), you can use its [`HelixClient`](https://docs.rs/twitch_api/latest/twitch_api/struct.HelixClient.html) instead of the underlying http client. 53 | //! 54 | //! 55 | //! This library can be used without any specific http client library (like if you don't want to use `await`), 56 | //! using methods like [`AppAccessToken::from_response`] and [`AppAccessToken::get_app_access_token_request`] 57 | //! or [`UserTokenBuilder::get_user_token_request`] and [`UserToken::from_response`] 58 | #[cfg(feature = "client")] 59 | pub mod client; 60 | pub mod id; 61 | pub mod scopes; 62 | pub mod tokens; 63 | pub mod types; 64 | 65 | use http::StatusCode; 66 | use id::TwitchTokenErrorResponse; 67 | #[cfg(feature = "client")] 68 | use tokens::errors::{RefreshTokenError, RevokeTokenError, ValidationError}; 69 | 70 | #[doc(inline)] 71 | pub use scopes::{Scope, Validator}; 72 | #[doc(inline)] 73 | pub use tokens::{ 74 | AppAccessToken, DeviceUserTokenBuilder, ImplicitUserTokenBuilder, TwitchToken, UserToken, 75 | UserTokenBuilder, ValidatedToken, 76 | }; 77 | 78 | pub use url; 79 | 80 | pub use types::{AccessToken, ClientId, ClientSecret, CsrfToken, RefreshToken}; 81 | 82 | #[doc(hidden)] 83 | pub use types::{AccessTokenRef, ClientIdRef, ClientSecretRef, CsrfTokenRef, RefreshTokenRef}; 84 | 85 | #[cfg(feature = "client")] 86 | use self::client::Client; 87 | 88 | /// Generate a url with a default if `mock_api` feature is disabled, or env var is not defined or is invalid utf8 89 | macro_rules! mock_env_url { 90 | ($var:literal, $default:expr $(,)?) => { 91 | once_cell::sync::Lazy::new(move || { 92 | #[cfg(feature = "mock_api")] 93 | if let Ok(url) = std::env::var($var) { 94 | return url::Url::parse(&url).expect(concat!( 95 | "URL could not be made from `env:", 96 | $var, 97 | "`." 98 | )); 99 | }; 100 | url::Url::parse(&$default).unwrap() 101 | }) 102 | }; 103 | } 104 | 105 | /// Defines the root path to twitch auth endpoints 106 | static TWITCH_OAUTH2_URL: once_cell::sync::Lazy = 107 | mock_env_url!("TWITCH_OAUTH2_URL", "https://id.twitch.tv/oauth2/"); 108 | 109 | /// Authorization URL (`https://id.twitch.tv/oauth2/authorize`) for `id.twitch.tv` 110 | /// 111 | /// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_AUTH_URL` to override the base (`https://id.twitch.tv/oauth2/`) url. 112 | /// 113 | /// # Examples 114 | /// 115 | /// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints. 116 | pub static AUTH_URL: once_cell::sync::Lazy = mock_env_url!("TWITCH_OAUTH2_AUTH_URL", { 117 | TWITCH_OAUTH2_URL.to_string() + "authorize" 118 | },); 119 | /// Token URL (`https://id.twitch.tv/oauth2/token`) for `id.twitch.tv` 120 | /// 121 | /// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_TOKEN_URL` to override the base (`https://id.twitch.tv/oauth2/`) url. 122 | /// 123 | /// # Examples 124 | /// 125 | /// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints. 126 | pub static TOKEN_URL: once_cell::sync::Lazy = mock_env_url!("TWITCH_OAUTH2_TOKEN_URL", { 127 | TWITCH_OAUTH2_URL.to_string() + "token" 128 | },); 129 | /// Device URL (`https://id.twitch.tv/oauth2/device`) for `id.twitch.tv` 130 | /// 131 | /// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_DEVICE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url. 132 | /// 133 | /// # Examples 134 | /// 135 | /// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints. 136 | pub static DEVICE_URL: once_cell::sync::Lazy = 137 | mock_env_url!("TWITCH_OAUTH2_DEVICE_URL", { 138 | TWITCH_OAUTH2_URL.to_string() + "device" 139 | },); 140 | /// Validation URL (`https://id.twitch.tv/oauth2/validate`) for `id.twitch.tv` 141 | /// 142 | /// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_VALIDATE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url. 143 | /// 144 | /// # Examples 145 | /// 146 | /// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints. 147 | pub static VALIDATE_URL: once_cell::sync::Lazy = 148 | mock_env_url!("TWITCH_OAUTH2_VALIDATE_URL", { 149 | TWITCH_OAUTH2_URL.to_string() + "validate" 150 | },); 151 | /// Revokation URL (`https://id.twitch.tv/oauth2/revoke`) for `id.twitch.tv` 152 | /// 153 | /// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_REVOKE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url. 154 | /// 155 | /// # Examples 156 | /// 157 | /// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints. 158 | pub static REVOKE_URL: once_cell::sync::Lazy = 159 | mock_env_url!("TWITCH_OAUTH2_REVOKE_URL", { 160 | TWITCH_OAUTH2_URL.to_string() + "revoke" 161 | },); 162 | 163 | impl AccessTokenRef { 164 | /// Get the request needed to validate this token. 165 | /// 166 | /// Parse the response from this endpoint with [ValidatedToken::from_response](crate::ValidatedToken::from_response) 167 | pub fn validate_token_request(&self) -> http::Request> { 168 | use http::{header::AUTHORIZATION, HeaderMap, Method}; 169 | 170 | let auth_header = format!("OAuth {}", self.secret()); 171 | let mut headers = HeaderMap::new(); 172 | headers.insert( 173 | AUTHORIZATION, 174 | auth_header 175 | .parse() 176 | .expect("Failed to parse header for validation"), 177 | ); 178 | 179 | crate::construct_request::<&[(String, String)], _, _>( 180 | &crate::VALIDATE_URL, 181 | &[], 182 | headers, 183 | Method::GET, 184 | vec![], 185 | ) 186 | } 187 | 188 | /// Validate this token. 189 | /// 190 | /// Should be checked on regularly, according to 191 | #[cfg(feature = "client")] 192 | pub async fn validate_token<'a, C>( 193 | &self, 194 | client: &'a C, 195 | ) -> Result::Error>> 196 | where 197 | C: Client, 198 | { 199 | let req = self.validate_token_request(); 200 | 201 | let resp = client.req(req).await.map_err(ValidationError::Request)?; 202 | if resp.status() == StatusCode::UNAUTHORIZED { 203 | return Err(ValidationError::NotAuthorized); 204 | } 205 | ValidatedToken::from_response(&resp).map_err(|v| v.into_other()) 206 | } 207 | 208 | /// Get the request needed to revoke this token. 209 | pub fn revoke_token_request(&self, client_id: &ClientId) -> http::Request> { 210 | use http::{HeaderMap, Method}; 211 | use std::collections::HashMap; 212 | let mut params = HashMap::new(); 213 | params.insert("client_id", client_id.as_str()); 214 | params.insert("token", self.secret()); 215 | 216 | construct_request( 217 | &crate::REVOKE_URL, 218 | ¶ms, 219 | HeaderMap::new(), 220 | Method::POST, 221 | vec![], 222 | ) 223 | } 224 | 225 | /// Revoke the token. 226 | /// 227 | /// See 228 | #[cfg(feature = "client")] 229 | pub async fn revoke_token<'a, C>( 230 | &self, 231 | http_client: &'a C, 232 | client_id: &ClientId, 233 | ) -> Result<(), RevokeTokenError<::Error>> 234 | where 235 | C: Client, 236 | { 237 | let req = self.revoke_token_request(client_id); 238 | 239 | let resp = http_client 240 | .req(req) 241 | .await 242 | .map_err(RevokeTokenError::RequestError)?; 243 | 244 | let _ = parse_token_response_raw(&resp)?; 245 | Ok(()) 246 | } 247 | } 248 | 249 | impl RefreshTokenRef { 250 | /// Get the request needed to refresh this token. 251 | /// 252 | /// Parse the response from this endpoint with [TwitchTokenResponse::from_response](crate::id::TwitchTokenResponse::from_response) 253 | pub fn refresh_token_request( 254 | &self, 255 | client_id: &ClientId, 256 | client_secret: Option<&ClientSecret>, 257 | ) -> http::Request> { 258 | use http::{HeaderMap, HeaderValue, Method}; 259 | use std::collections::HashMap; 260 | 261 | let mut headers = HeaderMap::new(); 262 | headers.append( 263 | "Content-Type", 264 | HeaderValue::from_static("application/x-www-form-urlencoded"), 265 | ); 266 | 267 | let mut params = HashMap::new(); 268 | params.insert("client_id", client_id.as_str()); 269 | if let Some(client_secret) = client_secret { 270 | params.insert("client_secret", client_secret.secret()); 271 | } 272 | params.insert("grant_type", "refresh_token"); 273 | params.insert("refresh_token", self.secret()); 274 | construct_request::<&[(String, String)], _, _>( 275 | &crate::TOKEN_URL, 276 | &[], 277 | headers, 278 | Method::POST, 279 | url::form_urlencoded::Serializer::new(String::new()) 280 | .extend_pairs(params) 281 | .finish() 282 | .into_bytes(), 283 | ) 284 | } 285 | 286 | /// Refresh the token, call if it has expired. 287 | /// 288 | /// See 289 | #[cfg(feature = "client")] 290 | pub async fn refresh_token<'a, C>( 291 | &self, 292 | http_client: &'a C, 293 | client_id: &ClientId, 294 | client_secret: Option<&ClientSecret>, 295 | ) -> Result< 296 | (AccessToken, std::time::Duration, Option), 297 | RefreshTokenError<::Error>, 298 | > 299 | where 300 | C: Client, 301 | { 302 | let req = self.refresh_token_request(client_id, client_secret); 303 | 304 | let resp = http_client 305 | .req(req) 306 | .await 307 | .map_err(RefreshTokenError::RequestError)?; 308 | let res = id::TwitchTokenResponse::from_response(&resp)?; 309 | 310 | let expires_in = res.expires_in().ok_or(RefreshTokenError::NoExpiration)?; 311 | let refresh_token = res.refresh_token; 312 | let access_token = res.access_token; 313 | Ok((access_token, expires_in, refresh_token)) 314 | } 315 | } 316 | 317 | /// Construct a request that accepts `application/json` on default 318 | fn construct_request( 319 | url: &url::Url, 320 | params: I, 321 | headers: http::HeaderMap, 322 | method: http::Method, 323 | body: Vec, 324 | ) -> http::Request> 325 | where 326 | I: std::iter::IntoIterator, 327 | I::Item: std::borrow::Borrow<(K, V)>, 328 | K: AsRef, 329 | V: AsRef, 330 | { 331 | let mut url = url.clone(); 332 | url.query_pairs_mut().extend_pairs(params); 333 | let url: String = url.into(); 334 | let mut req = http::Request::builder().method(method).uri(url); 335 | req.headers_mut().map(|h| h.extend(headers)).unwrap(); 336 | req.headers_mut() 337 | .map(|h| { 338 | if !h.contains_key(http::header::ACCEPT) { 339 | h.append(http::header::ACCEPT, "application/json".parse().unwrap()); 340 | } 341 | }) 342 | .unwrap(); 343 | req.body(body).unwrap() 344 | } 345 | 346 | /// Parses a response, validating it and returning the response if all ok. 347 | pub(crate) fn parse_token_response_raw>( 348 | resp: &http::Response, 349 | ) -> Result<&http::Response, RequestParseError> { 350 | match serde_json::from_slice::(resp.body().as_ref()) { 351 | Err(_) => match resp.status() { 352 | StatusCode::OK => Ok(resp), 353 | _ => Err(RequestParseError::Other(resp.status())), 354 | }, 355 | Ok(twitch_err) => Err(RequestParseError::TwitchError(twitch_err)), 356 | } 357 | } 358 | 359 | /// Parses a response, validating it and returning json deserialized response 360 | pub(crate) fn parse_response>( 361 | resp: &http::Response, 362 | ) -> Result { 363 | let body = parse_token_response_raw(resp)?.body().as_ref(); 364 | if let Some(_content) = resp.headers().get(http::header::CONTENT_TYPE) { 365 | // TODO: Remove this cfg, see issue https://github.com/twitchdev/twitch-cli/issues/81 366 | #[cfg(not(feature = "mock_api"))] 367 | if _content != "application/json" { 368 | return Err(RequestParseError::NotJson { 369 | found: String::from_utf8_lossy(_content.as_bytes()).into_owned(), 370 | }); 371 | } 372 | } 373 | serde_json::from_slice(body).map_err(Into::into) 374 | } 375 | 376 | /// Errors from parsing responses 377 | #[derive(Debug, thiserror::Error, displaydoc::Display)] 378 | #[non_exhaustive] 379 | pub enum RequestParseError { 380 | /// deserialization failed 381 | DeserializeError(#[from] serde_json::Error), 382 | /// twitch returned an error 383 | TwitchError(#[from] TwitchTokenErrorResponse), 384 | /// returned content is not `application/json`, found `{found}` 385 | NotJson { 386 | /// Found `Content-Type` header 387 | found: String, 388 | }, 389 | /// twitch returned an unexpected status code: {0} 390 | Other(StatusCode), 391 | } 392 | -------------------------------------------------------------------------------- /src/scopes.rs: -------------------------------------------------------------------------------- 1 | //! Module for all possible scopes in twitch. 2 | pub mod validator; 3 | pub use validator::Validator; 4 | 5 | use serde_derive::{Deserialize, Serialize}; 6 | use std::borrow::Cow; 7 | 8 | macro_rules! scope_impls { 9 | (@omit #[deprecated($depr:tt)] $i:ident) => { 10 | #[cfg(_internal_never)] 11 | Self::$i 12 | }; 13 | (@omit $i:ident) => { 14 | Self::$i 15 | }; 16 | 17 | ($($(#[cfg(($cfg:meta))])* $(#[deprecated($depr:meta)])? $i:ident,scope: $rename:literal, doc: $doc:literal);* $(;)? ) => { 18 | #[doc = "Scopes for twitch."] 19 | #[doc = ""] 20 | #[doc = ""] 21 | #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] 22 | #[non_exhaustive] 23 | #[serde(from = "String")] 24 | #[serde(into = "String")] 25 | pub enum Scope { 26 | $( 27 | $(#[cfg($cfg)])* 28 | $(#[deprecated($depr)])* 29 | #[doc = $doc] 30 | #[doc = "\n\n"] 31 | #[doc = "`"] 32 | #[doc = $rename] 33 | #[doc = "`"] 34 | #[serde(rename = $rename)] // Is this even needed? 35 | $i, 36 | )* 37 | #[doc = "Other scope that is not implemented."] 38 | Other(Cow<'static, str>), 39 | } 40 | 41 | impl std::fmt::Display for Scope { 42 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 43 | #![allow(deprecated)] 44 | 45 | f.write_str(match self { 46 | Scope::Other(s) => &s, 47 | $( 48 | $(#[cfg($cfg)])* 49 | Scope::$i => $rename, 50 | )* 51 | }) 52 | } 53 | } 54 | 55 | impl Scope { 56 | #[doc = "Get a vec of all defined twitch [Scopes][Scope]."] 57 | #[doc = "\n\n"] 58 | #[doc = "Please note that this may not work for you, as some auth flows and \"apis\" don't accept all scopes"] 59 | pub fn all() -> Vec { 60 | vec![ 61 | $( 62 | scope_impls!(@omit $(#[deprecated($depr)])* $i), 63 | )* 64 | ] 65 | } 66 | 67 | #[doc = "Get a slice of all defined twitch [Scopes][Scope]."] 68 | #[doc = "\n\n"] 69 | #[doc = "Please note that this may not work for you, as some auth flows and \"apis\" don't accept all scopes"] 70 | pub const fn all_slice() -> &'static [Scope] { 71 | &[ 72 | $( 73 | scope_impls!(@omit $(#[deprecated($depr)])* $i), 74 | )* 75 | ] 76 | } 77 | 78 | #[doc = "Get a description for the token"] 79 | pub const fn description(&self) -> &'static str { 80 | #![allow(deprecated)] 81 | 82 | match self { 83 | 84 | $( 85 | $(#[cfg($cfg)])* 86 | Self::$i => $doc, 87 | )* 88 | _ => "unknown scope" 89 | } 90 | } 91 | 92 | #[doc = "Make a scope from a cow string"] 93 | pub fn parse(s: C) -> Scope where C: Into> { 94 | #![allow(deprecated)] 95 | use std::borrow::Borrow; 96 | let s = s.into(); 97 | match s.borrow() { 98 | 99 | $( 100 | $(#[cfg($cfg)])* 101 | $rename => {Scope::$i} 102 | )*, 103 | _ => Scope::Other(s) 104 | } 105 | } 106 | 107 | /// Get the scope as a borrowed string. 108 | pub fn as_str(&self) -> &str { 109 | #![allow(deprecated)] 110 | match self { 111 | $( 112 | $(#[cfg($cfg)])* 113 | Scope::$i => $rename, 114 | )* 115 | Self::Other(c) => c.as_ref() 116 | } 117 | } 118 | 119 | /// Get the scope as a static string slice. 120 | /// 121 | /// ## Panics 122 | /// 123 | /// This function panics if the scope can't be represented as a static string slice 124 | pub const fn as_static_str(&self) -> &'static str { 125 | #![allow(deprecated)] 126 | match self { 127 | $( 128 | $(#[cfg($cfg)])* 129 | Scope::$i => $rename, 130 | )* 131 | Self::Other(Cow::Borrowed(s)) => s, 132 | _ => panic!(), 133 | } 134 | } 135 | } 136 | #[test] 137 | #[cfg(test)] 138 | fn sorted() { 139 | let slice = [$( 140 | $(#[cfg($cfg)])* 141 | $rename, 142 | )*]; 143 | let mut slice_sorted = [$( 144 | $(#[cfg($cfg)])* 145 | $rename, 146 | )*]; 147 | slice_sorted.sort(); 148 | for (scope, sorted) in slice.iter().zip(slice_sorted.iter()) { 149 | assert_eq!(scope, sorted); 150 | } 151 | assert_eq!(slice, slice_sorted); 152 | } 153 | }; 154 | } 155 | 156 | scope_impls!( 157 | AnalyticsReadExtensions, scope: "analytics:read:extensions", doc: "View analytics data for the Twitch Extensions owned by the authenticated account."; 158 | AnalyticsReadGames, scope: "analytics:read:games", doc: "View analytics data for the games owned by the authenticated account."; 159 | BitsRead, scope: "bits:read", doc: "View Bits information for a channel."; 160 | ChannelBot, scope: "channel:bot", doc: "Allows the client’s bot users access to a channel."; 161 | ChannelEditCommercial, scope: "channel:edit:commercial", doc: "Run commercials on a channel."; 162 | ChannelManageAds, scope: "channel:manage:ads", doc: "Manage ads schedule on a channel."; 163 | ChannelManageBroadcast, scope: "channel:manage:broadcast", doc: "Manage a channel’s broadcast configuration, including updating channel configuration and managing stream markers and stream tags."; 164 | ChannelManageExtensions, scope: "channel:manage:extensions", doc: "Manage a channel’s Extension configuration, including activating Extensions."; 165 | ChannelManageGuestStar, scope: "channel:manage:guest_star", doc: "Manage Guest Star for your channel."; 166 | ChannelManageModerators, scope: "channel:manage:moderators", doc: "Add or remove the moderator role from users in your channel."; 167 | ChannelManagePolls, scope: "channel:manage:polls", doc: "Manage a channel’s polls."; 168 | ChannelManagePredictions, scope: "channel:manage:predictions", doc: "Manage of channel’s Channel Points Predictions"; 169 | ChannelManageRaids, scope: "channel:manage:raids", doc: "Manage a channel raiding another channel."; 170 | ChannelManageRedemptions, scope: "channel:manage:redemptions", doc: "Manage Channel Points custom rewards and their redemptions on a channel."; 171 | ChannelManageSchedule, scope: "channel:manage:schedule", doc: "Manage a channel’s stream schedule."; 172 | ChannelManageVideos, scope: "channel:manage:videos", doc: "Manage a channel’s videos, including deleting videos."; 173 | ChannelManageVips, scope: "channel:manage:vips", doc: "Add or remove the VIP role from users in your channel."; 174 | ChannelModerate, scope: "channel:moderate", doc: "Perform moderation actions in a channel. The user requesting the scope must be a moderator in the channel."; 175 | ChannelReadAds, scope: "channel:read:ads", doc: "Read the ads schedule and details on your channel."; 176 | ChannelReadCharity, scope: "channel:read:charity", doc: "Read charity campaign details and user donations on your channel."; 177 | ChannelReadEditors, scope: "channel:read:editors", doc: "View a list of users with the editor role for a channel."; 178 | ChannelReadGoals, scope: "channel:read:goals", doc: "View Creator Goals for a channel."; 179 | ChannelReadGuestStar, scope: "channel:read:guest_star", doc: "Read Guest Star details for your channel."; 180 | ChannelReadHypeTrain, scope: "channel:read:hype_train", doc: "View Hype Train information for a channel."; 181 | ChannelReadPolls, scope: "channel:read:polls", doc: "View a channel’s polls."; 182 | ChannelReadPredictions, scope: "channel:read:predictions", doc: "View a channel’s Channel Points Predictions."; 183 | ChannelReadRedemptions, scope: "channel:read:redemptions", doc: "View Channel Points custom rewards and their redemptions on a channel."; 184 | ChannelReadStreamKey, scope: "channel:read:stream_key", doc: "View an authorized user’s stream key."; 185 | ChannelReadSubscriptions, scope: "channel:read:subscriptions", doc: "View a list of all subscribers to a channel and check if a user is subscribed to a channel."; 186 | ChannelReadVips, scope: "channel:read:vips", doc: "Read the list of VIPs in your channel."; 187 | #[deprecated(note = "Use `ChannelReadSubscriptions` (`channel:read:subscriptions`) instead")] 188 | ChannelSubscriptions, scope: "channel_subscriptions", doc: "Read all subscribers to your channel."; 189 | ChatEdit, scope: "chat:edit", doc: "Send live stream chat and rooms messages."; 190 | ChatRead, scope: "chat:read", doc: "View live stream chat and rooms messages."; 191 | ClipsEdit, scope: "clips:edit", doc: "Manage Clips for a channel."; 192 | ModerationRead, scope: "moderation:read", doc: "View a channel’s moderation data including Moderators, Bans, Timeouts, and Automod settings."; 193 | ModeratorManageAnnouncements, scope: "moderator:manage:announcements", doc: "Send announcements in channels where you have the moderator role."; 194 | ModeratorManageAutoMod, scope: "moderator:manage:automod", doc: "Manage messages held for review by AutoMod in channels where you are a moderator."; 195 | ModeratorManageAutomodSettings, scope: "moderator:manage:automod_settings", doc: "Manage a broadcaster’s AutoMod settings"; 196 | ModeratorManageBannedUsers, scope: "moderator:manage:banned_users", doc: "Ban and unban users."; 197 | ModeratorManageBlockedTerms, scope: "moderator:manage:blocked_terms", doc: "Manage a broadcaster’s list of blocked terms."; 198 | ModeratorManageChatMessages, scope: "moderator:manage:chat_messages", doc: "Delete chat messages in channels where you have the moderator role"; 199 | ModeratorManageChatSettings, scope: "moderator:manage:chat_settings", doc: "View a broadcaster’s chat room settings."; 200 | ModeratorManageGuestStar, scope: "moderator:manage:guest_star", doc: "Manage Guest Star for channels where you are a Guest Star moderator."; 201 | ModeratorManageShieldMode, scope: "moderator:manage:shield_mode", doc: "Manage a broadcaster’s Shield Mode status."; 202 | ModeratorManageShoutouts, scope: "moderator:manage:shoutouts", doc: "Manage a broadcaster’s shoutouts."; 203 | ModeratorManageUnbanRequests, scope: "moderator:manage:unban_requests", doc: "Manage a broadcaster’s unban requests."; 204 | ModeratorManageWarnings, scope: "moderator:manage:warnings", doc: "Manage a broadcaster’s chat warnings."; 205 | ModeratorReadAutomodSettings, scope: "moderator:read:automod_settings", doc: "View a broadcaster’s AutoMod settings."; 206 | ModeratorReadBannedUsers, scope: "moderator:read:banned_users", doc: "Read the list of bans or unbans in channels where you have the moderator role."; 207 | ModeratorReadBlockedTerms, scope: "moderator:read:blocked_terms", doc: "View a broadcaster’s list of blocked terms."; 208 | ModeratorReadChatMessages, scope: "moderator:read:chat_messages", doc: "Read deleted chat messages in channels where you have the moderator role."; 209 | ModeratorReadChatSettings, scope: "moderator:read:chat_settings", doc: "View a broadcaster’s chat room settings."; 210 | ModeratorReadChatters, scope: "moderator:read:chatters", doc: "View the chatters in a broadcaster’s chat room."; 211 | ModeratorReadFollowers, scope: "moderator:read:followers", doc: "Read the followers of a broadcaster."; 212 | ModeratorReadGuestStar, scope: "moderator:read:guest_star", doc: "Read Guest Star details for channels where you are a Guest Star moderator."; 213 | ModeratorReadModerators, scope: "moderator:read:moderators", doc: "Read the list of moderators in channels where you have the moderator role."; 214 | ModeratorReadShieldMode, scope: "moderator:read:shield_mode", doc: "View a broadcaster’s Shield Mode status."; 215 | ModeratorReadShoutouts, scope: "moderator:read:shoutouts", doc: "View a broadcaster’s shoutouts."; 216 | ModeratorReadSuspiciousUsers, scope: "moderator:read:suspicious_users", doc: "Read chat messages from suspicious users and see users flagged as suspicious in channels where you have the moderator role."; 217 | ModeratorReadUnbanRequests, scope: "moderator:read:unban_requests", doc: "View a broadcaster’s unban requests."; 218 | ModeratorReadVips, scope: "moderator:read:vips", doc: "Read the list of VIPs in channels where you have the moderator role."; 219 | ModeratorReadWarnings, scope: "moderator:read:warnings", doc: "View a broadcaster’s chat warnings."; 220 | UserBot, scope: "user:bot", doc: "Allows client’s bot to act as this user."; 221 | UserEdit, scope: "user:edit", doc: "Manage a user object."; 222 | UserEditBroadcast, scope: "user:edit:broadcast", doc: "Edit your channel's broadcast configuration, including extension configuration. (This scope implies user:read:broadcast capability.)"; 223 | #[deprecated(note = "Not used anymore, see https://discuss.dev.twitch.tv/t/deprecation-of-create-and-delete-follows-api-endpoints/32351")] 224 | UserEditFollows, scope: "user:edit:follows", doc: "\\[DEPRECATED\\] Was previously used for “Create User Follows” and “Delete User Follows."; 225 | UserManageBlockedUsers, scope: "user:manage:blocked_users", doc: "Manage the block list of a user."; 226 | UserManageChatColor, scope: "user:manage:chat_color", doc: "Update the color used for the user’s name in chat.Update User Chat Color"; 227 | UserManageWhispers, scope: "user:manage:whispers", doc: "Read whispers that you send and receive, and send whispers on your behalf."; 228 | UserReadBlockedUsers, scope: "user:read:blocked_users", doc: "View the block list of a user."; 229 | UserReadBroadcast, scope: "user:read:broadcast", doc: "View a user’s broadcasting configuration, including Extension configurations."; 230 | UserReadChat, scope: "user:read:chat", doc: "View live stream chat and room messages."; 231 | UserReadEmail, scope: "user:read:email", doc: "View a user’s email address."; 232 | UserReadEmotes, scope: "user:read:emotes", doc: "View emotes available to a user"; 233 | UserReadFollows, scope: "user:read:follows", doc: "View the list of channels a user follows."; 234 | UserReadModeratedChannels, scope: "user:read:moderated_channels", doc: "Read the list of channels you have moderator privileges in."; 235 | UserReadSubscriptions, scope: "user:read:subscriptions", doc: "View if an authorized user is subscribed to specific channels."; 236 | UserReadWhispers, scope: "user:read:whispers", doc: "Receive whispers sent to your user."; 237 | UserWriteChat, scope: "user:write:chat", doc: "Send messages in a chat room."; 238 | WhispersEdit, scope: "whispers:edit", doc: "Send whisper messages."; 239 | WhispersRead, scope: "whispers:read", doc: "View your whisper messages."; 240 | ); 241 | 242 | impl Scope { 243 | /// Get the scope as a [validator](Validator). 244 | pub const fn to_validator(self) -> Validator { Validator::scope(self) } 245 | } 246 | 247 | impl std::borrow::Borrow for Scope { 248 | fn borrow(&self) -> &str { self.as_str() } 249 | } 250 | 251 | impl From for Scope { 252 | fn from(s: String) -> Self { Scope::parse(s) } 253 | } 254 | 255 | impl From for String { 256 | fn from(s: Scope) -> Self { s.to_string() } 257 | } 258 | 259 | #[cfg(test)] 260 | mod tests { 261 | use super::*; 262 | #[test] 263 | fn custom_scope() { 264 | assert_eq!( 265 | Scope::Other(Cow::from("custom_scope")), 266 | Scope::parse("custom_scope") 267 | ) 268 | } 269 | 270 | #[test] 271 | fn roundabout() { 272 | for scope in Scope::all() { 273 | assert_eq!(scope, Scope::parse(scope.to_string())) 274 | } 275 | } 276 | 277 | #[test] 278 | #[allow(deprecated)] 279 | fn no_deprecated() { 280 | for scope in Scope::all() { 281 | assert!(scope != Scope::ChannelSubscriptions) 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/scopes/validator.rs: -------------------------------------------------------------------------------- 1 | //! Validator used for checking scopes in a token. 2 | use std::borrow::Cow; 3 | 4 | use super::Scope; 5 | 6 | /// A collection of validators 7 | pub type Validators = Cow<'static, [Validator]>; 8 | 9 | /// A [validator](Validator) is a way to check if an array of scopes matches a predicate. 10 | /// 11 | /// Can be constructed easily with the [validator!](crate::validator) macro. 12 | /// 13 | /// # Examples 14 | /// 15 | /// ```rust, no_run 16 | /// use twitch_oauth2::{validator, AppAccessToken, Scope, TwitchToken as _}; 17 | /// 18 | /// let token: AppAccessToken = token(); 19 | /// let validator = validator!(Scope::ChatEdit, Scope::ChatRead); 20 | /// assert!(validator.matches(token.scopes())); 21 | /// 22 | /// # pub fn token() -> AppAccessToken { todo!() } 23 | /// ``` 24 | #[derive(Clone, PartialEq)] 25 | #[non_exhaustive] 26 | pub enum Validator { 27 | /// A scope 28 | Scope(Scope), 29 | /// Matches true if all validators passed inside return true 30 | All(Sized), 31 | /// Matches true if **any** validator passed inside returns true 32 | Any(Sized), 33 | /// Matches true if all validators passed inside matches false 34 | Not(Sized), 35 | } 36 | 37 | impl std::fmt::Debug for Validator { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | match self { 40 | Validator::Scope(scope) => scope.fmt(f), 41 | Validator::All(Sized(all)) => f.debug_tuple("All").field(all).finish(), 42 | Validator::Any(Sized(any)) => f.debug_tuple("Any").field(any).finish(), 43 | Validator::Not(Sized(not)) => f.debug_tuple("Not").field(not).finish(), 44 | } 45 | } 46 | } 47 | 48 | impl std::fmt::Display for Validator { 49 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 | // dont allocate if we can avoid it, instead we map over the validators, and use write! 51 | match self { 52 | Validator::Scope(scope) => scope.fmt(f), 53 | Validator::All(Sized(all)) => { 54 | write!(f, "(")?; 55 | for (i, v) in all.iter().enumerate() { 56 | if i != 0 { 57 | write!(f, " and ")?; 58 | } 59 | write!(f, "{}", v)?; 60 | } 61 | write!(f, ")") 62 | } 63 | Validator::Any(Sized(any)) => { 64 | write!(f, "(")?; 65 | for (i, v) in any.iter().enumerate() { 66 | if i != 0 { 67 | write!(f, " or ")?; 68 | } 69 | write!(f, "{}", v)?; 70 | } 71 | write!(f, ")") 72 | } 73 | Validator::Not(Sized(not)) => { 74 | write!(f, "not(")?; 75 | for (i, v) in not.iter().enumerate() { 76 | if i != 0 { 77 | write!(f, ", ")?; 78 | } 79 | write!(f, "{}", v)?; 80 | } 81 | write!(f, ")") 82 | } 83 | } 84 | } 85 | } 86 | 87 | impl Validator { 88 | /// Checks if the given scopes match the predicate. 89 | /// 90 | /// # Examples 91 | /// 92 | /// ``` 93 | /// use twitch_oauth2::{validator, Scope}; 94 | /// 95 | /// let scopes: &[Scope] = &[Scope::ChatEdit, Scope::ChatRead]; 96 | /// let validator = validator!(Scope::ChatEdit, Scope::ChatRead); 97 | /// assert!(validator.matches(scopes)); 98 | /// assert!(!validator.matches(&scopes[..1])); 99 | /// ``` 100 | #[must_use] 101 | pub fn matches(&self, scopes: &[Scope]) -> bool { 102 | match &self { 103 | Validator::Scope(scope) => scopes.contains(scope), 104 | Validator::All(Sized(validators)) => validators.iter().all(|v| v.matches(scopes)), 105 | Validator::Any(Sized(validators)) => validators.iter().any(|v| v.matches(scopes)), 106 | Validator::Not(Sized(validator)) => !validator.iter().any(|v| v.matches(scopes)), 107 | } 108 | } 109 | 110 | /// Returns a validator only containing the unmatched scopes. 111 | /// 112 | /// # Examples 113 | /// 114 | /// ```rust 115 | /// use twitch_oauth2::{validator, Scope}; 116 | /// 117 | /// let validator = validator!(Scope::ChatEdit, Scope::ChatRead); 118 | /// 119 | /// let scopes = &[Scope::ChatEdit, Scope::ChatRead]; 120 | /// assert_eq!(validator.missing(scopes), None); 121 | /// ``` 122 | /// 123 | /// ```rust 124 | /// use twitch_oauth2::{validator, Scope}; 125 | /// 126 | /// let validator = validator!(Scope::ChatEdit, Scope::ChatRead); 127 | /// 128 | /// let scopes = &[Scope::ChatEdit]; 129 | /// if let Some(v) = validator.missing(scopes) { 130 | /// println!("Missing scopes: {}", v); 131 | /// } 132 | /// ``` 133 | /// 134 | /// ```rust 135 | /// use twitch_oauth2::{validator, Scope}; 136 | /// 137 | /// let validator = validator!( 138 | /// any( 139 | /// Scope::ModeratorReadBlockedTerms, 140 | /// Scope::ModeratorManageBlockedTerms 141 | /// ), 142 | /// any( 143 | /// Scope::ModeratorReadChatSettings, 144 | /// Scope::ModeratorManageChatSettings 145 | /// ) 146 | /// ); 147 | /// 148 | /// let scopes = &[Scope::ModeratorReadBlockedTerms]; 149 | /// let missing = validator.missing(scopes).unwrap(); 150 | /// // We're missing either of the chat settings scopes 151 | /// assert!(missing.matches(&[Scope::ModeratorReadChatSettings])); 152 | /// assert!(missing.matches(&[Scope::ModeratorManageChatSettings])); 153 | /// ``` 154 | pub fn missing(&self, scopes: &[Scope]) -> Option { 155 | if self.matches(scopes) { 156 | return None; 157 | } 158 | // a recursive prune approach, if a validator matches, we prune it. 159 | // TODO: There's a bit of allocation going on here, maybe we can remove it with some kind of descent 160 | match &self { 161 | Validator::Scope(scope) => { 162 | if scopes.contains(scope) { 163 | None 164 | } else { 165 | Some(Validator::Scope(scope.clone())) 166 | } 167 | } 168 | Validator::All(Sized(validators)) => { 169 | let mut missing = validators 170 | .iter() 171 | .filter_map(|v| v.missing(scopes)) 172 | .collect::>(); 173 | 174 | if missing.is_empty() { 175 | None 176 | } else if missing.len() == 1 { 177 | Some(missing.remove(0)) 178 | } else { 179 | Some(Validator::All(Sized(Cow::Owned(missing)))) 180 | } 181 | } 182 | Validator::Any(Sized(validators)) => { 183 | let mut missing = validators 184 | .iter() 185 | .filter(|v| !v.matches(scopes)) 186 | .filter_map(|v| v.missing(scopes)) 187 | .collect::>(); 188 | 189 | if missing.is_empty() { 190 | None 191 | } else if missing.len() == 1 { 192 | Some(missing.remove(0)) 193 | } else { 194 | Some(Validator::Any(Sized(Cow::Owned(missing)))) 195 | } 196 | } 197 | Validator::Not(Sized(validators)) => { 198 | // not is special, it's a negation, so a match is a failure. 199 | // we find out if the validators inside matches (e.g the scopes exists), 200 | // if they exist they are bad. 201 | // a validator should preferably not use not, because scopes are additive. 202 | 203 | let matching = validators 204 | .iter() 205 | .filter(|v| v.matches(scopes)) 206 | .collect::>(); 207 | 208 | if matching.is_empty() { 209 | None 210 | } else { 211 | Some(Validator::Not(Sized(Cow::Owned( 212 | matching.into_iter().cloned().collect(), 213 | )))) 214 | } 215 | } 216 | } 217 | } 218 | 219 | /// Create a [Validator] which matches if the scope is present. 220 | pub const fn scope(scope: Scope) -> Self { Validator::Scope(scope) } 221 | 222 | /// Create a [Validator] which matches if all validators passed inside matches true. 223 | pub const fn all_multiple(ands: &'static [Validator]) -> Self { 224 | Validator::All(Sized(Cow::Borrowed(ands))) 225 | } 226 | 227 | /// Create a [Validator] which matches if **any** validator passed inside matches true. 228 | pub const fn any_multiple(anys: &'static [Validator]) -> Self { 229 | Validator::Any(Sized(Cow::Borrowed(anys))) 230 | } 231 | 232 | /// Create a [Validator] which matches if all validators passed inside matches false. 233 | pub const fn not(not: &'static Validator) -> Self { 234 | Validator::Not(Sized(Cow::Borrowed(std::slice::from_ref(not)))) 235 | } 236 | 237 | /// Convert [Self] to [Self] 238 | /// 239 | /// # Notes 240 | /// 241 | /// This function doesn't do anything, but it powers the [validator!] macro 242 | #[doc(hidden)] 243 | pub const fn to_validator(self) -> Self { self } 244 | } 245 | 246 | // https://github.com/rust-lang/rust/issues/47032#issuecomment-568784919 247 | /// Hack for making `T: Sized` 248 | #[derive(Debug, Clone, PartialEq)] 249 | #[repr(transparent)] 250 | pub struct Sized(pub T); 251 | 252 | impl From for Validator { 253 | fn from(scope: Scope) -> Self { Validator::scope(scope) } 254 | } 255 | 256 | /// A [validator](Validator) is a way to check if a slice of scopes matches a predicate. 257 | /// 258 | /// Uses a functional style to compose the predicate. Can be used in const context. 259 | /// 260 | /// # Supported operators 261 | /// 262 | /// * `not(...)` 263 | /// * negates the validator passed inside, can only take one argument 264 | /// * `all(...)` 265 | /// * returns true if all validators passed inside return true 266 | /// * `any(...)` 267 | /// * returns true if **any** validator passed inside returns true 268 | /// 269 | /// # Examples 270 | /// 271 | /// ```rust, no_run 272 | /// use twitch_oauth2::{validator, AppAccessToken, Scope, TwitchToken as _}; 273 | /// 274 | /// let token: AppAccessToken = token(); 275 | /// let validator = validator!(Scope::ChatEdit, Scope::ChatRead); 276 | /// assert!(validator.matches(token.scopes())); 277 | /// 278 | /// # pub fn token() -> AppAccessToken { todo!() } 279 | /// ``` 280 | /// 281 | /// ## Multiple scopes 282 | /// 283 | /// ```rust 284 | /// use twitch_oauth2::{validator, Scope}; 285 | /// 286 | /// let scopes: &[Scope] = &[Scope::ChatEdit, Scope::ChatRead]; 287 | /// let validator = validator!(Scope::ChatEdit, Scope::ChatRead); 288 | /// assert!(validator.matches(scopes)); 289 | /// assert!(!validator.matches(&scopes[..1])); 290 | /// ``` 291 | /// 292 | /// ## Multiple scopes with explicit all(...) 293 | /// 294 | /// ```rust 295 | /// use twitch_oauth2::{validator, Scope}; 296 | /// 297 | /// let scopes: &[Scope] = &[Scope::ChatEdit, Scope::ChatRead]; 298 | /// let validator = validator!(all(Scope::ChatEdit, Scope::ChatRead)); 299 | /// assert!(validator.matches(scopes)); 300 | /// assert!(!validator.matches(&scopes[..1])); 301 | /// ``` 302 | /// 303 | /// ## Multiple scopes with nested any(...) 304 | /// 305 | /// ```rust 306 | /// use twitch_oauth2::{validator, Scope}; 307 | /// 308 | /// let scopes: &[Scope] = &[Scope::ChatEdit, Scope::ChatRead]; 309 | /// let validator = validator!( 310 | /// Scope::ChatEdit, 311 | /// any(Scope::ChatRead, Scope::ChannelReadSubscriptions) 312 | /// ); 313 | /// assert!(validator.matches(scopes)); 314 | /// assert!(!validator.matches(&scopes[1..])); 315 | /// ``` 316 | /// 317 | /// ## Not 318 | /// 319 | /// ```rust 320 | /// use twitch_oauth2::{validator, Scope}; 321 | /// 322 | /// let scopes: &[Scope] = &[Scope::ChatRead]; 323 | /// let validator = validator!(not(Scope::ChatEdit)); 324 | /// assert!(validator.matches(scopes)); 325 | /// ``` 326 | /// 327 | /// ## Combining other validators 328 | /// 329 | /// ``` 330 | /// use twitch_oauth2::{validator, Scope, Validator}; 331 | /// 332 | /// let scopes: &[Scope] = &[ 333 | /// Scope::ChatEdit, 334 | /// Scope::ChatRead, 335 | /// Scope::ModeratorManageAutoMod, 336 | /// Scope::ModerationRead, 337 | /// ]; 338 | /// const CHAT_SCOPES: Validator = validator!(all(Scope::ChatEdit, Scope::ChatRead)); 339 | /// const MODERATOR_SCOPES: Validator = 340 | /// validator!(Scope::ModerationRead, Scope::ModeratorManageAutoMod); 341 | /// const COMBINED: Validator = validator!(CHAT_SCOPES, MODERATOR_SCOPES); 342 | /// assert!(COMBINED.matches(scopes)); 343 | /// assert!(!COMBINED.matches(&scopes[1..])); 344 | /// ``` 345 | /// 346 | /// ## Empty 347 | /// 348 | /// ```rust 349 | /// use twitch_oauth2::{validator, Scope}; 350 | /// 351 | /// let scopes: &[Scope] = &[Scope::ChatRead]; 352 | /// let validator = validator!(); 353 | /// assert!(validator.matches(scopes)); 354 | /// ``` 355 | /// 356 | /// ## Invalid examples 357 | /// 358 | /// ### Invalid usage of not(...) 359 | /// 360 | /// ```compile_fail 361 | /// use twitch_oauth2::{Scope, validator}; 362 | /// 363 | /// let scopes: &[Scope] = &[Scope::ChatEdit, Scope::ChatRead]; 364 | /// let validator = validator!(not(Scope::ChatEdit, Scope::ChatRead)); 365 | /// assert!(validator.matches(scopes)); 366 | /// ``` 367 | /// 368 | /// ### Invalid operator 369 | /// 370 | /// ```compile_fail 371 | /// use twitch_oauth2::{Scope, validator}; 372 | /// 373 | /// let scopes: &[Scope] = &[Scope::ChatEdit, Scope::ChatRead]; 374 | /// let validator = validator!(xor(Scope::ChatEdit, Scope::ChatRead)); 375 | /// assert!(validator.matches(scopes)); 376 | /// ``` 377 | #[macro_export] 378 | macro_rules! validator { 379 | ($operator:ident($($scopes:tt)+)) => {{ 380 | $crate::validator_logic!(@$operator $($scopes)*) 381 | }}; 382 | ($scope:expr $(,)?) => {{ 383 | $scope.to_validator() 384 | }}; 385 | ($($all:tt)+) => {{ 386 | $crate::validator_logic!(@all $($all)*) 387 | }}; 388 | () => {{ 389 | $crate::Validator::all_multiple(&[]) 390 | }}; 391 | } 392 | 393 | /// Logical operators for the [validator!] macro. 394 | #[doc(hidden)] 395 | #[macro_export] 396 | macro_rules! validator_logic { 397 | (@all $($scope:tt)+) => {{ 398 | const MULT: &[$crate::Validator] = &$crate::validator_accumulate![@down [] $($scope)*]; 399 | $crate::Validator::all_multiple(MULT) 400 | }}; 401 | (@any $($scope:tt)+) => {{ 402 | const MULT: &[$crate::Validator] = &$crate::validator_accumulate![@down [] $($scope)*]; 403 | $crate::Validator::any_multiple(MULT) 404 | }}; 405 | (@not $($scope:tt)+) => {{ 406 | $crate::validator_logic!(@notend $($scope)*); 407 | const NOT: &[$crate::Validator] = &[$crate::validator!($($scope)*)]; 408 | $crate::Validator::not(&NOT[0]) 409 | }}; 410 | (@notend $e:expr) => {}; 411 | (@notend $e:expr, $($t:tt)*) => {compile_error!("not(...) takes only one argument")}; 412 | (@$operator:ident $($rest:tt)*) => { 413 | compile_error!(concat!("unknown operator `", stringify!($operator), "`, only `all`, `any` and `not` are supported")) 414 | } 415 | } 416 | 417 | /// Accumulator for the [validator!] macro. 418 | // Thanks to danielhenrymantilla, the macro wizard 419 | #[doc(hidden)] 420 | #[macro_export] 421 | macro_rules! validator_accumulate { 422 | // inner operator 423 | (@down 424 | [$($acc:tt)*] 425 | $operator:ident($($all:tt)*) $(, $($rest:tt)* )? 426 | ) => ( 427 | $crate::validator_accumulate![@down 428 | [$($acc)* $crate::validator!($operator($($all)*)),] 429 | $($($rest)*)? 430 | ] 431 | ); 432 | // inner scope 433 | (@down 434 | [$($acc:tt)*] 435 | $scope:expr $(, $($rest:tt)* )? 436 | ) => ( 437 | $crate::validator_accumulate![@down 438 | [$($acc)* $crate::validator!($scope),] 439 | $($($rest)*)? 440 | ] 441 | ); 442 | // nothing left 443 | (@down 444 | [$($output:tt)*] $(,)? 445 | ) => ( 446 | [ $($output)* ] 447 | ); 448 | } 449 | 450 | #[cfg(test)] 451 | mod tests { 452 | use super::*; 453 | use crate::Scope; 454 | 455 | #[test] 456 | fn valid_basic() { 457 | let scopes = &[Scope::ChatEdit, Scope::ChatRead]; 458 | const VALIDATOR: Validator = validator!(Scope::ChatEdit, Scope::ChatRead); 459 | dbg!(&VALIDATOR); 460 | assert!(VALIDATOR.matches(scopes)); 461 | } 462 | 463 | #[test] 464 | fn valid_all() { 465 | let scopes = &[Scope::ChatEdit, Scope::ChatRead]; 466 | const VALIDATOR: Validator = validator!(all(Scope::ChatEdit, Scope::ChatRead)); 467 | dbg!(&VALIDATOR); 468 | assert!(VALIDATOR.matches(scopes)); 469 | } 470 | 471 | #[test] 472 | fn valid_any() { 473 | let scopes = &[Scope::ChatEdit, Scope::ModerationRead]; 474 | const VALIDATOR: Validator = 475 | validator!(Scope::ChatEdit, any(Scope::ChatRead, Scope::ModerationRead)); 476 | dbg!(&VALIDATOR); 477 | assert!(VALIDATOR.matches(scopes)); 478 | } 479 | 480 | #[test] 481 | fn valid_not() { 482 | let scopes = &[Scope::ChannelEditCommercial, Scope::ChatRead]; 483 | const VALIDATOR: Validator = validator!(not(Scope::ChatEdit)); 484 | dbg!(&VALIDATOR); 485 | assert!(VALIDATOR.matches(scopes)); 486 | } 487 | 488 | #[test] 489 | fn valid_strange() { 490 | let scopes = &[Scope::ChatEdit, Scope::ModerationRead, Scope::UserEdit]; 491 | let scopes_1 = &[Scope::ChatEdit, Scope::ChatRead]; 492 | const VALIDATOR: Validator = validator!( 493 | Scope::ChatEdit, 494 | any(Scope::ChatRead, all(Scope::ModerationRead, Scope::UserEdit)) 495 | ); 496 | dbg!(&VALIDATOR); 497 | assert!(VALIDATOR.matches(scopes)); 498 | assert!(VALIDATOR.matches(scopes_1)); 499 | } 500 | #[test] 501 | fn valid_strange_not() { 502 | let scopes = &[Scope::ModerationRead, Scope::UserEdit]; 503 | let scopes_1 = &[Scope::ChatEdit, Scope::ChatRead]; 504 | const VALIDATOR: Validator = validator!( 505 | not(Scope::ChatEdit), 506 | any(Scope::ChatRead, all(Scope::ModerationRead, Scope::UserEdit)) 507 | ); 508 | dbg!(&VALIDATOR); 509 | assert!(VALIDATOR.matches(scopes)); 510 | assert!(!VALIDATOR.matches(scopes_1)); 511 | } 512 | 513 | #[test] 514 | fn missing() { 515 | let scopes = &[Scope::ChatEdit, Scope::ModerationRead]; 516 | const VALIDATOR: Validator = validator!( 517 | Scope::ChatEdit, 518 | any(Scope::ChatRead, all(Scope::ModerationRead, Scope::UserEdit)) 519 | ); 520 | dbg!(&VALIDATOR); 521 | let missing = VALIDATOR.missing(scopes).unwrap(); 522 | dbg!(&missing); 523 | assert_eq!(format!("{}", missing), "(chat:read or user:edit)"); 524 | 525 | const NOT_VALIDATOR: Validator = validator!(all( 526 | not(all(Scope::ChatEdit, Scope::ModerationRead)), // we don't want both of these 527 | Scope::ChatRead, 528 | Scope::UserEdit, 529 | any(Scope::ModerationRead, not(Scope::UserEdit)) // we don't want user:edit or we want moderation:read 530 | )); 531 | let missing = NOT_VALIDATOR.missing(scopes).unwrap(); 532 | dbg!(&missing); 533 | assert_eq!( 534 | format!("{}", missing), 535 | "(not((chat:edit and moderation:read)) and chat:read and user:edit)" 536 | ); 537 | } 538 | 539 | #[test] 540 | fn display() { 541 | const COMPLEX_VALIDATOR: Validator = validator!( 542 | Scope::ChatEdit, 543 | any(Scope::ChatRead, all(Scope::ModerationRead, Scope::UserEdit)) 544 | ); 545 | assert_eq!( 546 | format!("{}", COMPLEX_VALIDATOR), 547 | "(chat:edit and (chat:read or (moderation:read and user:edit)))" 548 | ); 549 | } 550 | } 551 | -------------------------------------------------------------------------------- /src/tokens.rs: -------------------------------------------------------------------------------- 1 | //! Twitch token types 2 | 3 | mod app_access_token; 4 | pub mod errors; 5 | mod user_token; 6 | 7 | pub use app_access_token::AppAccessToken; 8 | use twitch_types::{UserId, UserIdRef, UserName, UserNameRef}; 9 | pub use user_token::{ 10 | DeviceUserTokenBuilder, ImplicitUserTokenBuilder, UserToken, UserTokenBuilder, 11 | }; 12 | 13 | #[cfg(feature = "client")] 14 | use crate::client::Client; 15 | use crate::{id::TwitchTokenErrorResponse, scopes::Scope, RequestParseError}; 16 | 17 | use errors::ValidationError; 18 | #[cfg(feature = "client")] 19 | use errors::{RefreshTokenError, RevokeTokenError}; 20 | 21 | use crate::types::{AccessToken, ClientId}; 22 | use serde_derive::Deserialize; 23 | 24 | #[derive(Clone, Debug, PartialEq, Eq)] 25 | /// Types of bearer tokens 26 | pub enum BearerTokenType { 27 | /// Token for making requests in the context of an authenticated user. 28 | UserToken, 29 | /// Token for server-to-server requests. 30 | /// 31 | /// In some contexts (i.e [EventSub](https://dev.twitch.tv/docs/eventsub)) an App Access Token can be used in the context of users that have authenticated 32 | /// the specific Client ID 33 | AppAccessToken, 34 | } 35 | 36 | /// Trait for twitch tokens to get fields and generalize over [AppAccessToken] and [UserToken] 37 | #[cfg_attr(feature = "client", async_trait::async_trait)] 38 | pub trait TwitchToken { 39 | /// Get the type of token. 40 | fn token_type() -> BearerTokenType; 41 | /// Client ID associated with the token. Twitch requires this in all helix API calls 42 | fn client_id(&self) -> &ClientId; 43 | /// Get the [AccessToken] for authenticating 44 | /// 45 | /// # Example 46 | /// 47 | /// ```rust, no_run 48 | /// # use twitch_oauth2::UserToken; 49 | /// # fn t() -> UserToken {todo!()} 50 | /// # let user_token = t(); 51 | /// use twitch_oauth2::TwitchToken; 52 | /// println!("token: {}", user_token.token().secret()); 53 | /// ``` 54 | fn token(&self) -> &AccessToken; 55 | /// Get the username associated to this token 56 | fn login(&self) -> Option<&UserNameRef>; 57 | /// Get the user id associated to this token 58 | fn user_id(&self) -> Option<&UserIdRef>; 59 | /// Refresh this token, changing the token to a newer one 60 | #[cfg(feature = "client")] 61 | async fn refresh_token<'a, C>( 62 | &mut self, 63 | http_client: &'a C, 64 | ) -> Result<(), RefreshTokenError<::Error>> 65 | where 66 | Self: Sized, 67 | C: Client; 68 | /// Get current lifetime of token. 69 | fn expires_in(&self) -> std::time::Duration; 70 | 71 | /// Returns whether or not the token is expired. 72 | /// 73 | /// ```rust, no_run 74 | /// # use twitch_oauth2::UserToken; 75 | /// # fn t() -> UserToken {todo!()} 76 | /// # #[tokio::main] 77 | /// # async fn run() -> Result<(), Box>{ 78 | /// # let mut user_token = t(); 79 | /// use twitch_oauth2::{UserToken, TwitchToken}; 80 | /// if user_token.is_elapsed() { 81 | /// user_token.refresh_token(&reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build()?).await?; 82 | /// } 83 | /// # Ok(()) } 84 | /// # fn main() {run();} 85 | fn is_elapsed(&self) -> bool { 86 | let exp = self.expires_in(); 87 | exp.as_secs() == 0 && exp.as_nanos() == 0 88 | } 89 | /// Retrieve scopes attached to the token 90 | fn scopes(&self) -> &[Scope]; 91 | /// Validate this token. Should be checked on regularly, according to 92 | /// 93 | /// # Note 94 | /// 95 | /// This will not mutate any current data in the [TwitchToken] 96 | #[cfg(feature = "client")] 97 | async fn validate_token<'a, C>( 98 | &self, 99 | http_client: &'a C, 100 | ) -> Result::Error>> 101 | where 102 | Self: Sized, 103 | C: Client, 104 | { 105 | let token = &self.token(); 106 | token.validate_token(http_client).await 107 | } 108 | 109 | /// Revoke the token. See 110 | #[cfg(feature = "client")] 111 | async fn revoke_token<'a, C>( 112 | self, 113 | http_client: &'a C, 114 | ) -> Result<(), RevokeTokenError<::Error>> 115 | where 116 | Self: Sized, 117 | C: Client, 118 | { 119 | let token = self.token(); 120 | let client_id = self.client_id(); 121 | token.revoke_token(http_client, client_id).await 122 | } 123 | } 124 | 125 | #[cfg_attr(feature = "client", async_trait::async_trait)] 126 | impl TwitchToken for Box { 127 | fn token_type() -> BearerTokenType { T::token_type() } 128 | 129 | fn client_id(&self) -> &ClientId { (**self).client_id() } 130 | 131 | fn token(&self) -> &AccessToken { (**self).token() } 132 | 133 | fn login(&self) -> Option<&UserNameRef> { (**self).login() } 134 | 135 | fn user_id(&self) -> Option<&UserIdRef> { (**self).user_id() } 136 | 137 | #[cfg(feature = "client")] 138 | async fn refresh_token<'a, C>( 139 | &mut self, 140 | http_client: &'a C, 141 | ) -> Result<(), RefreshTokenError<::Error>> 142 | where 143 | Self: Sized, 144 | C: Client, 145 | { 146 | (**self).refresh_token(http_client).await 147 | } 148 | 149 | fn expires_in(&self) -> std::time::Duration { (**self).expires_in() } 150 | 151 | fn scopes(&self) -> &[Scope] { (**self).scopes() } 152 | } 153 | 154 | /// Token validation returned from `https://id.twitch.tv/oauth2/validate` 155 | /// 156 | /// See 157 | #[derive(Debug, Clone, Deserialize)] 158 | pub struct ValidatedToken { 159 | /// Client ID associated with the token. Twitch requires this in all helix API calls 160 | pub client_id: ClientId, 161 | /// Username associated with the token 162 | pub login: Option, 163 | /// User ID associated with the token 164 | pub user_id: Option, 165 | /// Scopes attached to the token. 166 | pub scopes: Option>, 167 | /// Lifetime of the token 168 | #[serde(deserialize_with = "expires_in")] 169 | pub expires_in: Option, 170 | } 171 | 172 | fn expires_in<'a, D: serde::de::Deserializer<'a>>( 173 | d: D, 174 | ) -> Result, D::Error> { 175 | use serde::Deserialize; 176 | let num = u64::deserialize(d)?; 177 | if num == 0 { 178 | Ok(None) 179 | } else { 180 | Ok(Some(std::time::Duration::from_secs(num))) 181 | } 182 | } 183 | 184 | impl ValidatedToken { 185 | /// Assemble a a validated token from a response. 186 | /// 187 | /// Get the request that generates this response with [`AccessToken::validate_token_request`][crate::types::AccessTokenRef::validate_token_request] 188 | pub fn from_response>( 189 | response: &http::Response, 190 | ) -> Result> { 191 | match crate::parse_response(response) { 192 | Ok(ok) => Ok(ok), 193 | Err(err) => match err { 194 | RequestParseError::TwitchError(TwitchTokenErrorResponse { status, .. }) 195 | if status == http::StatusCode::UNAUTHORIZED => 196 | { 197 | Err(ValidationError::NotAuthorized) 198 | } 199 | err => Err(err.into()), 200 | }, 201 | } 202 | } 203 | } 204 | 205 | #[cfg(test)] 206 | mod tests { 207 | use crate::ValidatedToken; 208 | 209 | use super::errors::ValidationError; 210 | 211 | #[test] 212 | fn validated_token() { 213 | let body = br#" 214 | { 215 | "client_id": "wbmytr93xzw8zbg0p1izqyzzc5mbiz", 216 | "login": "twitchdev", 217 | "scopes": [ 218 | "channel:read:subscriptions" 219 | ], 220 | "user_id": "141981764", 221 | "expires_in": 5520838 222 | } 223 | "#; 224 | let response = http::Response::builder().status(200).body(body).unwrap(); 225 | ValidatedToken::from_response(&response).unwrap(); 226 | } 227 | 228 | #[test] 229 | fn validated_non_expiring_token() { 230 | let body = br#" 231 | { 232 | "client_id": "wbmytr93xzw8zbg0p1izqyzzc5mbiz", 233 | "login": "twitchdev", 234 | "scopes": [ 235 | "channel:read:subscriptions" 236 | ], 237 | "user_id": "141981764", 238 | "expires_in": 0 239 | } 240 | "#; 241 | let response = http::Response::builder().status(200).body(body).unwrap(); 242 | let token = ValidatedToken::from_response(&response).unwrap(); 243 | assert!(token.expires_in.is_none()); 244 | } 245 | 246 | #[test] 247 | fn validated_error_response() { 248 | let body = br#" 249 | { 250 | "status": 401, 251 | "message": "missing authorization token", 252 | } 253 | "#; 254 | let response = http::Response::builder().status(401).body(body).unwrap(); 255 | let error = ValidatedToken::from_response(&response).unwrap_err(); 256 | assert!(matches!(error, ValidationError::RequestParseError(_))) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/tokens/app_access_token.rs: -------------------------------------------------------------------------------- 1 | use twitch_types::{UserIdRef, UserNameRef}; 2 | 3 | #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 4 | use std::time::Instant; 5 | 6 | #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 | use web_time::Instant; 8 | 9 | #[cfg(feature = "client")] 10 | use super::errors::{AppAccessTokenError, ValidationError}; 11 | #[cfg(feature = "client")] 12 | use crate::client::Client; 13 | #[cfg(feature = "client")] 14 | use crate::tokens::errors::RefreshTokenError; 15 | use crate::tokens::{Scope, TwitchToken}; 16 | use crate::{ 17 | types::{AccessToken, ClientId, ClientSecret, RefreshToken}, 18 | ClientIdRef, ClientSecretRef, 19 | }; 20 | 21 | /// An App Access Token from the [OAuth client credentials flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow) 22 | /// 23 | /// Used for server-to-server requests. Use [`UserToken`](super::UserToken) for requests that need to be in the context of an authenticated user. 24 | /// 25 | /// In some contexts (i.e [EventSub](https://dev.twitch.tv/docs/eventsub)) an App Access Token can be used in the context of users that have authenticated 26 | /// the specific Client ID 27 | #[derive(Clone)] 28 | pub struct AppAccessToken { 29 | /// The access token used to authenticate requests with 30 | pub access_token: AccessToken, 31 | /// The refresh token used to extend the life of this user token 32 | pub refresh_token: Option, 33 | /// Expiration from when the response was generated. 34 | expires_in: std::time::Duration, 35 | /// When this struct was created, not when token was created. 36 | struct_created: Instant, 37 | client_id: ClientId, 38 | client_secret: ClientSecret, 39 | scopes: Vec, 40 | } 41 | 42 | impl std::fmt::Debug for AppAccessToken { 43 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 | f.debug_struct("AppAccessToken") 45 | .field("access_token", &self.access_token) 46 | .field("refresh_token", &self.refresh_token) 47 | .field("client_id", &self.client_id) 48 | .field("client_secret", &self.client_secret) 49 | .field("expires_in", &self.expires_in()) 50 | .field("scopes", &self.scopes) 51 | .finish() 52 | } 53 | } 54 | 55 | #[cfg_attr(feature = "client", async_trait::async_trait)] 56 | impl TwitchToken for AppAccessToken { 57 | fn token_type() -> super::BearerTokenType { super::BearerTokenType::AppAccessToken } 58 | 59 | fn client_id(&self) -> &ClientId { &self.client_id } 60 | 61 | fn token(&self) -> &AccessToken { &self.access_token } 62 | 63 | fn login(&self) -> Option<&UserNameRef> { None } 64 | 65 | fn user_id(&self) -> Option<&UserIdRef> { None } 66 | 67 | #[cfg(feature = "client")] 68 | async fn refresh_token<'a, C>( 69 | &mut self, 70 | http_client: &'a C, 71 | ) -> Result<(), RefreshTokenError<::Error>> 72 | where 73 | C: Client, 74 | { 75 | let (access_token, expires_in, refresh_token) = 76 | if let Some(token) = self.refresh_token.take() { 77 | token 78 | .refresh_token(http_client, &self.client_id, Some(&self.client_secret)) 79 | .await? 80 | } else { 81 | return Err(RefreshTokenError::NoRefreshToken); 82 | }; 83 | self.access_token = access_token; 84 | self.expires_in = expires_in; 85 | self.refresh_token = refresh_token; 86 | self.struct_created = Instant::now(); 87 | Ok(()) 88 | } 89 | 90 | fn expires_in(&self) -> std::time::Duration { 91 | self.expires_in 92 | .checked_sub(self.struct_created.elapsed()) 93 | .unwrap_or_default() 94 | } 95 | 96 | fn scopes(&self) -> &[Scope] { self.scopes.as_slice() } 97 | } 98 | 99 | impl AppAccessToken { 100 | /// Assemble token without checks. 101 | /// 102 | /// This is useful if you already have an app access token and want to use it with this library. Be careful however, 103 | /// as this function does not check if the token is valid or expired, nor if it is an `app access token` or `user token`. 104 | /// 105 | /// # Notes 106 | /// 107 | /// If `expires_in` is `None`, we'll assume `token.is_elapsed() == true` 108 | pub fn from_existing_unchecked( 109 | access_token: AccessToken, 110 | refresh_token: impl Into>, 111 | client_id: impl Into, 112 | client_secret: impl Into, 113 | scopes: Option>, 114 | expires_in: Option, 115 | ) -> AppAccessToken { 116 | AppAccessToken { 117 | access_token, 118 | refresh_token: refresh_token.into(), 119 | client_id: client_id.into(), 120 | client_secret: client_secret.into(), 121 | expires_in: expires_in.unwrap_or_default(), 122 | struct_created: Instant::now(), 123 | scopes: scopes.unwrap_or_default(), 124 | } 125 | } 126 | 127 | /// Assemble token and validate it. Retrieves [`client_id`](TwitchToken::client_id) and [`scopes`](TwitchToken::scopes). 128 | #[cfg(feature = "client")] 129 | pub async fn from_existing( 130 | http_client: &C, 131 | access_token: AccessToken, 132 | refresh_token: impl Into>, 133 | client_secret: ClientSecret, 134 | ) -> Result::Error>> 135 | where 136 | C: Client, 137 | { 138 | let token = access_token; 139 | let validated = token.validate_token(http_client).await?; 140 | if validated.user_id.is_some() { 141 | return Err(ValidationError::InvalidToken( 142 | "expected an app access token, got a user access token", 143 | )); 144 | } 145 | Ok(Self::from_existing_unchecked( 146 | token, 147 | refresh_token.into(), 148 | validated.client_id, 149 | client_secret, 150 | validated.scopes, 151 | validated.expires_in, 152 | )) 153 | } 154 | 155 | /// Assemble token from twitch responses. 156 | pub fn from_response( 157 | response: crate::id::TwitchTokenResponse, 158 | client_id: impl Into, 159 | client_secret: impl Into, 160 | ) -> AppAccessToken { 161 | let expires_in = response.expires_in(); 162 | AppAccessToken::from_existing_unchecked( 163 | response.access_token, 164 | response.refresh_token, 165 | client_id.into(), 166 | client_secret, 167 | response.scopes, 168 | expires_in, 169 | ) 170 | } 171 | 172 | /// Generate an app access token via [OAuth client credentials flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow) 173 | /// 174 | /// # Examples 175 | /// 176 | /// ```rust,no_run 177 | /// use twitch_oauth2::{AccessToken, AppAccessToken}; 178 | /// // Make sure you enable the feature "reqwest" for twitch_oauth2 if you want to use reqwest 179 | /// # async {let client = twitch_oauth2::client::DummyClient; stringify!( 180 | /// let client = reqwest::Client::builder() 181 | /// .redirect(reqwest::redirect::Policy::none()) 182 | /// .build()?; 183 | /// # ); 184 | /// let token = AppAccessToken::get_app_access_token( 185 | /// &client, 186 | /// "my_client_id".into(), 187 | /// "my_client_secret".into(), 188 | /// vec![], // scopes 189 | /// ) 190 | /// .await?; 191 | /// # Ok::<(), Box>(())}; 192 | /// ``` 193 | #[cfg(feature = "client")] 194 | pub async fn get_app_access_token( 195 | http_client: &C, 196 | client_id: ClientId, 197 | client_secret: ClientSecret, 198 | scopes: Vec, 199 | ) -> Result::Error>> 200 | where 201 | C: Client, 202 | { 203 | let req = Self::get_app_access_token_request(&client_id, &client_secret, scopes); 204 | 205 | let resp = http_client 206 | .req(req) 207 | .await 208 | .map_err(AppAccessTokenError::Request)?; 209 | 210 | let response = crate::id::TwitchTokenResponse::from_response(&resp)?; 211 | let app_access = AppAccessToken::from_response(response, client_id, client_secret); 212 | 213 | Ok(app_access) 214 | } 215 | 216 | /// Get the request for getting an app access token. 217 | /// 218 | /// Parse with [TwitchTokenResponse::from_response](crate::id::TwitchTokenResponse::from_response) and [AppAccessToken::from_response] 219 | pub fn get_app_access_token_request( 220 | client_id: &ClientIdRef, 221 | client_secret: &ClientSecretRef, 222 | scopes: Vec, 223 | ) -> http::Request> { 224 | use http::{HeaderMap, Method}; 225 | use std::collections::HashMap; 226 | let scope: String = scopes 227 | .iter() 228 | .map(|s| s.to_string()) 229 | .collect::>() 230 | .join(" "); 231 | let mut params = HashMap::new(); 232 | params.insert("client_id", client_id.as_str()); 233 | params.insert("client_secret", client_secret.secret()); 234 | params.insert("grant_type", "client_credentials"); 235 | params.insert("scope", &scope); 236 | 237 | crate::construct_request( 238 | &crate::TOKEN_URL, 239 | ¶ms, 240 | HeaderMap::new(), 241 | Method::POST, 242 | vec![], 243 | ) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/tokens/errors.rs: -------------------------------------------------------------------------------- 1 | //! Errors 2 | 3 | /// General errors for talking with twitch, used in [`AppAccessToken::get_app_access_token`](crate::tokens::AppAccessToken::get_app_access_token) 4 | #[allow(missing_docs)] 5 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 6 | #[cfg(feature = "client")] 7 | #[non_exhaustive] 8 | pub enum AppAccessTokenError { 9 | /// request for token failed 10 | Request(#[source] RE), 11 | /// could not parse response when getting app access token 12 | RequestParseError(#[from] crate::RequestParseError), 13 | } 14 | 15 | /// Errors for [AccessToken::validate_token][crate::AccessTokenRef::validate_token] and [UserToken::from_response][crate::tokens::UserToken::from_response] 16 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 17 | #[non_exhaustive] 18 | pub enum ValidationError { 19 | /// token is not authorized for use 20 | NotAuthorized, 21 | /// could not parse response when validating token 22 | RequestParseError(#[from] crate::RequestParseError), 23 | /// failed to request validation 24 | Request(#[source] RE), 25 | /// given token is not of the correct token type: {0} 26 | InvalidToken(&'static str), 27 | } 28 | 29 | impl ValidationError { 30 | /// Convert this error from a infallible to another 31 | pub fn into_other(self) -> ValidationError { 32 | match self { 33 | ValidationError::NotAuthorized => ValidationError::NotAuthorized, 34 | ValidationError::RequestParseError(e) => ValidationError::RequestParseError(e), 35 | ValidationError::InvalidToken(s) => ValidationError::InvalidToken(s), 36 | ValidationError::Request(_) => unreachable!(), 37 | } 38 | } 39 | } 40 | 41 | /// Errors for [UserToken::from_refresh_token][crate::UserToken::from_refresh_token] and [UserToken::UserToken::from_existing_or_refresh_token][crate::UserToken::from_existing_or_refresh_token] 42 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 43 | #[non_exhaustive] 44 | #[cfg(feature = "client")] 45 | pub enum RetrieveTokenError { 46 | /// could not validate token 47 | ValidationError(#[from] ValidationError), 48 | /// could not refresh token 49 | RefreshTokenError(#[from] RefreshTokenError), 50 | } 51 | 52 | /// Errors for [AccessToken::revoke_token][crate::AccessTokenRef::revoke_token] 53 | #[allow(missing_docs)] 54 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 55 | #[non_exhaustive] 56 | #[cfg(feature = "client")] 57 | pub enum RevokeTokenError { 58 | /// could not parse response when revoking token 59 | RequestParseError(#[from] crate::RequestParseError), 60 | /// failed to do revokation 61 | RequestError(#[source] RE), 62 | } 63 | 64 | /// Errors for [TwitchToken::refresh_token][crate::TwitchToken::refresh_token] 65 | #[allow(missing_docs)] 66 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 67 | #[non_exhaustive] 68 | #[cfg(feature = "client")] 69 | pub enum RefreshTokenError { 70 | /// request when refreshing token failed 71 | RequestError(#[source] RE), 72 | /// could not parse response when refreshing token. 73 | RequestParseError(#[from] crate::RequestParseError), 74 | /// no client secret found 75 | // TODO: Include this in doc 76 | // A client secret is needed to request a refreshed token. 77 | NoClientSecretFound, 78 | /// no refresh token found 79 | NoRefreshToken, 80 | /// no expiration found on new token 81 | NoExpiration, 82 | } 83 | 84 | /// Errors for [`UserTokenBuilder::get_user_token`](crate::tokens::UserTokenBuilder::get_user_token) and [`UserToken::mock_token`](crate::tokens::UserToken::mock_token) 85 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 86 | #[non_exhaustive] 87 | #[cfg(feature = "client")] 88 | pub enum UserTokenExchangeError { 89 | /// request for user token failed 90 | RequestError(#[source] RE), 91 | /// could not parse response when getting user token 92 | RequestParseError(#[from] crate::RequestParseError), 93 | /// state CSRF does not match when exchanging user token 94 | StateMismatch, 95 | /// could not get validation for user token 96 | ValidationError(#[from] ValidationError), 97 | } 98 | 99 | /// Errors for [ImplicitUserTokenBuilder::get_user_token][crate::tokens::ImplicitUserTokenBuilder::get_user_token] 100 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 101 | #[non_exhaustive] 102 | #[cfg(feature = "client")] 103 | pub enum ImplicitUserTokenExchangeError { 104 | // FIXME: should be TwitchTokenErrorResponse 105 | /// twitch returned an error: {error:?} - {description:?} 106 | TwitchError { 107 | /// Error type 108 | error: Option, 109 | /// Description of error 110 | description: Option, 111 | }, 112 | /// state CSRF does not match 113 | StateMismatch, 114 | /// could not get validation for token 115 | ValidationError(#[from] ValidationError), 116 | } 117 | /// Errors for [`DeviceUserTokenBuilder`][crate::tokens::DeviceUserTokenBuilder] 118 | #[derive(thiserror::Error, Debug, displaydoc::Display)] 119 | #[non_exhaustive] 120 | #[cfg(feature = "client")] 121 | pub enum DeviceUserTokenExchangeError { 122 | /// request for exchange token failed 123 | DeviceExchangeRequestError(#[source] RE), 124 | /// could not parse response when getting exchange token 125 | DeviceExchangeParseError(#[source] crate::RequestParseError), 126 | /// request for user token failed 127 | TokenRequestError(#[source] RE), 128 | /// could not parse response when getting user token 129 | TokenParseError(#[source] crate::RequestParseError), 130 | /// could not get validation for token 131 | ValidationError(#[from] ValidationError), 132 | /// no device code found, exchange not started 133 | NoDeviceCode, 134 | /// the device code has expired 135 | Expired, 136 | } 137 | 138 | #[cfg(feature = "client")] 139 | impl DeviceUserTokenExchangeError { 140 | /// Check if the error is due to the authorization request being pending 141 | pub fn is_pending(&self) -> bool { 142 | matches!(self, DeviceUserTokenExchangeError::TokenParseError( 143 | crate::RequestParseError::TwitchError(crate::id::TwitchTokenErrorResponse { 144 | message, 145 | .. 146 | }), 147 | ) if message == "authorization_pending") 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/tokens/user_token.rs: -------------------------------------------------------------------------------- 1 | use twitch_types::{UserId, UserIdRef, UserName, UserNameRef}; 2 | 3 | #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 4 | use std::time::Instant; 5 | 6 | #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 | use web_time::Instant; 8 | 9 | use super::errors::ValidationError; 10 | #[cfg(feature = "client")] 11 | use super::errors::{ 12 | DeviceUserTokenExchangeError, ImplicitUserTokenExchangeError, RefreshTokenError, 13 | RetrieveTokenError, UserTokenExchangeError, 14 | }; 15 | #[cfg(feature = "client")] 16 | use crate::client::Client; 17 | 18 | use crate::tokens::{Scope, TwitchToken}; 19 | use crate::{ClientSecret, ValidatedToken}; 20 | 21 | use crate::types::{AccessToken, ClientId, RefreshToken}; 22 | 23 | #[allow(clippy::too_long_first_doc_paragraph)] // clippy bug - https://github.com/rust-lang/rust-clippy/issues/13315 24 | /// An User Token from the [OAuth implicit code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow) or [OAuth authorization code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow) 25 | /// 26 | /// Used for requests that need an authenticated user. See also [`AppAccessToken`](super::AppAccessToken) 27 | /// 28 | /// See [`UserToken::builder`](UserTokenBuilder::new) for authenticating the user using the `OAuth authorization code flow`. 29 | #[derive(Clone)] 30 | pub struct UserToken { 31 | /// The access token used to authenticate requests with 32 | pub access_token: AccessToken, 33 | client_id: ClientId, 34 | client_secret: Option, 35 | /// Username of user associated with this token 36 | pub login: UserName, 37 | /// User ID of the user associated with this token 38 | pub user_id: UserId, 39 | /// The refresh token used to extend the life of this user token 40 | pub refresh_token: Option, 41 | /// Expiration from when the response was generated. 42 | expires_in: std::time::Duration, 43 | /// When this struct was created, not when token was created. 44 | struct_created: Instant, 45 | scopes: Vec, 46 | /// Token will never expire 47 | /// 48 | /// This is only true for old client IDs, like and others 49 | pub never_expiring: bool, 50 | } 51 | 52 | impl std::fmt::Debug for UserToken { 53 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 54 | f.debug_struct("UserToken") 55 | .field("access_token", &self.access_token) 56 | .field("client_id", &self.client_id) 57 | .field("client_secret", &self.client_secret) 58 | .field("login", &self.login) 59 | .field("user_id", &self.user_id) 60 | .field("refresh_token", &self.refresh_token) 61 | .field("expires_in", &self.expires_in()) 62 | .field("scopes", &self.scopes) 63 | .finish() 64 | } 65 | } 66 | 67 | impl UserToken { 68 | /// Create a new token 69 | /// 70 | /// See [`UserToken::from_token`] and [`UserToken::from_existing`] for more ways to create a [`UserToken`] 71 | pub fn new( 72 | access_token: AccessToken, 73 | refresh_token: Option, 74 | validated: ValidatedToken, 75 | client_secret: impl Into>, 76 | ) -> Result> { 77 | Ok(UserToken::from_existing_unchecked( 78 | access_token, 79 | refresh_token, 80 | validated.client_id, 81 | client_secret, 82 | validated.login.ok_or(ValidationError::InvalidToken( 83 | "validation did not include a `login`, token might be an app access token", 84 | ))?, 85 | validated.user_id.ok_or(ValidationError::InvalidToken( 86 | "validation did not include a `user_id`, token might be an app access token", 87 | ))?, 88 | validated.scopes, 89 | validated.expires_in, 90 | )) 91 | } 92 | 93 | /// Create a [UserToken] from an existing active user token. Retrieves [`login`](TwitchToken::login), [`client_id`](TwitchToken::client_id) and [`scopes`](TwitchToken::scopes) 94 | /// 95 | /// If the token is already expired, this function will fail to produce a [`UserToken`] and return [`ValidationError::NotAuthorized`] 96 | /// 97 | /// # Examples 98 | /// 99 | /// ```rust,no_run 100 | /// use twitch_oauth2::{AccessToken, UserToken}; 101 | /// // Make sure you enable the feature "reqwest" for twitch_oauth2 if you want to use reqwest 102 | /// # async {let client = twitch_oauth2::client::DummyClient; stringify!( 103 | /// let client = reqwest::Client::builder() 104 | /// .redirect(reqwest::redirect::Policy::none()) 105 | /// .build()?; 106 | /// # ); 107 | /// let token = UserToken::from_token(&client, AccessToken::from("my_access_token")).await?; 108 | /// # Ok::<(), Box>(())}; 109 | /// ``` 110 | #[cfg(feature = "client")] 111 | pub async fn from_token( 112 | http_client: &C, 113 | access_token: AccessToken, 114 | ) -> Result::Error>> 115 | where 116 | C: Client, 117 | { 118 | Self::from_existing(http_client, access_token, None, None).await 119 | } 120 | 121 | /// Creates a [UserToken] using a refresh token. Retrieves the [`login`](TwitchToken::login) and [`scopes`](TwitchToken::scopes). 122 | /// 123 | /// If an active user token is associated with the provided refresh token, this function will invalidate that existing user token. 124 | #[cfg(feature = "client")] 125 | pub async fn from_refresh_token( 126 | http_client: &C, 127 | refresh_token: RefreshToken, 128 | client_id: ClientId, 129 | client_secret: impl Into>, 130 | ) -> Result::Error>> 131 | where 132 | C: Client, 133 | { 134 | let client_secret: Option = client_secret.into(); 135 | let (access_token, _, refresh_token) = refresh_token 136 | .refresh_token(http_client, &client_id, client_secret.as_ref()) 137 | .await?; 138 | Self::from_existing(http_client, access_token, refresh_token, client_secret) 139 | .await 140 | .map_err(Into::into) 141 | } 142 | 143 | /// Create a [UserToken] from an existing active user token. Retrieves [`login`](TwitchToken::login) and [`scopes`](TwitchToken::scopes) 144 | /// 145 | /// If the token is already expired, this function will fail to produce a [`UserToken`] and return [`ValidationError::NotAuthorized`]. 146 | /// If you have a refresh token, you can use [`UserToken::from_refresh_token`] to refresh the token if was expired. 147 | /// 148 | /// Consider using [`UserToken::from_existing_or_refresh_token`] to automatically refresh the token if it is expired. 149 | /// 150 | /// # Examples 151 | /// 152 | /// ```rust,no_run 153 | /// use twitch_oauth2::{AccessToken, ClientSecret, RefreshToken, UserToken}; 154 | /// // Make sure you enable the feature "reqwest" for twitch_oauth2 if you want to use reqwest 155 | /// # async {let client = twitch_oauth2::client::DummyClient; stringify!( 156 | /// let client = reqwest::Client::builder() 157 | /// .redirect(reqwest::redirect::Policy::none()) 158 | /// .build()?; 159 | /// # ); 160 | /// let token = UserToken::from_existing( 161 | /// &client, 162 | /// AccessToken::from("my_access_token"), 163 | /// RefreshToken::from("my_refresh_token"), 164 | /// ClientSecret::from("my_client_secret"), 165 | /// ) 166 | /// .await?; 167 | /// # Ok::<(), Box>(())}; 168 | /// ``` 169 | #[cfg(feature = "client")] 170 | pub async fn from_existing( 171 | http_client: &C, 172 | access_token: AccessToken, 173 | refresh_token: impl Into>, 174 | client_secret: impl Into>, 175 | ) -> Result::Error>> 176 | where 177 | C: Client, 178 | { 179 | let validated = access_token.validate_token(http_client).await?; 180 | Self::new(access_token, refresh_token.into(), validated, client_secret) 181 | .map_err(|e| e.into_other()) 182 | } 183 | 184 | /// Create a [UserToken] from an existing active user token or refresh token if the access token is expired. Retrieves [`login`](TwitchToken::login), [`client_id`](TwitchToken::client_id) and [`scopes`](TwitchToken::scopes). 185 | /// 186 | /// # Examples 187 | /// 188 | /// ```rust,no_run 189 | /// use twitch_oauth2::{AccessToken, ClientId, ClientSecret, RefreshToken, UserToken}; 190 | /// // Make sure you enable the feature "reqwest" for twitch_oauth2 if you want to use reqwest 191 | /// # async {let client = twitch_oauth2::client::DummyClient; stringify!( 192 | /// let client = reqwest::Client::builder() 193 | /// .redirect(reqwest::redirect::Policy::none()) 194 | /// .build()?; 195 | /// # ); 196 | /// let token = UserToken::from_existing_or_refresh_token( 197 | /// &client, 198 | /// AccessToken::from("my_access_token"), 199 | /// RefreshToken::from("my_refresh_token"), 200 | /// ClientId::from("my_client_id"), 201 | /// ClientSecret::from("my_optional_client_secret"), 202 | /// ).await?; 203 | /// # Ok::<(), Box>(())}; 204 | #[cfg(feature = "client")] 205 | pub async fn from_existing_or_refresh_token( 206 | http_client: &C, 207 | access_token: AccessToken, 208 | refresh_token: RefreshToken, 209 | client_id: ClientId, 210 | client_secret: impl Into>, 211 | ) -> Result::Error>> 212 | where 213 | C: Client, 214 | { 215 | match access_token.validate_token(http_client).await { 216 | Ok(v) => Self::new(access_token, Some(refresh_token), v, client_secret) 217 | .map_err(|e| e.into_other().into()), 218 | Err(ValidationError::NotAuthorized) => { 219 | Self::from_refresh_token(http_client, refresh_token, client_id, client_secret).await 220 | } 221 | Err(e) => return Err(e.into()), 222 | } 223 | } 224 | 225 | /// Assemble token without checks. 226 | /// 227 | /// # Notes 228 | /// 229 | /// If `expires_in` is `None`, we'll assume [`token.is_elapsed`](TwitchToken::is_elapsed) is always false 230 | #[allow(clippy::too_many_arguments)] 231 | pub fn from_existing_unchecked( 232 | access_token: impl Into, 233 | refresh_token: impl Into>, 234 | client_id: impl Into, 235 | client_secret: impl Into>, 236 | login: UserName, 237 | user_id: UserId, 238 | scopes: Option>, 239 | expires_in: Option, 240 | ) -> UserToken { 241 | UserToken { 242 | access_token: access_token.into(), 243 | client_id: client_id.into(), 244 | client_secret: client_secret.into(), 245 | login, 246 | user_id, 247 | refresh_token: refresh_token.into(), 248 | expires_in: expires_in.unwrap_or(std::time::Duration::MAX), 249 | struct_created: Instant::now(), 250 | scopes: scopes.unwrap_or_default(), 251 | never_expiring: expires_in.is_none(), 252 | } 253 | } 254 | 255 | /// Assemble token from twitch responses. 256 | pub fn from_response( 257 | response: crate::id::TwitchTokenResponse, 258 | validated: ValidatedToken, 259 | client_secret: impl Into>, 260 | ) -> Result> { 261 | Self::new( 262 | response.access_token, 263 | response.refresh_token, 264 | validated, 265 | client_secret, 266 | ) 267 | } 268 | 269 | #[doc(hidden)] 270 | /// Returns true if this token is never expiring. 271 | /// 272 | /// Hidden because it's not expected to be used. 273 | pub fn never_expires(&self) -> bool { self.never_expiring } 274 | 275 | /// Create a [`UserTokenBuilder`] to get a token with the [OAuth Authorization Code](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow) 276 | pub fn builder( 277 | client_id: ClientId, 278 | client_secret: ClientSecret, 279 | // FIXME: Braid or string or this? 280 | redirect_url: url::Url, 281 | ) -> UserTokenBuilder { 282 | UserTokenBuilder::new(client_id, client_secret, redirect_url) 283 | } 284 | 285 | /// Generate a user token from [mock-api](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md#auth-namespace) 286 | /// 287 | /// # Examples 288 | /// 289 | /// ```rust,no_run 290 | /// # #[tokio::main] 291 | /// # async fn run() -> Result<(), Box>{ 292 | /// let token = twitch_oauth2::UserToken::mock_token( 293 | /// &reqwest::Client::builder() 294 | /// .redirect(reqwest::redirect::Policy::none()) 295 | /// .build()?, 296 | /// "mockclientid".into(), 297 | /// "mockclientsecret".into(), 298 | /// "user_id", 299 | /// vec![], 300 | /// ) 301 | /// .await?; 302 | /// # Ok(())} 303 | /// # fn main() {run();} 304 | /// ``` 305 | #[cfg(all(feature = "mock_api", feature = "client"))] 306 | pub async fn mock_token( 307 | http_client: &C, 308 | client_id: ClientId, 309 | client_secret: ClientSecret, 310 | user_id: impl AsRef, 311 | scopes: Vec, 312 | ) -> Result::Error>> 313 | where 314 | C: Client, 315 | { 316 | use http::{HeaderMap, Method}; 317 | use std::collections::HashMap; 318 | 319 | let user_id = user_id.as_ref(); 320 | let scope_str = scopes.as_slice().join(" "); 321 | let mut params = HashMap::new(); 322 | params.insert("client_id", client_id.as_str()); 323 | params.insert("client_secret", client_secret.secret()); 324 | params.insert("grant_type", "user_token"); 325 | params.insert("scope", &scope_str); 326 | params.insert("user_id", user_id); 327 | 328 | let req = crate::construct_request( 329 | &crate::AUTH_URL, 330 | ¶ms, 331 | HeaderMap::new(), 332 | Method::POST, 333 | vec![], 334 | ); 335 | 336 | let resp = http_client 337 | .req(req) 338 | .await 339 | .map_err(UserTokenExchangeError::RequestError)?; 340 | let response = crate::id::TwitchTokenResponse::from_response(&resp)?; 341 | 342 | UserToken::from_existing( 343 | http_client, 344 | response.access_token, 345 | response.refresh_token, 346 | client_secret, 347 | ) 348 | .await 349 | .map_err(Into::into) 350 | } 351 | 352 | /// Set the client secret 353 | pub fn set_secret(&mut self, secret: Option) { self.client_secret = secret } 354 | } 355 | 356 | #[cfg_attr(feature = "client", async_trait::async_trait)] 357 | impl TwitchToken for UserToken { 358 | fn token_type() -> super::BearerTokenType { super::BearerTokenType::UserToken } 359 | 360 | fn client_id(&self) -> &ClientId { &self.client_id } 361 | 362 | fn token(&self) -> &AccessToken { &self.access_token } 363 | 364 | fn login(&self) -> Option<&UserNameRef> { Some(&self.login) } 365 | 366 | fn user_id(&self) -> Option<&UserIdRef> { Some(&self.user_id) } 367 | 368 | #[cfg(feature = "client")] 369 | async fn refresh_token<'a, C>( 370 | &mut self, 371 | http_client: &'a C, 372 | ) -> Result<(), RefreshTokenError<::Error>> 373 | where 374 | Self: Sized, 375 | C: Client, 376 | { 377 | let (access_token, expires, refresh_token) = if let Some(token) = self.refresh_token.take() 378 | { 379 | token 380 | .refresh_token(http_client, &self.client_id, self.client_secret.as_ref()) 381 | .await? 382 | } else { 383 | return Err(RefreshTokenError::NoRefreshToken); 384 | }; 385 | self.access_token = access_token; 386 | self.expires_in = expires; 387 | self.refresh_token = refresh_token; 388 | self.struct_created = Instant::now(); 389 | Ok(()) 390 | } 391 | 392 | fn expires_in(&self) -> std::time::Duration { 393 | if !self.never_expiring { 394 | self.expires_in 395 | .checked_sub(self.struct_created.elapsed()) 396 | .unwrap_or_default() 397 | } else { 398 | // We don't return an option here because it's not expected to use this if the token is known to be unexpiring. 399 | std::time::Duration::MAX 400 | } 401 | } 402 | 403 | fn scopes(&self) -> &[Scope] { self.scopes.as_slice() } 404 | } 405 | 406 | /// Builder for [OAuth authorization code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow) 407 | /// 408 | /// See [`ImplicitUserTokenBuilder`] for the [OAuth implicit code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow) (does not require Client Secret) 409 | /// 410 | /// # Examples 411 | /// 412 | /// See also [the auth flow example](https://github.com/twitch-rs/twitch_oauth2/blob/main/examples/auth_flow.rs) 413 | /// 414 | /// To generate a user token with this auth flow, you need to: 415 | /// 416 | /// 1. Initialize the [`UserTokenBuilder`] with [`UserTokenBuilder::new`](UserTokenBuilder::new), providing your client id, client secret, and a redirect URL. 417 | /// Use [`set_scopes(vec![])`](UserTokenBuilder::set_scopes) to add any necessary scopes to the request. You can also use [`force_verify(true)`](UserTokenBuilder::force_verify) to force the user to 418 | /// re-authorize your app’s access to their resources. 419 | /// 420 | /// Make sure you've added the redirect URL to the app settings on [the Twitch Developer Console](https://dev.twitch.tv/console). 421 | /// 422 | /// ```rust 423 | /// use twitch_oauth2::{id::TwitchTokenResponse, tokens::UserTokenBuilder, Scope}; 424 | /// use url::Url; 425 | /// 426 | /// // This is the URL the user will be redirected to after authorizing your application 427 | /// let redirect_url = Url::parse("http://localhost/twitch/register")?; 428 | /// let mut builder = UserTokenBuilder::new("myclientid", "myclientsecret", redirect_url); 429 | /// builder = builder.set_scopes(vec![Scope::ChatRead, Scope::ChatEdit]); 430 | /// builder = builder.force_verify(true); // Defaults to false 431 | /// # Ok::<(), Box>(()) 432 | /// ``` 433 | /// 434 | /// 2. Generate a URL for the user to visit using [`generate_url()`](UserTokenBuilder::generate_url). This method also returns a CSRF token that you need to save for later validation. 435 | /// 436 | /// ```rust 437 | /// # use twitch_oauth2::{id::TwitchTokenResponse, tokens::UserTokenBuilder, Scope}; 438 | /// # use url::Url; 439 | /// # let redirect_url = Url::parse("http://localhost/twitch/register")?; 440 | /// # let mut builder = UserTokenBuilder::new("myclientid", "myclientsecret", redirect_url); 441 | /// let (url, csrf_token) = builder.generate_url(); 442 | /// // Make your user navigate to this URL, for example 443 | /// println!("Visit this URL to authorize Twitch access: {}", url); 444 | /// # Ok::<(), Box>(()) 445 | /// ``` 446 | /// 447 | /// 3. Have the user visit the generated URL. They will be asked to authorize your application if they haven't previously done so 448 | /// or if you've set [`force_verify`](UserTokenBuilder::force_verify) to `true`. 449 | /// 450 | /// You can do this by providing the link in [a web page](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a), have the user [be directed](https://developer.mozilla.org/en-US/docs/Web/API/Location/assign), 451 | /// the console, or by [opening it](https://docs.rs/webbrowser/0.8.10/webbrowser/) in a browser. 452 | /// 453 | /// If this is a web server, you should store the [UserTokenBuilder] somewhere you can retrieve it later. A good place to store it is in a [`Cache`](https://docs.rs/retainer/0.3.0/retainer/cache/struct.Cache.html) 454 | /// or a [`HashMap`](std::collections::HashMap) with the CSRF token as the key. 455 | /// 456 | /// 4. When the user has been redirected to the redirect URL by twitch, extract the `state` and `code` query parameters from the URL. 457 | /// 458 | /// ```rust 459 | /// use std::borrow::Cow; 460 | /// use std::collections::BTreeMap; 461 | /// 462 | /// fn extract_pair<'a>( 463 | /// query: &BTreeMap, Cow<'a, str>>, 464 | /// key1: &str, 465 | /// key2: &str, 466 | /// ) -> Option<(Cow<'a, str>, Cow<'a, str>)> { 467 | /// Some((query.get(key1)?.clone(), query.get(key2)?.clone())) 468 | /// } 469 | /// 470 | /// /// Extract the state and code from the URL a user was redirected to after authorizing the application. 471 | /// fn extract_url<'a>( 472 | /// url: &'a url::Url, 473 | /// ) -> Result<(Cow<'a, str>, Cow<'a, str>), Option<(Cow<'a, str>, Cow<'a, str>)>> { 474 | /// let query: BTreeMap<_, _> = url.query_pairs().collect(); 475 | /// if let Some((error, error_description)) = extract_pair(&query, "error", "error_description") { 476 | /// Err(Some((error, error_description))) 477 | /// } else if let Some((state, code)) = extract_pair(&query, "state", "code") { 478 | /// Ok((state, code)) 479 | /// } else { 480 | /// Err(None) 481 | /// } 482 | /// } 483 | /// ``` 484 | /// 5. Finally, call [`get_user_token`](UserTokenBuilder::get_user_token) with the `state` and `code` query parameters to get the user's access token. 485 | /// 486 | /// ```rust 487 | /// # async move { 488 | /// # use twitch_oauth2::{id::TwitchTokenResponse, tokens::UserTokenBuilder, Scope}; 489 | /// # use url::Url; 490 | /// # use std::borrow::Cow; 491 | /// # let redirect_url = Url::parse("http://localhost/twitch/register")?; 492 | /// # let mut builder = UserTokenBuilder::new("myclientid", "myclientsecret", redirect_url); 493 | /// # let (url, csrf_token) = builder.generate_url(); 494 | /// # fn extract_url<'a>(_: &'a url::Url) -> Result<(Cow<'a, str>, Cow<'a, str>), std::io::Error> { Ok((Cow::default(), Cow::default())) } 495 | /// # let url = url::Url::parse("http://localhost/twitch/register?code=code&state=state")?; 496 | /// # let client = twitch_oauth2::client::DummyClient; stringify!( 497 | /// let client = reqwest::Client::builder() 498 | /// .redirect(reqwest::redirect::Policy::none()) 499 | /// .build()?; 500 | /// # ); 501 | /// let (state, code) = extract_url(&url)?; 502 | /// let token = builder.get_user_token(&client, state.as_ref(), code.as_ref()).await?; 503 | /// println!("User token: {:?}", token); 504 | /// # Ok::<(), Box>(()) 505 | /// # }; 506 | /// ``` 507 | pub struct UserTokenBuilder { 508 | pub(crate) scopes: Vec, 509 | pub(crate) csrf: Option, 510 | pub(crate) force_verify: bool, 511 | pub(crate) redirect_url: url::Url, 512 | client_id: ClientId, 513 | client_secret: ClientSecret, 514 | } 515 | 516 | impl UserTokenBuilder { 517 | /// Create a [`UserTokenBuilder`] 518 | /// 519 | /// # Notes 520 | /// 521 | /// The `redirect_url` must be present, verbatim, on [the Twitch Developer Console](https://dev.twitch.tv/console). 522 | /// 523 | /// The `url` crate converts empty paths into "/" (such as `https://example.com` into `https://example.com/`), 524 | /// which means that you'll need to add `https://example.com/` to your redirect URIs (note the "trailing" slash) if you want to use an empty path. 525 | /// 526 | /// To avoid this, use a path such as `https://example.com/twitch/register` or similar instead, where the `url` crate would not add a trailing `/`. 527 | pub fn new( 528 | client_id: impl Into, 529 | client_secret: impl Into, 530 | redirect_url: url::Url, 531 | ) -> UserTokenBuilder { 532 | UserTokenBuilder { 533 | scopes: vec![], 534 | csrf: None, 535 | force_verify: false, 536 | redirect_url, 537 | client_id: client_id.into(), 538 | client_secret: client_secret.into(), 539 | } 540 | } 541 | 542 | /// Add scopes to the request 543 | pub fn set_scopes(mut self, scopes: Vec) -> Self { 544 | self.scopes = scopes; 545 | self 546 | } 547 | 548 | /// Add a single scope to request 549 | pub fn add_scope(&mut self, scope: Scope) { self.scopes.push(scope); } 550 | 551 | /// Enable or disable function to make the user able to switch accounts if needed. 552 | pub fn force_verify(mut self, b: bool) -> Self { 553 | self.force_verify = b; 554 | self 555 | } 556 | 557 | /// Generate the URL to request a code. 558 | /// 559 | /// First step in the [guide](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#get-the-user-to-authorize-your-app) 560 | pub fn generate_url(&mut self) -> (url::Url, crate::types::CsrfToken) { 561 | let csrf = crate::types::CsrfToken::new_random(); 562 | self.csrf = Some(csrf.clone()); 563 | let mut url = crate::AUTH_URL.clone(); 564 | 565 | let auth = vec![ 566 | ("response_type", "code"), 567 | ("client_id", self.client_id.as_str()), 568 | ("redirect_uri", self.redirect_url.as_str()), 569 | ("state", csrf.as_str()), 570 | ]; 571 | 572 | url.query_pairs_mut().extend_pairs(auth); 573 | 574 | if !self.scopes.is_empty() { 575 | url.query_pairs_mut() 576 | .append_pair("scope", &self.scopes.as_slice().join(" ")); 577 | } 578 | 579 | if self.force_verify { 580 | url.query_pairs_mut().append_pair("force_verify", "true"); 581 | }; 582 | 583 | (url, csrf) 584 | } 585 | 586 | /// Set the CSRF token. 587 | /// 588 | /// Hidden because you should preferably not use this. 589 | #[doc(hidden)] 590 | pub fn set_csrf(&mut self, csrf: crate::types::CsrfToken) { self.csrf = Some(csrf); } 591 | 592 | /// Check if the CSRF is valid 593 | pub fn csrf_is_valid(&self, csrf: &str) -> bool { 594 | if let Some(csrf2) = &self.csrf { 595 | csrf2.secret() == csrf 596 | } else { 597 | false 598 | } 599 | } 600 | 601 | /// Get the request for getting a [TwitchTokenResponse](crate::id::TwitchTokenResponse), to be used in [UserToken::from_response]. 602 | /// 603 | /// # Examples 604 | /// 605 | /// ```rust 606 | /// use twitch_oauth2::{tokens::UserTokenBuilder, id::TwitchTokenResponse}; 607 | /// use url::Url; 608 | /// let callback_url = Url::parse("http://localhost/twitch/register")?; 609 | /// let mut builder = UserTokenBuilder::new("myclientid", "myclientsecret", callback_url); 610 | /// let (url, _csrf_code) = builder.generate_url(); 611 | /// 612 | /// // Direct the user to this url. 613 | /// // Later when your server gets a response on `callback_url` with `?code=xxxxxxx&state=xxxxxxx&scope=aa%3Aaa+bb%3Abb` 614 | /// 615 | /// // validate the state 616 | /// # let state_in_query = _csrf_code.secret(); 617 | /// if !builder.csrf_is_valid(state_in_query) { 618 | /// panic!("state mismatched") 619 | /// } 620 | /// // and then get your token 621 | /// # let code_in_query = _csrf_code.secret(); 622 | /// let request = builder.get_user_token_request(code_in_query); 623 | /// 624 | /// // use your favorite http client 625 | /// 626 | /// let response: http::Response> = client_req(request); 627 | /// let twitch_response = TwitchTokenResponse::from_response(&response)?; 628 | /// 629 | /// // you now have a access token, do what you want with it. 630 | /// // You're recommended to convert it into a `UserToken` via `UserToken::from_response` 631 | /// 632 | /// // You can validate the access_token like this 633 | /// let validated_req = twitch_response.access_token.validate_token_request(); 634 | /// # fn client_req(_: http::Request>) -> http::Response> { http::Response::new( 635 | /// # r#"{"access_token":"rfx2uswqe8l4g1mkagrvg5tv0ks3","expires_in":14124,"refresh_token":"5b93chm6hdve3mycz05zfzatkfdenfspp1h1ar2xxdalen01","scope":["channel:moderate","chat:edit","chat:read"],"token_type":"bearer"}"#.bytes().collect() 636 | /// # ) } 637 | /// # Ok::<(), Box>(()) 638 | /// ``` 639 | pub fn get_user_token_request(&self, code: &str) -> http::Request> { 640 | use http::{HeaderMap, Method}; 641 | use std::collections::HashMap; 642 | let mut params = HashMap::new(); 643 | params.insert("client_id", self.client_id.as_str()); 644 | params.insert("client_secret", self.client_secret.secret()); 645 | params.insert("code", code); 646 | params.insert("grant_type", "authorization_code"); 647 | params.insert("redirect_uri", self.redirect_url.as_str()); 648 | 649 | crate::construct_request( 650 | &crate::TOKEN_URL, 651 | ¶ms, 652 | HeaderMap::new(), 653 | Method::POST, 654 | vec![], 655 | ) 656 | } 657 | 658 | /// Generate the code with the help of the authorization code 659 | /// 660 | /// Last step in the [guide](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#use-the-authorization-code-to-get-a-token) 661 | /// 662 | /// On failure to authenticate due to wrong redirect url or other errors, twitch redirects the user to `?error=&error_description=` 663 | #[cfg(feature = "client")] 664 | pub async fn get_user_token<'a, C>( 665 | self, 666 | http_client: &'a C, 667 | state: &str, 668 | // TODO: Should be either str or AuthorizationCode 669 | code: &str, 670 | ) -> Result::Error>> 671 | where 672 | C: Client, 673 | { 674 | if !self.csrf_is_valid(state) { 675 | return Err(UserTokenExchangeError::StateMismatch); 676 | } 677 | 678 | let req = self.get_user_token_request(code); 679 | 680 | let resp = http_client 681 | .req(req) 682 | .await 683 | .map_err(UserTokenExchangeError::RequestError)?; 684 | 685 | let response = crate::id::TwitchTokenResponse::from_response(&resp)?; 686 | let validated = response.access_token.validate_token(http_client).await?; 687 | 688 | UserToken::from_response(response, validated, self.client_secret) 689 | .map_err(|v| v.into_other().into()) 690 | } 691 | } 692 | 693 | /// Builder for [OAuth implicit code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow) 694 | /// 695 | /// See [`UserTokenBuilder`] for the [OAuth authorization code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow) (requires Client Secret, generally more secure) 696 | pub struct ImplicitUserTokenBuilder { 697 | pub(crate) scopes: Vec, 698 | pub(crate) csrf: Option, 699 | pub(crate) redirect_url: url::Url, 700 | pub(crate) force_verify: bool, 701 | client_id: ClientId, 702 | } 703 | 704 | impl ImplicitUserTokenBuilder { 705 | /// Create a [`ImplicitUserTokenBuilder`] 706 | /// 707 | /// # Notes 708 | /// 709 | /// The `redirect_url` must be present, verbatim, on [the Twitch Developer Console](https://dev.twitch.tv/console). 710 | /// 711 | /// The `url` crate converts empty paths into "/" (such as `https://example.com` into `https://example.com/`), 712 | /// which means that you'll need to add `https://example.com/` to your redirect URIs (note the "trailing" slash) if you want to use an empty path. 713 | /// 714 | /// To avoid this, use a path such as `https://example.com/twitch/register` or similar instead, where the `url` crate would not add a trailing `/`. 715 | pub fn new(client_id: ClientId, redirect_url: url::Url) -> ImplicitUserTokenBuilder { 716 | ImplicitUserTokenBuilder { 717 | scopes: vec![], 718 | redirect_url, 719 | csrf: None, 720 | force_verify: false, 721 | client_id, 722 | } 723 | } 724 | 725 | /// Add scopes to the request 726 | pub fn set_scopes(mut self, scopes: Vec) -> Self { 727 | self.scopes = scopes; 728 | self 729 | } 730 | 731 | /// Add a single scope to request 732 | pub fn add_scope(&mut self, scope: Scope) { self.scopes.push(scope); } 733 | 734 | /// Enable or disable function to make the user able to switch accounts if needed. 735 | pub fn force_verify(mut self, b: bool) -> Self { 736 | self.force_verify = b; 737 | self 738 | } 739 | 740 | /// Generate the URL to request a token. 741 | /// 742 | /// First step in the [guide](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow) 743 | pub fn generate_url(&mut self) -> (url::Url, crate::types::CsrfToken) { 744 | let csrf = crate::types::CsrfToken::new_random(); 745 | self.csrf = Some(csrf.clone()); 746 | let mut url = crate::AUTH_URL.clone(); 747 | 748 | let auth = vec![ 749 | ("response_type", "token"), 750 | ("client_id", self.client_id.as_str()), 751 | ("redirect_uri", self.redirect_url.as_str()), 752 | ("state", csrf.as_str()), 753 | ]; 754 | 755 | url.query_pairs_mut().extend_pairs(auth); 756 | 757 | if !self.scopes.is_empty() { 758 | url.query_pairs_mut() 759 | .append_pair("scope", &self.scopes.as_slice().join(" ")); 760 | } 761 | 762 | if self.force_verify { 763 | url.query_pairs_mut().append_pair("force_verify", "true"); 764 | }; 765 | 766 | (url, csrf) 767 | } 768 | 769 | /// Check if the CSRF is valid 770 | pub fn csrf_is_valid(&self, csrf: &str) -> bool { 771 | if let Some(csrf2) = &self.csrf { 772 | csrf2.secret() == csrf 773 | } else { 774 | false 775 | } 776 | } 777 | 778 | /// Generate the code with the help of the hash. 779 | /// 780 | /// You can skip this method and instead use the token in the hash directly with [`UserToken::from_existing()`], but it's provided here for convenience. 781 | /// 782 | /// Last step in the [guide](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow) 783 | /// 784 | /// # Example 785 | /// 786 | /// When the user authenticates, they are sent to `#access_token=&scope=&state=&token_type=bearer` 787 | /// 788 | /// On failure, they are sent to 789 | /// 790 | /// `?error=&error_description=&state=` 791 | /// Get the hash of the url with javascript. 792 | /// 793 | /// ```js 794 | /// document.location.hash.substr(1); 795 | /// ``` 796 | /// 797 | /// and send it to your client in what ever way convenient. 798 | /// 799 | /// Provided below is an example of how to do it, no guarantees on the safety of this method. 800 | /// 801 | /// ```html 802 | /// 803 | /// 804 | /// 805 | /// Authorization 806 | /// 807 | /// 808 | /// 822 | /// 827 | /// 828 | /// 829 | ///

Authorization

830 | ///