Skip to main content

haste_server/auth_n/oidc/routes/
introspect.rs

1//! Token introspection (RFC 7662), with the response fields SMART App Launch
2//! adds (<https://hl7.org/fhir/smart-app-launch/STU2.2/token-introspection.html>).
3
4use crate::{
5    auth_n::{
6        middleware::jwt::validate_jwt,
7        oidc::{
8            error::{OIDCError, OIDCErrorCode},
9            extract::{body::OAuthIntrospectionBody, client_app::find_client_app},
10            routes::token::{
11                is_patient_bound_scope, require_client_secret, smart_style_url,
12                verify_client_credentials,
13            },
14            schemas::token_introspection::OAuth2TokenIntrospectionBody,
15        },
16    },
17    config::ServerConfig,
18    extract::path_tenant::{ProjectIdentifier, TenantIdentifier},
19    route_path::issuer_url,
20    services::ServerState,
21};
22use axum::{Json, extract::State};
23use axum_extra::{extract::Cached, routing::TypedPath};
24use haste_fhir_search::SearchEngine;
25use haste_fhir_terminology::FHIRTerminology;
26use haste_jwt::{
27    ProjectId, TenantId,
28    scopes::{LaunchSystemScope, Scope, SmartScope},
29};
30use haste_repository::Repository;
31use serde::Serialize;
32use std::sync::Arc;
33
34#[derive(TypedPath)]
35#[typed_path("/introspect")]
36pub struct IntrospectPath;
37
38/// An introspection response (RFC 7662 §2.2). An inactive token gets
39/// `active: false` and nothing else, so every other field is optional.
40#[derive(Serialize, Default, Debug)]
41pub struct IntrospectionResponse {
42    active: bool,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    scope: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    client_id: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    token_type: Option<&'static str>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    exp: Option<usize>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    sub: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    aud: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    iss: Option<String>,
57    #[serde(rename = "fhirUser", skip_serializing_if = "Option::is_none")]
58    fhir_user: Option<String>,
59    // The SMART launch context the token response returned.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    patient: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    encounter: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    need_patient_banner: Option<bool>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    smart_style_url: Option<String>,
68}
69
70fn invalid_client() -> OIDCError {
71    OIDCError::new(
72        OIDCErrorCode::InvalidClient,
73        Some("Invalid credentials".to_string()),
74        None,
75    )
76}
77
78/// Requires the caller to authenticate as a confidential client of the
79/// project, so the endpoint cannot be used to probe tokens (RFC 7662 §4).
80async fn authenticate_caller<
81    Repo: Repository + Send + Sync,
82    Search: SearchEngine + Send + Sync,
83    Terminology: FHIRTerminology + Send + Sync,
84>(
85    state: &ServerState<Repo, Search, Terminology>,
86    tenant: &TenantId,
87    project: &ProjectId,
88    body: &OAuth2TokenIntrospectionBody,
89) -> Result<(), OIDCError> {
90    let Some(client_id) = body.client_id.as_deref() else {
91        return Err(invalid_client());
92    };
93
94    // An unknown client gets the same answer as a wrong secret.
95    let client_app = find_client_app(
96        state,
97        tenant.clone(),
98        project.clone(),
99        client_id.to_string(),
100    )
101    .await
102    .map_err(|e| match e.error {
103        OIDCErrorCode::InvalidClient => invalid_client(),
104        _ => e,
105    })?;
106
107    require_client_secret(&client_app)?;
108    verify_client_credentials(&client_app, Some(client_id), body.client_secret.as_deref())
109}
110
111/// Describes `token`. It is active when the project's FHIR API would accept
112/// it; anything else (unknown, malformed, expired, or issued for another
113/// project) is inactive.
114fn introspect_token(
115    config: &ServerConfig,
116    tenant: &TenantId,
117    project: &ProjectId,
118    token: &str,
119) -> Result<IntrospectionResponse, OIDCError> {
120    let Ok(claims) = validate_jwt(config, tenant, project, token) else {
121        return Ok(IntrospectionResponse::default());
122    };
123
124    if claims.tenant != *tenant || claims.project.as_ref() != Some(project) {
125        return Ok(IntrospectionResponse::default());
126    }
127
128    // The token response returns the launch context with the `launch` scope,
129    // which is only issued for an EHR launch, and the patient with the scopes
130    // bound to it.
131    let launch_granted = claims
132        .scope
133        .contains_scope(&Scope::SMART(SmartScope::LaunchSystem(LaunchSystemScope)));
134    let patient_returned = launch_granted || claims.scope.0.iter().any(is_patient_bound_scope);
135
136    let iss = issuer_url(&config.api_uri, tenant, project)
137        .map_err(|e| OIDCError::new(OIDCErrorCode::ServerError, Some(e.to_string()), None))?;
138
139    Ok(IntrospectionResponse {
140        active: true,
141        scope: Some(String::from(claims.scope)),
142        client_id: claims.client_id,
143        token_type: Some("Bearer"),
144        exp: Some(claims.exp),
145        sub: Some(claims.sub.to_string()),
146        aud: Some(claims.aud),
147        iss: Some(iss.to_string()),
148        fhir_user: claims.fhir_user.and_then(|fhir_user| fhir_user.value),
149        patient: claims.patient.filter(|_| patient_returned),
150        encounter: claims.encounter,
151        need_patient_banner: launch_granted.then_some(true),
152        smart_style_url: if launch_granted {
153            Some(smart_style_url(&config.api_uri, tenant, project)?)
154        } else {
155            None
156        },
157    })
158}
159
160pub async fn introspect<
161    Repo: Repository + Send + Sync,
162    Search: SearchEngine + Send + Sync,
163    Terminology: FHIRTerminology + Send + Sync,
164>(
165    _: IntrospectPath,
166    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
167    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
168    State(state): State<Arc<ServerState<Repo, Search, Terminology>>>,
169    OAuthIntrospectionBody(body): OAuthIntrospectionBody,
170) -> Result<Json<IntrospectionResponse>, OIDCError> {
171    authenticate_caller(&state, &tenant, &project, &body).await?;
172
173    Ok(Json(introspect_token(
174        state.config.as_ref(),
175        &tenant,
176        &project,
177        &body.token,
178    )?))
179}