├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── LICENSE-MIT ├── README.md ├── examples ├── microsoft_devicecode_common_user.rs ├── microsoft_devicecode_tenant_user.rs ├── google_devicecode.rs ├── github.rs ├── github_async.rs ├── letterboxd.rs ├── google.rs ├── msgraph.rs └── wunderlist.rs ├── src ├── ureq_client.rs ├── reqwest_client.rs ├── curl_client.rs ├── error.rs ├── basic.rs ├── endpoint.rs ├── tests.rs ├── helpers.rs ├── code.rs ├── introspection.rs ├── types.rs └── revocation.rs ├── Cargo.toml ├── LICENSE-APACHE └── UPGRADE.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ramosbugs] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/** 2 | *.iml 3 | /target 4 | /Cargo.lock 5 | *~ 6 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Alex Crichton 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAuth2 2 | 3 | 4 | [![Build Status](https://github.com/ramosbugs/oauth2-rs/actions/workflows/main.yml/badge.svg)](https://github.com/ramosbugs/oauth2-rs/actions/workflows/main.yml) 5 | 6 | An extensible, strongly-typed implementation of OAuth2 7 | ([RFC 6749](https://tools.ietf.org/html/rfc6749)). 8 | 9 | Documentation is available on [docs.rs](https://docs.rs/oauth2). Release notes are available on [GitHub](https://github.com/ramosbugs/oauth2-rs/releases). 10 | 11 | For authentication (e.g., single sign-on or social login) purposes, consider using the 12 | [`openidconnect`](https://github.com/ramosbugs/openidconnect-rs) crate, which is built on top of 13 | this one. 14 | 15 | ## Minimum Supported Rust Version (MSRV) 16 | 17 | The MSRV for *5.0* and newer releases of this crate is Rust **1.65**. 18 | 19 | The MSRV for *4.x* releases of this crate is Rust 1.45. 20 | 21 | Beginning with the 5.0.0 release, this crate will maintain a policy of supporting 22 | Rust releases going back at least 6 months. Changes that break compatibility with Rust releases 23 | older than 6 months will no longer be considered SemVer breaking changes and will not result in a 24 | new major version number for this crate. MSRV changes will coincide with minor version updates 25 | and will not happen in patch releases. 26 | -------------------------------------------------------------------------------- /examples/microsoft_devicecode_common_user.rs: -------------------------------------------------------------------------------- 1 | use oauth2::basic::BasicClient; 2 | use oauth2::{ 3 | AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, StandardDeviceAuthorizationResponse, TokenUrl, 4 | }; 5 | 6 | use std::error::Error; 7 | 8 | #[tokio::main] 9 | async fn main() -> Result<(), Box> { 10 | let client = BasicClient::new(ClientId::new("client_id".to_string())) 11 | .set_auth_uri(AuthUrl::new( 12 | "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".to_string(), 13 | )?) 14 | .set_token_uri(TokenUrl::new( 15 | "https://login.microsoftonline.com/common/oauth2/v2.0/token".to_string(), 16 | )?) 17 | .set_device_authorization_url(DeviceAuthorizationUrl::new( 18 | "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode".to_string(), 19 | )?); 20 | 21 | let http_client = reqwest::ClientBuilder::new() 22 | // Following redirects opens the client up to SSRF vulnerabilities. 23 | .redirect(reqwest::redirect::Policy::none()) 24 | .build() 25 | .expect("Client should build"); 26 | 27 | let details: StandardDeviceAuthorizationResponse = client 28 | .exchange_device_code() 29 | .add_scope(Scope::new("read".to_string())) 30 | .request_async(&http_client) 31 | .await?; 32 | 33 | eprintln!( 34 | "Open this URL in your browser:\n{}\nand enter the code: {}", 35 | details.verification_uri(), 36 | details.user_code().secret(), 37 | ); 38 | 39 | let token_result = client 40 | .exchange_device_access_token(&details) 41 | .request_async(&http_client, tokio::time::sleep, None) 42 | .await; 43 | 44 | eprintln!("Token:{token_result:?}"); 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /examples/microsoft_devicecode_tenant_user.rs: -------------------------------------------------------------------------------- 1 | use oauth2::basic::BasicClient; 2 | use oauth2::reqwest; 3 | use oauth2::StandardDeviceAuthorizationResponse; 4 | use oauth2::{AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, TokenUrl}; 5 | 6 | use std::error::Error; 7 | 8 | // Reference: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code 9 | // Please use your tenant id when using this example 10 | const TENANT_ID: &str = "{tenant}"; 11 | 12 | #[tokio::main] 13 | async fn main() -> Result<(), Box> { 14 | let client = BasicClient::new(ClientId::new("client_id".to_string())) 15 | .set_auth_uri(AuthUrl::new(format!( 16 | "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize", 17 | TENANT_ID 18 | ))?) 19 | .set_token_uri(TokenUrl::new(format!( 20 | "https://login.microsoftonline.com/{}/oauth2/v2.0/token", 21 | TENANT_ID 22 | ))?) 23 | .set_device_authorization_url(DeviceAuthorizationUrl::new(format!( 24 | "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode", 25 | TENANT_ID 26 | ))?); 27 | 28 | let http_client = reqwest::ClientBuilder::new() 29 | // Following redirects opens the client up to SSRF vulnerabilities. 30 | .redirect(reqwest::redirect::Policy::none()) 31 | .build() 32 | .expect("Client should build"); 33 | 34 | let details: StandardDeviceAuthorizationResponse = client 35 | .exchange_device_code() 36 | .add_scope(Scope::new("read".to_string())) 37 | .request_async(&http_client) 38 | .await?; 39 | 40 | eprintln!( 41 | "Open this URL in your browser:\n{}\nand enter the code: {}", 42 | details.verification_uri(), 43 | details.user_code().secret(), 44 | ); 45 | 46 | let token_result = client 47 | .exchange_device_access_token(&details) 48 | .request_async(&http_client, tokio::time::sleep, None) 49 | .await; 50 | 51 | eprintln!("Token:{token_result:?}"); 52 | 53 | Ok(()) 54 | } 55 | -------------------------------------------------------------------------------- /src/ureq_client.rs: -------------------------------------------------------------------------------- 1 | use crate::{HttpClientError, HttpRequest, HttpResponse}; 2 | 3 | use http::{ 4 | header::{HeaderValue, CONTENT_TYPE}, 5 | method::Method, 6 | status::StatusCode, 7 | }; 8 | 9 | use std::io::Read; 10 | 11 | impl crate::SyncHttpClient for ureq::Agent { 12 | type Error = HttpClientError; 13 | 14 | fn call(&self, request: HttpRequest) -> Result { 15 | let mut req = if *request.method() == Method::POST { 16 | self.post(&request.uri().to_string()) 17 | } else { 18 | debug_assert_eq!(*request.method(), Method::GET); 19 | self.get(&request.uri().to_string()) 20 | }; 21 | 22 | for (name, value) in request.headers() { 23 | req = req.set( 24 | name.as_ref(), 25 | // TODO: In newer `ureq` it should be easier to convert arbitrary byte sequences 26 | // without unnecessary UTF-8 fallibility here. 27 | value.to_str().map_err(|_| { 28 | HttpClientError::Other(format!( 29 | "invalid `{name}` header value {:?}", 30 | value.as_bytes() 31 | )) 32 | })?, 33 | ); 34 | } 35 | 36 | let response = if let Method::POST = *request.method() { 37 | req.send_bytes(request.body()) 38 | } else { 39 | req.call() 40 | } 41 | .map_err(Box::new)?; 42 | 43 | let mut builder = http::Response::builder() 44 | .status(StatusCode::from_u16(response.status()).map_err(http::Error::from)?); 45 | 46 | if let Some(content_type) = response 47 | .header(CONTENT_TYPE.as_str()) 48 | .map(HeaderValue::from_str) 49 | .transpose() 50 | .map_err(http::Error::from)? 51 | { 52 | builder = builder.header(CONTENT_TYPE, content_type); 53 | } 54 | 55 | let mut body = Vec::new(); 56 | response.into_reader().read_to_end(&mut body)?; 57 | 58 | builder.body(body).map_err(HttpClientError::Http) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oauth2" 3 | authors = ["Alex Crichton ", "Florin Lipan ", "David A. Ramos "] 4 | version = "5.0.0-rc.1" 5 | license = "MIT OR Apache-2.0" 6 | description = "An extensible, strongly-typed implementation of OAuth2" 7 | repository = "https://github.com/ramosbugs/oauth2-rs" 8 | edition = "2021" 9 | readme = "README.md" 10 | rust-version = "1.65" 11 | 12 | [package.metadata.docs.rs] 13 | all-features = true 14 | 15 | [badges] 16 | maintenance = { status = "actively-developed" } 17 | 18 | [features] 19 | default = ["reqwest", "rustls-tls"] 20 | pkce-plain = [] 21 | native-tls = ["reqwest/native-tls"] 22 | reqwest-blocking = ["reqwest/blocking"] 23 | rustls-tls = ["reqwest/rustls-tls"] 24 | timing-resistant-secret-traits = [] 25 | 26 | [[example]] 27 | name = "github" 28 | required-features = ["reqwest-blocking"] 29 | 30 | [[example]] 31 | name = "google" 32 | required-features = ["reqwest-blocking"] 33 | 34 | [[example]] 35 | name = "google_devicecode" 36 | required-features = ["reqwest-blocking"] 37 | 38 | [[example]] 39 | name = "letterboxd" 40 | required-features = ["reqwest-blocking"] 41 | 42 | [[example]] 43 | name = "msgraph" 44 | required-features = ["reqwest-blocking"] 45 | 46 | [[example]] 47 | name = "wunderlist" 48 | required-features = ["reqwest-blocking"] 49 | 50 | [dependencies] 51 | base64 = ">= 0.21, <0.23" 52 | thiserror = "1.0" 53 | http = "1.0" 54 | rand = "0.8" 55 | reqwest = { version = "0.12", optional = true, default-features = false } 56 | serde = { version = "1.0", features = ["derive"] } 57 | serde_json = "1.0" 58 | sha2 = "0.10" 59 | ureq = { version = "2", optional = true } 60 | url = { version = "2.1", features = ["serde"] } 61 | chrono = { version = "0.4.31", default-features = false, features = ["clock", "serde", "std", "wasmbind"] } 62 | serde_path_to_error = "0.1.2" 63 | 64 | [target.'cfg(target_arch = "wasm32")'.dependencies] 65 | getrandom = { version = "0.2", features = ["js"] } 66 | 67 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 68 | curl = { version = "0.4.0", optional = true } 69 | 70 | [dev-dependencies] 71 | hex = "0.4" 72 | hmac = "0.12" 73 | uuid = { version = "1.10", features = ["v4"] } 74 | anyhow = "1.0" 75 | tokio = { version = "1.0", features = ["full"] } 76 | async-std = "1.13" 77 | -------------------------------------------------------------------------------- /src/reqwest_client.rs: -------------------------------------------------------------------------------- 1 | use crate::{AsyncHttpClient, HttpClientError, HttpRequest, HttpResponse}; 2 | 3 | use std::future::Future; 4 | use std::pin::Pin; 5 | 6 | impl<'c> AsyncHttpClient<'c> for reqwest::Client { 7 | type Error = HttpClientError; 8 | 9 | #[cfg(target_arch = "wasm32")] 10 | type Future = Pin> + 'c>>; 11 | #[cfg(not(target_arch = "wasm32"))] 12 | type Future = 13 | Pin> + Send + Sync + 'c>>; 14 | 15 | fn call(&'c self, request: HttpRequest) -> Self::Future { 16 | Box::pin(async move { 17 | let response = self 18 | .execute(request.try_into().map_err(Box::new)?) 19 | .await 20 | .map_err(Box::new)?; 21 | 22 | let mut builder = http::Response::builder().status(response.status()); 23 | 24 | #[cfg(not(target_arch = "wasm32"))] 25 | { 26 | builder = builder.version(response.version()); 27 | } 28 | 29 | for (name, value) in response.headers().iter() { 30 | builder = builder.header(name, value); 31 | } 32 | 33 | builder 34 | .body(response.bytes().await.map_err(Box::new)?.to_vec()) 35 | .map_err(HttpClientError::Http) 36 | }) 37 | } 38 | } 39 | 40 | #[cfg(all(feature = "reqwest-blocking", not(target_arch = "wasm32")))] 41 | impl crate::SyncHttpClient for reqwest::blocking::Client { 42 | type Error = HttpClientError; 43 | 44 | fn call(&self, request: HttpRequest) -> Result { 45 | let mut response = self 46 | .execute(request.try_into().map_err(Box::new)?) 47 | .map_err(Box::new)?; 48 | 49 | let mut builder = http::Response::builder() 50 | .status(response.status()) 51 | .version(response.version()); 52 | 53 | for (name, value) in response.headers().iter() { 54 | builder = builder.header(name, value); 55 | } 56 | 57 | let mut body = Vec::new(); 58 | ::read_to_end(&mut response, &mut body)?; 59 | 60 | builder.body(body).map_err(HttpClientError::Http) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/curl_client.rs: -------------------------------------------------------------------------------- 1 | use crate::{HttpClientError, HttpRequest, HttpResponse, SyncHttpClient}; 2 | 3 | use curl::easy::Easy; 4 | use http::header::{HeaderValue, CONTENT_TYPE}; 5 | use http::method::Method; 6 | use http::status::StatusCode; 7 | 8 | use std::io::Read; 9 | 10 | /// A synchronous HTTP client using [`curl`]. 11 | pub struct CurlHttpClient; 12 | impl SyncHttpClient for CurlHttpClient { 13 | type Error = HttpClientError; 14 | 15 | fn call(&self, request: HttpRequest) -> Result { 16 | let mut easy = Easy::new(); 17 | easy.url(&request.uri().to_string()[..]).map_err(Box::new)?; 18 | 19 | let mut headers = curl::easy::List::new(); 20 | for (name, value) in request.headers() { 21 | headers 22 | .append(&format!( 23 | "{}: {}", 24 | name, 25 | // TODO: Unnecessary fallibility, curl uses a CString under the hood 26 | value.to_str().map_err(|_| HttpClientError::Other(format!( 27 | "invalid `{name}` header value {:?}", 28 | value.as_bytes() 29 | )))? 30 | )) 31 | .map_err(Box::new)? 32 | } 33 | 34 | easy.http_headers(headers).map_err(Box::new)?; 35 | 36 | if let Method::POST = *request.method() { 37 | easy.post(true).map_err(Box::new)?; 38 | easy.post_field_size(request.body().len() as u64) 39 | .map_err(Box::new)?; 40 | } else { 41 | assert_eq!(*request.method(), Method::GET); 42 | } 43 | 44 | let mut form_slice = &request.body()[..]; 45 | let mut data = Vec::new(); 46 | { 47 | let mut transfer = easy.transfer(); 48 | 49 | transfer 50 | .read_function(|buf| Ok(form_slice.read(buf).unwrap_or(0))) 51 | .map_err(Box::new)?; 52 | 53 | transfer 54 | .write_function(|new_data| { 55 | data.extend_from_slice(new_data); 56 | Ok(new_data.len()) 57 | }) 58 | .map_err(Box::new)?; 59 | 60 | transfer.perform().map_err(Box::new)?; 61 | } 62 | 63 | let mut builder = http::Response::builder().status( 64 | StatusCode::from_u16(easy.response_code().map_err(Box::new)? as u16) 65 | .map_err(http::Error::from)?, 66 | ); 67 | 68 | if let Some(content_type) = easy 69 | .content_type() 70 | .map_err(Box::new)? 71 | .map(HeaderValue::from_str) 72 | .transpose() 73 | .map_err(http::Error::from)? 74 | { 75 | builder = builder.header(CONTENT_TYPE, content_type); 76 | } 77 | 78 | builder.body(data).map_err(HttpClientError::Http) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the workflow will run 4 | on: 5 | # Triggers the workflow on push or pull request events. 6 | push: {} 7 | pull_request: {} 8 | schedule: 9 | # Run daily to catch breakages in new Rust versions as well as new cargo audit findings. 10 | - cron: '0 16 * * *' 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | env: 16 | CARGO_TERM_COLOR: always 17 | 18 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 19 | jobs: 20 | # This workflow contains a single job called "build" 21 | test: 22 | # The type of runner that the job will run on 23 | runs-on: ${{ matrix.rust_os.os }} 24 | 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | rust_os: 29 | - { rust: 1.65.0, os: ubuntu-20.04 } 30 | - { rust: stable, os: ubuntu-latest } 31 | - { rust: beta, os: ubuntu-latest } 32 | - { rust: nightly, os: ubuntu-latest } 33 | 34 | env: 35 | CARGO_NET_GIT_FETCH_WITH_CLI: "true" 36 | 37 | # Steps represent a sequence of tasks that will be executed as part of the job 38 | steps: 39 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 40 | - uses: actions/checkout@v2 41 | 42 | - name: Install Rust toolchain 43 | uses: actions-rs/toolchain@v1 44 | with: 45 | toolchain: ${{ matrix.rust_os.rust }} 46 | override: true 47 | components: clippy, rustfmt 48 | target: wasm32-unknown-unknown 49 | 50 | # Newer dependency versions may not support the MSRV, so we use a Cargo.lock file for those 51 | # builds. 52 | - name: Use Rust 1.65 lockfile 53 | if: ${{ matrix.rust_os.rust == '1.65.0' }} 54 | run: | 55 | cp Cargo-1.65.lock Cargo.lock 56 | echo "CARGO_LOCKED=--locked" >> $GITHUB_ENV 57 | 58 | - name: Run tests 59 | run: cargo ${CARGO_LOCKED} test --tests --examples 60 | - name: Doc tests 61 | run: | 62 | cargo ${CARGO_LOCKED} test --doc 63 | cargo ${CARGO_LOCKED} test --doc --no-default-features 64 | cargo ${CARGO_LOCKED} test --doc --all-features 65 | - name: Test with all features enabled 66 | run: cargo ${CARGO_LOCKED} test --all-features 67 | # Curl without reqwest (examples will not build) 68 | - name: Test with curl (w/o reqwest) 69 | run: cargo ${CARGO_LOCKED} test --tests --features curl --no-default-features 70 | 71 | - name: Check fmt 72 | if: ${{ matrix.rust_os.rust == '1.65.0' }} 73 | run: cargo ${CARGO_LOCKED} fmt --all -- --check 74 | 75 | - name: Clippy 76 | if: ${{ matrix.rust_os.rust == '1.65.0' }} 77 | run: cargo ${CARGO_LOCKED} clippy --all --all-features -- --deny warnings 78 | 79 | - name: Audit 80 | if: ${{ matrix.rust_os.rust == 'stable' }} 81 | run: | 82 | cargo ${CARGO_LOCKED} install --force cargo-audit 83 | # The chrono thread safety issue doesn't affect this crate since the crate does not rely 84 | # on the system's local time zone, only UTC. See: 85 | # https://github.com/chronotope/chrono/issues/499#issuecomment-946388161 86 | cargo ${CARGO_LOCKED} audit --ignore RUSTSEC-2020-0159 87 | 88 | - name: Check WASM build 89 | run: cargo ${CARGO_LOCKED} check --target wasm32-unknown-unknown 90 | -------------------------------------------------------------------------------- /examples/google_devicecode.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Google OAuth2 process for requesting access to the Google Calendar features 3 | //! and the user's profile. 4 | //! 5 | //! Before running it, you'll need to generate your own Google OAuth2 credentials. 6 | //! 7 | //! In order to run the example call: 8 | //! 9 | //! ```sh 10 | //! GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy cargo run --example google 11 | //! ``` 12 | //! 13 | //! ...and follow the instructions. 14 | //! 15 | 16 | use oauth2::basic::BasicClient; 17 | use oauth2::reqwest; 18 | use oauth2::{ 19 | AuthType, AuthUrl, ClientId, ClientSecret, DeviceAuthorizationResponse, DeviceAuthorizationUrl, 20 | ExtraDeviceAuthorizationFields, Scope, TokenUrl, 21 | }; 22 | use serde::{Deserialize, Serialize}; 23 | 24 | use std::collections::HashMap; 25 | use std::env; 26 | 27 | #[derive(Debug, Serialize, Deserialize)] 28 | struct StoringFields(HashMap); 29 | 30 | impl ExtraDeviceAuthorizationFields for StoringFields {} 31 | type StoringDeviceAuthorizationResponse = DeviceAuthorizationResponse; 32 | 33 | fn main() { 34 | let google_client_id = ClientId::new( 35 | env::var("GOOGLE_CLIENT_ID").expect("Missing the GOOGLE_CLIENT_ID environment variable."), 36 | ); 37 | let google_client_secret = ClientSecret::new( 38 | env::var("GOOGLE_CLIENT_SECRET") 39 | .expect("Missing the GOOGLE_CLIENT_SECRET environment variable."), 40 | ); 41 | let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()) 42 | .expect("Invalid authorization endpoint URL"); 43 | let token_url = TokenUrl::new("https://www.googleapis.com/oauth2/v3/token".to_string()) 44 | .expect("Invalid token endpoint URL"); 45 | let device_auth_url = 46 | DeviceAuthorizationUrl::new("https://oauth2.googleapis.com/device/code".to_string()) 47 | .expect("Invalid device authorization endpoint URL"); 48 | 49 | // Set up the config for the Google OAuth2 process. 50 | // 51 | // Google's OAuth endpoint expects the client_id to be in the request body, 52 | // so ensure that option is set. 53 | let device_client = BasicClient::new(google_client_id) 54 | .set_client_secret(google_client_secret) 55 | .set_auth_uri(auth_url) 56 | .set_token_uri(token_url) 57 | .set_device_authorization_url(device_auth_url) 58 | .set_auth_type(AuthType::RequestBody); 59 | 60 | let http_client = reqwest::blocking::ClientBuilder::new() 61 | // Following redirects opens the client up to SSRF vulnerabilities. 62 | .redirect(reqwest::redirect::Policy::none()) 63 | .build() 64 | .expect("Client should build"); 65 | 66 | // Request the set of codes from the Device Authorization endpoint. 67 | let details: StoringDeviceAuthorizationResponse = device_client 68 | .exchange_device_code() 69 | .add_scope(Scope::new("profile".to_string())) 70 | .request(&http_client) 71 | .expect("Failed to request codes from device auth endpoint"); 72 | 73 | // Display the URL and user-code. 74 | println!( 75 | "Open this URL in your browser:\n{}\nand enter the code: {}", 76 | details.verification_uri(), 77 | details.user_code().secret(), 78 | ); 79 | 80 | // Now poll for the token 81 | let token = device_client 82 | .exchange_device_access_token(&details) 83 | .request(&http_client, std::thread::sleep, None) 84 | .expect("Failed to get token"); 85 | 86 | println!("Google returned the following token:\n{token:?}\n"); 87 | } 88 | -------------------------------------------------------------------------------- /examples/github.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Github OAuth2 process for requesting access to the user's public repos and 3 | //! email address. 4 | //! 5 | //! Before running it, you'll need to generate your own Github OAuth2 credentials. 6 | //! 7 | //! In order to run the example call: 8 | //! 9 | //! ```sh 10 | //! GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=yyy cargo run --example github 11 | //! ``` 12 | //! 13 | //! ...and follow the instructions. 14 | //! 15 | 16 | use oauth2::basic::BasicClient; 17 | use oauth2::reqwest; 18 | use oauth2::{ 19 | AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, Scope, 20 | TokenResponse, TokenUrl, 21 | }; 22 | use url::Url; 23 | 24 | use std::env; 25 | use std::io::{BufRead, BufReader, Write}; 26 | use std::net::TcpListener; 27 | 28 | fn main() { 29 | let github_client_id = ClientId::new( 30 | env::var("GITHUB_CLIENT_ID").expect("Missing the GITHUB_CLIENT_ID environment variable."), 31 | ); 32 | let github_client_secret = ClientSecret::new( 33 | env::var("GITHUB_CLIENT_SECRET") 34 | .expect("Missing the GITHUB_CLIENT_SECRET environment variable."), 35 | ); 36 | let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) 37 | .expect("Invalid authorization endpoint URL"); 38 | let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) 39 | .expect("Invalid token endpoint URL"); 40 | 41 | // Set up the config for the Github OAuth2 process. 42 | let client = BasicClient::new(github_client_id) 43 | .set_client_secret(github_client_secret) 44 | .set_auth_uri(auth_url) 45 | .set_token_uri(token_url) 46 | // This example will be running its own server at localhost:8080. 47 | // See below for the server implementation. 48 | .set_redirect_uri( 49 | RedirectUrl::new("http://localhost:8080".to_string()).expect("Invalid redirect URL"), 50 | ); 51 | 52 | let http_client = reqwest::blocking::ClientBuilder::new() 53 | // Following redirects opens the client up to SSRF vulnerabilities. 54 | .redirect(reqwest::redirect::Policy::none()) 55 | .build() 56 | .expect("Client should build"); 57 | 58 | // Generate the authorization URL to which we'll redirect the user. 59 | let (authorize_url, csrf_state) = client 60 | .authorize_url(CsrfToken::new_random) 61 | // This example is requesting access to the user's public repos and email. 62 | .add_scope(Scope::new("public_repo".to_string())) 63 | .add_scope(Scope::new("user:email".to_string())) 64 | .url(); 65 | 66 | println!("Open this URL in your browser:\n{authorize_url}\n"); 67 | 68 | let (code, state) = { 69 | // A very naive implementation of the redirect server. 70 | let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); 71 | 72 | // The server will terminate itself after collecting the first code. 73 | let Some(mut stream) = listener.incoming().flatten().next() else { 74 | panic!("listener terminated without accepting a connection"); 75 | }; 76 | 77 | let mut reader = BufReader::new(&stream); 78 | 79 | let mut request_line = String::new(); 80 | reader.read_line(&mut request_line).unwrap(); 81 | 82 | let redirect_url = request_line.split_whitespace().nth(1).unwrap(); 83 | let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); 84 | 85 | let code = url 86 | .query_pairs() 87 | .find(|(key, _)| key == "code") 88 | .map(|(_, code)| AuthorizationCode::new(code.into_owned())) 89 | .unwrap(); 90 | 91 | let state = url 92 | .query_pairs() 93 | .find(|(key, _)| key == "state") 94 | .map(|(_, state)| CsrfToken::new(state.into_owned())) 95 | .unwrap(); 96 | 97 | let message = "Go back to your terminal :)"; 98 | let response = format!( 99 | "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", 100 | message.len(), 101 | message 102 | ); 103 | stream.write_all(response.as_bytes()).unwrap(); 104 | 105 | (code, state) 106 | }; 107 | 108 | println!("Github returned the following code:\n{}\n", code.secret()); 109 | println!( 110 | "Github returned the following state:\n{} (expected `{}`)\n", 111 | state.secret(), 112 | csrf_state.secret() 113 | ); 114 | 115 | // Exchange the code with a token. 116 | let token_res = client.exchange_code(code).request(&http_client); 117 | 118 | println!("Github returned the following token:\n{token_res:?}\n"); 119 | 120 | if let Ok(token) = token_res { 121 | // NB: Github returns a single comma-separated "scope" parameter instead of multiple 122 | // space-separated scopes. Github-specific clients can parse this scope into 123 | // multiple scopes by splitting at the commas. Note that it's not safe for the 124 | // library to do this by default because RFC 6749 allows scopes to contain commas. 125 | let scopes = if let Some(scopes_vec) = token.scopes() { 126 | scopes_vec 127 | .iter() 128 | .flat_map(|comma_separated| comma_separated.split(',')) 129 | .collect::>() 130 | } else { 131 | Vec::new() 132 | }; 133 | println!("Github returned the following scopes:\n{scopes:?}\n"); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /examples/github_async.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Github OAuth2 process for requesting access to the user's public repos and 3 | //! email address. 4 | //! 5 | //! Before running it, you'll need to generate your own Github OAuth2 credentials. 6 | //! 7 | //! In order to run the example call: 8 | //! 9 | //! ```sh 10 | //! GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=yyy cargo run --example github 11 | //! ``` 12 | //! 13 | //! ...and follow the instructions. 14 | //! 15 | 16 | use oauth2::basic::BasicClient; 17 | use oauth2::reqwest; 18 | use oauth2::{ 19 | AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, Scope, 20 | TokenResponse, TokenUrl, 21 | }; 22 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 23 | use tokio::net::TcpListener; 24 | use url::Url; 25 | 26 | use std::env; 27 | 28 | #[tokio::main] 29 | async fn main() { 30 | let github_client_id = ClientId::new( 31 | env::var("GITHUB_CLIENT_ID").expect("Missing the GITHUB_CLIENT_ID environment variable."), 32 | ); 33 | let github_client_secret = ClientSecret::new( 34 | env::var("GITHUB_CLIENT_SECRET") 35 | .expect("Missing the GITHUB_CLIENT_SECRET environment variable."), 36 | ); 37 | let auth_url = AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) 38 | .expect("Invalid authorization endpoint URL"); 39 | let token_url = TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) 40 | .expect("Invalid token endpoint URL"); 41 | 42 | // Set up the config for the Github OAuth2 process. 43 | let client = BasicClient::new(github_client_id) 44 | .set_client_secret(github_client_secret) 45 | .set_auth_uri(auth_url) 46 | .set_token_uri(token_url) 47 | // This example will be running its own server at localhost:8080. 48 | // See below for the server implementation. 49 | .set_redirect_uri( 50 | RedirectUrl::new("http://localhost:8080".to_string()).expect("Invalid redirect URL"), 51 | ); 52 | 53 | let http_client = reqwest::ClientBuilder::new() 54 | // Following redirects opens the client up to SSRF vulnerabilities. 55 | .redirect(reqwest::redirect::Policy::none()) 56 | .build() 57 | .expect("Client should build"); 58 | 59 | // Generate the authorization URL to which we'll redirect the user. 60 | let (authorize_url, csrf_state) = client 61 | .authorize_url(CsrfToken::new_random) 62 | // This example is requesting access to the user's public repos and email. 63 | .add_scope(Scope::new("public_repo".to_string())) 64 | .add_scope(Scope::new("user:email".to_string())) 65 | .url(); 66 | 67 | println!("Open this URL in your browser:\n{authorize_url}\n"); 68 | 69 | let (code, state) = { 70 | // A very naive implementation of the redirect server. 71 | let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); 72 | 73 | loop { 74 | if let Ok((mut stream, _)) = listener.accept().await { 75 | let mut reader = BufReader::new(&mut stream); 76 | 77 | let mut request_line = String::new(); 78 | reader.read_line(&mut request_line).await.unwrap(); 79 | 80 | let redirect_url = request_line.split_whitespace().nth(1).unwrap(); 81 | let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); 82 | 83 | let code = url 84 | .query_pairs() 85 | .find(|(key, _)| key == "code") 86 | .map(|(_, code)| AuthorizationCode::new(code.into_owned())) 87 | .unwrap(); 88 | 89 | let state = url 90 | .query_pairs() 91 | .find(|(key, _)| key == "state") 92 | .map(|(_, state)| CsrfToken::new(state.into_owned())) 93 | .unwrap(); 94 | 95 | let message = "Go back to your terminal :)"; 96 | let response = format!( 97 | "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", 98 | message.len(), 99 | message 100 | ); 101 | stream.write_all(response.as_bytes()).await.unwrap(); 102 | 103 | // The server will terminate itself after collecting the first code. 104 | break (code, state); 105 | } 106 | } 107 | }; 108 | 109 | println!("Github returned the following code:\n{}\n", code.secret()); 110 | println!( 111 | "Github returned the following state:\n{} (expected `{}`)\n", 112 | state.secret(), 113 | csrf_state.secret() 114 | ); 115 | 116 | // Exchange the code with a token. 117 | let token_res = client.exchange_code(code).request_async(&http_client).await; 118 | 119 | println!("Github returned the following token:\n{token_res:?}\n"); 120 | 121 | if let Ok(token) = token_res { 122 | // NB: Github returns a single comma-separated "scope" parameter instead of multiple 123 | // space-separated scopes. Github-specific clients can parse this scope into 124 | // multiple scopes by splitting at the commas. Note that it's not safe for the 125 | // library to do this by default because RFC 6749 allows scopes to contain commas. 126 | let scopes = if let Some(scopes_vec) = token.scopes() { 127 | scopes_vec 128 | .iter() 129 | .flat_map(|comma_separated| comma_separated.split(',')) 130 | .collect::>() 131 | } else { 132 | Vec::new() 133 | }; 134 | println!("Github returned the following scopes:\n{scopes:?}\n"); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /examples/letterboxd.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Letterboxd OAuth2 process for requesting access 3 | //! to the API features restricted by authentication. Letterboxd requires all 4 | //! requests being signed as described in http://api-docs.letterboxd.com/#signing. 5 | //! So this serves as an example how to implement a custom client, which signs 6 | //! requests and appends the signature to the url query. 7 | //! 8 | //! Before running it, you'll need to get access to the API. 9 | //! 10 | //! In order to run the example call: 11 | //! 12 | //! ```sh 13 | //! LETTERBOXD_CLIENT_ID=xxx LETTERBOXD_CLIENT_SECRET=yyy LETTERBOXD_USERNAME=www LETTERBOXD_PASSWORD=zzz cargo run --example letterboxd 14 | //! ``` 15 | 16 | use hex::ToHex; 17 | use hmac::{Hmac, Mac}; 18 | use oauth2::{ 19 | basic::BasicClient, AuthType, AuthUrl, ClientId, ClientSecret, HttpRequest, HttpResponse, 20 | ResourceOwnerPassword, ResourceOwnerUsername, SyncHttpClient, TokenUrl, 21 | }; 22 | use sha2::Sha256; 23 | use url::Url; 24 | 25 | use std::env; 26 | use std::time; 27 | 28 | fn main() -> Result<(), anyhow::Error> { 29 | // a.k.a api key in Letterboxd API documentation 30 | let letterboxd_client_id = ClientId::new( 31 | env::var("LETTERBOXD_CLIENT_ID") 32 | .expect("Missing the LETTERBOXD_CLIENT_ID environment variable."), 33 | ); 34 | // a.k.a api secret in Letterboxd API documentation 35 | let letterboxd_client_secret = ClientSecret::new( 36 | env::var("LETTERBOXD_CLIENT_SECRET") 37 | .expect("Missing the LETTERBOXD_CLIENT_SECRET environment variable."), 38 | ); 39 | // Letterboxd uses the Resource Owner flow and does not have an auth url 40 | let auth_url = AuthUrl::new("https://api.letterboxd.com/api/v0/auth/404".to_string())?; 41 | let token_url = TokenUrl::new("https://api.letterboxd.com/api/v0/auth/token".to_string())?; 42 | 43 | // Set up the config for the Letterboxd OAuth2 process. 44 | let client = BasicClient::new(letterboxd_client_id.clone()) 45 | .set_client_secret(letterboxd_client_secret.clone()) 46 | .set_auth_uri(auth_url) 47 | .set_token_uri(token_url); 48 | 49 | // Resource Owner flow uses username and password for authentication 50 | let letterboxd_username = ResourceOwnerUsername::new( 51 | env::var("LETTERBOXD_USERNAME") 52 | .expect("Missing the LETTERBOXD_USERNAME environment variable."), 53 | ); 54 | let letterboxd_password = ResourceOwnerPassword::new( 55 | env::var("LETTERBOXD_PASSWORD") 56 | .expect("Missing the LETTERBOXD_PASSWORD environment variable."), 57 | ); 58 | 59 | // All API requests must be signed as described at http://api-docs.letterboxd.com/#signing; 60 | // for that, we use a custom http client. 61 | let http_client = SigningHttpClient::new(letterboxd_client_id, letterboxd_client_secret); 62 | 63 | let token_result = client 64 | .set_auth_type(AuthType::RequestBody) 65 | .exchange_password(&letterboxd_username, &letterboxd_password) 66 | .request(&|request| http_client.execute(request))?; 67 | 68 | println!("{token_result:?}"); 69 | 70 | Ok(()) 71 | } 72 | 73 | /// Custom HTTP client which signs requests. 74 | /// 75 | /// See http://api-docs.letterboxd.com/#signing. 76 | #[derive(Debug, Clone)] 77 | struct SigningHttpClient { 78 | client_id: ClientId, 79 | client_secret: ClientSecret, 80 | inner: reqwest::blocking::Client, 81 | } 82 | 83 | impl SigningHttpClient { 84 | fn new(client_id: ClientId, client_secret: ClientSecret) -> Self { 85 | Self { 86 | client_id, 87 | client_secret, 88 | inner: reqwest::blocking::ClientBuilder::new() 89 | // Following redirects opens the client up to SSRF vulnerabilities. 90 | .redirect(reqwest::redirect::Policy::none()) 91 | .build() 92 | .expect("Client should build"), 93 | } 94 | } 95 | 96 | /// Signs the request before calling `oauth2::reqwest::http_client`. 97 | fn execute(&self, mut request: HttpRequest) -> Result { 98 | let signed_url = self.sign_url( 99 | Url::parse(&request.uri().to_string()).expect("http::Uri should be a valid url::Url"), 100 | request.method(), 101 | request.body(), 102 | ); 103 | *request.uri_mut() = signed_url 104 | .as_str() 105 | .try_into() 106 | .expect("url::Url should be a valid http::Uri"); 107 | self.inner.call(request) 108 | } 109 | 110 | /// Signs the request based on a random and unique nonce, timestamp, and 111 | /// client id and secret. 112 | /// 113 | /// The client id, nonce, timestamp and signature are added to the url's 114 | /// query. 115 | /// 116 | /// See http://api-docs.letterboxd.com/#signing. 117 | fn sign_url(&self, mut url: Url, method: &http::method::Method, body: &[u8]) -> Url { 118 | let nonce = uuid::Uuid::new_v4(); // use UUID as random and unique nonce 119 | 120 | let timestamp = time::SystemTime::now() 121 | .duration_since(time::UNIX_EPOCH) 122 | .expect("SystemTime::duration_since failed") 123 | .as_secs(); 124 | 125 | url.query_pairs_mut() 126 | .append_pair("apikey", &self.client_id) 127 | .append_pair("nonce", &format!("{}", nonce)) 128 | .append_pair("timestamp", &format!("{}", timestamp)); 129 | 130 | // create signature 131 | let mut hmac = Hmac::::new_from_slice(self.client_secret.secret().as_bytes()) 132 | .expect("HMAC can take key of any size"); 133 | hmac.update(method.as_str().as_bytes()); 134 | hmac.update(&[b'\0']); 135 | hmac.update(url.as_str().as_bytes()); 136 | hmac.update(&[b'\0']); 137 | hmac.update(body); 138 | let signature: String = hmac.finalize().into_bytes().encode_hex(); 139 | 140 | url.query_pairs_mut().append_pair("signature", &signature); 141 | 142 | url 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /examples/google.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Google OAuth2 process for requesting access to the Google Calendar features 3 | //! and the user's profile. 4 | //! 5 | //! Before running it, you'll need to generate your own Google OAuth2 credentials. 6 | //! 7 | //! In order to run the example call: 8 | //! 9 | //! ```sh 10 | //! GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy cargo run --example google 11 | //! ``` 12 | //! 13 | //! ...and follow the instructions. 14 | //! 15 | 16 | use oauth2::reqwest; 17 | use oauth2::{basic::BasicClient, StandardRevocableToken, TokenResponse}; 18 | use oauth2::{ 19 | AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, 20 | RevocationUrl, Scope, TokenUrl, 21 | }; 22 | use url::Url; 23 | 24 | use std::env; 25 | use std::io::{BufRead, BufReader, Write}; 26 | use std::net::TcpListener; 27 | 28 | fn main() { 29 | let google_client_id = ClientId::new( 30 | env::var("GOOGLE_CLIENT_ID").expect("Missing the GOOGLE_CLIENT_ID environment variable."), 31 | ); 32 | let google_client_secret = ClientSecret::new( 33 | env::var("GOOGLE_CLIENT_SECRET") 34 | .expect("Missing the GOOGLE_CLIENT_SECRET environment variable."), 35 | ); 36 | let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()) 37 | .expect("Invalid authorization endpoint URL"); 38 | let token_url = TokenUrl::new("https://www.googleapis.com/oauth2/v3/token".to_string()) 39 | .expect("Invalid token endpoint URL"); 40 | 41 | // Set up the config for the Google OAuth2 process. 42 | let client = BasicClient::new(google_client_id) 43 | .set_client_secret(google_client_secret) 44 | .set_auth_uri(auth_url) 45 | .set_token_uri(token_url) 46 | // This example will be running its own server at localhost:8080. 47 | // See below for the server implementation. 48 | .set_redirect_uri( 49 | RedirectUrl::new("http://localhost:8080".to_string()).expect("Invalid redirect URL"), 50 | ) 51 | // Google supports OAuth 2.0 Token Revocation (RFC-7009) 52 | .set_revocation_url( 53 | RevocationUrl::new("https://oauth2.googleapis.com/revoke".to_string()) 54 | .expect("Invalid revocation endpoint URL"), 55 | ); 56 | 57 | let http_client = reqwest::blocking::ClientBuilder::new() 58 | // Following redirects opens the client up to SSRF vulnerabilities. 59 | .redirect(reqwest::redirect::Policy::none()) 60 | .build() 61 | .expect("Client should build"); 62 | 63 | // Google supports Proof Key for Code Exchange (PKCE - https://oauth.net/2/pkce/). 64 | // Create a PKCE code verifier and SHA-256 encode it as a code challenge. 65 | let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); 66 | 67 | // Generate the authorization URL to which we'll redirect the user. 68 | let (authorize_url, csrf_state) = client 69 | .authorize_url(CsrfToken::new_random) 70 | // This example is requesting access to the "calendar" features and the user's profile. 71 | .add_scope(Scope::new( 72 | "https://www.googleapis.com/auth/calendar".to_string(), 73 | )) 74 | .add_scope(Scope::new( 75 | "https://www.googleapis.com/auth/plus.me".to_string(), 76 | )) 77 | .set_pkce_challenge(pkce_code_challenge) 78 | .url(); 79 | 80 | println!("Open this URL in your browser:\n{authorize_url}\n"); 81 | 82 | let (code, state) = { 83 | // A very naive implementation of the redirect server. 84 | let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); 85 | 86 | // The server will terminate itself after collecting the first code. 87 | let Some(mut stream) = listener.incoming().flatten().next() else { 88 | panic!("listener terminated without accepting a connection"); 89 | }; 90 | 91 | let mut reader = BufReader::new(&stream); 92 | 93 | let mut request_line = String::new(); 94 | reader.read_line(&mut request_line).unwrap(); 95 | 96 | let redirect_url = request_line.split_whitespace().nth(1).unwrap(); 97 | let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); 98 | 99 | let code = url 100 | .query_pairs() 101 | .find(|(key, _)| key == "code") 102 | .map(|(_, code)| AuthorizationCode::new(code.into_owned())) 103 | .unwrap(); 104 | 105 | let state = url 106 | .query_pairs() 107 | .find(|(key, _)| key == "state") 108 | .map(|(_, state)| CsrfToken::new(state.into_owned())) 109 | .unwrap(); 110 | 111 | let message = "Go back to your terminal :)"; 112 | let response = format!( 113 | "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", 114 | message.len(), 115 | message 116 | ); 117 | stream.write_all(response.as_bytes()).unwrap(); 118 | 119 | (code, state) 120 | }; 121 | 122 | println!("Google returned the following code:\n{}\n", code.secret()); 123 | println!( 124 | "Google returned the following state:\n{} (expected `{}`)\n", 125 | state.secret(), 126 | csrf_state.secret() 127 | ); 128 | 129 | // Exchange the code with a token. 130 | let token_response = client 131 | .exchange_code(code) 132 | .set_pkce_verifier(pkce_code_verifier) 133 | .request(&http_client); 134 | 135 | println!("Google returned the following token:\n{token_response:?}\n"); 136 | 137 | // Revoke the obtained token 138 | let token_response = token_response.unwrap(); 139 | let token_to_revoke: StandardRevocableToken = match token_response.refresh_token() { 140 | Some(token) => token.into(), 141 | None => token_response.access_token().into(), 142 | }; 143 | 144 | client 145 | .revoke_token(token_to_revoke) 146 | .unwrap() 147 | .request(&http_client) 148 | .expect("Failed to revoke token"); 149 | } 150 | -------------------------------------------------------------------------------- /examples/msgraph.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Microsoft Graph OAuth2 process for requesting access to Microsoft 3 | //! services using PKCE. 4 | //! 5 | //! Before running it, you'll need to generate your own Microsoft OAuth2 credentials. See 6 | //! https://docs.microsoft.com/azure/active-directory/develop/quickstart-register-app 7 | //! * Register a `Web` application with a `Redirect URI` of `http://localhost:3003/redirect`. 8 | //! * In the left menu select `Overview`. Copy the `Application (client) ID` as the MSGRAPH_CLIENT_ID. 9 | //! * In the left menu select `Certificates & secrets` and add a new client secret. Copy the secret value 10 | //! as MSGRAPH_CLIENT_SECRET. 11 | //! * In the left menu select `API permissions` and add a permission. Select Microsoft Graph and 12 | //! `Delegated permissions`. Add the `Files.Read` permission. 13 | //! 14 | //! In order to run the example call: 15 | //! 16 | //! ```sh 17 | //! MSGRAPH_CLIENT_ID=xxx MSGRAPH_CLIENT_SECRET=yyy cargo run --example msgraph 18 | //! ``` 19 | //! 20 | //! ...and follow the instructions. 21 | //! 22 | 23 | use oauth2::basic::BasicClient; 24 | use oauth2::reqwest; 25 | use oauth2::{ 26 | AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, 27 | RedirectUrl, Scope, TokenUrl, 28 | }; 29 | use url::Url; 30 | 31 | use std::env; 32 | use std::io::{BufRead, BufReader, Write}; 33 | use std::net::TcpListener; 34 | 35 | fn main() { 36 | let graph_client_id = ClientId::new( 37 | env::var("MSGRAPH_CLIENT_ID").expect("Missing the MSGRAPH_CLIENT_ID environment variable."), 38 | ); 39 | let graph_client_secret = ClientSecret::new( 40 | env::var("MSGRAPH_CLIENT_SECRET") 41 | .expect("Missing the MSGRAPH_CLIENT_SECRET environment variable."), 42 | ); 43 | let auth_url = 44 | AuthUrl::new("https://login.microsoftonline.com/common/oauth2/v2.0/authorize".to_string()) 45 | .expect("Invalid authorization endpoint URL"); 46 | let token_url = 47 | TokenUrl::new("https://login.microsoftonline.com/common/oauth2/v2.0/token".to_string()) 48 | .expect("Invalid token endpoint URL"); 49 | 50 | // Set up the config for the Microsoft Graph OAuth2 process. 51 | let client = BasicClient::new(graph_client_id) 52 | .set_client_secret(graph_client_secret) 53 | .set_auth_uri(auth_url) 54 | .set_token_uri(token_url) 55 | // Microsoft Graph requires client_id and client_secret in URL rather than 56 | // using Basic authentication. 57 | .set_auth_type(AuthType::RequestBody) 58 | // This example will be running its own server at localhost:3003. 59 | // See below for the server implementation. 60 | .set_redirect_uri( 61 | RedirectUrl::new("http://localhost:3003/redirect".to_string()) 62 | .expect("Invalid redirect URL"), 63 | ); 64 | 65 | let http_client = reqwest::blocking::ClientBuilder::new() 66 | // Following redirects opens the client up to SSRF vulnerabilities. 67 | .redirect(reqwest::redirect::Policy::none()) 68 | .build() 69 | .expect("Client should build"); 70 | 71 | // Microsoft Graph supports Proof Key for Code Exchange (PKCE - https://oauth.net/2/pkce/). 72 | // Create a PKCE code verifier and SHA-256 encode it as a code challenge. 73 | let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); 74 | 75 | // Generate the authorization URL to which we'll redirect the user. 76 | let (authorize_url, csrf_state) = client 77 | .authorize_url(CsrfToken::new_random) 78 | // This example requests read access to OneDrive. 79 | .add_scope(Scope::new( 80 | "https://graph.microsoft.com/Files.Read".to_string(), 81 | )) 82 | .set_pkce_challenge(pkce_code_challenge) 83 | .url(); 84 | 85 | println!("Open this URL in your browser:\n{authorize_url}\n"); 86 | 87 | let (code, state) = { 88 | // A very naive implementation of the redirect server. 89 | let listener = TcpListener::bind("127.0.0.1:3003").unwrap(); 90 | 91 | // The server will terminate itself after collecting the first code. 92 | let Some(mut stream) = listener.incoming().flatten().next() else { 93 | panic!("listener terminated without accepting a connection"); 94 | }; 95 | 96 | let mut reader = BufReader::new(&stream); 97 | 98 | let mut request_line = String::new(); 99 | reader.read_line(&mut request_line).unwrap(); 100 | 101 | let redirect_url = request_line.split_whitespace().nth(1).unwrap(); 102 | let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); 103 | 104 | let code = url 105 | .query_pairs() 106 | .find(|(key, _)| key == "code") 107 | .map(|(_, code)| AuthorizationCode::new(code.into_owned())) 108 | .unwrap(); 109 | 110 | let state = url 111 | .query_pairs() 112 | .find(|(key, _)| key == "state") 113 | .map(|(_, state)| CsrfToken::new(state.into_owned())) 114 | .unwrap(); 115 | 116 | let message = "Go back to your terminal :)"; 117 | let response = format!( 118 | "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", 119 | message.len(), 120 | message 121 | ); 122 | stream.write_all(response.as_bytes()).unwrap(); 123 | 124 | (code, state) 125 | }; 126 | 127 | println!("MS Graph returned the following code:\n{}\n", code.secret()); 128 | println!( 129 | "MS Graph returned the following state:\n{} (expected `{}`)\n", 130 | state.secret(), 131 | csrf_state.secret() 132 | ); 133 | 134 | // Exchange the code with a token. 135 | let token = client 136 | .exchange_code(code) 137 | // Send the PKCE code verifier in the token request 138 | .set_pkce_verifier(pkce_code_verifier) 139 | .request(&http_client); 140 | 141 | println!("MS Graph returned the following token:\n{token:?}\n"); 142 | } 143 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use serde::de::DeserializeOwned; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use std::error::Error; 5 | use std::fmt::{Debug, Display, Formatter}; 6 | 7 | /// Server Error Response 8 | /// 9 | /// See [Section 5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) of RFC 6749. 10 | /// This trait exists separately from the `StandardErrorResponse` struct 11 | /// to support customization by clients, such as supporting interoperability with 12 | /// non-standards-complaint OAuth2 providers. 13 | /// 14 | /// The [`Display`] trait implementation for types implementing [`ErrorResponse`] should be a 15 | /// human-readable string suitable for printing (e.g., within a [`RequestTokenError`]). 16 | pub trait ErrorResponse: Debug + Display + DeserializeOwned + Serialize {} 17 | 18 | /// Error types enum. 19 | /// 20 | /// NOTE: The serialization must return the `snake_case` representation of 21 | /// this error type. This value must match the error type from the relevant OAuth 2.0 standards 22 | /// (RFC 6749 or an extension). 23 | pub trait ErrorResponseType: Debug + DeserializeOwned + Serialize {} 24 | 25 | /// Error response returned by server after requesting an access token. 26 | /// 27 | /// The fields in this structure are defined in 28 | /// [Section 5.2 of RFC 6749](https://tools.ietf.org/html/rfc6749#section-5.2). This 29 | /// trait is parameterized by a `ErrorResponseType` to support error types specific to future OAuth2 30 | /// authentication schemes and extensions. 31 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 32 | pub struct StandardErrorResponse { 33 | #[serde(bound = "T: ErrorResponseType")] 34 | pub(crate) error: T, 35 | #[serde(default)] 36 | #[serde(skip_serializing_if = "Option::is_none")] 37 | pub(crate) error_description: Option, 38 | #[serde(default)] 39 | #[serde(skip_serializing_if = "Option::is_none")] 40 | pub(crate) error_uri: Option, 41 | } 42 | 43 | impl StandardErrorResponse { 44 | /// Instantiate a new `ErrorResponse`. 45 | /// 46 | /// # Arguments 47 | /// 48 | /// * `error` - REQUIRED. A single ASCII error code deserialized to the generic parameter. 49 | /// `ErrorResponseType`. 50 | /// * `error_description` - OPTIONAL. Human-readable ASCII text providing additional 51 | /// information, used to assist the client developer in understanding the error that 52 | /// occurred. Values for this parameter MUST NOT include characters outside the set 53 | /// `%x20-21 / %x23-5B / %x5D-7E`. 54 | /// * `error_uri` - OPTIONAL. A URI identifying a human-readable web page with information 55 | /// about the error used to provide the client developer with additional information about 56 | /// the error. Values for the "error_uri" parameter MUST conform to the URI-reference 57 | /// syntax and thus MUST NOT include characters outside the set `%x21 / %x23-5B / %x5D-7E`. 58 | pub fn new(error: T, error_description: Option, error_uri: Option) -> Self { 59 | Self { 60 | error, 61 | error_description, 62 | error_uri, 63 | } 64 | } 65 | 66 | /// REQUIRED. A single ASCII error code deserialized to the generic parameter 67 | /// `ErrorResponseType`. 68 | pub fn error(&self) -> &T { 69 | &self.error 70 | } 71 | /// OPTIONAL. Human-readable ASCII text providing additional information, used to assist 72 | /// the client developer in understanding the error that occurred. Values for this 73 | /// parameter MUST NOT include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. 74 | pub fn error_description(&self) -> Option<&String> { 75 | self.error_description.as_ref() 76 | } 77 | /// OPTIONAL. URI identifying a human-readable web page with information about the error, 78 | /// used to provide the client developer with additional information about the error. 79 | /// Values for the "error_uri" parameter MUST conform to the URI-reference syntax and 80 | /// thus MUST NOT include characters outside the set `%x21 / %x23-5B / %x5D-7E`. 81 | pub fn error_uri(&self) -> Option<&String> { 82 | self.error_uri.as_ref() 83 | } 84 | } 85 | 86 | impl ErrorResponse for StandardErrorResponse where T: ErrorResponseType + Display + 'static {} 87 | 88 | impl Display for StandardErrorResponse 89 | where 90 | TE: ErrorResponseType + Display, 91 | { 92 | fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { 93 | let mut formatted = self.error().to_string(); 94 | 95 | if let Some(error_description) = self.error_description() { 96 | formatted.push_str(": "); 97 | formatted.push_str(error_description); 98 | } 99 | 100 | if let Some(error_uri) = self.error_uri() { 101 | formatted.push_str(" (see "); 102 | formatted.push_str(error_uri); 103 | formatted.push(')'); 104 | } 105 | 106 | write!(f, "{formatted}") 107 | } 108 | } 109 | 110 | /// Error encountered while requesting access token. 111 | #[derive(Debug, thiserror::Error)] 112 | pub enum RequestTokenError 113 | where 114 | RE: Error + 'static, 115 | T: ErrorResponse + 'static, 116 | { 117 | /// Error response returned by authorization server. Contains the parsed `ErrorResponse` 118 | /// returned by the server. 119 | #[error("Server returned error response: {0}")] 120 | ServerResponse(T), 121 | /// An error occurred while sending the request or receiving the response (e.g., network 122 | /// connectivity failed). 123 | #[error("Request failed")] 124 | Request(#[from] RE), 125 | /// Failed to parse server response. Parse errors may occur while parsing either successful 126 | /// or error responses. 127 | #[error("Failed to parse server response")] 128 | Parse( 129 | #[source] serde_path_to_error::Error, 130 | Vec, 131 | ), 132 | /// Some other type of error occurred (e.g., an unexpected server response). 133 | #[error("Other error: {}", _0)] 134 | Other(String), 135 | } 136 | 137 | #[cfg(test)] 138 | mod tests { 139 | use crate::basic::{BasicErrorResponse, BasicErrorResponseType}; 140 | 141 | #[test] 142 | fn test_error_response_serializer() { 143 | assert_eq!( 144 | "{\"error\":\"unauthorized_client\"}", 145 | serde_json::to_string(&BasicErrorResponse::new( 146 | BasicErrorResponseType::UnauthorizedClient, 147 | None, 148 | None, 149 | )) 150 | .unwrap(), 151 | ); 152 | 153 | assert_eq!( 154 | "{\ 155 | \"error\":\"invalid_client\",\ 156 | \"error_description\":\"Invalid client_id\",\ 157 | \"error_uri\":\"https://example.com/errors/invalid_client\"\ 158 | }", 159 | serde_json::to_string(&BasicErrorResponse::new( 160 | BasicErrorResponseType::InvalidClient, 161 | Some("Invalid client_id".to_string()), 162 | Some("https://example.com/errors/invalid_client".to_string()), 163 | )) 164 | .unwrap(), 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/basic.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | revocation::{RevocationErrorResponseType, StandardRevocableToken}, 3 | Client, EmptyExtraTokenFields, EndpointNotSet, ErrorResponseType, RequestTokenError, 4 | StandardErrorResponse, StandardTokenIntrospectionResponse, StandardTokenResponse, TokenType, 5 | }; 6 | 7 | use std::fmt::Error as FormatterError; 8 | use std::fmt::{Debug, Display, Formatter}; 9 | 10 | /// Basic OAuth2 client specialization, suitable for most applications. 11 | pub type BasicClient< 12 | HasAuthUrl = EndpointNotSet, 13 | HasDeviceAuthUrl = EndpointNotSet, 14 | HasIntrospectionUrl = EndpointNotSet, 15 | HasRevocationUrl = EndpointNotSet, 16 | HasTokenUrl = EndpointNotSet, 17 | > = Client< 18 | BasicErrorResponse, 19 | BasicTokenResponse, 20 | BasicTokenIntrospectionResponse, 21 | StandardRevocableToken, 22 | BasicRevocationErrorResponse, 23 | HasAuthUrl, 24 | HasDeviceAuthUrl, 25 | HasIntrospectionUrl, 26 | HasRevocationUrl, 27 | HasTokenUrl, 28 | >; 29 | 30 | /// Basic OAuth2 authorization token types. 31 | #[derive(Clone, Debug, PartialEq, Eq)] 32 | pub enum BasicTokenType { 33 | /// Bearer token 34 | /// ([OAuth 2.0 Bearer Tokens - RFC 6750](https://tools.ietf.org/html/rfc6750)). 35 | Bearer, 36 | /// MAC ([OAuth 2.0 Message Authentication Code (MAC) 37 | /// Tokens](https://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-05)). 38 | Mac, 39 | /// An extension not defined by RFC 6749. 40 | Extension(String), 41 | } 42 | impl BasicTokenType { 43 | fn from_str(s: &str) -> Self { 44 | match s { 45 | "bearer" => BasicTokenType::Bearer, 46 | "mac" => BasicTokenType::Mac, 47 | ext => BasicTokenType::Extension(ext.to_string()), 48 | } 49 | } 50 | } 51 | impl AsRef for BasicTokenType { 52 | fn as_ref(&self) -> &str { 53 | match *self { 54 | BasicTokenType::Bearer => "bearer", 55 | BasicTokenType::Mac => "mac", 56 | BasicTokenType::Extension(ref ext) => ext.as_str(), 57 | } 58 | } 59 | } 60 | impl<'de> serde::Deserialize<'de> for BasicTokenType { 61 | fn deserialize(deserializer: D) -> Result 62 | where 63 | D: serde::de::Deserializer<'de>, 64 | { 65 | let variant_str = String::deserialize(deserializer)?; 66 | Ok(Self::from_str(&variant_str)) 67 | } 68 | } 69 | impl serde::ser::Serialize for BasicTokenType { 70 | fn serialize(&self, serializer: S) -> Result 71 | where 72 | S: serde::ser::Serializer, 73 | { 74 | serializer.serialize_str(self.as_ref()) 75 | } 76 | } 77 | impl TokenType for BasicTokenType {} 78 | 79 | /// Basic OAuth2 token response. 80 | pub type BasicTokenResponse = StandardTokenResponse; 81 | 82 | /// Basic OAuth2 token introspection response. 83 | pub type BasicTokenIntrospectionResponse = 84 | StandardTokenIntrospectionResponse; 85 | 86 | /// Basic access token error types. 87 | /// 88 | /// These error types are defined in 89 | /// [Section 5.2 of RFC 6749](https://tools.ietf.org/html/rfc6749#section-5.2). 90 | #[derive(Clone, PartialEq, Eq)] 91 | pub enum BasicErrorResponseType { 92 | /// Client authentication failed (e.g., unknown client, no client authentication included, 93 | /// or unsupported authentication method). 94 | InvalidClient, 95 | /// The provided authorization grant (e.g., authorization code, resource owner credentials) 96 | /// or refresh token is invalid, expired, revoked, does not match the redirection URI used 97 | /// in the authorization request, or was issued to another client. 98 | InvalidGrant, 99 | /// The request is missing a required parameter, includes an unsupported parameter value 100 | /// (other than grant type), repeats a parameter, includes multiple credentials, utilizes 101 | /// more than one mechanism for authenticating the client, or is otherwise malformed. 102 | InvalidRequest, 103 | /// The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the 104 | /// resource owner. 105 | InvalidScope, 106 | /// The authenticated client is not authorized to use this authorization grant type. 107 | UnauthorizedClient, 108 | /// The authorization grant type is not supported by the authorization server. 109 | UnsupportedGrantType, 110 | /// An extension not defined by RFC 6749. 111 | Extension(String), 112 | } 113 | impl BasicErrorResponseType { 114 | pub(crate) fn from_str(s: &str) -> Self { 115 | match s { 116 | "invalid_client" => BasicErrorResponseType::InvalidClient, 117 | "invalid_grant" => BasicErrorResponseType::InvalidGrant, 118 | "invalid_request" => BasicErrorResponseType::InvalidRequest, 119 | "invalid_scope" => BasicErrorResponseType::InvalidScope, 120 | "unauthorized_client" => BasicErrorResponseType::UnauthorizedClient, 121 | "unsupported_grant_type" => BasicErrorResponseType::UnsupportedGrantType, 122 | ext => BasicErrorResponseType::Extension(ext.to_string()), 123 | } 124 | } 125 | } 126 | impl AsRef for BasicErrorResponseType { 127 | fn as_ref(&self) -> &str { 128 | match *self { 129 | BasicErrorResponseType::InvalidClient => "invalid_client", 130 | BasicErrorResponseType::InvalidGrant => "invalid_grant", 131 | BasicErrorResponseType::InvalidRequest => "invalid_request", 132 | BasicErrorResponseType::InvalidScope => "invalid_scope", 133 | BasicErrorResponseType::UnauthorizedClient => "unauthorized_client", 134 | BasicErrorResponseType::UnsupportedGrantType => "unsupported_grant_type", 135 | BasicErrorResponseType::Extension(ref ext) => ext.as_str(), 136 | } 137 | } 138 | } 139 | impl<'de> serde::Deserialize<'de> for BasicErrorResponseType { 140 | fn deserialize(deserializer: D) -> Result 141 | where 142 | D: serde::de::Deserializer<'de>, 143 | { 144 | let variant_str = String::deserialize(deserializer)?; 145 | Ok(Self::from_str(&variant_str)) 146 | } 147 | } 148 | impl serde::ser::Serialize for BasicErrorResponseType { 149 | fn serialize(&self, serializer: S) -> Result 150 | where 151 | S: serde::ser::Serializer, 152 | { 153 | serializer.serialize_str(self.as_ref()) 154 | } 155 | } 156 | impl ErrorResponseType for BasicErrorResponseType {} 157 | impl Debug for BasicErrorResponseType { 158 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 159 | Display::fmt(self, f) 160 | } 161 | } 162 | 163 | impl Display for BasicErrorResponseType { 164 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 165 | write!(f, "{}", self.as_ref()) 166 | } 167 | } 168 | 169 | /// Error response specialization for basic OAuth2 implementation. 170 | pub type BasicErrorResponse = StandardErrorResponse; 171 | 172 | /// Token error specialization for basic OAuth2 implementation. 173 | pub type BasicRequestTokenError = RequestTokenError; 174 | 175 | /// Revocation error response specialization for basic OAuth2 implementation. 176 | pub type BasicRevocationErrorResponse = StandardErrorResponse; 177 | -------------------------------------------------------------------------------- /examples/wunderlist.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! This example showcases the Wunderlist OAuth2 process for requesting access to the user's todo lists. 3 | //! Wunderlist does not implement the correct token response, so this serves as an example of how to 4 | //! implement a custom client. 5 | //! 6 | //! Before running it, you'll need to create your own wunderlist app. 7 | //! 8 | //! In order to run the example call: 9 | //! 10 | //! ```sh 11 | //! WUNDER_CLIENT_ID=xxx WUNDER_CLIENT_SECRET=yyy cargo run --example wunderlist 12 | //! ``` 13 | //! 14 | //! ...and follow the instructions. 15 | //! 16 | 17 | use oauth2::basic::{ 18 | BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, 19 | BasicTokenType, 20 | }; 21 | use oauth2::reqwest; 22 | use oauth2::StandardRevocableToken; 23 | use oauth2::{ 24 | AccessToken, AuthUrl, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, 25 | EmptyExtraTokenFields, EndpointNotSet, ExtraTokenFields, RedirectUrl, RefreshToken, Scope, 26 | TokenResponse, TokenUrl, 27 | }; 28 | use serde::{Deserialize, Serialize}; 29 | use url::Url; 30 | 31 | use std::env; 32 | use std::io::{BufRead, BufReader, Write}; 33 | use std::net::TcpListener; 34 | use std::time::Duration; 35 | 36 | type SpecialTokenResponse = NonStandardTokenResponse; 37 | type SpecialClient< 38 | HasAuthUrl = EndpointNotSet, 39 | HasDeviceAuthUrl = EndpointNotSet, 40 | HasIntrospectionUrl = EndpointNotSet, 41 | HasRevocationUrl = EndpointNotSet, 42 | HasTokenUrl = EndpointNotSet, 43 | > = Client< 44 | BasicErrorResponse, 45 | SpecialTokenResponse, 46 | BasicTokenIntrospectionResponse, 47 | StandardRevocableToken, 48 | BasicRevocationErrorResponse, 49 | HasAuthUrl, 50 | HasDeviceAuthUrl, 51 | HasIntrospectionUrl, 52 | HasRevocationUrl, 53 | HasTokenUrl, 54 | >; 55 | 56 | fn default_token_type() -> Option { 57 | Some(BasicTokenType::Bearer) 58 | } 59 | 60 | /// Non Standard OAuth2 token response. 61 | /// 62 | /// This struct includes the fields defined in 63 | /// [Section 5.1 of RFC 6749](https://tools.ietf.org/html/rfc6749#section-5.1), as well as 64 | /// extensions defined by the `EF` type parameter. 65 | /// In this particular example token_type is optional to showcase how to deal with a non 66 | /// compliant provider. 67 | #[derive(Clone, Debug, Deserialize, Serialize)] 68 | pub struct NonStandardTokenResponse { 69 | access_token: AccessToken, 70 | // In this example wunderlist does not follow the RFC specs and don't return the 71 | // token_type. `NonStandardTokenResponse` makes the `token_type` optional. 72 | #[serde(default = "default_token_type")] 73 | token_type: Option, 74 | #[serde(skip_serializing_if = "Option::is_none")] 75 | expires_in: Option, 76 | #[serde(skip_serializing_if = "Option::is_none")] 77 | refresh_token: Option, 78 | #[serde(rename = "scope")] 79 | #[serde(deserialize_with = "oauth2::helpers::deserialize_space_delimited_vec")] 80 | #[serde(serialize_with = "oauth2::helpers::serialize_space_delimited_vec")] 81 | #[serde(skip_serializing_if = "Option::is_none")] 82 | #[serde(default)] 83 | scopes: Option>, 84 | 85 | #[serde(bound = "EF: ExtraTokenFields")] 86 | #[serde(flatten)] 87 | extra_fields: EF, 88 | } 89 | 90 | impl TokenResponse for NonStandardTokenResponse 91 | where 92 | EF: ExtraTokenFields, 93 | { 94 | type TokenType = BasicTokenType; 95 | /// REQUIRED. The access token issued by the authorization server. 96 | fn access_token(&self) -> &AccessToken { 97 | &self.access_token 98 | } 99 | /// REQUIRED. The type of the token issued as described in 100 | /// [Section 7.1](https://tools.ietf.org/html/rfc6749#section-7.1). 101 | /// Value is case insensitive and deserialized to the generic `TokenType` parameter. 102 | /// But in this particular case as the service is non compliant, it has a default value 103 | fn token_type(&self) -> &BasicTokenType { 104 | match &self.token_type { 105 | Some(t) => t, 106 | None => &BasicTokenType::Bearer, 107 | } 108 | } 109 | /// RECOMMENDED. The lifetime in seconds of the access token. For example, the value 3600 110 | /// denotes that the access token will expire in one hour from the time the response was 111 | /// generated. If omitted, the authorization server SHOULD provide the expiration time via 112 | /// other means or document the default value. 113 | fn expires_in(&self) -> Option { 114 | self.expires_in.map(Duration::from_secs) 115 | } 116 | /// OPTIONAL. The refresh token, which can be used to obtain new access tokens using the same 117 | /// authorization grant as described in 118 | /// [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 119 | fn refresh_token(&self) -> Option<&RefreshToken> { 120 | self.refresh_token.as_ref() 121 | } 122 | /// OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED. The 123 | /// scope of the access token as described by 124 | /// [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). If included in the response, 125 | /// this space-delimited field is parsed into a `Vec` of individual scopes. If omitted from 126 | /// the response, this field is `None`. 127 | fn scopes(&self) -> Option<&Vec> { 128 | self.scopes.as_ref() 129 | } 130 | } 131 | 132 | fn main() { 133 | let client_id_str = env::var("WUNDERLIST_CLIENT_ID") 134 | .expect("Missing the WUNDERLIST_CLIENT_ID environment variable."); 135 | 136 | let client_secret_str = env::var("WUNDERLIST_CLIENT_SECRET") 137 | .expect("Missing the WUNDERLIST_CLIENT_SECRET environment variable."); 138 | 139 | let wunder_client_id = ClientId::new(client_id_str.clone()); 140 | let wunderlist_client_secret = ClientSecret::new(client_secret_str.clone()); 141 | let auth_url = AuthUrl::new("https://www.wunderlist.com/oauth/authorize".to_string()) 142 | .expect("Invalid authorization endpoint URL"); 143 | let token_url = TokenUrl::new("https://www.wunderlist.com/oauth/access_token".to_string()) 144 | .expect("Invalid token endpoint URL"); 145 | 146 | // Set up the config for the Wunderlist OAuth2 process. 147 | let client = SpecialClient::new(wunder_client_id) 148 | .set_client_secret(wunderlist_client_secret) 149 | .set_auth_uri(auth_url) 150 | .set_token_uri(token_url) 151 | // This example will be running its own server at localhost:8080. 152 | // See below for the server implementation. 153 | .set_redirect_uri( 154 | RedirectUrl::new("http://localhost:8080".to_string()).expect("Invalid redirect URL"), 155 | ); 156 | 157 | let http_client = reqwest::blocking::ClientBuilder::new() 158 | // Following redirects opens the client up to SSRF vulnerabilities. 159 | .redirect(reqwest::redirect::Policy::none()) 160 | .build() 161 | .expect("Client should build"); 162 | 163 | // Generate the authorization URL to which we'll redirect the user. 164 | let (authorize_url, csrf_state) = client.authorize_url(CsrfToken::new_random).url(); 165 | 166 | println!("Open this URL in your browser:\n{authorize_url}\n"); 167 | 168 | let (code, state) = { 169 | // A very naive implementation of the redirect server. 170 | let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); 171 | 172 | // The server will terminate itself after collecting the first code. 173 | let Some(mut stream) = listener.incoming().flatten().next() else { 174 | panic!("listener terminated without accepting a connection"); 175 | }; 176 | 177 | let mut reader = BufReader::new(&stream); 178 | 179 | let mut request_line = String::new(); 180 | reader.read_line(&mut request_line).unwrap(); 181 | 182 | let redirect_url = request_line.split_whitespace().nth(1).unwrap(); 183 | let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); 184 | 185 | let code = url 186 | .query_pairs() 187 | .find(|(key, _)| key == "code") 188 | .map(|(_, code)| AuthorizationCode::new(code.into_owned())) 189 | .unwrap(); 190 | 191 | let state = url 192 | .query_pairs() 193 | .find(|(key, _)| key == "state") 194 | .map(|(_, state)| CsrfToken::new(state.into_owned())) 195 | .unwrap(); 196 | 197 | let message = "Go back to your terminal :)"; 198 | let response = format!( 199 | "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", 200 | message.len(), 201 | message 202 | ); 203 | stream.write_all(response.as_bytes()).unwrap(); 204 | 205 | (code, state) 206 | }; 207 | 208 | println!( 209 | "Wunderlist returned the following code:\n{}\n", 210 | code.secret() 211 | ); 212 | println!( 213 | "Wunderlist returned the following state:\n{} (expected `{}`)\n", 214 | state.secret(), 215 | csrf_state.secret() 216 | ); 217 | 218 | // Exchange the code with a token. 219 | let token_res = client 220 | .exchange_code(code) 221 | .add_extra_param("client_id", client_id_str) 222 | .add_extra_param("client_secret", client_secret_str) 223 | .request(&http_client); 224 | 225 | println!("Wunderlist returned the following token:\n{token_res:?}\n"); 226 | } 227 | -------------------------------------------------------------------------------- /src/endpoint.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | AuthType, ClientId, ClientSecret, ErrorResponse, RedirectUrl, RequestTokenError, Scope, 3 | CONTENT_TYPE_FORMENCODED, CONTENT_TYPE_JSON, 4 | }; 5 | 6 | use base64::prelude::*; 7 | use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; 8 | use http::{HeaderValue, StatusCode}; 9 | use serde::de::DeserializeOwned; 10 | use url::{form_urlencoded, Url}; 11 | 12 | use std::borrow::Cow; 13 | use std::error::Error; 14 | use std::future::Future; 15 | 16 | /// An HTTP request. 17 | pub type HttpRequest = http::Request>; 18 | 19 | /// An HTTP response. 20 | pub type HttpResponse = http::Response>; 21 | 22 | /// An asynchronous (future-based) HTTP client. 23 | pub trait AsyncHttpClient<'c> { 24 | /// Error type returned by HTTP client. 25 | type Error: Error + 'static; 26 | 27 | /// Future type returned by HTTP client. 28 | type Future: Future> + 'c; 29 | 30 | /// Perform a single HTTP request. 31 | fn call(&'c self, request: HttpRequest) -> Self::Future; 32 | } 33 | impl<'c, E, F, T> AsyncHttpClient<'c> for T 34 | where 35 | E: Error + 'static, 36 | F: Future> + 'c, 37 | // We can't implement this for FnOnce because the device authorization flow requires clients to 38 | // supportmultiple calls. 39 | T: Fn(HttpRequest) -> F, 40 | { 41 | type Error = E; 42 | type Future = F; 43 | 44 | fn call(&'c self, request: HttpRequest) -> Self::Future { 45 | self(request) 46 | } 47 | } 48 | 49 | /// A synchronous (blocking) HTTP client. 50 | pub trait SyncHttpClient { 51 | /// Error type returned by HTTP client. 52 | type Error: Error + 'static; 53 | 54 | /// Perform a single HTTP request. 55 | fn call(&self, request: HttpRequest) -> Result; 56 | } 57 | impl SyncHttpClient for T 58 | where 59 | E: Error + 'static, 60 | // We can't implement this for FnOnce because the device authorization flow requires clients to 61 | // support multiple calls. 62 | T: Fn(HttpRequest) -> Result, 63 | { 64 | type Error = E; 65 | 66 | fn call(&self, request: HttpRequest) -> Result { 67 | self(request) 68 | } 69 | } 70 | 71 | #[allow(clippy::too_many_arguments)] 72 | pub(crate) fn endpoint_request<'a>( 73 | auth_type: &'a AuthType, 74 | client_id: &'a ClientId, 75 | client_secret: Option<&'a ClientSecret>, 76 | extra_params: &'a [(Cow<'a, str>, Cow<'a, str>)], 77 | redirect_url: Option>, 78 | scopes: Option<&'a Vec>>, 79 | url: &'a Url, 80 | params: Vec<(&'a str, &'a str)>, 81 | ) -> Result { 82 | let mut builder = http::Request::builder() 83 | .uri(url.to_string()) 84 | .method(http::Method::POST) 85 | .header(ACCEPT, HeaderValue::from_static(CONTENT_TYPE_JSON)) 86 | .header( 87 | CONTENT_TYPE, 88 | HeaderValue::from_static(CONTENT_TYPE_FORMENCODED), 89 | ); 90 | 91 | let scopes_opt = scopes.and_then(|scopes| { 92 | if !scopes.is_empty() { 93 | Some( 94 | scopes 95 | .iter() 96 | .map(|s| s.to_string()) 97 | .collect::>() 98 | .join(" "), 99 | ) 100 | } else { 101 | None 102 | } 103 | }); 104 | 105 | let mut params: Vec<(&str, &str)> = params; 106 | if let Some(ref scopes) = scopes_opt { 107 | params.push(("scope", scopes)); 108 | } 109 | 110 | // FIXME: add support for auth extensions? e.g., client_secret_jwt and private_key_jwt 111 | match (auth_type, client_secret) { 112 | // Basic auth only makes sense when a client secret is provided. Otherwise, always pass the 113 | // client ID in the request body. 114 | (AuthType::BasicAuth, Some(secret)) => { 115 | // Section 2.3.1 of RFC 6749 requires separately url-encoding the id and secret 116 | // before using them as HTTP Basic auth username and password. Note that this is 117 | // not standard for ordinary Basic auth, so curl won't do it for us. 118 | let urlencoded_id: String = 119 | form_urlencoded::byte_serialize(client_id.as_bytes()).collect(); 120 | let urlencoded_secret: String = 121 | form_urlencoded::byte_serialize(secret.secret().as_bytes()).collect(); 122 | let b64_credential = 123 | BASE64_STANDARD.encode(format!("{}:{}", &urlencoded_id, urlencoded_secret)); 124 | builder = builder.header( 125 | AUTHORIZATION, 126 | HeaderValue::from_str(&format!("Basic {}", &b64_credential)).unwrap(), 127 | ); 128 | } 129 | (AuthType::RequestBody, _) | (AuthType::BasicAuth, None) => { 130 | params.push(("client_id", client_id)); 131 | if let Some(client_secret) = client_secret { 132 | params.push(("client_secret", client_secret.secret())); 133 | } 134 | } 135 | } 136 | 137 | if let Some(ref redirect_url) = redirect_url { 138 | params.push(("redirect_uri", redirect_url.as_str())); 139 | } 140 | 141 | params.extend_from_slice( 142 | extra_params 143 | .iter() 144 | .map(|(k, v)| (k.as_ref(), v.as_ref())) 145 | .collect::>() 146 | .as_slice(), 147 | ); 148 | 149 | let body = form_urlencoded::Serializer::new(String::new()) 150 | .extend_pairs(params) 151 | .finish() 152 | .into_bytes(); 153 | 154 | builder.body(body) 155 | } 156 | 157 | pub(crate) fn endpoint_response( 158 | http_response: HttpResponse, 159 | ) -> Result> 160 | where 161 | RE: Error, 162 | TE: ErrorResponse, 163 | DO: DeserializeOwned, 164 | { 165 | check_response_status(&http_response)?; 166 | 167 | check_response_body(&http_response)?; 168 | 169 | let response_body = http_response.body().as_slice(); 170 | serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_slice(response_body)) 171 | .map_err(|e| RequestTokenError::Parse(e, response_body.to_vec())) 172 | } 173 | 174 | pub(crate) fn endpoint_response_status_only( 175 | http_response: HttpResponse, 176 | ) -> Result<(), RequestTokenError> 177 | where 178 | RE: Error + 'static, 179 | TE: ErrorResponse, 180 | { 181 | check_response_status(&http_response) 182 | } 183 | 184 | fn check_response_status( 185 | http_response: &HttpResponse, 186 | ) -> Result<(), RequestTokenError> 187 | where 188 | RE: Error + 'static, 189 | TE: ErrorResponse, 190 | { 191 | if http_response.status() != StatusCode::OK { 192 | let reason = http_response.body().as_slice(); 193 | if reason.is_empty() { 194 | Err(RequestTokenError::Other( 195 | "server returned empty error response".to_string(), 196 | )) 197 | } else { 198 | let error = match serde_path_to_error::deserialize::<_, TE>( 199 | &mut serde_json::Deserializer::from_slice(reason), 200 | ) { 201 | Ok(error) => RequestTokenError::ServerResponse(error), 202 | Err(error) => RequestTokenError::Parse(error, reason.to_vec()), 203 | }; 204 | Err(error) 205 | } 206 | } else { 207 | Ok(()) 208 | } 209 | } 210 | 211 | fn check_response_body( 212 | http_response: &HttpResponse, 213 | ) -> Result<(), RequestTokenError> 214 | where 215 | RE: Error + 'static, 216 | TE: ErrorResponse, 217 | { 218 | // Validate that the response Content-Type is JSON. 219 | http_response 220 | .headers() 221 | .get(CONTENT_TYPE) 222 | .map_or(Ok(()), |content_type| 223 | // Section 3.1.1.1 of RFC 7231 indicates that media types are case-insensitive and 224 | // may be followed by optional whitespace and/or a parameter (e.g., charset). 225 | // See https://tools.ietf.org/html/rfc7231#section-3.1.1.1. 226 | if content_type.to_str().ok().filter(|ct| ct.to_lowercase().starts_with(CONTENT_TYPE_JSON)).is_none() { 227 | Err( 228 | RequestTokenError::Other( 229 | format!( 230 | "unexpected response Content-Type: {content_type:?}, should be `{CONTENT_TYPE_JSON}`", 231 | ) 232 | ) 233 | ) 234 | } else { 235 | Ok(()) 236 | } 237 | )?; 238 | 239 | if http_response.body().is_empty() { 240 | return Err(RequestTokenError::Other( 241 | "server returned empty response body".to_string(), 242 | )); 243 | } 244 | 245 | Ok(()) 246 | } 247 | 248 | #[cfg(test)] 249 | mod tests { 250 | use crate::tests::{new_client, FakeError}; 251 | use crate::{AuthorizationCode, TokenResponse}; 252 | 253 | use http::{Response, StatusCode}; 254 | 255 | #[tokio::test] 256 | async fn test_async_client_closure() { 257 | let client = new_client(); 258 | 259 | let http_response = Response::builder() 260 | .status(StatusCode::OK) 261 | .body( 262 | "{\"access_token\": \"12/34\", \"token_type\": \"BEARER\"}" 263 | .to_string() 264 | .into_bytes(), 265 | ) 266 | .unwrap(); 267 | 268 | let token = client 269 | .exchange_code(AuthorizationCode::new("ccc".to_string())) 270 | // NB: This tests that the closure doesn't require a static lifetime. 271 | .request_async(&|_| async { Ok(http_response.clone()) as Result<_, FakeError> }) 272 | .await 273 | .unwrap(); 274 | 275 | assert_eq!("12/34", token.access_token().secret()); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /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. 202 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use crate::basic::{ 2 | BasicClient, BasicErrorResponseType, BasicRevocationErrorResponse, BasicTokenType, 3 | }; 4 | use crate::{ 5 | AccessToken, AuthType, AuthUrl, AuthorizationCode, AuthorizationRequest, Client, 6 | ClientCredentialsTokenRequest, ClientId, ClientSecret, CodeTokenRequest, CsrfToken, 7 | DeviceAccessTokenRequest, DeviceAuthorizationRequest, DeviceAuthorizationUrl, DeviceCode, 8 | DeviceCodeErrorResponse, DeviceCodeErrorResponseType, EmptyExtraDeviceAuthorizationFields, 9 | EmptyExtraTokenFields, EndUserVerificationUrl, EndpointNotSet, EndpointSet, HttpClientError, 10 | HttpRequest, HttpResponse, PasswordTokenRequest, PkceCodeChallenge, PkceCodeChallengeMethod, 11 | PkceCodeVerifier, RedirectUrl, RefreshToken, RefreshTokenRequest, RequestTokenError, 12 | ResourceOwnerPassword, ResourceOwnerUsername, ResponseType, Scope, 13 | StandardDeviceAuthorizationResponse, StandardErrorResponse, StandardRevocableToken, 14 | StandardTokenIntrospectionResponse, StandardTokenResponse, TokenUrl, UserCode, 15 | }; 16 | 17 | use http::header::HeaderName; 18 | use http::HeaderValue; 19 | use thiserror::Error; 20 | use url::Url; 21 | 22 | pub(crate) fn new_client( 23 | ) -> BasicClient { 24 | BasicClient::new(ClientId::new("aaa".to_string())) 25 | .set_auth_uri(AuthUrl::new("https://example.com/auth".to_string()).unwrap()) 26 | .set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap()) 27 | .set_client_secret(ClientSecret::new("bbb".to_string())) 28 | } 29 | 30 | pub(crate) fn mock_http_client( 31 | request_headers: Vec<(HeaderName, &'static str)>, 32 | request_body: &'static str, 33 | request_url: Option, 34 | response: HttpResponse, 35 | ) -> impl Fn(HttpRequest) -> Result { 36 | move |request: HttpRequest| { 37 | assert_eq!( 38 | &Url::parse(&request.uri().to_string()).unwrap(), 39 | request_url 40 | .as_ref() 41 | .unwrap_or(&Url::parse("https://example.com/token").unwrap()) 42 | ); 43 | assert_eq!( 44 | request.headers(), 45 | &request_headers 46 | .iter() 47 | .map(|(name, value)| (name.clone(), HeaderValue::from_str(value).unwrap())) 48 | .collect(), 49 | ); 50 | assert_eq!( 51 | &String::from_utf8(request.body().to_owned()).unwrap(), 52 | request_body 53 | ); 54 | 55 | Ok(response.clone()) 56 | } 57 | } 58 | 59 | #[derive(Debug, Error)] 60 | pub(crate) enum FakeError { 61 | #[error("error")] 62 | Err, 63 | } 64 | 65 | pub(crate) mod colorful_extension { 66 | extern crate serde_json; 67 | 68 | use crate::{ 69 | Client, ErrorResponseType, ExtraTokenFields, RevocableToken, StandardErrorResponse, 70 | StandardTokenIntrospectionResponse, StandardTokenResponse, TokenType, 71 | }; 72 | 73 | use serde::{Deserialize, Serialize}; 74 | 75 | use std::fmt::Error as FormatterError; 76 | use std::fmt::{Debug, Display, Formatter}; 77 | 78 | pub type ColorfulClient< 79 | HasAuthUrl, 80 | HasDeviceAuthUrl, 81 | HasIntrospectionUrl, 82 | HasRevocationUrl, 83 | HasTokenUrl, 84 | > = Client< 85 | StandardErrorResponse, 86 | StandardTokenResponse, 87 | StandardTokenIntrospectionResponse, 88 | ColorfulRevocableToken, 89 | StandardErrorResponse, 90 | HasAuthUrl, 91 | HasDeviceAuthUrl, 92 | HasIntrospectionUrl, 93 | HasRevocationUrl, 94 | HasTokenUrl, 95 | >; 96 | 97 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 98 | #[serde(rename_all = "lowercase")] 99 | pub enum ColorfulTokenType { 100 | Green, 101 | Red, 102 | } 103 | impl TokenType for ColorfulTokenType {} 104 | 105 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 106 | pub struct ColorfulFields { 107 | #[serde(rename = "shape")] 108 | #[serde(skip_serializing_if = "Option::is_none")] 109 | pub shape: Option, 110 | #[serde(rename = "height")] 111 | pub height: u32, 112 | } 113 | impl ColorfulFields { 114 | pub fn shape(&self) -> Option<&String> { 115 | self.shape.as_ref() 116 | } 117 | pub fn height(&self) -> u32 { 118 | self.height 119 | } 120 | } 121 | impl ExtraTokenFields for ColorfulFields {} 122 | 123 | #[derive(Clone, Deserialize, PartialEq, Eq, Serialize)] 124 | #[serde(rename_all = "snake_case")] 125 | pub enum ColorfulErrorResponseType { 126 | TooDark, 127 | TooLight, 128 | WrongColorSpace, 129 | } 130 | 131 | impl ColorfulErrorResponseType { 132 | fn to_str(&self) -> &str { 133 | match self { 134 | ColorfulErrorResponseType::TooDark => "too_dark", 135 | ColorfulErrorResponseType::TooLight => "too_light", 136 | ColorfulErrorResponseType::WrongColorSpace => "wrong_color_space", 137 | } 138 | } 139 | } 140 | 141 | impl ErrorResponseType for ColorfulErrorResponseType {} 142 | 143 | impl Debug for ColorfulErrorResponseType { 144 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 145 | Display::fmt(self, f) 146 | } 147 | } 148 | 149 | impl Display for ColorfulErrorResponseType { 150 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 151 | let message: &str = self.to_str(); 152 | 153 | write!(f, "{message}") 154 | } 155 | } 156 | 157 | pub type ColorfulTokenResponse = StandardTokenResponse; 158 | 159 | pub enum ColorfulRevocableToken { 160 | Red(String), 161 | } 162 | impl RevocableToken for ColorfulRevocableToken { 163 | fn secret(&self) -> &str { 164 | match self { 165 | ColorfulRevocableToken::Red(secret) => secret, 166 | } 167 | } 168 | 169 | fn type_hint(&self) -> Option<&str> { 170 | match self { 171 | ColorfulRevocableToken::Red(_) => Some("red_token"), 172 | } 173 | } 174 | } 175 | } 176 | 177 | pub(crate) fn mock_http_client_success_fail( 178 | request_url: Option, 179 | request_headers: Vec<(HeaderName, &'static str)>, 180 | request_body: &'static str, 181 | failure_response: HttpResponse, 182 | num_failures: usize, 183 | success_response: HttpResponse, 184 | ) -> impl Fn(HttpRequest) -> Result { 185 | let responses: Vec = std::iter::from_fn(|| Some(failure_response.clone())) 186 | .take(num_failures) 187 | .chain(std::iter::once(success_response)) 188 | .collect(); 189 | let sync_responses = std::sync::Mutex::new(responses); 190 | 191 | move |request: HttpRequest| { 192 | assert_eq!( 193 | &Url::parse(&request.uri().to_string()).unwrap(), 194 | request_url 195 | .as_ref() 196 | .unwrap_or(&Url::parse("https://example.com/token").unwrap()) 197 | ); 198 | assert_eq!( 199 | request.headers(), 200 | &request_headers 201 | .iter() 202 | .map(|(name, value)| (name.clone(), HeaderValue::from_str(value).unwrap())) 203 | .collect(), 204 | ); 205 | assert_eq!( 206 | &String::from_utf8(request.body().to_owned()).unwrap(), 207 | request_body 208 | ); 209 | 210 | { 211 | let mut rsp_vec = sync_responses.lock().unwrap(); 212 | if rsp_vec.len() == 0 { 213 | Err(FakeError::Err) 214 | } else { 215 | Ok(rsp_vec.remove(0)) 216 | } 217 | } 218 | } 219 | } 220 | 221 | #[test] 222 | fn test_send_sync_impl() { 223 | fn is_sync_and_send() {} 224 | #[derive(Debug)] 225 | struct TestError; 226 | impl std::fmt::Display for TestError { 227 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 228 | write!(f, "TestError") 229 | } 230 | } 231 | impl std::error::Error for TestError {} 232 | 233 | is_sync_and_send::(); 234 | is_sync_and_send::(); 235 | is_sync_and_send::(); 236 | is_sync_and_send::(); 237 | is_sync_and_send::< 238 | Client< 239 | StandardErrorResponse, 240 | StandardTokenResponse, 241 | StandardTokenIntrospectionResponse, 242 | StandardRevocableToken, 243 | BasicRevocationErrorResponse, 244 | EndpointNotSet, 245 | EndpointNotSet, 246 | EndpointNotSet, 247 | EndpointNotSet, 248 | EndpointNotSet, 249 | >, 250 | >(); 251 | is_sync_and_send::< 252 | ClientCredentialsTokenRequest< 253 | StandardErrorResponse, 254 | StandardTokenResponse, 255 | >, 256 | >(); 257 | is_sync_and_send::(); 258 | is_sync_and_send::(); 259 | is_sync_and_send::< 260 | CodeTokenRequest< 261 | StandardErrorResponse, 262 | StandardTokenResponse, 263 | >, 264 | >(); 265 | is_sync_and_send::(); 266 | is_sync_and_send::(); 267 | is_sync_and_send::(); 268 | is_sync_and_send::(); 269 | is_sync_and_send::< 270 | PasswordTokenRequest< 271 | StandardErrorResponse, 272 | StandardTokenResponse, 273 | >, 274 | >(); 275 | is_sync_and_send::(); 276 | is_sync_and_send::(); 277 | is_sync_and_send::(); 278 | is_sync_and_send::(); 279 | is_sync_and_send::(); 280 | is_sync_and_send::< 281 | RefreshTokenRequest< 282 | StandardErrorResponse, 283 | StandardTokenResponse, 284 | >, 285 | >(); 286 | is_sync_and_send::(); 287 | is_sync_and_send::(); 288 | is_sync_and_send::(); 289 | is_sync_and_send::(); 290 | is_sync_and_send::>(); 291 | is_sync_and_send::>(); 292 | is_sync_and_send::(); 293 | 294 | is_sync_and_send::(); 295 | is_sync_and_send::(); 296 | is_sync_and_send::(); 297 | is_sync_and_send::>>( 298 | ); 299 | 300 | is_sync_and_send::(); 301 | is_sync_and_send::(); 302 | is_sync_and_send::(); 303 | is_sync_and_send::(); 304 | is_sync_and_send::(); 305 | is_sync_and_send::< 306 | DeviceAccessTokenRequest< 307 | StandardTokenResponse, 308 | EmptyExtraDeviceAuthorizationFields, 309 | >, 310 | >(); 311 | is_sync_and_send::>>(); 312 | is_sync_and_send::(); 313 | is_sync_and_send::(); 314 | 315 | #[cfg(feature = "curl")] 316 | is_sync_and_send::>(); 317 | #[cfg(any(feature = "reqwest", feature = "reqwest-blocking"))] 318 | is_sync_and_send::>(); 319 | #[cfg(feature = "ureq")] 320 | is_sync_and_send::>(); 321 | } 322 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Upgrading from 4.x to 5.x 4 | 5 | The 5.0 release includes breaking changes to address several long-standing API issues, along with 6 | a few minor improvements. Consider following the tips below to help ensure a smooth upgrade 7 | process. 8 | 9 | ### Upgrade Rust to 1.65 or newer 10 | 11 | The minimum supported Rust version (MSRV) is now 1.65. Going forward, this crate will maintain a 12 | policy of supporting Rust releases going back at least 6 months. Changes that break compatibility 13 | with Rust releases older than 6 months will no longer be considered SemVer breaking changes and will 14 | not result in a new major version number for this crate. MSRV changes will coincide with minor 15 | version updates and will not happen in patch releases. 16 | 17 | ### Add typestate generic types to `Client` 18 | 19 | Each auth flow depends on one or more server endpoints. For example, the 20 | authorization code flow depends on both an authorization endpoint and a token endpoint, while the 21 | client credentials flow only depends on a token endpoint. Previously, it was possible to instantiate 22 | a `Client` without a token endpoint and then attempt to use an auth flow that required a token 23 | endpoint, leading to errors at runtime. Also, the authorization endpoint was always required, even 24 | for auth flows that do not use it. 25 | 26 | In the 5.0 release, all endpoints are optional. 27 | [Typestates](https://cliffle.com/blog/rust-typestate/) are used to statically track, at compile 28 | time, which endpoints' setters (e.g., `set_auth_uri()`) have been called. Auth flows that depend on 29 | an endpoint cannot be used without first calling the corresponding setter, which is enforced by the 30 | compiler's type checker. This guarantees that certain errors will not arise at runtime. 31 | 32 | In addition to unconditional setters (e.g., `set_auth_uri()`), each 33 | endpoint has a corresponding conditional setter (e.g., `set_auth_uri_option()`) that sets a 34 | conditional typestate (`EndpointMaybeSet`). When the conditional typestate is set, endpoints can 35 | be used via fallible methods that return `Err(ConfigurationError::MissingUrl(_))` if an endpoint 36 | has not been set. This is useful in dynamic scenarios such as 37 | [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html), in which 38 | it cannot be determined until runtime whether an endpoint is configured. 39 | 40 | There are three possible typestates, each implementing the `EndpointState` trait: 41 | * `EndpointNotSet`: the corresponding endpoint has **not** been set and cannot be used. 42 | * `EndpointSet`: the corresponding endpoint **has** been set and is ready to be used. 43 | * `EndpointMaybeSet`: the corresponding endpoint **may have** been set and can be used via fallible 44 | methods that return `Result<_, ConfigurationError>`. 45 | 46 | The following code changes are required to support the new interface: 47 | 1. Update calls to 48 | [`Client::new()`](https://docs.rs/oauth2/latest/oauth2/struct.Client.html#method.new) to use the 49 | single-argument constructor (which accepts only a `ClientId`). Use the `set_auth_uri()`, 50 | `set_token_uri()`, and `set_client_secret()` methods to set the authorization endpoint, 51 | token endpoint, and client secret, respectively, if applicable to your application's auth flows. 52 | 2. If required by your usage of the `Client` or `BasicClient` types (i.e., if you see related 53 | compiler errors), add the following generic parameters: 54 | ```rust 55 | HasAuthUrl: EndpointState, 56 | HasDeviceAuthUrl: EndpointState, 57 | HasIntrospectionUrl: EndpointState, 58 | HasRevocationUrl: EndpointState, 59 | HasTokenUrl: EndpointState, 60 | ``` 61 | For example, if you store a `BasicClient` within another data type, you may need to annotate it 62 | as `BasicClient` if it 63 | has both an authorization endpoint and a 64 | token endpoint set. Compiler error messages will likely guide you to the appropriate combination 65 | of typestates. 66 | 67 | If, instead of using `BasicClient`, you are directly using `Client` with a different set of type 68 | parameters, you will need to append the five generic typestate parameters. For example, replace: 69 | ```rust 70 | type SpecialClient = Client< 71 | BasicErrorResponse, 72 | SpecialTokenResponse, 73 | BasicTokenType, 74 | BasicTokenIntrospectionResponse, 75 | StandardRevocableToken, 76 | BasicRevocationErrorResponse, 77 | >; 78 | ``` 79 | with: 80 | ```rust 81 | type SpecialClient< 82 | HasAuthUrl = EndpointNotSet, 83 | HasDeviceAuthUrl = EndpointNotSet, 84 | HasIntrospectionUrl = EndpointNotSet, 85 | HasRevocationUrl = EndpointNotSet, 86 | HasTokenUrl = EndpointNotSet, 87 | > = Client< 88 | BasicErrorResponse, 89 | SpecialTokenResponse, 90 | BasicTokenType, 91 | BasicTokenIntrospectionResponse, 92 | StandardRevocableToken, 93 | BasicRevocationErrorResponse, 94 | HasAuthUrl, 95 | HasDeviceAuthUrl, 96 | HasIntrospectionUrl, 97 | HasRevocationUrl, 98 | HasTokenUrl, 99 | >; 100 | ``` 101 | The default values (`= EndpointNotSet`) are optional but often helpful since they will allow you 102 | to instantiate a client using `SpecialClient::new()` instead of having to specify 103 | `SpecialClient::::new()`. 104 | 105 | ### Rename endpoint getters and setters for consistency 106 | 107 | The 4.0 release aimed to align the naming of each endpoint with the terminology used in the relevant 108 | RFC. For example, [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1) uses the 109 | term "endpoint URI" to refer to the authorization and token endpoints, while 110 | [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009#section-2) refers to the 111 | "token revocation endpoint URL," and 112 | [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2) uses neither "URI" nor "URL" 113 | to describe the introspection endpoint. However, the renaming in 4.0 was both internally 114 | inconsistent, and inconsistent with the specs. 115 | 116 | In 5.0, the `Client`'s getters and setters for each endpoint are now named as follows: 117 | * Authorization endpoint: `auth_uri()`/`set_auth_uri()` 118 | * Token endpoint: `token_uri()`/`set_token_uri()` 119 | * Redirect: `redirect_uri()`/`set_redirect_uri()` 120 | * Revocation endpoint: `revocation_url()`/`set_revocation_url()` 121 | * Introspection endpoint: `introspection_url()`/`set_introspection_url()` 122 | * Device authorization endpoint: `device_authorization_url()`/`set_device_authorization_url()` 123 | (no change) 124 | 125 | ### Use stateful HTTP clients 126 | 127 | Previously, the HTTP clients provided by this crate were stateless. For example, the 128 | `oauth2::reqwest::async_http_client()` method would instantiate a new `reqwest::Client` for each 129 | request. This meant that TCP connections could not be reused across requests, and customizing HTTP 130 | clients (e.g., adding a custom request header to every request) was inconvenient. 131 | 132 | The 5.0 release introduces two new traits: `AsyncHttpClient` and `SyncHttpClient`. Each 133 | `request_async()` and `request()` method now accepts a reference to a type that implements these 134 | traits, respectively, rather than a function type. 135 | 136 | > [!WARNING] 137 | > To prevent 138 | [SSRF](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) 139 | vulnerabilities, be sure to configure the HTTP client **not to follow redirects**. For example, use 140 | > [`redirect::Policy::none`](https://docs.rs/reqwest/latest/reqwest/redirect/struct.Policy.html#method.none) 141 | > when using `reqwest`, or 142 | > [`redirects(0)`](https://docs.rs/ureq/latest/ureq/struct.AgentBuilder.html#method.redirects) 143 | > when using `ureq`. 144 | 145 | The `AsyncHttpClient` trait is implemented for the following types: 146 | * `reqwest::Client` (when the default `reqwest` feature is enabled) 147 | * Any function type that implements: 148 | ```rust 149 | Fn(HttpRequest) -> F 150 | where 151 | E: std::error::Error + 'static, 152 | F: Future>, 153 | ``` 154 | To implement a custom asynchronous HTTP client, either directly implement the `AsyncHttpClient` 155 | trait, or use a function that implements the signature above. 156 | 157 | The `SyncHttpClient` trait is implemented for the following types: 158 | * `reqwest::blocking::Client` (when the `reqwest-blocking` feature is enabled; see below) 159 | * `ureq::Agent` (when the `ureq` feature is enabled) 160 | * `oauth2::CurlHttpClient` (when the `curl` feature is enabled) 161 | * Any function type that implements: 162 | ```rust 163 | Fn(HttpRequest) -> Result 164 | where 165 | E: std::error::Error + 'static, 166 | ``` 167 | To implement a custom synchronous HTTP client, either directly implement the `SyncHttpClient` 168 | trait, or use a function that implements the signature above. 169 | 170 | ### Upgrade `http` to 1.0 and `reqwest` to 0.12 171 | 172 | The 5.0 release of this crate depends on the new stable [`http`](https://docs.rs/http/latest/http/) 173 | 1.0 release, which affects various public interfaces. In particular, `reqwest` has been upgraded 174 | to 0.12, which uses `http` 1.0. 175 | 176 | ### Enable the `reqwest-blocking` feature to use the synchronous `reqwest` HTTP client 177 | 178 | In 4.0, enabling the (default) `reqwest` feature also enabled `reqwest`'s `blocking` feature. 179 | To reduce dependencies and improve compilation speed, the `reqwest` feature now only enables 180 | `reqwest`'s asynchronous (non-blocking) client. To use the synchronous (blocking) client, enable the 181 | `reqwest-blocking` feature in `Cargo.toml`: 182 | ```toml 183 | oauth2 = { version = "5", features = ["reqwest-blocking" ] } 184 | ``` 185 | 186 | ### Use `http::{Request, Response}` for custom HTTP clients 187 | 188 | The `HttpRequest` and `HttpResponse` structs have been replaced with type aliases to 189 | [`http::Request`](https://docs.rs/http/latest/http/request/struct.Request.html) and 190 | [`http::Response`](https://docs.rs/http/latest/http/response/struct.Response.html), respectively. 191 | Custom HTTP clients will need to be updated to use the `http` types. See the 192 | [`reqwest` client implementations](https://github.com/ramosbugs/oauth2-rs/blob/23b952b23e6069525bc7e4c4f2c4924b8d28ce3a/src/reqwest.rs) 193 | for an example. 194 | 195 | ### Import device code flow and token revocation types from the root 196 | 197 | Previously, certain types were exported from both the root of the crate and the `devicecode` or 198 | `revocation` modules. These modules are no longer public, and their public types are exported from 199 | the root. For example, if you were previously importing 200 | `oauth2::devicecode::DeviceAuthorizationResponse`, instead import 201 | `oauth2::DeviceAuthorizationResponse`. 202 | 203 | ### Replace `TT` generic type parameter in `TokenResponse` with associated type 204 | 205 | Previously, the `TokenResponse` and `TokenIntrospectionResponse` traits had a generic type 206 | parameter `TT: TokenType`. This has been replaced with an associated type called `TokenType`. 207 | Uses of `BasicTokenResponse` and `BasicTokenIntrospectionResponse` should continue to work without 208 | changes, but custom implementations of either trait will need to be updated to replace the type 209 | parameter with an associated type. 210 | 211 | #### Remove `TT` generic type parameter from `Client` and each `*Request` type 212 | 213 | Removing the `TT` generic type parameter from `TokenResponse` (see above) made the `TT` parameters 214 | to `Client` and each `*Request` (e.g., `CodeTokenRequest`) redundant. Consequently, the `TT` 215 | parameter has been removed from each of these types. `BasicClient` should continue to work 216 | without any changes, but code that provides generic types for `Client` or any of the `*Response` 217 | types will need to be updated to remove the `TT` type parameter. 218 | 219 | ### Add `Display` to `ErrorResponse` trait 220 | 221 | To improve error messages, the 222 | [`RequestTokenError::ServerResponse`](https://docs.rs/oauth2/latest/oauth2/enum.RequestTokenError.html#variant.ServerResponse) 223 | enum variant now prints a message describing the server response using the `Display` trait. For most 224 | users (i.e., those using the default 225 | [`StandardErrorResponse`](https://docs.rs/oauth2/latest/oauth2/struct.StandardErrorResponse.html)), 226 | this does not require any code changes. However, users providing their own implementations 227 | of the `ErrorResponse` trait must now implement the `Display` trait. See 228 | `StandardErrorResponse`'s 229 | [`Display` implementation](https://github.com/ramosbugs/oauth2-rs/blob/9d8f11addf819134f15c6d7f03276adb3d32e80b/src/error.rs#L88-L108) 230 | for an example. 231 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use serde::de::value::SeqAccessDeserializer; 2 | use serde::ser::{Impossible, SerializeStructVariant, SerializeTupleVariant}; 3 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 4 | use serde_json::Value; 5 | 6 | use std::error::Error; 7 | use std::fmt::{Display, Formatter}; 8 | use std::marker::PhantomData; 9 | 10 | /// Serde case-insensitive deserializer for an untagged `enum`. 11 | /// 12 | /// This function converts values to lowercase before deserializing as the `enum`. Requires the 13 | /// `#[serde(rename_all = "lowercase")]` attribute to be set on the `enum`. 14 | /// 15 | /// # Example 16 | /// 17 | /// In example below, the following JSON values all deserialize to 18 | /// `GroceryBasket { fruit_item: Fruit::Banana }`: 19 | /// 20 | /// * `{"fruit_item": "banana"}` 21 | /// * `{"fruit_item": "BANANA"}` 22 | /// * `{"fruit_item": "Banana"}` 23 | /// 24 | /// Note: this example does not compile automatically due to 25 | /// [Rust issue #29286](https://github.com/rust-lang/rust/issues/29286). 26 | /// 27 | /// ``` 28 | /// # /* 29 | /// use serde::Deserialize; 30 | /// 31 | /// #[derive(Deserialize)] 32 | /// #[serde(rename_all = "lowercase")] 33 | /// enum Fruit { 34 | /// Apple, 35 | /// Banana, 36 | /// Orange, 37 | /// } 38 | /// 39 | /// #[derive(Deserialize)] 40 | /// struct GroceryBasket { 41 | /// #[serde(deserialize_with = "helpers::deserialize_untagged_enum_case_insensitive")] 42 | /// fruit_item: Fruit, 43 | /// } 44 | /// # */ 45 | /// ``` 46 | pub fn deserialize_untagged_enum_case_insensitive<'de, T, D>(deserializer: D) -> Result 47 | where 48 | T: Deserialize<'de>, 49 | D: Deserializer<'de>, 50 | { 51 | T::deserialize(Value::String( 52 | String::deserialize(deserializer)?.to_lowercase(), 53 | )) 54 | .map_err(serde::de::Error::custom) 55 | } 56 | 57 | /// Serde space-delimited string deserializer for a `Vec`. 58 | /// 59 | /// This function splits a JSON string at each space character into a `Vec` . 60 | /// 61 | /// # Example 62 | /// 63 | /// In example below, the JSON value `{"items": "foo bar baz"}` would deserialize to: 64 | /// 65 | /// ``` 66 | /// # struct GroceryBasket { 67 | /// # items: Vec, 68 | /// # } 69 | /// GroceryBasket { 70 | /// items: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()] 71 | /// }; 72 | /// ``` 73 | /// 74 | /// Note: this example does not compile automatically due to 75 | /// [Rust issue #29286](https://github.com/rust-lang/rust/issues/29286). 76 | /// 77 | /// ``` 78 | /// # /* 79 | /// use serde::Deserialize; 80 | /// 81 | /// #[derive(Deserialize)] 82 | /// struct GroceryBasket { 83 | /// #[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")] 84 | /// items: Vec, 85 | /// } 86 | /// # */ 87 | /// ``` 88 | pub fn deserialize_space_delimited_vec<'de, T, D>(deserializer: D) -> Result 89 | where 90 | T: Default + Deserialize<'de>, 91 | D: Deserializer<'de>, 92 | { 93 | if let Some(space_delimited) = Option::::deserialize(deserializer)? { 94 | let entries = space_delimited 95 | .split(' ') 96 | .map(|s| Value::String(s.to_string())) 97 | .collect(); 98 | T::deserialize(Value::Array(entries)).map_err(serde::de::Error::custom) 99 | } else { 100 | // If the JSON value is null, use the default value. 101 | Ok(T::default()) 102 | } 103 | } 104 | 105 | /// Deserializes a string or array of strings into an array of strings 106 | pub fn deserialize_optional_string_or_vec_string<'de, D>( 107 | deserializer: D, 108 | ) -> Result>, D::Error> 109 | where 110 | D: Deserializer<'de>, 111 | { 112 | struct StringOrVec(PhantomData>); 113 | 114 | impl<'de> serde::de::Visitor<'de> for StringOrVec { 115 | type Value = Option>; 116 | 117 | fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { 118 | formatter.write_str("string or list of strings") 119 | } 120 | 121 | fn visit_str(self, value: &str) -> Result 122 | where 123 | E: serde::de::Error, 124 | { 125 | Ok(Some(vec![value.to_owned()])) 126 | } 127 | 128 | fn visit_none(self) -> Result 129 | where 130 | E: serde::de::Error, 131 | { 132 | Ok(None) 133 | } 134 | 135 | fn visit_unit(self) -> Result 136 | where 137 | E: serde::de::Error, 138 | { 139 | Ok(None) 140 | } 141 | 142 | fn visit_seq(self, visitor: S) -> Result 143 | where 144 | S: serde::de::SeqAccess<'de>, 145 | { 146 | Deserialize::deserialize(SeqAccessDeserializer::new(visitor)).map(Some) 147 | } 148 | } 149 | 150 | deserializer.deserialize_any(StringOrVec(PhantomData)) 151 | } 152 | 153 | /// Serde space-delimited string serializer for an `Option>`. 154 | /// 155 | /// This function serializes a string vector into a single space-delimited string. 156 | /// If `string_vec_opt` is `None`, the function serializes it as `None` (e.g., `null` 157 | /// in the case of JSON serialization). 158 | pub fn serialize_space_delimited_vec( 159 | vec_opt: &Option>, 160 | serializer: S, 161 | ) -> Result 162 | where 163 | T: AsRef, 164 | S: Serializer, 165 | { 166 | if let Some(ref vec) = *vec_opt { 167 | let space_delimited = vec.iter().map(|s| s.as_ref()).collect::>().join(" "); 168 | 169 | serializer.serialize_str(&space_delimited) 170 | } else { 171 | serializer.serialize_none() 172 | } 173 | } 174 | 175 | /// Serde string serializer for an enum. 176 | /// 177 | /// Source: 178 | /// [https://github.com/serde-rs/serde/issues/553](https://github.com/serde-rs/serde/issues/553) 179 | pub fn variant_name(t: &T) -> &'static str { 180 | #[derive(Debug)] 181 | struct NotEnum; 182 | type Result = std::result::Result; 183 | impl Error for NotEnum { 184 | fn description(&self) -> &str { 185 | "not struct" 186 | } 187 | } 188 | impl Display for NotEnum { 189 | fn fmt(&self, _f: &mut Formatter) -> std::fmt::Result { 190 | unimplemented!() 191 | } 192 | } 193 | impl serde::ser::Error for NotEnum { 194 | fn custom(_msg: T) -> Self { 195 | NotEnum 196 | } 197 | } 198 | 199 | struct VariantName; 200 | impl Serializer for VariantName { 201 | type Ok = &'static str; 202 | type Error = NotEnum; 203 | type SerializeSeq = Impossible; 204 | type SerializeTuple = Impossible; 205 | type SerializeTupleStruct = Impossible; 206 | type SerializeTupleVariant = Enum; 207 | type SerializeMap = Impossible; 208 | type SerializeStruct = Impossible; 209 | type SerializeStructVariant = Enum; 210 | fn serialize_bool(self, _v: bool) -> Result { 211 | Err(NotEnum) 212 | } 213 | fn serialize_i8(self, _v: i8) -> Result { 214 | Err(NotEnum) 215 | } 216 | fn serialize_i16(self, _v: i16) -> Result { 217 | Err(NotEnum) 218 | } 219 | fn serialize_i32(self, _v: i32) -> Result { 220 | Err(NotEnum) 221 | } 222 | fn serialize_i64(self, _v: i64) -> Result { 223 | Err(NotEnum) 224 | } 225 | fn serialize_u8(self, _v: u8) -> Result { 226 | Err(NotEnum) 227 | } 228 | fn serialize_u16(self, _v: u16) -> Result { 229 | Err(NotEnum) 230 | } 231 | fn serialize_u32(self, _v: u32) -> Result { 232 | Err(NotEnum) 233 | } 234 | fn serialize_u64(self, _v: u64) -> Result { 235 | Err(NotEnum) 236 | } 237 | fn serialize_f32(self, _v: f32) -> Result { 238 | Err(NotEnum) 239 | } 240 | fn serialize_f64(self, _v: f64) -> Result { 241 | Err(NotEnum) 242 | } 243 | fn serialize_char(self, _v: char) -> Result { 244 | Err(NotEnum) 245 | } 246 | fn serialize_str(self, _v: &str) -> Result { 247 | Err(NotEnum) 248 | } 249 | fn serialize_bytes(self, _v: &[u8]) -> Result { 250 | Err(NotEnum) 251 | } 252 | fn serialize_none(self) -> Result { 253 | Err(NotEnum) 254 | } 255 | fn serialize_some(self, _value: &T) -> Result { 256 | Err(NotEnum) 257 | } 258 | fn serialize_unit(self) -> Result { 259 | Err(NotEnum) 260 | } 261 | fn serialize_unit_struct(self, _name: &'static str) -> Result { 262 | Err(NotEnum) 263 | } 264 | fn serialize_unit_variant( 265 | self, 266 | _name: &'static str, 267 | _variant_index: u32, 268 | variant: &'static str, 269 | ) -> Result { 270 | Ok(variant) 271 | } 272 | fn serialize_newtype_struct( 273 | self, 274 | _name: &'static str, 275 | _value: &T, 276 | ) -> Result { 277 | Err(NotEnum) 278 | } 279 | fn serialize_newtype_variant( 280 | self, 281 | _name: &'static str, 282 | _variant_index: u32, 283 | variant: &'static str, 284 | _value: &T, 285 | ) -> Result { 286 | Ok(variant) 287 | } 288 | fn serialize_seq(self, _len: Option) -> Result { 289 | Err(NotEnum) 290 | } 291 | fn serialize_tuple(self, _len: usize) -> Result { 292 | Err(NotEnum) 293 | } 294 | fn serialize_tuple_struct( 295 | self, 296 | _name: &'static str, 297 | _len: usize, 298 | ) -> Result { 299 | Err(NotEnum) 300 | } 301 | fn serialize_tuple_variant( 302 | self, 303 | _name: &'static str, 304 | _variant_index: u32, 305 | variant: &'static str, 306 | _len: usize, 307 | ) -> Result { 308 | Ok(Enum(variant)) 309 | } 310 | fn serialize_map(self, _len: Option) -> Result { 311 | Err(NotEnum) 312 | } 313 | fn serialize_struct( 314 | self, 315 | _name: &'static str, 316 | _len: usize, 317 | ) -> Result { 318 | Err(NotEnum) 319 | } 320 | fn serialize_struct_variant( 321 | self, 322 | _name: &'static str, 323 | _variant_index: u32, 324 | variant: &'static str, 325 | _len: usize, 326 | ) -> Result { 327 | Ok(Enum(variant)) 328 | } 329 | } 330 | 331 | struct Enum(&'static str); 332 | impl SerializeStructVariant for Enum { 333 | type Ok = &'static str; 334 | type Error = NotEnum; 335 | fn serialize_field( 336 | &mut self, 337 | _key: &'static str, 338 | _value: &T, 339 | ) -> Result<()> { 340 | Ok(()) 341 | } 342 | fn end(self) -> Result { 343 | Ok(self.0) 344 | } 345 | } 346 | impl SerializeTupleVariant for Enum { 347 | type Ok = &'static str; 348 | type Error = NotEnum; 349 | fn serialize_field(&mut self, _value: &T) -> Result<()> { 350 | Ok(()) 351 | } 352 | fn end(self) -> Result { 353 | Ok(self.0) 354 | } 355 | } 356 | 357 | t.serialize(VariantName).unwrap() 358 | } 359 | 360 | #[cfg(test)] 361 | mod tests { 362 | use serde::Deserialize; 363 | 364 | #[derive(Deserialize, Debug, Clone)] 365 | pub struct ObjectWithOptionalStringOrVecString { 366 | #[serde(deserialize_with = "crate::helpers::deserialize_optional_string_or_vec_string")] 367 | pub strings: Option>, 368 | } 369 | 370 | #[test] 371 | fn test_deserialize_optional_string_or_vec_string_none() { 372 | let list_of_strings: ObjectWithOptionalStringOrVecString = 373 | serde_json::from_str(r#"{ "strings": null }"#).unwrap(); 374 | assert_eq!(None, list_of_strings.strings); 375 | } 376 | 377 | #[test] 378 | fn test_deserialize_optional_string_or_vec_string_single_value() { 379 | let list_of_strings: ObjectWithOptionalStringOrVecString = 380 | serde_json::from_str(r#"{ "strings": "v1" }"#).unwrap(); 381 | assert_eq!(Some(vec!["v1".to_string()]), list_of_strings.strings); 382 | } 383 | 384 | #[test] 385 | fn test_deserialize_optional_string_or_vec_string_vec() { 386 | let list_of_strings: ObjectWithOptionalStringOrVecString = 387 | serde_json::from_str(r#"{ "strings": ["v1", "v2"] }"#).unwrap(); 388 | assert_eq!( 389 | Some(vec!["v1".to_string(), "v2".to_string()]), 390 | list_of_strings.strings 391 | ); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/code.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | AuthUrl, Client, ClientId, CsrfToken, EndpointState, ErrorResponse, PkceCodeChallenge, 3 | RedirectUrl, ResponseType, RevocableToken, Scope, TokenIntrospectionResponse, TokenResponse, 4 | }; 5 | 6 | use url::Url; 7 | 8 | use std::borrow::Cow; 9 | 10 | impl< 11 | TE, 12 | TR, 13 | TIR, 14 | RT, 15 | TRE, 16 | HasAuthUrl, 17 | HasDeviceAuthUrl, 18 | HasIntrospectionUrl, 19 | HasRevocationUrl, 20 | HasTokenUrl, 21 | > 22 | Client< 23 | TE, 24 | TR, 25 | TIR, 26 | RT, 27 | TRE, 28 | HasAuthUrl, 29 | HasDeviceAuthUrl, 30 | HasIntrospectionUrl, 31 | HasRevocationUrl, 32 | HasTokenUrl, 33 | > 34 | where 35 | TE: ErrorResponse + 'static, 36 | TR: TokenResponse, 37 | TIR: TokenIntrospectionResponse, 38 | RT: RevocableToken, 39 | TRE: ErrorResponse + 'static, 40 | HasAuthUrl: EndpointState, 41 | HasDeviceAuthUrl: EndpointState, 42 | HasIntrospectionUrl: EndpointState, 43 | HasRevocationUrl: EndpointState, 44 | HasTokenUrl: EndpointState, 45 | { 46 | pub(crate) fn authorize_url_impl<'a, S>( 47 | &'a self, 48 | auth_url: &'a AuthUrl, 49 | state_fn: S, 50 | ) -> AuthorizationRequest<'a> 51 | where 52 | S: FnOnce() -> CsrfToken, 53 | { 54 | AuthorizationRequest { 55 | auth_url, 56 | client_id: &self.client_id, 57 | extra_params: Vec::new(), 58 | pkce_challenge: None, 59 | redirect_url: self.redirect_url.as_ref().map(Cow::Borrowed), 60 | response_type: "code".into(), 61 | scopes: Vec::new(), 62 | state: state_fn(), 63 | } 64 | } 65 | } 66 | 67 | /// A request to the authorization endpoint 68 | #[derive(Debug)] 69 | pub struct AuthorizationRequest<'a> { 70 | pub(crate) auth_url: &'a AuthUrl, 71 | pub(crate) client_id: &'a ClientId, 72 | pub(crate) extra_params: Vec<(Cow<'a, str>, Cow<'a, str>)>, 73 | pub(crate) pkce_challenge: Option, 74 | pub(crate) redirect_url: Option>, 75 | pub(crate) response_type: Cow<'a, str>, 76 | pub(crate) scopes: Vec>, 77 | pub(crate) state: CsrfToken, 78 | } 79 | impl<'a> AuthorizationRequest<'a> { 80 | /// Appends a new scope to the authorization URL. 81 | pub fn add_scope(mut self, scope: Scope) -> Self { 82 | self.scopes.push(Cow::Owned(scope)); 83 | self 84 | } 85 | 86 | /// Appends a collection of scopes to the token request. 87 | pub fn add_scopes(mut self, scopes: I) -> Self 88 | where 89 | I: IntoIterator, 90 | { 91 | self.scopes.extend(scopes.into_iter().map(Cow::Owned)); 92 | self 93 | } 94 | 95 | /// Appends an extra param to the authorization URL. 96 | /// 97 | /// This method allows extensions to be used without direct support from 98 | /// this crate. If `name` conflicts with a parameter managed by this crate, the 99 | /// behavior is undefined. In particular, do not set parameters defined by 100 | /// [RFC 6749](https://tools.ietf.org/html/rfc6749) or 101 | /// [RFC 7636](https://tools.ietf.org/html/rfc7636). 102 | /// 103 | /// # Security Warning 104 | /// 105 | /// Callers should follow the security recommendations for any OAuth2 extensions used with 106 | /// this function, which are beyond the scope of 107 | /// [RFC 6749](https://tools.ietf.org/html/rfc6749). 108 | pub fn add_extra_param(mut self, name: N, value: V) -> Self 109 | where 110 | N: Into>, 111 | V: Into>, 112 | { 113 | self.extra_params.push((name.into(), value.into())); 114 | self 115 | } 116 | 117 | /// Enables the [Implicit Grant](https://tools.ietf.org/html/rfc6749#section-4.2) flow. 118 | pub fn use_implicit_flow(mut self) -> Self { 119 | self.response_type = "token".into(); 120 | self 121 | } 122 | 123 | /// Enables custom flows other than the `code` and `token` (implicit flow) grant. 124 | pub fn set_response_type(mut self, response_type: &ResponseType) -> Self { 125 | self.response_type = (**response_type).to_owned().into(); 126 | self 127 | } 128 | 129 | /// Enables the use of [Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636) 130 | /// (PKCE). 131 | /// 132 | /// PKCE is *highly recommended* for all public clients (i.e., those for which there 133 | /// is no client secret or for which the client secret is distributed with the client, 134 | /// such as in a native, mobile app, or browser app). 135 | pub fn set_pkce_challenge(mut self, pkce_code_challenge: PkceCodeChallenge) -> Self { 136 | self.pkce_challenge = Some(pkce_code_challenge); 137 | self 138 | } 139 | 140 | /// Overrides the `redirect_url` to the one specified. 141 | pub fn set_redirect_uri(mut self, redirect_url: Cow<'a, RedirectUrl>) -> Self { 142 | self.redirect_url = Some(redirect_url); 143 | self 144 | } 145 | 146 | /// Returns the full authorization URL and CSRF state for this authorization 147 | /// request. 148 | pub fn url(self) -> (Url, CsrfToken) { 149 | let scopes = self 150 | .scopes 151 | .iter() 152 | .map(|s| s.to_string()) 153 | .collect::>() 154 | .join(" "); 155 | 156 | let url = { 157 | let mut pairs: Vec<(&str, &str)> = vec![ 158 | ("response_type", self.response_type.as_ref()), 159 | ("client_id", self.client_id), 160 | ("state", self.state.secret()), 161 | ]; 162 | 163 | if let Some(ref pkce_challenge) = self.pkce_challenge { 164 | pairs.push(("code_challenge", pkce_challenge.as_str())); 165 | pairs.push(("code_challenge_method", pkce_challenge.method().as_str())); 166 | } 167 | 168 | if let Some(ref redirect_url) = self.redirect_url { 169 | pairs.push(("redirect_uri", redirect_url.as_str())); 170 | } 171 | 172 | if !scopes.is_empty() { 173 | pairs.push(("scope", &scopes)); 174 | } 175 | 176 | let mut url: Url = self.auth_url.url().to_owned(); 177 | 178 | url.query_pairs_mut() 179 | .extend_pairs(pairs.iter().map(|&(k, v)| (k, v))); 180 | 181 | url.query_pairs_mut() 182 | .extend_pairs(self.extra_params.iter().cloned()); 183 | url 184 | }; 185 | 186 | (url, self.state) 187 | } 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use crate::basic::BasicClient; 193 | use crate::tests::new_client; 194 | use crate::{ 195 | AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, 196 | RedirectUrl, ResponseType, Scope, TokenUrl, 197 | }; 198 | 199 | use url::form_urlencoded::byte_serialize; 200 | use url::Url; 201 | 202 | use std::borrow::Cow; 203 | 204 | #[test] 205 | fn test_authorize_url() { 206 | let client = new_client(); 207 | let (url, _) = client 208 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 209 | .url(); 210 | 211 | assert_eq!( 212 | Url::parse( 213 | "https://example.com/auth?response_type=code&client_id=aaa&state=csrf_token" 214 | ) 215 | .unwrap(), 216 | url 217 | ); 218 | } 219 | 220 | #[test] 221 | fn test_authorize_random() { 222 | let client = new_client(); 223 | let (url, csrf_state) = client.authorize_url(CsrfToken::new_random).url(); 224 | 225 | assert_eq!( 226 | Url::parse(&format!( 227 | "https://example.com/auth?response_type=code&client_id=aaa&state={}", 228 | byte_serialize(csrf_state.secret().clone().into_bytes().as_slice()) 229 | .collect::>() 230 | .join("") 231 | )) 232 | .unwrap(), 233 | url 234 | ); 235 | } 236 | 237 | #[test] 238 | fn test_authorize_url_pkce() { 239 | // Example from https://tools.ietf.org/html/rfc7636#appendix-B 240 | let client = new_client(); 241 | 242 | let (url, _) = client 243 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 244 | .set_pkce_challenge(PkceCodeChallenge::from_code_verifier_sha256( 245 | &PkceCodeVerifier::new("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".to_string()), 246 | )) 247 | .url(); 248 | assert_eq!( 249 | Url::parse(concat!( 250 | "https://example.com/auth", 251 | "?response_type=code&client_id=aaa", 252 | "&state=csrf_token", 253 | "&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", 254 | "&code_challenge_method=S256", 255 | )) 256 | .unwrap(), 257 | url 258 | ); 259 | } 260 | 261 | #[test] 262 | fn test_authorize_url_implicit() { 263 | let client = new_client(); 264 | 265 | let (url, _) = client 266 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 267 | .use_implicit_flow() 268 | .url(); 269 | 270 | assert_eq!( 271 | Url::parse( 272 | "https://example.com/auth?response_type=token&client_id=aaa&state=csrf_token" 273 | ) 274 | .unwrap(), 275 | url 276 | ); 277 | } 278 | 279 | #[test] 280 | fn test_authorize_url_with_param() { 281 | let client = BasicClient::new(ClientId::new("aaa".to_string())) 282 | .set_client_secret(ClientSecret::new("bbb".to_string())) 283 | .set_auth_uri(AuthUrl::new("https://example.com/auth?foo=bar".to_string()).unwrap()) 284 | .set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap()); 285 | 286 | let (url, _) = client 287 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 288 | .url(); 289 | 290 | assert_eq!( 291 | Url::parse( 292 | "https://example.com/auth?foo=bar&response_type=code&client_id=aaa&state=csrf_token" 293 | ) 294 | .unwrap(), 295 | url 296 | ); 297 | } 298 | 299 | #[test] 300 | fn test_authorize_url_with_scopes() { 301 | let scopes = vec![ 302 | Scope::new("read".to_string()), 303 | Scope::new("write".to_string()), 304 | ]; 305 | let (url, _) = new_client() 306 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 307 | .add_scopes(scopes) 308 | .url(); 309 | 310 | assert_eq!( 311 | Url::parse( 312 | "https://example.com/auth\ 313 | ?response_type=code\ 314 | &client_id=aaa\ 315 | &state=csrf_token\ 316 | &scope=read+write" 317 | ) 318 | .unwrap(), 319 | url 320 | ); 321 | } 322 | 323 | #[test] 324 | fn test_authorize_url_with_one_scope() { 325 | let (url, _) = new_client() 326 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 327 | .add_scope(Scope::new("read".to_string())) 328 | .url(); 329 | 330 | assert_eq!( 331 | Url::parse( 332 | "https://example.com/auth\ 333 | ?response_type=code\ 334 | &client_id=aaa\ 335 | &state=csrf_token\ 336 | &scope=read" 337 | ) 338 | .unwrap(), 339 | url 340 | ); 341 | } 342 | 343 | #[test] 344 | fn test_authorize_url_with_extension_response_type() { 345 | let client = new_client(); 346 | 347 | let (url, _) = client 348 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 349 | .set_response_type(&ResponseType::new("code token".to_string())) 350 | .add_extra_param("foo", "bar") 351 | .url(); 352 | 353 | assert_eq!( 354 | Url::parse( 355 | "https://example.com/auth?response_type=code+token&client_id=aaa&state=csrf_token\ 356 | &foo=bar" 357 | ) 358 | .unwrap(), 359 | url 360 | ); 361 | } 362 | 363 | #[test] 364 | fn test_authorize_url_with_redirect_url() { 365 | let client = new_client() 366 | .set_redirect_uri(RedirectUrl::new("https://localhost/redirect".to_string()).unwrap()); 367 | 368 | let (url, _) = client 369 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 370 | .url(); 371 | 372 | assert_eq!( 373 | Url::parse( 374 | "https://example.com/auth?response_type=code\ 375 | &client_id=aaa\ 376 | &state=csrf_token\ 377 | &redirect_uri=https%3A%2F%2Flocalhost%2Fredirect" 378 | ) 379 | .unwrap(), 380 | url 381 | ); 382 | } 383 | 384 | #[test] 385 | fn test_authorize_url_with_redirect_url_override() { 386 | let client = new_client() 387 | .set_redirect_uri(RedirectUrl::new("https://localhost/redirect".to_string()).unwrap()); 388 | 389 | let (url, _) = client 390 | .authorize_url(|| CsrfToken::new("csrf_token".to_string())) 391 | .set_redirect_uri(Cow::Owned( 392 | RedirectUrl::new("https://localhost/alternative".to_string()).unwrap(), 393 | )) 394 | .url(); 395 | 396 | assert_eq!( 397 | Url::parse( 398 | "https://example.com/auth?response_type=code\ 399 | &client_id=aaa\ 400 | &state=csrf_token\ 401 | &redirect_uri=https%3A%2F%2Flocalhost%2Falternative" 402 | ) 403 | .unwrap(), 404 | url 405 | ); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/introspection.rs: -------------------------------------------------------------------------------- 1 | use crate::endpoint::{endpoint_request, endpoint_response}; 2 | use crate::{ 3 | AccessToken, AsyncHttpClient, AuthType, Client, ClientId, ClientSecret, EndpointState, 4 | ErrorResponse, ExtraTokenFields, HttpRequest, IntrospectionUrl, RequestTokenError, 5 | RevocableToken, Scope, SyncHttpClient, TokenResponse, TokenType, 6 | }; 7 | 8 | use chrono::serde::ts_seconds_option; 9 | use chrono::{DateTime, Utc}; 10 | use serde::de::DeserializeOwned; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | use std::borrow::Cow; 14 | use std::error::Error; 15 | use std::fmt::Debug; 16 | use std::future::Future; 17 | use std::marker::PhantomData; 18 | 19 | impl< 20 | TE, 21 | TR, 22 | TIR, 23 | RT, 24 | TRE, 25 | HasAuthUrl, 26 | HasDeviceAuthUrl, 27 | HasIntrospectionUrl, 28 | HasRevocationUrl, 29 | HasTokenUrl, 30 | > 31 | Client< 32 | TE, 33 | TR, 34 | TIR, 35 | RT, 36 | TRE, 37 | HasAuthUrl, 38 | HasDeviceAuthUrl, 39 | HasIntrospectionUrl, 40 | HasRevocationUrl, 41 | HasTokenUrl, 42 | > 43 | where 44 | TE: ErrorResponse + 'static, 45 | TR: TokenResponse, 46 | TIR: TokenIntrospectionResponse, 47 | RT: RevocableToken, 48 | TRE: ErrorResponse + 'static, 49 | HasAuthUrl: EndpointState, 50 | HasDeviceAuthUrl: EndpointState, 51 | HasIntrospectionUrl: EndpointState, 52 | HasRevocationUrl: EndpointState, 53 | HasTokenUrl: EndpointState, 54 | { 55 | pub(crate) fn introspect_impl<'a>( 56 | &'a self, 57 | introspection_url: &'a IntrospectionUrl, 58 | token: &'a AccessToken, 59 | ) -> IntrospectionRequest<'a, TE, TIR> { 60 | IntrospectionRequest { 61 | auth_type: &self.auth_type, 62 | client_id: &self.client_id, 63 | client_secret: self.client_secret.as_ref(), 64 | extra_params: Vec::new(), 65 | introspection_url, 66 | token, 67 | token_type_hint: None, 68 | _phantom: PhantomData, 69 | } 70 | } 71 | } 72 | 73 | /// A request to introspect an access token. 74 | /// 75 | /// See . 76 | #[derive(Debug)] 77 | pub struct IntrospectionRequest<'a, TE, TIR> 78 | where 79 | TE: ErrorResponse, 80 | TIR: TokenIntrospectionResponse, 81 | { 82 | pub(crate) token: &'a AccessToken, 83 | pub(crate) token_type_hint: Option>, 84 | pub(crate) auth_type: &'a AuthType, 85 | pub(crate) client_id: &'a ClientId, 86 | pub(crate) client_secret: Option<&'a ClientSecret>, 87 | pub(crate) extra_params: Vec<(Cow<'a, str>, Cow<'a, str>)>, 88 | pub(crate) introspection_url: &'a IntrospectionUrl, 89 | pub(crate) _phantom: PhantomData<(TE, TIR)>, 90 | } 91 | 92 | impl<'a, TE, TIR> IntrospectionRequest<'a, TE, TIR> 93 | where 94 | TE: ErrorResponse + 'static, 95 | TIR: TokenIntrospectionResponse, 96 | { 97 | /// Sets the optional token_type_hint parameter. 98 | /// 99 | /// See . 100 | /// 101 | /// OPTIONAL. A hint about the type of the token submitted for 102 | /// introspection. The protected resource MAY pass this parameter to 103 | /// help the authorization server optimize the token lookup. If the 104 | /// server is unable to locate the token using the given hint, it MUST 105 | /// extend its search across all of its supported token types. An 106 | /// authorization server MAY ignore this parameter, particularly if it 107 | /// is able to detect the token type automatically. Values for this 108 | /// field are defined in the "OAuth Token Type Hints" registry defined 109 | /// in OAuth Token Revocation [RFC7009](https://tools.ietf.org/html/rfc7009). 110 | pub fn set_token_type_hint(mut self, value: V) -> Self 111 | where 112 | V: Into>, 113 | { 114 | self.token_type_hint = Some(value.into()); 115 | 116 | self 117 | } 118 | 119 | /// Appends an extra param to the token introspection request. 120 | /// 121 | /// This method allows extensions to be used without direct support from 122 | /// this crate. If `name` conflicts with a parameter managed by this crate, the 123 | /// behavior is undefined. In particular, do not set parameters defined by 124 | /// [RFC 6749](https://tools.ietf.org/html/rfc6749) or 125 | /// [RFC 7662](https://tools.ietf.org/html/rfc7662). 126 | /// 127 | /// # Security Warning 128 | /// 129 | /// Callers should follow the security recommendations for any OAuth2 extensions used with 130 | /// this function, which are beyond the scope of 131 | /// [RFC 6749](https://tools.ietf.org/html/rfc6749). 132 | pub fn add_extra_param(mut self, name: N, value: V) -> Self 133 | where 134 | N: Into>, 135 | V: Into>, 136 | { 137 | self.extra_params.push((name.into(), value.into())); 138 | self 139 | } 140 | 141 | fn prepare_request(self) -> Result> 142 | where 143 | RE: Error + 'static, 144 | { 145 | let mut params: Vec<(&str, &str)> = vec![("token", self.token.secret())]; 146 | if let Some(ref token_type_hint) = self.token_type_hint { 147 | params.push(("token_type_hint", token_type_hint)); 148 | } 149 | 150 | endpoint_request( 151 | self.auth_type, 152 | self.client_id, 153 | self.client_secret, 154 | &self.extra_params, 155 | None, 156 | None, 157 | self.introspection_url.url(), 158 | params, 159 | ) 160 | .map_err(|err| RequestTokenError::Other(format!("failed to prepare request: {err}"))) 161 | } 162 | 163 | /// Synchronously sends the request to the authorization server and awaits a response. 164 | pub fn request( 165 | self, 166 | http_client: &C, 167 | ) -> Result::Error, TE>> 168 | where 169 | C: SyncHttpClient, 170 | { 171 | endpoint_response(http_client.call(self.prepare_request()?)?) 172 | } 173 | 174 | /// Asynchronously sends the request to the authorization server and returns a Future. 175 | pub fn request_async<'c, C>( 176 | self, 177 | http_client: &'c C, 178 | ) -> impl Future>::Error, TE>>> + 'c 179 | where 180 | Self: 'c, 181 | C: AsyncHttpClient<'c>, 182 | { 183 | Box::pin(async move { endpoint_response(http_client.call(self.prepare_request()?).await?) }) 184 | } 185 | } 186 | 187 | /// Common methods shared by all OAuth2 token introspection implementations. 188 | /// 189 | /// The methods in this trait are defined in 190 | /// [Section 2.2 of RFC 7662](https://tools.ietf.org/html/rfc7662#section-2.2). This trait exists 191 | /// separately from the `StandardTokenIntrospectionResponse` struct to support customization by 192 | /// clients, such as supporting interoperability with non-standards-complaint OAuth2 providers. 193 | pub trait TokenIntrospectionResponse: Debug + DeserializeOwned + Serialize { 194 | /// Type of OAuth2 access token included in this response. 195 | type TokenType: TokenType; 196 | 197 | /// REQUIRED. Boolean indicator of whether or not the presented token 198 | /// is currently active. The specifics of a token's "active" state 199 | /// will vary depending on the implementation of the authorization 200 | /// server and the information it keeps about its tokens, but a "true" 201 | /// value return for the "active" property will generally indicate 202 | /// that a given token has been issued by this authorization server, 203 | /// has not been revoked by the resource owner, and is within its 204 | /// given time window of validity (e.g., after its issuance time and 205 | /// before its expiration time). 206 | fn active(&self) -> bool; 207 | /// OPTIONAL. A JSON string containing a space-separated list of 208 | /// scopes associated with this token, in the format described in 209 | /// [Section 3.3 of RFC 7662](https://tools.ietf.org/html/rfc7662#section-3.3). 210 | /// If included in the response, 211 | /// this space-delimited field is parsed into a `Vec` of individual scopes. If omitted from 212 | /// the response, this field is `None`. 213 | fn scopes(&self) -> Option<&Vec>; 214 | /// OPTIONAL. Client identifier for the OAuth 2.0 client that 215 | /// requested this token. 216 | fn client_id(&self) -> Option<&ClientId>; 217 | /// OPTIONAL. Human-readable identifier for the resource owner who 218 | /// authorized this token. 219 | fn username(&self) -> Option<&str>; 220 | /// OPTIONAL. Type of the token as defined in 221 | /// [Section 5.1 of RFC 7662](https://tools.ietf.org/html/rfc7662#section-5.1). 222 | /// Value is case insensitive and deserialized to the generic `TokenType` parameter. 223 | fn token_type(&self) -> Option<&Self::TokenType>; 224 | /// OPTIONAL. Integer timestamp, measured in the number of seconds 225 | /// since January 1 1970 UTC, indicating when this token will expire, 226 | /// as defined in JWT [RFC7519](https://tools.ietf.org/html/rfc7519). 227 | fn exp(&self) -> Option>; 228 | /// OPTIONAL. Integer timestamp, measured in the number of seconds 229 | /// since January 1 1970 UTC, indicating when this token was 230 | /// originally issued, as defined in JWT [RFC7519](https://tools.ietf.org/html/rfc7519). 231 | fn iat(&self) -> Option>; 232 | /// OPTIONAL. Integer timestamp, measured in the number of seconds 233 | /// since January 1 1970 UTC, indicating when this token is not to be 234 | /// used before, as defined in JWT [RFC7519](https://tools.ietf.org/html/rfc7519). 235 | fn nbf(&self) -> Option>; 236 | /// OPTIONAL. Subject of the token, as defined in JWT [RFC7519](https://tools.ietf.org/html/rfc7519). 237 | /// Usually a machine-readable identifier of the resource owner who 238 | /// authorized this token. 239 | fn sub(&self) -> Option<&str>; 240 | /// OPTIONAL. Service-specific string identifier or list of string 241 | /// identifiers representing the intended audience for this token, as 242 | /// defined in JWT [RFC7519](https://tools.ietf.org/html/rfc7519). 243 | fn aud(&self) -> Option<&Vec>; 244 | /// OPTIONAL. String representing the issuer of this token, as 245 | /// defined in JWT [RFC7519](https://tools.ietf.org/html/rfc7519). 246 | fn iss(&self) -> Option<&str>; 247 | /// OPTIONAL. String identifier for the token, as defined in JWT 248 | /// [RFC7519](https://tools.ietf.org/html/rfc7519). 249 | fn jti(&self) -> Option<&str>; 250 | } 251 | 252 | /// Standard OAuth2 token introspection response. 253 | /// 254 | /// This struct includes the fields defined in 255 | /// [Section 2.2 of RFC 7662](https://tools.ietf.org/html/rfc7662#section-2.2), as well as 256 | /// extensions defined by the `EF` type parameter. 257 | #[derive(Clone, Debug, Deserialize, Serialize)] 258 | pub struct StandardTokenIntrospectionResponse 259 | where 260 | EF: ExtraTokenFields, 261 | TT: TokenType + 'static, 262 | { 263 | active: bool, 264 | #[serde(rename = "scope")] 265 | #[serde(deserialize_with = "crate::helpers::deserialize_space_delimited_vec")] 266 | #[serde(serialize_with = "crate::helpers::serialize_space_delimited_vec")] 267 | #[serde(skip_serializing_if = "Option::is_none")] 268 | #[serde(default)] 269 | scopes: Option>, 270 | #[serde(skip_serializing_if = "Option::is_none")] 271 | client_id: Option, 272 | #[serde(skip_serializing_if = "Option::is_none")] 273 | username: Option, 274 | #[serde( 275 | bound = "TT: TokenType", 276 | skip_serializing_if = "Option::is_none", 277 | deserialize_with = "crate::helpers::deserialize_untagged_enum_case_insensitive", 278 | default = "none_field" 279 | )] 280 | token_type: Option, 281 | #[serde(skip_serializing_if = "Option::is_none")] 282 | #[serde(with = "ts_seconds_option")] 283 | #[serde(default)] 284 | exp: Option>, 285 | #[serde(skip_serializing_if = "Option::is_none")] 286 | #[serde(with = "ts_seconds_option")] 287 | #[serde(default)] 288 | iat: Option>, 289 | #[serde(skip_serializing_if = "Option::is_none")] 290 | #[serde(with = "ts_seconds_option")] 291 | #[serde(default)] 292 | nbf: Option>, 293 | #[serde(skip_serializing_if = "Option::is_none")] 294 | sub: Option, 295 | #[serde(skip_serializing_if = "Option::is_none")] 296 | #[serde(default)] 297 | #[serde(deserialize_with = "crate::helpers::deserialize_optional_string_or_vec_string")] 298 | aud: Option>, 299 | #[serde(skip_serializing_if = "Option::is_none")] 300 | iss: Option, 301 | #[serde(skip_serializing_if = "Option::is_none")] 302 | jti: Option, 303 | 304 | #[serde(bound = "EF: ExtraTokenFields")] 305 | #[serde(flatten)] 306 | extra_fields: EF, 307 | } 308 | 309 | fn none_field() -> Option { 310 | None 311 | } 312 | 313 | impl StandardTokenIntrospectionResponse 314 | where 315 | EF: ExtraTokenFields, 316 | TT: TokenType, 317 | { 318 | /// Instantiate a new OAuth2 token introspection response. 319 | pub fn new(active: bool, extra_fields: EF) -> Self { 320 | Self { 321 | active, 322 | 323 | scopes: None, 324 | client_id: None, 325 | username: None, 326 | token_type: None, 327 | exp: None, 328 | iat: None, 329 | nbf: None, 330 | sub: None, 331 | aud: None, 332 | iss: None, 333 | jti: None, 334 | extra_fields, 335 | } 336 | } 337 | 338 | /// Sets the `set_active` field. 339 | pub fn set_active(&mut self, active: bool) { 340 | self.active = active; 341 | } 342 | /// Sets the `set_scopes` field. 343 | pub fn set_scopes(&mut self, scopes: Option>) { 344 | self.scopes = scopes; 345 | } 346 | /// Sets the `set_client_id` field. 347 | pub fn set_client_id(&mut self, client_id: Option) { 348 | self.client_id = client_id; 349 | } 350 | /// Sets the `set_username` field. 351 | pub fn set_username(&mut self, username: Option) { 352 | self.username = username; 353 | } 354 | /// Sets the `set_token_type` field. 355 | pub fn set_token_type(&mut self, token_type: Option) { 356 | self.token_type = token_type; 357 | } 358 | /// Sets the `set_exp` field. 359 | pub fn set_exp(&mut self, exp: Option>) { 360 | self.exp = exp; 361 | } 362 | /// Sets the `set_iat` field. 363 | pub fn set_iat(&mut self, iat: Option>) { 364 | self.iat = iat; 365 | } 366 | /// Sets the `set_nbf` field. 367 | pub fn set_nbf(&mut self, nbf: Option>) { 368 | self.nbf = nbf; 369 | } 370 | /// Sets the `set_sub` field. 371 | pub fn set_sub(&mut self, sub: Option) { 372 | self.sub = sub; 373 | } 374 | /// Sets the `set_aud` field. 375 | pub fn set_aud(&mut self, aud: Option>) { 376 | self.aud = aud; 377 | } 378 | /// Sets the `set_iss` field. 379 | pub fn set_iss(&mut self, iss: Option) { 380 | self.iss = iss; 381 | } 382 | /// Sets the `set_jti` field. 383 | pub fn set_jti(&mut self, jti: Option) { 384 | self.jti = jti; 385 | } 386 | /// Extra fields defined by the client application. 387 | pub fn extra_fields(&self) -> &EF { 388 | &self.extra_fields 389 | } 390 | /// Sets the `set_extra_fields` field. 391 | pub fn set_extra_fields(&mut self, extra_fields: EF) { 392 | self.extra_fields = extra_fields; 393 | } 394 | } 395 | impl TokenIntrospectionResponse for StandardTokenIntrospectionResponse 396 | where 397 | EF: ExtraTokenFields, 398 | TT: TokenType, 399 | { 400 | type TokenType = TT; 401 | 402 | fn active(&self) -> bool { 403 | self.active 404 | } 405 | 406 | fn scopes(&self) -> Option<&Vec> { 407 | self.scopes.as_ref() 408 | } 409 | 410 | fn client_id(&self) -> Option<&ClientId> { 411 | self.client_id.as_ref() 412 | } 413 | 414 | fn username(&self) -> Option<&str> { 415 | self.username.as_deref() 416 | } 417 | 418 | fn token_type(&self) -> Option<&TT> { 419 | self.token_type.as_ref() 420 | } 421 | 422 | fn exp(&self) -> Option> { 423 | self.exp 424 | } 425 | 426 | fn iat(&self) -> Option> { 427 | self.iat 428 | } 429 | 430 | fn nbf(&self) -> Option> { 431 | self.nbf 432 | } 433 | 434 | fn sub(&self) -> Option<&str> { 435 | self.sub.as_deref() 436 | } 437 | 438 | fn aud(&self) -> Option<&Vec> { 439 | self.aud.as_ref() 440 | } 441 | 442 | fn iss(&self) -> Option<&str> { 443 | self.iss.as_deref() 444 | } 445 | 446 | fn jti(&self) -> Option<&str> { 447 | self.jti.as_deref() 448 | } 449 | } 450 | 451 | #[cfg(test)] 452 | mod tests { 453 | use crate::basic::BasicTokenType; 454 | use crate::tests::{mock_http_client, new_client}; 455 | use crate::{AccessToken, AuthType, ClientId, IntrospectionUrl, RedirectUrl, Scope}; 456 | 457 | use chrono::DateTime; 458 | use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; 459 | use http::{HeaderValue, Response, StatusCode}; 460 | 461 | #[test] 462 | fn test_token_introspection_successful_with_basic_auth_minimal_response() { 463 | let client = new_client() 464 | .set_auth_type(AuthType::BasicAuth) 465 | .set_redirect_uri(RedirectUrl::new("https://redirect/here".to_string()).unwrap()) 466 | .set_introspection_url( 467 | IntrospectionUrl::new("https://introspection/url".to_string()).unwrap(), 468 | ); 469 | 470 | let introspection_response = client 471 | .introspect(&AccessToken::new("access_token_123".to_string())) 472 | .request(&mock_http_client( 473 | vec![ 474 | (ACCEPT, "application/json"), 475 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 476 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 477 | ], 478 | "token=access_token_123", 479 | Some("https://introspection/url".parse().unwrap()), 480 | Response::builder() 481 | .status(StatusCode::OK) 482 | .header( 483 | CONTENT_TYPE, 484 | HeaderValue::from_str("application/json").unwrap(), 485 | ) 486 | .body( 487 | "{\ 488 | \"active\": true\ 489 | }" 490 | .to_string() 491 | .into_bytes(), 492 | ) 493 | .unwrap(), 494 | )) 495 | .unwrap(); 496 | 497 | assert!(introspection_response.active); 498 | assert_eq!(None, introspection_response.scopes); 499 | assert_eq!(None, introspection_response.client_id); 500 | assert_eq!(None, introspection_response.username); 501 | assert_eq!(None, introspection_response.token_type); 502 | assert_eq!(None, introspection_response.exp); 503 | assert_eq!(None, introspection_response.iat); 504 | assert_eq!(None, introspection_response.nbf); 505 | assert_eq!(None, introspection_response.sub); 506 | assert_eq!(None, introspection_response.aud); 507 | assert_eq!(None, introspection_response.iss); 508 | assert_eq!(None, introspection_response.jti); 509 | } 510 | 511 | #[test] 512 | fn test_token_introspection_successful_with_basic_auth_full_response() { 513 | let client = new_client() 514 | .set_auth_type(AuthType::BasicAuth) 515 | .set_redirect_uri(RedirectUrl::new("https://redirect/here".to_string()).unwrap()) 516 | .set_introspection_url( 517 | IntrospectionUrl::new("https://introspection/url".to_string()).unwrap(), 518 | ); 519 | 520 | let introspection_response = client 521 | .introspect(&AccessToken::new("access_token_123".to_string())) 522 | .set_token_type_hint("access_token") 523 | .request(&mock_http_client( 524 | vec![ 525 | (ACCEPT, "application/json"), 526 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 527 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 528 | ], 529 | "token=access_token_123&token_type_hint=access_token", 530 | Some("https://introspection/url".parse().unwrap()), 531 | Response::builder() 532 | .status(StatusCode::OK) 533 | .header( 534 | CONTENT_TYPE, 535 | HeaderValue::from_str("application/json").unwrap(), 536 | ) 537 | .body( 538 | r#"{ 539 | "active": true, 540 | "scope": "email profile", 541 | "client_id": "aaa", 542 | "username": "demo", 543 | "token_type": "bearer", 544 | "exp": 1604073517, 545 | "iat": 1604073217, 546 | "nbf": 1604073317, 547 | "sub": "demo", 548 | "aud": "demo", 549 | "iss": "http://127.0.0.1:8080/auth/realms/test-realm", 550 | "jti": "be1b7da2-fc18-47b3-bdf1-7a4f50bcf53f" 551 | }"# 552 | .to_string() 553 | .into_bytes(), 554 | ) 555 | .unwrap(), 556 | )) 557 | .unwrap(); 558 | 559 | assert!(introspection_response.active); 560 | assert_eq!( 561 | Some(vec![ 562 | Scope::new("email".to_string()), 563 | Scope::new("profile".to_string()) 564 | ]), 565 | introspection_response.scopes 566 | ); 567 | assert_eq!( 568 | Some(ClientId::new("aaa".to_string())), 569 | introspection_response.client_id 570 | ); 571 | assert_eq!(Some("demo".to_string()), introspection_response.username); 572 | assert_eq!( 573 | Some(BasicTokenType::Bearer), 574 | introspection_response.token_type 575 | ); 576 | assert_eq!( 577 | Some(DateTime::from_timestamp(1604073517, 0).unwrap()), 578 | introspection_response.exp 579 | ); 580 | assert_eq!( 581 | Some(DateTime::from_timestamp(1604073217, 0).unwrap()), 582 | introspection_response.iat 583 | ); 584 | assert_eq!( 585 | Some(DateTime::from_timestamp(1604073317, 0).unwrap()), 586 | introspection_response.nbf 587 | ); 588 | assert_eq!(Some("demo".to_string()), introspection_response.sub); 589 | assert_eq!(Some(vec!["demo".to_string()]), introspection_response.aud); 590 | assert_eq!( 591 | Some("http://127.0.0.1:8080/auth/realms/test-realm".to_string()), 592 | introspection_response.iss 593 | ); 594 | assert_eq!( 595 | Some("be1b7da2-fc18-47b3-bdf1-7a4f50bcf53f".to_string()), 596 | introspection_response.jti 597 | ); 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use base64::prelude::*; 2 | use rand::{thread_rng, Rng}; 3 | use serde::{Deserialize, Serialize}; 4 | use sha2::{Digest, Sha256}; 5 | use url::Url; 6 | 7 | use std::fmt::Error as FormatterError; 8 | use std::fmt::{Debug, Formatter}; 9 | #[cfg(feature = "timing-resistant-secret-traits")] 10 | use std::hash::{Hash, Hasher}; 11 | use std::ops::Deref; 12 | 13 | macro_rules! new_type { 14 | // Convenience pattern without an impl. 15 | ( 16 | $(#[$attr:meta])* 17 | $name:ident( 18 | $(#[$type_attr:meta])* 19 | $type:ty 20 | ) 21 | ) => { 22 | new_type![ 23 | @new_type $(#[$attr])*, 24 | $name( 25 | $(#[$type_attr])* 26 | $type 27 | ), 28 | concat!( 29 | "Create a new `", 30 | stringify!($name), 31 | "` to wrap the given `", 32 | stringify!($type), 33 | "`." 34 | ), 35 | impl {} 36 | ]; 37 | }; 38 | // Main entry point with an impl. 39 | ( 40 | $(#[$attr:meta])* 41 | $name:ident( 42 | $(#[$type_attr:meta])* 43 | $type:ty 44 | ) 45 | impl { 46 | $($item:tt)* 47 | } 48 | ) => { 49 | new_type![ 50 | @new_type $(#[$attr])*, 51 | $name( 52 | $(#[$type_attr])* 53 | $type 54 | ), 55 | concat!( 56 | "Create a new `", 57 | stringify!($name), 58 | "` to wrap the given `", 59 | stringify!($type), 60 | "`." 61 | ), 62 | impl { 63 | $($item)* 64 | } 65 | ]; 66 | }; 67 | // Actual implementation, after stringifying the #[doc] attr. 68 | ( 69 | @new_type $(#[$attr:meta])*, 70 | $name:ident( 71 | $(#[$type_attr:meta])* 72 | $type:ty 73 | ), 74 | $new_doc:expr, 75 | impl { 76 | $($item:tt)* 77 | } 78 | ) => { 79 | $(#[$attr])* 80 | #[derive(Clone, Debug, PartialEq)] 81 | pub struct $name( 82 | $(#[$type_attr])* 83 | $type 84 | ); 85 | impl $name { 86 | $($item)* 87 | 88 | #[doc = $new_doc] 89 | pub const fn new(s: $type) -> Self { 90 | $name(s) 91 | } 92 | } 93 | impl Deref for $name { 94 | type Target = $type; 95 | fn deref(&self) -> &$type { 96 | &self.0 97 | } 98 | } 99 | impl From<$name> for $type { 100 | fn from(t: $name) -> $type { 101 | t.0 102 | } 103 | } 104 | } 105 | } 106 | 107 | macro_rules! new_secret_type { 108 | ( 109 | $(#[$attr:meta])* 110 | $name:ident($type:ty) 111 | ) => { 112 | new_secret_type![ 113 | $(#[$attr])* 114 | $name($type) 115 | impl {} 116 | ]; 117 | }; 118 | ( 119 | $(#[$attr:meta])* 120 | $name:ident($type:ty) 121 | impl { 122 | $($item:tt)* 123 | } 124 | ) => { 125 | new_secret_type![ 126 | $(#[$attr])*, 127 | $name($type), 128 | concat!( 129 | "Create a new `", 130 | stringify!($name), 131 | "` to wrap the given `", 132 | stringify!($type), 133 | "`." 134 | ), 135 | concat!("Get the secret contained within this `", stringify!($name), "`."), 136 | impl { 137 | $($item)* 138 | } 139 | ]; 140 | }; 141 | ( 142 | $(#[$attr:meta])*, 143 | $name:ident($type:ty), 144 | $new_doc:expr, 145 | $secret_doc:expr, 146 | impl { 147 | $($item:tt)* 148 | } 149 | ) => { 150 | $( 151 | #[$attr] 152 | )* 153 | #[cfg_attr(feature = "timing-resistant-secret-traits", derive(Eq))] 154 | pub struct $name($type); 155 | impl $name { 156 | $($item)* 157 | 158 | #[doc = $new_doc] 159 | pub fn new(s: $type) -> Self { 160 | $name(s) 161 | } 162 | 163 | #[doc = $secret_doc] 164 | /// 165 | /// # Security Warning 166 | /// 167 | /// Leaking this value may compromise the security of the OAuth2 flow. 168 | pub fn secret(&self) -> &$type { &self.0 } 169 | 170 | #[doc = $secret_doc] 171 | /// 172 | /// # Security Warning 173 | /// 174 | /// Leaking this value may compromise the security of the OAuth2 flow. 175 | pub fn into_secret(self) -> $type { self.0 } 176 | } 177 | impl Debug for $name { 178 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 179 | write!(f, concat!(stringify!($name), "([redacted])")) 180 | } 181 | } 182 | 183 | #[cfg(feature = "timing-resistant-secret-traits")] 184 | impl PartialEq for $name { 185 | fn eq(&self, other: &Self) -> bool { 186 | Sha256::digest(&self.0) == Sha256::digest(&other.0) 187 | } 188 | } 189 | 190 | #[cfg(feature = "timing-resistant-secret-traits")] 191 | impl Hash for $name { 192 | fn hash(&self, state: &mut H) { 193 | Sha256::digest(&self.0).hash(state) 194 | } 195 | } 196 | 197 | }; 198 | } 199 | 200 | /// Creates a URL-specific new type 201 | /// 202 | /// Types created by this macro enforce during construction that the contained value represents a 203 | /// syntactically valid URL. However, comparisons and hashes of these types are based on the string 204 | /// representation given during construction, disregarding any canonicalization performed by the 205 | /// underlying `Url` struct. OpenID Connect requires certain URLs (e.g., ID token issuers) to be 206 | /// compared exactly, without canonicalization. 207 | /// 208 | /// In addition to the raw string representation, these types include a `url` method to retrieve a 209 | /// parsed `Url` struct. 210 | macro_rules! new_url_type { 211 | // Convenience pattern without an impl. 212 | ( 213 | $(#[$attr:meta])* 214 | $name:ident 215 | ) => { 216 | new_url_type![ 217 | @new_type_pub $(#[$attr])*, 218 | $name, 219 | concat!("Create a new `", stringify!($name), "` from a `String` to wrap a URL."), 220 | concat!("Create a new `", stringify!($name), "` from a `Url` to wrap a URL."), 221 | concat!("Return this `", stringify!($name), "` as a parsed `Url`."), 222 | impl {} 223 | ]; 224 | }; 225 | // Main entry point with an impl. 226 | ( 227 | $(#[$attr:meta])* 228 | $name:ident 229 | impl { 230 | $($item:tt)* 231 | } 232 | ) => { 233 | new_url_type![ 234 | @new_type_pub $(#[$attr])*, 235 | $name, 236 | concat!("Create a new `", stringify!($name), "` from a `String` to wrap a URL."), 237 | concat!("Create a new `", stringify!($name), "` from a `Url` to wrap a URL."), 238 | concat!("Return this `", stringify!($name), "` as a parsed `Url`."), 239 | impl { 240 | $($item)* 241 | } 242 | ]; 243 | }; 244 | // Actual implementation, after stringifying the #[doc] attr. 245 | ( 246 | @new_type_pub $(#[$attr:meta])*, 247 | $name:ident, 248 | $new_doc:expr, 249 | $from_url_doc:expr, 250 | $url_doc:expr, 251 | impl { 252 | $($item:tt)* 253 | } 254 | ) => { 255 | $(#[$attr])* 256 | #[derive(Clone)] 257 | pub struct $name(Url, String); 258 | impl $name { 259 | #[doc = $new_doc] 260 | pub fn new(url: String) -> Result { 261 | Ok($name(Url::parse(&url)?, url)) 262 | } 263 | #[doc = $from_url_doc] 264 | pub fn from_url(url: Url) -> Self { 265 | let s = url.to_string(); 266 | Self(url, s) 267 | } 268 | #[doc = $url_doc] 269 | pub fn url(&self) -> &Url { 270 | return &self.0; 271 | } 272 | $($item)* 273 | } 274 | impl Deref for $name { 275 | type Target = String; 276 | fn deref(&self) -> &String { 277 | &self.1 278 | } 279 | } 280 | impl ::std::fmt::Display for $name { 281 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { 282 | write!(f, "{}", self.1) 283 | } 284 | } 285 | impl ::std::fmt::Debug for $name { 286 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { 287 | let mut debug_trait_builder = f.debug_tuple(stringify!($name)); 288 | debug_trait_builder.field(&self.1); 289 | debug_trait_builder.finish() 290 | } 291 | } 292 | impl<'de> ::serde::Deserialize<'de> for $name { 293 | fn deserialize(deserializer: D) -> Result 294 | where 295 | D: ::serde::de::Deserializer<'de>, 296 | { 297 | struct UrlVisitor; 298 | impl<'de> ::serde::de::Visitor<'de> for UrlVisitor { 299 | type Value = $name; 300 | 301 | fn expecting( 302 | &self, 303 | formatter: &mut ::std::fmt::Formatter 304 | ) -> ::std::fmt::Result { 305 | formatter.write_str(stringify!($name)) 306 | } 307 | 308 | fn visit_str(self, v: &str) -> Result 309 | where 310 | E: ::serde::de::Error, 311 | { 312 | $name::new(v.to_string()).map_err(E::custom) 313 | } 314 | } 315 | deserializer.deserialize_str(UrlVisitor {}) 316 | } 317 | } 318 | impl ::serde::Serialize for $name { 319 | fn serialize(&self, serializer: SE) -> Result 320 | where 321 | SE: ::serde::Serializer, 322 | { 323 | serializer.serialize_str(&self.1) 324 | } 325 | } 326 | impl ::std::hash::Hash for $name { 327 | fn hash(&self, state: &mut H) -> () { 328 | ::std::hash::Hash::hash(&(self.1), state); 329 | } 330 | } 331 | impl Ord for $name { 332 | fn cmp(&self, other: &$name) -> ::std::cmp::Ordering { 333 | self.1.cmp(&other.1) 334 | } 335 | } 336 | impl PartialOrd for $name { 337 | fn partial_cmp(&self, other: &$name) -> Option<::std::cmp::Ordering> { 338 | Some(self.cmp(other)) 339 | } 340 | } 341 | impl PartialEq for $name { 342 | fn eq(&self, other: &$name) -> bool { 343 | self.1 == other.1 344 | } 345 | } 346 | impl Eq for $name {} 347 | }; 348 | } 349 | 350 | new_type![ 351 | /// Client identifier issued to the client during the registration process described by 352 | /// [Section 2.2](https://tools.ietf.org/html/rfc6749#section-2.2). 353 | #[derive(Deserialize, Serialize, Eq, Hash)] 354 | ClientId(String) 355 | ]; 356 | 357 | new_url_type![ 358 | /// URL of the authorization server's authorization endpoint. 359 | AuthUrl 360 | ]; 361 | new_url_type![ 362 | /// URL of the authorization server's token endpoint. 363 | TokenUrl 364 | ]; 365 | new_url_type![ 366 | /// URL of the client's redirection endpoint. 367 | RedirectUrl 368 | ]; 369 | new_url_type![ 370 | /// URL of the client's [RFC 7662 OAuth 2.0 Token Introspection](https://tools.ietf.org/html/rfc7662) endpoint. 371 | IntrospectionUrl 372 | ]; 373 | new_url_type![ 374 | /// URL of the authorization server's RFC 7009 token revocation endpoint. 375 | RevocationUrl 376 | ]; 377 | new_url_type![ 378 | /// URL of the client's device authorization endpoint. 379 | DeviceAuthorizationUrl 380 | ]; 381 | new_url_type![ 382 | /// URL of the end-user verification URI on the authorization server. 383 | EndUserVerificationUrl 384 | ]; 385 | new_type![ 386 | /// Authorization endpoint response (grant) type defined in 387 | /// [Section 3.1.1](https://tools.ietf.org/html/rfc6749#section-3.1.1). 388 | #[derive(Deserialize, Serialize, Eq, Hash)] 389 | ResponseType(String) 390 | ]; 391 | new_type![ 392 | /// Resource owner's username used directly as an authorization grant to obtain an access 393 | /// token. 394 | #[derive(Deserialize, Serialize, Eq, Hash)] 395 | ResourceOwnerUsername(String) 396 | ]; 397 | 398 | new_type![ 399 | /// Access token scope, as defined by the authorization server. 400 | #[derive(Deserialize, Serialize, Eq, Hash)] 401 | Scope(String) 402 | ]; 403 | impl AsRef for Scope { 404 | fn as_ref(&self) -> &str { 405 | self 406 | } 407 | } 408 | 409 | new_type![ 410 | /// Code Challenge Method used for [PKCE](https://tools.ietf.org/html/rfc7636) protection 411 | /// via the `code_challenge_method` parameter. 412 | #[derive(Deserialize, Serialize, Eq, Hash)] 413 | PkceCodeChallengeMethod(String) 414 | ]; 415 | // This type intentionally does not implement Clone in order to make it difficult to reuse PKCE 416 | // challenges across multiple requests. 417 | new_secret_type![ 418 | /// Code Verifier used for [PKCE](https://tools.ietf.org/html/rfc7636) protection via the 419 | /// `code_verifier` parameter. The value must have a minimum length of 43 characters and a 420 | /// maximum length of 128 characters. Each character must be ASCII alphanumeric or one of 421 | /// the characters "-" / "." / "_" / "~". 422 | #[derive(Deserialize, Serialize)] 423 | PkceCodeVerifier(String) 424 | ]; 425 | 426 | /// Code Challenge used for [PKCE](https://tools.ietf.org/html/rfc7636) protection via the 427 | /// `code_challenge` parameter. 428 | #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] 429 | pub struct PkceCodeChallenge { 430 | code_challenge: String, 431 | code_challenge_method: PkceCodeChallengeMethod, 432 | } 433 | impl PkceCodeChallenge { 434 | /// Generate a new random, base64-encoded SHA-256 PKCE code. 435 | pub fn new_random_sha256() -> (Self, PkceCodeVerifier) { 436 | Self::new_random_sha256_len(32) 437 | } 438 | 439 | /// Generate a new random, base64-encoded SHA-256 PKCE challenge code and verifier. 440 | /// 441 | /// # Arguments 442 | /// 443 | /// * `num_bytes` - Number of random bytes to generate, prior to base64-encoding. 444 | /// The value must be in the range 32 to 96 inclusive in order to generate a verifier 445 | /// with a suitable length. 446 | /// 447 | /// # Panics 448 | /// 449 | /// This method panics if the resulting PKCE code verifier is not of a suitable length 450 | /// to comply with [RFC 7636](https://tools.ietf.org/html/rfc7636). 451 | pub fn new_random_sha256_len(num_bytes: u32) -> (Self, PkceCodeVerifier) { 452 | let code_verifier = Self::new_random_len(num_bytes); 453 | ( 454 | Self::from_code_verifier_sha256(&code_verifier), 455 | code_verifier, 456 | ) 457 | } 458 | 459 | /// Generate a new random, base64-encoded PKCE code verifier. 460 | /// 461 | /// # Arguments 462 | /// 463 | /// * `num_bytes` - Number of random bytes to generate, prior to base64-encoding. 464 | /// The value must be in the range 32 to 96 inclusive in order to generate a verifier 465 | /// with a suitable length. 466 | /// 467 | /// # Panics 468 | /// 469 | /// This method panics if the resulting PKCE code verifier is not of a suitable length 470 | /// to comply with [RFC 7636](https://tools.ietf.org/html/rfc7636). 471 | fn new_random_len(num_bytes: u32) -> PkceCodeVerifier { 472 | // The RFC specifies that the code verifier must have "a minimum length of 43 473 | // characters and a maximum length of 128 characters". 474 | // This implies 32-96 octets of random data to be base64 encoded. 475 | assert!((32..=96).contains(&num_bytes)); 476 | let random_bytes: Vec = (0..num_bytes).map(|_| thread_rng().gen::()).collect(); 477 | PkceCodeVerifier::new(BASE64_URL_SAFE_NO_PAD.encode(random_bytes)) 478 | } 479 | 480 | /// Generate a SHA-256 PKCE code challenge from the supplied PKCE code verifier. 481 | /// 482 | /// # Panics 483 | /// 484 | /// This method panics if the supplied PKCE code verifier is not of a suitable length 485 | /// to comply with [RFC 7636](https://tools.ietf.org/html/rfc7636). 486 | pub fn from_code_verifier_sha256(code_verifier: &PkceCodeVerifier) -> Self { 487 | // The RFC specifies that the code verifier must have "a minimum length of 43 488 | // characters and a maximum length of 128 characters". 489 | assert!(code_verifier.secret().len() >= 43 && code_verifier.secret().len() <= 128); 490 | 491 | let digest = Sha256::digest(code_verifier.secret().as_bytes()); 492 | let code_challenge = BASE64_URL_SAFE_NO_PAD.encode(digest); 493 | 494 | Self { 495 | code_challenge, 496 | code_challenge_method: PkceCodeChallengeMethod::new("S256".to_string()), 497 | } 498 | } 499 | 500 | /// Generate a new random, base64-encoded PKCE code. 501 | /// Use is discouraged unless the endpoint does not support SHA-256. 502 | /// 503 | /// # Panics 504 | /// 505 | /// This method panics if the supplied PKCE code verifier is not of a suitable length 506 | /// to comply with [RFC 7636](https://tools.ietf.org/html/rfc7636). 507 | #[cfg(feature = "pkce-plain")] 508 | pub fn new_random_plain() -> (Self, PkceCodeVerifier) { 509 | let code_verifier = Self::new_random_len(32); 510 | ( 511 | Self::from_code_verifier_plain(&code_verifier), 512 | code_verifier, 513 | ) 514 | } 515 | 516 | /// Generate a plain PKCE code challenge from the supplied PKCE code verifier. 517 | /// Use is discouraged unless the endpoint does not support SHA-256. 518 | /// 519 | /// # Panics 520 | /// 521 | /// This method panics if the supplied PKCE code verifier is not of a suitable length 522 | /// to comply with [RFC 7636](https://tools.ietf.org/html/rfc7636). 523 | #[cfg(feature = "pkce-plain")] 524 | pub fn from_code_verifier_plain(code_verifier: &PkceCodeVerifier) -> Self { 525 | // The RFC specifies that the code verifier must have "a minimum length of 43 526 | // characters and a maximum length of 128 characters". 527 | assert!(code_verifier.secret().len() >= 43 && code_verifier.secret().len() <= 128); 528 | 529 | let code_challenge = code_verifier.secret().clone(); 530 | 531 | Self { 532 | code_challenge, 533 | code_challenge_method: PkceCodeChallengeMethod::new("plain".to_string()), 534 | } 535 | } 536 | 537 | /// Returns the PKCE code challenge as a string. 538 | pub fn as_str(&self) -> &str { 539 | &self.code_challenge 540 | } 541 | 542 | /// Returns the PKCE code challenge method as a string. 543 | pub fn method(&self) -> &PkceCodeChallengeMethod { 544 | &self.code_challenge_method 545 | } 546 | } 547 | 548 | new_secret_type![ 549 | /// Client password issued to the client during the registration process described by 550 | /// [Section 2.2](https://tools.ietf.org/html/rfc6749#section-2.2). 551 | #[derive(Clone, Deserialize, Serialize)] 552 | ClientSecret(String) 553 | ]; 554 | new_secret_type![ 555 | /// Value used for [CSRF](https://tools.ietf.org/html/rfc6749#section-10.12) protection 556 | /// via the `state` parameter. 557 | #[must_use] 558 | #[derive(Clone, Deserialize, Serialize)] 559 | CsrfToken(String) 560 | impl { 561 | /// Generate a new random, base64-encoded 128-bit CSRF token. 562 | pub fn new_random() -> Self { 563 | CsrfToken::new_random_len(16) 564 | } 565 | /// Generate a new random, base64-encoded CSRF token of the specified length. 566 | /// 567 | /// # Arguments 568 | /// 569 | /// * `num_bytes` - Number of random bytes to generate, prior to base64-encoding. 570 | pub fn new_random_len(num_bytes: u32) -> Self { 571 | let random_bytes: Vec = (0..num_bytes).map(|_| thread_rng().gen::()).collect(); 572 | CsrfToken::new(BASE64_URL_SAFE_NO_PAD.encode(random_bytes)) 573 | } 574 | } 575 | ]; 576 | new_secret_type![ 577 | /// Authorization code returned from the authorization endpoint. 578 | #[derive(Clone, Deserialize, Serialize)] 579 | AuthorizationCode(String) 580 | ]; 581 | new_secret_type![ 582 | /// Refresh token used to obtain a new access token (if supported by the authorization server). 583 | #[derive(Clone, Deserialize, Serialize)] 584 | RefreshToken(String) 585 | ]; 586 | new_secret_type![ 587 | /// Access token returned by the token endpoint and used to access protected resources. 588 | #[derive(Clone, Deserialize, Serialize)] 589 | AccessToken(String) 590 | ]; 591 | new_secret_type![ 592 | /// Resource owner's password used directly as an authorization grant to obtain an access 593 | /// token. 594 | #[derive(Clone)] 595 | ResourceOwnerPassword(String) 596 | ]; 597 | new_secret_type![ 598 | /// Device code returned by the device authorization endpoint and used to query the token endpoint. 599 | #[derive(Clone, Deserialize, Serialize)] 600 | DeviceCode(String) 601 | ]; 602 | new_secret_type![ 603 | /// Verification URI returned by the device authorization endpoint and visited by the user 604 | /// to authorize. Contains the user code. 605 | #[derive(Clone, Deserialize, Serialize)] 606 | VerificationUriComplete(String) 607 | ]; 608 | new_secret_type![ 609 | /// User code returned by the device authorization endpoint and used by the user to authorize at 610 | /// the verification URI. 611 | #[derive(Clone, Deserialize, Serialize)] 612 | UserCode(String) 613 | ]; 614 | 615 | #[cfg(test)] 616 | mod tests { 617 | use crate::{ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier}; 618 | 619 | #[test] 620 | fn test_secret_conversion() { 621 | let secret = CsrfToken::new("top_secret".into()); 622 | assert_eq!(secret.into_secret().into_boxed_str(), "top_secret".into()); 623 | } 624 | 625 | #[test] 626 | fn test_secret_redaction() { 627 | let secret = ClientSecret::new("top_secret".to_string()); 628 | assert_eq!("ClientSecret([redacted])", format!("{secret:?}")); 629 | } 630 | 631 | #[test] 632 | #[should_panic] 633 | fn test_code_verifier_too_short() { 634 | PkceCodeChallenge::new_random_sha256_len(31); 635 | } 636 | 637 | #[test] 638 | #[should_panic] 639 | fn test_code_verifier_too_long() { 640 | PkceCodeChallenge::new_random_sha256_len(97); 641 | } 642 | 643 | #[test] 644 | fn test_code_verifier_min() { 645 | let code = PkceCodeChallenge::new_random_sha256_len(32); 646 | assert_eq!(code.1.secret().len(), 43); 647 | } 648 | 649 | #[test] 650 | fn test_code_verifier_max() { 651 | let code = PkceCodeChallenge::new_random_sha256_len(96); 652 | assert_eq!(code.1.secret().len(), 128); 653 | } 654 | 655 | #[test] 656 | fn test_code_verifier_challenge() { 657 | // Example from https://tools.ietf.org/html/rfc7636#appendix-B 658 | let code_verifier = 659 | PkceCodeVerifier::new("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".to_string()); 660 | assert_eq!( 661 | PkceCodeChallenge::from_code_verifier_sha256(&code_verifier).as_str(), 662 | "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", 663 | ); 664 | } 665 | } 666 | -------------------------------------------------------------------------------- /src/revocation.rs: -------------------------------------------------------------------------------- 1 | use crate::basic::BasicErrorResponseType; 2 | use crate::endpoint::{endpoint_request, endpoint_response_status_only}; 3 | use crate::{ 4 | AccessToken, AsyncHttpClient, AuthType, Client, ClientId, ClientSecret, ConfigurationError, 5 | EndpointState, ErrorResponse, ErrorResponseType, HttpRequest, RefreshToken, RequestTokenError, 6 | RevocationUrl, SyncHttpClient, TokenIntrospectionResponse, TokenResponse, 7 | }; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use std::borrow::Cow; 12 | use std::error::Error; 13 | use std::fmt::Error as FormatterError; 14 | use std::fmt::{Debug, Display, Formatter}; 15 | use std::future::Future; 16 | use std::marker::PhantomData; 17 | 18 | impl< 19 | TE, 20 | TR, 21 | TIR, 22 | RT, 23 | TRE, 24 | HasAuthUrl, 25 | HasDeviceAuthUrl, 26 | HasIntrospectionUrl, 27 | HasRevocationUrl, 28 | HasTokenUrl, 29 | > 30 | Client< 31 | TE, 32 | TR, 33 | TIR, 34 | RT, 35 | TRE, 36 | HasAuthUrl, 37 | HasDeviceAuthUrl, 38 | HasIntrospectionUrl, 39 | HasRevocationUrl, 40 | HasTokenUrl, 41 | > 42 | where 43 | TE: ErrorResponse + 'static, 44 | TR: TokenResponse, 45 | TIR: TokenIntrospectionResponse, 46 | RT: RevocableToken, 47 | TRE: ErrorResponse + 'static, 48 | HasAuthUrl: EndpointState, 49 | HasDeviceAuthUrl: EndpointState, 50 | HasIntrospectionUrl: EndpointState, 51 | HasRevocationUrl: EndpointState, 52 | HasTokenUrl: EndpointState, 53 | { 54 | pub(crate) fn revoke_token_impl<'a>( 55 | &'a self, 56 | revocation_url: &'a RevocationUrl, 57 | token: RT, 58 | ) -> Result, ConfigurationError> { 59 | // https://tools.ietf.org/html/rfc7009#section-2 states: 60 | // "The client requests the revocation of a particular token by making an 61 | // HTTP POST request to the token revocation endpoint URL. This URL 62 | // MUST conform to the rules given in [RFC6749], Section 3.1. Clients 63 | // MUST verify that the URL is an HTTPS URL." 64 | if revocation_url.url().scheme() != "https" { 65 | return Err(ConfigurationError::InsecureUrl("revocation")); 66 | } 67 | 68 | Ok(RevocationRequest { 69 | auth_type: &self.auth_type, 70 | client_id: &self.client_id, 71 | client_secret: self.client_secret.as_ref(), 72 | extra_params: Vec::new(), 73 | revocation_url, 74 | token, 75 | _phantom: PhantomData, 76 | }) 77 | } 78 | } 79 | 80 | /// A revocable token. 81 | /// 82 | /// Implement this trait to indicate support for token revocation per [RFC 7009 OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009#section-2.2). 83 | pub trait RevocableToken { 84 | /// The actual token value to be revoked. 85 | fn secret(&self) -> &str; 86 | 87 | /// Indicates the type of the token being revoked, as defined by [RFC 7009, Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1). 88 | /// 89 | /// Implementations should return `Some(...)` values for token types that the target authorization servers are 90 | /// expected to know (e.g. because they are registered in the [OAuth Token Type Hints Registry](https://tools.ietf.org/html/rfc7009#section-4.1.2)) 91 | /// so that they can potentially optimize their search for the token to be revoked. 92 | fn type_hint(&self) -> Option<&str>; 93 | } 94 | 95 | /// A token representation usable with authorization servers that support [RFC 7009](https://tools.ietf.org/html/rfc7009) token revocation. 96 | /// 97 | /// For use with [`revoke_token()`]. 98 | /// 99 | /// Automatically reports the correct RFC 7009 [`token_type_hint`](https://tools.ietf.org/html/rfc7009#section-2.1) value corresponding to the token type variant used, i.e. 100 | /// `access_token` for [`AccessToken`] and `secret_token` for [`RefreshToken`]. 101 | /// 102 | /// # Example 103 | /// 104 | /// Per [RFC 7009, Section 2](https://tools.ietf.org/html/rfc7009#section-2) prefer revocation by refresh token which, 105 | /// if issued to the client, must be supported by the server, otherwise fallback to access token (which may or may not 106 | /// be supported by the server). 107 | /// 108 | /// ```rust 109 | /// # use http::{Response, StatusCode}; 110 | /// # use oauth2::{ 111 | /// # AccessToken, AuthUrl, ClientId, EmptyExtraTokenFields, HttpResponse, RequestTokenError, 112 | /// # RevocationUrl, StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl, 113 | /// # }; 114 | /// # use oauth2::basic::{BasicClient, BasicRequestTokenError, BasicTokenResponse, BasicTokenType}; 115 | /// # 116 | /// # fn err_wrapper() -> Result<(), anyhow::Error> { 117 | /// # 118 | /// # let token_response = BasicTokenResponse::new( 119 | /// # AccessToken::new("access".to_string()), 120 | /// # BasicTokenType::Bearer, 121 | /// # EmptyExtraTokenFields {}, 122 | /// # ); 123 | /// # 124 | /// # #[derive(Debug, thiserror::Error)] 125 | /// # enum FakeError {} 126 | /// # 127 | /// # let http_client = |_| -> Result> { 128 | /// # Ok(Response::builder() 129 | /// # .status(StatusCode::OK) 130 | /// # .body(Vec::new()) 131 | /// # .unwrap()) 132 | /// # }; 133 | /// # 134 | /// let client = BasicClient::new(ClientId::new("aaa".to_string())) 135 | /// .set_auth_uri(AuthUrl::new("https://example.com/auth".to_string()).unwrap()) 136 | /// .set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap()) 137 | /// // Be sure to set a revocation URL. 138 | /// .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 139 | /// 140 | /// // ... 141 | /// 142 | /// let token_to_revoke: StandardRevocableToken = match token_response.refresh_token() { 143 | /// Some(token) => token.into(), 144 | /// None => token_response.access_token().into(), 145 | /// }; 146 | /// 147 | /// client 148 | /// .revoke_token(token_to_revoke)? 149 | /// .request(&http_client) 150 | /// # .unwrap(); 151 | /// # Ok(()) 152 | /// # } 153 | /// ``` 154 | /// 155 | /// [`revoke_token()`]: crate::Client::revoke_token() 156 | #[derive(Clone, Debug, Deserialize, Serialize)] 157 | #[non_exhaustive] 158 | pub enum StandardRevocableToken { 159 | /// A representation of an [`AccessToken`] suitable for use with [`revoke_token()`](crate::Client::revoke_token()). 160 | AccessToken(AccessToken), 161 | /// A representation of an [`RefreshToken`] suitable for use with [`revoke_token()`](crate::Client::revoke_token()). 162 | RefreshToken(RefreshToken), 163 | } 164 | impl RevocableToken for StandardRevocableToken { 165 | fn secret(&self) -> &str { 166 | match self { 167 | Self::AccessToken(token) => token.secret(), 168 | Self::RefreshToken(token) => token.secret(), 169 | } 170 | } 171 | 172 | /// Indicates the type of the token to be revoked, as defined by [RFC 7009, Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1), i.e.: 173 | /// 174 | /// * `access_token`: An access token as defined in [RFC 6749, 175 | /// Section 1.4](https://tools.ietf.org/html/rfc6749#section-1.4) 176 | /// 177 | /// * `refresh_token`: A refresh token as defined in [RFC 6749, 178 | /// Section 1.5](https://tools.ietf.org/html/rfc6749#section-1.5) 179 | fn type_hint(&self) -> Option<&str> { 180 | match self { 181 | StandardRevocableToken::AccessToken(_) => Some("access_token"), 182 | StandardRevocableToken::RefreshToken(_) => Some("refresh_token"), 183 | } 184 | } 185 | } 186 | 187 | impl From for StandardRevocableToken { 188 | fn from(token: AccessToken) -> Self { 189 | Self::AccessToken(token) 190 | } 191 | } 192 | 193 | impl From<&AccessToken> for StandardRevocableToken { 194 | fn from(token: &AccessToken) -> Self { 195 | Self::AccessToken(token.clone()) 196 | } 197 | } 198 | 199 | impl From for StandardRevocableToken { 200 | fn from(token: RefreshToken) -> Self { 201 | Self::RefreshToken(token) 202 | } 203 | } 204 | 205 | impl From<&RefreshToken> for StandardRevocableToken { 206 | fn from(token: &RefreshToken) -> Self { 207 | Self::RefreshToken(token.clone()) 208 | } 209 | } 210 | 211 | /// A request to revoke a token via an [`RFC 7009`](https://tools.ietf.org/html/rfc7009#section-2.1) compatible 212 | /// endpoint. 213 | #[derive(Debug)] 214 | pub struct RevocationRequest<'a, RT, TE> 215 | where 216 | RT: RevocableToken, 217 | TE: ErrorResponse, 218 | { 219 | pub(crate) token: RT, 220 | pub(crate) auth_type: &'a AuthType, 221 | pub(crate) client_id: &'a ClientId, 222 | pub(crate) client_secret: Option<&'a ClientSecret>, 223 | pub(crate) extra_params: Vec<(Cow<'a, str>, Cow<'a, str>)>, 224 | pub(crate) revocation_url: &'a RevocationUrl, 225 | pub(crate) _phantom: PhantomData<(RT, TE)>, 226 | } 227 | 228 | impl<'a, RT, TE> RevocationRequest<'a, RT, TE> 229 | where 230 | RT: RevocableToken, 231 | TE: ErrorResponse + 'static, 232 | { 233 | /// Appends an extra param to the token revocation request. 234 | /// 235 | /// This method allows extensions to be used without direct support from 236 | /// this crate. If `name` conflicts with a parameter managed by this crate, the 237 | /// behavior is undefined. In particular, do not set parameters defined by 238 | /// [RFC 6749](https://tools.ietf.org/html/rfc6749) or 239 | /// [RFC 7662](https://tools.ietf.org/html/rfc7662). 240 | /// 241 | /// # Security Warning 242 | /// 243 | /// Callers should follow the security recommendations for any OAuth2 extensions used with 244 | /// this function, which are beyond the scope of 245 | /// [RFC 6749](https://tools.ietf.org/html/rfc6749). 246 | pub fn add_extra_param(mut self, name: N, value: V) -> Self 247 | where 248 | N: Into>, 249 | V: Into>, 250 | { 251 | self.extra_params.push((name.into(), value.into())); 252 | self 253 | } 254 | 255 | fn prepare_request(self) -> Result> 256 | where 257 | RE: Error + 'static, 258 | { 259 | let mut params: Vec<(&str, &str)> = vec![("token", self.token.secret())]; 260 | if let Some(type_hint) = self.token.type_hint() { 261 | params.push(("token_type_hint", type_hint)); 262 | } 263 | 264 | endpoint_request( 265 | self.auth_type, 266 | self.client_id, 267 | self.client_secret, 268 | &self.extra_params, 269 | None, 270 | None, 271 | self.revocation_url.url(), 272 | params, 273 | ) 274 | .map_err(|err| RequestTokenError::Other(format!("failed to prepare request: {err}"))) 275 | } 276 | 277 | /// Synchronously sends the request to the authorization server and awaits a response. 278 | /// 279 | /// A successful response indicates that the server either revoked the token or the token was not known to the 280 | /// server. 281 | /// 282 | /// Error [`UnsupportedTokenType`](RevocationErrorResponseType::UnsupportedTokenType) will be returned if the 283 | /// type of token type given is not supported by the server. 284 | pub fn request( 285 | self, 286 | http_client: &C, 287 | ) -> Result<(), RequestTokenError<::Error, TE>> 288 | where 289 | C: SyncHttpClient, 290 | { 291 | // From https://tools.ietf.org/html/rfc7009#section-2.2: 292 | // "The content of the response body is ignored by the client as all 293 | // necessary information is conveyed in the response code." 294 | endpoint_response_status_only(http_client.call(self.prepare_request()?)?) 295 | } 296 | 297 | /// Asynchronously sends the request to the authorization server and returns a Future. 298 | pub fn request_async<'c, C>( 299 | self, 300 | http_client: &'c C, 301 | ) -> impl Future>::Error, TE>>> + 'c 302 | where 303 | Self: 'c, 304 | C: AsyncHttpClient<'c>, 305 | { 306 | Box::pin(async move { 307 | endpoint_response_status_only(http_client.call(self.prepare_request()?).await?) 308 | }) 309 | } 310 | } 311 | 312 | /// OAuth 2.0 Token Revocation error response types. 313 | /// 314 | /// These error types are defined in 315 | /// [Section 2.2.1 of RFC 7009](https://tools.ietf.org/html/rfc7009#section-2.2.1) and 316 | /// [Section 5.2 of RFC 6749](https://tools.ietf.org/html/rfc8628#section-5.2) 317 | #[derive(Clone, PartialEq, Eq)] 318 | pub enum RevocationErrorResponseType { 319 | /// The authorization server does not support the revocation of the presented token type. 320 | UnsupportedTokenType, 321 | /// The authorization server responded with some other error as defined [RFC 6749](https://tools.ietf.org/html/rfc6749) error. 322 | Basic(BasicErrorResponseType), 323 | } 324 | impl RevocationErrorResponseType { 325 | fn from_str(s: &str) -> Self { 326 | match BasicErrorResponseType::from_str(s) { 327 | BasicErrorResponseType::Extension(ext) => match ext.as_str() { 328 | "unsupported_token_type" => RevocationErrorResponseType::UnsupportedTokenType, 329 | _ => RevocationErrorResponseType::Basic(BasicErrorResponseType::Extension(ext)), 330 | }, 331 | basic => RevocationErrorResponseType::Basic(basic), 332 | } 333 | } 334 | } 335 | impl AsRef for RevocationErrorResponseType { 336 | fn as_ref(&self) -> &str { 337 | match self { 338 | RevocationErrorResponseType::UnsupportedTokenType => "unsupported_token_type", 339 | RevocationErrorResponseType::Basic(basic) => basic.as_ref(), 340 | } 341 | } 342 | } 343 | impl<'de> serde::Deserialize<'de> for RevocationErrorResponseType { 344 | fn deserialize(deserializer: D) -> Result 345 | where 346 | D: serde::de::Deserializer<'de>, 347 | { 348 | let variant_str = String::deserialize(deserializer)?; 349 | Ok(Self::from_str(&variant_str)) 350 | } 351 | } 352 | impl serde::ser::Serialize for RevocationErrorResponseType { 353 | fn serialize(&self, serializer: S) -> Result 354 | where 355 | S: serde::ser::Serializer, 356 | { 357 | serializer.serialize_str(self.as_ref()) 358 | } 359 | } 360 | impl ErrorResponseType for RevocationErrorResponseType {} 361 | impl Debug for RevocationErrorResponseType { 362 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 363 | Display::fmt(self, f) 364 | } 365 | } 366 | 367 | impl Display for RevocationErrorResponseType { 368 | fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> { 369 | write!(f, "{}", self.as_ref()) 370 | } 371 | } 372 | 373 | #[cfg(test)] 374 | mod tests { 375 | use crate::basic::BasicRevocationErrorResponse; 376 | use crate::tests::colorful_extension::{ColorfulClient, ColorfulRevocableToken}; 377 | use crate::tests::{mock_http_client, new_client}; 378 | use crate::{ 379 | AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, RequestTokenError, 380 | RevocationErrorResponseType, RevocationUrl, TokenUrl, 381 | }; 382 | 383 | use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; 384 | use http::{HeaderValue, Response, StatusCode}; 385 | 386 | #[test] 387 | fn test_token_revocation_with_missing_url() { 388 | let client = new_client().set_revocation_url_option(None); 389 | 390 | let result = client 391 | .revoke_token(AccessToken::new("access_token_123".to_string()).into()) 392 | .unwrap_err(); 393 | 394 | assert_eq!(result.to_string(), "No revocation endpoint URL specified"); 395 | } 396 | 397 | #[test] 398 | fn test_token_revocation_with_non_https_url() { 399 | let client = new_client(); 400 | 401 | let result = client 402 | .set_revocation_url(RevocationUrl::new("http://revocation/url".to_string()).unwrap()) 403 | .revoke_token(AccessToken::new("access_token_123".to_string()).into()) 404 | .unwrap_err(); 405 | 406 | assert_eq!( 407 | result.to_string(), 408 | "Scheme for revocation endpoint URL must be HTTPS" 409 | ); 410 | } 411 | 412 | #[test] 413 | fn test_token_revocation_with_unsupported_token_type() { 414 | let client = new_client() 415 | .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 416 | 417 | let revocation_response = client 418 | .revoke_token(AccessToken::new("access_token_123".to_string()).into()).unwrap() 419 | .request(&mock_http_client( 420 | vec![ 421 | (ACCEPT, "application/json"), 422 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 423 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 424 | ], 425 | "token=access_token_123&token_type_hint=access_token", 426 | Some("https://revocation/url".parse().unwrap()), 427 | Response::builder() 428 | .status(StatusCode::BAD_REQUEST) 429 | .header( 430 | CONTENT_TYPE, 431 | HeaderValue::from_str("application/json").unwrap(), 432 | ) 433 | .body( 434 | "{\ 435 | \"error\": \"unsupported_token_type\", \"error_description\": \"stuff happened\", \ 436 | \"error_uri\": \"https://errors\"\ 437 | }" 438 | .to_string() 439 | .into_bytes(), 440 | ) 441 | .unwrap(), 442 | )); 443 | 444 | assert!(matches!( 445 | revocation_response, 446 | Err(RequestTokenError::ServerResponse( 447 | BasicRevocationErrorResponse { 448 | error: RevocationErrorResponseType::UnsupportedTokenType, 449 | .. 450 | } 451 | )) 452 | )); 453 | } 454 | 455 | #[test] 456 | fn test_token_revocation_with_access_token_and_empty_json_response() { 457 | let client = new_client() 458 | .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 459 | 460 | client 461 | .revoke_token(AccessToken::new("access_token_123".to_string()).into()) 462 | .unwrap() 463 | .request(&mock_http_client( 464 | vec![ 465 | (ACCEPT, "application/json"), 466 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 467 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 468 | ], 469 | "token=access_token_123&token_type_hint=access_token", 470 | Some("https://revocation/url".parse().unwrap()), 471 | Response::builder() 472 | .status(StatusCode::OK) 473 | .header( 474 | CONTENT_TYPE, 475 | HeaderValue::from_str("application/json").unwrap(), 476 | ) 477 | .body(b"{}".to_vec()) 478 | .unwrap(), 479 | )) 480 | .unwrap(); 481 | } 482 | 483 | #[test] 484 | fn test_token_revocation_with_access_token_and_empty_response() { 485 | let client = new_client() 486 | .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 487 | 488 | client 489 | .revoke_token(AccessToken::new("access_token_123".to_string()).into()) 490 | .unwrap() 491 | .request(&mock_http_client( 492 | vec![ 493 | (ACCEPT, "application/json"), 494 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 495 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 496 | ], 497 | "token=access_token_123&token_type_hint=access_token", 498 | Some("https://revocation/url".parse().unwrap()), 499 | Response::builder() 500 | .status(StatusCode::OK) 501 | .body(vec![]) 502 | .unwrap(), 503 | )) 504 | .unwrap(); 505 | } 506 | 507 | #[test] 508 | fn test_token_revocation_with_access_token_and_non_json_response() { 509 | let client = new_client() 510 | .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 511 | 512 | client 513 | .revoke_token(AccessToken::new("access_token_123".to_string()).into()) 514 | .unwrap() 515 | .request(&mock_http_client( 516 | vec![ 517 | (ACCEPT, "application/json"), 518 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 519 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 520 | ], 521 | "token=access_token_123&token_type_hint=access_token", 522 | Some("https://revocation/url".parse().unwrap()), 523 | Response::builder() 524 | .status(StatusCode::OK) 525 | .header( 526 | CONTENT_TYPE, 527 | HeaderValue::from_str("application/octet-stream").unwrap(), 528 | ) 529 | .body(vec![1, 2, 3]) 530 | .unwrap(), 531 | )) 532 | .unwrap(); 533 | } 534 | 535 | #[test] 536 | fn test_token_revocation_with_refresh_token() { 537 | let client = new_client() 538 | .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 539 | 540 | client 541 | .revoke_token(RefreshToken::new("refresh_token_123".to_string()).into()) 542 | .unwrap() 543 | .request(&mock_http_client( 544 | vec![ 545 | (ACCEPT, "application/json"), 546 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 547 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 548 | ], 549 | "token=refresh_token_123&token_type_hint=refresh_token", 550 | Some("https://revocation/url".parse().unwrap()), 551 | Response::builder() 552 | .status(StatusCode::OK) 553 | .header( 554 | CONTENT_TYPE, 555 | HeaderValue::from_str("application/json").unwrap(), 556 | ) 557 | .body(b"{}".to_vec()) 558 | .unwrap(), 559 | )) 560 | .unwrap(); 561 | } 562 | 563 | #[test] 564 | fn test_extension_token_revocation_successful() { 565 | let client = ColorfulClient::new(ClientId::new("aaa".to_string())) 566 | .set_client_secret(ClientSecret::new("bbb".to_string())) 567 | .set_auth_uri(AuthUrl::new("https://example.com/auth".to_string()).unwrap()) 568 | .set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap()) 569 | .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap()); 570 | 571 | client 572 | .revoke_token(ColorfulRevocableToken::Red( 573 | "colorful_token_123".to_string(), 574 | )) 575 | .unwrap() 576 | .request(&mock_http_client( 577 | vec![ 578 | (ACCEPT, "application/json"), 579 | (CONTENT_TYPE, "application/x-www-form-urlencoded"), 580 | (AUTHORIZATION, "Basic YWFhOmJiYg=="), 581 | ], 582 | "token=colorful_token_123&token_type_hint=red_token", 583 | Some("https://revocation/url".parse().unwrap()), 584 | Response::builder() 585 | .status(StatusCode::OK) 586 | .header( 587 | CONTENT_TYPE, 588 | HeaderValue::from_str("application/json").unwrap(), 589 | ) 590 | .body(b"{}".to_vec()) 591 | .unwrap(), 592 | )) 593 | .unwrap(); 594 | } 595 | } 596 | --------------------------------------------------------------------------------