use std::fmt::Debug; use std::sync::Arc; use actix::Addr; use actix_identity::Identity; use actix_web::error::ErrorUnauthorized; use actix_web::{web, HttpRequest, HttpResponse, Responder}; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::Engine as _; use light_openid::primitives::{OpenIDConfig, OpenIDTokenResponse, OpenIDUserInfo}; use crate::actors::openid_sessions_actor::{OpenIDSessionsActor, Session, SessionID}; use crate::actors::users_actor::UsersActor; use crate::actors::{openid_sessions_actor, users_actor}; use crate::constants::*; use crate::controllers::base_controller::{build_fatal_error_page, redirect_user}; use crate::data::action_logger::{Action, ActionLogger}; use crate::data::app_config::AppConfig; use crate::data::client::{AdditionalClaims, ClientID, ClientManager}; use crate::data::code_challenge::CodeChallenge; use crate::data::current_user::CurrentUser; use crate::data::id_token::IdToken; use crate::data::jwt_signer::{JWTSigner, JsonWebKey}; use crate::data::login_redirect::{get_2fa_url, LoginRedirect}; use crate::data::session_identity::SessionIdentity; use crate::data::user::User; use crate::utils::string_utils::rand_str; use crate::utils::time::time; pub async fn get_configuration(req: HttpRequest) -> impl Responder { let is_secure_request = req .headers() .get("HTTP_X_FORWARDED_PROTO") .or_else(|| req.headers().get("X-Forwarded-Proto")) .map(|v| v.to_str().unwrap_or_default().to_lowercase().eq("https")) .unwrap_or(false); let host = match req.headers().get("Host") { None => return HttpResponse::BadRequest().body("Missing Host header!"), Some(s) => s.to_str().unwrap_or_default(), }; let curr_origin = format!( "{}://{}", match is_secure_request { true => "https", false => "http", }, host ); HttpResponse::Ok() .insert_header(("access-control-allow-origin", "*")) .json(OpenIDConfig { issuer: AppConfig::get().website_origin.clone(), authorization_endpoint: AppConfig::get().full_url(AUTHORIZE_URI), token_endpoint: curr_origin.clone() + TOKEN_URI, userinfo_endpoint: Some(curr_origin.clone() + USERINFO_URI), jwks_uri: curr_origin + CERT_URI, scopes_supported: Some(vec![ "openid".to_string(), "profile".to_string(), "email".to_string(), ]), response_types_supported: vec![ "code".to_string(), "id_token".to_string(), "token id_token".to_string(), ], subject_types_supported: vec!["public".to_string()], id_token_signing_alg_values_supported: vec!["RS256".to_string()], token_endpoint_auth_methods_supported: Some(vec![ "client_secret_post".to_string(), "client_secret_basic".to_string(), ]), claims_supported: Some(vec![ "sub".to_string(), "name".to_string(), "given_name".to_string(), "family_name".to_string(), "email".to_string(), ]), code_challenge_methods_supported: Some(vec!["plain".to_string(), "S256".to_string()]), }) } #[derive(serde::Deserialize, Debug)] pub struct AuthorizeQuery { /// REQUIRED. OpenID Connect requests MUST contain the openid scope value. If the openid scope value is not present, the behavior is entirely unspecified. Other scope values MAY be present. Scope values used that are not understood by an implementation SHOULD be ignored. See Sections 5.4 and 11 for additional scope values defined by this specification. scope: String, /// REQUIRED. OAuth 2.0 Response Type value that determines the authorization processing flow to be used, including what parameters are returned from the endpoints used. When using the Authorization Code Flow, this value is code. response_type: String, /// REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server. client_id: ClientID, /// REQUIRED. Redirection URI to which the response will be sent. This URI MUST exactly match one of the Redirection URI values for the Client pre-registered at the OpenID Provider, with the matching performed as described in Section 6.2.1 of RFC3986 (Simple String Comparison). When using this flow, the Redirection URI SHOULD use the https scheme; however, it MAY use the http scheme, provided that the Client Type is confidential, as defined in Section 2.1 of OAuth 2.0, and provided the OP allows the use of http Redirection URIs in this case. The Redirection URI MAY use an alternate scheme, such as one that is intended to identify a callback into a native application. redirect_uri: String, /// RECOMMENDED. Opaque value used to maintain state between the request and the callback. Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this parameter with a browser cookie. state: Option, /// OPTIONAL. String value used to associate a Client session with an ID Token, and to mitigate replay attacks. The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST be present in the nonce values used to prevent attackers from guessing values. nonce: Option, /// OPTIONAL - code_challenge: Option, code_challenge_method: Option, } fn error_redirect(query: &AuthorizeQuery, error: &str, description: &str) -> HttpResponse { log::warn!( "Failed to process sign in request ({} => {}): {:?}", error, description, query ); HttpResponse::Found() .append_header(( "Location", format!( "{}?error={}?error_description={}{}", query.redirect_uri, urlencoding::encode(error), urlencoding::encode(description), match &query.state { Some(s) => format!("&state={}", urlencoding::encode(s)), None => "".to_string(), } ), )) .finish() } #[allow(clippy::too_many_arguments)] pub async fn authorize( req: HttpRequest, user: CurrentUser, id: Identity, query: web::Query, clients: web::Data>, sessions: web::Data>, logger: ActionLogger, jwt_signer: web::Data, ) -> actix_web::Result { let client = match clients.find_by_id(&query.client_id) { None => { return Ok( HttpResponse::BadRequest().body(build_fatal_error_page("Client is invalid!")) ); } Some(c) => c, }; // Check if 2FA is required if client.enforce_2fa_auth && user.should_request_2fa_for_critical_functions() { let uri = get_2fa_url(&LoginRedirect::from_req(&req), true); return Ok(redirect_user(&uri)); } // Validate specified redirect URI let redirect_uri = query.redirect_uri.trim().to_string(); if !redirect_uri.starts_with(&client.redirect_uri) { return Ok( HttpResponse::BadRequest().body(build_fatal_error_page("Redirect URI is invalid!")) ); } if !query.scope.split(' ').any(|x| x == "openid") { return Ok(error_redirect( &query, "invalid_request", "openid scope missing!", )); } if query.state.as_ref().map(String::is_empty).unwrap_or(false) { return Ok(error_redirect( &query, "invalid_request", "State is specified but empty!", )); } let code_challenge = match query.0.code_challenge.clone() { Some(chal) => { let meth = query.0.code_challenge_method.as_deref().unwrap_or("plain"); if !meth.eq("S256") && !meth.eq("plain") { return Ok(error_redirect( &query, "invalid_request", "Only S256 and plain code challenge methods are supported!", )); } Some(CodeChallenge { code_challenge: chal, code_challenge_method: meth.to_string(), }) } _ => None, }; // Check if user is authorized to access the application if !user.can_access_app(&client) { return Ok(error_redirect( &query, "invalid_request", "User is not authorized to access this application!", )); } // Check that requested authorization flow is supported if query.response_type != "code" && query.response_type != "id_token" { return Ok(error_redirect( &query, "invalid_request", "Unsupported authorization flow!", )); } match (client.has_secret(), query.response_type.as_str()) { (_, "code") => { // Save all authentication information in memory let session = Session { session_id: SessionID(rand_str(OPEN_ID_SESSION_LEN)), client: client.id.clone(), user: user.uid.clone(), auth_time: SessionIdentity(Some(&id)).auth_time(), redirect_uri, authorization_code: rand_str(OPEN_ID_AUTHORIZATION_CODE_LEN), authorization_code_expire_at: time() + OPEN_ID_AUTHORIZATION_CODE_TIMEOUT, access_token: None, access_token_expire_at: time() + OPEN_ID_ACCESS_TOKEN_TIMEOUT, refresh_token: "".to_string(), refresh_token_expire_at: 0, nonce: query.0.nonce, code_challenge, }; sessions .send(openid_sessions_actor::PushNewSession(session.clone())) .await .unwrap(); log::trace!("New OpenID session: {:#?}", session); logger.log(Action::NewOpenIDSession { client: &client }); Ok(HttpResponse::Found() .append_header(( "Location", format!( "{}?{}session_state={}&code={}", session.redirect_uri, match &query.0.state { Some(state) => format!("state={}&", urlencoding::encode(state)), None => "".to_string(), }, urlencoding::encode(&session.session_id.0), urlencoding::encode(&session.authorization_code) ), )) .finish()) } // id_token is available only if user has no secret configured (false, "id_token") => { let id_token = IdToken { issuer: AppConfig::get().website_origin.to_string(), subject_identifier: user.uid.0.clone(), audience: client.id.0.to_string(), expiration_time: time() + OPEN_ID_ID_TOKEN_TIMEOUT, issued_at: time(), auth_time: SessionIdentity(Some(&id)).auth_time(), nonce: query.nonce.clone(), email: user.email.clone(), additional_claims: client.claims_id_token(&user), }; log::trace!("New OpenID id token: {:#?}", &id_token); logger.log(Action::NewOpenIDSuccessfulImplicitAuth { client: &client }); Ok(HttpResponse::Found() .append_header(( "Location", format!( "{}?{}token_type=bearer&id_token={}&expires_in={OPEN_ID_ID_TOKEN_TIMEOUT}", client.redirect_uri, match &query.0.state { Some(state) => format!("state={}&", urlencoding::encode(state)), None => "".to_string(), }, jwt_signer.sign_token(id_token.to_jwt_claims())? ), )) .finish()) } (secret, code) => { log::warn!( "For client {:?}, configured with secret {:?}, made request with code {}", client.id, secret, code ); Ok(error_redirect( &query, "invalid_request", "Requested authentication flow is unsupported / not configured for this client!", )) } } } #[derive(serde::Serialize)] struct ErrorResponse { error: String, error_description: String, } pub fn error_response(query: &D, error: &str, description: &str) -> HttpResponse { log::warn!( "request failed: {} - {} => '{:#?}'", error, description, query ); HttpResponse::BadRequest().json(ErrorResponse { error: error.to_string(), error_description: description.to_string(), }) } #[derive(Debug, serde::Deserialize)] pub struct TokenAuthorizationCodeQuery { redirect_uri: String, code: String, code_verifier: Option, } #[derive(Debug, serde::Deserialize)] pub struct TokenRefreshTokenQuery { refresh_token: String, } #[derive(Debug, serde::Deserialize)] pub struct TokenQuery { grant_type: String, client_id: Option, client_secret: Option, #[serde(flatten)] authorization_code_query: Option, #[serde(flatten)] refresh_token_query: Option, } pub async fn token( req: HttpRequest, query: web::Form, clients: web::Data>, sessions: web::Data>, users: web::Data>, jwt_signer: web::Data, ) -> actix_web::Result { // Extraction authentication information let authorization_header = req.headers().get("authorization"); let (client_id, client_secret) = match (&query.client_id, &query.client_secret, authorization_header) { // post authentication (Some(client_id), client_secret, None) => (client_id.clone(), client_secret.clone()), // Basic authentication (_, None, Some(v)) => { let token = match v.to_str().unwrap_or_default().strip_prefix("Basic ") { None => { return Ok(error_response( &query, "invalid_request", &format!( "Authorization header does not start with 'Basic ', got '{v:#?}'" ), )); } Some(v) => v, }; let decode = String::from_utf8_lossy(&match BASE64_STANDARD.decode(token) { Ok(d) => d, Err(e) => { log::error!("Failed to decode authorization header: {:?}", e); return Ok(error_response( &query, "invalid_request", "Failed to decode authorization header!", )); } }) .to_string(); match decode.split_once(':') { None => (ClientID(decode), None), Some((id, secret)) => (ClientID(id.to_string()), Some(secret.to_string())), } } _ => { return Ok(error_response( &query, "invalid_request", "Client authentication method on token endpoint unsupported!", )); } }; let client = clients .find_by_id(&client_id) .ok_or_else(|| ErrorUnauthorized("Client not found"))?; // Retrieving token requires the client to have a defined secret if client.secret != client_secret { return Ok(error_response( &query, "invalid_request", "Client secret is invalid!", )); } let token_response = match ( query.grant_type.as_str(), &query.authorization_code_query, &query.refresh_token_query, ) { ("authorization_code", Some(q), _) => { let mut session: Session = match sessions .send(openid_sessions_actor::FindSessionByAuthorizationCode( q.code.clone(), )) .await .unwrap() { None => { return Ok(error_response( &query, "invalid_request", "Session not found!", )); } Some(s) => s, }; if session.client != client.id { return Ok(error_response( &query, "invalid_request", "Client mismatch!", )); } if session.redirect_uri != q.redirect_uri { return Ok(error_response( &query, "invalid_request", "Invalid redirect URI!", )); } if session.authorization_code_expire_at < time() { return Ok(error_response( &query, "invalid_request", "Authorization code expired!", )); } // Check code challenge, if needed if let Some(chall) = &session.code_challenge { let code_verifier = match &q.code_verifier { None => { return Ok(error_response( &query, "access_denied", "Code verifier missing", )); } Some(s) => s, }; if !chall.verify_code(code_verifier) { return Ok(error_response( &query, "invalid_grant", "Invalid code verifier", )); } } else if q.code_verifier.is_some() { return Ok(error_response( &query, "invalid_grant", "Unexpected `code_verifier` parameter!", )); } if session.access_token.is_some() { return Ok(error_response( &query, "invalid_request", "Authorization code already used!", )); } session.regenerate_access_and_refresh_tokens(AppConfig::get(), &jwt_signer)?; sessions .send(openid_sessions_actor::UpdateSession(session.clone())) .await .unwrap(); let user: Option = users .send(users_actor::GetUserRequest(session.user.clone())) .await .unwrap() .0; let user = match user { None => return Ok(error_response(&query, "invalid_request", "User not found!")), Some(u) => u, }; // Generate id token let id_token = IdToken { issuer: AppConfig::get().website_origin.to_string(), subject_identifier: session.user.0, audience: session.client.0.to_string(), expiration_time: session.access_token_expire_at, issued_at: time(), auth_time: session.auth_time, nonce: session.nonce, email: user.email.to_string(), additional_claims: client.claims_id_token(&user), }; OpenIDTokenResponse { access_token: session.access_token.expect("Missing access token!"), token_type: "Bearer".to_string(), refresh_token: Some(session.refresh_token), expires_in: Some(session.access_token_expire_at - time()), id_token: Some(jwt_signer.sign_token(id_token.to_jwt_claims())?), } } ("refresh_token", _, Some(q)) => { let mut session: Session = match sessions .send(openid_sessions_actor::FindSessionByRefreshToken( q.refresh_token.clone(), )) .await .unwrap() { None => { return Ok(error_response( &query, "invalid_request", "Session not found!", )); } Some(s) => s, }; if session.client != client.id { return Ok(error_response( &query, "invalid_request", "Client mismatch!", )); } if session.refresh_token_expire_at < time() { return Ok(error_response( &query, "access_denied", "Refresh token has expired!", )); } session.regenerate_access_and_refresh_tokens(AppConfig::get(), &jwt_signer)?; sessions .send(openid_sessions_actor::UpdateSession(session.clone())) .await .unwrap(); OpenIDTokenResponse { access_token: session.access_token.expect("Missing access token!"), token_type: "Bearer".to_string(), refresh_token: Some(session.refresh_token), expires_in: Some(session.access_token_expire_at - time()), id_token: None, } } _ => { return Ok(error_response( &query, "invalid_request", "Grant type unsupported!", )); } }; Ok(HttpResponse::Ok() .insert_header(("Cache-Control", "no-store")) .insert_header(("Pragma", "no-cache")) .insert_header(("access-control-allow-origin", "*")) .json(token_response)) } #[derive(serde::Serialize)] struct CertsResponse { keys: Vec, } pub async fn cert_uri(jwt_signer: web::Data) -> impl Responder { HttpResponse::Ok().json(CertsResponse { keys: vec![jwt_signer.get_json_web_key()], }) } fn user_info_error(err: &str, description: &str) -> HttpResponse { HttpResponse::Unauthorized() .insert_header(( "WWW-Authenticate", format!("Bearer error=\"{err}\", error_description=\"{description}\""), )) .finish() } #[derive(serde::Deserialize)] pub struct UserInfoQuery { access_token: Option, } pub async fn user_info_post( req: HttpRequest, form: Option>, query: web::Query, sessions: web::Data>, users: web::Data>, clients: web::Data>, ) -> impl Responder { user_info( req, form.map(|f| f.0.access_token) .unwrap_or_default() .or(query.0.access_token), sessions, users, clients, ) .await } pub async fn user_info_get( req: HttpRequest, query: web::Query, sessions: web::Data>, users: web::Data>, clients: web::Data>, ) -> impl Responder { user_info(req, query.0.access_token, sessions, users, clients).await } #[derive(serde::Serialize)] pub struct UserInfoWithCustomClaims { #[serde(flatten)] info: OpenIDUserInfo, #[serde(skip_serializing_if = "Option::is_none")] #[serde(flatten)] additional_claims: Option, } /// Authenticate request using RFC6750 /// async fn user_info( req: HttpRequest, token: Option, sessions: web::Data>, users: web::Data>, clients: web::Data>, ) -> impl Responder { let token = match token { Some(t) => t, None => { let token = match req.headers().get("Authorization") { None => return user_info_error("invalid_request", "Missing access token!"), Some(t) => t, }; let token = match token.to_str() { Err(_) => return user_info_error("invalid_request", "Failed to decode token!"), Ok(t) => t, }; let token = match token.strip_prefix("Bearer ") { None => { return user_info_error( "invalid_request", "Header token does not start with 'Bearer '!", ); } Some(t) => t, }; token.to_string() } }; let session: Option = sessions .send(openid_sessions_actor::FindSessionByAccessToken(token)) .await .unwrap(); let session = match session { None => { return user_info_error("invalid_request", "Session not found!"); } Some(s) => s, }; if session.access_token_expire_at < time() { return user_info_error("invalid_request", "Access token has expired!"); } let client = clients .find_by_id(&session.client) .expect("Could not extract client information!"); let user: Option = users .send(users_actor::GetUserRequest(session.user)) .await .unwrap() .0; let user = match user { None => { return user_info_error("invalid_request", "Failed to extract user information!"); } Some(u) => u, }; HttpResponse::Ok().json(UserInfoWithCustomClaims { info: OpenIDUserInfo { name: Some(user.full_name()), sub: user.uid.0.to_string(), given_name: Some(user.first_name.to_string()), family_name: Some(user.last_name.to_string()), preferred_username: Some(user.username.to_string()), email: Some(user.email.to_string()), email_verified: Some(true), }, additional_claims: client.claims_user_info(&user), }) }