use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use actix_web::dev::ServiceRequest;
use actix_web::http::header::AUTHORIZATION;
use oauth2::basic::BasicClient;
use oauth2::reqwest::async_http_client;
use oauth2::{
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
use openidconnect::{
ClientId as OidcClientId, ClientSecret as OidcClientSecret, IssuerUrl, Nonce,
RedirectUrl as OidcRedirectUrl, TokenResponse as OidcTokenResponse,
};
use serde::{Deserialize, Serialize};
use url::Url;
use super::config::Authenticator;
use super::user::User;
#[derive(Debug, Clone)]
pub enum OAuth2Error {
Configuration(String),
Discovery(String),
TokenExchange(String),
TokenValidation(String),
UserInfo(String),
InvalidState(String),
InvalidNonce(String),
}
impl fmt::Display for OAuth2Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OAuth2Error::Configuration(msg) => write!(f, "Configuration error: {}", msg),
OAuth2Error::Discovery(msg) => write!(f, "Discovery error: {}", msg),
OAuth2Error::TokenExchange(msg) => write!(f, "Token exchange error: {}", msg),
OAuth2Error::TokenValidation(msg) => write!(f, "Token validation error: {}", msg),
OAuth2Error::UserInfo(msg) => write!(f, "User info error: {}", msg),
OAuth2Error::InvalidState(msg) => write!(f, "Invalid state: {}", msg),
OAuth2Error::InvalidNonce(msg) => write!(f, "Invalid nonce: {}", msg),
}
}
}
impl std::error::Error for OAuth2Error {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum OAuth2Provider {
Google,
GitHub,
Microsoft,
Facebook,
Apple,
Okta,
Auth0,
Keycloak,
Custom,
}
impl OAuth2Provider {
pub fn discovery_url(&self) -> Option<&'static str> {
match self {
OAuth2Provider::Google => {
Some("https://accounts.google.com/.well-known/openid-configuration")
}
OAuth2Provider::Microsoft => Some(
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
),
OAuth2Provider::Apple => {
Some("https://appleid.apple.com/.well-known/openid-configuration")
}
_ => None,
}
}
pub fn auth_url(&self) -> Option<&'static str> {
match self {
OAuth2Provider::Google => Some("https://accounts.google.com/o/oauth2/v2/auth"),
OAuth2Provider::GitHub => Some("https://github.com/login/oauth/authorize"),
OAuth2Provider::Microsoft => {
Some("https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
}
OAuth2Provider::Facebook => Some("https://www.facebook.com/v18.0/dialog/oauth"),
OAuth2Provider::Apple => Some("https://appleid.apple.com/auth/authorize"),
_ => None,
}
}
pub fn token_url(&self) -> Option<&'static str> {
match self {
OAuth2Provider::Google => Some("https://oauth2.googleapis.com/token"),
OAuth2Provider::GitHub => Some("https://github.com/login/oauth/access_token"),
OAuth2Provider::Microsoft => {
Some("https://login.microsoftonline.com/common/oauth2/v2.0/token")
}
OAuth2Provider::Facebook => Some("https://graph.facebook.com/v18.0/oauth/access_token"),
OAuth2Provider::Apple => Some("https://appleid.apple.com/auth/token"),
_ => None,
}
}
pub fn userinfo_url(&self) -> Option<&'static str> {
match self {
OAuth2Provider::Google => Some("https://openidconnect.googleapis.com/v1/userinfo"),
OAuth2Provider::GitHub => Some("https://api.github.com/user"),
OAuth2Provider::Microsoft => Some("https://graph.microsoft.com/oidc/userinfo"),
OAuth2Provider::Facebook => Some("https://graph.facebook.com/me?fields=id,name,email"),
_ => None,
}
}
pub fn default_scopes(&self) -> Vec<&'static str> {
match self {
OAuth2Provider::Google => vec!["openid", "email", "profile"],
OAuth2Provider::GitHub => vec!["read:user", "user:email"],
OAuth2Provider::Microsoft => vec!["openid", "email", "profile"],
OAuth2Provider::Facebook => vec!["email", "public_profile"],
OAuth2Provider::Apple => vec!["openid", "email", "name"],
OAuth2Provider::Okta => vec!["openid", "email", "profile"],
OAuth2Provider::Auth0 => vec!["openid", "email", "profile"],
OAuth2Provider::Keycloak => vec!["openid", "email", "profile"],
OAuth2Provider::Custom => vec!["openid"],
}
}
pub fn supports_oidc(&self) -> bool {
matches!(
self,
OAuth2Provider::Google
| OAuth2Provider::Microsoft
| OAuth2Provider::Apple
| OAuth2Provider::Okta
| OAuth2Provider::Auth0
| OAuth2Provider::Keycloak
)
}
}
#[derive(Debug, Clone)]
pub struct OAuth2Config {
pub registration_id: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub provider: OAuth2Provider,
pub authorization_uri: Option<String>,
pub token_uri: Option<String>,
pub userinfo_uri: Option<String>,
pub issuer_uri: Option<String>,
pub jwk_set_uri: Option<String>,
pub scopes: Vec<String>,
pub use_pkce: bool,
pub authorization_params: HashMap<String, String>,
pub username_attribute: String,
}
impl OAuth2Config {
pub fn new(
client_id: impl Into<String>,
client_secret: impl Into<String>,
redirect_uri: impl Into<String>,
) -> Self {
Self {
registration_id: String::new(),
client_id: client_id.into(),
client_secret: client_secret.into(),
redirect_uri: redirect_uri.into(),
provider: OAuth2Provider::Custom,
authorization_uri: None,
token_uri: None,
userinfo_uri: None,
issuer_uri: None,
jwk_set_uri: None,
scopes: vec!["openid".to_string()],
use_pkce: true,
authorization_params: HashMap::new(),
username_attribute: "sub".to_string(),
}
}
pub fn registration_id(mut self, id: impl Into<String>) -> Self {
self.registration_id = id.into();
self
}
pub fn provider(mut self, provider: OAuth2Provider) -> Self {
self.provider = provider;
if self.registration_id.is_empty() {
self.registration_id = format!("{:?}", provider).to_lowercase();
}
if let Some(auth_url) = provider.auth_url() {
self.authorization_uri = Some(auth_url.to_string());
}
if let Some(token_url) = provider.token_url() {
self.token_uri = Some(token_url.to_string());
}
if let Some(userinfo_url) = provider.userinfo_url() {
self.userinfo_uri = Some(userinfo_url.to_string());
}
if self.scopes.len() == 1 && self.scopes[0] == "openid" {
self.scopes = provider
.default_scopes()
.into_iter()
.map(String::from)
.collect();
}
if matches!(provider, OAuth2Provider::GitHub | OAuth2Provider::Facebook) {
self.use_pkce = false;
}
self
}
pub fn authorization_uri(mut self, uri: impl Into<String>) -> Self {
self.authorization_uri = Some(uri.into());
self
}
pub fn token_uri(mut self, uri: impl Into<String>) -> Self {
self.token_uri = Some(uri.into());
self
}
pub fn userinfo_uri(mut self, uri: impl Into<String>) -> Self {
self.userinfo_uri = Some(uri.into());
self
}
pub fn issuer_uri(mut self, uri: impl Into<String>) -> Self {
self.issuer_uri = Some(uri.into());
self
}
pub fn jwk_set_uri(mut self, uri: impl Into<String>) -> Self {
self.jwk_set_uri = Some(uri.into());
self
}
pub fn scopes(mut self, scopes: Vec<impl Into<String>>) -> Self {
self.scopes = scopes.into_iter().map(|s| s.into()).collect();
self
}
pub fn add_scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
pub fn use_pkce(mut self, use_pkce: bool) -> Self {
self.use_pkce = use_pkce;
self
}
pub fn authorization_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.authorization_params.insert(key.into(), value.into());
self
}
pub fn username_attribute(mut self, attr: impl Into<String>) -> Self {
self.username_attribute = attr.into();
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth2User {
pub sub: String,
pub name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub picture: Option<String>,
pub locale: Option<String>,
#[serde(flatten)]
pub attributes: HashMap<String, serde_json::Value>,
#[serde(skip)]
pub access_token: Option<String>,
#[serde(skip)]
pub refresh_token: Option<String>,
pub expires_at: Option<i64>,
pub provider: String,
}
impl OAuth2User {
pub fn new(sub: impl Into<String>, provider: impl Into<String>) -> Self {
Self {
sub: sub.into(),
name: None,
given_name: None,
family_name: None,
email: None,
email_verified: None,
picture: None,
locale: None,
attributes: HashMap::new(),
access_token: None,
refresh_token: None,
expires_at: None,
provider: provider.into(),
}
}
pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
self.attributes.get(key)
}
pub fn username(&self) -> &str {
self.email.as_deref().unwrap_or(&self.sub)
}
pub fn to_user(&self) -> User {
User::new(self.username().to_string(), String::new())
.roles(&["USER".to_string()])
.authorities(&[format!("OAUTH2_USER_{}", self.provider.to_uppercase())])
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcUser {
#[serde(flatten)]
pub oauth2_user: OAuth2User,
pub id_token_claims: Option<IdTokenClaims>,
#[serde(skip)]
pub id_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdTokenClaims {
pub iss: String,
pub sub: String,
pub aud: Vec<String>,
pub exp: i64,
pub iat: i64,
pub auth_time: Option<i64>,
pub nonce: Option<String>,
pub at_hash: Option<String>,
}
impl OidcUser {
pub fn to_user(&self) -> User {
self.oauth2_user.to_user()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorizationRequestState {
pub state: String,
pub pkce_verifier: Option<String>,
pub nonce: Option<String>,
pub redirect_uri: Option<String>,
pub registration_id: String,
pub created_at: i64,
}
#[derive(Clone)]
pub struct OAuth2Client {
config: OAuth2Config,
oauth2_client: BasicClient,
oidc_client: Option<Arc<CoreClient>>,
}
impl OAuth2Client {
pub async fn new(config: OAuth2Config) -> Result<Self, OAuth2Error> {
let auth_url = config
.authorization_uri
.as_ref()
.ok_or_else(|| OAuth2Error::Configuration("Missing authorization URI".to_string()))?;
let token_url = config
.token_uri
.as_ref()
.ok_or_else(|| OAuth2Error::Configuration("Missing token URI".to_string()))?;
let oauth2_client = BasicClient::new(
ClientId::new(config.client_id.clone()),
Some(ClientSecret::new(config.client_secret.clone())),
AuthUrl::new(auth_url.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
Some(
TokenUrl::new(token_url.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
),
)
.set_redirect_uri(
RedirectUrl::new(config.redirect_uri.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
);
let oidc_client = if config.provider.supports_oidc() {
if let Some(issuer_uri) = &config.issuer_uri {
match Self::create_oidc_client(&config, issuer_uri).await {
Ok(client) => Some(Arc::new(client)),
Err(e) => {
eprintln!("Warning: OIDC discovery failed: {}", e);
None
}
}
} else if let Some(discovery_url) = config.provider.discovery_url() {
let issuer = discovery_url.trim_end_matches("/.well-known/openid-configuration");
match Self::create_oidc_client(&config, issuer).await {
Ok(client) => Some(Arc::new(client)),
Err(e) => {
eprintln!("Warning: OIDC discovery failed: {}", e);
None
}
}
} else {
None
}
} else {
None
};
Ok(Self {
config,
oauth2_client,
oidc_client,
})
}
pub fn new_basic(config: OAuth2Config) -> Result<Self, OAuth2Error> {
let auth_url = config
.authorization_uri
.as_ref()
.ok_or_else(|| OAuth2Error::Configuration("Missing authorization URI".to_string()))?;
let token_url = config
.token_uri
.as_ref()
.ok_or_else(|| OAuth2Error::Configuration("Missing token URI".to_string()))?;
let oauth2_client = BasicClient::new(
ClientId::new(config.client_id.clone()),
Some(ClientSecret::new(config.client_secret.clone())),
AuthUrl::new(auth_url.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
Some(
TokenUrl::new(token_url.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
),
)
.set_redirect_uri(
RedirectUrl::new(config.redirect_uri.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
);
Ok(Self {
config,
oauth2_client,
oidc_client: None,
})
}
async fn create_oidc_client(
config: &OAuth2Config,
issuer_uri: &str,
) -> Result<CoreClient, OAuth2Error> {
let issuer_url = IssuerUrl::new(issuer_uri.to_string())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?;
let provider_metadata = CoreProviderMetadata::discover_async(
issuer_url,
openidconnect::reqwest::async_http_client,
)
.await
.map_err(|e| OAuth2Error::Discovery(format!("{:?}", e)))?;
let client = CoreClient::from_provider_metadata(
provider_metadata,
OidcClientId::new(config.client_id.clone()),
Some(OidcClientSecret::new(config.client_secret.clone())),
)
.set_redirect_uri(
OidcRedirectUrl::new(config.redirect_uri.clone())
.map_err(|e| OAuth2Error::Configuration(e.to_string()))?,
);
Ok(client)
}
pub fn authorization_url(&self) -> (Url, CsrfToken, Option<PkceCodeVerifier>, Option<Nonce>) {
if let Some(oidc_client) = &self.oidc_client {
let mut auth_request = oidc_client.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
);
for scope in &self.config.scopes {
auth_request = auth_request.add_scope(openidconnect::Scope::new(scope.clone()));
}
let pkce_verifier = if self.config.use_pkce {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
auth_request = auth_request.set_pkce_challenge(pkce_challenge);
Some(pkce_verifier)
} else {
None
};
let (url, state, nonce) = auth_request.url();
(url, state, pkce_verifier, Some(nonce))
} else {
let mut auth_request = self.oauth2_client.authorize_url(CsrfToken::new_random);
for scope in &self.config.scopes {
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
}
let pkce_verifier = if self.config.use_pkce {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
auth_request = auth_request.set_pkce_challenge(pkce_challenge);
Some(pkce_verifier)
} else {
None
};
let (url, state) = auth_request.url();
(url, state, pkce_verifier, None)
}
}
pub async fn exchange_code(
&self,
code: &str,
pkce_verifier: Option<PkceCodeVerifier>,
nonce: Option<&Nonce>,
) -> Result<(OAuth2User, Option<OidcUser>), OAuth2Error> {
if let Some(oidc_client) = &self.oidc_client {
let mut token_request =
oidc_client.exchange_code(AuthorizationCode::new(code.to_string()));
if let Some(verifier) = pkce_verifier {
token_request = token_request.set_pkce_verifier(verifier);
}
let token_response = token_request
.request_async(openidconnect::reqwest::async_http_client)
.await
.map_err(|e| OAuth2Error::TokenExchange(format!("{:?}", e)))?;
let id_token = token_response
.id_token()
.ok_or_else(|| OAuth2Error::TokenValidation("Missing ID token".to_string()))?;
let id_token_verifier = oidc_client.id_token_verifier();
let nonce_ref = nonce.cloned().unwrap_or_else(|| Nonce::new(String::new()));
let claims = id_token.claims(&id_token_verifier, &nonce_ref).map_err(
|e: openidconnect::ClaimsVerificationError| {
OAuth2Error::TokenValidation(e.to_string())
},
)?;
let subject = claims.subject().as_str().to_string();
let issuer = claims.issuer().as_str().to_string();
let exp = claims.expiration().timestamp();
let iat = claims.issue_time().timestamp();
let mut oauth2_user = OAuth2User::new(&subject, &self.config.registration_id);
oauth2_user.access_token = Some(token_response.access_token().secret().clone());
oauth2_user.refresh_token = token_response.refresh_token().map(|t| t.secret().clone());
oauth2_user.email_verified = claims.email_verified();
let oidc_user = OidcUser {
oauth2_user: oauth2_user.clone(),
id_token_claims: Some(IdTokenClaims {
iss: issuer,
sub: subject,
aud: vec![self.config.client_id.clone()],
exp,
iat,
auth_time: None,
nonce: None,
at_hash: None,
}),
id_token: Some(id_token.to_string()),
};
Ok((oauth2_user, Some(oidc_user)))
} else {
let mut token_request = self
.oauth2_client
.exchange_code(AuthorizationCode::new(code.to_string()));
if let Some(verifier) = pkce_verifier {
token_request = token_request.set_pkce_verifier(verifier);
}
let token_response = token_request
.request_async(async_http_client)
.await
.map_err(|e| OAuth2Error::TokenExchange(e.to_string()))?;
let mut oauth2_user = self
.fetch_user_info(token_response.access_token().secret())
.await?;
oauth2_user.access_token = Some(token_response.access_token().secret().clone());
oauth2_user.refresh_token = token_response.refresh_token().map(|t| t.secret().clone());
Ok((oauth2_user, None))
}
}
async fn fetch_user_info(&self, access_token: &str) -> Result<OAuth2User, OAuth2Error> {
let userinfo_url = self
.config
.userinfo_uri
.as_ref()
.ok_or_else(|| OAuth2Error::UserInfo("Missing userinfo URI".to_string()))?;
let http_client = reqwest::Client::new();
let response = http_client
.get(userinfo_url)
.bearer_auth(access_token)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| OAuth2Error::UserInfo(e.to_string()))?;
if !response.status().is_success() {
return Err(OAuth2Error::UserInfo(format!(
"HTTP {}: {}",
response.status(),
response.text().await.unwrap_or_default()
)));
}
let attributes: HashMap<String, serde_json::Value> = response
.json()
.await
.map_err(|e| OAuth2Error::UserInfo(e.to_string()))?;
let sub = self.extract_user_id(&attributes)?;
let mut user = OAuth2User::new(sub, &self.config.registration_id);
user.access_token = Some(access_token.to_string());
user.attributes = attributes.clone();
user.name = attributes
.get("name")
.and_then(|v| v.as_str())
.map(String::from);
user.email = attributes
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
user.picture = attributes
.get("picture")
.or_else(|| attributes.get("avatar_url"))
.and_then(|v| v.as_str())
.map(String::from);
Ok(user)
}
fn extract_user_id(
&self,
attributes: &HashMap<String, serde_json::Value>,
) -> Result<String, OAuth2Error> {
let id_fields = ["sub", "id", "user_id", "login"];
for field in &id_fields {
if let Some(value) = attributes.get(*field) {
if let Some(s) = value.as_str() {
return Ok(s.to_string());
}
if let Some(n) = value.as_i64() {
return Ok(n.to_string());
}
}
}
Err(OAuth2Error::UserInfo(
"Could not extract user ID".to_string(),
))
}
pub fn config(&self) -> &OAuth2Config {
&self.config
}
pub fn has_oidc(&self) -> bool {
self.oidc_client.is_some()
}
}
#[derive(Clone, Default)]
pub struct OAuth2ClientRepository {
clients: HashMap<String, OAuth2Client>,
}
impl OAuth2ClientRepository {
pub fn new() -> Self {
Self {
clients: HashMap::new(),
}
}
pub fn add_client(&mut self, client: OAuth2Client) {
self.clients
.insert(client.config.registration_id.clone(), client);
}
pub fn get_client(&self, registration_id: &str) -> Option<&OAuth2Client> {
self.clients.get(registration_id)
}
pub fn registration_ids(&self) -> Vec<&String> {
self.clients.keys().collect()
}
pub async fn from_configs(configs: Vec<OAuth2Config>) -> Result<Self, OAuth2Error> {
let mut repo = Self::new();
for config in configs {
let client = OAuth2Client::new(config).await?;
repo.add_client(client);
}
Ok(repo)
}
}
#[derive(Clone)]
pub struct OAuth2Authenticator {
issuer: Option<String>,
jwks_uri: Option<String>,
username_attribute: String,
}
impl OAuth2Authenticator {
pub fn new() -> Self {
Self {
issuer: None,
jwks_uri: None,
username_attribute: "sub".to_string(),
}
}
pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
pub fn jwks_uri(mut self, uri: impl Into<String>) -> Self {
self.jwks_uri = Some(uri.into());
self
}
pub fn username_attribute(mut self, attr: impl Into<String>) -> Self {
self.username_attribute = attr.into();
self
}
fn extract_token(&self, req: &ServiceRequest) -> Option<String> {
let auth_header = req.headers().get(AUTHORIZATION)?;
let auth_str = auth_header.to_str().ok()?;
auth_str
.strip_prefix("Bearer ")
.map(|token| token.to_string())
}
}
impl Default for OAuth2Authenticator {
fn default() -> Self {
Self::new()
}
}
impl Authenticator for OAuth2Authenticator {
fn get_user(&self, req: &ServiceRequest) -> Option<User> {
let _token = self.extract_token(req)?;
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_oauth2_config_builder() {
let config = OAuth2Config::new("client-id", "secret", "http://localhost/callback")
.provider(OAuth2Provider::Google)
.add_scope("custom_scope");
assert_eq!(config.client_id, "client-id");
assert_eq!(config.provider, OAuth2Provider::Google);
assert!(config.scopes.contains(&"openid".to_string()));
assert!(config.scopes.contains(&"email".to_string()));
assert!(config.scopes.contains(&"custom_scope".to_string()));
assert!(config.use_pkce);
}
#[test]
fn test_oauth2_provider_endpoints() {
assert!(OAuth2Provider::Google.auth_url().is_some());
assert!(OAuth2Provider::Google.token_url().is_some());
assert!(OAuth2Provider::Google.userinfo_url().is_some());
assert!(OAuth2Provider::Google.supports_oidc());
assert!(OAuth2Provider::GitHub.auth_url().is_some());
assert!(!OAuth2Provider::GitHub.supports_oidc());
}
#[test]
fn test_oauth2_user() {
let mut user = OAuth2User::new("user123", "google");
user.email = Some("user@example.com".to_string());
user.name = Some("Test User".to_string());
assert_eq!(user.username(), "user@example.com");
let auth_user = user.to_user();
assert_eq!(auth_user.get_username(), "user@example.com");
assert!(auth_user.get_roles().contains(&"USER".to_string()));
}
#[test]
fn test_provider_default_scopes() {
let google_scopes = OAuth2Provider::Google.default_scopes();
assert!(google_scopes.contains(&"openid"));
assert!(google_scopes.contains(&"email"));
let github_scopes = OAuth2Provider::GitHub.default_scopes();
assert!(github_scopes.contains(&"read:user"));
}
#[test]
fn test_oauth2_client_basic() {
let config = OAuth2Config::new("client-id", "secret", "http://localhost/callback")
.provider(OAuth2Provider::GitHub);
let client = OAuth2Client::new_basic(config).unwrap();
assert!(!client.has_oidc());
}
}