Skip to main content

haste_server/auth_n/middleware/
jwt.rs

1use crate::error::ServerError;
2use crate::{
3    auth_n::certificates,
4    config::ServerConfig,
5    extract::{
6        bearer_token::AuthBearer,
7        path_tenant::{ProjectIdentifier, TenantIdentifier},
8    },
9    route_path::{api_fhir_root_url, project_path},
10    services::ServerState,
11};
12use axum::{
13    extract::{OriginalUri, Request, State},
14    http::{HeaderMap, StatusCode, Uri},
15    middleware::Next,
16    response::{IntoResponse as _, Response},
17};
18use axum_extra::extract::Cached;
19use derivative::Derivative;
20use haste_fhir_operation_error::OperationOutcomeError;
21use haste_fhir_search::SearchEngine;
22use haste_fhir_terminology::FHIRTerminology;
23use haste_jwt::{ProjectId, TenantId, claims::UserTokenClaims};
24use haste_repository::Repository;
25use jsonwebtoken::Validation;
26use std::{path::PathBuf, sync::Arc};
27use url::Url;
28
29#[derive(Derivative)]
30#[derivative(Debug)]
31pub struct User {
32    #[allow(dead_code)]
33    #[derivative(Debug = "ignore")]
34    pub token: Option<String>,
35    pub claims: haste_jwt::claims::UserTokenClaims,
36}
37
38/// The claims of `token` if it is an access token issued for `tenant` and
39/// `project`'s FHIR API: signed with one of the server's keys, addressed to
40/// that API and not expired.
41pub(crate) fn validate_jwt(
42    config: &ServerConfig,
43    tenant: &TenantId,
44    project: &ProjectId,
45    token: &str,
46) -> Result<UserTokenClaims, StatusCode> {
47    let header = jsonwebtoken::decode_header(token).map_err(|_| StatusCode::UNAUTHORIZED)?;
48
49    let kid = header.kid.ok_or(StatusCode::UNAUTHORIZED)?;
50
51    let cert_provider = certificates::get_certification_provider(config);
52
53    let decoding_key = cert_provider
54        .decoding_key(kid.as_str())
55        .map_err(|_| StatusCode::UNAUTHORIZED)?;
56
57    // Per SMART on FHIR, `aud` identifies the FHIR resource server a token was
58    // minted for, binding the token to this tenant/project's FHIR endpoint.
59    let expected_audience = api_fhir_root_url(&config.api_uri, tenant, project)
60        .map_err(|_| StatusCode::UNAUTHORIZED)?;
61
62    let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
63    validation.set_audience(&[expected_audience.as_str()]);
64
65    let result =
66        jsonwebtoken::decode::<UserTokenClaims>(token, &decoding_key.decoding_key, &validation)
67            .map_err(|_| StatusCode::UNAUTHORIZED)?;
68
69    Ok(result.claims)
70}
71
72pub fn derive_well_known_openid_configuration_url(
73    api_url: &str,
74    tenant: &TenantId,
75    project: &ProjectId,
76) -> Result<Url, OperationOutcomeError> {
77    let path = PathBuf::from("/.well-known/openid-configuration");
78
79    Url::parse(api_url)
80        .map_err(ServerError::from)?
81        .join(
82            path.join(project_path(tenant, project).strip_prefix("/").unwrap())
83                .to_str()
84                .unwrap_or_default(),
85        )
86        .map_err(|e| ServerError::from(e).into())
87}
88
89pub fn derive_protected_resource_metadata_url(
90    resource_uri: &Uri,
91    api_url: &str,
92) -> Result<Url, OperationOutcomeError> {
93    let path = PathBuf::from("/.well-known/oauth-protected-resource");
94    let tenant_url = Url::parse(api_url)
95        .map_err(ServerError::from)?
96        .join(
97            path.join(resource_uri.path().strip_prefix("/").unwrap_or_default())
98                .to_str()
99                .unwrap_or_default(),
100        )
101        .map_err(ServerError::from)?;
102
103    Ok(tenant_url)
104}
105
106fn invalid_jwt_response(uri: &Uri, api_url: &str, status_code: StatusCode) -> Response {
107    tracing::warn!(
108        "Invalid JWT token provided in request sending '{}'",
109        status_code
110    );
111
112    let Ok(protected_resource_metadata_url) = derive_protected_resource_metadata_url(uri, api_url)
113    else {
114        return (status_code).into_response();
115    };
116
117    let mut headers = HeaderMap::new();
118    headers.insert(
119        axum::http::header::WWW_AUTHENTICATE,
120        format!(
121            r#"Bearer resource_metadata="{}""#,
122            protected_resource_metadata_url
123        )
124        .parse()
125        .unwrap(),
126    );
127    (status_code, headers).into_response()
128}
129
130pub async fn token_verifcation<
131    Repo: Repository + Send + Sync + 'static,
132    Search: SearchEngine + Send + Sync + 'static,
133    Terminology: FHIRTerminology + Send + Sync + 'static,
134>(
135    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
136    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
137    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
138    // run the `HeaderMap` extractor
139    AuthBearer(token): AuthBearer,
140    // you can also add more extractors here but the last
141    // extractor must implement `FromRequest` which
142    // `Request` does
143    OriginalUri(uri): OriginalUri,
144    mut request: Request,
145    next: Next,
146) -> Result<Response, Response> {
147    let Some(token) = token else {
148        return Err(invalid_jwt_response(
149            &uri,
150            &state.config.api_uri,
151            StatusCode::UNAUTHORIZED,
152        ));
153    };
154
155    match validate_jwt(state.config.as_ref(), &tenant, &project, &token) {
156        Ok(claims) => {
157            request.extensions_mut().insert(Arc::new(User {
158                token: Some(token),
159                claims,
160            }));
161            Ok(next.run(request).await)
162        }
163        Err(status_code) => match status_code {
164            StatusCode::UNAUTHORIZED => Err(invalid_jwt_response(
165                &uri,
166                &state.config.api_uri,
167                status_code,
168            )),
169            _ => Err((status_code).into_response()),
170        },
171    }
172}