haste_server/auth_n/oidc/routes/federated/
initiate.rs

1use crate::{
2    ServerEnvironmentVariables,
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::AppState,
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::{IdentityProvider, Project as FHIRProject, Resource, ResourceType},
22    terminology::{IdentityProviderPkceChallengeMethod, IssueType},
23};
24use haste_fhir_operation_error::OperationOutcomeError;
25use haste_fhir_search::SearchEngine;
26use haste_fhir_terminology::FHIRTerminology;
27use haste_jwt::{ProjectId, TenantId};
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                if ref_id.as_str() == &format!("IdentityProvider/{}", identity_provider_id) {
50                    return Ok(());
51                }
52            }
53        }
54    }
55    Err(OperationOutcomeError::error(
56        IssueType::Forbidden(None),
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    identity_provider_id: String,
69) -> Result<IdentityProvider, OperationOutcomeError> {
70    let identity_provider = fhir_client
71        .read(
72            Arc::new(ServerCTX::system(
73                tenant.clone(),
74                ProjectId::System,
75                fhir_client.clone(),
76            )),
77            ResourceType::IdentityProvider,
78            identity_provider_id,
79        )
80        .await?
81        .and_then(|r| match r {
82            Resource::IdentityProvider(ip) => Some(ip),
83            _ => None,
84        })
85        .ok_or_else(|| {
86            OperationOutcomeError::error(
87                IssueType::NotFound(None),
88                "The specified identity provider was not found.".to_string(),
89            )
90        })?;
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.id.as_ref().ok_or_else(|| {
112        OperationOutcomeError::error(
113            IssueType::Invalid(None),
114            "Identity Provider resource is missing an ID.".to_string(),
115        )
116    })?;
117
118    let info: IDPSessionInfo = session
119        .get(federated_session_info_key(idp_id).as_str())
120        .await
121        .map_err(|_| {
122            OperationOutcomeError::error(
123                IssueType::Exception(None),
124                "Failed to retrieve session information.".to_string(),
125            )
126        })?
127        .ok_or_else(|| {
128            OperationOutcomeError::error(
129                IssueType::NotFound(None),
130                "No session information found for the specified identity provider.".to_string(),
131            )
132        })?;
133
134    Ok(info)
135}
136
137async fn set_session_info(
138    session: &mut Session,
139    project_id: ProjectId,
140    idp: &IdentityProvider,
141    uri: &OriginalUri,
142) -> Result<IDPSessionInfo, OperationOutcomeError> {
143    let idp_id = idp.id.as_ref().ok_or_else(|| {
144        OperationOutcomeError::error(
145            IssueType::Invalid(None),
146            "Identity Provider resource is missing an ID.".to_string(),
147        )
148    })?;
149
150    let state = generate_id(Some(20));
151
152    let mut info = IDPSessionInfo {
153        state,
154        redirect_to: redirect_authorize_uri(
155            uri,
156            &FederatedInitiate {
157                identity_provider_id: idp_id.clone(),
158            }
159            .to_string(),
160        ),
161        project: project_id,
162        code_verifier: None,
163    };
164
165    if let Some(oidc) = &idp.oidc {
166        if let Some(pkce) = &oidc.pkce {
167            if pkce.enabled.as_ref().and_then(|b| b.value).unwrap_or(false) {
168                let code_verifier = generate_code_verifier();
169                info.code_verifier = Some(code_verifier);
170            }
171        }
172    }
173
174    session
175        .insert(federated_session_info_key(idp_id).as_str(), &info)
176        .await
177        .map_err(|_| {
178            OperationOutcomeError::error(
179                IssueType::Exception(None),
180                "Failed to set session information.".to_string(),
181            )
182        })?;
183
184    Ok(info)
185}
186
187fn oidc_pkce_challenge_method(
188    challenge: &IdentityProviderPkceChallengeMethod,
189) -> Option<PKCECodeChallengeMethod> {
190    match challenge {
191        IdentityProviderPkceChallengeMethod::S256(None) => Some(PKCECodeChallengeMethod::S256),
192        IdentityProviderPkceChallengeMethod::Plain(None) => Some(PKCECodeChallengeMethod::Plain),
193        _ => None,
194    }
195}
196
197async fn create_federated_authorization_url(
198    session: &mut Session,
199    tenant: &TenantId,
200    project: ProjectId,
201    api_uri: &str,
202    original_uri: &OriginalUri,
203    identity_provider: &IdentityProvider,
204) -> Result<Url, OperationOutcomeError> {
205    if let Some(oidc) = &identity_provider.oidc {
206        let mut authorization_url = oidc
207            .authorization_endpoint
208            .value
209            .as_ref()
210            .and_then(|s| Url::parse(s).ok())
211            .ok_or_else(|| {
212                OperationOutcomeError::error(
213                    IssueType::Invalid(None),
214                    "Invalid authorization endpoint URL for identity provider".to_string(),
215                )
216            })?;
217
218        let client_id = oidc.client.clientId.value.as_ref().ok_or_else(|| {
219            OperationOutcomeError::error(
220                IssueType::Invalid(None),
221                "Missing client ID for identity provider.".to_string(),
222            )
223        })?;
224
225        let scopes = oidc.scopes.as_ref().map(|s| {
226            s.iter()
227                .filter_map(|v| v.value.as_ref())
228                .map(|s| s.as_str())
229                .collect::<Vec<_>>()
230                .join(" ")
231        });
232
233        authorization_url.set_query(Some("response_type=code"));
234        authorization_url
235            .query_pairs_mut()
236            .append_pair("client_id", client_id)
237            .append_pair("scope", &scopes.unwrap_or_default())
238            .append_pair(
239                "redirect_uri",
240                &create_federated_callback_url(
241                    api_uri,
242                    tenant,
243                    &identity_provider.id.clone().unwrap_or_default(),
244                )?,
245            );
246
247        let info = set_session_info(session, project, &identity_provider, original_uri).await?;
248        authorization_url
249            .query_pairs_mut()
250            .append_pair("state", &info.state);
251        if let Some(code_verifier) = info.code_verifier
252            && let Some(challenge_method) = oidc
253                .pkce
254                .as_ref()
255                .and_then(|p| p.code_challenge_method.as_ref())
256                .and_then(|c| oidc_pkce_challenge_method(c))
257        {
258            let code_challenge = generate_code_challenge(&code_verifier, &challenge_method)?;
259            authorization_url
260                .query_pairs_mut()
261                .append_pair("code_challenge", &code_challenge);
262            authorization_url
263                .query_pairs_mut()
264                .append_pair("code_challenge_method", &String::from(challenge_method));
265        }
266
267        Ok(authorization_url)
268    } else {
269        return Err(OperationOutcomeError::error(
270            IssueType::NotFound(None),
271            "The specified identity provider was not found.".to_string(),
272        ));
273    }
274}
275
276pub async fn federated_initiate<
277    Repo: Repository + Send + Sync,
278    Search: SearchEngine + Send + Sync,
279    Terminology: FHIRTerminology + Send + Sync,
280>(
281    FederatedInitiate {
282        identity_provider_id,
283    }: FederatedInitiate,
284    Cached(mut current_session): Cached<Session>,
285    uri: OriginalUri,
286    State(state): State<Arc<AppState<Repo, Search, Terminology>>>,
287    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
288    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
289    Cached(Project(project_resource)): Cached<Project>,
290    OIDCClientApplication(_client_app): OIDCClientApplication,
291    _uri: OriginalUri,
292) -> Result<Redirect, OperationOutcomeError> {
293    let api_uri = state.config.get(ServerEnvironmentVariables::APIURI)?;
294    validate_identity_provider_in_project(&identity_provider_id, &project_resource)?;
295    let identity_provider =
296        get_idp(&tenant, state.fhir_client.clone(), identity_provider_id).await?;
297
298    let federated_authorization_url = create_federated_authorization_url(
299        &mut current_session,
300        &tenant,
301        project,
302        &api_uri,
303        &uri,
304        &identity_provider,
305    )
306    .await?;
307
308    Ok(Redirect::to(federated_authorization_url.as_str()))
309}