haste_server/auth_n/oidc/routes/
discovery.rs

1use crate::{
2    auth_n::oidc::{
3        error::{OIDCError, OIDCErrorCode},
4        routes::{AUTH_NESTED_PATH, authorize, jwks, token},
5    },
6    extract::path_tenant::{ProjectIdentifier, TenantIdentifier},
7    services::AppState,
8};
9use axum::{
10    extract::{FromRequestParts, Json, Path, State},
11    http::request::Parts,
12    response::{IntoResponse, Response},
13};
14use axum_extra::extract::Cached;
15use haste_fhir_search::SearchEngine;
16use haste_fhir_terminology::FHIRTerminology;
17use haste_jwt::{ProjectId, TenantId, scopes::Scopes};
18use haste_repository::Repository;
19use serde::{Deserialize, Serialize};
20use std::sync::Arc;
21use url::Url;
22
23#[derive(Serialize, Deserialize, Debug, Clone)]
24pub struct WellKnownDiscoveryDocument {
25    pub issuer: String,
26    pub authorization_endpoint: String,
27    pub jwks_uri: String,
28    pub token_endpoint: String,
29    pub scopes_supported: Vec<String>,
30    pub response_types_supported: Vec<String>,
31    pub token_endpoint_auth_methods_supported: Vec<String>,
32    pub id_token_signing_alg_values_supported: Vec<String>,
33    pub subject_types_supported: Vec<String>,
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct OAuthProtectedResourceDocument {
38    /**
39     * REQUIRED.  The protected resource's resource identifier, as
40     * defined in Section 1.2.
41     */
42    resource: String,
43
44    /**
45     * OPTIONAL.  JSON array containing a list of OAuth authorization
46     * server issuer identifiers, as defined in [RFC8414], for
47     * authorization servers that can be used with this protected
48     * resource.  Protected resources MAY choose not to advertise some
49     * supported authorization servers even when this parameter is used.
50     * In some use cases, the set of authorization servers will not be
51     * enumerable, in which case this metadata parameter would not be
52     * used.
53     */
54    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
55    authorization_servers: Option<Vec<String>>,
56
57    /**
58     * OPTIONAL.  URL of the protected resource's JSON Web Key (JWK) Set
59     * [JWK] document.  This contains public keys belonging to the
60     * protected resource, such as signing key(s) that the resource
61     * server uses to sign resource responses.  This URL MUST use the
62     * https scheme.  When both signing and encryption keys are made
63     * available, a use (public key use) parameter value is REQUIRED for
64     * all keys in the referenced JWK Set to indicate each key's intended
65     * usage.
66     */
67    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
68    jwks_uri: Option<String>,
69
70    /**
71     * RECOMMENDED.  JSON array containing a list of scope values, as
72     * defined in OAuth 2.0 [RFC6749], that are used in authorization
73     * requests to request access to this protected resource.  Protected
74     * resources MAY choose not to advertise some scope values supported
75     * even when this parameter is used.
76     */
77    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
78    scopes_supported: Option<Vec<String>>,
79
80    /**
81     * OPTIONAL.  JSON array containing a list of the supported methods
82     * of sending an OAuth 2.0 bearer token [RFC6750] to the protected
83     * resource.  Defined values are ["header", "body", "query"],
84     * corresponding to Sections 2.1, 2.2, and 2.3 of [RFC6750].  The
85     * empty array [] can be used to indicate that no bearer methods are
86     * supported.  If this entry is omitted, no default bearer methods
87     * supported are implied, nor does its absence indicate that they are
88     * not supported.
89     */
90    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
91    bearer_methods_supported: Option<Vec<String>>,
92
93    /**
94     * OPTIONAL.  JSON array containing a list of the JWS [JWS] signing
95     * algorithms (alg values) [JWA] supported by the protected resource
96     * for signing resource responses, for instance, as described in
97     * [FAPI.MessageSigning].  No default algorithms are implied if this
98     * entry is omitted.  The value none MUST NOT be used.
99     */
100    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
101    resource_signing_alg_values_supported: Option<Vec<String>>,
102
103    /**
104     * Human-readable name of the protected resource intended for display
105     * to the end user.  It is RECOMMENDED that protected resource
106     * metadata include this field.  The value of this field MAY be
107     * internationalized, as described in Section 2.1.
108     */
109    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
110    resource_name: Option<String>,
111
112    /**
113     * OPTIONAL.  URL of a page containing human-readable information
114     * that developers might want or need to know when using the
115     * protected resource.  The value of this field MAY be
116     * internationalized, as described in Section 2.1.
117     */
118    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
119    resource_documentation: Option<String>,
120
121    /**
122     * OPTIONAL.  URL of a page containing human-readable information
123     * about the protected resource's requirements on how the client can
124     * use the data provided by the protected resource.  The value of
125     * this field MAY be internationalized, as described in Section 2.1.
126     */
127    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
128    resource_policy_uri: Option<String>,
129
130    /**
131     * OPTIONAL.  URL of a page containing human-readable information
132     * about the protected resource's terms of service.  The value of
133     * this field MAY be internationalized, as described in Section 2.1.
134     */
135    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
136    resource_tos_uri: Option<String>,
137
138    /**
139     * OPTIONAL.  Boolean value indicating protected resource support for
140     * mutual-TLS client certificate-bound access tokens [RFC8705].  If
141     * omitted, the default value is false.
142     */
143    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
144    tls_client_certificate_bound_access_tokens: Option<bool>,
145
146    /**
147     * OPTIONAL.  JSON array containing a list of the authorization
148     * details type values supported by the resource server when the
149     * authorization_details request parameter [RFC9396] is used.
150     */
151    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
152    authorization_details_types_supported: Option<Vec<String>>,
153
154    /**
155     * OPTIONAL.  JSON array containing a list of the JWS alg values
156     * (from the "JSON Web Signature and Encryption Algorithms" registry
157     * [IANA.JOSE]) supported by the resource server for validating
158     * Demonstrating Proof of Possession (DPoP) proof JWTs [RFC9449].
159     */
160    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
161    dpop_signing_alg_values_supported: Option<Vec<String>>,
162
163    /**
164     * OPTIONAL.  Boolean value specifying whether the protected resource
165     * always requires the use of DPoP-bound access tokens [RFC9449].  If
166     * omitted, the default value is false.
167     */
168    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
169    dpop_bound_access_tokens_required: Option<bool>,
170}
171
172fn construct_oidc_route(tenant: &TenantId, project: &ProjectId, path: &str) -> String {
173    format!(
174        "/w/{}/{}/api/v1/oidc{}",
175        tenant.as_ref(),
176        project.as_ref(),
177        path
178    )
179}
180
181#[derive(Deserialize, Clone)]
182pub struct ResourcePath {
183    pub resource: String,
184}
185
186impl<S: Send + Sync> FromRequestParts<S> for ResourcePath {
187    type Rejection = Response;
188
189    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
190        let Path(resource) = Path::<ResourcePath>::from_request_parts(parts, state)
191            .await
192            .map_err(|err| err.into_response())?;
193
194        Ok(resource)
195    }
196}
197
198pub async fn oauth_protected_resource<
199    Repo: Repository + Send + Sync,
200    Search: SearchEngine + Send + Sync,
201    Terminology: FHIRTerminology + Send + Sync,
202>(
203    Cached(ResourcePath { resource }): Cached<ResourcePath>,
204    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
205    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
206    State(state): State<Arc<AppState<Repo, Search, Terminology>>>,
207) -> Result<Json<OAuthProtectedResourceDocument>, OIDCError> {
208    let api_url_string = state
209        .config
210        .get(crate::ServerEnvironmentVariables::APIURI)
211        .unwrap_or_default();
212
213    if api_url_string.is_empty() {
214        return Err(OIDCError::new(
215            OIDCErrorCode::ServerError,
216            Some("API_URL is not set in the configuration".to_string()),
217            None,
218        ));
219    }
220
221    let Ok(api_url) = Url::parse(&api_url_string) else {
222        return Err(OIDCError::new(
223            OIDCErrorCode::ServerError,
224            Some("Invalid API_URL format".to_string()),
225            None,
226        ));
227    };
228
229    // Default to openid profile user/*.* scopes for FHIR access.
230    let default_scopes =
231        Scopes::try_from("openid profile user/*.* offline_access").unwrap_or_default();
232
233    let oauth_protected_resource = OAuthProtectedResourceDocument {
234        resource: api_url
235            .join(&format!(
236                "/w/{}/{}/{}",
237                tenant.as_ref(),
238                project.as_ref(),
239                resource
240            ))
241            .unwrap()
242            .to_string(),
243
244        authorization_servers: Some(vec![
245            api_url
246                .join(&format!("/w/{}/{}", tenant.as_ref(), project.as_ref()))
247                .unwrap()
248                .to_string(),
249        ]),
250        jwks_uri: None,
251
252        scopes_supported: Some(
253            default_scopes
254                .0
255                .into_iter()
256                .map(|s| String::from(s))
257                .collect::<Vec<_>>(),
258        ),
259        bearer_methods_supported: None,
260        resource_signing_alg_values_supported: None,
261        resource_name: None,
262        resource_documentation: None,
263        resource_policy_uri: None,
264        resource_tos_uri: None,
265        tls_client_certificate_bound_access_tokens: None,
266        authorization_details_types_supported: None,
267        dpop_signing_alg_values_supported: None,
268        dpop_bound_access_tokens_required: None,
269    };
270
271    Ok(Json(oauth_protected_resource))
272}
273
274pub fn create_oidc_discovery_document(
275    tenant: &TenantId,
276    project: &ProjectId,
277    api_url_string: &str,
278) -> Result<WellKnownDiscoveryDocument, OIDCError> {
279    if api_url_string.is_empty() {
280        return Err(OIDCError::new(
281            OIDCErrorCode::ServerError,
282            Some("API_URL is not set in the configuration".to_string()),
283            None,
284        ));
285    }
286
287    let Ok(api_url) = Url::parse(&api_url_string) else {
288        return Err(OIDCError::new(
289            OIDCErrorCode::ServerError,
290            Some("Invalid API_URL format".to_string()),
291            None,
292        ));
293    };
294
295    let authorize_path = construct_oidc_route(
296        &tenant,
297        &project,
298        &(AUTH_NESTED_PATH.to_string() + authorize::AuthorizePath.to_string().as_str()),
299    );
300
301    let token_path = construct_oidc_route(
302        &tenant,
303        &project,
304        &(AUTH_NESTED_PATH.to_string() + token::TokenPath.to_string().as_str()),
305    );
306
307    let jwks_path = construct_oidc_route(&tenant, &project, &(jwks::JWKSPath.to_string().as_str()));
308
309    let oidc_response = WellKnownDiscoveryDocument {
310        issuer: api_url.to_string(),
311        authorization_endpoint: api_url.join(&authorize_path).unwrap().to_string(),
312        token_endpoint: api_url.join(&token_path).unwrap().to_string(),
313        jwks_uri: api_url.join(&jwks_path).unwrap().to_string(),
314        scopes_supported: vec![
315            "openid".to_string(),
316            "profile".to_string(),
317            "email".to_string(),
318            "offline_access".to_string(),
319        ],
320        response_types_supported: vec![
321            "code".to_string(),
322            "id_token".to_string(),
323            "id_token token".to_string(),
324        ],
325        token_endpoint_auth_methods_supported: vec![
326            "client_secret_basic".to_string(),
327            "client_secret_post".to_string(),
328        ],
329        id_token_signing_alg_values_supported: vec!["RS256".to_string()],
330        subject_types_supported: vec!["public".to_string()],
331    };
332
333    Ok(oidc_response)
334}
335
336pub async fn openid_configuration<
337    Repo: Repository + Send + Sync,
338    Search: SearchEngine + Send + Sync,
339    Terminology: FHIRTerminology + Send + Sync,
340>(
341    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
342    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
343    State(state): State<Arc<AppState<Repo, Search, Terminology>>>,
344) -> Result<Json<WellKnownDiscoveryDocument>, OIDCError> {
345    let api_url_string = state
346        .config
347        .get(crate::ServerEnvironmentVariables::APIURI)
348        .unwrap_or_default();
349
350    Ok(Json(create_oidc_discovery_document(
351        &tenant,
352        &project,
353        &api_url_string,
354    )?))
355}