├── .cargo-ok ├── .cargo └── config.toml ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── COPYING ├── Cargo.toml ├── MIT-LICENSE ├── README.md ├── UNLICENSE ├── release.sh ├── src ├── address.rs ├── bearer.rs ├── claims.rs ├── client.rs ├── config.rs ├── configurable.rs ├── custom_claims.rs ├── deserializers.rs ├── discovered.rs ├── display.rs ├── error.rs ├── lib.rs ├── options.rs ├── prompt.rs ├── provider │ ├── microsoft.rs │ └── mod.rs ├── standard_claims.rs ├── standard_claims_subject.rs ├── token.rs ├── token_introspection.rs ├── uma2 │ ├── claim_token_format.rs │ ├── config.rs │ ├── discovered.rs │ ├── error.rs │ ├── mod.rs │ ├── permission_association.rs │ ├── permission_ticket.rs │ ├── provider.rs │ ├── resource.rs │ └── rpt.rs ├── userinfo.rs └── validation.rs └── templates └── README.md /.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilork/openid/514b6f4271478dbc50d81f9bbff65fd7d522c3db/.cargo-ok -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | f = "fmt -- --config unstable_features=true --config wrap_comments=true --config group_imports=StdExternalCrate" 3 | [resolver] 4 | incompatible-rust-versions = "fallback" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kilork 2 | ko_fi: kilork 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: dtolnay/rust-toolchain@1.84.0 20 | - name: Build 21 | run: cargo build 22 | - name: Run tests 23 | run: cargo test 24 | - name: Build with UMA2 25 | run: cargo build --features uma2 26 | - name: Run tests with UMA2 27 | run: cargo test --features uma2 28 | - name: Build with Microsoft feature 29 | run: cargo build --features microsoft 30 | - name: Run tests with Microsoft feature 31 | run: cargo test --features uma2 32 | - name: Build with rustls 33 | run: cargo build --no-default-features --features rustls 34 | - name: Run tests with rustls 35 | run: cargo test --no-default-features --features rustls 36 | publish: 37 | name: Publish 38 | if: startsWith( github.ref, 'refs/tags/v' ) 39 | uses: ./.github/workflows/release.yml 40 | needs: build 41 | secrets: inherit -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to crates.io and draft release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | CRATES_TOKEN: 7 | required: true 8 | 9 | jobs: 10 | publish: 11 | name: Publish to crates.io 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: dtolnay/rust-toolchain@1.84.0 16 | - run: | 17 | cargo publish --token ${CRATES_TOKEN} 18 | env: 19 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 20 | release: 21 | name: Release on GitHub 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Create a Release 26 | uses: elgohr/Github-Release-Action@v5 27 | env: 28 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | with: 30 | title: Release ${{ github.ref_name }} 31 | tag: ${{ github.ref }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | *.iml 5 | .idea/ 6 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | This project is dual-licensed under the Unlicense and MIT licenses. 2 | 3 | You may use this code under the terms of either license. -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "openid" 3 | version = "0.17.0" 4 | authors = ["Alexander Korolev "] 5 | edition = "2021" 6 | categories = ["authentication"] 7 | description = """ 8 | OpenID Connect & Discovery client library using async / await. 9 | """ 10 | homepage = "https://github.com/kilork/openid" 11 | keywords = ["authentication", "authorization", "oauth", "openid", "uma2"] 12 | license = "Unlicense OR MIT" 13 | readme = "README.md" 14 | repository = "https://github.com/kilork/openid" 15 | rust-version = "1.84.0" 16 | 17 | [features] 18 | default = ["native-tls"] 19 | microsoft = [] 20 | uma2 = [] 21 | native-tls = ["reqwest/native-tls"] 22 | rustls = ["reqwest/rustls-tls"] 23 | rustls-native-roots = ["reqwest/rustls-tls-native-roots"] 24 | 25 | [dependencies] 26 | lazy_static = "1.4" 27 | serde_json = { version = "1", default-features = false } 28 | base64 = "0.22" 29 | biscuit = "0.7" 30 | thiserror = "1" 31 | validator = { version = "0.19", features = ["derive"] } 32 | mime = "0.3" 33 | 34 | [dependencies.url] 35 | version = "2" 36 | default-features = false 37 | features = ["serde"] 38 | 39 | [dependencies.chrono] 40 | version = "0.4" 41 | default-features = false 42 | features = ["serde"] 43 | 44 | [dependencies.serde] 45 | version = "1" 46 | default-features = false 47 | features = ["derive"] 48 | 49 | [dependencies.reqwest] 50 | version = "0.12" 51 | default-features = false 52 | features = ["json"] 53 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Alexander Korolev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenID Connect & Discovery client library using async / await 2 | 3 | ## Legal 4 | 5 | Dual-licensed under `MIT` or the [UNLICENSE](http://unlicense.org/). 6 | 7 | ## Features 8 | 9 | Implements [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) and [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html). 10 | 11 | Implements [UMA2](https://docs.kantarainitiative.org/uma/wg/oauth-uma-federated-authz-2.0-09.html) - User Managed Access, an extension to OIDC/OAuth2. Use feature flag `uma2` to enable this feature. 12 | 13 | Implements [OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662). 14 | 15 | It supports Microsoft OIDC with feature `microsoft`. This adds methods for authentication and token validation, those skip issuer check. 16 | 17 | Originally developed as a quick adaptation to leverage async/await functionality, based on [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc), the library has since evolved into a mature and robust solution, offering expanded features and improved performance. 18 | 19 | Using [reqwest](https://crates.io/crates/reqwest) for the HTTP client and [biscuit](https://crates.io/crates/biscuit) for Javascript Object Signing and Encryption (JOSE). 20 | 21 | ## Support: 22 | 23 | You can contribute to the ongoing development and maintenance of OpenID library in various ways: 24 | 25 | ### Sponsorship 26 | 27 | Your support, no matter how big or small, helps sustain the project and ensures its continued improvement. Reach out to explore sponsorship opportunities. 28 | 29 | ### Feedback 30 | 31 | Whether you are a developer, user, or enthusiast, your feedback is invaluable. Share your thoughts, suggestions, and ideas to help shape the future of the library. 32 | 33 | ### Contribution 34 | 35 | If you're passionate about open-source and have skills to share, consider contributing to the project. Every contribution counts! 36 | 37 | Thank you for being part of OpenID community. Together, we are making authentication processes more accessible, reliable, and efficient for everyone. 38 | 39 | ## Usage 40 | 41 | Add dependency to Cargo.toml: 42 | 43 | ```toml 44 | [dependencies] 45 | openid = "0.17" 46 | ``` 47 | 48 | By default we use native tls, if you want to use `rustls`: 49 | 50 | ```toml 51 | [dependencies] 52 | openid = { version = "0.17", default-features = false, features = ["rustls"] } 53 | ``` 54 | 55 | Alternatively, you can use `rustls` with the platform’s native certificates: 56 | 57 | ```toml 58 | [dependencies] 59 | openid = { version = "0.17", default-features = false, features = ["rustls-native-roots"] } 60 | ``` 61 | 62 | ### Use case: [Warp](https://crates.io/crates/warp) web server with [JHipster](https://www.jhipster.tech/) generated frontend and [Google OpenID Connect](https://developers.google.com/identity/protocols/OpenIDConnect) 63 | 64 | This example provides only Rust part, assuming just default JHipster frontend settings. 65 | 66 | in Cargo.toml: 67 | 68 | ```toml 69 | [dependencies] 70 | anyhow = "1.0" 71 | cookie = "0.18" 72 | dotenv = "0.15" 73 | log = "0.4" 74 | openid = "0.17" 75 | pretty_env_logger = "0.5" 76 | reqwest = "0.12" 77 | serde = { version = "1", default-features = false, features = [ "derive" ] } 78 | serde_json = "1" 79 | tokio = { version = "1", default-features = false, features = [ "rt-multi-thread", "macros" ] } 80 | uuid = { version = "1.0", default-features = false, features = [ "v4" ] } 81 | warp = { version = "0.3", default-features = false } 82 | ``` 83 | 84 | in src/main.rs: 85 | 86 | ```rust, compile_fail 87 | use std::{convert::Infallible, env, net::SocketAddr, sync::Arc}; 88 | 89 | use cookie::time::Duration; 90 | use log::{error, info}; 91 | use openid::{Client, Discovered, DiscoveredClient, Options, StandardClaims, Token, Userinfo}; 92 | use openid_examples::{ 93 | entity::{LoginQuery, Sessions, User}, 94 | INDEX_HTML, 95 | }; 96 | use tokio::sync::RwLock; 97 | use warp::{ 98 | http::{Response, StatusCode}, 99 | reject, Filter, Rejection, Reply, 100 | }; 101 | 102 | type OpenIDClient = Client; 103 | 104 | const EXAMPLE_COOKIE: &str = "openid_warp_example"; 105 | 106 | #[tokio::main] 107 | async fn main() -> anyhow::Result<()> { 108 | dotenv::dotenv().ok(); 109 | 110 | pretty_env_logger::init(); 111 | 112 | let client_id = env::var("CLIENT_ID").expect(" for your provider"); 113 | let client_secret = env::var("CLIENT_SECRET").ok(); 114 | let issuer_url = 115 | env::var("ISSUER").unwrap_or_else(|_| "https://accounts.google.com".to_string()); 116 | let redirect = Some(host("/login/oauth2/code/oidc")); 117 | let issuer = reqwest::Url::parse(&issuer_url)?; 118 | let listen: SocketAddr = env::var("LISTEN") 119 | .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) 120 | .parse()?; 121 | 122 | info!("redirect: {:?}", redirect); 123 | info!("issuer: {}", issuer); 124 | 125 | let client = Arc::new( 126 | DiscoveredClient::discover( 127 | client_id, 128 | client_secret.unwrap_or_default(), 129 | redirect, 130 | issuer, 131 | ) 132 | .await?, 133 | ); 134 | 135 | info!("discovered config: {:?}", client.config()); 136 | 137 | let with_client = |client: Arc>| warp::any().map(move || client.clone()); 138 | 139 | let sessions = Arc::new(RwLock::new(Sessions::default())); 140 | 141 | let with_sessions = |sessions: Arc>| warp::any().map(move || sessions.clone()); 142 | 143 | let index = warp::path::end() 144 | .and(warp::get()) 145 | .map(|| warp::reply::html(INDEX_HTML)); 146 | 147 | let authorize = warp::path!("oauth2" / "authorization" / "oidc") 148 | .and(warp::get()) 149 | .and(with_client(client.clone())) 150 | .and_then(reply_authorize); 151 | 152 | let login = warp::path!("login" / "oauth2" / "code" / "oidc") 153 | .and(warp::get()) 154 | .and(with_client(client.clone())) 155 | .and(warp::query::()) 156 | .and(with_sessions(sessions.clone())) 157 | .and_then(reply_login); 158 | 159 | let logout = warp::path!("logout") 160 | .and(warp::get()) 161 | .and(with_client(client.clone())) 162 | .and(warp::cookie::optional(EXAMPLE_COOKIE)) 163 | .and(with_sessions(sessions.clone())) 164 | .and_then(reply_logout); 165 | 166 | let api_account = warp::path!("api" / "account") 167 | .and(warp::get()) 168 | .and(with_user(sessions)) 169 | .map(|user: User| warp::reply::json(&user)); 170 | 171 | let routes = index 172 | .or(authorize) 173 | .or(login) 174 | .or(logout) 175 | .or(api_account) 176 | .recover(handle_rejections); 177 | 178 | let logged_routes = routes.with(warp::log("openid_warp_example")); 179 | 180 | warp::serve(logged_routes).run(listen).await; 181 | 182 | Ok(()) 183 | } 184 | 185 | async fn request_token( 186 | oidc_client: &OpenIDClient, 187 | login_query: &LoginQuery, 188 | ) -> anyhow::Result> { 189 | let mut token: Token = oidc_client.request_token(&login_query.code).await?.into(); 190 | 191 | if let Some(id_token) = token.id_token.as_mut() { 192 | oidc_client.decode_token(id_token)?; 193 | oidc_client.validate_token(id_token, None, None)?; 194 | info!("token: {:?}", id_token); 195 | } else { 196 | return Ok(None); 197 | } 198 | 199 | let userinfo = oidc_client.request_userinfo(&token).await?; 200 | 201 | info!("user info: {:?}", userinfo); 202 | 203 | Ok(Some((token, userinfo))) 204 | } 205 | 206 | async fn reply_login( 207 | oidc_client: Arc, 208 | login_query: LoginQuery, 209 | sessions: Arc>, 210 | ) -> Result { 211 | let request_token = request_token(&oidc_client, &login_query).await; 212 | match request_token { 213 | Ok(Some((token, user_info))) => { 214 | let id = uuid::Uuid::new_v4().to_string(); 215 | 216 | let login = user_info.preferred_username.clone(); 217 | let email = user_info.email.clone(); 218 | 219 | let user = User { 220 | id: user_info.sub.clone().unwrap_or_default(), 221 | login, 222 | last_name: user_info.family_name.clone(), 223 | first_name: user_info.name.clone(), 224 | email, 225 | activated: user_info.email_verified, 226 | image_url: user_info.picture.clone().map(|x| x.to_string()), 227 | lang_key: Some("en".to_string()), 228 | authorities: vec!["ROLE_USER".to_string()], 229 | }; 230 | 231 | let authorization_cookie = ::cookie::Cookie::build((EXAMPLE_COOKIE, &id)) 232 | .path("/") 233 | .http_only(true) 234 | .build() 235 | .to_string(); 236 | 237 | sessions 238 | .write() 239 | .await 240 | .map 241 | .insert(id, (user, token, user_info)); 242 | 243 | let redirect_url = login_query.state.clone().unwrap_or_else(|| host("/")); 244 | 245 | Ok(Response::builder() 246 | .status(StatusCode::MOVED_PERMANENTLY) 247 | .header(warp::http::header::LOCATION, redirect_url) 248 | .header(warp::http::header::SET_COOKIE, authorization_cookie) 249 | .body("") 250 | .unwrap()) 251 | } 252 | Ok(None) => { 253 | error!("login error in call: no id_token found"); 254 | 255 | response_unauthorized() 256 | } 257 | Err(err) => { 258 | error!("login error in call: {:?}", err); 259 | 260 | response_unauthorized() 261 | } 262 | } 263 | } 264 | 265 | fn response_unauthorized() -> Result, Infallible> { 266 | Ok(Response::builder() 267 | .status(StatusCode::UNAUTHORIZED) 268 | .body("") 269 | .unwrap()) 270 | } 271 | 272 | async fn reply_logout( 273 | oidc_client: Arc, 274 | session_id: Option, 275 | sessions: Arc>, 276 | ) -> Result { 277 | let Some(id) = session_id else { 278 | return response_unauthorized(); 279 | }; 280 | 281 | let session_removed = sessions.write().await.map.remove(&id); 282 | 283 | if let Some(id_token) = session_removed.and_then(|(_, token, _)| token.bearer.id_token) { 284 | let authorization_cookie = ::cookie::Cookie::build((EXAMPLE_COOKIE, &id)) 285 | .path("/") 286 | .http_only(true) 287 | .max_age(Duration::seconds(-1)) 288 | .build() 289 | .to_string(); 290 | 291 | let return_redirect_url = host("/"); 292 | 293 | let redirect_url = oidc_client 294 | .config() 295 | .end_session_endpoint 296 | .clone() 297 | .map(|mut logout_provider_endpoint| { 298 | logout_provider_endpoint 299 | .query_pairs_mut() 300 | .append_pair("id_token_hint", &id_token) 301 | .append_pair("post_logout_redirect_uri", &return_redirect_url); 302 | logout_provider_endpoint.to_string() 303 | }) 304 | .unwrap_or_else(|| return_redirect_url); 305 | 306 | info!("logout redirect url: {redirect_url}"); 307 | 308 | Ok(Response::builder() 309 | .status(StatusCode::FOUND) 310 | .header(warp::http::header::LOCATION, redirect_url) 311 | .header(warp::http::header::SET_COOKIE, authorization_cookie) 312 | .body("") 313 | .unwrap()) 314 | } else { 315 | response_unauthorized() 316 | } 317 | } 318 | 319 | async fn reply_authorize(oidc_client: Arc) -> Result { 320 | let origin_url = env::var("ORIGIN").unwrap_or_else(|_| host("")); 321 | 322 | let auth_url = oidc_client.auth_url(&Options { 323 | scope: Some("openid email profile".into()), 324 | state: Some(origin_url), 325 | ..Default::default() 326 | }); 327 | 328 | info!("authorize: {}", auth_url); 329 | 330 | let url: String = auth_url.into(); 331 | 332 | Ok(warp::reply::with_header( 333 | StatusCode::FOUND, 334 | warp::http::header::LOCATION, 335 | url, 336 | )) 337 | } 338 | 339 | #[derive(Debug)] 340 | struct Unauthorized; 341 | 342 | impl reject::Reject for Unauthorized {} 343 | 344 | async fn extract_user( 345 | session_id: Option, 346 | sessions: Arc>, 347 | ) -> Result { 348 | if let Some(session_id) = session_id { 349 | if let Some((user, _, _)) = sessions.read().await.map.get(&session_id) { 350 | Ok(user.clone()) 351 | } else { 352 | Err(warp::reject::custom(Unauthorized)) 353 | } 354 | } else { 355 | Err(warp::reject::custom(Unauthorized)) 356 | } 357 | } 358 | 359 | fn with_user( 360 | sessions: Arc>, 361 | ) -> impl Filter + Clone { 362 | warp::cookie::optional(EXAMPLE_COOKIE) 363 | .and(warp::any().map(move || sessions.clone())) 364 | .and_then(extract_user) 365 | } 366 | 367 | async fn handle_rejections(err: Rejection) -> Result { 368 | let code = if err.is_not_found() { 369 | StatusCode::NOT_FOUND 370 | } else if let Some(Unauthorized) = err.find() { 371 | StatusCode::UNAUTHORIZED 372 | } else { 373 | StatusCode::INTERNAL_SERVER_ERROR 374 | }; 375 | 376 | Ok(warp::reply::with_status(warp::reply(), code)) 377 | } 378 | 379 | /// This host is the address, where user would be redirected after initial authorization. 380 | /// For DEV environment with WebPack this is usually something like `http://localhost:9000`. 381 | /// We are using `http://localhost:8080` in all-in-one example. 382 | pub fn host(path: &str) -> String { 383 | env::var("REDIRECT_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()) + path 384 | } 385 | ``` 386 | 387 | See full example: [openid-examples: warp](https://github.com/kilork/openid-examples/blob/v0.17/examples/warp.rs) 388 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | RELEASE_TYPE=${RELEASE_TYPE:-minor} 6 | cargo set-version --bump ${RELEASE_TYPE} 7 | VERSION=`cargo pkgid | cut -d"#" -f2` 8 | export OPENID_RUST_MAJOR_VERSION=`echo ${VERSION} | cut -d"." -f1,2` 9 | if [ "${RELEASE_TYPE}" != "patch" ]; then 10 | pushd ../openid-examples 11 | git checkout main 12 | git pull 13 | cargo upgrade -p openid@${OPENID_RUST_MAJOR_VERSION} 14 | cargo update 15 | cargo build 16 | git add . 17 | git commit -m"openid version ${OPENID_RUST_MAJOR_VERSION}" 18 | git branch v${OPENID_RUST_MAJOR_VERSION} 19 | git push 20 | git push origin v${OPENID_RUST_MAJOR_VERSION} 21 | popd 22 | fi 23 | handlebars-magic templates . 24 | git add . 25 | git commit -m"Release ${VERSION}" 26 | git tag v${VERSION} 27 | git push && git push --tag -------------------------------------------------------------------------------- /src/address.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// Address Claim struct. Can be only formatted, only the rest, or both. 4 | #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] 5 | pub struct Address { 6 | #[serde(default)] 7 | /// Full mailing address, formatted for display or use on a mailing label. 8 | /// This field MAY contain multiple lines, separated by newlines. Newlines 9 | /// can be represented either as a carriage return/line feed pair ("\r\n") 10 | /// or as a single line feed character ("\n"). 11 | pub formatted: Option, 12 | #[serde(default)] 13 | /// Full street address component, which MAY include house number, street 14 | /// name, Post Office Box, and multi-line extended street address 15 | /// information. This field MAY contain multiple lines, separated by 16 | /// newlines. Newlines can be represented either as a carriage return/line 17 | /// feed pair ("\r\n") or as a single line feed character ("\n"). 18 | pub street_address: Option, 19 | #[serde(default)] 20 | /// City or locality component. 21 | pub locality: Option, 22 | #[serde(default)] 23 | /// State, province, prefecture, or region component. 24 | pub region: Option, 25 | // Countries like the UK use alphanumeric postal codes, so you can't just use a number here 26 | #[serde(default)] 27 | /// Zip code or postal code component. 28 | pub postal_code: Option, 29 | #[serde(default)] 30 | /// Country name component. 31 | pub country: Option, 32 | } 33 | -------------------------------------------------------------------------------- /src/bearer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use chrono::{DateTime, Duration, Utc}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// The bearer token type. 7 | /// 8 | /// See: 9 | /// - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse) 10 | /// - [RFC 6750](http://tools.ietf.org/html/rfc6750). 11 | /// - [RFC 6749 5.1](https://datatracker.ietf.org/doc/html/rfc6749#section-5.1) 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 13 | pub struct Bearer { 14 | /// The access token issued by the authorization server. 15 | /// 16 | /// See: 17 | /// - [RFC 6749 1.4](https://datatracker.ietf.org/doc/html/rfc6749#section-1.4) 18 | pub access_token: String, 19 | /// The type of the token issued. 20 | /// 21 | /// Value is case insensitive. 22 | /// 23 | /// See: 24 | /// - [RFC 6749 7.1](https://datatracker.ietf.org/doc/html/rfc6749#section-7.1) 25 | pub token_type: String, 26 | /// OPTIONAL, if identical to the scope requested by the client; otherwise, 27 | /// REQUIRED 28 | pub scope: Option, 29 | /// OAuth 2.0 state value. REQUIRED if the state parameter is present in the 30 | /// Authorization Request. Clients MUST verify that the state value is equal 31 | /// to the value of state parameter in the Authorization Request. 32 | pub state: Option, 33 | /// The refresh token, which can be used to obtain new access tokens using 34 | /// the same authorization grant. 35 | /// 36 | /// See: 37 | /// - [RFC 6749 1.5](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5) 38 | pub refresh_token: Option, 39 | /// The lifetime in seconds of the access token. 40 | /// 41 | /// For example, the value "3600" denotes that the access token will 42 | /// expire in one hour from the time the response was generated. 43 | /// If omitted, the authorization server SHOULD provide the 44 | /// expiration time via other means or document the default value. 45 | pub expires_in: Option, 46 | /// ID Token value associated with the authenticated session. 47 | /// 48 | /// See: 49 | /// - [OpenID Connect Core 1.0: Token Response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse) 50 | pub id_token: Option, 51 | /// Additional properties which are not part of the standard OAuth 2.0 52 | /// response. 53 | #[serde(flatten)] 54 | pub extra: Option>, 55 | } 56 | 57 | /// Manages bearer tokens along with their expiration times. 58 | #[derive(Debug)] 59 | pub struct TemporalBearerGuard { 60 | bearer: Bearer, 61 | expires_at: Option>, 62 | } 63 | 64 | impl TemporalBearerGuard { 65 | /// Calculates whether the bearer has expired. 66 | /// 67 | /// The current time is compared to `self.expires_at` and a boolean 68 | /// value indicating whether the bearer has expired is returned. 69 | pub fn expired(&self) -> bool { 70 | self.expires_at 71 | .map(|expires_at| Utc::now() >= expires_at) 72 | .unwrap_or_default() 73 | } 74 | 75 | /// Calculates whether the bearer will expire at a given point in time. 76 | /// 77 | /// Returns `true` if the bearer token's expiration time matches the 78 | /// provided `expiration_point`. 79 | pub fn expires_at(&self) -> Option> { 80 | self.expires_at 81 | } 82 | } 83 | 84 | impl AsRef for TemporalBearerGuard { 85 | fn as_ref(&self) -> &Bearer { 86 | &self.bearer 87 | } 88 | } 89 | 90 | impl From for TemporalBearerGuard { 91 | fn from(bearer: Bearer) -> Self { 92 | let expires_at = bearer 93 | .expires_in 94 | .map(|expires_in| Utc::now() + Duration::seconds(expires_in as i64)); 95 | Self { bearer, expires_at } 96 | } 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | 103 | #[test] 104 | fn from_successful_response() { 105 | let json = r#" 106 | { 107 | "access_token":"2YotnFZFEjr1zCsicMWpAA", 108 | "token_type":"example", 109 | "expires_in":3600, 110 | "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", 111 | "example_parameter":"example_value" 112 | } 113 | "#; 114 | let bearer: Bearer = serde_json::from_str(json).unwrap(); 115 | assert_eq!("2YotnFZFEjr1zCsicMWpAA", bearer.access_token); 116 | assert_eq!("example", bearer.token_type); 117 | assert_eq!(Some(3600), bearer.expires_in); 118 | assert_eq!(Some("tGzv3JOkF0XG5Qx2TlKWIA".into()), bearer.refresh_token); 119 | assert_eq!( 120 | Some( 121 | [("example_parameter".into(), "example_value".into())] 122 | .into_iter() 123 | .collect() 124 | ), 125 | bearer.extra 126 | ); 127 | } 128 | 129 | #[test] 130 | fn from_response_refresh() { 131 | let json = r#" 132 | { 133 | "token_type":"Bearer", 134 | "access_token":"aaaaaaaa", 135 | "expires_in":3600, 136 | "refresh_token":"bbbbbbbb" 137 | } 138 | "#; 139 | let bearer: Bearer = serde_json::from_str(json).unwrap(); 140 | assert_eq!("aaaaaaaa", bearer.access_token); 141 | assert_eq!(None, bearer.scope); 142 | assert_eq!(Some("bbbbbbbb".into()), bearer.refresh_token); 143 | assert_eq!(Some(3600), bearer.expires_in); 144 | } 145 | 146 | #[test] 147 | fn from_response_static() { 148 | let json = r#" 149 | { 150 | "token_type":"Bearer", 151 | "access_token":"aaaaaaaa" 152 | } 153 | "#; 154 | let bearer: Bearer = serde_json::from_str(json).unwrap(); 155 | assert_eq!("aaaaaaaa", bearer.access_token); 156 | assert_eq!(None, bearer.scope); 157 | assert_eq!(None, bearer.refresh_token); 158 | assert_eq!(None, bearer.expires_in); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/claims.rs: -------------------------------------------------------------------------------- 1 | use base64::{ 2 | alphabet, 3 | engine::{GeneralPurpose, GeneralPurposeConfig}, 4 | Engine as _, 5 | }; 6 | use biscuit::SingleOrMultiple; 7 | use url::Url; 8 | 9 | use crate::Userinfo; 10 | 11 | const ANYPAD: GeneralPurposeConfig = GeneralPurposeConfig::new() 12 | .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent); 13 | const URL_SAFE_ANYPAD: GeneralPurpose = GeneralPurpose::new(&alphabet::URL_SAFE, ANYPAD); 14 | 15 | /// The primary extension that OpenID Connect makes to OAuth 2.0 to enable 16 | /// End-Users to be Authenticated is the ID Token data structure. The ID Token 17 | /// is a security token that contains Claims about the Authentication of an 18 | /// End-User by an Authorization Server when using a Client, and potentially 19 | /// other requested Claims. The ID Token is represented as a JSON Web Token 20 | /// (JWT) [JWT]. 21 | pub trait Claims { 22 | /// Issuer Identifier for the Issuer of the response. The iss value is a 23 | /// case sensitive URL using the https scheme that contains scheme, host, 24 | /// and optionally, port number and path components and no query or fragment 25 | /// components. 26 | fn iss(&self) -> &Url; 27 | /// Subject Identifier. A locally unique and never reassigned identifier 28 | /// within the Issuer for the End-User, which is intended to be consumed by 29 | /// the Client, e.g., 24400320 or AItOawmwtWwcT0k51BayewNvutrJUqsvl6qs7A4. 30 | /// It MUST NOT exceed 255 ASCII characters in length. The sub value is a 31 | /// case sensitive string. 32 | fn sub(&self) -> &str; 33 | /// Audience(s) that this ID Token is intended for. It MUST contain the 34 | /// OAuth 2.0 client_id of the Relying Party as an audience value. It MAY 35 | /// also contain identifiers for other audiences. In the general case, the 36 | /// aud value is an array of case sensitive strings. In the common special 37 | /// case when there is one audience, the aud value MAY be a single case 38 | /// sensitive string. 39 | fn aud(&self) -> &SingleOrMultiple; 40 | /// Expiration time on or after which the ID Token MUST NOT be accepted for 41 | /// processing. The processing of this parameter requires that the current 42 | /// date/time MUST be before the expiration date/time listed in the value. 43 | /// Implementers MAY provide for some small leeway, usually no more than a 44 | /// few minutes, to account for clock skew. Its value is a JSON number 45 | /// representing the number of seconds from 1970-01-01T0:0:0Z as measured in 46 | /// UTC until the date/time. See RFC 3339 [RFC3339] for details regarding 47 | /// date/times in general and UTC in particular. 48 | fn exp(&self) -> i64; 49 | /// Time at which the JWT was issued. Its value is a JSON number 50 | /// representing the number of seconds from 1970-01-01T0:0:0Z as measured in 51 | /// UTC until the date/time. 52 | fn iat(&self) -> i64; 53 | /// Time when the End-User authentication occurred. Its value is a JSON 54 | /// number representing the number of seconds from 1970-01-01T0:0:0Z as 55 | /// measured in UTC until the date/time. When a max_age request is made or 56 | /// when auth_time is requested as an Essential Claim, then this Claim is 57 | /// REQUIRED; otherwise, its inclusion is OPTIONAL. (The auth_time Claim 58 | /// semantically corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] auth_time 59 | /// response parameter.) 60 | fn auth_time(&self) -> Option; 61 | /// String value used to associate a Client session with an ID Token, and to 62 | /// mitigate replay attacks. The value is passed through unmodified from the 63 | /// Authentication Request to the ID Token. If present in the ID Token, 64 | /// Clients MUST verify that the nonce Claim Value is equal to the value of 65 | /// the nonce parameter sent in the Authentication Request. If present in 66 | /// the Authentication Request, Authorization Servers MUST include a nonce 67 | /// Claim in the ID Token with the Claim Value being the nonce value sent in 68 | /// the Authentication Request. Authorization Servers SHOULD perform no 69 | /// other processing on nonce values used. The nonce value is a case 70 | /// sensitive string. 71 | fn nonce(&self) -> Option<&String>; 72 | /// Access Token hash value. Its value is the base64url encoding of the 73 | /// left-most half of the hash of the octets of the ASCII representation of 74 | /// the access_token value, where the hash algorithm used is the hash 75 | /// algorithm used in the alg Header Parameter of the ID Token's JOSE 76 | /// Header. For instance, if the alg is RS256, hash the access_token value 77 | /// with SHA-256, then take the left-most 128 bits and base64url encode 78 | /// them. The at_hash value is a case sensitive string. 79 | fn at_hash(&self) -> Option<&String>; 80 | /// Code hash value. Its value is the base64url encoding of the left-most 81 | /// half of the hash of the octets of the ASCII representation of the code 82 | /// value, where the hash algorithm used is the hash algorithm used in the 83 | /// alg Header Parameter of the ID Token's JOSE Header. For instance, if the 84 | /// alg is HS512, hash the code value with SHA-512, then take the left-most 85 | /// 256 bits and base64url encode them. The c_hash value is a case sensitive 86 | /// string. If the ID Token is issued from the Authorization Endpoint 87 | /// with a code, which is the case for the response_type values code 88 | /// id_token and code id_token token, this is REQUIRED; otherwise, its 89 | /// inclusion is OPTIONAL. 90 | fn c_hash(&self) -> Option<&String>; 91 | /// Authentication Context Class Reference. String specifying an 92 | /// Authentication Context Class Reference value that identifies the 93 | /// Authentication Context Class that the authentication performed 94 | /// satisfied. The value "0" indicates the End-User authentication did not 95 | /// meet the requirements of ISO/IEC 29115 [ISO29115] level 1. 96 | /// Authentication using a long-lived browser cookie, for instance, is one 97 | /// example where the use of "level 0" is appropriate. Authentications with 98 | /// level 0 SHOULD NOT be used to authorize access to any resource of any 99 | /// monetary value. (This corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] 100 | /// nist_auth_level 0.) An absolute URI or an RFC 6711 [RFC6711] registered 101 | /// name SHOULD be used as the acr value; registered names MUST NOT be used 102 | /// with a different meaning than that which is registered. Parties using 103 | /// this claim will need to agree upon the meanings of the values used, 104 | /// which may be context-specific. The acr value is a case sensitive string. 105 | fn acr(&self) -> Option<&String>; 106 | /// Authentication Methods References. JSON array of strings that are 107 | /// identifiers for authentication methods used in the authentication. For 108 | /// instance, values might indicate that both password and OTP 109 | /// authentication methods were used. The definition of particular values to 110 | /// be used in the amr Claim is beyond the scope of this specification. 111 | /// Parties using this claim will need to agree upon the meanings of the 112 | /// values used, which may be context-specific. The amr value is an array of 113 | /// case sensitive strings. 114 | fn amr(&self) -> Option<&Vec>; 115 | /// Authorized party - the party to which the ID Token was issued. If 116 | /// present, it MUST contain the OAuth 2.0 Client ID of this party. This 117 | /// Claim is only needed when the ID Token has a single audience value and 118 | /// that audience is different than the authorized party. It MAY be included 119 | /// even when the authorized party is the same as the sole audience. The azp 120 | /// value is a case sensitive string containing a StringOrURI value. 121 | fn azp(&self) -> Option<&String>; 122 | 123 | /// The userinfo method returns a reference to the Userinfo struct, which 124 | /// contains information about the user who issued the ID Token. 125 | fn userinfo(&self) -> &Userinfo; 126 | 127 | /// Decodes at_hash. Returns None if it doesn't exist or something goes 128 | /// wrong. 129 | /// 130 | /// See [spec 3.1.3.6](https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken) 131 | /// 132 | /// The returned Vec is the first 128 bits of the access token hash using 133 | /// alg's hash alg 134 | fn at_hash_to_vec(&self) -> Option> { 135 | URL_SAFE_ANYPAD.decode(self.at_hash()?).ok() 136 | } 137 | /// Decodes c_hash. Returns None if it doesn't exist or something goes 138 | /// wrong. 139 | /// 140 | /// See [spec 3.3.2.11](https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken) 141 | /// 142 | /// The returned Vec is the first 128 bits of the code hash using alg's hash 143 | /// alg 144 | fn c_hash_to_vec(&self) -> Option> { 145 | URL_SAFE_ANYPAD.decode(self.c_hash()?).ok() 146 | } 147 | } 148 | 149 | #[cfg(test)] 150 | mod tests { 151 | use super::*; 152 | 153 | #[test] 154 | fn decode_at_hash() { 155 | let x = URL_SAFE_ANYPAD.decode("zglPCMCEP7ilF3LP_NExow"); 156 | let y = URL_SAFE_ANYPAD.decode("zglPCMCEP7ilF3LP_NExow=="); 157 | assert!(x.is_ok()); 158 | assert!(y.is_ok()); 159 | assert_eq!(x, y); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, marker::PhantomData}; 2 | 3 | use biscuit::{ 4 | jwa::{self, SignatureAlgorithm}, 5 | jwk::{AlgorithmParameters, JWKSet}, 6 | jws::{Compact, Secret}, 7 | CompactJson, Empty, 8 | }; 9 | use chrono::Duration; 10 | use reqwest::header::{ACCEPT, CONTENT_TYPE}; 11 | use serde_json::Value; 12 | use url::{form_urlencoded::Serializer, Url}; 13 | 14 | use crate::{ 15 | bearer::TemporalBearerGuard, 16 | discovered, 17 | error::{ 18 | ClientError, Decode, Error, Introspection as ErrorIntrospection, Jose, 19 | Userinfo as ErrorUserinfo, 20 | }, 21 | standard_claims_subject::StandardClaimsSubject, 22 | validation::{ 23 | validate_token_aud, validate_token_exp, validate_token_issuer, validate_token_nonce, 24 | }, 25 | Bearer, Claims, Config, Configurable, Discovered, IdToken, OAuth2Error, Options, Provider, 26 | StandardClaims, Token, TokenIntrospection, Userinfo, 27 | }; 28 | 29 | /// OpenID Connect 1.0 / OAuth 2.0 client. 30 | #[derive(Debug)] 31 | pub struct Client

{ 32 | /// OAuth provider. 33 | pub provider: P, 34 | 35 | /// Client ID. 36 | pub client_id: String, 37 | 38 | /// Client secret. 39 | pub client_secret: Option, 40 | 41 | /// Redirect URI. 42 | pub redirect_uri: Option, 43 | 44 | /// Reqwest client used to send HTTP requests. 45 | pub http_client: reqwest::Client, 46 | 47 | /// The set of JSON Web Keys for this client. They will be discovered via an 48 | /// OIDC discovery process. 49 | pub jwks: Option>, 50 | 51 | marker: PhantomData, 52 | } 53 | 54 | // Common pattern in the Client::decode function when dealing with mismatched 55 | // keys 56 | macro_rules! wrong_key { 57 | ($expected:expr, $actual:expr) => { 58 | Err(Jose::WrongKeyType { 59 | expected: format!("{:?}", $expected), 60 | actual: format!("{:?}", $actual), 61 | } 62 | .into()) 63 | }; 64 | } 65 | 66 | /// Implement clone if the provider can be cloned. 67 | impl Clone for Client { 68 | fn clone(&self) -> Self { 69 | let jwks = self.jwks.as_ref().map(|jwks| JWKSet { 70 | keys: jwks.keys.clone(), 71 | }); 72 | 73 | Self { 74 | provider: self.provider.clone(), 75 | client_id: self.client_id.clone(), 76 | client_secret: self.client_secret.clone(), 77 | redirect_uri: self.redirect_uri.as_ref().cloned(), 78 | http_client: self.http_client.clone(), 79 | jwks, 80 | marker: PhantomData, 81 | } 82 | } 83 | } 84 | 85 | impl Client { 86 | /// Constructs a client from an issuer url and client parameters via 87 | /// discovery 88 | pub async fn discover( 89 | id: String, 90 | secret: impl Into>, 91 | redirect: impl Into>, 92 | issuer: Url, 93 | ) -> Result { 94 | Self::discover_with_client(reqwest::Client::new(), id, secret, redirect, issuer).await 95 | } 96 | 97 | /// Constructs a client from an issuer url and client parameters via 98 | /// discovery 99 | pub async fn discover_with_client( 100 | http_client: reqwest::Client, 101 | id: String, 102 | secret: impl Into>, 103 | redirect: impl Into>, 104 | issuer: Url, 105 | ) -> Result { 106 | let config = discovered::discover(&http_client, issuer).await?; 107 | let jwks = discovered::jwks(&http_client, config.jwks_uri.clone()).await?; 108 | 109 | let provider = config.into(); 110 | 111 | Ok(Self::new( 112 | provider, 113 | id, 114 | secret, 115 | redirect, 116 | http_client, 117 | Some(jwks), 118 | )) 119 | } 120 | } 121 | 122 | impl Client { 123 | /// Passthrough to the redirect_url stored in inth_oauth2 as a str. 124 | pub fn redirect_url(&self) -> &str { 125 | self.redirect_uri 126 | .as_ref() 127 | .expect("We always require a redirect to construct client!") 128 | } 129 | 130 | /// A reference to the config document of the provider obtained via 131 | /// discovery 132 | pub fn config(&self) -> &Config { 133 | self.provider.config() 134 | } 135 | 136 | /// Constructs the auth_url to redirect a client to the provider. Options 137 | /// are... optional. Use them as needed. Keep the Options struct around 138 | /// for authentication, or at least the nonce and max_age parameter - we 139 | /// need to verify they stay the same and validate if you used them. 140 | pub fn auth_url(&self, options: &Options) -> Url { 141 | let scope = match options.scope.as_deref() { 142 | Some(scope) => { 143 | if !scope.contains("openid") { 144 | Cow::Owned(String::from("openid ") + scope) 145 | } else { 146 | Cow::Borrowed(scope) 147 | } 148 | } 149 | // Default scope value 150 | None => Cow::Borrowed("openid"), 151 | }; 152 | 153 | let mut url = self.auth_uri(&*scope, options.state.as_deref()); 154 | { 155 | let mut query = url.query_pairs_mut(); 156 | if let Some(ref nonce) = options.nonce { 157 | query.append_pair("nonce", nonce.as_str()); 158 | } 159 | if let Some(ref display) = options.display { 160 | query.append_pair("display", display.as_str()); 161 | } 162 | if let Some(ref prompt) = options.prompt { 163 | let s = prompt 164 | .iter() 165 | .map(|s| s.as_str()) 166 | .collect::>() 167 | .join(" "); 168 | query.append_pair("prompt", s.as_str()); 169 | } 170 | if let Some(max_age) = options.max_age { 171 | query.append_pair("max_age", max_age.num_seconds().to_string().as_str()); 172 | } 173 | if let Some(ref ui_locales) = options.ui_locales { 174 | query.append_pair("ui_locales", ui_locales.as_str()); 175 | } 176 | if let Some(ref claims_locales) = options.claims_locales { 177 | query.append_pair("claims_locales", claims_locales.as_str()); 178 | } 179 | if let Some(ref id_token_hint) = options.id_token_hint { 180 | query.append_pair("id_token_hint", id_token_hint.as_str()); 181 | } 182 | if let Some(ref login_hint) = options.login_hint { 183 | query.append_pair("login_hint", login_hint.as_str()); 184 | } 185 | if let Some(ref acr_values) = options.acr_values { 186 | query.append_pair("acr_values", acr_values.as_str()); 187 | } 188 | } 189 | url 190 | } 191 | 192 | /// Given an auth_code and auth options, request the token, decode, and 193 | /// validate it. 194 | pub async fn authenticate( 195 | &self, 196 | auth_code: &str, 197 | nonce: impl Into>, 198 | max_age: impl Into>, 199 | ) -> Result, Error> { 200 | let bearer = self.request_token(auth_code).await.map_err(Error::from)?; 201 | let mut token: Token = bearer.into(); 202 | if let Some(id_token) = token.id_token.as_mut() { 203 | self.decode_token(id_token)?; 204 | self.validate_token(id_token, nonce, max_age)?; 205 | } 206 | Ok(token) 207 | } 208 | 209 | /// Mutates a Compact::encoded Token to Compact::decoded. 210 | /// 211 | /// # Errors 212 | /// 213 | /// - [Decode::MissingKid] if the keyset has multiple keys but the key id on 214 | /// the token is missing 215 | /// - [Decode::MissingKey] if the given key id is not in the key set 216 | /// - [Decode::EmptySet] if the keyset is empty 217 | /// - [Jose::WrongKeyType] if the alg of the key and the alg in the token 218 | /// header mismatch 219 | /// - [Jose::WrongKeyType] if the specified key alg isn't a signature 220 | /// algorithm 221 | /// - [Decode::UnsupportedEllipticCurve] if the alg is cryptographic curve 222 | /// - [Decode::UnsupportedOctetKeyPair] if the alg is octet key pair 223 | /// - [Error::Jose] error if decoding fails 224 | pub fn decode_token(&self, token: &mut IdToken) -> Result<(), Error> { 225 | // This is an early return if the token is already decoded 226 | if let Compact::Decoded { .. } = *token { 227 | return Ok(()); 228 | } 229 | 230 | if self.jwks.is_none() { 231 | return Ok(()); 232 | } 233 | 234 | let jwks = self.jwks.as_ref().unwrap(); 235 | 236 | let header = token.unverified_header()?; 237 | // If there is more than one key, the token MUST have a key id 238 | let key = if jwks.keys.len() > 1 { 239 | let token_kid = header.registered.key_id.ok_or(Decode::MissingKid)?; 240 | jwks.find(&token_kid).ok_or(Decode::MissingKey(token_kid))? 241 | } else { 242 | // TODO We would want to verify the keyset is >1 in the constructor 243 | // rather than every decode call, but we can't return an error in new(). 244 | jwks.keys.first().as_ref().ok_or(Decode::EmptySet)? 245 | }; 246 | 247 | if let Some(alg) = key.common.algorithm.as_ref() { 248 | if let jwa::Algorithm::Signature(sig) = *alg { 249 | if header.registered.algorithm != sig { 250 | return wrong_key!(sig, header.registered.algorithm); 251 | } 252 | } else { 253 | return wrong_key!(SignatureAlgorithm::default(), alg); 254 | } 255 | } 256 | 257 | let alg = header.registered.algorithm; 258 | let secret = match key.algorithm { 259 | // HMAC 260 | AlgorithmParameters::OctetKey(ref parameters) => match alg { 261 | SignatureAlgorithm::HS256 262 | | SignatureAlgorithm::HS384 263 | | SignatureAlgorithm::HS512 => { 264 | Ok::<_, Error>(Secret::Bytes(parameters.value.clone())) 265 | } 266 | _ => wrong_key!("HS256 | HS384 | HS512", alg), 267 | }, 268 | AlgorithmParameters::RSA(ref params) => match alg { 269 | SignatureAlgorithm::RS256 270 | | SignatureAlgorithm::RS384 271 | | SignatureAlgorithm::RS512 => Ok(params.jws_public_key_secret()), 272 | _ => wrong_key!("RS256 | RS384 | RS512", alg), 273 | }, 274 | AlgorithmParameters::EllipticCurve(_) => Err(Decode::UnsupportedEllipticCurve.into()), 275 | AlgorithmParameters::OctetKeyPair(_) => Err(Decode::UnsupportedOctetKeyPair.into()), 276 | }?; 277 | 278 | *token = token.decode(&secret, alg)?; 279 | 280 | Ok(()) 281 | } 282 | 283 | /// Validate a decoded token. If you don't get an error, its valid! Nonce 284 | /// and max_age come from your auth_uri options. 285 | /// 286 | /// # Errors 287 | /// 288 | /// - [Error::Jose] Error if the Token isn't decoded 289 | /// - [Error::Validation]::[Mismatch](crate::error::Validation::Mismatch)::[Issuer](crate::error::Mismatch::Issuer) if the provider issuer and token issuer mismatch 290 | /// - [Error::Validation]::[Mismatch](crate::error::Validation::Mismatch)::[Nonce](crate::error::Mismatch::Nonce) if a given nonce and the token nonce mismatch 291 | /// - [Error::Validation]::[Missing](crate::error::Validation::Missing)::[Nonce](crate::error::Missing::Nonce) if args has a nonce and the token does not 292 | /// - [Error::Validation]::[Missing](crate::error::Validation::Missing)::[Audience](crate::error::Missing::Audience) if the token aud doesn't contain the client id 293 | /// - [Error::Validation]::[Missing](crate::error::Validation::Missing)::[AuthorizedParty](crate::error::Missing::AuthorizedParty) if there are multiple audiences and azp is missing 294 | /// - [Error::Validation]::[Mismatch](crate::error::Validation::Mismatch)::[AuthorizedParty](crate::error::Mismatch::AuthorizedParty) if the azp is not the client_id 295 | /// - [Error::Validation]::[Expired](crate::error::Validation::Expired)::[Expires](crate::error::Expiry::Expires) if the current time is past the expiration time 296 | /// - [Error::Validation]::[Expired](crate::error::Validation::Expired)::[MaxAge](crate::error::Expiry::MaxAge) is the token is older than the provided max_age 297 | /// - [Error::Validation]::[Missing](crate::error::Validation::Missing)::[AuthTime](crate::error::Missing::AuthTime) if a max_age was given and the token has no auth time 298 | pub fn validate_token<'nonce, 'max_age>( 299 | &self, 300 | token: &IdToken, 301 | nonce: impl Into>, 302 | max_age: impl Into>, 303 | ) -> Result<(), Error> { 304 | let claims = token.payload()?; 305 | let config = self.config(); 306 | 307 | validate_token_issuer(claims, config)?; 308 | 309 | validate_token_nonce(claims, nonce)?; 310 | 311 | validate_token_aud(claims, &self.client_id)?; 312 | 313 | validate_token_exp(claims, max_age)?; 314 | 315 | Ok(()) 316 | } 317 | 318 | /// Get a userinfo json document for a given token at the provider's 319 | /// userinfo endpoint. Returns [Standard Claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) as [Userinfo] struct. 320 | /// 321 | /// # Errors 322 | /// 323 | /// - [ErrorUserinfo::NoUrl] if this provider doesn't have a userinfo 324 | /// endpoint 325 | /// - [Error::Insecure] if the userinfo url is not https 326 | /// - [Error::Jose] if the token is not decoded 327 | /// - [Error::Http] if something goes wrong getting the document 328 | /// - [Error::Json] if the response is not a valid Userinfo document 329 | /// - [ErrorUserinfo::MissingSubject] if subject (sub) is missing 330 | /// - [ErrorUserinfo::MismatchSubject] if the returned userinfo document and 331 | /// tokens subject mismatch 332 | pub async fn request_userinfo(&self, token: &Token) -> Result { 333 | self.request_userinfo_custom(token).await 334 | } 335 | 336 | /// Get a userinfo json document for a given token at the provider's 337 | /// userinfo endpoint. Returns [UserInfo Response](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse) 338 | /// including non-standard claims. The sub (subject) Claim MUST always be 339 | /// returned in the UserInfo Response. 340 | /// 341 | /// # Errors 342 | /// 343 | /// - [ErrorUserinfo::NoUrl] if this provider doesn't have a userinfo 344 | /// endpoint 345 | /// - [Error::Insecure] if the userinfo url is not https 346 | /// - [Decode::MissingKid] if the keyset has multiple keys but the key id on 347 | /// the token is missing 348 | /// - [Decode::MissingKey] if the given key id is not in the key set 349 | /// - [Decode::EmptySet] if the keyset is empty 350 | /// - [Jose::WrongKeyType] if the alg of the key and the alg in the token 351 | /// header mismatch 352 | /// - [Jose::WrongKeyType] if the specified key alg isn't a signature 353 | /// algorithm 354 | /// - [Error::Jose] if the token is not decoded 355 | /// - [Error::Http] if something goes wrong getting the document 356 | /// - [Error::Json] if the response is not a valid Userinfo document 357 | /// - [ErrorUserinfo::MissingSubject] if subject (sub) is missing 358 | /// - [ErrorUserinfo::MismatchSubject] if the returned userinfo document and 359 | /// tokens subject mismatch 360 | /// - [ErrorUserinfo::MissingContentType] if content-type header is missing 361 | /// - [ErrorUserinfo::ParseContentType] if content-type header is not 362 | /// parsable 363 | /// - [ErrorUserinfo::WrongContentType] if content-type header is not 364 | /// accepted 365 | /// 366 | /// # Examples 367 | /// 368 | /// ```no_run 369 | /// # use openid::{Bearer, DiscoveredClient, error::StandardClaimsSubjectMissing, StandardClaims, StandardClaimsSubject, Token}; 370 | /// # use serde::{Deserialize, Serialize}; 371 | /// # async fn _main() -> Result<(), Box> { 372 | /// # let bearer: Bearer = serde_json::from_str("{}").unwrap(); 373 | /// # let token = Token::::from(bearer); 374 | /// # let client = DiscoveredClient::discover("client_id".to_string(), "client_secret".to_string(), "http://redirect".to_string(), url::Url::parse("http://issuer".into()).unwrap(),).await?; 375 | /// #[derive(Debug, Deserialize, Serialize)] 376 | /// struct CustomUserinfo(std::collections::HashMap); 377 | /// 378 | /// impl StandardClaimsSubject for CustomUserinfo { 379 | /// fn sub(&self) -> Result<&str, StandardClaimsSubjectMissing> { 380 | /// self.0 381 | /// .get("sub") 382 | /// .and_then(|x| x.as_str()) 383 | /// .ok_or(StandardClaimsSubjectMissing) 384 | /// } 385 | /// } 386 | /// 387 | /// impl openid::CompactJson for CustomUserinfo {} 388 | /// 389 | /// let custom_userinfo: CustomUserinfo = client.request_userinfo_custom(&token).await?; 390 | /// # Ok(()) } 391 | /// ``` 392 | pub async fn request_userinfo_custom(&self, token: &Token) -> Result 393 | where 394 | U: StandardClaimsSubject, 395 | { 396 | match self.config().userinfo_endpoint { 397 | Some(ref url) => { 398 | let auth_code = token.bearer.access_token.to_string(); 399 | 400 | let response = self 401 | .http_client 402 | .get(url.clone()) 403 | .bearer_auth(auth_code) 404 | .send() 405 | .await? 406 | .error_for_status()?; 407 | 408 | let content_type = response 409 | .headers() 410 | .get(&CONTENT_TYPE) 411 | .and_then(|content_type| content_type.to_str().ok()) 412 | .ok_or(ErrorUserinfo::MissingContentType)?; 413 | 414 | let mime_type = match content_type { 415 | "application/json" => mime::APPLICATION_JSON, 416 | content_type => content_type.parse::().map_err(|_| { 417 | ErrorUserinfo::ParseContentType { 418 | content_type: content_type.to_string(), 419 | } 420 | })?, 421 | }; 422 | 423 | let info: U = match (mime_type.type_(), mime_type.subtype().as_str()) { 424 | (mime::APPLICATION, "json") => { 425 | let info_value: Value = response.json().await?; 426 | if info_value.get("error").is_some() { 427 | let oauth2_error: OAuth2Error = serde_json::from_value(info_value)?; 428 | return Err(Error::ClientError(oauth2_error.into())); 429 | } 430 | serde_json::from_value(info_value)? 431 | } 432 | (mime::APPLICATION, "jwt") => { 433 | let jwt = response.text().await?; 434 | let mut jwt_encoded: Compact = Compact::new_encoded(&jwt); 435 | self.decode_token(&mut jwt_encoded)?; 436 | let (_, info) = jwt_encoded.unwrap_decoded(); 437 | info 438 | } 439 | _ => { 440 | return Err(ErrorUserinfo::WrongContentType { 441 | content_type: content_type.to_string(), 442 | body: response.bytes().await?.to_vec(), 443 | } 444 | .into()) 445 | } 446 | }; 447 | 448 | let claims = token.id_token.as_ref().map(|x| x.payload()).transpose()?; 449 | if let Some(claims) = claims { 450 | let info_sub = info.sub().map_err(ErrorUserinfo::from)?; 451 | if claims.sub() != info_sub { 452 | let expected = info_sub.to_string(); 453 | let actual = claims.sub().to_string(); 454 | return Err(ErrorUserinfo::MismatchSubject { expected, actual }.into()); 455 | } 456 | } 457 | 458 | Ok(info) 459 | } 460 | None => Err(ErrorUserinfo::NoUrl.into()), 461 | } 462 | } 463 | 464 | /// Get a token introspection json document for a given token at the 465 | /// provider's token introspection endpoint. Returns [Token Introspection Response](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2) 466 | /// as [TokenIntrospection] struct. 467 | /// 468 | /// # Errors 469 | /// 470 | /// - [Error::Http] if something goes wrong getting the document 471 | /// - [Error::Insecure] if the token introspection url is not https 472 | /// - [Error::Json] if the response is not a valid TokenIntrospection 473 | /// document 474 | /// - [ErrorIntrospection::MissingContentType] if content-type header is 475 | /// missing 476 | /// - [ErrorIntrospection::NoUrl] if this provider doesn't have a token 477 | /// introspection endpoint 478 | /// - [ErrorIntrospection::ParseContentType] if content-type header is not 479 | /// parsable 480 | /// - [ErrorIntrospection::WrongContentType] if content-type header is not 481 | /// accepted 482 | pub async fn request_token_introspection( 483 | &self, 484 | token: &Token, 485 | ) -> Result, Error> 486 | where 487 | I: CompactJson, 488 | { 489 | match self.config().introspection_endpoint { 490 | Some(ref url) => { 491 | let access_token = token.bearer.access_token.to_string(); 492 | 493 | let body = { 494 | let mut body = Serializer::new(String::new()); 495 | body.append_pair("token", &access_token); 496 | body.finish() 497 | }; 498 | 499 | let response = self 500 | .http_client 501 | .post(url.clone()) 502 | .basic_auth(&self.client_id, self.client_secret.as_ref()) 503 | .header(ACCEPT, "application/json") 504 | .header(CONTENT_TYPE, "application/x-www-form-urlencoded") 505 | .body(body) 506 | .send() 507 | .await? 508 | .error_for_status()?; 509 | 510 | let content_type = response 511 | .headers() 512 | .get(&CONTENT_TYPE) 513 | .and_then(|content_type| content_type.to_str().ok()) 514 | .ok_or(ErrorIntrospection::MissingContentType)?; 515 | 516 | let mime_type = match content_type { 517 | "application/json" => mime::APPLICATION_JSON, 518 | content_type => content_type.parse::().map_err(|_| { 519 | ErrorIntrospection::ParseContentType { 520 | content_type: content_type.to_string(), 521 | } 522 | })?, 523 | }; 524 | 525 | let info: TokenIntrospection = 526 | match (mime_type.type_(), mime_type.subtype().as_str()) { 527 | (mime::APPLICATION, "json") => { 528 | let info_value: Value = response.json().await?; 529 | if info_value.get("error").is_some() { 530 | let oauth2_error: OAuth2Error = serde_json::from_value(info_value)?; 531 | return Err(Error::ClientError(oauth2_error.into())); 532 | } 533 | serde_json::from_value(info_value)? 534 | } 535 | _ => { 536 | return Err(ErrorIntrospection::WrongContentType { 537 | content_type: content_type.to_string(), 538 | body: response.bytes().await?.to_vec(), 539 | } 540 | .into()) 541 | } 542 | }; 543 | 544 | Ok(info) 545 | } 546 | None => Err(ErrorIntrospection::NoUrl.into()), 547 | } 548 | } 549 | } 550 | 551 | impl Client 552 | where 553 | P: Provider, 554 | C: CompactJson + Claims, 555 | { 556 | /// Creates a client. 557 | /// 558 | /// # Examples 559 | /// 560 | /// ``` 561 | /// use openid::{Client, StandardClaims}; 562 | /// use openid::provider::google::Installed; 563 | /// 564 | /// let client: Client<_, StandardClaims> = Client::new( 565 | /// Installed, 566 | /// String::from("CLIENT_ID"), 567 | /// String::from("CLIENT_SECRET"), 568 | /// Some(String::from("urn:ietf:wg:oauth:2.0:oob")), 569 | /// reqwest::Client::new(), None, 570 | /// ); 571 | /// ``` 572 | pub fn new( 573 | provider: P, 574 | client_id: String, 575 | client_secret: impl Into>, 576 | redirect_uri: impl Into>, 577 | http_client: reqwest::Client, 578 | jwks: Option>, 579 | ) -> Self { 580 | Client { 581 | provider, 582 | client_id, 583 | client_secret: client_secret.into(), 584 | redirect_uri: redirect_uri.into(), 585 | http_client, 586 | jwks, 587 | marker: PhantomData, 588 | } 589 | } 590 | 591 | /// Returns an authorization endpoint URI to direct the user to. 592 | /// 593 | /// This function is used by [Client::auth_url]. 594 | /// In most situations it is non needed to use it directly. 595 | /// 596 | /// See [RFC 6749, section 3.1](http://tools.ietf.org/html/rfc6749#section-3.1). 597 | /// 598 | /// # Examples 599 | /// 600 | /// ``` 601 | /// use openid::Client; 602 | /// use openid::provider::google::Installed; 603 | /// 604 | /// let client: Client<_> = Client::new( 605 | /// Installed, 606 | /// String::from("CLIENT_ID"), 607 | /// String::from("CLIENT_SECRET"), 608 | /// Some(String::from("urn:ietf:wg:oauth:2.0:oob")), 609 | /// reqwest::Client::new(), None, 610 | /// ); 611 | /// 612 | /// let auth_uri = client.auth_uri( 613 | /// Some("https://www.googleapis.com/auth/userinfo.email"), 614 | /// None, 615 | /// ); 616 | /// ``` 617 | pub fn auth_uri<'scope, 'state>( 618 | &self, 619 | scope: impl Into>, 620 | state: impl Into>, 621 | ) -> Url { 622 | let mut uri = self.provider.auth_uri().clone(); 623 | 624 | { 625 | let mut query = uri.query_pairs_mut(); 626 | 627 | query.append_pair("response_type", "code"); 628 | query.append_pair("client_id", &self.client_id); 629 | 630 | if let Some(ref redirect_uri) = self.redirect_uri { 631 | query.append_pair("redirect_uri", redirect_uri); 632 | } 633 | 634 | self.append_scope(&mut query, scope); 635 | 636 | if let Some(state) = state.into() { 637 | query.append_pair("state", state); 638 | } 639 | } 640 | 641 | uri 642 | } 643 | 644 | /// Requests an access token using an authorization code. 645 | /// 646 | /// See [RFC 6749, section 4.1.3](http://tools.ietf.org/html/rfc6749#section-4.1.3). 647 | pub async fn request_token(&self, code: &str) -> Result { 648 | // Ensure the non thread-safe `Serializer` is not kept across 649 | // an `await` boundary by localizing it to this inner scope. 650 | let body = { 651 | let mut body = Serializer::new(String::new()); 652 | body.append_pair("grant_type", "authorization_code"); 653 | body.append_pair("code", code); 654 | 655 | if let Some(ref redirect_uri) = self.redirect_uri { 656 | body.append_pair("redirect_uri", redirect_uri); 657 | } 658 | 659 | self.append_credentials(&mut body); 660 | 661 | body.finish() 662 | }; 663 | 664 | let json = self.post_token(body).await?; 665 | let token: Bearer = serde_json::from_value(json)?; 666 | Ok(token) 667 | } 668 | 669 | /// Requests an access token using the Resource Owner Password Credentials 670 | /// Grant flow 671 | /// 672 | /// See [RFC 6749, section 4.3](https://tools.ietf.org/html/rfc6749#section-4.3) 673 | pub async fn request_token_using_password_credentials( 674 | &self, 675 | username: &str, 676 | password: &str, 677 | scope: impl Into>, 678 | ) -> Result { 679 | // Ensure the non thread-safe `Serializer` is not kept across 680 | // an `await` boundary by localizing it to this inner scope. 681 | let body = { 682 | let mut body = Serializer::new(String::new()); 683 | body.append_pair("grant_type", "password"); 684 | body.append_pair("username", username); 685 | body.append_pair("password", password); 686 | 687 | self.append_scope(&mut body, scope); 688 | 689 | self.append_credentials(&mut body); 690 | 691 | body.finish() 692 | }; 693 | 694 | let json = self.post_token(body).await?; 695 | let token: Bearer = serde_json::from_value(json)?; 696 | Ok(token) 697 | } 698 | 699 | /// Requests an access token using the Client Credentials Grant flow 700 | /// 701 | /// See [RFC 6749, section 4.4](https://tools.ietf.org/html/rfc6749#section-4.4) 702 | pub async fn request_token_using_client_credentials( 703 | &self, 704 | scope: impl Into>, 705 | ) -> Result { 706 | // Ensure the non thread-safe `Serializer` is not kept across 707 | // an `await` boundary by localizing it to this inner scope. 708 | let body = { 709 | let mut body = Serializer::new(String::new()); 710 | body.append_pair("grant_type", "client_credentials"); 711 | 712 | self.append_scope(&mut body, scope); 713 | 714 | self.append_credentials(&mut body); 715 | 716 | body.finish() 717 | }; 718 | 719 | let json = self.post_token(body).await?; 720 | let token: Bearer = serde_json::from_value(json)?; 721 | Ok(token) 722 | } 723 | 724 | /// Refreshes an access token. 725 | /// 726 | /// See [RFC 6749, section 6](http://tools.ietf.org/html/rfc6749#section-6). 727 | pub async fn refresh_token( 728 | &self, 729 | token: impl AsRef, 730 | scope: impl Into>, 731 | ) -> Result { 732 | // Ensure the non thread-safe `Serializer` is not kept across 733 | // an `await` boundary by localizing it to this inner scope. 734 | let body = { 735 | let mut body = Serializer::new(String::new()); 736 | body.append_pair("grant_type", "refresh_token"); 737 | body.append_pair( 738 | "refresh_token", 739 | token 740 | .as_ref() 741 | .refresh_token 742 | .as_deref() 743 | .expect("No refresh_token field"), 744 | ); 745 | 746 | self.append_scope(&mut body, scope); 747 | 748 | self.append_credentials(&mut body); 749 | 750 | body.finish() 751 | }; 752 | 753 | let json = self.post_token(body).await?; 754 | let mut new_token: Bearer = serde_json::from_value(json)?; 755 | if new_token.refresh_token.is_none() { 756 | new_token.refresh_token = token.as_ref().refresh_token.clone(); 757 | } 758 | Ok(new_token) 759 | } 760 | 761 | /// Ensures an access token is valid by refreshing it if necessary. 762 | pub async fn ensure_token( 763 | &self, 764 | token_guard: TemporalBearerGuard, 765 | ) -> Result { 766 | if token_guard.expired() { 767 | self.refresh_token(token_guard, None).await.map(From::from) 768 | } else { 769 | Ok(token_guard) 770 | } 771 | } 772 | 773 | async fn post_token(&self, body: String) -> Result { 774 | let json = self 775 | .http_client 776 | .post(self.provider.token_uri().clone()) 777 | .basic_auth(&self.client_id, self.client_secret.as_ref()) 778 | .header(ACCEPT, "application/json") 779 | .header(CONTENT_TYPE, "application/x-www-form-urlencoded") 780 | .body(body) 781 | .send() 782 | .await? 783 | .json::() 784 | .await?; 785 | 786 | let error: Result = serde_json::from_value(json.clone()); 787 | 788 | if let Ok(error) = error { 789 | Err(ClientError::from(error)) 790 | } else { 791 | Ok(json) 792 | } 793 | } 794 | 795 | fn append_credentials(&self, body: &mut Serializer) { 796 | if self.provider.credentials_in_body() { 797 | body.append_pair("client_id", &self.client_id); 798 | if let Some(client_secret) = self.client_secret.as_deref() { 799 | body.append_pair("client_secret", client_secret); 800 | } 801 | } 802 | } 803 | 804 | fn append_scope<'scope, T>( 805 | &self, 806 | body: &mut Serializer, 807 | scope: impl Into>, 808 | ) where 809 | T: url::form_urlencoded::Target, 810 | { 811 | if let Some(scope) = scope.into() { 812 | body.append_pair("scope", scope); 813 | } 814 | } 815 | } 816 | 817 | #[cfg(test)] 818 | mod tests { 819 | use url::Url; 820 | 821 | use super::Client; 822 | use crate::provider::Provider; 823 | 824 | struct Test { 825 | auth_uri: Url, 826 | token_uri: Url, 827 | } 828 | impl Provider for Test { 829 | fn auth_uri(&self) -> &Url { 830 | &self.auth_uri 831 | } 832 | fn token_uri(&self) -> &Url { 833 | &self.token_uri 834 | } 835 | } 836 | impl Test { 837 | fn new() -> Self { 838 | Test { 839 | auth_uri: Url::parse("http://example.com/oauth2/auth").unwrap(), 840 | token_uri: Url::parse("http://example.com/oauth2/token").unwrap(), 841 | } 842 | } 843 | } 844 | 845 | #[test] 846 | fn auth_uri() { 847 | let http_client = reqwest::Client::new(); 848 | let client: Client<_> = Client::new( 849 | Test::new(), 850 | String::from("foo"), 851 | String::from("bar"), 852 | None, 853 | http_client, 854 | None, 855 | ); 856 | assert_eq!( 857 | "http://example.com/oauth2/auth?response_type=code&client_id=foo", 858 | client.auth_uri(None, None).as_str() 859 | ); 860 | } 861 | 862 | #[test] 863 | fn auth_uri_with_redirect_uri() { 864 | let http_client = reqwest::Client::new(); 865 | let client: Client<_> = Client::new( 866 | Test::new(), 867 | String::from("foo"), 868 | String::from("bar"), 869 | Some(String::from("http://example.com/oauth2/callback")), 870 | http_client, 871 | None, 872 | ); 873 | assert_eq!( 874 | "http://example.com/oauth2/auth?response_type=code&client_id=foo&redirect_uri=http%3A%2F%2Fexample.com%2Foauth2%2Fcallback", 875 | client.auth_uri(None, None).as_str() 876 | ); 877 | } 878 | 879 | #[test] 880 | fn auth_uri_with_scope() { 881 | let http_client = reqwest::Client::new(); 882 | let client: Client<_> = Client::new( 883 | Test::new(), 884 | String::from("foo"), 885 | String::from("bar"), 886 | None, 887 | http_client, 888 | None, 889 | ); 890 | assert_eq!( 891 | "http://example.com/oauth2/auth?response_type=code&client_id=foo&scope=baz", 892 | client.auth_uri(Some("baz"), None).as_str() 893 | ); 894 | } 895 | 896 | #[test] 897 | fn auth_uri_with_state() { 898 | let http_client = reqwest::Client::new(); 899 | let client: Client<_> = Client::new( 900 | Test::new(), 901 | String::from("foo"), 902 | String::from("bar"), 903 | None, 904 | http_client, 905 | None, 906 | ); 907 | assert_eq!( 908 | "http://example.com/oauth2/auth?response_type=code&client_id=foo&state=baz", 909 | client.auth_uri(None, Some("baz")).as_str() 910 | ); 911 | } 912 | } 913 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use url::Url; 3 | 4 | /// Config represents an OpenID / OAuth 2.0 provider metadata. 5 | /// 6 | /// OpenID / OAuth 2.0 Providers have metadata describing their configuration. 7 | /// These OpenID / OAuth 2.0 Provider Metadata values are used by OpenID Connect 8 | /// / OAuth 2.0 Authorization. 9 | /// 10 | /// See: 11 | /// 12 | /// - [OpenID Connect Discovery 1.0: OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata) 13 | /// - [https://datatracker.ietf.org/doc/html/rfc8414](https://datatracker.ietf.org/doc/html/rfc8414) 14 | #[derive(Clone, Debug, Deserialize, Serialize)] 15 | pub struct Config { 16 | /// The authorization server's issuer identifier. 17 | /// 18 | /// URL using the `https` scheme with no query or fragment components that 19 | /// the OP asserts as its Issuer Identifier. If Issuer discovery is 20 | /// supported (see [Section 2](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery)), this value MUST be identical to the issuer value 21 | /// returned by WebFinger. This also MUST be identical to the `iss` Claim 22 | /// value in ID Tokens issued from this Issuer. 23 | pub issuer: Url, 24 | /// URL of the OP's OAuth 2.0 Authorization Endpoint [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core). 25 | /// 26 | /// This URL MUST use the `https` scheme and MAY contain port, path, and 27 | /// query parameter components. 28 | pub authorization_endpoint: Url, 29 | // Only optional in the implicit flow 30 | // TODO For now, we only support code flows. 31 | /// URL of the OP's OAuth 2.0 Token Endpoint [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core). 32 | /// 33 | /// This is the URL where clients will send a request to exchange an 34 | /// authorization code for an access token. This URL MUST use the `https` 35 | /// scheme and MAY contain port, path, and query parameter components. 36 | pub token_endpoint: Url, 37 | /// The user info endpoint. 38 | /// 39 | /// URL of the OP's UserInfo Endpoint [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core). This URL MUST use the `https` scheme and MAY contain port, path, and query parameter components. 40 | #[serde(default)] 41 | pub userinfo_endpoint: Option, 42 | /// The JWKS URI. 43 | /// 44 | /// URL of the OP's JWK Set [JWK](https://openid.net/specs/openid-connect-discovery-1_0.html#JWK) document, 45 | /// which MUST use the `https` scheme. This contains the signing key(s) the 46 | /// RP uses to validate signatures from the OP. The JWK Set MAY also 47 | /// contain the Server's encryption key(s), which are used by RPs to 48 | /// encrypt requests to the Server. When both signing and encryption 49 | /// keys are made available, a `use` (public key use) parameter value is 50 | /// REQUIRED for all keys in the referenced JWK Set to indicate each 51 | /// key's intended usage. Although some algorithms allow the same key to 52 | /// be used for both signatures and encryption, doing so is NOT 53 | /// RECOMMENDED, as it is less secure. The JWK `x5c` parameter MAY be 54 | /// used to provide X.509 representations of keys provided. When used, 55 | /// the bare key values MUST still be present and MUST match those in 56 | /// the certificate. The JWK Set MUST NOT contain private or symmetric 57 | /// key values. 58 | pub jwks_uri: Url, 59 | /// The dynamic client registration endpoint. 60 | /// 61 | /// URL of the OP's Dynamic Client Registration Endpoint [OpenID.Registration](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Registration), 62 | /// which MUST use the `https` scheme. 63 | #[serde(default)] 64 | pub registration_endpoint: Option, 65 | /// The scopes supported. 66 | /// 67 | /// JSON array containing a list of the [OAuth 2.0: RFC6749](https://openid.net/specs/openid-connect-discovery-1_0.html#RFC6749) scope values 68 | /// that this server supports. The server MUST support the `openid` scope 69 | /// value. Servers MAY choose not to advertise some supported scope values 70 | /// even when this parameter is used, although those defined in 71 | /// [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core) SHOULD be listed, if supported. 72 | #[serde(default)] 73 | pub scopes_supported: Option>, 74 | // There are only three valid response types, plus combinations of them, and none 75 | // If we want to make these user friendly we want a struct to represent all 7 types 76 | /// JSON array containing a list of the OAuth 2.0 `response_type` values 77 | /// that this OP supports. Dynamic OpenID Providers MUST support the 78 | /// `code,id_token`, and the `id_token` token Response Type values. 79 | pub response_types_supported: Vec, 80 | // There are only two possible values here, query and fragment. Default is both. 81 | /// JSON array containing a list of the OAuth 2.0 `response_mode` values that this OP supports, as specified in [OAuth 2.0 Multiple Response Type Encoding Practices](https://openid.net/specs/openid-connect-discovery-1_0.html#OAuth.Responses). If omitted, the default for Dynamic OpenID Providers is `["query", "fragment"]`. 82 | #[serde(default)] 83 | pub response_modes_supported: Option>, 84 | // Must support at least authorization_code and implicit. 85 | /// JSON array containing a list of the OAuth 2.0 Grant Type values that 86 | /// this OP supports. Dynamic OpenID Providers MUST support the 87 | /// `authorization_code` and `implicit` Grant Type values and MAY support 88 | /// other Grant Types. If omitted, the default value is 89 | /// `["authorization_code", "implicit"]`. 90 | #[serde(default)] 91 | pub grant_types_supported: Option>, 92 | /// JSON array containing a list of the Authentication Context Class 93 | /// References that this OP supports. 94 | #[serde(default)] 95 | pub acr_values_supported: Option>, 96 | // pairwise and public are valid by spec, but servers can add more 97 | /// JSON array containing a list of the Subject Identifier types that this 98 | /// OP supports. Valid types include `pairwise` and `public`. 99 | #[serde(default = "empty_string_vec")] 100 | pub subject_types_supported: Vec, 101 | // Must include at least RS256, none is only allowed with response types without id tokens 102 | /// JSON array containing a list of the JWS signing algorithms (`alg` 103 | /// values) supported by the OP for the ID Token to encode the Claims in a 104 | /// [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). The algorithm `RS256` MUST be included. The value `none` MAY be 105 | /// supported but MUST NOT be used unless the Response Type used returns no 106 | /// ID Token from the Authorization Endpoint (such as when using the 107 | /// Authorization Code Flow). 108 | #[serde(default = "empty_string_vec")] 109 | pub id_token_signing_alg_values_supported: Vec, 110 | /// JSON array containing a list of the [JWE](https://openid.net/specs/openid-connect-discovery-1_0.html#JWE) encryption algorithms (`alg` 111 | /// values) supported by the OP for the ID Token to encode the Claims in a 112 | /// [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). 113 | #[serde(default)] 114 | pub id_token_encryption_alg_values_supported: Option>, 115 | /// JSON array containing a list of the [JWE](https://openid.net/specs/openid-connect-discovery-1_0.html#JWE) encryption algorithms (`enc` 116 | /// values) supported by the OP for the ID Token to encode the Claims in a 117 | /// [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). 118 | #[serde(default)] 119 | pub id_token_encryption_enc_values_supported: Option>, 120 | /// JSON array containing a list of the [JWS](https://openid.net/specs/openid-connect-discovery-1_0.html#JWS) signing algorithms (`alg` 121 | /// values) [JWA](https://openid.net/specs/openid-connect-discovery-1_0.html#JWA) supported by the UserInfo Endpoint to encode the Claims in 122 | /// a [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). The value `none` MAY be included. 123 | #[serde(default)] 124 | pub userinfo_signing_alg_values_supported: Option>, 125 | /// JSON array containing a list of the [JWE](https://openid.net/specs/openid-connect-discovery-1_0.html#JWE) encryption algorithms (`alg` values) [JWA](https://openid.net/specs/openid-connect-discovery-1_0.html#JWA) supported by the UserInfo Endpoint to encode the Claims in a [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). 126 | #[serde(default)] 127 | pub userinfo_encryption_alg_values_supported: Option>, 128 | /// JSON array containing a list of the [JWE](https://openid.net/specs/openid-connect-discovery-1_0.html#JWE) encryption algorithms (`enc` values) [JWA](https://openid.net/specs/openid-connect-discovery-1_0.html#JWA) supported by the UserInfo Endpoint to encode the Claims in a [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). 129 | #[serde(default)] 130 | pub userinfo_encryption_enc_values_supported: Option>, 131 | /// JSON array containing a list of the [JWS](https://openid.net/specs/openid-connect-discovery-1_0.html#JWS) signing algorithms (`alg` values) 132 | /// supported by the OP for Request Objects, which are described in Section 133 | /// 6.1 of OpenID Connect Core 1.0 [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core). These algorithms are used 134 | /// both when the Request Object is passed by value (using the `request` 135 | /// parameter) and when it is passed by reference (using the `request_uri` 136 | /// parameter). Servers SHOULD support `none` and `RS256`. 137 | #[serde(default)] 138 | pub request_object_signing_alg_values_supported: Option>, 139 | /// JSON array containing a list of the [JWE](https://openid.net/specs/openid-connect-discovery-1_0.html#JWE) encryption algorithms (`alg` 140 | /// values) supported by the OP for Request Objects. These algorithms are 141 | /// used both when the Request Object is passed by value and when it is 142 | /// passed by reference. 143 | #[serde(default)] 144 | pub request_object_encryption_alg_values_supported: Option>, 145 | /// JSON array containing a list of the [JWE](https://openid.net/specs/openid-connect-discovery-1_0.html#JWE) encryption algorithms (`enc` 146 | /// values) supported by the OP for Request Objects. These algorithms are 147 | /// used both when the Request Object is passed by value and when it is 148 | /// passed by reference. 149 | #[serde(default)] 150 | pub request_object_encryption_enc_values_supported: Option>, 151 | // Spec options are client_secret_post, client_secret_basic, client_secret_jwt, private_key_jwt 152 | // If omitted, client_secret_basic is used 153 | /// JSON array containing a list of Client Authentication methods supported 154 | /// by this Token Endpoint. The options are `client_secret_post`, 155 | /// `client_secret_basic`, `client_secret_jwt`, and `private_key_jwt`, as 156 | /// described in Section 9 of OpenID Connect Core 1.0 [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core). Other 157 | /// authentication methods MAY be defined by extensions. If omitted, the 158 | /// default is client_secret_basic -- the HTTP Basic Authentication Scheme 159 | /// specified in Section 2.3.1 of [OAuth 2.0: RFC6749](https://openid.net/specs/openid-connect-discovery-1_0.html#RFC6749). 160 | #[serde(default)] 161 | pub token_endpoint_auth_methods_supported: Option>, 162 | // Only wanted with jwt auth methods, should have RS256, none not allowed 163 | /// JSON array containing a list of the JWS signing algorithms (alg values) 164 | /// supported by the Token Endpoint for the signature on the [JWT](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT) used to 165 | /// authenticate the Client at the Token Endpoint for the private_key_jwt 166 | /// and client_secret_jwt authentication methods. Servers SHOULD support 167 | /// RS256. The value none MUST NOT be used. 168 | #[serde(default)] 169 | pub token_endpoint_auth_signing_alg_values_supported: Option>, 170 | /// JSON array containing a list of the display parameter values that the 171 | /// OpenID Provider supports. These values are described in Section 3.1.2.1 172 | /// of OpenID Connect Core 1.0 [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core) 173 | #[serde(default)] 174 | pub display_values_supported: Option>, 175 | // Valid options are normal, aggregated, and distributed. If omitted, only use normal 176 | /// The claim types supported by the OpenID Connect provider. 177 | /// 178 | /// JSON array containing a list of the Claim Types that the OpenID Provider 179 | /// supports. These Claim Types are described in Section 5.6 of OpenID 180 | /// Connect Core 1.0 [OpenID.Core](https://openid.net/specs/openid-connect-discovery-1_0.html#OpenID.Core). Values defined by this specification are 181 | /// normal, aggregated, and distributed. If omitted, the implementation 182 | /// supports only normal Claims. 183 | #[serde(default)] 184 | pub claim_types_supported: Option>, 185 | /// The claims supported by the OpenID Connect provider. 186 | /// 187 | /// JSON array containing a list of the Claim Names of the Claims that the 188 | /// OpenID Provider MAY be able to supply values for. Note that for privacy 189 | /// or other reasons, this might not be an exhaustive list. 190 | #[serde(default)] 191 | pub claims_supported: Option>, 192 | /// The service documentation URL of the OpenID Connect provider. 193 | /// 194 | /// URL of a page containing human-readable information that developers 195 | /// might want or need to know when using the OpenID Provider. In 196 | /// particular, if the OpenID Provider does not support Dynamic Client 197 | /// Registration, then information on how to register Clients needs to be 198 | /// provided in this documentation. 199 | #[serde(default)] 200 | pub service_documentation: Option, 201 | /// The supported claim locales for the OpenID Connect provider. 202 | /// 203 | /// Languages and scripts supported for values in Claims being returned, 204 | /// represented as a JSON array of BCP47 [RFC5646](https://openid.net/specs/openid-connect-discovery-1_0.html#RFC5646) language tag values. Not 205 | /// all languages and scripts are necessarily supported for all Claim 206 | /// values. 207 | #[serde(default)] 208 | pub claims_locales_supported: Option>, 209 | /// The UI locales supported by the OpenID Connect provider. 210 | /// 211 | /// Languages and scripts supported for the user interface, represented as a 212 | /// JSON array of BCP47 [RFC5646](https://openid.net/specs/openid-connect-discovery-1_0.html#RFC5646) language tag values. 213 | #[serde(default)] 214 | pub ui_locales_supported: Option>, 215 | /// Boolean value specifying whether the OP supports use of the `claims` 216 | /// parameter, with `true` indicating support. If omitted, the default value 217 | /// is `false`. 218 | #[serde(default)] 219 | pub claims_parameter_supported: bool, 220 | /// Boolean value specifying whether the OP supports use of the `request` 221 | /// parameter, with `true` indicating support. If omitted, the default value 222 | /// is `false`. 223 | #[serde(default)] 224 | pub request_parameter_supported: bool, 225 | /// Boolean value specifying whether the OP supports use of the 226 | /// `request_uri` parameter, with `true` indicating support. If omitted, 227 | /// the default value is `false`. 228 | #[serde(default = "tru")] 229 | pub request_uri_parameter_supported: bool, 230 | /// Boolean value specifying whether the OP requires any `request_uri` 231 | /// values used to be pre-registered using the `request_uris` 232 | /// registration parameter. Pre-registration is REQUIRED when the value 233 | /// is `true`. If omitted, the default value is `false`. 234 | #[serde(default)] 235 | pub require_request_uri_registration: bool, 236 | /// URL that the OpenID Provider provides to the person registering the 237 | /// Client to read about the OP's requirements on how the Relying Party can 238 | /// use the data provided by the OP. The registration process SHOULD display 239 | /// this URL to the person registering the Client if it is given. 240 | #[serde(default)] 241 | pub op_policy_uri: Option, 242 | /// URL that the OpenID Provider provides to the person registering the 243 | /// Client to read about the OpenID Provider's terms of service. The 244 | /// registration process SHOULD display this URL to the person registering 245 | /// the Client if it is given. 246 | #[serde(default)] 247 | pub op_tos_uri: Option, 248 | /// The end session endpoint of the OpenID Connect provider. 249 | /// 250 | /// This is the URL where clients will send a request to invalidate an 251 | /// existing authorization code. It should be a fully qualified URL and 252 | /// must include a scheme (such as `http` or `https`) followed by a host, 253 | /// path, query parameters, and fragment. 254 | #[serde(default)] 255 | pub end_session_endpoint: Option, 256 | /// The introspection endpoint of the OpenID Connect provider. 257 | /// 258 | /// This is the URL where clients will send a request to check the validity 259 | /// of an access token. It should be a fully qualified URL and must 260 | /// include a scheme (such as `http` or `https`) followed by a host, 261 | /// path, query parameters, and fragment. 262 | #[serde(default)] 263 | pub introspection_endpoint: Option, 264 | /// The code challenge methods supported by the OpenID Connect provider. 265 | /// 266 | /// This is an optional list of code challenge method strings that the 267 | /// service supports. This is a NONSTANDARD extension Google uses that 268 | /// is a part of the OAuth discovery draft. 269 | #[serde(default)] 270 | pub code_challenge_methods_supported: Option>, 271 | } 272 | 273 | // This seems really dumb... 274 | fn tru() -> bool { 275 | true 276 | } 277 | 278 | fn empty_string_vec() -> Vec { 279 | vec![] 280 | } 281 | -------------------------------------------------------------------------------- /src/configurable.rs: -------------------------------------------------------------------------------- 1 | use crate::Config; 2 | 3 | /// A trait for types that can be configured. 4 | /// 5 | /// This trait defines a method `config` which returns a reference to a 6 | /// `Config`. 7 | pub trait Configurable { 8 | /// Returns a reference to the configuration of this type. 9 | /// 10 | /// # Examples 11 | /// 12 | /// ```rust, no_run 13 | /// # use openid::{Configurable, Config}; 14 | /// # #[derive(Default)] 15 | /// # struct MyType; 16 | /// # impl Configurable for MyType { fn config(&self) -> &Config { todo!() }} 17 | /// let config = MyType::default().config(); 18 | /// ``` 19 | fn config(&self) -> &Config; 20 | } 21 | -------------------------------------------------------------------------------- /src/custom_claims.rs: -------------------------------------------------------------------------------- 1 | use serde::{de::DeserializeOwned, Serialize}; 2 | 3 | use crate::{Claims, StandardClaims}; 4 | 5 | /// Custom Claims embedded extension. 6 | /// 7 | /// # Examples 8 | /// 9 | /// ``` 10 | /// # use serde::{Deserialize, Serialize}; 11 | /// use openid::{Claims, CompactJson, CustomClaims, StandardClaims, Client, Discovered}; 12 | /// use openid::provider::google::Installed; 13 | /// 14 | /// #[derive(Deserialize, Serialize)] 15 | /// struct MyClaims { 16 | /// my_claim: Option, 17 | /// #[serde(flatten)] 18 | /// standard_claims: StandardClaims, 19 | /// } 20 | /// 21 | /// impl CustomClaims for MyClaims { 22 | /// fn standard_claims(&self) -> &StandardClaims { 23 | /// &self.standard_claims 24 | /// } 25 | /// } 26 | /// 27 | /// impl CompactJson for MyClaims {} 28 | /// 29 | /// let client: Client = Client::new( 30 | /// Installed, 31 | /// String::from("CLIENT_ID"), 32 | /// String::from("CLIENT_SECRET"), 33 | /// Some(String::from("urn:ietf:wg:oauth:2.0:oob")), 34 | /// reqwest::Client::new(), None, 35 | /// ); 36 | /// ``` 37 | /// 38 | /// See full example: [openid-example:custom_claims](https://github.com/kilork/openid-example/blob/master/examples/custom_claims.rs) 39 | pub trait CustomClaims: Serialize + DeserializeOwned { 40 | /// The standard claims. 41 | fn standard_claims(&self) -> &StandardClaims; 42 | } 43 | 44 | impl Claims for T 45 | where 46 | T: CustomClaims, 47 | { 48 | fn iss(&self) -> &url::Url { 49 | self.standard_claims().iss() 50 | } 51 | fn sub(&self) -> &str { 52 | self.standard_claims().sub() 53 | } 54 | fn aud(&self) -> &crate::SingleOrMultiple { 55 | self.standard_claims().aud() 56 | } 57 | fn exp(&self) -> i64 { 58 | self.standard_claims().exp() 59 | } 60 | fn iat(&self) -> i64 { 61 | self.standard_claims().iat() 62 | } 63 | fn auth_time(&self) -> Option { 64 | self.standard_claims().auth_time() 65 | } 66 | fn nonce(&self) -> Option<&String> { 67 | self.standard_claims().nonce() 68 | } 69 | fn at_hash(&self) -> Option<&String> { 70 | self.standard_claims().at_hash() 71 | } 72 | fn c_hash(&self) -> Option<&String> { 73 | self.standard_claims().c_hash() 74 | } 75 | fn acr(&self) -> Option<&String> { 76 | self.standard_claims().acr() 77 | } 78 | fn amr(&self) -> Option<&Vec> { 79 | self.standard_claims().amr() 80 | } 81 | fn azp(&self) -> Option<&String> { 82 | self.standard_claims().azp() 83 | } 84 | fn userinfo(&self) -> &crate::Userinfo { 85 | self.standard_claims().userinfo() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/deserializers.rs: -------------------------------------------------------------------------------- 1 | use de::Visitor; 2 | use serde::{de, Deserializer}; 3 | 4 | pub fn bool_from_str_or_bool<'de, D>(deserializer: D) -> Result 5 | where 6 | D: Deserializer<'de>, 7 | { 8 | deserializer.deserialize_any(BoolOrStringVisitor) 9 | } 10 | 11 | struct BoolOrStringVisitor; 12 | 13 | impl Visitor<'_> for BoolOrStringVisitor { 14 | type Value = bool; 15 | 16 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 17 | formatter.write_str("a boolean or string of \"true\", \"false\".") 18 | } 19 | 20 | fn visit_bool(self, value: bool) -> Result 21 | where 22 | E: de::Error, 23 | { 24 | Ok(value) 25 | } 26 | 27 | fn visit_str(self, value: &str) -> Result 28 | where 29 | E: de::Error, 30 | { 31 | match value { 32 | "true" => Ok(true), 33 | "false" => Ok(false), 34 | _s => Err(E::custom(format!("Unknown string value: {}", _s))), 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/discovered.rs: -------------------------------------------------------------------------------- 1 | use biscuit::{jwk::JWKSet, Empty}; 2 | use reqwest::Client; 3 | use url::Url; 4 | 5 | use crate::{error::Error, Config, Configurable, Provider}; 6 | 7 | /// A discovered provider. 8 | /// 9 | /// This struct is used to store configuration for a provider that was 10 | /// discovered using the discovery protocol. 11 | #[derive(Debug, Clone)] 12 | pub struct Discovered(Config); 13 | 14 | impl Provider for Discovered { 15 | fn auth_uri(&self) -> &Url { 16 | &self.0.authorization_endpoint 17 | } 18 | 19 | fn token_uri(&self) -> &Url { 20 | &self.0.token_endpoint 21 | } 22 | } 23 | 24 | impl Configurable for Discovered { 25 | fn config(&self) -> &Config { 26 | &self.0 27 | } 28 | } 29 | 30 | impl From for Discovered { 31 | fn from(value: Config) -> Self { 32 | Self(value) 33 | } 34 | } 35 | 36 | pub async fn discover(client: &Client, mut issuer: Url) -> Result { 37 | issuer 38 | .path_segments_mut() 39 | .map_err(|_| Error::CannotBeABase)? 40 | .extend(&[".well-known", "openid-configuration"]); 41 | 42 | let resp = client.get(issuer).send().await?.error_for_status()?; 43 | resp.json().await.map_err(Error::from) 44 | } 45 | 46 | /// Get the JWK set from the given Url. Errors are either a reqwest error or an 47 | /// Insecure error if the url isn't https. 48 | pub async fn jwks(client: &Client, url: Url) -> Result, Error> { 49 | let resp = client.get(url).send().await?.error_for_status()?; 50 | resp.json().await.map_err(Error::from) 51 | } 52 | -------------------------------------------------------------------------------- /src/display.rs: -------------------------------------------------------------------------------- 1 | /// The four values for the preferred `display` parameter in the Options. See 2 | /// spec for details. 3 | /// 4 | /// See: [OpenID Connect Core 1.0: Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) 5 | #[derive(Debug, Clone, Copy)] 6 | pub enum Display { 7 | /// The Authorization Server SHOULD display the authentication and consent 8 | /// UI consistent with a full User Agent page view. If the display parameter 9 | /// is not specified, this is the default display mode. 10 | Page, 11 | /// The Authorization Server SHOULD display the authentication and consent 12 | /// UI consistent with a popup User Agent window. The popup User Agent 13 | /// window should be of an appropriate size for a login-focused dialog and 14 | /// should not obscure the entire window that it is popping up over. 15 | Popup, 16 | /// The Authorization Server SHOULD display the authentication and consent 17 | /// UI consistent with a device that leverages a touch interface. 18 | Touch, 19 | /// The Authorization Server SHOULD display the authentication and consent 20 | /// UI consistent with a "feature phone" type display. 21 | Wap, 22 | } 23 | 24 | impl Display { 25 | pub(crate) fn as_str(&self) -> &'static str { 26 | use Display::*; 27 | match *self { 28 | Page => "page", 29 | Popup => "popup", 30 | Touch => "touch", 31 | Wap => "wap", 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Library errors for OAuth 2.0 and OpenID. 3 | */ 4 | use std::{error, fmt}; 5 | 6 | use serde::Deserialize; 7 | 8 | /// OAuth 2.0 error. 9 | /// 10 | /// See [RFC 6749, section 5.2](http://tools.ietf.org/html/rfc6749#section-5.2). 11 | #[derive(Deserialize, Debug, PartialEq, Eq)] 12 | pub struct OAuth2Error { 13 | /// Error code. 14 | pub error: OAuth2ErrorCode, 15 | 16 | /// Human-readable text providing additional information about the error. 17 | pub error_description: Option, 18 | 19 | /// A URI identifying a human-readable web page with information about the 20 | /// error. 21 | pub error_uri: Option, 22 | } 23 | 24 | impl fmt::Display for OAuth2Error { 25 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 26 | write!(f, "{:?}", self.error)?; 27 | if let Some(ref description) = self.error_description { 28 | write!(f, ": {}", description)?; 29 | } 30 | if let Some(ref uri) = self.error_uri { 31 | write!(f, " ({})", uri)?; 32 | } 33 | Ok(()) 34 | } 35 | } 36 | 37 | impl error::Error for OAuth2Error { 38 | fn description(&self) -> &str { 39 | "OAuth 2.0 API error" 40 | } 41 | } 42 | 43 | /// OAuth 2.0 error codes. 44 | /// 45 | /// See [RFC 6749, section 5.2](http://tools.ietf.org/html/rfc6749#section-5.2). 46 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 47 | #[serde(rename_all = "snake_case")] 48 | pub enum OAuth2ErrorCode { 49 | /// The request is missing a required parameter, includes an unsupported 50 | /// parameter value (other than grant type), repeats a parameter, 51 | /// includes multiple credentials, utilizes more than one mechanism for 52 | /// authenticating the client, or is otherwise malformed. 53 | InvalidRequest, 54 | 55 | /// Client authentication failed (e.g., unknown client, no client 56 | /// authentication included, or unsupported authentication method). 57 | InvalidClient, 58 | 59 | /// The provided authorization grant (e.g., authorization code, resource 60 | /// owner credentials) or refresh token is invalid, expired, revoked, 61 | /// does not match the redirection URI used in the authorization 62 | /// request, or was issued to another client. 63 | InvalidGrant, 64 | 65 | /// The authenticated client is not authorized to use this authorization 66 | /// grant type. 67 | UnauthorizedClient, 68 | 69 | /// The authorization grant type is not supported by the authorization 70 | /// server. 71 | UnsupportedGrantType, 72 | 73 | /// The requested scope is invalid, unknown, malformed, or exceeds the scope 74 | /// granted by the resource owner. 75 | InvalidScope, 76 | 77 | /// An unrecognized error code, not defined in RFC 6749. 78 | Unrecognized(String), 79 | } 80 | 81 | impl From<&str> for OAuth2ErrorCode { 82 | fn from(s: &str) -> OAuth2ErrorCode { 83 | match s { 84 | "invalid_request" => OAuth2ErrorCode::InvalidRequest, 85 | "invalid_client" => OAuth2ErrorCode::InvalidClient, 86 | "invalid_grant" => OAuth2ErrorCode::InvalidGrant, 87 | "unauthorized_client" => OAuth2ErrorCode::UnauthorizedClient, 88 | "unsupported_grant_type" => OAuth2ErrorCode::UnsupportedGrantType, 89 | "invalid_scope" => OAuth2ErrorCode::InvalidScope, 90 | s => OAuth2ErrorCode::Unrecognized(s.to_owned()), 91 | } 92 | } 93 | } 94 | 95 | /// Client side error. 96 | #[derive(Debug)] 97 | pub enum ClientError { 98 | /// IO error. 99 | Io(std::io::Error), 100 | 101 | /// URL error. 102 | Url(url::ParseError), 103 | 104 | /// Reqwest error. 105 | Reqwest(reqwest::Error), 106 | 107 | /// JSON error. 108 | Json(serde_json::Error), 109 | 110 | /// OAuth 2.0 error. 111 | OAuth2(OAuth2Error), 112 | 113 | /// UMA2 error. 114 | #[cfg(feature = "uma2")] 115 | Uma2(Uma2Error), 116 | } 117 | 118 | impl fmt::Display for ClientError { 119 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 120 | match *self { 121 | ClientError::Io(ref err) => write!(f, "{}", err), 122 | ClientError::Url(ref err) => write!(f, "{}", err), 123 | ClientError::Reqwest(ref err) => write!(f, "{}", err), 124 | ClientError::Json(ref err) => write!(f, "{}", err), 125 | ClientError::OAuth2(ref err) => write!(f, "{}", err), 126 | #[cfg(feature = "uma2")] 127 | ClientError::Uma2(ref err) => write!(f, "{}", err), 128 | } 129 | } 130 | } 131 | 132 | impl error::Error for ClientError { 133 | fn cause(&self) -> Option<&dyn error::Error> { 134 | match *self { 135 | ClientError::Io(ref err) => Some(err), 136 | ClientError::Url(ref err) => Some(err), 137 | ClientError::Reqwest(ref err) => Some(err), 138 | ClientError::Json(ref err) => Some(err), 139 | ClientError::OAuth2(ref err) => Some(err), 140 | #[cfg(feature = "uma2")] 141 | ClientError::Uma2(ref err) => Some(err), 142 | } 143 | } 144 | } 145 | 146 | macro_rules! impl_from { 147 | ($v:path, $t:ty) => { 148 | impl From<$t> for ClientError { 149 | fn from(err: $t) -> Self { 150 | $v(err) 151 | } 152 | } 153 | }; 154 | } 155 | 156 | impl_from!(ClientError::Io, std::io::Error); 157 | impl_from!(ClientError::Url, url::ParseError); 158 | impl_from!(ClientError::Reqwest, reqwest::Error); 159 | impl_from!(ClientError::Json, serde_json::Error); 160 | impl_from!(ClientError::OAuth2, OAuth2Error); 161 | 162 | pub use biscuit::errors::Error as Jose; 163 | pub use reqwest::Error as Http; 164 | pub use serde_json::Error as Json; 165 | use thiserror::Error; 166 | 167 | #[cfg(feature = "uma2")] 168 | use crate::uma2::Uma2Error; 169 | 170 | /// openid library error. 171 | /// 172 | /// Wraps different sources of errors under one error type for library. 173 | #[derive(Debug, Error)] 174 | pub enum Error { 175 | /// [biscuit] errors. 176 | #[error(transparent)] 177 | Jose(#[from] Jose), 178 | /// [reqwest] errors. 179 | #[error(transparent)] 180 | Http(#[from] Http), 181 | /// [serde_json] errors. 182 | #[error(transparent)] 183 | Json(#[from] Json), 184 | /// Decode token error. 185 | #[error(transparent)] 186 | Decode(#[from] Decode), 187 | /// Validation error. 188 | #[error(transparent)] 189 | Validation(#[from] Validation), 190 | /// Errors related to userinfo endpoint. 191 | #[error(transparent)] 192 | Userinfo(#[from] Userinfo), 193 | /// Errors related to introspection endpoint. 194 | #[error(transparent)] 195 | Introspection(#[from] Introspection), 196 | /// Secure connection is required. 197 | #[error("Url must use TLS: '{0}'")] 198 | Insecure(::reqwest::Url), 199 | /// The scope must contain `openid`. 200 | #[error("Scope must contain Openid")] 201 | MissingOpenidScope, 202 | /// Path segments in url is cannot-be-a-base. 203 | #[error("Url: Path segments is cannot-be-a-base")] 204 | CannotBeABase, 205 | /// Client side error. 206 | #[error(transparent)] 207 | ClientError(#[from] ClientError), 208 | } 209 | 210 | /// Decode token error. 211 | #[derive(Debug, Error)] 212 | pub enum Decode { 213 | /// Token missing a key id when the key set has multiple keys. 214 | #[error("Token missing a key id when the key set has multiple keys")] 215 | MissingKid, 216 | /// Token wants this key id not in the key set. 217 | #[error("Token wants this key id not in the key set: {0}")] 218 | MissingKey(String), 219 | /// JWK Set is empty. 220 | #[error("JWK Set is empty")] 221 | EmptySet, 222 | /// No support for EC keys yet. 223 | #[error("No support for EC keys yet")] 224 | UnsupportedEllipticCurve, 225 | /// No support for Octet key pair yet. 226 | #[error("No support for Octet key pair yet")] 227 | UnsupportedOctetKeyPair, 228 | } 229 | 230 | /// Validation failure related to mismatch of values, missing values or expired 231 | /// values. 232 | #[derive(Debug, Error)] 233 | pub enum Validation { 234 | /// Mismatch in token attribute. 235 | #[error(transparent)] 236 | Mismatch(#[from] Mismatch), 237 | /// Missing required token attribute. 238 | #[error(transparent)] 239 | Missing(#[from] Missing), 240 | /// Token expired. 241 | #[error(transparent)] 242 | Expired(#[from] Expiry), 243 | } 244 | 245 | /// Mismatch in token attribute. 246 | #[derive(Debug, Error)] 247 | pub enum Mismatch { 248 | /// Client ID and Token authorized party mismatch. 249 | #[error("Client ID and Token authorized party mismatch: '{expected}', '{actual}'")] 250 | AuthorizedParty { 251 | /// Expected value. 252 | expected: String, 253 | /// Actual value. 254 | actual: String, 255 | }, 256 | /// Configured issuer and token issuer mismatch. 257 | #[error("Configured issuer and token issuer mismatch: '{expected}', '{actual}'")] 258 | Issuer { 259 | /// Expected value. 260 | expected: String, 261 | /// Actual value. 262 | actual: String, 263 | }, 264 | /// Given nonce does not match token nonce. 265 | #[error("Given nonce does not match token nonce: '{expected}', '{actual}'")] 266 | Nonce { 267 | /// Expected value. 268 | expected: String, 269 | /// Actual value. 270 | actual: String, 271 | }, 272 | } 273 | 274 | /// Missing required token attribute. 275 | #[derive(Debug, Clone, Copy, Error)] 276 | pub enum Missing { 277 | /// Token missing Audience. 278 | #[error("Token missing Audience")] 279 | Audience, 280 | /// Token missing AZP. 281 | #[error("Token missing AZP")] 282 | AuthorizedParty, 283 | /// Token missing Auth Time. 284 | #[error("Token missing Auth Time")] 285 | AuthTime, 286 | /// Token missing Nonce. 287 | #[error("Token missing Nonce")] 288 | Nonce, 289 | } 290 | 291 | /// Token expiration variants. 292 | #[derive(Debug, Clone, Copy, Error)] 293 | pub enum Expiry { 294 | /// Token expired. 295 | #[error("Token expired at: {0}")] 296 | Expires(::chrono::DateTime<::chrono::Utc>), 297 | /// Token is too old. 298 | #[error("Token is too old: {0}")] 299 | MaxAge(::chrono::Duration), 300 | /// Token exp is not valid UNIX timestamp. 301 | #[error("Token exp is not valid UNIX timestamp: {0}")] 302 | NotUnix(i64), 303 | } 304 | 305 | /// Errors related to userinfo endpoint. 306 | #[derive(Debug, Error)] 307 | pub enum Userinfo { 308 | /// Config has no userinfo url. 309 | #[error("Config has no userinfo url")] 310 | NoUrl, 311 | /// The UserInfo Endpoint MUST return a content-type header to indicate 312 | /// which format is being returned. 313 | #[error("The UserInfo Endpoint MUST return a content-type header to indicate which format is being returned")] 314 | MissingContentType, 315 | /// Not parsable content type header. 316 | #[error("Not parsable content type header: {content_type}")] 317 | ParseContentType { 318 | /// Content type header value. 319 | content_type: String, 320 | }, 321 | /// Wrong content type header. 322 | /// 323 | /// The following are accepted content types: `application/json`, 324 | /// `application/jwt`. 325 | #[error("Wrong content type header: {content_type}. The following are accepted content types: application/json, application/jwt")] 326 | WrongContentType { 327 | /// Content type header value. 328 | content_type: String, 329 | /// Request body for analyze. 330 | body: Vec, 331 | }, 332 | /// Token and Userinfo Subjects mismatch. 333 | #[error("Token and Userinfo Subjects mismatch: '{expected}', '{actual}'")] 334 | MismatchSubject { 335 | /// Expected token subject value. 336 | expected: String, 337 | /// Actual token subject value. 338 | actual: String, 339 | }, 340 | /// The sub (subject) Claim MUST always be returned in the UserInfo 341 | /// Response. 342 | #[error(transparent)] 343 | MissingSubject(#[from] StandardClaimsSubjectMissing), 344 | } 345 | 346 | /// The sub (subject) Claim MUST always be returned in the UserInfo Response. 347 | #[derive(Debug, Copy, Clone, Error)] 348 | #[error("The sub (subject) Claim MUST always be returned in the UserInfo Response")] 349 | pub struct StandardClaimsSubjectMissing; 350 | 351 | /// Introspection error details. 352 | #[derive(Debug, Error)] 353 | pub enum Introspection { 354 | /// Config has no introspection url. 355 | #[error("Config has no introspection url")] 356 | NoUrl, 357 | /// The Introspection Endpoint MUST return a `content-type` header to 358 | /// indicate which format is being returned. 359 | #[error("The Introspection Endpoint MUST return a content-type header to indicate which format is being returned")] 360 | MissingContentType, 361 | /// Not parsable content type header. 362 | #[error("Not parsable content type header: {content_type}")] 363 | ParseContentType { 364 | /// Content type header value. 365 | content_type: String, 366 | }, 367 | /// Wrong content type header. 368 | /// 369 | /// The following are accepted content types: `application/json`. 370 | #[error("Wrong content type header: {content_type}. The following are accepted content types: application/json")] 371 | WrongContentType { 372 | /// Content type header value. 373 | content_type: String, 374 | /// Request body for analyze. 375 | body: Vec, 376 | }, 377 | } 378 | 379 | #[cfg(test)] 380 | mod tests { 381 | use serde_json::json; 382 | 383 | use super::*; 384 | 385 | #[test] 386 | fn it_deserializes_error() { 387 | let error_json = json!({ 388 | "error": "invalid_request", 389 | "error_description": "Only resources with owner managed accessed can have policies", 390 | }); 391 | 392 | let error: OAuth2Error = serde_json::from_value(error_json).unwrap(); 393 | 394 | assert_eq!( 395 | error, 396 | OAuth2Error { 397 | error: OAuth2ErrorCode::InvalidRequest, 398 | error_description: Some( 399 | "Only resources with owner managed accessed can have policies".to_string() 400 | ), 401 | error_uri: None, 402 | } 403 | ); 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | #![warn( 3 | missing_docs, 4 | missing_debug_implementations, 5 | missing_copy_implementations, 6 | trivial_casts, 7 | trivial_numeric_casts, 8 | unused_extern_crates, 9 | unused_import_braces, 10 | unused_qualifications, 11 | variant_size_differences 12 | )] 13 | #[macro_use] 14 | extern crate lazy_static; 15 | 16 | mod address; 17 | mod bearer; 18 | mod claims; 19 | mod client; 20 | mod config; 21 | mod configurable; 22 | mod custom_claims; 23 | mod deserializers; 24 | mod discovered; 25 | mod display; 26 | pub mod error; 27 | mod options; 28 | mod prompt; 29 | pub mod provider; 30 | mod standard_claims; 31 | mod standard_claims_subject; 32 | mod token; 33 | mod token_introspection; 34 | mod userinfo; 35 | /// Token validation methods. 36 | pub mod validation; 37 | 38 | /// UMA2 OIDC/OAuth2 extension. 39 | /// 40 | /// See [Federated Authorization for User-Managed Access (UMA) 2.0](https://docs.kantarainitiative.org/uma/wg/oauth-uma-federated-authz-2.0-09.html) 41 | #[cfg(any(feature = "uma2", doc))] 42 | pub mod uma2; 43 | 44 | pub use ::biscuit::{jws::Compact as Jws, Compact, CompactJson, Empty, SingleOrMultiple}; 45 | pub use address::Address; 46 | pub use bearer::{Bearer, TemporalBearerGuard}; 47 | pub use claims::Claims; 48 | pub use client::Client; 49 | pub use config::Config; 50 | pub use configurable::Configurable; 51 | pub use custom_claims::CustomClaims; 52 | pub use discovered::Discovered; 53 | pub use display::Display; 54 | pub use error::{OAuth2Error, OAuth2ErrorCode}; 55 | pub use options::Options; 56 | pub use prompt::Prompt; 57 | pub use provider::Provider; 58 | pub use standard_claims::StandardClaims; 59 | pub use standard_claims_subject::StandardClaimsSubject; 60 | pub use token::Token; 61 | pub use token_introspection::TokenIntrospection; 62 | pub use userinfo::Userinfo; 63 | 64 | /// Reimport `biscuit` dependency. 65 | pub mod biscuit { 66 | pub use biscuit::*; 67 | } 68 | 69 | /// Alias for [Jws] 70 | pub type IdToken = Jws; 71 | /// Alias for discovered [Client]. 72 | /// 73 | /// See also: 74 | /// 75 | /// - [Discovered] 76 | /// - [StandardClaims] 77 | pub type DiscoveredClient = Client; 78 | /// Alias for discovered UMA2 [Client] 79 | /// 80 | /// See also: 81 | /// 82 | /// - [uma2::DiscoveredUma2] 83 | /// - [StandardClaims] 84 | #[cfg(feature = "uma2")] 85 | pub type DiscoveredUma2Client = Client; 86 | -------------------------------------------------------------------------------- /src/options.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use chrono::Duration; 4 | 5 | use crate::{Display, Prompt}; 6 | 7 | /// Optional request parameters. 8 | /// 9 | /// The request parameters that [OpenID specifies](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) for the auth URI. 10 | /// Derives Default, so remember to ..Default::default() after you specify what 11 | /// you want. 12 | #[derive(Default, Debug)] 13 | pub struct Options { 14 | /// REQUIRED. OpenID Connect requests MUST contain the `openid` scope value. 15 | /// 16 | /// If the `openid` scope value is not present, the behavior is entirely 17 | /// unspecified. Other scope values MAY be present. Scope values used that 18 | /// are not understood by an implementation SHOULD be ignored. See Sections 19 | /// [5.4](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) and [11](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess) for additional scope values defined by this 20 | /// specification. 21 | pub scope: Option, 22 | /// RECOMMENDED. Opaque value used to maintain state between the request and 23 | /// the callback. 24 | /// 25 | /// Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by 26 | /// cryptographically binding the value of this parameter with a browser 27 | /// cookie. 28 | pub state: Option, 29 | /// OPTIONAL. String value used to associate a Client session with an ID 30 | /// Token, and to mitigate replay attacks. 31 | /// 32 | /// The value is passed through unmodified from the Authentication Request 33 | /// to the ID Token. Sufficient entropy MUST be present in the `nonce` 34 | /// values used to prevent attackers from guessing values. For 35 | /// implementation notes, see [Section 15.5.2](https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes). 36 | pub nonce: Option, 37 | /// OPTIONAL. ASCII string value that specifies how the Authorization Server 38 | /// displays the authentication and consent user interface pages to the 39 | /// End-User. 40 | pub display: Option, 41 | /// OPTIONAL. Space-delimited, case-sensitive list of ASCII string values 42 | /// that specifies whether the Authorization Server prompts the End-User for 43 | /// reauthentication and consent. 44 | /// 45 | /// The `prompt` parameter can be used by the Client to make sure that the 46 | /// End-User is still present for the current session or to bring attention 47 | /// to the request. If this parameter contains none with any other value, an 48 | /// error is returned. If an OP receives a `prompt` value outside the 49 | /// set defined above that it does not understand, it MAY return an error or 50 | /// it MAY ignore it; in practice, not returning errors for not-understood 51 | /// values will help facilitate phasing in extensions using new `prompt` 52 | /// values. 53 | pub prompt: Option>, 54 | /// OPTIONAL. Maximum Authentication Age. 55 | /// 56 | /// Specifies the allowable elapsed time in seconds since the last time the 57 | /// End-User was actively authenticated by the OP. If the elapsed time is 58 | /// greater than this value, the OP MUST attempt to actively re-authenticate 59 | /// the End-User. (The `max_age` request parameter corresponds to the OpenID 60 | /// 2.0 PAPE [OpenID.PAPE](https://openid.net/specs/openid-connect-core-1_0.html#OpenID.PAPE) `max_auth_age` request parameter.) When `max_age` 61 | /// is used, the ID Token returned MUST include an `auth_time` Claim Value. 62 | /// Note that `max_age=0` is equivalent to `prompt=login`. 63 | pub max_age: Option, 64 | /// OPTIONAL. End-User's preferred languages and scripts for the user 65 | /// interface, represented as a space-separated list of BCP47 [RFC5646](https://openid.net/specs/openid-connect-core-1_0.html#RFC5646) 66 | /// language tag values, ordered by preference. For instance, the value 67 | /// "fr-CA fr en" represents a preference for French as spoken in Canada, 68 | /// then French (without a region designation), followed by English (without 69 | /// a region designation). An error SHOULD NOT result if some or all of the 70 | /// requested locales are not supported by the OpenID Provider. 71 | pub ui_locales: Option, 72 | /// OPTIONAL. End-User's preferred languages and scripts for Claims being 73 | /// returned, represented as a space-separated list of BCP47 [RFC5646] 74 | /// language tag values, ordered by preference. An error SHOULD NOT result 75 | /// if some or all of the requested locales are not supported by the OpenID 76 | /// Provider. 77 | pub claims_locales: Option, 78 | /// OPTIONAL. ID Token previously issued by the Authorization Server being 79 | /// passed as a hint about the End-User's current or past authenticated 80 | /// session with the Client. 81 | /// 82 | /// If the End-User identified by the ID Token is already logged in or is 83 | /// logged in as a result of the request (with the OP possibly evaluating 84 | /// other information beyond the ID Token in this decision), then the 85 | /// Authorization Server returns a positive response; otherwise, it MUST 86 | /// return an error, such as `login_required`. When possible, an 87 | /// `id_token_hint` SHOULD be present when `prompt=none` is used and an 88 | /// invalid_request error MAY be returned if it is not; however, the server 89 | /// SHOULD respond successfully when possible, even if it is not present. 90 | /// The Authorization Server need not be listed as an audience of the ID 91 | /// Token when it is used as an `id_token_hint` value. If the ID Token 92 | /// received by the RP from the OP is encrypted, to use it as an 93 | /// `id_token_hint`, the Client MUST decrypt the signed ID Token contained 94 | /// within the encrypted ID Token. The Client MAY re-encrypt the signed ID 95 | /// token to the Authentication Server using a key that enables the server 96 | /// to decrypt the ID Token and use the re-encrypted ID token as the 97 | /// `id_token_hint` value. 98 | pub id_token_hint: Option, 99 | /// OPTIONAL. Hint to the Authorization Server about the login identifier 100 | /// the End-User might use to log in (if necessary). 101 | /// 102 | /// This hint can be used by an RP if it first asks the End-User for their 103 | /// e-mail address (or other identifier) and then wants to pass that value 104 | /// as a hint to the discovered authorization service. It is RECOMMENDED 105 | /// that the hint value match the value used for discovery. This value MAY 106 | /// also be a phone number in the format specified for the phone_number 107 | /// Claim. The use of this parameter is left to the OP's discretion. 108 | pub login_hint: Option, 109 | /// OPTIONAL. Requested Authentication Context Class Reference values. 110 | /// 111 | /// Space-separated string that specifies the `acr` values that the 112 | /// Authorization Server is being requested to use for processing this 113 | /// Authentication Request, with the values appearing in order of 114 | /// preference. The Authentication Context Class satisfied by the 115 | /// authentication performed is returned as the `acr` Claim Value, as 116 | /// specified in [Section 2](https://openid.net/specs/openid-connect-core-1_0.html#IDToken). The `acr` Claim is requested as a Voluntary 117 | /// Claim by this parameter. 118 | pub acr_values: Option, 119 | } 120 | -------------------------------------------------------------------------------- /src/prompt.rs: -------------------------------------------------------------------------------- 1 | /// Authorization Server prompts. 2 | /// 3 | /// The four possible values for the prompt parameter set in Options. 4 | /// 5 | /// See [OpenID: 3.1.2.1. Authentication Request prompt](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 7 | pub enum Prompt { 8 | /// The Authorization Server MUST NOT display any authentication or consent 9 | /// user interface pages. An error is returned if an End-User is not already 10 | /// authenticated or the Client does not have pre-configured consent for the 11 | /// requested Claims or does not fulfill other conditions for processing the 12 | /// request. The error code will typically be `login_required`, 13 | /// `interaction_required`, or another code defined in [Section 3.1.2.6](https://openid.net/specs/openid-connect-core-1_0.html#AuthError). This 14 | /// can be used as a method to check for existing authentication and/or 15 | /// consent. 16 | None, 17 | /// The Authorization Server SHOULD prompt the End-User for 18 | /// reauthentication. If it cannot reauthenticate the End-User, it MUST 19 | /// return an error, typically `login_required`. 20 | Login, 21 | /// The Authorization Server SHOULD prompt the End-User for consent before 22 | /// returning information to the Client. If it cannot obtain consent, it 23 | /// MUST return an error, typically `consent_required`. 24 | Consent, 25 | /// The Authorization Server SHOULD prompt the End-User to select a user 26 | /// account. This enables an End-User who has multiple accounts at the 27 | /// Authorization Server to select amongst the multiple accounts that they 28 | /// might have current sessions for. If it cannot obtain an account 29 | /// selection choice made by the End-User, it MUST return an error, 30 | /// typically `account_selection_required`. 31 | SelectAccount, 32 | } 33 | 34 | impl Prompt { 35 | pub(crate) fn as_str(&self) -> &'static str { 36 | use Prompt::*; 37 | match *self { 38 | None => "none", 39 | Login => "login", 40 | Consent => "consent", 41 | SelectAccount => "select_account", 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/provider/microsoft.rs: -------------------------------------------------------------------------------- 1 | use biscuit::CompactJson; 2 | use chrono::Duration; 3 | 4 | use crate::{ 5 | client::Client, 6 | error::Error, 7 | validation::{validate_token_aud, validate_token_exp, validate_token_nonce}, 8 | Claims, Configurable, IdToken, Provider, Token, 9 | }; 10 | 11 | /// Given an auth_code and auth options, request the token, decode, and validate 12 | /// it. This validation is specific to Microsoft OIDC provider, it skips issuer 13 | /// validation. 14 | pub async fn authenticate( 15 | client: &Client, 16 | auth_code: &str, 17 | nonce: Option<&str>, 18 | max_age: Option<&Duration>, 19 | ) -> Result, Error> { 20 | let bearer = client.request_token(auth_code).await.map_err(Error::from)?; 21 | let mut token: Token = bearer.into(); 22 | 23 | if let Some(mut id_token) = token.id_token.as_mut() { 24 | client.decode_token(&mut id_token)?; 25 | validate_token(client, &id_token, nonce, max_age)?; 26 | } 27 | 28 | Ok(token) 29 | } 30 | 31 | /// Validate a decoded token for Microsoft OpenID. If you don't get an error, 32 | /// its valid! Nonce and max_age come from your auth_uri options. Errors are: 33 | /// 34 | /// - Jose Error if the Token isn't decoded 35 | /// - Validation::Mismatch::Nonce if a given nonce and the token nonce mismatch 36 | /// - Validation::Missing::Nonce if either the token or args has a nonce and the 37 | /// other does not 38 | /// - Validation::Missing::Audience if the token aud doesn't contain the client 39 | /// id 40 | /// - Validation::Missing::AuthorizedParty if there are multiple audiences and 41 | /// azp is missing 42 | /// - Validation::Mismatch::AuthorizedParty if the azp is not the client_id 43 | /// - Validation::Expired::Expires if the current time is past the expiration 44 | /// time 45 | /// - Validation::Expired::MaxAge is the token is older than the provided 46 | /// max_age 47 | /// - Validation::Expired::NotUnix if the expiration time is not valid UNIX 48 | /// timestamp 49 | /// - Validation::Missing::Authtime if a max_age was given and the token has no 50 | /// auth time 51 | pub fn validate_token( 52 | client: &Client, 53 | token: &IdToken, 54 | nonce: Option<&str>, 55 | max_age: Option<&Duration>, 56 | ) -> Result<(), Error> { 57 | let claims = token.payload()?; 58 | 59 | validate_token_nonce(claims, nonce)?; 60 | 61 | validate_token_aud(claims, &client.client_id)?; 62 | 63 | validate_token_exp(claims, max_age)?; 64 | 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /src/provider/mod.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | OAuth 2.0 providers. 3 | */ 4 | /// Microsoft OpenID Connect. 5 | /// 6 | /// See [Microsoft identity platform and OpenID Connect protocol](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc) 7 | #[cfg(any(feature = "microsoft", doc))] 8 | pub mod microsoft; 9 | 10 | use url::Url; 11 | 12 | /// OAuth 2.0 providers. 13 | pub trait Provider { 14 | /// The authorization endpoint URI. 15 | /// 16 | /// See [RFC 6749, section 3.1](http://tools.ietf.org/html/rfc6749#section-3.1). 17 | fn auth_uri(&self) -> &Url; 18 | 19 | /// The token endpoint URI. 20 | /// 21 | /// See [RFC 6749, section 3.2](http://tools.ietf.org/html/rfc6749#section-3.2). 22 | fn token_uri(&self) -> &Url; 23 | 24 | /// Provider requires credentials via request body. 25 | /// 26 | /// Although not recommended by the RFC, some providers require `client_id` 27 | /// and `client_secret` as part of the request body. 28 | /// 29 | /// See [RFC 6749, section 2.3.1](http://tools.ietf.org/html/rfc6749#section-2.3.1). 30 | fn credentials_in_body(&self) -> bool { 31 | false 32 | } 33 | } 34 | 35 | /// Google OAuth 2.0 providers. 36 | /// 37 | /// See [Using OAuth 2.0 to Access Google 38 | /// APIs](https://developers.google.com/identity/protocols/OAuth2). 39 | pub mod google { 40 | use url::Url; 41 | 42 | use super::Provider; 43 | 44 | /// Signals the server to return the authorization code by prompting the 45 | /// user to copy and paste. 46 | /// 47 | /// See [Choosing a redirect URI][uri]. 48 | /// 49 | /// [uri]: https://developers.google.com/identity/protocols/OAuth2InstalledApp#choosingredirecturi 50 | pub const REDIRECT_URI_OOB: &str = "urn:ietf:wg:oauth:2.0:oob"; 51 | 52 | /// Signals the server to return the authorization code in the page title. 53 | /// 54 | /// See [Choosing a redirect URI][uri]. 55 | /// 56 | /// [uri]: https://developers.google.com/identity/protocols/OAuth2InstalledApp#choosingredirecturi 57 | pub const REDIRECT_URI_OOB_AUTO: &str = "urn:ietf:wg:oauth:2.0:oob:auto"; 58 | 59 | lazy_static! { 60 | static ref AUTH_URI: Url = 61 | Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(); 62 | static ref TOKEN_URI: Url = 63 | Url::parse("https://www.googleapis.com/oauth2/v4/token").unwrap(); 64 | } 65 | 66 | /// Google OAuth 2.0 provider for web applications. 67 | /// 68 | /// See [Using OAuth 2.0 for Web Server 69 | /// Applications](https://developers.google.com/identity/protocols/OAuth2WebServer). 70 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 71 | pub struct Web; 72 | impl Provider for Web { 73 | fn auth_uri(&self) -> &Url { 74 | &AUTH_URI 75 | } 76 | fn token_uri(&self) -> &Url { 77 | &TOKEN_URI 78 | } 79 | } 80 | 81 | /// Google OAuth 2.0 provider for installed applications. 82 | /// 83 | /// See [Using OAuth 2.0 for Installed 84 | /// Applications](https://developers.google.com/identity/protocols/OAuth2InstalledApp). 85 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 86 | pub struct Installed; 87 | impl Provider for Installed { 88 | fn auth_uri(&self) -> &Url { 89 | &AUTH_URI 90 | } 91 | fn token_uri(&self) -> &Url { 92 | &TOKEN_URI 93 | } 94 | } 95 | } 96 | 97 | lazy_static! { 98 | static ref GITHUB_AUTH_URI: Url = 99 | Url::parse("https://github.com/login/oauth/authorize").unwrap(); 100 | static ref GITHUB_TOKEN_URI: Url = 101 | Url::parse("https://github.com/login/oauth/access_token").unwrap(); 102 | static ref IMGUR_AUTH_URI: Url = Url::parse("https://api.imgur.com/oauth2/authorize").unwrap(); 103 | static ref IMGUR_TOKEN_URI: Url = Url::parse("https://api.imgur.com/oauth2/token").unwrap(); 104 | } 105 | 106 | /// GitHub OAuth 2.0 provider. 107 | /// 108 | /// See [OAuth, GitHub Developer Guide](https://developer.github.com/v3/oauth/). 109 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 110 | pub struct GitHub; 111 | impl Provider for GitHub { 112 | fn auth_uri(&self) -> &Url { 113 | &GITHUB_AUTH_URI 114 | } 115 | fn token_uri(&self) -> &Url { 116 | &GITHUB_TOKEN_URI 117 | } 118 | } 119 | 120 | /// Imgur OAuth 2.0 provider. 121 | /// 122 | /// See [OAuth 2.0, Imgur](https://api.imgur.com/oauth2). 123 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 124 | pub struct Imgur; 125 | impl Provider for Imgur { 126 | fn auth_uri(&self) -> &Url { 127 | &IMGUR_AUTH_URI 128 | } 129 | fn token_uri(&self) -> &Url { 130 | &IMGUR_TOKEN_URI 131 | } 132 | } 133 | 134 | #[test] 135 | fn google_urls() { 136 | let prov = google::Web; 137 | prov.auth_uri(); 138 | prov.token_uri(); 139 | let prov = google::Installed; 140 | prov.auth_uri(); 141 | prov.token_uri(); 142 | } 143 | 144 | #[test] 145 | fn github_urls() { 146 | let prov = GitHub; 147 | prov.auth_uri(); 148 | prov.token_uri(); 149 | } 150 | 151 | #[test] 152 | fn imgur_urls() { 153 | let prov = Imgur; 154 | prov.auth_uri(); 155 | prov.token_uri(); 156 | } 157 | -------------------------------------------------------------------------------- /src/standard_claims.rs: -------------------------------------------------------------------------------- 1 | use biscuit::{CompactJson, SingleOrMultiple}; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | 5 | use crate::{Claims, Userinfo}; 6 | 7 | /// ID Token contents. [See spec.](https://openid.net/specs/openid-connect-core-1_0.html#IDToken) 8 | #[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] 9 | pub struct StandardClaims { 10 | /// Issuer Identifier for the Issuer of the response. 11 | /// 12 | /// The `iss` value is a case-sensitive URL using the `https` scheme that 13 | /// contains scheme, host, and optionally, port number and path components 14 | /// and no query or fragment components. 15 | pub iss: Url, 16 | // Max 255 ASCII chars 17 | // Can't deserialize a [u8; 255] 18 | /// Subject Identifier. 19 | /// 20 | /// A locally unique and never reassigned identifier within the Issuer for 21 | /// the End-User, which is intended to be consumed by the Client, e.g., 22 | /// `24400320` or `AItOawmwtWwcT0k51BayewNvutrJUqsvl6qs7A4`. It MUST NOT 23 | /// exceed 255 ASCII [RFC20] characters in length. The `sub` value is a 24 | /// case-sensitive string. 25 | pub sub: String, 26 | // Either an array of audiences, or just the client_id 27 | /// Audience(s) that this ID Token is intended for. 28 | /// 29 | /// It MUST contain the OAuth 2.0 `client_id` of the Relying Party as an 30 | /// audience value. It MAY also contain identifiers for other audiences. In 31 | /// the general case, the `aud` value is an array of case-sensitive strings. 32 | /// In the common special case when there is one audience, the `aud` value 33 | /// MAY be a single case-sensitive string. 34 | pub aud: SingleOrMultiple, 35 | // Not perfectly accurate for what time values we can get back... 36 | // By spec, this is an arbitrarilly large number. In practice, an 37 | // i64 unix time is up to 293 billion years from 1970. 38 | // 39 | // Make sure this cannot silently underflow, see: 40 | // https://github.com/serde-rs/json/blob/8e01f44f479b3ea96b299efc0da9131e7aff35dc/src/de.rs#L341 41 | /// Expiration time on or after which the ID Token MUST NOT be accepted by 42 | /// the RP when performing authentication with the OP. 43 | /// 44 | /// The processing of this parameter requires that the current date/time 45 | /// MUST be before the expiration date/time listed in the value. 46 | /// Implementers MAY provide for some small leeway, usually no more than a 47 | /// few minutes, to account for clock skew. Its value is a JSON [RFC8259] 48 | /// number representing the number of seconds from `1970-01-01T00:00:00Z` as 49 | /// measured in UTC until the date/time. See RFC 3339 [RFC3339] for details 50 | /// regarding date/times in general and UTC in particular. NOTE: The ID 51 | /// Token expiration time is unrelated the lifetime of the authenticated 52 | /// session between the RP and the OP. 53 | pub exp: i64, 54 | /// Time at which the JWT was issued. 55 | /// 56 | /// Its value is a JSON number representing the number of seconds from 57 | /// `1970-01-01T00:00:00Z` as measured in UTC until the date/time. 58 | pub iat: i64, 59 | // required for max_age request 60 | /// Time when the End-User authentication occurred. 61 | /// 62 | /// Its value is a JSON number representing the number of seconds from 63 | /// `1970-01-01T00:00:00Z` as measured in UTC until the date/time. When a 64 | /// `max_age` request is made or when `auth_time` is requested as an 65 | /// Essential Claim, then this Claim is REQUIRED; otherwise, its inclusion 66 | /// is OPTIONAL. (The `auth_time` Claim semantically corresponds to the 67 | /// OpenID 2.0 PAPE [OpenID.PAPE] `auth_time` response parameter.) 68 | #[serde(default)] 69 | pub auth_time: Option, 70 | /// String value used to associate a Client session with an ID Token, and to 71 | /// mitigate replay attacks. 72 | /// 73 | /// The value is passed through unmodified from the Authentication Request 74 | /// to the ID Token. If present in the ID Token, Clients MUST verify that 75 | /// the `nonce` Claim Value is equal to the value of the `nonce` parameter 76 | /// sent in the Authentication Request. If present in the Authentication 77 | /// Request, Authorization Servers MUST include a `nonce` Claim in the ID 78 | /// Token with the Claim Value being the `nonce` value sent in the 79 | /// Authentication Request. Authorization Servers SHOULD perform no other 80 | /// processing on `nonce` values used. The `nonce` value is a case-sensitive 81 | /// string. 82 | #[serde(default)] 83 | pub nonce: Option, 84 | // base64 encoded, need to decode it! 85 | /// Access Token hash value. Its value is the base64url encoding of the 86 | /// left-most half of the hash of the octets of the ASCII representation of 87 | /// the access_token value, where the hash algorithm used is the hash 88 | /// algorithm used in the alg Header Parameter of the ID Token's JOSE 89 | /// Header. For instance, if the alg is RS256, hash the access_token value 90 | /// with SHA-256, then take the left-most 128 bits and base64url-encode 91 | /// them. The at_hash value is a case-sensitive string. 92 | #[serde(default)] 93 | at_hash: Option, 94 | // base64 encoded, need to decode it! 95 | /// Code hash value. Its value is the base64url encoding of the left-most 96 | /// half of the hash of the octets of the ASCII representation of the code 97 | /// value, where the hash algorithm used is the hash algorithm used in the 98 | /// alg Header Parameter of the ID Token's JOSE Header. For instance, if the 99 | /// alg is HS512, hash the code value with SHA-512, then take the left-most 100 | /// 256 bits and base64url-encode them. The c_hash value is a case-sensitive 101 | /// string. 102 | #[serde(default)] 103 | c_hash: Option, 104 | /// Authentication Context Class Reference. 105 | /// 106 | /// String specifying an Authentication Context Class Reference value that 107 | /// identifies the Authentication Context Class that the authentication 108 | /// performed satisfied. The value "0" indicates the End-User authentication 109 | /// did not meet the requirements of ISO/IEC 29115 [ISO29115] level 1. For 110 | /// historic reasons, the value "0" is used to indicate that there is no 111 | /// confidence that the same person is actually there. Authentications with 112 | /// level 0 SHOULD NOT be used to authorize access to any resource of any 113 | /// monetary value. (This corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] 114 | /// `nist_auth_level` 0.) An absolute URI or an RFC 6711 [RFC6711] 115 | /// registered name SHOULD be used as the `acr` value; registered names MUST 116 | /// NOT be used with a different meaning than that which is registered. 117 | /// Parties using this claim will need to agree upon the meanings of the 118 | /// values used, which may be context specific. The `acr` value is a 119 | /// case-sensitive string. 120 | #[serde(default)] 121 | pub acr: Option, 122 | /// Authentication Methods References. 123 | /// 124 | /// JSON array of strings that are identifiers for authentication methods 125 | /// used in the authentication. For instance, values might indicate that 126 | /// both password and OTP authentication methods were used. The `amr` value 127 | /// is an array of case-sensitive strings. Values used in the `amr` Claim 128 | /// SHOULD be from those registered in the IANA Authentication Method 129 | /// Reference Values registry [IANA.AMR] established by [RFC8176]; parties 130 | /// using this claim will need to agree upon the meanings of any 131 | /// unregistered values used, which may be context specific. 132 | #[serde(default)] 133 | pub amr: Option>, 134 | // If exists, must be client_id 135 | /// Authorized party - the party to which the ID Token was issued. If 136 | /// present, it MUST contain the OAuth 2.0 Client ID of this party. The 137 | /// `azp` value is a case-sensitive string containing a StringOrURI value. 138 | /// Note that in practice, the `azp` Claim only occurs when extensions 139 | /// beyond the scope of this specification are used; therefore, 140 | /// implementations not using such extensions are encouraged to not use 141 | /// `azp` and to ignore it when it does occur. 142 | #[serde(default)] 143 | pub azp: Option, 144 | /// The standard claims. 145 | /// 146 | /// See [Standard Claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) 147 | #[serde(flatten)] 148 | pub userinfo: Userinfo, 149 | } 150 | 151 | impl Claims for StandardClaims { 152 | fn userinfo(&self) -> &Userinfo { 153 | &self.userinfo 154 | } 155 | fn c_hash(&self) -> Option<&String> { 156 | self.c_hash.as_ref() 157 | } 158 | fn at_hash(&self) -> Option<&String> { 159 | self.at_hash.as_ref() 160 | } 161 | fn iss(&self) -> &Url { 162 | &self.iss 163 | } 164 | fn sub(&self) -> &str { 165 | &self.sub 166 | } 167 | fn aud(&self) -> &SingleOrMultiple { 168 | &self.aud 169 | } 170 | fn exp(&self) -> i64 { 171 | self.exp 172 | } 173 | fn iat(&self) -> i64 { 174 | self.iat 175 | } 176 | fn auth_time(&self) -> Option { 177 | self.auth_time 178 | } 179 | fn nonce(&self) -> Option<&String> { 180 | self.nonce.as_ref() 181 | } 182 | fn acr(&self) -> Option<&String> { 183 | self.acr.as_ref() 184 | } 185 | fn amr(&self) -> Option<&Vec> { 186 | self.amr.as_ref() 187 | } 188 | fn azp(&self) -> Option<&String> { 189 | self.azp.as_ref() 190 | } 191 | } 192 | 193 | // THIS IS CRAZY VOODOO WITCHCRAFT MAGIC 194 | impl CompactJson for StandardClaims {} 195 | -------------------------------------------------------------------------------- /src/standard_claims_subject.rs: -------------------------------------------------------------------------------- 1 | use crate::error::StandardClaimsSubjectMissing; 2 | 3 | /// Standard Claims: Subject. 4 | pub trait StandardClaimsSubject: crate::CompactJson { 5 | /// Subject - Identifier for the End-User at the Issuer. 6 | /// 7 | /// See [Standard Claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) 8 | /// 9 | /// Errors: 10 | /// 11 | /// - [StandardClaimsSubjectMissing] if subject (sub) is missing 12 | fn sub(&self) -> Result<&str, StandardClaimsSubjectMissing>; 13 | } 14 | -------------------------------------------------------------------------------- /src/token.rs: -------------------------------------------------------------------------------- 1 | pub use biscuit::jws::Compact as Jws; 2 | use biscuit::CompactJson; 3 | 4 | use crate::{Bearer, Claims, IdToken, StandardClaims}; 5 | 6 | /// An OpenID Connect token. This is the only token allowed by spec. 7 | /// Has an `access_token` for bearer, and the `id_token` for authentication. 8 | /// Wraps an oauth bearer token. 9 | #[allow(missing_debug_implementations)] 10 | pub struct Token { 11 | /// Bearer Token. 12 | /// 13 | /// `access_token` 14 | pub bearer: Bearer, 15 | /// ID Token 16 | /// 17 | /// `id_token` 18 | pub id_token: Option>, 19 | } 20 | 21 | impl From for Token { 22 | fn from(bearer: Bearer) -> Self { 23 | let id_token = bearer 24 | .id_token 25 | .as_ref() 26 | .map(|token| Jws::new_encoded(token)); 27 | Self { bearer, id_token } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/token_introspection.rs: -------------------------------------------------------------------------------- 1 | use biscuit::CompactJson; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | 5 | use crate::SingleOrMultiple; 6 | 7 | /// This struct contains all fields defined in [the spec](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). 8 | #[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] 9 | pub struct TokenIntrospection { 10 | #[serde(default)] 11 | /// Boolean indicator of whether or not the presented token is currently 12 | /// active. The specifics of a token's "active" state will vary 13 | /// depending on the implementation of the authorization server and the 14 | /// information it keeps about its tokens, but a "true" value return for the 15 | /// "active" property will generally indicate that a given token has been 16 | /// issued by this authorization server, has not been revoked by the 17 | /// resource owner, and is within its given time window of validity 18 | /// (e.g., after its issuance time and before its expiration time). See [Section 4](https://datatracker.ietf.org/doc/html/rfc7662#section-4) for information on 19 | /// implementation of such checks. 20 | pub active: bool, 21 | 22 | #[serde(default)] 23 | /// A JSON string containing a space-separated list of scopes associated 24 | /// with this token, in the format described in [Section 3.3](https://datatracker.ietf.org/doc/html/rfc7662#section-3.3) 25 | /// of OAuth 2.0 [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749). 26 | pub scope: Option, 27 | 28 | #[serde(default)] 29 | /// Client identifier for the OAuth 2.0 client that requested this token. 30 | pub client_id: Option, 31 | 32 | #[serde(default)] 33 | /// Human-readable identifier for the resource owner who authorized this 34 | /// token. 35 | pub username: Option, 36 | 37 | #[serde(default)] 38 | /// Type of the token as defined in [Section 5.1](https://datatracker.ietf.org/doc/html/rfc7662#section-5.1) 39 | /// of OAuth 2.0 [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749). 40 | pub token_type: Option, 41 | 42 | // Not perfectly accurate for what time values we can get back... 43 | // By spec, this is an arbitrarilly large number. In practice, an 44 | // i64 unix time is up to 293 billion years from 1970. 45 | // 46 | // Make sure this cannot silently underflow, see: 47 | // https://github.com/serde-rs/json/blob/8e01f44f479b3ea96b299efc0da9131e7aff35dc/src/de.rs#L341 48 | #[serde(default)] 49 | /// Integer timestamp, measured in the number of seconds since January 1 50 | /// 1970 UTC, indicating when this token will expire, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 51 | pub exp: Option, 52 | #[serde(default)] 53 | /// Integer timestamp, measured in the number of seconds since January 1 54 | /// 1970 UTC, indicating when this token was originally issued, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 55 | pub iat: Option, 56 | #[serde(default)] 57 | /// Integer timestamp, measured in the number of seconds since January 1 58 | /// 1970 UTC, indicating when this token is not to be used before, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 59 | pub nbf: Option, 60 | 61 | // Max 255 ASCII chars 62 | // Can't deserialize a [u8; 255] 63 | #[serde(default)] 64 | /// Subject of the token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 65 | /// Usually a machine-readable identifier of the resource owner who 66 | /// authorized this token. 67 | pub sub: Option, 68 | 69 | // Either an array of audiences, or just the client_id 70 | #[serde(default)] 71 | /// Service-specific string identifier or list of string identifiers 72 | /// representing the intended audience for this token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 73 | pub aud: Option>, 74 | 75 | #[serde(default)] 76 | /// String representing the issuer of this token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 77 | pub iss: Option, 78 | 79 | #[serde(default)] 80 | /// String identifier for the token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). 81 | pub jti: Option, 82 | 83 | #[serde(flatten)] 84 | /// Any custom fields which are not defined in the RFC. 85 | pub custom: Option, 86 | } 87 | 88 | impl CompactJson for TokenIntrospection where I: CompactJson {} 89 | -------------------------------------------------------------------------------- /src/uma2/claim_token_format.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | /// UMA2 claim token format 4 | /// Either is an access token (urn:ietf:params:oauth:token-type:jwt) or an OIDC 5 | /// ID token 6 | pub enum Uma2ClaimTokenFormat { 7 | OAuthJwt, // urn:ietf:params:oauth:token-type:jwt 8 | OidcIdToken, // https://openid.net/specs/openid-connect-core-1_0.html#IDToken 9 | } 10 | 11 | impl fmt::Display for Uma2ClaimTokenFormat { 12 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 13 | write!( 14 | f, 15 | "{}", 16 | match *self { 17 | Uma2ClaimTokenFormat::OAuthJwt => "urn:ietf:params:oauth:token-type:jwt", 18 | Uma2ClaimTokenFormat::OidcIdToken => 19 | "https://openid.net/specs/openid-connect-core-1_0.html#IDToken", 20 | } 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/uma2/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use url::Url; 3 | 4 | use crate::Config; 5 | 6 | #[derive(Debug, Deserialize, Serialize)] 7 | pub struct Uma2Config { 8 | // UMA2 additions 9 | #[serde(default)] 10 | pub resource_registration_endpoint: Option, 11 | #[serde(default)] 12 | pub permission_endpoint: Option, 13 | #[serde(default)] 14 | pub policy_endpoint: Option, 15 | #[serde(default)] 16 | pub introspection_endpoint: Option, 17 | 18 | #[serde(flatten)] 19 | pub config: Config, 20 | } 21 | -------------------------------------------------------------------------------- /src/uma2/discovered.rs: -------------------------------------------------------------------------------- 1 | use biscuit::CompactJson; 2 | use url::Url; 3 | 4 | use crate::{ 5 | error::Error, 6 | uma2::{Uma2Config, Uma2Provider}, 7 | Claims, Client, Config, Configurable, Provider, 8 | }; 9 | 10 | pub struct DiscoveredUma2(Uma2Config); 11 | 12 | impl Provider for DiscoveredUma2 { 13 | fn auth_uri(&self) -> &Url { 14 | &self.config().authorization_endpoint 15 | } 16 | 17 | fn token_uri(&self) -> &Url { 18 | &self.config().token_endpoint 19 | } 20 | } 21 | 22 | impl Configurable for DiscoveredUma2 { 23 | fn config(&self) -> &Config { 24 | &self.0.config 25 | } 26 | } 27 | 28 | impl From for DiscoveredUma2 { 29 | fn from(value: Uma2Config) -> Self { 30 | Self(value) 31 | } 32 | } 33 | 34 | impl Uma2Provider for DiscoveredUma2 { 35 | fn uma2_discovered(&self) -> bool { 36 | self.0.resource_registration_endpoint.is_some() 37 | } 38 | 39 | fn resource_registration_uri(&self) -> Option<&Url> { 40 | self.0.resource_registration_endpoint.as_ref() 41 | } 42 | 43 | fn permission_uri(&self) -> Option<&Url> { 44 | self.0.permission_endpoint.as_ref() 45 | } 46 | 47 | fn uma_policy_uri(&self) -> Option<&Url> { 48 | self.0.policy_endpoint.as_ref() 49 | } 50 | } 51 | 52 | impl Client { 53 | /// Constructs a client from an issuer url and client parameters via 54 | /// discovery 55 | pub async fn discover_uma2( 56 | id: String, 57 | secret: String, 58 | redirect: impl Into>, 59 | issuer: Url, 60 | ) -> Result { 61 | let http_client = reqwest::Client::new(); 62 | let uma2_config = discover_uma2(&http_client, &issuer).await?; 63 | let jwks = 64 | crate::discovered::jwks(&http_client, uma2_config.config.jwks_uri.clone()).await?; 65 | 66 | let provider = uma2_config.into(); 67 | 68 | Ok(Self::new( 69 | provider, 70 | id, 71 | secret, 72 | redirect.into(), 73 | http_client, 74 | Some(jwks), 75 | )) 76 | } 77 | } 78 | 79 | pub async fn discover_uma2(client: &reqwest::Client, issuer: &Url) -> Result { 80 | let mut issuer = issuer.clone(); 81 | issuer 82 | .path_segments_mut() 83 | .map_err(|_| Error::CannotBeABase)? 84 | .extend(&[".well-known", "uma2-configuration"]); 85 | let resp = client.get(issuer).send().await?; 86 | resp.json().await.map_err(Error::from) 87 | } 88 | -------------------------------------------------------------------------------- /src/uma2/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub enum Uma2Error { 3 | NoUma2Discovered, 4 | AudienceFieldRequired, 5 | NoResourceSetEndpoint, 6 | NoPermissionsEndpoint, 7 | NoPolicyAssociationEndpoint, 8 | ResourceSetEndpointMalformed, 9 | PolicyAssociationEndpointMalformed, 10 | } 11 | 12 | impl std::error::Error for Uma2Error { 13 | fn description(&self) -> &str { 14 | "UMA2 API error" 15 | } 16 | } 17 | 18 | impl std::fmt::Display for Uma2Error { 19 | fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { 20 | write!( 21 | f, 22 | "{}", 23 | match *self { 24 | Uma2Error::NoUma2Discovered => "No UMA2 discovered", 25 | Uma2Error::AudienceFieldRequired => "Audience field required", 26 | Uma2Error::NoResourceSetEndpoint => "No resource_set endpoint discovered", 27 | Uma2Error::NoPermissionsEndpoint => "No permissions endpoint discovered", 28 | Uma2Error::NoPolicyAssociationEndpoint => 29 | "No permissions policy association endpoint discovered", 30 | Uma2Error::ResourceSetEndpointMalformed => "resource_set endpoint is malformed", 31 | Uma2Error::PolicyAssociationEndpointMalformed => "policy_endpoint is malformed", 32 | } 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/uma2/mod.rs: -------------------------------------------------------------------------------- 1 | mod claim_token_format; 2 | mod config; 3 | mod discovered; 4 | mod error; 5 | mod permission_association; 6 | mod permission_ticket; 7 | mod provider; 8 | mod resource; 9 | mod rpt; 10 | 11 | pub use claim_token_format::Uma2ClaimTokenFormat; 12 | pub use config::Uma2Config; 13 | pub use discovered::{discover_uma2, DiscoveredUma2}; 14 | pub use error::Uma2Error; 15 | pub use permission_association::{ 16 | Uma2PermissionAssociation, Uma2PermissionDecisionStrategy, Uma2PermissionLogic, 17 | }; 18 | pub use permission_ticket::{Uma2PermissionTicketRequest, Uma2PermissionTicketResponse}; 19 | pub use provider::Uma2Provider; 20 | pub use resource::{Uma2Owner, Uma2Resource, Uma2ResourceScope}; 21 | pub use rpt::Uma2AuthenticationMethod; 22 | -------------------------------------------------------------------------------- /src/uma2/permission_association.rs: -------------------------------------------------------------------------------- 1 | use biscuit::CompactJson; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::{ 5 | error::ClientError, 6 | uma2::{error::Uma2Error::*, Uma2Provider}, 7 | Claims, Client, Provider, 8 | }; 9 | 10 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 11 | #[serde(rename_all = "UPPERCASE")] 12 | pub enum Uma2PermissionLogic { 13 | Positive, 14 | Negative, 15 | } 16 | 17 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 18 | #[serde(rename_all = "UPPERCASE")] 19 | pub enum Uma2PermissionDecisionStrategy { 20 | Unanimous, 21 | Affirmative, 22 | Consensus, 23 | } 24 | 25 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 26 | pub struct Uma2PermissionAssociation { 27 | pub id: Option, 28 | pub name: String, 29 | pub description: String, 30 | pub scopes: Vec, 31 | pub roles: Option>, 32 | pub groups: Option>, 33 | pub clients: Option>, 34 | pub owner: Option, 35 | #[serde(rename = "type")] 36 | pub permission_type: Option, 37 | pub logic: Option, 38 | #[serde(rename = "decisionStrategy")] 39 | pub decision_strategy: Option, 40 | } 41 | 42 | impl Client 43 | where 44 | P: Provider + Uma2Provider, 45 | C: CompactJson + Claims, 46 | { 47 | /// Used when permissions can be set to resources by resource servers on 48 | /// behalf of their users 49 | /// 50 | /// # Arguments 51 | /// * `token` This API is protected by a bearer token that must represent 52 | /// a consent granted by the user to the resource server to manage 53 | /// permissions on his behalf. The bearer token can be a regular access 54 | /// token obtained from the token endpoint using: 55 | /// - Resource Owner Password Credentials Grant Type 56 | /// - Token Exchange, in order to exchange an access token granted 57 | /// to some client (public client) for a token where audience is 58 | /// the resource server 59 | /// * `resource_id` Resource ID to be protected 60 | /// * `name` Name for the permission 61 | /// * `description` Description for the permission 62 | /// * `scopes` A list of scopes given on this resource to the user if the 63 | /// permission validates 64 | /// * `roles` Give the permission to users in a list of roles 65 | /// * `groups` Give the permission to users in a list of groups 66 | /// * `clients` Give the permission to users using a specific list of 67 | /// clients 68 | /// * `owner` Give the permission to the owner 69 | /// * `logic` Positive: If the user is in the required groups/roles or using 70 | /// the right client, then give the permission to the user. Negative - the 71 | /// inverse 72 | /// * `decision_strategy` Go through the required conditions. If it is more 73 | /// than one condition, give the permission to the user if the following 74 | /// conditions are met: 75 | /// - Unanimous: The default strategy if none is provided. In this 76 | /// case, all policies must evaluate to a positive decision for 77 | /// the final decision to be also positive. 78 | /// - Affirmative: In this case, at least one policy must evaluate 79 | /// to a positive decision in order for the final decision to be 80 | /// also positive. 81 | /// - Consensus: In this case, the number of positive decisions must 82 | /// be greater than the number of negative decisions. If the 83 | /// number of positive and negative decisions is the same, the 84 | /// final decision will be negative 85 | #[allow(clippy::too_many_arguments)] 86 | pub async fn associate_uma2_resource_with_a_permission( 87 | &self, 88 | token: String, 89 | resource_id: String, 90 | name: String, 91 | description: String, 92 | scopes: Vec, 93 | roles: impl Into>>, 94 | groups: impl Into>>, 95 | clients: impl Into>>, 96 | owner: impl Into>, 97 | logic: impl Into>, 98 | decision_strategy: impl Into>, 99 | ) -> Result { 100 | let url = self.asserted_uma2_policy_url_id(&resource_id)?; 101 | 102 | let permission = Uma2PermissionAssociation { 103 | id: None, 104 | name, 105 | description, 106 | scopes, 107 | roles: roles.into(), 108 | groups: groups.into(), 109 | clients: clients.into(), 110 | owner: owner.into(), 111 | permission_type: None, 112 | logic: logic.into(), 113 | decision_strategy: decision_strategy.into(), 114 | }; 115 | 116 | self.post(url, token, permission).await 117 | } 118 | 119 | /// Update a UMA2 resource's associated permission 120 | /// 121 | /// # Arguments 122 | /// * `id` The ID of the the associated permission 123 | /// * `token` This API is protected by a bearer token that must represent 124 | /// a consent granted by the user to the resource server to manage 125 | /// permissions on his behalf. The bearer token can be a regular access 126 | /// token obtained from the token endpoint using: 127 | /// - Resource Owner Password Credentials Grant Type 128 | /// - Token Exchange, in order to exchange an access token granted 129 | /// to some client (public client) for a token where audience is 130 | /// the resource server 131 | /// * `name` Name for the permission 132 | /// * `description` Description for the permission 133 | /// * `scopes` A list of scopes given on this resource to the user if the 134 | /// permission validates 135 | /// * `roles` Give the permission to users in a list of roles 136 | /// * `groups` Give the permission to users in a list of groups 137 | /// * `clients` Give the permission to users using a specific list of 138 | /// clients 139 | /// * `owner` Give the permission to the owner 140 | /// * `logic` Positive: If the user is in the required groups/roles or using 141 | /// the right client, then give the permission to the user. Negative - the 142 | /// inverse 143 | /// * `decision_strategy` Go through the required conditions. If it is more 144 | /// than one condition, give the permission to the user if the following 145 | /// conditions are met: 146 | /// - Unanimous: The default strategy if none is provided. In this 147 | /// case, all policies must evaluate to a positive decision for 148 | /// the final decision to be also positive. 149 | /// - Affirmative: In this case, at least one policy must evaluate 150 | /// to a positive decision in order for the final decision to be 151 | /// also positive. 152 | /// - Consensus: In this case, the number of positive decisions must 153 | /// be greater than the number of negative decisions. If the 154 | /// number of positive and negative decisions is the same, the 155 | /// final decision will be negative 156 | #[allow(clippy::too_many_arguments)] 157 | pub async fn update_uma2_resource_permission( 158 | &self, 159 | id: String, 160 | token: String, 161 | name: String, 162 | description: String, 163 | scopes: Vec, 164 | roles: impl Into>>, 165 | groups: impl Into>>, 166 | clients: impl Into>>, 167 | owner: impl Into>, 168 | logic: impl Into>, 169 | decision_strategy: impl Into>, 170 | ) -> Result { 171 | let url = self.asserted_uma2_policy_url_id(&id)?; 172 | 173 | let permission = Uma2PermissionAssociation { 174 | id: Some(id), 175 | name, 176 | description, 177 | scopes, 178 | roles: roles.into(), 179 | groups: groups.into(), 180 | clients: clients.into(), 181 | owner: owner.into(), 182 | permission_type: Some("uma".to_string()), 183 | logic: logic.into(), 184 | decision_strategy: decision_strategy.into(), 185 | }; 186 | 187 | self.put(url, token, permission).await 188 | } 189 | 190 | /// Delete a UMA2 resource's permission 191 | /// 192 | /// # Arguments 193 | /// * `id` The ID of the resource permission 194 | /// * `token` This API is protected by a bearer token that must represent 195 | /// a consent granted by the user to the resource server to manage 196 | /// permissions on his behalf. The bearer token can be a regular access 197 | /// token obtained from the token endpoint using: 198 | /// - Resource Owner Password Credentials Grant Type 199 | /// - Token Exchange, in order to exchange an access token granted 200 | /// to some client (public client) for a token where audience is 201 | /// the resource server 202 | pub async fn delete_uma2_resource_permission( 203 | &self, 204 | id: String, 205 | token: String, 206 | ) -> Result<(), ClientError> { 207 | let url = self.asserted_uma2_policy_url_id(&id)?; 208 | 209 | self.delete(url, token).await 210 | } 211 | 212 | /// Search for UMA2 resource associated permissions 213 | /// 214 | /// # Arguments 215 | /// * `token` This API is protected by a bearer token that must represent 216 | /// a consent granted by the user to the resource server to manage 217 | /// permissions on his behalf. The bearer token can be a regular access 218 | /// token obtained from the token endpoint using: 219 | /// - Resource Owner Password Credentials Grant Type 220 | /// - Token Exchange, in order to exchange an access token granted 221 | /// to some client (public client) for a token where audience is 222 | /// the resource server 223 | /// * `resource` Search by resource id 224 | /// * `name` Search by name 225 | /// * `scope` Search by scope 226 | /// * `offset` Skip n amounts of permissions. 227 | /// * `count` Max amount of permissions to return. Should be used especially 228 | /// with large return sets 229 | pub async fn search_for_uma2_resource_permission( 230 | &self, 231 | token: String, 232 | resource: impl Into>, 233 | name: impl Into>, 234 | scope: impl Into>, 235 | offset: impl Into>, 236 | count: impl Into>, 237 | ) -> Result, ClientError> { 238 | let mut url = self.asserted_uma2_policy_url()?; 239 | { 240 | let mut query = url.query_pairs_mut(); 241 | if let Some(resource) = resource.into().as_deref() { 242 | query.append_pair("resource", resource); 243 | } 244 | if let Some(name) = name.into().as_deref() { 245 | query.append_pair("name", name); 246 | } 247 | if let Some(scope) = scope.into().as_deref() { 248 | query.append_pair("scope", scope); 249 | } 250 | if let Some(offset) = offset.into() { 251 | query.append_pair("first", &format!("{offset}")); 252 | } 253 | if let Some(count) = count.into() { 254 | query.append_pair("max", &format!("{count}")); 255 | } 256 | } 257 | 258 | self.get(url, token).await 259 | } 260 | 261 | fn asserted_uma2_policy_url(&self) -> Result { 262 | if !self.provider.uma2_discovered() { 263 | return Err(ClientError::Uma2(NoUma2Discovered)); 264 | } 265 | 266 | self.provider 267 | .uma_policy_uri() 268 | .cloned() 269 | .ok_or(ClientError::Uma2(NoPolicyAssociationEndpoint)) 270 | } 271 | 272 | fn asserted_uma2_policy_url_id(&self, id: &str) -> Result { 273 | let mut url = self.asserted_uma2_policy_url()?; 274 | url.path_segments_mut() 275 | .map_err(|_| ClientError::Uma2(PolicyAssociationEndpointMalformed))? 276 | .extend(&[id]); 277 | Ok(url) 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/uma2/permission_ticket.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 6 | pub struct Uma2PermissionTicketRequest { 7 | pub resource_id: String, 8 | pub resource_scopes: Option>, 9 | pub claims: Option>, 10 | } 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 13 | pub struct Uma2PermissionTicketResponse { 14 | pub ticket: String, 15 | } 16 | -------------------------------------------------------------------------------- /src/uma2/provider.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | pub trait Uma2Provider { 4 | /// Whether UMA2 capabilities have been discovered 5 | fn uma2_discovered(&self) -> bool; 6 | 7 | /// UMA-compliant Resource Registration Endpoint which resource servers can 8 | /// use to manage their protected resources and scopes. This endpoint 9 | /// provides operations create, read, update and delete resources and 10 | /// scopes 11 | fn resource_registration_uri(&self) -> Option<&Url>; 12 | 13 | /// UMA-compliant Permission Endpoint which resource servers can use to 14 | /// manage permission tickets. This endpoint provides operations create, 15 | /// read, update, and delete permission tickets 16 | fn permission_uri(&self) -> Option<&Url>; 17 | 18 | /// API from where permissions can be set to resources by resource servers 19 | /// on behalf of their users. 20 | fn uma_policy_uri(&self) -> Option<&Url>; 21 | } 22 | -------------------------------------------------------------------------------- /src/uma2/resource.rs: -------------------------------------------------------------------------------- 1 | use biscuit::CompactJson; 2 | use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; 3 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 4 | use serde_json::Value; 5 | 6 | use crate::{ 7 | error::ClientError, 8 | uma2::{error::Uma2Error::*, Uma2Provider}, 9 | Claims, Client, OAuth2Error, Provider, 10 | }; 11 | 12 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 13 | pub struct Uma2Resource { 14 | #[serde(rename = "_id")] 15 | pub id: Option, 16 | pub name: String, 17 | #[serde(rename = "type")] 18 | pub resource_type: Option, 19 | pub icon_uri: Option, 20 | pub resource_scopes: Option>, 21 | #[serde(rename = "displayName")] 22 | pub display_name: Option, 23 | pub owner: Option, 24 | #[serde(rename = "ownerManagedAccess")] 25 | pub owner_managed_access: Option, 26 | } 27 | 28 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 29 | pub struct Uma2ResourceScope { 30 | pub id: Option, 31 | pub name: Option, 32 | } 33 | 34 | #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] 35 | pub struct Uma2Owner { 36 | pub id: Option, 37 | pub name: Option, 38 | } 39 | 40 | impl Client 41 | where 42 | P: Provider + Uma2Provider, 43 | C: CompactJson + Claims, 44 | { 45 | /// 46 | /// Create a UMA2 managed resource 47 | /// 48 | /// # Arguments 49 | /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but 50 | /// should have the 51 | /// uma_protection scope defined 52 | /// * `name` User readable name for this resource. 53 | /// * `resource_type` The type of resource. Helps to categorise resources 54 | /// * `icon_uri` User visible icon's URL 55 | /// * `resource_scopes` A list of scopes attached to this resource 56 | /// * `description` A readable description 57 | /// * `owner` Resource server is the default user, unless this value is set. 58 | /// Can be the username 59 | /// of the user or its server identifier 60 | /// * `owner_managed_access` Whether to allow user managed access of this 61 | /// resource 62 | #[allow(clippy::too_many_arguments)] 63 | pub async fn create_uma2_resource( 64 | &self, 65 | pat_token: String, 66 | name: String, 67 | resource_type: impl Into>, 68 | icon_uri: impl Into>, 69 | resource_scopes: impl Into>>, 70 | display_name: impl Into>, 71 | owner: impl Into>, 72 | owner_managed_access: impl Into>, 73 | ) -> Result { 74 | let url = self.asserted_uma2_resource_url()?; 75 | 76 | let resource_scopes = resource_scopes.into().map(|names| { 77 | names 78 | .iter() 79 | .map(|name| Uma2ResourceScope { 80 | name: Some(name.clone()), 81 | id: None, 82 | }) 83 | .collect() 84 | }); 85 | 86 | let body = Uma2Resource { 87 | id: None, 88 | name, 89 | resource_type: resource_type.into(), 90 | icon_uri: icon_uri.into(), 91 | resource_scopes, 92 | display_name: display_name.into(), 93 | owner: owner.into(), 94 | owner_managed_access: owner_managed_access.into(), 95 | }; 96 | 97 | self.post(url, pat_token, body).await 98 | } 99 | 100 | /// 101 | /// Update a UMA2 managed resource 102 | /// 103 | /// # Arguments 104 | /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but 105 | /// should have the 106 | /// uma_protection scope defined 107 | /// * `name` User readable name for this resource. 108 | /// * `resource_type` The type of resource. Helps to categorise resources 109 | /// * `icon_uri` User visible icon's URL 110 | /// * `resource_scopes` A list of scopes attached to this resource 111 | /// * `description` A readable description 112 | /// * `owner` Resource server is the default user, unless this value is set. 113 | /// Can be the username 114 | /// of the user or its server identifier 115 | /// * `owner_managed_access` Whether to allow user managed access of this 116 | /// resource 117 | #[allow(clippy::too_many_arguments)] 118 | pub async fn update_uma2_resource( 119 | &self, 120 | pat_token: String, 121 | name: String, 122 | resource_type: Option, 123 | icon_uri: Option, 124 | resource_scopes: Option>, 125 | display_name: Option, 126 | owner: Option, 127 | owner_managed_access: Option, 128 | ) -> Result { 129 | let url = self.asserted_uma2_resource_url()?; 130 | 131 | let resource_scopes = resource_scopes.map(|names| { 132 | names 133 | .iter() 134 | .map(|name| Uma2ResourceScope { 135 | name: Some(name.clone()), 136 | id: None, 137 | }) 138 | .collect() 139 | }); 140 | 141 | let body = Uma2Resource { 142 | id: None, 143 | name, 144 | resource_type, 145 | icon_uri, 146 | resource_scopes, 147 | display_name, 148 | owner, 149 | owner_managed_access, 150 | }; 151 | 152 | self.put(url, pat_token, body).await 153 | } 154 | 155 | /// Deletes a UMA2 managed resource 156 | /// 157 | /// # Arguments 158 | /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but 159 | /// should have the 160 | /// * `id` The server identifier of the resource 161 | pub async fn delete_uma2_resource( 162 | &self, 163 | pat_token: String, 164 | id: String, 165 | ) -> Result<(), ClientError> { 166 | let url = self.asserted_uma2_resource_url_id(&id)?; 167 | 168 | self.delete(url, pat_token).await 169 | } 170 | 171 | /// Get a UMA2 managed resource by its identifier 172 | /// 173 | /// # Arguments 174 | /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but 175 | /// should have the 176 | /// * `id` The server identifier of the resource 177 | pub async fn get_uma2_resource_by_id( 178 | &self, 179 | pat_token: String, 180 | id: String, 181 | ) -> Result { 182 | let url = self.asserted_uma2_resource_url_id(&id)?; 183 | 184 | self.get(url, pat_token).await 185 | } 186 | 187 | /// 188 | /// Search for a UMA2 resource 189 | /// 190 | /// # Arguments 191 | /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but 192 | /// should have the 193 | /// * `name` Search by the resource's name 194 | /// * `uri` Search by the resource's uri 195 | /// * `owner` Search by the resource's owner 196 | /// * `resource_type` Search by the resource's type 197 | /// * `scope` Search by the resource's scope 198 | pub async fn search_for_uma2_resources( 199 | &self, 200 | pat_token: String, 201 | name: impl Into>, 202 | uri: impl Into>, 203 | owner: impl Into>, 204 | resource_type: impl Into>, 205 | scope: impl Into>, 206 | ) -> Result, ClientError> { 207 | let mut url = self.asserted_uma2_resource_url()?; 208 | { 209 | let mut query = url.query_pairs_mut(); 210 | if let Some(name) = name.into().as_deref() { 211 | query.append_pair("name", name); 212 | } 213 | if let Some(uri) = uri.into().as_deref() { 214 | query.append_pair("uri", uri); 215 | } 216 | if let Some(owner) = owner.into().as_deref() { 217 | query.append_pair("owner", owner); 218 | } 219 | if let Some(resource_type) = resource_type.into().as_deref() { 220 | query.append_pair("type", resource_type); 221 | } 222 | if let Some(scope) = scope.into().as_deref() { 223 | query.append_pair("scope", scope); 224 | } 225 | } 226 | 227 | self.get(url, pat_token).await 228 | } 229 | 230 | pub(crate) async fn post( 231 | &self, 232 | url: url::Url, 233 | token: String, 234 | body: B, 235 | ) -> Result 236 | where 237 | T: DeserializeOwned, 238 | B: Serialize, 239 | { 240 | self.request(url, token, reqwest::Method::POST, body).await 241 | } 242 | 243 | pub(crate) async fn put( 244 | &self, 245 | url: url::Url, 246 | token: String, 247 | body: B, 248 | ) -> Result 249 | where 250 | T: DeserializeOwned, 251 | B: Serialize, 252 | { 253 | self.request(url, token, reqwest::Method::PUT, body).await 254 | } 255 | 256 | pub(crate) async fn get( 257 | &self, 258 | url: url::Url, 259 | token: String, 260 | ) -> Result { 261 | let json = self 262 | .http_client 263 | .get(url) 264 | .header(CONTENT_TYPE, "application/json") 265 | .header(AUTHORIZATION, format!("Bearer {:}", token)) 266 | .header(ACCEPT, "application/json") 267 | .send() 268 | .await? 269 | .json() 270 | .await?; 271 | 272 | self.json_to_oauth2_result(json) 273 | } 274 | 275 | pub(crate) async fn delete(&self, url: url::Url, token: String) -> Result<(), ClientError> { 276 | let json = self 277 | .http_client 278 | .delete(url) 279 | .header(AUTHORIZATION, format!("Bearer {:}", token)) 280 | .send() 281 | .await? 282 | .json() 283 | .await?; 284 | 285 | let error: Result = serde_json::from_value(json); 286 | 287 | if let Ok(error) = error { 288 | Err(ClientError::from(error)) 289 | } else { 290 | Ok(()) 291 | } 292 | } 293 | 294 | async fn request( 295 | &self, 296 | url: url::Url, 297 | token: String, 298 | method: reqwest::Method, 299 | body: B, 300 | ) -> Result 301 | where 302 | T: DeserializeOwned, 303 | B: Serialize, 304 | { 305 | let json = self 306 | .http_client 307 | .request(method, url) 308 | .header(CONTENT_TYPE, "application/json") 309 | .header(AUTHORIZATION, format!("Bearer {:}", token)) 310 | .header(ACCEPT, "application/json") 311 | .json(&body) 312 | .send() 313 | .await? 314 | .json() 315 | .await?; 316 | 317 | self.json_to_oauth2_result(json) 318 | } 319 | 320 | fn json_to_oauth2_result(&self, json: Value) -> Result { 321 | let error: Result = serde_json::from_value(json.clone()); 322 | 323 | if let Ok(error) = error { 324 | Err(ClientError::from(error)) 325 | } else { 326 | Ok(serde_json::from_value(json)?) 327 | } 328 | } 329 | 330 | fn asserted_uma2_resource_url(&self) -> Result { 331 | if !self.provider.uma2_discovered() { 332 | return Err(ClientError::Uma2(NoUma2Discovered)); 333 | } 334 | 335 | self.provider 336 | .resource_registration_uri() 337 | .cloned() 338 | .ok_or(ClientError::Uma2(NoResourceSetEndpoint)) 339 | } 340 | 341 | fn asserted_uma2_resource_url_id(&self, id: &str) -> Result { 342 | let mut url = self.asserted_uma2_resource_url()?; 343 | 344 | url.path_segments_mut() 345 | .map_err(|_| ClientError::Uma2(ResourceSetEndpointMalformed))? 346 | .extend(&[id]); 347 | 348 | Ok(url) 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/uma2/rpt.rs: -------------------------------------------------------------------------------- 1 | use biscuit::CompactJson; 2 | use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; 3 | use serde_json::Value; 4 | use url::form_urlencoded::Serializer; 5 | 6 | use crate::{ 7 | error::ClientError, 8 | uma2::{error::Uma2Error::*, permission_ticket::Uma2PermissionTicketRequest, *}, 9 | Bearer, Claims, Client, OAuth2Error, Provider, 10 | }; 11 | 12 | pub enum Uma2AuthenticationMethod { 13 | Bearer, 14 | Basic, 15 | } 16 | 17 | impl Client 18 | where 19 | P: Provider + Uma2Provider, 20 | C: CompactJson + Claims, 21 | { 22 | /// 23 | /// Obtain an RPT from a UMA2 compliant OIDC server 24 | /// 25 | /// # Arguments 26 | /// * `token` Bearer token to do the RPT call 27 | /// * `ticket` The most recent permission ticket received by the client as 28 | /// part of the UMA authorization process 29 | /// * `claim_token` A string representing additional claims that should be 30 | /// considered by the server when evaluating permissions for the 31 | /// resource(s) and scope(s) being requested. 32 | /// * `claim_token_format` urn:ietf:params:oauth:token-type:jwt or https://openid.net/specs/openid-connect-core-1_0.html#IDToken 33 | /// * `rpt` A previously issued RPT which permissions should also be 34 | /// evaluated and added in a new one. This parameter allows clients in 35 | /// possession of an RPT to perform incremental authorization where 36 | /// permissions are added on demand. 37 | /// * `permission` String representing a set of one or more resources and 38 | /// scopes the client is seeking access. This parameter can be defined 39 | /// multiple times in order to request permission for multiple resource 40 | /// and scopes. This parameter is an extension to 41 | /// urn:ietf:params:oauth:grant-type:uma-ticket grant type in order to 42 | /// allow clients to send authorization requests without a permission 43 | /// ticket 44 | /// * `audience` The client identifier of the resource server to which the 45 | /// client is seeking 46 | /// access. This parameter is mandatory in case the permission parameter is 47 | /// defined 48 | /// * `response_include_resource_name` A boolean value indicating to the 49 | /// server whether resource names should be included in the RPT’s 50 | /// permissions. If false, only the resource identifier is included 51 | /// * `response_permissions_limit` An integer N that defines a limit for the 52 | /// amount of permissions an RPT can have. When used together with rpt 53 | /// parameter, only the last N requested permissions will be kept in the 54 | /// RPT. 55 | /// * `submit_request` A boolean value indicating whether the server should 56 | /// create permission requests to the resources and scopes referenced by a 57 | /// permission ticket. This parameter only have effect if used together 58 | /// with the ticket parameter as part of a UMA authorization process 59 | #[allow(clippy::too_many_arguments)] 60 | pub async fn obtain_requesting_party_token( 61 | &self, 62 | token: String, 63 | auth_method: Uma2AuthenticationMethod, 64 | ticket: impl Into>, 65 | claim_token: impl Into>, 66 | claim_token_format: impl Into>, 67 | rpt: impl Into>, 68 | permission: impl Into>>, 69 | audience: impl Into>, 70 | response_include_resource_name: impl Into>, 71 | response_permissions_limit: impl Into>, 72 | submit_request: impl Into>, 73 | ) -> Result { 74 | if !self.provider.uma2_discovered() { 75 | return Err(ClientError::Uma2(NoUma2Discovered)); 76 | } 77 | 78 | let permission = permission.into(); 79 | let audience = audience.into(); 80 | if let Some(p) = permission.as_ref() { 81 | if p.is_empty() && audience.is_none() { 82 | return Err(ClientError::Uma2(AudienceFieldRequired)); 83 | } 84 | } 85 | 86 | let body = { 87 | let mut body = Serializer::new(String::new()); 88 | body.append_pair("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket"); 89 | if let Some(ticket) = ticket.into() { 90 | body.append_pair("ticket", &ticket); 91 | } 92 | 93 | if let Some(claim_token) = claim_token.into() { 94 | body.append_pair("claim_token", &claim_token); 95 | } 96 | 97 | if let Some(claim_token_format) = claim_token_format.into() { 98 | body.append_pair( 99 | "claim_token_format", 100 | claim_token_format.to_string().as_str(), 101 | ); 102 | } 103 | 104 | if let Some(rpt) = rpt.into() { 105 | body.append_pair("rpt", &rpt); 106 | } 107 | 108 | if let Some(permission) = permission { 109 | permission.iter().for_each(|perm| { 110 | body.append_pair("permission", perm.as_str()); 111 | }); 112 | } 113 | 114 | if let Some(audience) = audience { 115 | body.append_pair("audience", &audience); 116 | } 117 | 118 | if let Some(response_include_resource_name) = response_include_resource_name.into() { 119 | body.append_pair( 120 | "response_include_resource_name", 121 | if response_include_resource_name { 122 | "true" 123 | } else { 124 | "false" 125 | }, 126 | ); 127 | } 128 | if let Some(response_permissions_limit) = response_permissions_limit.into() { 129 | body.append_pair( 130 | "response_permissions_limit", 131 | format!("{:}", response_permissions_limit).as_str(), 132 | ); 133 | } 134 | 135 | if let Some(submit_request) = submit_request.into() { 136 | body.append_pair("submit_request", format!("{:}", submit_request).as_str()); 137 | } 138 | 139 | body.finish() 140 | }; 141 | let auth_method = match auth_method { 142 | Uma2AuthenticationMethod::Basic => format!("Basic {:}", token), 143 | Uma2AuthenticationMethod::Bearer => format!("Bearer {:}", token), 144 | }; 145 | 146 | let json = self 147 | .http_client 148 | .post(self.provider.token_uri().clone()) 149 | .header(CONTENT_TYPE, "application/x-www-form-urlencoded") 150 | .header(AUTHORIZATION, auth_method.as_str()) 151 | .body(body) 152 | .send() 153 | .await? 154 | .json::() 155 | .await?; 156 | 157 | let error: Result = serde_json::from_value(json.clone()); 158 | 159 | if let Ok(error) = error { 160 | Err(ClientError::from(error)) 161 | } else { 162 | let new_token: Bearer = serde_json::from_value(json)?; 163 | Ok(new_token.access_token) 164 | } 165 | } 166 | 167 | /// 168 | /// Create a permission ticket. 169 | /// A permission ticket is a special security token type representing a 170 | /// permission request. Per the UMA specification, a permission ticket 171 | /// is: A correlation handle that is conveyed from an authorization 172 | /// server to a resource server, from a resource server to a client, and 173 | /// ultimately from a client back to an authorization server, to enable 174 | /// the authorization server to assess the correct policies to apply to a 175 | /// request for authorization data. 176 | /// 177 | /// # Arguments 178 | /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but 179 | /// should have the 180 | /// * `requests` A list of resources, optionally with their scopes, 181 | /// optionally with extra claims to be processed. 182 | pub async fn create_uma2_permission_ticket( 183 | &self, 184 | pat_token: String, 185 | requests: Vec, 186 | ) -> Result { 187 | if !self.provider.uma2_discovered() { 188 | return Err(ClientError::Uma2(NoUma2Discovered)); 189 | } 190 | 191 | let Some(url) = self.provider.permission_uri().cloned() else { 192 | return Err(ClientError::Uma2(NoPermissionsEndpoint)); 193 | }; 194 | 195 | let json = self 196 | .http_client 197 | .post(url) 198 | .header(CONTENT_TYPE, "application/json") 199 | .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) 200 | .json(&requests) 201 | .send() 202 | .await? 203 | .json::() 204 | .await?; 205 | 206 | let error: Result = serde_json::from_value(json.clone()); 207 | 208 | if let Ok(error) = error { 209 | Err(ClientError::from(error)) 210 | } else { 211 | let response = serde_json::from_value(json)?; 212 | Ok(response) 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/userinfo.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDate; 2 | use serde::{Deserialize, Serialize}; 3 | use url::Url; 4 | use validator::Validate; 5 | 6 | use crate::{deserializers::bool_from_str_or_bool, Address, StandardClaimsSubject}; 7 | 8 | /// The userinfo struct contains all possible userinfo fields regardless of 9 | /// scope. 10 | /// 11 | /// See: [OpenID Connect Core 1.0: Standard Claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) 12 | #[derive(Debug, Deserialize, Serialize, Validate, Clone, Eq, PartialEq)] 13 | pub struct Userinfo { 14 | #[serde(default)] 15 | /// Subject - Identifier for the End-User at the Issuer. 16 | pub sub: Option, 17 | #[serde(default)] 18 | /// End-User's full name in displayable form including all name parts, 19 | /// possibly including titles and suffixes, ordered according to the 20 | /// End-User's locale and preferences. 21 | pub name: Option, 22 | #[serde(default)] 23 | /// Given name(s) or first name(s) of the End-User. Note that in some 24 | /// cultures, people can have multiple given names; all can be present, with 25 | /// the names being separated by space characters. 26 | pub given_name: Option, 27 | #[serde(default)] 28 | /// Surname(s) or last name(s) of the End-User. Note that in some cultures, 29 | /// people can have multiple family names or no family name; all can be 30 | /// present, with the names being separated by space characters. 31 | pub family_name: Option, 32 | #[serde(default)] 33 | /// Middle name(s) of the End-User. Note that in some cultures, people can 34 | /// have multiple middle names; all can be present, with the names being 35 | /// separated by space characters. Also note that in some cultures, middle 36 | /// names are not used. 37 | pub middle_name: Option, 38 | #[serde(default)] 39 | /// Casual name of the End-User that may or may not be the same as the 40 | /// given_name. For instance, a nickname value of Mike might be returned 41 | /// alongside a given_name value of Michael. 42 | pub nickname: Option, 43 | #[serde(default)] 44 | /// Shorthand name by which the End-User wishes to be referred to at the RP, 45 | /// such as janedoe or j.doe. This value MAY be any valid JSON string 46 | /// including special characters such as @, /, or whitespace. The RP MUST 47 | /// NOT rely upon this value being unique, as discussed in Section 5.7. 48 | pub preferred_username: Option, 49 | #[serde(default)] 50 | /// URL of the End-User's profile page. The contents of this Web page SHOULD 51 | /// be about the End-User. 52 | pub profile: Option, 53 | #[serde(default)] 54 | /// URL of the End-User's profile picture. This URL MUST refer to an image 55 | /// file (for example, a PNG, JPEG, or GIF image file), rather than to a Web 56 | /// page containing an image. Note that this URL SHOULD specifically 57 | /// reference a profile photo of the End-User suitable for displaying when 58 | /// describing the End-User, rather than an arbitrary photo taken by the 59 | /// End-User. 60 | pub picture: Option, 61 | #[serde(default)] 62 | /// URL of the End-User's Web page or blog. This Web page SHOULD contain 63 | /// information published by the End-User or an organization that the 64 | /// End-User is affiliated with. 65 | pub website: Option, 66 | #[serde(default)] 67 | #[validate(email)] 68 | /// End-User's preferred e-mail address. Its value MUST conform to the RFC 69 | /// 5322 [RFC5322] addr-spec syntax. The RP MUST NOT rely upon this value 70 | /// being unique, as discussed in Section 5.7. 71 | pub email: Option, 72 | #[serde(default, deserialize_with = "bool_from_str_or_bool")] 73 | /// True if the End-User's e-mail address has been verified; otherwise 74 | /// false. When this Claim Value is true, this means that the OP took 75 | /// affirmative steps to ensure that this e-mail address was controlled by 76 | /// the End-User at the time the verification was performed. The means by 77 | /// which an e-mail address is verified is context-specific, and dependent 78 | /// upon the trust framework or contractual agreements within which the 79 | /// parties are operating. 80 | pub email_verified: bool, 81 | // Isn't required to be just male or female 82 | #[serde(default)] 83 | /// End-User's gender. Values defined by this specification are female and 84 | /// male. Other values MAY be used when neither of the defined values are 85 | /// applicable. 86 | pub gender: Option, 87 | // ISO 9601:2004 YYYY-MM-DD or YYYY. 88 | #[serde(default)] 89 | /// End-User's birthday, represented as an ISO 8601:2004 [ISO8601‑2004] 90 | /// YYYY-MM-DD format. The year MAY be 0000, indicating that it is omitted. 91 | /// To represent only the year, YYYY format is allowed. Note that depending 92 | /// on the underlying platform's date related function, providing just year 93 | /// can result in varying month and day, so the implementers need to take 94 | /// this factor into account to correctly process the dates. 95 | pub birthdate: Option, 96 | // Region/City codes. Should also have a more concrete serializer form. 97 | /// String from zoneinfo [zoneinfo] time zone database representing the 98 | /// End-User's time zone. For example, Europe/Paris or America/Los_Angeles. 99 | #[serde(default)] 100 | pub zoneinfo: Option, 101 | // Usually RFC5646 langcode-countrycode, maybe with a _ sep, could be arbitrary 102 | #[serde(default)] 103 | /// End-User's locale, represented as a BCP47 [RFC5646] language tag. This 104 | /// is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase 105 | /// and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, 106 | /// separated by a dash. For example, en-US or fr-CA. As a compatibility 107 | /// note, some implementations have used an underscore as the separator 108 | /// rather than a dash, for example, en_US; Relying Parties MAY choose to 109 | /// accept this locale syntax as well. 110 | pub locale: Option, 111 | // Usually E.164 format number 112 | #[serde(default)] 113 | /// End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as 114 | /// the format of this Claim, for example, +1 (425) 555-1212 or +56 (2) 687 115 | /// 2400. If the phone number contains an extension, it is RECOMMENDED that 116 | /// the extension be represented using the RFC 3966 [RFC3966] extension 117 | /// syntax, for example, +1 (604) 555-1234;ext=5678. 118 | pub phone_number: Option, 119 | #[serde(default, deserialize_with = "bool_from_str_or_bool")] 120 | /// True if the End-User's phone number has been verified; otherwise false. 121 | /// When this Claim Value is true, this means that the OP took affirmative 122 | /// steps to ensure that this phone number was controlled by the End-User at 123 | /// the time the verification was performed. The means by which a phone 124 | /// number is verified is context-specific, and dependent upon the trust 125 | /// framework or contractual agreements within which the parties are 126 | /// operating. When true, the phone_number Claim MUST be in E.164 format and 127 | /// any extensions MUST be represented in RFC 3966 format. 128 | pub phone_number_verified: bool, 129 | #[serde(default)] 130 | /// End-User's preferred postal address. The value of the address member is 131 | /// a JSON [RFC4627] structure containing some or all of the members defined 132 | /// in Section 5.1.1. 133 | pub address: Option

, 134 | #[serde(default)] 135 | /// Time the End-User's information was last updated. Its value is a JSON 136 | /// number representing the number of seconds from 1970-01-01T0:0:0Z as 137 | /// measured in UTC until the date/time. 138 | pub updated_at: Option, 139 | } 140 | 141 | impl StandardClaimsSubject for Userinfo { 142 | fn sub(&self) -> Result<&str, crate::error::StandardClaimsSubjectMissing> { 143 | self.sub 144 | .as_deref() 145 | .ok_or(crate::error::StandardClaimsSubjectMissing) 146 | } 147 | } 148 | 149 | impl biscuit::CompactJson for Userinfo {} 150 | -------------------------------------------------------------------------------- /src/validation.rs: -------------------------------------------------------------------------------- 1 | use biscuit::SingleOrMultiple; 2 | use chrono::{DateTime, Duration, Utc}; 3 | 4 | use crate::{ 5 | error::{Error, Expiry, Mismatch, Missing, Validation}, 6 | Claims, Config, 7 | }; 8 | 9 | /// Validate token issuer. 10 | pub fn validate_token_issuer(claims: &C, config: &Config) -> Result<(), Error> { 11 | if claims.iss() != &config.issuer { 12 | let expected = config.issuer.as_str().to_string(); 13 | let actual = claims.iss().as_str().to_string(); 14 | return Err(Validation::Mismatch(Mismatch::Issuer { expected, actual }).into()); 15 | } 16 | 17 | Ok(()) 18 | } 19 | 20 | /// Validate token nonce. 21 | pub fn validate_token_nonce<'nonce, C: Claims>( 22 | claims: &C, 23 | nonce: impl Into>, 24 | ) -> Result<(), Error> { 25 | if let Some(expected) = nonce.into() { 26 | match claims.nonce() { 27 | Some(actual) => { 28 | if expected != actual { 29 | let expected = expected.to_string(); 30 | let actual = actual.to_string(); 31 | return Err(Validation::Mismatch(Mismatch::Nonce { expected, actual }).into()); 32 | } 33 | } 34 | None => return Err(Validation::Missing(Missing::Nonce).into()), 35 | } 36 | } 37 | 38 | Ok(()) 39 | } 40 | 41 | /// Validate token aud. 42 | pub fn validate_token_aud(claims: &C, client_id: &str) -> Result<(), Error> { 43 | if !claims.aud().contains(client_id) { 44 | return Err(Validation::Missing(Missing::Audience).into()); 45 | } 46 | // By spec, if there are multiple auds, we must have an azp 47 | if let SingleOrMultiple::Multiple(_) = claims.aud() { 48 | if claims.azp().is_none() { 49 | return Err(Validation::Missing(Missing::AuthorizedParty).into()); 50 | } 51 | } 52 | // If there is an authorized party, it must be our client_id 53 | if let Some(actual) = claims.azp() { 54 | if actual != client_id { 55 | let expected = client_id.to_string(); 56 | let actual = actual.to_string(); 57 | return Err( 58 | Validation::Mismatch(Mismatch::AuthorizedParty { expected, actual }).into(), 59 | ); 60 | } 61 | } 62 | 63 | Ok(()) 64 | } 65 | 66 | /// Validate token expiration against current time. 67 | pub fn validate_token_exp<'max_age, C: Claims>( 68 | claims: &C, 69 | max_age: impl Into>, 70 | ) -> Result<(), Error> { 71 | let now = Utc::now(); 72 | let exp = claims.exp(); 73 | if exp <= now.timestamp() { 74 | return Err(Validation::Expired( 75 | DateTime::from_timestamp(exp, 0) 76 | .map(Expiry::Expires) 77 | .unwrap_or_else(|| Expiry::NotUnix(exp)), 78 | ) 79 | .into()); 80 | } 81 | 82 | if let Some(max) = max_age.into() { 83 | match claims.auth_time() { 84 | Some(time) => { 85 | let age = Duration::seconds(now.timestamp() - time); 86 | if age >= *max { 87 | return Err(Validation::Expired(Expiry::MaxAge(age)).into()); 88 | } 89 | } 90 | None => return Err(Validation::Missing(Missing::AuthTime).into()), 91 | } 92 | } 93 | 94 | Ok(()) 95 | } 96 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | # OpenID Connect & Discovery client library using async / await 2 | 3 | ## Legal 4 | 5 | Dual-licensed under `MIT` or the [UNLICENSE](http://unlicense.org/). 6 | 7 | ## Features 8 | 9 | Implements [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) and [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html). 10 | 11 | Implements [UMA2](https://docs.kantarainitiative.org/uma/wg/oauth-uma-federated-authz-2.0-09.html) - User Managed Access, an extension to OIDC/OAuth2. Use feature flag `uma2` to enable this feature. 12 | 13 | Implements [OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662). 14 | 15 | It supports Microsoft OIDC with feature `microsoft`. This adds methods for authentication and token validation, those skip issuer check. 16 | 17 | Originally developed as a quick adaptation to leverage async/await functionality, based on [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc), the library has since evolved into a mature and robust solution, offering expanded features and improved performance. 18 | 19 | Using [reqwest](https://crates.io/crates/reqwest) for the HTTP client and [biscuit](https://crates.io/crates/biscuit) for Javascript Object Signing and Encryption (JOSE). 20 | 21 | ## Support: 22 | 23 | You can contribute to the ongoing development and maintenance of OpenID library in various ways: 24 | 25 | ### Sponsorship 26 | 27 | Your support, no matter how big or small, helps sustain the project and ensures its continued improvement. Reach out to explore sponsorship opportunities. 28 | 29 | ### Feedback 30 | 31 | Whether you are a developer, user, or enthusiast, your feedback is invaluable. Share your thoughts, suggestions, and ideas to help shape the future of the library. 32 | 33 | ### Contribution 34 | 35 | If you're passionate about open-source and have skills to share, consider contributing to the project. Every contribution counts! 36 | 37 | Thank you for being part of OpenID community. Together, we are making authentication processes more accessible, reliable, and efficient for everyone. 38 | 39 | ## Usage 40 | 41 | Add dependency to Cargo.toml: 42 | 43 | ```toml 44 | [dependencies] 45 | openid = "{{ env_var "OPENID_RUST_MAJOR_VERSION" }}" 46 | ``` 47 | 48 | By default we use native tls, if you want to use `rustls`: 49 | 50 | ```toml 51 | [dependencies] 52 | openid = { version = "{{ env_var "OPENID_RUST_MAJOR_VERSION" }}", default-features = false, features = ["rustls"] } 53 | ``` 54 | 55 | Alternatively, you can use `rustls` with the platform’s native certificates: 56 | 57 | ```toml 58 | [dependencies] 59 | openid = { version = "{{ env_var "OPENID_RUST_MAJOR_VERSION" }}", default-features = false, features = ["rustls-native-roots"] } 60 | ``` 61 | 62 | ### Use case: [Warp](https://crates.io/crates/warp) web server with [JHipster](https://www.jhipster.tech/) generated frontend and [Google OpenID Connect](https://developers.google.com/identity/protocols/OpenIDConnect) 63 | 64 | This example provides only Rust part, assuming just default JHipster frontend settings. 65 | 66 | in Cargo.toml: 67 | 68 | {{ codeblock "toml" ( to "[patch.crates-io]" ( from "[dependencies]" ( http_get (replace "https://raw.githubusercontent.com/kilork/openid-examples/vVERSION/Cargo.toml" "VERSION" (env_var "OPENID_RUST_MAJOR_VERSION") ) ) ) ) }} 69 | 70 | in src/main.rs: 71 | 72 | {{ codeblock "rust, compile_fail" ( http_get (replace "https://raw.githubusercontent.com/kilork/openid-examples/vVERSION/examples/warp.rs" "VERSION" (env_var "OPENID_RUST_MAJOR_VERSION") ) ) }} 73 | 74 | See full example: [openid-examples: warp](https://github.com/kilork/openid-examples/blob/v{{ env_var "OPENID_RUST_MAJOR_VERSION" }}/examples/warp.rs) 75 | --------------------------------------------------------------------------------