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