haste_server/auth_n/oidc/routes/federated/
initiate.rs1use crate::auth_n::error::AuthError;
2use crate::{
3 auth_n::oidc::{
4 code_verification::{generate_code_challenge, generate_code_verifier},
5 extract::client_app::OIDCClientApplication,
6 routes::{
7 authorize::redirect_authorize_uri, federated::callback::create_federated_callback_url,
8 },
9 },
10 extract::path_tenant::{Project, ProjectIdentifier, TenantIdentifier},
11 fhir_client::{FHIRServerClient, ServerCTX},
12 services::ServerState,
13};
14use axum::{
15 extract::{OriginalUri, State},
16 response::Redirect,
17};
18use axum_extra::{extract::Cached, routing::TypedPath};
19use haste_fhir_client::FHIRClient;
20use haste_fhir_model::r4::generated::{
21 resources::{
22 IdentityProvider, IdentityProviderOidc, Project as FHIRProject, Resource, ResourceType,
23 },
24 terminology::{BoundCode, IdentityProviderPkceChallengeMethod, IssueType},
25};
26use haste_fhir_operation_error::OperationOutcomeError;
27use haste_fhir_search::SearchEngine;
28use haste_fhir_terminology::FHIRTerminology;
29use haste_jwt::{ProjectId, TenantId};
30use haste_rate_limit::RateLimit;
31use haste_repository::{
32 Repository, types::authorization_code::PKCECodeChallengeMethod, utilities::generate_id,
33};
34use serde::{Deserialize, Serialize};
35use std::sync::Arc;
36use tower_sessions::Session;
37use url::Url;
38
39#[derive(TypedPath, Deserialize)]
40#[typed_path("/federated/{identity_provider_id}/initiate")]
41pub struct FederatedInitiate {
42 pub identity_provider_id: String,
43}
44
45pub fn validate_identity_provider_in_project(
46 identity_provider_id: &str,
47 project: &FHIRProject,
48) -> Result<(), OperationOutcomeError> {
49 if let Some(identity_providers) = &project.identityProvider {
50 for ip_ref in identity_providers {
51 if let Some(ref_id) = &ip_ref.reference.as_ref().and_then(|r| r.value.as_ref())
52 && ref_id.as_str() == format!("IdentityProvider/{}", identity_provider_id)
53 {
54 return Ok(());
55 }
56 }
57 }
58 Err(OperationOutcomeError::error(
59 IssueType::forbidden(),
60 "The specified identity provider is not associated with the project.".to_string(),
61 ))
62}
63
64pub async fn get_idp<
65 Repo: Repository + Send + Sync,
66 Search: SearchEngine + Send + Sync,
67 Terminology: FHIRTerminology + Send + Sync,
68>(
69 tenant: &TenantId,
70 fhir_client: Arc<FHIRServerClient<Repo, Search, Terminology>>,
71 rate_limit: Arc<dyn RateLimit>,
72 identity_provider_id: String,
73) -> Result<IdentityProvider, OperationOutcomeError> {
74 let identity_provider = fhir_client
75 .read(
76 Arc::new(ServerCTX::system(
77 tenant.clone(),
78 ProjectId::System,
79 fhir_client.clone(),
80 rate_limit.clone(),
81 )),
82 ResourceType::IdentityProvider,
83 identity_provider_id,
84 )
85 .await?
86 .and_then(|r| match r {
87 Resource::IdentityProvider(ip) => Some(ip),
88 _ => None,
89 })
90 .ok_or(AuthError::IdentityProviderNotFound)?;
91
92 Ok(identity_provider)
93}
94
95#[derive(Deserialize, Serialize, Clone)]
96pub struct IDPSessionInfo {
97 pub state: String,
98 pub redirect_to: String,
99 pub project: ProjectId,
100 pub code_verifier: Option<String>,
101}
102
103fn federated_session_info_key(idp_id: &str) -> String {
104 format!("federated_initiate_{}", idp_id)
105}
106
107pub async fn get_idp_session_info(
108 session: &Session,
109 idp: &IdentityProvider,
110) -> Result<IDPSessionInfo, OperationOutcomeError> {
111 let idp_id = idp
112 .id
113 .as_ref()
114 .ok_or(AuthError::IdentityProviderMissingId)?;
115
116 let info: IDPSessionInfo = session
117 .get(federated_session_info_key(idp_id).as_str())
118 .await
119 .map_err(|_| {
120 OperationOutcomeError::error(
121 IssueType::exception(),
122 "Failed to retrieve session information.".to_string(),
123 )
124 })?
125 .ok_or_else(|| {
126 OperationOutcomeError::error(
127 IssueType::not_found(),
128 "No session information found for the specified identity provider.".to_string(),
129 )
130 })?;
131
132 Ok(info)
133}
134
135async fn set_session_info(
136 session: &mut Session,
137 project_id: ProjectId,
138 idp: &IdentityProvider,
139 uri: &OriginalUri,
140) -> Result<IDPSessionInfo, OperationOutcomeError> {
141 let idp_id = idp
142 .id
143 .as_ref()
144 .ok_or(AuthError::IdentityProviderMissingId)?;
145
146 let state = generate_id(Some(20));
147
148 let mut info = IDPSessionInfo {
149 state,
150 redirect_to: redirect_authorize_uri(
151 uri,
152 &FederatedInitiate {
153 identity_provider_id: idp_id.clone(),
154 }
155 .to_string(),
156 ),
157 project: project_id,
158 code_verifier: None,
159 };
160
161 if let Some(oidc) = &idp.oidc
162 && let Some(pkce) = &oidc.pkce
163 && pkce.enabled.as_ref().and_then(|b| b.value).unwrap_or(false)
164 {
165 let code_verifier = generate_code_verifier();
166 info.code_verifier = Some(code_verifier);
167 }
168
169 session
170 .insert(federated_session_info_key(idp_id).as_str(), &info)
171 .await
172 .map_err(|_| {
173 OperationOutcomeError::error(
174 IssueType::exception(),
175 "Failed to set session information.".to_string(),
176 )
177 })?;
178
179 Ok(info)
180}
181
182fn federated_scopes(oidc: &IdentityProviderOidc) -> String {
190 let configured = oidc
191 .scopes
192 .iter()
193 .flatten()
194 .filter_map(|scope| scope.value.as_deref())
195 .filter(|scope| !scope.is_empty() && *scope != "openid");
196
197 std::iter::once("openid")
198 .chain(configured)
199 .collect::<Vec<_>>()
200 .join(" ")
201}
202
203fn oidc_pkce_challenge_method(
204 challenge: &BoundCode<IdentityProviderPkceChallengeMethod>,
205) -> Option<PKCECodeChallengeMethod> {
206 if challenge == &IdentityProviderPkceChallengeMethod::s256() {
207 Some(PKCECodeChallengeMethod::S256)
208 } else {
209 None
210 }
211}
212
213async fn create_federated_authorization_url(
214 session: &mut Session,
215 tenant: &TenantId,
216 project: ProjectId,
217 api_uri: &str,
218 original_uri: &OriginalUri,
219 identity_provider: &IdentityProvider,
220) -> Result<Url, OperationOutcomeError> {
221 if let Some(oidc) = &identity_provider.oidc {
222 let mut authorization_url = oidc
223 .authorization_endpoint
224 .value
225 .as_ref()
226 .and_then(|s| Url::parse(s).ok())
227 .ok_or_else(|| {
228 OperationOutcomeError::error(
229 IssueType::invalid(),
230 "Invalid authorization endpoint URL for identity provider".to_string(),
231 )
232 })?;
233
234 let client_id = oidc.client.clientId.value.as_ref().ok_or_else(|| {
235 OperationOutcomeError::error(
236 IssueType::invalid(),
237 "Missing client ID for identity provider.".to_string(),
238 )
239 })?;
240
241 authorization_url.set_query(Some("response_type=code"));
242 authorization_url
243 .query_pairs_mut()
244 .append_pair("client_id", client_id)
245 .append_pair("scope", &federated_scopes(oidc))
246 .append_pair(
247 "redirect_uri",
248 &create_federated_callback_url(
249 api_uri,
250 tenant,
251 &identity_provider.id.clone().unwrap_or_default(),
252 )?,
253 );
254
255 let info = set_session_info(session, project, identity_provider, original_uri).await?;
256 authorization_url
257 .query_pairs_mut()
258 .append_pair("state", &info.state);
259 if let Some(code_verifier) = info.code_verifier
260 && let Some(challenge_method) = oidc
261 .pkce
262 .as_ref()
263 .and_then(|p| p.code_challenge_method.as_ref())
264 .and_then(oidc_pkce_challenge_method)
265 {
266 let code_challenge = generate_code_challenge(&code_verifier, &challenge_method)?;
267 authorization_url
268 .query_pairs_mut()
269 .append_pair("code_challenge", &code_challenge);
270 authorization_url
271 .query_pairs_mut()
272 .append_pair("code_challenge_method", &String::from(challenge_method));
273 }
274
275 Ok(authorization_url)
276 } else {
277 Err(AuthError::IdentityProviderNotFound.into())
278 }
279}
280
281pub async fn federated_initiate<
282 Repo: Repository + Send + Sync,
283 Search: SearchEngine + Send + Sync,
284 Terminology: FHIRTerminology + Send + Sync,
285>(
286 FederatedInitiate {
287 identity_provider_id,
288 }: FederatedInitiate,
289 Cached(mut current_session): Cached<Session>,
290 uri: OriginalUri,
291 State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
292 Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
293 Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
294 Cached(Project(project_resource)): Cached<Project>,
295 OIDCClientApplication(_client_app): OIDCClientApplication,
296 _uri: OriginalUri,
297) -> Result<Redirect, OperationOutcomeError> {
298 let api_uri = &state.config.api_uri;
299 validate_identity_provider_in_project(&identity_provider_id, &project_resource)?;
300 let identity_provider = get_idp(
301 &tenant,
302 state.fhir_client.clone(),
303 state.rate_limit.clone(),
304 identity_provider_id,
305 )
306 .await?;
307
308 let federated_authorization_url = create_federated_authorization_url(
309 &mut current_session,
310 &tenant,
311 project,
312 api_uri,
313 &uri,
314 &identity_provider,
315 )
316 .await?;
317
318 Ok(Redirect::to(federated_authorization_url.as_str()))
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use haste_fhir_model::r4::generated::resources::IdentityProviderOidcClient;
325
326 fn oidc(scopes: Option<Vec<&str>>) -> IdentityProviderOidc {
327 IdentityProviderOidc {
328 scopes: scopes.map(|scopes| {
329 scopes
330 .into_iter()
331 .map(|scope| haste_fhir_model::r4::generated::types::FHIRString {
332 value: Some(scope.to_string()),
333 ..Default::default()
334 })
335 .collect()
336 }),
337 client: IdentityProviderOidcClient::default(),
338 ..Default::default()
339 }
340 }
341
342 #[test]
343 fn openid_is_requested_when_not_configured() {
344 assert_eq!(federated_scopes(&oidc(None)), "openid");
345 assert_eq!(federated_scopes(&oidc(Some(vec![]))), "openid");
346 assert_eq!(
347 federated_scopes(&oidc(Some(vec!["profile", "email"]))),
348 "openid profile email"
349 );
350 }
351
352 #[test]
353 fn openid_is_not_requested_twice() {
354 assert_eq!(
355 federated_scopes(&oidc(Some(vec!["openid", "profile"]))),
356 "openid profile"
357 );
358 }
359}