haste_server/auth_n/oidc/routes/
introspect.rs1use 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#[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 #[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
78async 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 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
111fn 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 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}