From 6a5f963b43851e1720f17df24d58b413ea6d2926 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Mon, 30 Sep 2024 16:57:34 +0200 Subject: OpenID Connect implementation (closes #298) --- crates/jmap/src/auth/oauth/auth.rs | 81 +++++++++++++++++++----- crates/jmap/src/auth/oauth/mod.rs | 35 +---------- crates/jmap/src/auth/oauth/openid.rs | 116 +++++++++++++++++++++++++++++++++++ crates/jmap/src/auth/oauth/token.rs | 39 ++++++++---- 4 files changed, 214 insertions(+), 57 deletions(-) create mode 100644 crates/jmap/src/auth/oauth/openid.rs (limited to 'crates/jmap/src/auth/oauth') diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index 9cb93a3f..44179cff 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -14,6 +14,7 @@ use common::{ Server, }; use rand::distributions::Standard; +use serde::Deserialize; use serde_json::json; use std::future::Future; use store::{ @@ -23,12 +24,27 @@ use store::{ }; use crate::{ - api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, + api::{ + http::{HttpContext, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, + }, auth::oauth::OAuthStatus, }; use super::{DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, MAX_POST_LEN}; +#[derive(Debug, serde::Serialize, Deserialize)] +pub struct OAuthMetadata { + pub issuer: String, + pub token_endpoint: String, + pub authorization_endpoint: String, + pub device_authorization_endpoint: String, + pub introspection_endpoint: String, + pub grant_types_supported: Vec, + pub response_types_supported: Vec, + pub scopes_supported: Vec, +} + pub trait OAuthApiHandler: Sync + Send { fn handle_oauth_api_request( &self, @@ -39,8 +55,13 @@ pub trait OAuthApiHandler: Sync + Send { fn handle_device_auth( &self, req: &mut HttpRequest, - base_url: impl AsRef + Send, - session_id: u64, + session: HttpSessionData, + ) -> impl Future> + Send; + + fn handle_oauth_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, ) -> impl Future> + Send; } @@ -98,7 +119,7 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{client_code}").into_bytes(), value, - self.core.jmap.oauth_expiry_auth_code.into(), + self.core.oauth.oauth_expiry_auth_code.into(), ) .await?; @@ -146,7 +167,7 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{device_code}").into_bytes(), auth_code.serialize(), - self.core.jmap.oauth_expiry_auth_code.into(), + self.core.oauth.oauth_expiry_auth_code.into(), ) .await?; } @@ -164,11 +185,10 @@ impl OAuthApiHandler for Server { async fn handle_device_auth( &self, req: &mut HttpRequest, - base_url: impl AsRef, - session_id: u64, + session: HttpSessionData, ) -> trc::Result { // Parse form - let client_id = FormData::from_request(req, MAX_POST_LEN, session_id) + let client_id = FormData::from_request(req, MAX_POST_LEN, session.session_id) .await? .remove("client_id") .filter(|client_id| client_id.len() < CLIENT_ID_MAX_LEN) @@ -215,7 +235,7 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{device_code}").into_bytes(), oauth_code.clone(), - self.core.jmap.oauth_expiry_user_code.into(), + self.core.oauth.oauth_expiry_user_code.into(), ) .await?; @@ -226,20 +246,53 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{user_code}").into_bytes(), oauth_code, - self.core.jmap.oauth_expiry_user_code.into(), + self.core.oauth.oauth_expiry_user_code.into(), ) .await?; // Build response - let base_url = base_url.as_ref(); + let base_url = HttpContext::new(&session, req) + .resolve_response_url(self) + .await; Ok(JsonResponse::new(DeviceAuthResponse { - verification_uri: format!("{}/authorize", base_url), - verification_uri_complete: format!("{}/authorize/?code={}", base_url, user_code), + verification_uri: format!("{base_url}/authorize"), + verification_uri_complete: format!("{base_url}/authorize/?code={user_code}"), device_code, user_code, - expires_in: self.core.jmap.oauth_expiry_user_code, + expires_in: self.core.oauth.oauth_expiry_user_code, interval: 5, }) .into_http_response()) } + + async fn handle_oauth_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + let base_url = HttpContext::new(&session, &req) + .resolve_response_url(self) + .await; + + Ok(JsonResponse::new(OAuthMetadata { + authorization_endpoint: format!("{base_url}/authorize/code",), + token_endpoint: format!("{base_url}/auth/token"), + grant_types_supported: vec![ + "authorization_code".to_string(), + "implicit".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ], + device_authorization_endpoint: format!("{base_url}/auth/device"), + response_types_supported: vec![ + "code".to_string(), + "id_token".to_string(), + "code token".to_string(), + "id_token token".to_string(), + ], + scopes_supported: vec!["openid".to_string(), "offline_access".to_string()], + introspection_endpoint: format!("{base_url}/auth/introspect"), + issuer: base_url, + }) + .into_http_response()) + } } diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index eaa8c0c1..14d1caa9 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -11,6 +11,7 @@ use utils::map::vec_map::VecMap; use crate::api::{http::fetch_body, HttpRequest}; pub mod auth; +pub mod openid; pub mod token; #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -110,6 +111,8 @@ pub struct OAuthResponse { pub refresh_token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -136,38 +139,6 @@ pub enum ErrorType { ExpiredToken, } -#[derive(Debug, Serialize, Deserialize)] -pub struct OAuthMetadata { - pub issuer: String, - pub token_endpoint: String, - pub authorization_endpoint: String, - pub device_authorization_endpoint: String, - pub introspection_endpoint: String, - pub grant_types_supported: Vec, - pub response_types_supported: Vec, - pub scopes_supported: Vec, -} - -impl OAuthMetadata { - pub fn new(base_url: impl AsRef) -> Self { - let base_url = base_url.as_ref(); - OAuthMetadata { - issuer: base_url.into(), - authorization_endpoint: format!("{base_url}/authorize/code",), - token_endpoint: format!("{base_url}/auth/token"), - grant_types_supported: vec![ - "authorization_code".to_string(), - "implicit".to_string(), - "urn:ietf:params:oauth:grant-type:device_code".to_string(), - ], - device_authorization_endpoint: format!("{base_url}/auth/device"), - response_types_supported: vec!["code".to_string(), "code token".to_string()], - scopes_supported: vec!["offline_access".to_string()], - introspection_endpoint: format!("{base_url}/auth/introspect"), - } - } -} - #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum OAuthCodeRequest { diff --git a/crates/jmap/src/auth/oauth/openid.rs b/crates/jmap/src/auth/oauth/openid.rs new file mode 100644 index 00000000..47825c6c --- /dev/null +++ b/crates/jmap/src/auth/oauth/openid.rs @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::{ + auth::{oauth::oidc::Userinfo, AccessToken}, + Server, +}; +use serde::{Deserialize, Serialize}; + +use crate::api::{ + http::{HttpContext, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenIdMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, + pub jwks_uri: String, + pub registration_endpoint: String, + pub scopes_supported: Vec, + pub response_types_supported: Vec, + pub subject_types_supported: Vec, + pub grant_types_supported: Vec, + pub id_token_signing_alg_values_supported: Vec, + pub claims_supported: Vec, +} + +pub trait OpenIdHandler: Sync + Send { + fn handle_userinfo_request( + &self, + access_token: &AccessToken, + ) -> impl Future> + Send; + + fn handle_oidc_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> impl Future> + Send; +} + +impl OpenIdHandler for Server { + async fn handle_userinfo_request( + &self, + access_token: &AccessToken, + ) -> trc::Result { + Ok(JsonResponse::new(Userinfo { + sub: Some(access_token.primary_id.to_string()), + name: access_token.description.clone(), + preferred_username: Some(access_token.name.clone()), + email: access_token.emails.first().cloned(), + email_verified: !access_token.emails.is_empty(), + ..Default::default() + }) + .into_http_response()) + } + + async fn handle_oidc_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + let base_url = HttpContext::new(&session, &req) + .resolve_response_url(self) + .await; + + Ok(JsonResponse::new(OpenIdMetadata { + authorization_endpoint: format!("{base_url}/authorize/code",), + token_endpoint: format!("{base_url}/auth/token"), + userinfo_endpoint: format!("{base_url}/auth/userinfo"), + jwks_uri: format!("{base_url}/auth/jwks.json"), + registration_endpoint: format!("{base_url}/auth/register"), + response_types_supported: vec![ + "code".to_string(), + "id_token".to_string(), + "id_token token".to_string(), + ], + grant_types_supported: vec![ + "authorization_code".to_string(), + "implicit".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ], + scopes_supported: vec!["openid".to_string(), "offline_access".to_string()], + subject_types_supported: vec!["public".to_string()], + id_token_signing_alg_values_supported: vec![ + "RS256".to_string(), + "RS384".to_string(), + "RS512".to_string(), + "ES256".to_string(), + "ES384".to_string(), + "PS256".to_string(), + "PS384".to_string(), + "PS512".to_string(), + "HS256".to_string(), + "HS384".to_string(), + "HS512".to_string(), + ], + claims_supported: vec![ + "sub".to_string(), + "name".to_string(), + "preferred_username".to_string(), + "email".to_string(), + "email_verified".to_string(), + ], + issuer: base_url, + }) + .into_http_response()) + } +} diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 49046746..677028b9 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -12,7 +12,10 @@ use hyper::StatusCode; use std::future::Future; use store::write::Bincode; -use crate::api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}; +use crate::api::{ + http::{HttpContext, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, +}; use super::{ ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN, @@ -22,7 +25,7 @@ pub trait TokenHandler: Sync + Send { fn handle_token_request( &self, req: &mut HttpRequest, - session_id: u64, + session: HttpSessionData, ) -> impl Future> + Send; fn handle_token_introspect( @@ -36,6 +39,7 @@ pub trait TokenHandler: Sync + Send { &self, account_id: u32, client_id: &str, + issuer: String, with_refresh_token: bool, ) -> impl Future> + Send; } @@ -45,14 +49,18 @@ impl TokenHandler for Server { async fn handle_token_request( &self, req: &mut HttpRequest, - session_id: u64, + session: HttpSessionData, ) -> trc::Result { // Parse form - let params = FormData::from_request(req, MAX_POST_LEN, session_id).await?; + let params = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?; let grant_type = params.get("grant_type").unwrap_or_default(); let mut response = TokenResponse::error(ErrorType::InvalidGrant); + let issuer = HttpContext::new(&session, req) + .resolve_response_url(self) + .await; + if grant_type.eq_ignore_ascii_case("authorization_code") { response = if let (Some(code), Some(client_id), Some(redirect_uri)) = ( params.get("code"), @@ -80,7 +88,7 @@ impl TokenHandler for Server { .await?; // Issue token - self.issue_token(oauth.account_id, &oauth.client_id, true) + self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) .await .map(TokenResponse::Granted) .map_err(|err| { @@ -126,7 +134,7 @@ impl TokenHandler for Server { .await?; // Issue token - self.issue_token(oauth.account_id, &oauth.client_id, true) + self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) .await .map(TokenResponse::Granted) .map_err(|err| { @@ -156,8 +164,9 @@ impl TokenHandler for Server { .issue_token( token_info.account_id, &token_info.client_id, + issuer, token_info.expires_in - <= self.core.jmap.oauth_expiry_refresh_token_renew, + <= self.core.oauth.oauth_expiry_refresh_token_renew, ) .await .map(TokenResponse::Granted) @@ -171,7 +180,7 @@ impl TokenHandler for Server { trc::error!(err .caused_by(trc::location!()) .details("Failed to validate refresh token") - .span_id(session_id)); + .span_id(session.session_id)); TokenResponse::error(ErrorType::InvalidGrant) } }; @@ -216,6 +225,7 @@ impl TokenHandler for Server { &self, account_id: u32, client_id: &str, + issuer: String, with_refresh_token: bool, ) -> trc::Result { Ok(OAuthResponse { @@ -224,23 +234,30 @@ impl TokenHandler for Server { GrantType::AccessToken, account_id, client_id, - self.core.jmap.oauth_expiry_token, + self.core.oauth.oauth_expiry_token, ) .await?, token_type: "bearer".to_string(), - expires_in: self.core.jmap.oauth_expiry_token, + expires_in: self.core.oauth.oauth_expiry_token, refresh_token: if with_refresh_token { self.encode_access_token( GrantType::RefreshToken, account_id, client_id, - self.core.jmap.oauth_expiry_refresh_token, + self.core.oauth.oauth_expiry_refresh_token, ) .await? .into() } else { None }, + id_token: match self.issue_id_token(account_id.to_string(), issuer, client_id) { + Ok(id_token) => Some(id_token), + Err(err) => { + trc::error!(err); + None + } + }, scope: None, }) } -- cgit v1.2.3-70-g09d2