Skip to main content

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

1use 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
182/// Scopes sent to the identity provider's authorization endpoint.
183///
184/// `openid` is always requested. The callback identifies the user from the
185/// `sub` claim of the id token, and a provider only returns an id token when
186/// the request is an OIDC one, so leaving `openid` out of
187/// `IdentityProvider.oidc.scopes` breaks the sign-in after the user has
188/// already authenticated with the provider.
189fn 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}