openidconnect-rs icon indicating copy to clipboard operation
openidconnect-rs copied to clipboard

Better examples needed

Open frenchtoastbeer opened this issue 5 years ago • 7 comments

I eventually was able to take the samples of how to use this (specifically the await example) and turn it into something at compiled. A few things were necessary in order to do that, one of which centered around error handling. I'm sure I did it wrong, but without a better option I'm just going to posit here what I did so that you'll know that A - it didn't appear to me that your example usage code was functional and B - maybe having my sample code helps get to a better solution faster.

cargo.toml

[dependencies]
openidconnect = { version = "1.0", features = ["futures-03","reqwest-010"], default-features = false }
oauth2 = "3.0"
failure = "0.1"

auth.rs

pub async fn authenticate() -> Result<(oauth2::AccessToken, Option<oauth2::RefreshToken>), MyErr> {
    use openidconnect::{
        AccessTokenHash,
        AsyncCodeTokenRequest,
        AuthorizationCode,
        ClientId,
        ClientSecret,
        CsrfToken,
        Nonce,
        IssuerUrl,
        PkceCodeChallenge,
        RedirectUrl,
        Scope,
    };
    use openidconnect::core::{
      CoreAuthenticationFlow,
      CoreClient,
      CoreProviderMetadata,
    };
    use openidconnect::reqwest::async_http_client;
    
    // Use OpenID Connect Discovery to fetch the provider metadata.
    use openidconnect::{OAuth2TokenResponse, TokenResponse};
    let provider_metadata = CoreProviderMetadata::discover_async(
        IssuerUrl::new("https://accounts.example.com".to_string())?,
        async_http_client,
    )
    .await?;
    
    // Create an OpenID Connect client by specifying the client ID, client secret, authorization URL
    // and token URL.
    let client =
        CoreClient::from_provider_metadata(
            provider_metadata,
            ClientId::new("client_id".to_string()),
            Some(ClientSecret::new("client_secret".to_string())),
        )
        // Set the URL the user will be redirected to after the authorization process.
        .set_redirect_uri(RedirectUrl::new("http://redirect".to_string())?);
    
    // Generate a PKCE challenge.
    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
    
    // Generate the full authorization URL.
    let (auth_url, csrf_token, nonce) = client
        .authorize_url(
            CoreAuthenticationFlow::AuthorizationCode,
            CsrfToken::new_random,
            Nonce::new_random,
        )
        // Set the desired scopes.
        .add_scope(Scope::new("read".to_string()))
        .add_scope(Scope::new("write".to_string()))
        // Set the PKCE code challenge.
        .set_pkce_challenge(pkce_challenge)
        .url();
    
    // This is the URL you should redirect the user to, in order to trigger the authorization
    // process.
    println!("Browse to: {}", auth_url);
    
    // Once the user has been redirected to the redirect URL, you'll have access to the
    // authorization code. For security reasons, your code should verify that the `state`
    // parameter returned by the server matches `csrf_state`.
    
    // Now you can exchange it for an access token and ID token.
    let token_response =
        client
            .exchange_code(AuthorizationCode::new("some authorization code".to_string()))
            // Set the PKCE code verifier.
            .set_pkce_verifier(pkce_verifier)
            .request_async(async_http_client)
            .await?;
    
    // Extract the ID token claims after verifying its authenticity and nonce.
    let id_token = token_response
      .id_token()
      .ok_or_else(|| failure::format_err!("Server did not return an ID token"))?;
    let claims = id_token.claims(&client.id_token_verifier(), &nonce)?;
    
    // Verify the access token hash to ensure that the access token hasn't been substituted for
    // another user's.
    if let Some(expected_access_token_hash) = claims.access_token_hash() {
        let actual_access_token_hash = AccessTokenHash::from_token(
            token_response.access_token(),
            &id_token.signing_alg()?
        )?;
        if actual_access_token_hash != *expected_access_token_hash {
            return Err(MyErr::from(failure::Error::from_boxed_compat("Invalid access token".into())));
        }
    }
    
    // The authenticated user's identity is now available. See the IdTokenClaims struct for a
    // complete listing of the available claims.
    println!(
        "User {} with e-mail address {} has authenticated successfully",
        claims.subject().as_str(),
        claims.email().map(|email| email.as_str()).unwrap_or("<not provided>"),
    );
    
    // See the OAuth2TokenResponse trait for a listing of other available fields such as
    // access_token() and refresh_token().
    let access_token = token_response.access_token().clone();
    if let Some(refresh_token) = token_response.refresh_token() {
        Ok((access_token, Some(refresh_token.clone())))
    } else {
        Ok((access_token, None))
    }
}

#[derive(Debug)]
enum Errors {
    Discovery(openidconnect::DiscoveryError<oauth2::reqwest::Error<reqwest::Error>>),
    RequestToken(oauth2::RequestTokenError<oauth2::reqwest::Error<reqwest::Error>, oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>>),
    Parse(url::ParseError),
    Signing(openidconnect::SigningError),
    Failure(failure::Error),
    ClaimsVerification(openidconnect::ClaimsVerificationError),
    None
}

impl Default for Errors {
    fn default() -> Errors {
        Errors::None
    }
}

#[derive(Debug, Default)]
struct MyErr {
    error: Errors
}

impl std::fmt::Display for MyErr {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self.error {
            Errors::Discovery(e) => format!("{}",e),
            Errors::RequestToken(e) => format!("{}",e),
            Errors::Parse(e) => format!("{}",e),
            Errors::Signing(e) => format!("{}",e),
            Errors::Failure(e) => format!("{}",e),
            Errors::ClaimsVerification(e) => format!("{}",e),
            Errors::None => format!("Error wasn't captured."),
        }
    }
}
impl std::error::Error for MyErr {}

impl std::convert::From<openidconnect::DiscoveryError<oauth2::reqwest::Error<reqwest::Error>>> for MyErr {
    fn from(error: openidconnect::DiscoveryError<oauth2::reqwest::Error<reqwest::Error>>) -> MyErr {
        MyErr{
            error: Errors::Discovery(error)
        }
    }
}

impl std::convert::From<oauth2::RequestTokenError<oauth2::reqwest::Error<reqwest::Error>, oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>>> for MyErr {
    fn from(error: oauth2::RequestTokenError<oauth2::reqwest::Error<reqwest::Error>, oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>>) -> MyErr {
        MyErr{
            error: Errors::RequestToken(error)
        }
    }
}

impl std::convert::From<url::ParseError> for MyErr {
    fn from(error: url::ParseError) -> MyErr {
        MyErr{
            error: Errors::Parse(error)
        }
    }
}

impl std::convert::From<openidconnect::SigningError> for MyErr {
    fn from(error: openidconnect::SigningError) -> MyErr {
        MyErr{
            error: Errors::Signing(error)
        }
    }
}

impl std::convert::From<failure::Error> for MyErr {
    fn from(error: failure::Error) -> MyErr {
        MyErr{
            error: Errors::Failure(error)
        }
    }
}

impl std::convert::From<openidconnect::ClaimsVerificationError> for MyErr {
    fn from(error: openidconnect::ClaimsVerificationError) -> MyErr {
        MyErr{
            error: Errors::ClaimsVerification(error)
        }
    }
}

frenchtoastbeer avatar Aug 04 '20 18:08 frenchtoastbeer

I myself would love to see examples of how to define your own additional claims. I've gotten so far as implementing the trait for a struct but I'm not sure what to do with it once I've done that.

zelda-at-tempus avatar Sep 16 '20 21:09 zelda-at-tempus

I'd definitely welcome PRs to address any gaps in the examples.

I myself would love to see examples of how to define your own additional claims. I've gotten so far as implementing the trait for a struct but I'm not sure what to do with it once I've done that.

The AC (additional claims) type parameter for the Client struct specifies the type of the additional claims. To receive custom claims, I recommend defining your own type alias similar to CoreClient but with the appropriate AC type. Then, you can access these claims in the ID token returned in the token response via IdTokenClaims::additional_claims.

To construct a new ID token with custom claims (i.e., when acting as an OIDC provider), the additional claims are one of the parameters to IdTokenClaims::new.

These unit tests might be helpful: https://github.com/ramosbugs/openidconnect-rs/blob/f4a7abef527a22a905521a198a98b6f1b435c422/src/id_token.rs#L955-L1020

ramosbugs avatar Sep 16 '20 21:09 ramosbugs

I feel so foolish that I didn't even think to check if you had unit tests. Thanks for pointing me in that direction

zelda-at-tempus avatar Sep 17 '20 15:09 zelda-at-tempus

Please ignore everything below, I finally found the issue, I will open up a PR to show how UserInfo can be requested in the example.

See: https://github.com/ramosbugs/openidconnect-rs/pull/36

@ramosbugs somehow related to the examples:

Can you enhance the example with a UserInfo Request?

I'm trying it like this:

let userinfo_request = match client.user_info(*token_response.access_token(), None) {
    Ok(o) => o,
    Err(e) => return Err(format!("No user info endpoint: {:?}", e)),
};
let userinfo = match userinfo_request.request(http_client) {
    Ok(o) => o,
    Err(e) => return Err(format!("Failed requesting user info: {:?}", e)),
};

But it complains about type annotations needed for openidconnect::UserInfoClaims<AC, GC>` in line 5 of the code above.

And when I explicitly add the type for userinfo like this: : UserInfoClaims<MyClaim, GenderClaim> it complains about

the size for values of type `(dyn openidconnect::GenderClaim + 'static)` cannot be known at compilation time
doesn't have a size known at compile-time

Maybe I have to dig deeper, but I can't really figure out how to solve this. Any hint is very appreciated.

benjaminSchilling33 avatar Jan 29 '21 20:01 benjaminSchilling33

The AC (additional claims) type parameter for the Client struct specifies the type of the additional claims. To receive custom claims, I recommend defining your own type alias similar to CoreClient but with the appropriate AC type. Then, you can access these claims in the ID token returned in the token response via IdTokenClaims::additional_claims.

Could you elaborate a bit more on that? I gave this a try, but my Rust is not yet perfect. I tried to define a type alias of Client as you did for CoreClient. But that required me to also define a MyTokenResponse which required me to define a MyIdTokenFields`, ...

I did all of this, but at some point I could not call access_token() on the response anymore. Somehow it cannot get the implementation of TokenResponse and I'm totally lost now.

domma avatar May 25 '23 20:05 domma

The AC (additional claims) type parameter for the Client struct specifies the type of the additional claims. To receive custom claims, I recommend defining your own type alias similar to CoreClient but with the appropriate AC type. Then, you can access these claims in the ID token returned in the token response via IdTokenClaims::additional_claims.

Could you elaborate a bit more on that? I gave this a try, but my Rust is not yet perfect. I tried to define a type alias of Client as you did for CoreClient. But that required me to also define a MyTokenResponse which required me to define a MyIdTokenFields`, ...

I did all of this, but at some point I could not call access_token() on the response anymore. Somehow it cannot get the implementation of TokenResponse and I'm totally lost now.

Adding additional claims shouldn't require defining your own TokenResponse struct. Just use StandardTokenResponse<IdTokenFields<MyClaims, ...>, CoreTokenType>. The idea is to copy whichever typedefs from the core module need to be customized, and just modify the minimum set of type parameters.

ramosbugs avatar May 25 '23 22:05 ramosbugs

Adding additional claims shouldn't require defining your own TokenResponse struct. Just use StandardTokenResponse<IdTokenFields<MyClaims, ...>, CoreTokenType>. The idea is to copy whichever typedefs from the core module need to be customized, and just modify the minimum set of type parameters.

Thanks for your feedback. It got it working! The following code works for me to retrieve group information from Zitadel:

pub type ZitadelIdTokenFields = IdTokenFields<
    ZitadelClaims,
    EmptyExtraTokenFields,
    CoreGenderClaim,
    CoreJweContentEncryptionAlgorithm,
    CoreJwsSigningAlgorithm,
    CoreJsonWebKeyType,
>;

pub type ZitadelTokenResponse = StandardTokenResponse<ZitadelIdTokenFields, BasicTokenType>;


pub type Client = openidconnect::Client<
    ZitadelClaims,
    CoreAuthDisplay,
    CoreGenderClaim,
    CoreJweContentEncryptionAlgorithm,
    CoreJwsSigningAlgorithm,
    CoreJsonWebKeyType,
    CoreJsonWebKeyUse,
    CoreJsonWebKey,
    CoreAuthPrompt,
    StandardErrorResponse<CoreErrorResponseType>,
    ZitadelTokenResponse,
    BasicTokenType,
    CoreTokenIntrospectionResponse,
    CoreRevocableToken,
    CoreRevocationErrorResponse,
>;

#[derive(Serialize, Deserialize, Debug)]
pub struct ZitadelClaims {
    #[serde(alias = "urn:zitadel:iam:org:project:roles")]
    groups: HashMap<String, HashMap<String, String>>
}

impl AdditionalClaims for ZitadelClaims {}

One challenge for me was the existence of openidconnect::TokenResponse and oauth2::TokenResponse. Both need to be in scope for my client code and the error message if one is missing probably led me in a completely wrong direction. Now everything is working in a nice and simple way. Thanks again!

domma avatar May 26 '23 08:05 domma