Skip to main content

haste_server/auth_n/oidc/routes/
scope.rs

1use crate::auth_n::constant_time;
2use crate::{
3    auth_n::{
4        oidc::{
5            error::{OIDCError, OIDCErrorCode},
6            extract::client_app::find_client_app,
7            routes::route_string::oidc_route_string,
8            utilities::is_valid_redirect_url,
9        },
10        session,
11    },
12    extract::{
13        csrf_token::CSRFToken,
14        path_tenant::{ProjectIdentifier, TenantIdentifier},
15    },
16    services::ServerState,
17};
18use axum::{
19    extract::{OriginalUri, State},
20    response::{IntoResponse, Response},
21};
22use axum_extra::{
23    extract::{Cached, Form},
24    routing::TypedPath,
25};
26use haste_fhir_search::SearchEngine;
27use haste_fhir_terminology::FHIRTerminology;
28use haste_jwt::scopes::{OIDCScope, Scope, Scopes, SmartScope};
29use haste_repository::{
30    Repository,
31    admin::ProjectModelAdmin,
32    types::scope::{ClientId, CreateScope, UserId},
33};
34use serde::{Deserialize, Deserializer};
35use std::sync::Arc;
36use tower_sessions::Session;
37
38#[derive(TypedPath)]
39#[typed_path("/scope")]
40pub struct ScopePost;
41
42#[derive(Deserialize, Debug)]
43pub struct ScopeForm {
44    pub csrf_token: String,
45    pub client_id: String,
46    pub response_type: String,
47    pub state: String,
48    pub code_challenge: String,
49    pub code_challenge_method: String,
50    /// On the approval page, the scopes the app requested. Posted back, the
51    /// scopes the user approved: one `scope` field per checked scope, or a
52    /// single space-separated value.
53    #[serde(default, deserialize_with = "scopes_from_values")]
54    pub scope: Scopes,
55    pub redirect_uri: String,
56    /// SMART EHR launch: the launch value, carried back to authorize.
57    pub launch: Option<String>,
58    /// SMART App Launch: the FHIR server the app will call, carried back to
59    /// authorize so it is checked there.
60    pub aud: Option<String>,
61    pub accept: Option<String>,
62}
63
64/// Every value of a repeated `scope` field, joined and parsed as one scope
65/// string.
66fn scopes_from_values<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Scopes, D::Error> {
67    let values = Vec::<String>::deserialize(deserializer)?;
68    Scopes::try_from(values.join(" ").as_str()).map_err(serde::de::Error::custom)
69}
70
71/// Whether the approval page keeps `scope` checked. These scopes shape the
72/// flow rather than grant access to data: `openid` asks for the id_token, and
73/// the launch scopes for the context the app runs in (`launch` carries an EHR
74/// launch's patient and encounter). Every other scope the user may deselect.
75pub fn is_fixed_scope(scope: &Scope) -> bool {
76    matches!(
77        scope,
78        Scope::OIDC(OIDCScope::OpenId)
79            | Scope::SMART(SmartScope::LaunchSystem(_) | SmartScope::LaunchType(_))
80    )
81}
82
83/// Every requested scope must be within what the client is configured to
84/// request. The configured scopes are a ceiling, not a list of exact strings:
85/// `patient/*.rs` lets a client request `patient/Medication.rs`, and
86/// `patient/Medication.rs` lets it request `patient/Medication.s` (see
87/// [`Scopes::covers_scope`]).
88pub fn verify_requested_scope_is_subset(
89    requested: &Scopes,
90    allowed: &Scopes,
91) -> Result<(), OIDCError> {
92    for scope in requested.0.iter() {
93        if !allowed.covers_scope(scope) {
94            return Err(OIDCError::new(
95                OIDCErrorCode::InvalidScope,
96                Some(format!(
97                    "Requested scope '{}' is not allowed. Check client configuration for what scopes are allowed.",
98                    String::from(scope.clone())
99                )),
100                None,
101            ));
102        }
103    }
104    Ok(())
105}
106
107pub async fn scope_post<
108    Repo: Repository + Send + Sync,
109    Search: SearchEngine + Send + Sync,
110    Terminology: FHIRTerminology + Send + Sync,
111>(
112    _: ScopePost,
113    _uri: OriginalUri,
114    CSRFToken(csrf_token): CSRFToken,
115    State(app_state): State<Arc<ServerState<Repo, Search, Terminology>>>,
116    Cached(current_session): Cached<Session>,
117    Cached(TenantIdentifier { tenant }): Cached<TenantIdentifier>,
118    Cached(ProjectIdentifier { project }): Cached<ProjectIdentifier>,
119    Form(scope_data): Form<ScopeForm>,
120) -> Result<Response, OIDCError> {
121    // The client comes from the approval form, which repeats `scope` and so
122    // is not run through the OIDC parameter layer that authorize uses.
123    let client_app = find_client_app(
124        &app_state,
125        tenant.clone(),
126        project.clone(),
127        scope_data.client_id.clone(),
128    )
129    .await?;
130
131    // The form's redirect URI is only redirected to once it is known to be
132    // registered for the client (RFC 6749 ยง4.1.2.1); errors on a request that
133    // names some other URI are answered in the response instead.
134    let error_redirect_uri = is_valid_redirect_url(&scope_data.redirect_uri, &client_app)
135        .then(|| scope_data.redirect_uri.clone());
136
137    if !constant_time::str_eq(&csrf_token, &scope_data.csrf_token) {
138        return Err(OIDCError::new(
139            OIDCErrorCode::InvalidRequest,
140            Some("Invalid CSRF Token.".to_string()),
141            error_redirect_uri,
142        ));
143    }
144
145    let completed_auth_state = session::user::get_completed_authorization_state(&current_session)
146        .await
147        .map_err(|_| {
148            OIDCError::new(
149                OIDCErrorCode::ServerError,
150                Some("Failed to retrieve user from session.".to_string()),
151                error_redirect_uri.clone(),
152            )
153        })?;
154
155    // Approving none of the scopes is a denial. Otherwise the approved subset
156    // is stored and authorize runs again with it, so the code and the token
157    // carry only what the user approved.
158    if let Some("on") = scope_data.accept.as_deref()
159        && !scope_data.scope.0.is_empty()
160    {
161        let client_scope = client_app
162            .scope
163            .as_ref()
164            .and_then(|s| s.value.clone())
165            .unwrap_or_default();
166
167        let allowed = Scopes::try_from(client_scope.as_str()).map_err(|_| {
168            OIDCError::new(
169                OIDCErrorCode::InvalidScope,
170                Some("Client application has an invalid scope configured.".to_string()),
171                error_redirect_uri.clone(),
172            )
173        })?;
174        verify_requested_scope_is_subset(&scope_data.scope, &allowed)?;
175
176        let client_id = client_app.id.clone().ok_or_else(|| {
177            OIDCError::new(
178                OIDCErrorCode::ServerError,
179                Some("Failed to retrieve client ID.".to_string()),
180                None,
181            )
182        })?;
183
184        ProjectModelAdmin::create(
185            &*app_state.repo,
186            &tenant,
187            &project,
188            CreateScope {
189                client: ClientId::new(client_id.clone()),
190                user_: UserId::new(completed_auth_state.user.id),
191                scope: scope_data.scope.clone(),
192            },
193        )
194        .await
195        .map_err(|_| {
196            OIDCError::new(
197                OIDCErrorCode::ServerError,
198                Some("Failed to create scope authorization.".to_string()),
199                error_redirect_uri.clone(),
200            )
201        })?;
202
203        // Back to authorize with the same request, but the approved scopes,
204        // which now match the stored ones. Encoded: the scope has spaces, and
205        // state, redirect_uri and launch are the client's own.
206        let mut query = url::form_urlencoded::Serializer::new(String::new());
207        query
208            .append_pair("client_id", client_id.as_str())
209            .append_pair("response_type", scope_data.response_type.as_str())
210            .append_pair("state", scope_data.state.as_str())
211            .append_pair("code_challenge", scope_data.code_challenge.as_str())
212            .append_pair(
213                "code_challenge_method",
214                scope_data.code_challenge_method.as_str(),
215            )
216            .append_pair("scope", String::from(scope_data.scope).as_str())
217            .append_pair("redirect_uri", scope_data.redirect_uri.as_str());
218        if let Some(launch) = scope_data.launch.as_deref() {
219            query.append_pair("launch", launch);
220        }
221        if let Some(aud) = scope_data.aud.as_deref() {
222            query.append_pair("aud", aud);
223        }
224
225        let authorization_route = oidc_route_string(&tenant, &project, "auth/authorize")
226            .to_str()
227            .expect("Could not create authorize route.")
228            .to_string()
229            + "?"
230            + query.finish().as_str();
231        let redirect = axum::response::Redirect::to(&authorization_route);
232        Ok(redirect.into_response())
233    } else {
234        Err(OIDCError::new(
235            OIDCErrorCode::AccessDenied,
236            Some("User did not accept the requested scopes.".to_string()),
237            error_redirect_uri,
238        ))
239    }
240}