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::ServerState,
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 SmartConfigurationAssociatedEndpoint {
39    pub url: String,
40    pub capabilities: Vec<String>,
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone)]
44pub struct SmartConfigurationDocument {
45    /**
46     * CONDITIONAL.  String conveying this system's OpenID Connect Issuer
47     * URL.  Required if the server's capabilities include
48     * sso-openid-connect; otherwise, omitted.
49     */
50    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
51    pub issuer: Option<String>,
52
53    /**
54     * CONDITIONAL.  String conveying this system's JSON Web Key Set URL.
55     * Required if the server's capabilities include sso-openid-connect;
56     * otherwise, optional.
57     */
58    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
59    pub jwks_uri: Option<String>,
60
61    /**
62     * CONDITIONAL.  URL to the OAuth2 authorization endpoint.  Required if
63     * server supports the launch-ehr or launch-standalone capability;
64     * otherwise, optional.
65     */
66    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
67    pub authorization_endpoint: Option<String>,
68
69    /**
70     * REQUIRED.  Array of grant types supported at the token endpoint.
71     * The options are "authorization_code" (when SMART App Launch is
72     * supported) and "client_credentials" (when SMART Backend Services is
73     * supported).
74     */
75    pub grant_types_supported: Vec<String>,
76
77    /**
78     * REQUIRED.  URL to the OAuth2 token endpoint.
79     */
80    pub token_endpoint: String,
81
82    /**
83     * OPTIONAL.  Array of client authentication methods supported by the
84     * token endpoint.  The options are "client_secret_post",
85     * "client_secret_basic", and "private_key_jwt".
86     */
87    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
88    pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
89
90    /**
91     * OPTIONAL.  If available, URL to the OAuth2 dynamic registration
92     * endpoint for this FHIR server.
93     */
94    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
95    pub registration_endpoint: Option<String>,
96
97    /**
98     * OPTIONAL, DEPRECATED.  URL to the EHR's app state endpoint.
99     * Deprecated; use associated_endpoints with the smart-app-state
100     * capability instead.
101     */
102    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
103    pub smart_app_state_endpoint: Option<String>,
104
105    /**
106     * OPTIONAL.  Array of objects for endpoints that share the same
107     * authorization mechanism as this FHIR endpoint, each with a "url"
108     * and "capabilities" array.  This property is deemed experimental.
109     */
110    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
111    pub associated_endpoints: Option<Vec<SmartConfigurationAssociatedEndpoint>>,
112
113    /**
114     * RECOMMENDED.  URL for a Brand Bundle.  See User Access Brands.
115     */
116    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
117    pub user_access_brand_bundle: Option<String>,
118
119    /**
120     * RECOMMENDED.  Identifier for the primary entry in a Brand Bundle.
121     * See User Access Brands.
122     */
123    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
124    pub user_access_brand_identifier: Option<String>,
125
126    /**
127     * RECOMMENDED.  Array of scopes a client may request.  See scopes and
128     * launch context.  The server SHALL support all scopes listed here;
129     * additional scopes MAY be supported (so clients should not consider
130     * this an exhaustive list).
131     */
132    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
133    pub scopes_supported: Option<Vec<String>>,
134
135    /**
136     * RECOMMENDED.  Array of OAuth2 response_type values that are
137     * supported.  Implementers can refer to response_types defined in
138     * OAuth 2.0 (RFC 6749) and in OIDC Core.
139     */
140    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
141    pub response_types_supported: Option<Vec<String>>,
142
143    /**
144     * RECOMMENDED.  URL where an end-user can view which applications
145     * currently have access to data and can make adjustments to these
146     * access rights.
147     */
148    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
149    pub management_endpoint: Option<String>,
150
151    /**
152     * RECOMMENDED.  URL to a server's introspection endpoint that can be
153     * used to validate a token.
154     */
155    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
156    pub introspection_endpoint: Option<String>,
157
158    /**
159     * RECOMMENDED.  URL to a server's revoke endpoint that can be used to
160     * revoke a token.
161     */
162    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
163    pub revocation_endpoint: Option<String>,
164
165    /**
166     * REQUIRED.  Array of strings representing SMART capabilities (e.g.,
167     * sso-openid-connect or launch-standalone) that the server supports.
168     */
169    pub capabilities: Vec<String>,
170
171    /**
172     * REQUIRED.  Array of PKCE code challenge methods supported.  The
173     * S256 method SHALL be included in this list, and the plain method
174     * SHALL NOT be included in this list.
175     */
176    pub code_challenge_methods_supported: Vec<String>,
177}
178
179#[derive(Serialize, Deserialize, Debug, Clone)]
180pub struct OAuthProtectedResourceDocument {
181    /**
182     * REQUIRED.  The protected resource's resource identifier, as
183     * defined in Section 1.2.
184     */
185    resource: String,
186
187    /**
188     * OPTIONAL.  JSON array containing a list of OAuth authorization
189     * server issuer identifiers, as defined in [RFC8414], for
190     * authorization servers that can be used with this protected
191     * resource.  Protected resources MAY choose not to advertise some
192     * supported authorization servers even when this parameter is used.
193     * In some use cases, the set of authorization servers will not be
194     * enumerable, in which case this metadata parameter would not be
195     * used.
196     */
197    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
198    authorization_servers: Option<Vec<String>>,
199
200    /**
201     * OPTIONAL.  URL of the protected resource's JSON Web Key (JWK) Set
202     * [JWK] document.  This contains public keys belonging to the
203     * protected resource, such as signing key(s) that the resource
204     * server uses to sign resource responses.  This URL MUST use the
205     * https scheme.  When both signing and encryption keys are made
206     * available, a use (public key use) parameter value is REQUIRED for
207     * all keys in the referenced JWK Set to indicate each key's intended
208     * usage.
209     */
210    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
211    jwks_uri: Option<String>,
212
213    /**
214     * RECOMMENDED.  JSON array containing a list of scope values, as
215     * defined in OAuth 2.0 [RFC6749], that are used in authorization
216     * requests to request access to this protected resource.  Protected
217     * resources MAY choose not to advertise some scope values supported
218     * even when this parameter is used.
219     */
220    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
221    scopes_supported: Option<Vec<String>>,
222
223    /**
224     * OPTIONAL.  JSON array containing a list of the supported methods
225     * of sending an OAuth 2.0 bearer token [RFC6750] to the protected
226     * resource.  Defined values are ["header", "body", "query"],
227     * corresponding to Sections 2.1, 2.2, and 2.3 of [RFC6750].  The
228     * empty array [] can be used to indicate that no bearer methods are
229     * supported.  If this entry is omitted, no default bearer methods
230     * supported are implied, nor does its absence indicate that they are
231     * not supported.
232     */
233    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
234    bearer_methods_supported: Option<Vec<String>>,
235
236    /**
237     * OPTIONAL.  JSON array containing a list of the JWS [JWS] signing
238     * algorithms (alg values) [JWA] supported by the protected resource
239     * for signing resource responses, for instance, as described in
240     * [FAPI.MessageSigning].  No default algorithms are implied if this
241     * entry is omitted.  The value none MUST NOT be used.
242     */
243    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
244    resource_signing_alg_values_supported: Option<Vec<String>>,
245
246    /**
247     * Human-readable name of the protected resource intended for display
248     * to the end user.  It is RECOMMENDED that protected resource
249     * metadata include this field.  The value of this field MAY be
250     * internationalized, as described in Section 2.1.
251     */
252    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
253    resource_name: Option<String>,
254
255    /**
256     * OPTIONAL.  URL of a page containing human-readable information
257     * that developers might want or need to know when using the
258     * protected resource.  The value of this field MAY be
259     * internationalized, as described in Section 2.1.
260     */
261    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
262    resource_documentation: Option<String>,
263
264    /**
265     * OPTIONAL.  URL of a page containing human-readable information
266     * about the protected resource's requirements on how the client can
267     * use the data provided by the protected resource.  The value of
268     * this field MAY be internationalized, as described in Section 2.1.
269     */
270    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
271    resource_policy_uri: Option<String>,
272
273    /**
274     * OPTIONAL.  URL of a page containing human-readable information
275     * about the protected resource's terms of service.  The value of
276     * this field MAY be internationalized, as described in Section 2.1.
277     */
278    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
279    resource_tos_uri: Option<String>,
280
281    /**
282     * OPTIONAL.  Boolean value indicating protected resource support for
283     * mutual-TLS client certificate-bound access tokens [RFC8705].  If
284     * omitted, the default value is false.
285     */
286    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
287    tls_client_certificate_bound_access_tokens: Option<bool>,
288
289    /**
290     * OPTIONAL.  JSON array containing a list of the authorization
291     * details type values supported by the resource server when the
292     * authorization_details request parameter [RFC9396] is used.
293     */
294    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
295    authorization_details_types_supported: Option<Vec<String>>,
296
297    /**
298     * OPTIONAL.  JSON array containing a list of the JWS alg values
299     * (from the "JSON Web Signature and Encryption Algorithms" registry
300     * [IANA.JOSE]) supported by the resource server for validating
301     * Demonstrating Proof of Possession (DPoP) proof JWTs [RFC9449].
302     */
303    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
304    dpop_signing_alg_values_supported: Option<Vec<String>>,
305
306    /**
307     * OPTIONAL.  Boolean value specifying whether the protected resource
308     * always requires the use of DPoP-bound access tokens [RFC9449].  If
309     * omitted, the default value is false.
310     */
311    #[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
312    dpop_bound_access_tokens_required: Option<bool>,
313}
314
315#[derive(Deserialize, Clone)]
316pub struct ResourcePath {
317    pub resource: String,
318}
319
320impl<S: Send + Sync> FromRequestParts<S> for ResourcePath {
321    type Rejection = Response;
322
323    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
324        let Path(resource) = Path::<ResourcePath>::from_request_parts(parts, state)
325            .await
326            .map_err(|err| err.into_response())?;
327
328        Ok(resource)
329    }
330}
331
332pub async fn oauth_protected_resource<
333    Repo: Repository + Send + Sync,
334    Search: SearchEngine + Send + Sync,
335    Terminology: FHIRTerminology + Send + Sync,
336>(
337    Cached(ResourcePath { resource }): Cached<ResourcePath>,
338    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
339    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
340    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
341) -> Result<Json<OAuthProtectedResourceDocument>, OIDCError> {
342    let api_url_string = &state.config.api_uri;
343
344    if api_url_string.is_empty() {
345        return Err(OIDCError::new(
346            OIDCErrorCode::ServerError,
347            Some("API_URL is not set in the configuration".to_string()),
348            None,
349        ));
350    }
351
352    let Ok(api_url) = Url::parse(api_url_string) else {
353        return Err(OIDCError::new(
354            OIDCErrorCode::ServerError,
355            Some("Invalid API_URL format".to_string()),
356            None,
357        ));
358    };
359
360    // Default to openid profile user/*.* scopes for FHIR access.
361    let default_scopes =
362        Scopes::try_from("openid profile user/*.* offline_access fhirUser").unwrap_or_default();
363
364    let oauth_protected_resource = OAuthProtectedResourceDocument {
365        resource: api_url
366            .join(
367                project_path(&tenant, &project)
368                    .join(&resource)
369                    .to_str()
370                    .unwrap_or_default(),
371            )
372            .unwrap()
373            .to_string(),
374        authorization_servers: Some(vec![
375            api_url
376                .join(project_path(&tenant, &project).to_str().unwrap())
377                .unwrap()
378                .to_string(),
379        ]),
380        jwks_uri: None,
381
382        scopes_supported: Some(
383            default_scopes
384                .0
385                .into_iter()
386                .map(String::from)
387                .collect::<Vec<_>>(),
388        ),
389        bearer_methods_supported: None,
390        resource_signing_alg_values_supported: None,
391        resource_name: None,
392        resource_documentation: None,
393        resource_policy_uri: None,
394        resource_tos_uri: None,
395        tls_client_certificate_bound_access_tokens: None,
396        authorization_details_types_supported: None,
397        dpop_signing_alg_values_supported: None,
398        dpop_bound_access_tokens_required: None,
399    };
400
401    Ok(Json(oauth_protected_resource))
402}
403
404pub fn create_oidc_discovery_document(
405    tenant: &TenantId,
406    project: &ProjectId,
407    api_url_string: &str,
408) -> Result<WellKnownDiscoveryDocument, OIDCError> {
409    if api_url_string.is_empty() {
410        return Err(OIDCError::new(
411            OIDCErrorCode::ServerError,
412            Some("API_URL is not set in the configuration".to_string()),
413            None,
414        ));
415    }
416
417    let Ok(api_url) = Url::parse(api_url_string) else {
418        return Err(OIDCError::new(
419            OIDCErrorCode::ServerError,
420            Some("Invalid API_URL format".to_string()),
421            None,
422        ));
423    };
424
425    let authorize_path = api_v1_oidc_auth_path(tenant, project).join(
426        authorize::AuthorizePath
427            .to_string()
428            .strip_prefix("/")
429            .unwrap(),
430    );
431
432    let token_path = api_v1_oidc_auth_path(tenant, project)
433        .join(token::TokenPath.to_string().strip_prefix("/").unwrap());
434
435    let jwks_path = api_v1_oidc_path(tenant, project)
436        .join(jwks::JWKSPath.to_string().strip_prefix("/").unwrap());
437
438    let oidc_response = WellKnownDiscoveryDocument {
439        // Must exactly match the authorization server identifier the client
440        // used to look this document up (RFC 8414 ยง3.3) - the same
441        // tenant/project-scoped URL `oauth_protected_resource` advertises
442        // via `authorization_servers`, not the bare root API URL.
443        issuer: api_url
444            .join(project_path(tenant, project).to_str().unwrap_or_default())
445            .unwrap_or(api_url.clone())
446            .to_string(),
447        authorization_endpoint: api_url
448            .join(authorize_path.to_str().unwrap_or_default())
449            .unwrap()
450            .to_string(),
451        token_endpoint: api_url
452            .join(token_path.to_str().unwrap_or_default())
453            .unwrap()
454            .to_string(),
455        jwks_uri: api_url
456            .join(jwks_path.to_str().unwrap_or_default())
457            .unwrap()
458            .to_string(),
459        scopes_supported: vec![
460            "openid".to_string(),
461            "profile".to_string(),
462            "email".to_string(),
463            "offline_access".to_string(),
464            "fhirUser".to_string(),
465        ],
466        response_types_supported: vec![
467            "code".to_string(),
468            "id_token".to_string(),
469            "id_token token".to_string(),
470        ],
471        token_endpoint_auth_methods_supported: vec![
472            "client_secret_basic".to_string(),
473            "client_secret_post".to_string(),
474        ],
475        id_token_signing_alg_values_supported: vec!["RS256".to_string()],
476        subject_types_supported: vec!["public".to_string()],
477    };
478
479    Ok(oidc_response)
480}
481
482pub fn create_smart_configuration(
483    tenant: &TenantId,
484    project: &ProjectId,
485    api_url_string: &str,
486) -> Result<SmartConfigurationDocument, OIDCError> {
487    if api_url_string.is_empty() {
488        return Err(OIDCError::new(
489            OIDCErrorCode::ServerError,
490            Some("api_uri is not set in the configuration".to_string()),
491            None,
492        ));
493    }
494
495    let Ok(api_url) = Url::parse(api_url_string) else {
496        return Err(OIDCError::new(
497            OIDCErrorCode::ServerError,
498            Some("Invalid api_uri format".to_string()),
499            None,
500        ));
501    };
502
503    let authorize_path = api_v1_oidc_auth_path(tenant, project).join(
504        authorize::AuthorizePath
505            .to_string()
506            .strip_prefix("/")
507            .unwrap(),
508    );
509
510    let token_path = api_v1_oidc_auth_path(tenant, project)
511        .join(token::TokenPath.to_string().strip_prefix("/").unwrap());
512
513    let jwks_path = api_v1_oidc_path(tenant, project)
514        .join(jwks::JWKSPath.to_string().strip_prefix("/").unwrap());
515
516    let smart_document = SmartConfigurationDocument {
517        issuer: Some(api_url.to_string()),
518        authorization_endpoint: Some(
519            api_url
520                .join(authorize_path.to_str().unwrap_or_default())
521                .unwrap()
522                .to_string(),
523        ),
524        token_endpoint: api_url
525            .join(token_path.to_str().unwrap_or_default())
526            .unwrap()
527            .to_string(),
528        jwks_uri: Some(
529            api_url
530                .join(jwks_path.to_str().unwrap_or_default())
531                .unwrap()
532                .to_string(),
533        ),
534        scopes_supported: Some(vec![
535            "openid".to_string(),
536            "profile".to_string(),
537            "email".to_string(),
538            "offline_access".to_string(),
539            "fhirUser".to_string(),
540            // SMART scopes supported TODO patient scopes.
541            "user/*.cruds".to_string(),
542            "system/*.cruds".to_string(),
543        ]),
544        response_types_supported: Some(vec![
545            "code".to_string(),
546            "id_token".to_string(),
547            "id_token token".to_string(),
548        ]),
549        token_endpoint_auth_methods_supported: Some(vec![
550            "client_secret_basic".to_string(),
551            "client_secret_post".to_string(),
552        ]),
553        grant_types_supported: vec![
554            "authorization_code".to_string(),
555            "client_credentials".to_string(),
556        ],
557        capabilities: vec![
558            "sso-openid-connect".to_string(),
559            "client-confidential-symmetric".to_string(),
560            "launch-standalone".to_string(),
561            "permission-user".to_string(),
562            "permission-v2".to_string(),
563        ],
564        code_challenge_methods_supported: vec!["S256".to_string()],
565        registration_endpoint: None,
566        smart_app_state_endpoint: None,
567        associated_endpoints: None,
568        user_access_brand_bundle: None,
569        user_access_brand_identifier: None,
570        management_endpoint: None,
571        introspection_endpoint: None,
572        revocation_endpoint: None,
573    };
574
575    Ok(smart_document)
576}
577
578pub async fn openid_configuration<
579    Repo: Repository + Send + Sync,
580    Search: SearchEngine + Send + Sync,
581    Terminology: FHIRTerminology + Send + Sync,
582>(
583    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
584    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
585    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
586) -> Result<Json<WellKnownDiscoveryDocument>, OIDCError> {
587    let api_url_string = &state.config.api_uri;
588
589    Ok(Json(create_oidc_discovery_document(
590        &tenant,
591        &project,
592        api_url_string,
593    )?))
594}
595
596pub async fn smart_configuration<
597    Repo: Repository + Send + Sync,
598    Search: SearchEngine + Send + Sync,
599    Terminology: FHIRTerminology + Send + Sync,
600>(
601    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
602    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
603    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
604) -> Result<Json<SmartConfigurationDocument>, OIDCError> {
605    let api_url_string = &state.config.api_uri;
606
607    Ok(Json(create_smart_configuration(
608        &tenant,
609        &project,
610        api_url_string,
611    )?))
612}