Skip to main content

haste_server/auth_n/oidc/routes/
discovery.rs

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