Git Product home page Git Product logo

openid's Introduction

OpenID Connect & Discovery client library using async / await

Legal

Dual-licensed under MIT or the UNLICENSE.

Features

Implements OpenID Connect Core 1.0 and OpenID Connect Discovery 1.0.

Implements UMA2 - User Managed Access, an extension to OIDC/OAuth2. Use feature flag uma2 to enable this feature.

It supports Microsoft OIDC with feature microsoft. This adds methods for authentication and token validation, those skip issuer check.

Originally developed as a quick adaptation to leverage async/await functionality, based on inth-oauth2 and oidc, the library has since evolved into a mature and robust solution, offering expanded features and improved performance.

Using reqwest for the HTTP client and biscuit for Javascript Object Signing and Encryption (JOSE).

Support:

You can contribute to the ongoing development and maintenance of OpenID library in various ways:

Sponsorship

Your support, no matter how big or small, helps sustain the project and ensures its continued improvement. Reach out to explore sponsorship opportunities.

Feedback

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.

Contribution

If you're passionate about open-source and have skills to share, consider contributing to the project. Every contribution counts!

Thank you for being part of OpenID community. Together, we are making authentication processes more accessible, reliable, and efficient for everyone.

Usage

Add dependency to Cargo.toml:

[dependencies]
openid = "0.14"

By default we use native tls, if you want to use rustls:

[dependencies]
openid = { version = "0.14", default-features = false, features = ["rustls"] }

Use case: Warp web server with JHipster generated frontend and Google OpenID Connect

This example provides only Rust part, assuming just default JHipster frontend settings.

in Cargo.toml:

[dependencies]
anyhow = "1.0"
cookie = "0.18"
dotenv = "0.15"
log = "0.4"
openid = "0.14"
pretty_env_logger = "0.5"
reqwest = "0.12"
serde = { version = "1", default-features = false, features = [ "derive" ] }
serde_json = "1"
tokio = { version = "1", default-features = false, features = [ "rt-multi-thread", "macros" ] }
uuid = { version = "1.0", default-features = false, features = [ "v4" ] }
warp = { version = "0.3", default-features = false }

in src/main.rs:

use std::{convert::Infallible, env, net::SocketAddr, sync::Arc};

use cookie::time::Duration;
use log::{error, info};
use openid::{Client, Discovered, DiscoveredClient, Options, StandardClaims, Token, Userinfo};
use openid_examples::{
    entity::{LoginQuery, Sessions, User},
    INDEX_HTML,
};
use tokio::sync::RwLock;
use warp::{
    http::{Response, StatusCode},
    reject, Filter, Rejection, Reply,
};

type OpenIDClient = Client<Discovered, StandardClaims>;

const EXAMPLE_COOKIE: &str = "openid_warp_example";

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv::dotenv().ok();

    pretty_env_logger::init();

    let client_id = env::var("CLIENT_ID").expect("<client id> for your provider");
    let client_secret = env::var("CLIENT_SECRET").ok();
    let issuer_url =
        env::var("ISSUER").unwrap_or_else(|_| "https://accounts.google.com".to_string());
    let redirect = Some(host("/login/oauth2/code/oidc"));
    let issuer = reqwest::Url::parse(&issuer_url)?;
    let listen: SocketAddr = env::var("LISTEN")
        .unwrap_or_else(|_| "127.0.0.1:8080".to_string())
        .parse()?;

    info!("redirect: {:?}", redirect);
    info!("issuer: {}", issuer);

    let client = Arc::new(
        DiscoveredClient::discover(
            client_id,
            client_secret.unwrap_or_default(),
            redirect,
            issuer,
        )
        .await?,
    );

    info!("discovered config: {:?}", client.config());

    let with_client = |client: Arc<Client<_>>| warp::any().map(move || client.clone());

    let sessions = Arc::new(RwLock::new(Sessions::default()));

    let with_sessions = |sessions: Arc<RwLock<Sessions>>| warp::any().map(move || sessions.clone());

    let index = warp::path::end()
        .and(warp::get())
        .map(|| warp::reply::html(INDEX_HTML));

    let authorize = warp::path!("oauth2" / "authorization" / "oidc")
        .and(warp::get())
        .and(with_client(client.clone()))
        .and_then(reply_authorize);

    let login = warp::path!("login" / "oauth2" / "code" / "oidc")
        .and(warp::get())
        .and(with_client(client.clone()))
        .and(warp::query::<LoginQuery>())
        .and(with_sessions(sessions.clone()))
        .and_then(reply_login);

    let logout = warp::path!("logout")
        .and(warp::get())
        .and(with_client(client.clone()))
        .and(warp::cookie::optional(EXAMPLE_COOKIE))
        .and(with_sessions(sessions.clone()))
        .and_then(reply_logout);

    let api_account = warp::path!("api" / "account")
        .and(warp::get())
        .and(with_user(sessions))
        .map(|user: User| warp::reply::json(&user));

    let routes = index
        .or(authorize)
        .or(login)
        .or(logout)
        .or(api_account)
        .recover(handle_rejections);

    let logged_routes = routes.with(warp::log("openid_warp_example"));

    warp::serve(logged_routes).run(listen).await;

    Ok(())
}

async fn request_token(
    oidc_client: &OpenIDClient,
    login_query: &LoginQuery,
) -> anyhow::Result<Option<(Token, Userinfo)>> {
    let mut token: Token = oidc_client.request_token(&login_query.code).await?.into();

    if let Some(id_token) = token.id_token.as_mut() {
        oidc_client.decode_token(id_token)?;
        oidc_client.validate_token(id_token, None, None)?;
        info!("token: {:?}", id_token);
    } else {
        return Ok(None);
    }

    let userinfo = oidc_client.request_userinfo(&token).await?;

    info!("user info: {:?}", userinfo);

    Ok(Some((token, userinfo)))
}

async fn reply_login(
    oidc_client: Arc<OpenIDClient>,
    login_query: LoginQuery,
    sessions: Arc<RwLock<Sessions>>,
) -> Result<impl warp::Reply, Infallible> {
    let request_token = request_token(&oidc_client, &login_query).await;
    match request_token {
        Ok(Some((token, user_info))) => {
            let id = uuid::Uuid::new_v4().to_string();

            let login = user_info.preferred_username.clone();
            let email = user_info.email.clone();

            let user = User {
                id: user_info.sub.clone().unwrap_or_default(),
                login,
                last_name: user_info.family_name.clone(),
                first_name: user_info.name.clone(),
                email,
                activated: user_info.email_verified,
                image_url: user_info.picture.clone().map(|x| x.to_string()),
                lang_key: Some("en".to_string()),
                authorities: vec!["ROLE_USER".to_string()],
            };

            let authorization_cookie = ::cookie::Cookie::build((EXAMPLE_COOKIE, &id))
                .path("/")
                .http_only(true)
                .build()
                .to_string();

            sessions
                .write()
                .await
                .map
                .insert(id, (user, token, user_info));

            let redirect_url = login_query.state.clone().unwrap_or_else(|| host("/"));

            Ok(Response::builder()
                .status(StatusCode::MOVED_PERMANENTLY)
                .header(warp::http::header::LOCATION, redirect_url)
                .header(warp::http::header::SET_COOKIE, authorization_cookie)
                .body("")
                .unwrap())
        }
        Ok(None) => {
            error!("login error in call: no id_token found");

            response_unauthorized()
        }
        Err(err) => {
            error!("login error in call: {:?}", err);

            response_unauthorized()
        }
    }
}

fn response_unauthorized() -> Result<Response<&'static str>, Infallible> {
    Ok(Response::builder()
        .status(StatusCode::UNAUTHORIZED)
        .body("")
        .unwrap())
}

async fn reply_logout(
    oidc_client: Arc<OpenIDClient>,
    session_id: Option<String>,
    sessions: Arc<RwLock<Sessions>>,
) -> Result<impl warp::Reply, Infallible> {
    let Some(id) = session_id else {
        return response_unauthorized();
    };

    let session_removed = sessions.write().await.map.remove(&id);

    if let Some(id_token) = session_removed.and_then(|(_, token, _)| token.bearer.id_token) {
        let authorization_cookie = ::cookie::Cookie::build((EXAMPLE_COOKIE, &id))
            .path("/")
            .http_only(true)
            .max_age(Duration::seconds(-1))
            .build()
            .to_string();

        let return_redirect_url = host("/");

        let redirect_url = oidc_client
            .config()
            .end_session_endpoint
            .clone()
            .map(|mut logout_provider_endpoint| {
                logout_provider_endpoint
                    .query_pairs_mut()
                    .append_pair("id_token_hint", &id_token)
                    .append_pair("post_logout_redirect_uri", &return_redirect_url);
                logout_provider_endpoint.to_string()
            })
            .unwrap_or_else(|| return_redirect_url);

        info!("logout redirect url: {redirect_url}");

        Ok(Response::builder()
            .status(StatusCode::FOUND)
            .header(warp::http::header::LOCATION, redirect_url)
            .header(warp::http::header::SET_COOKIE, authorization_cookie)
            .body("")
            .unwrap())
    } else {
        response_unauthorized()
    }
}

async fn reply_authorize(oidc_client: Arc<OpenIDClient>) -> Result<impl warp::Reply, Infallible> {
    let origin_url = env::var("ORIGIN").unwrap_or_else(|_| host(""));

    let auth_url = oidc_client.auth_url(&Options {
        scope: Some("openid email profile".into()),
        state: Some(origin_url),
        ..Default::default()
    });

    info!("authorize: {}", auth_url);

    let url: String = auth_url.into();

    Ok(warp::reply::with_header(
        StatusCode::FOUND,
        warp::http::header::LOCATION,
        url,
    ))
}

#[derive(Debug)]
struct Unauthorized;

impl reject::Reject for Unauthorized {}

async fn extract_user(
    session_id: Option<String>,
    sessions: Arc<RwLock<Sessions>>,
) -> Result<User, Rejection> {
    if let Some(session_id) = session_id {
        if let Some((user, _, _)) = sessions.read().await.map.get(&session_id) {
            Ok(user.clone())
        } else {
            Err(warp::reject::custom(Unauthorized))
        }
    } else {
        Err(warp::reject::custom(Unauthorized))
    }
}

fn with_user(
    sessions: Arc<RwLock<Sessions>>,
) -> impl Filter<Extract = (User,), Error = Rejection> + Clone {
    warp::cookie::optional(EXAMPLE_COOKIE)
        .and(warp::any().map(move || sessions.clone()))
        .and_then(extract_user)
}

async fn handle_rejections(err: Rejection) -> Result<impl Reply, Infallible> {
    let code = if err.is_not_found() {
        StatusCode::NOT_FOUND
    } else if let Some(Unauthorized) = err.find() {
        StatusCode::UNAUTHORIZED
    } else {
        StatusCode::INTERNAL_SERVER_ERROR
    };

    Ok(warp::reply::with_status(warp::reply(), code))
}

/// This host is the address, where user would be redirected after initial authorization.
/// For DEV environment with WebPack this is usually something like `http://localhost:9000`.
/// We are using `http://localhost:8080` in all-in-one example.
pub fn host(path: &str) -> String {
    env::var("REDIRECT_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()) + path
}

See full example: openid-examples: warp

openid's People

Contributors

brianhv avatar ctron avatar jetersen avatar kilork avatar michaelmattig avatar mvniekerk avatar rbartlensky avatar rphmeier avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

openid's Issues

Way to add query parameter to redirect URL

Would it make sense (assuming it's not there already and I'm missing it) to add a way to add a query parameter to the callback URL after the Client is constructed? My use case is returning the user to the page they were trying to hit before they were forced to log in.

I imagine this working by adding a field to the Options struct for auth_url(). For example, if the redirect is set to https://example.com/login/oauth2/code/oidc when the Client is constructed:

// This returns a url with a callback of https://example.com/login/oauth2/code/oidc
let auth_url = oidc_client.auth_url(&Options {
    scope: Some("email".into()),
    ..Default::default()
});

// So does this
let auth_url = oidc_client.auth_url(&Options {
    scope: Some("email".into()),
    extra_callback_query: None,
    ..Default::default()
});

// But this returns a url with a callback of
// https://example.com/login/oauth2/code/oidc?original=/return/path/here
let auth_url = oidc_client.auth_url(&Options {
    scope: Some("email".into()),
    extra_callback_query: Some(("original", "/return/path/here")),
    ..Default::default()
});

I'm in no way attached to names or data types here; just illustrating the principle.

Receiving Decode(MissingKey(...)) error from Google after many hours of running

Hi, I've been using openid in my project for a while now (code for it is here) - thanks for the effort you've put into it! I have however run into one small issue, which is that after leaving the server running for a long time (4 days to a week of uptime, perhaps), Google begins returning Decode(MissingKey(...)) errors, so nobody can log in. Upon restarting the server, though, it immediately begins working again. Thus, I'm not really sure which part of the stack is at fault here - my code, Google, or the library. My first thought was maybe it could be this library (something to do with reusing/resuming connections perhaps?) If not, I wonder if you know where I could start investigating this problem or where it's likely to be?

Reqwest (Hyper) refusing to load HTTPS issuer

Hello.

I am currently experiencing the following error when using this crate:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Http(reqwest::Error { kind: Request, url: Url { scheme: "https", host: Some(Domain("api.fusionfabric.cloud")), port: None, path: "/login/v1/sandbox/.well-known/openid-configuration", query: None, fragment: None }, source: hyper::Error(Connect, "invalid URL, scheme is not http") })', src/main.rs:17:13

The panic fires from the Client's discover fn, when entering an HTTPS issuer, exactly the following: https://api.fusionfabric.cloud/login/v1/sandbox

If I use actix and add its TLS component as shown in this crate's documentation, error does not show, but in my case I do not need actix

To workaround this, I have cloned this repo and added the following in Cargo.toml, to make reqwest use native TLS:

[dependencies.reqwest]
version = '0.11'
default-features = false
features = ['json', 'native-tls']

when original Cargo.toml does not contain the native-tls feature for reqwest.

So I want to ask if there is a better to fix this without patching crate manually?

Thanks and grettings!

Optional issuer validation?

Hi, Thanks for this library.

I wonder what the best way to solve an issue I have with validating the issuer. Microsoft OIDC uses {tenantId} in the issuer URL returned in the claims.

In the claims I can request the tid to get the tenant id.

For now I have a branch of openid where I just ignore the issuer check. Would be interested in a upstream change that would allow for optional issuer check? Or perhaps you have another suggestion.

See: https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration

{
  // ...
  "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0",
  // ...
}

openid/src/client.rs

Lines 254 to 258 in 94cb5e9

if claims.iss() != &self.config().issuer {
let expected = self.config().issuer.as_str().to_string();
let actual = claims.iss().as_str().to_string();
return Err(Validation::Mismatch(Mismatch::Issuer { expected, actual }).into());
}

Issues with Nonce

I have an issue with using nonces.

To my understanding, nonces should be validated between the browser and the SSO server.

When validating a token on the application (backend) side using openid::client::Client::validate_token, it requires a nonce, if the claims contain a nonce.

To my understanding, that shouldn't be enforced. Or is there a different way to validate a token in a backend system?

Regression in at_hash behavior from Version 0.10.1

In the upgrade from base64 from 0.13 to 0.21 the behavior was lost where both unpadded or correctly padded strings were accepted. I am using dex which is currently sending me unpadded strings. I have not checked if this behavior is allowed as per the spec or not.


at_hash on ξ‚  master [?] is πŸ“¦ v0.1.0 via 🐍 v3.10.12 (env) via πŸ¦€ v1.70.0 
// base64 0.13.1

fn main() {
    let x = "zglPCMCEP7ilF3LP_NExow";
    match base64::decode_config(x, base64::URL_SAFE) {
        Ok(_) => println!("ok"),
        Err(e) => println!("error {}", e),
    };
}


❯ cargo run
   Compiling at_hash v0.1.0 (/home/issac/src/at_hash)
    Finished dev [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/at_hash`
ok




// base64 0.21
use base64::{engine::general_purpose::URL_SAFE, Engine as _};
fn main() {
    let x = "zglPCMCEP7ilF3LP_NExow";
    match URL_SAFE.decode(x) {
        Ok(_) => println!("ok"),
        Err(e) => println!("error {}", e),
    };
}

at_hash on ξ‚  master [?] is πŸ“¦ v0.1.0 via 🐍 v3.10.12 (env) via πŸ¦€ v1.70.0 
❯ cargo run             
   Compiling at_hash v0.1.0 (/home/issac/src/at_hash)
    Finished dev [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/at_hash`
error Invalid padding




`decode_token` may panic

To me it looks like decode_token may panic, due to the use of unimplemented!.

So if a user provided token contains a algorithm which is not supported, that could let the function panic.

I think handling the situation gracefully, with a proper error return, would be better.

Allow cloning a client

Sometimes is it necessary to have two instances of the same client. However, it isn't really necessary to re-discover the client. It would be great if the client could be cloned.

Custom claims?

Hi! Thanks for helping making openid connect a lot less complicated with this crate!

Is there any support for custom additional claims? Or plans for such support? I didn't find anything in the docs.

feat: support reqwest-middleware

This would allow adding caching, otel etc without needing more changes to the openid crate. One pattern I quite like is to define a trait that specifies only what you actually use from reqwest, and then users can implement that themselves as needed.

Allow using custom CA for discovery

Assuming you want to use a custom CA and discovery at the same time, this seems not possible as the discover methods creates its own reqwest::Client, without any chance of customizing the instance.

I think it would be helpful to provide a discover_with_client function, which accepts either a client or a client builder.

Bearer’s expires field gets scrambled after serialize β†’ deserialize round-trip

When Bearer is deserialized it expects the expires_in field in seconds-from-now, and adds a Utc::now() to it in order to obtain a fixed non-relative UTC instant. That makes sense.

But then when it is serialized it directly serializes expires.timestamp() (Unix timestamp in secodns) as the expires_in field – which is not consistent.

This means that if you save the serialized bearer value eg. for session keeping then each time you deserialize it you add a Unix timestamp Utc::now() worth amount of seconds to the expires field, making it unusable.

There should be either type for the serialize-deserialize round-trip (a SerializableBearer type that just has expires field?) or some other way to restore it from serialization.

Nonce validation shouldn't fail when not using nonce

When the client doesn't provide a nonce but the provider responds with a nonce, validation currently fails.

This was already brought up in #21 and closed with a workaround but I'd like to request again that this behavior be removed. I just hit this issue with AWS Cognito with Google as an upstream.

If I understand the discussion in the other issue, this behavior

  • Is not necessary
  • Doesn't add safety
  • Reduces reliability depending on upstream behavior
  • Goes against expectation (depending on the provider, users are unlikely to have working code the first time following examples)
  • The workaround is non-obvious (is it documented anywhere?)

Removing the claims.nonce().is_some() check in the None branch would allow everyone to use the same method for validation, simplifying the interface and solving all the above issues.

Validating access tokens

I noticed that the validate_token function does validate an id token. On the backend, shouldn't there be a way to validate access tokens?

upgrade to reqwest 0.12

reqwest 0.12 is out and it is based on the http v1 crate.

In order to be able to provide an existing reqwest::Client and in order to reduce the number of dependencies, it would make sense to upgrade too.

Optional client secret

I looks like the client secret is optional, and missing for "public" (non-confidential) clients.

To me it looks like in this implementation the client secret is required though.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.