Skip to main content

alibi_plugins/oauth_provider/
logout.rs

1//! RP initiated logout binds signed ID-token hints to real stored sessions.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::utils::cookie_utils::{delete_session_cookie_headers, related_cookie_name};
6use alibi_core::utils::json::JsValue;
7use alibi_core::{AuthContext, AuthResult, AuthSchema, AuthSession};
8use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
9use serde_json::{Value, json};
10impl OAuthProviderPlugin {
11    pub fn oauth2_end_session_confirmation_endpoint() -> AuthResult<ServerEndpoint<Value>> {
12        ServerEndpoint::new("oauth-provider", "oauth2EndSessionConfirmation")
13            .with_body(&json!({"action":"confirm"}))
14    }
15    pub fn oauth2_end_session_endpoint(input: &Value) -> AuthResult<ServerEndpoint<Value>> {
16        ServerEndpoint::new("oauth-provider", "oauth2EndSession").with_query(input)
17    }
18    pub(super) async fn end_session<S: AuthSchema>(
19        &self,
20        query: Option<&JsValue>,
21        body: Option<&JsValue>,
22        req: &impl SessionRequest,
23        ctx: &AuthContext<S>,
24    ) -> AuthResult<EndpointResponse> {
25        let mut input = query
26            .map(JsValue::to_json_value)
27            .transpose()?
28            .unwrap_or(json!({}));
29        if let Some(body) = body {
30            for (key, value) in body.to_json_value()?.as_object().into_iter().flatten() {
31                if let Some(input) = input.as_object_mut() {
32                    _ = input.insert(key.clone(), value.clone());
33                }
34            }
35        }
36        let manager = ctx.session_manager();
37        let current = if let Some(token) = manager.extract_session_token(req) {
38            manager.get_session(&token).await?
39        } else {
40            None
41        };
42        let hint = input.get("id_token_hint").and_then(Value::as_str);
43        let decoded = hint
44            .and_then(|token| token.split('.').nth(1))
45            .and_then(|part| URL_SAFE_NO_PAD.decode(part).ok())
46            .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok());
47        let client_id = input
48            .get("client_id")
49            .and_then(Value::as_str)
50            .filter(|value| !value.is_empty())
51            .or_else(|| {
52                decoded.as_ref().and_then(|payload| {
53                    payload
54                        .get("aud")
55                        .and_then(Value::as_str)
56                        .filter(|value| !value.is_empty())
57                        .or_else(|| {
58                            payload
59                                .get("azp")
60                                .and_then(Value::as_str)
61                                .filter(|value| !value.is_empty())
62                        })
63                        .or_else(|| {
64                            payload
65                                .get("aud")
66                                .and_then(Value::as_array)
67                                .filter(|values| values.len() == 1)
68                                .and_then(|values| values.first().and_then(Value::as_str))
69                        })
70                })
71            });
72        let client = if let Some(id) = client_id {
73            super::discovery::resolve(ctx, id).await?
74        } else {
75            None
76        };
77        if hint.is_some() || client_id.is_some() {
78            let Some(client) = &client else {
79                if hint.is_some() && current.is_some() && browser(req) {
80                    return confirmation(req, current.as_ref(), None, &json!({}), ctx);
81                }
82                return protocol_error(
83                    req,
84                    400,
85                    "invalid_client",
86                    "The logout client does not exist",
87                );
88            };
89            if client.metadata.get("disabled") == Some(&Value::Bool(true)) {
90                return protocol_error(req, 400, "invalid_client", "The logout client is disabled");
91            }
92            if client.metadata.get("enable_end_session") != Some(&Value::Bool(true)) {
93                return protocol_error(
94                    req,
95                    401,
96                    "invalid_client",
97                    "The client is not allowed to initiate logout",
98                );
99            }
100        }
101        let Some(hint) = hint else {
102            return confirmation(req, current.as_ref(), client.as_ref(), &input, ctx);
103        };
104        let Some(client) = client else {
105            return protocol_error(
106                req,
107                400,
108                "invalid_client",
109                "The logout client does not exist",
110            );
111        };
112        let jwt = ctx.extensions.get::<crate::jwt::JwtPlugin>();
113        let claims = if let Some(jwt) = jwt {
114            jwt.verify_signed_payload(hint, req.physical_request(), ctx)
115                .await
116                .unwrap_or(None)
117        } else {
118            None
119        };
120        let base = format!(
121            "{}{}",
122            ctx.config.base_url.trim_end_matches('/'),
123            ctx.config.base_path
124        );
125        let discovery = ctx.extensions.get::<crate::jwt::JwtDiscoveryOptions>();
126        let issuer = discovery
127            .as_ref()
128            .and_then(|options| options.issuer.as_deref())
129            .unwrap_or(&base);
130        let Some(claims) = claims.filter(|claims| {
131            claims.get("iss").and_then(Value::as_str) == Some(issuer)
132                && claims.get("aud").is_some_and(|aud| {
133                    aud.as_str() == Some(client.client_id.as_str())
134                        || aud.as_array().is_some_and(|values| {
135                            values
136                                .iter()
137                                .any(|value| value.as_str() == Some(client.client_id.as_str()))
138                        })
139                })
140                && ["sub", "sid"].iter().all(|field| {
141                    claims
142                        .get(*field)
143                        .and_then(Value::as_str)
144                        .is_some_and(|value| !value.is_empty())
145                })
146        }) else {
147            if current.is_some() && browser(req) {
148                let selected = if input
149                    .get("client_id")
150                    .and_then(Value::as_str)
151                    .is_some_and(|value| !value.is_empty())
152                {
153                    Some(&client)
154                } else {
155                    None
156                };
157                return confirmation(req, current.as_ref(), selected, &input, ctx);
158            }
159            return protocol_error(req, 401, "invalid_token", "The id_token_hint is invalid");
160        };
161        let Some(sid) = claims.get("sid").and_then(Value::as_str) else {
162            return protocol_error(req, 401, "invalid_token", "The id_token_hint is invalid");
163        };
164        let matches = current
165            .as_ref()
166            .is_some_and(|session| session.id().as_ref() == sid);
167        if current.is_some() && !matches {
168            return confirmation(req, current.as_ref(), Some(&client), &input, ctx);
169        }
170        if let Some(session) = ctx.database.get_session_by_id(sid).await? {
171            ctx.database.delete_session(session.token()).await?;
172        }
173        let mut response = if let Some(uri) = input
174            .get("post_logout_redirect_uri")
175            .and_then(Value::as_str)
176            .filter(|uri| !uri.is_empty())
177            && client
178                .metadata
179                .get("post_logout_redirect_uris")
180                .and_then(Value::as_array)
181                .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(uri)))
182        {
183            let location = if let Some(state) = input
184                .get("state")
185                .and_then(Value::as_str)
186                .filter(|state| !state.is_empty())
187            {
188                let mut url = url::Url::parse(uri)
189                    .map_err(|_| alibi_core::AuthError::bad_request("Invalid logout redirect"))?;
190                _ = url.query_pairs_mut().append_pair("state", state);
191                url.to_string()
192            } else {
193                uri.to_owned()
194            };
195            EndpointResponse::redirect(location).with_header("content-type", "application/json")
196        } else if browser(req) {
197            success_page(false)
198        } else {
199            EndpointResponse::json(&Value::Null)?
200        };
201        if matches {
202            for header in delete_session_cookie_headers(&ctx.config)? {
203                response = response.with_header("set-cookie", header);
204            }
205        }
206        response = response.with_header("set-cookie", confirmation_cookie(ctx, "", true)?);
207        Ok(no_store(response))
208    }
209}
210fn no_store(response: EndpointResponse) -> EndpointResponse {
211    response
212        .with_header("cache-control", "no-store")
213        .with_header("pragma", "no-cache")
214}
215fn protocol_error(
216    req: &impl SessionRequest,
217    status: u16,
218    error: &str,
219    description: &str,
220) -> AuthResult<EndpointResponse> {
221    if browser(req) {
222        return Ok(page(
223            "Logout error",
224            &format!(
225                "<main><h1>Logout error</h1><p data-oidc-logout-state=\"error\">{}</p></main>",
226                escape(description)
227            ),
228            status,
229        ));
230    }
231    super::clients::status_protocol_error(status, error, description).map(no_store)
232}
233pub(super) fn validate(input: Option<&JsValue>, location: &str) -> AuthResult<Option<JsValue>> {
234    input
235        .map(|input| {
236            crate::endpoint::validate_fields(
237                Some(input),
238                location,
239                &[
240                    crate::authentication_helpers::JsonField::string("id_token_hint", false),
241                    crate::authentication_helpers::JsonField::string("client_id", false),
242                    crate::authentication_helpers::JsonField::string(
243                        "post_logout_redirect_uri",
244                        false,
245                    ),
246                    crate::authentication_helpers::JsonField::string("state", false),
247                ],
248            )
249        })
250        .transpose()
251}
252
253fn browser(req: &impl SessionRequest) -> bool {
254    let headers = req
255        .physical_request()
256        .map_or_else(|| req.session_headers(), |request| &request.headers);
257    let header = |key: &str| {
258        headers
259            .iter()
260            .find(|(name, _)| name.eq_ignore_ascii_case(key))
261            .map_or("", |(_, value)| value.as_str())
262    };
263    header("sec-fetch-mode") != "cors"
264        && (header("sec-fetch-mode") == "navigate"
265            || header("accept").contains("text/html")
266            || header("accept").contains("application/xhtml+xml"))
267}
268fn escape(value: &str) -> String {
269    value
270        .replace('&', "&amp;")
271        .replace('<', "&lt;")
272        .replace('>', "&gt;")
273        .replace('"', "&quot;")
274        .replace('\'', "&#39;")
275}
276fn page(title: &str, body: &str, status: u16) -> EndpointResponse {
277    let mut response = alibi_core::AuthResponse::new(status);
278    response.body=format!("<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title></head><body>{body}</body></html>",escape(title)).into_bytes();
279    _ = response
280        .headers
281        .insert("content-type", "text/html; charset=utf-8");
282    _ = response.headers.insert(
283        "content-security-policy",
284        "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
285    );
286    _ = response.headers.insert("x-content-type-options", "nosniff");
287    no_store(EndpointResponse::from_http_response(response))
288}
289fn success_page(invalid_redirect: bool) -> EndpointResponse {
290    page(
291        "Logged out",
292        if invalid_redirect {
293            "<main><p data-oidc-logout-state=\"logged-out\">Logged out. The requested post-logout redirect was not registered.</p></main>"
294        } else {
295            "<main><p data-oidc-logout-state=\"logged-out\">Logged out.</p></main>"
296        },
297        200,
298    )
299}
300fn confirmation_cookie<S: AuthSchema>(
301    ctx: &AuthContext<S>,
302    value: &str,
303    clear: bool,
304) -> AuthResult<String> {
305    use alibi_core::utils::cookie_utils::create_derived_session_cookie;
306    let name = format!(
307        "{}.oauth_logout_confirmation",
308        related_cookie_name(&ctx.config, "session_token")
309    );
310    let raw = create_derived_session_cookie(&name, value, clear, &ctx.config)?;
311    let mut cookie = cookie::Cookie::parse(raw)
312        .map_err(|error| alibi_core::AuthError::internal(error.to_string()))?
313        .into_owned();
314    cookie.set_path(format!(
315        "{}/oauth2/end-session/confirm",
316        ctx.config.base_path
317    ));
318    cookie.set_max_age(cookie::time::Duration::seconds(if clear { 0 } else { 300 }));
319    cookie.set_http_only(true);
320    cookie.set_same_site(cookie::SameSite::Lax);
321    Ok(cookie.to_string())
322}
323fn confirmation<S: AuthSchema>(
324    req: &impl SessionRequest,
325    current: Option<&S::Session>,
326    client: Option<&alibi_core::store::OAuthClient>,
327    input: &Value,
328    ctx: &AuthContext<S>,
329) -> AuthResult<EndpointResponse> {
330    if !browser(req) {
331        return protocol_error(
332            req,
333            400,
334            "invalid_request",
335            if current.is_some() {
336                "User confirmation is required to complete logout"
337            } else {
338                "No active session is available for logout"
339            },
340        );
341    }
342    let mut state = serde_json::Map::from_iter([(
343        "expiresAt".into(),
344        json!(chrono::Utc::now().timestamp_millis() + 300_000),
345    )]);
346    if let Some(current) = current {
347        _ = state.insert("sessionId".into(), json!(current.id()));
348    }
349    if let Some(client) = client
350        && let Some(uri) = input
351            .get("post_logout_redirect_uri")
352            .and_then(Value::as_str)
353            .filter(|value| !value.is_empty())
354    {
355        if client
356            .metadata
357            .get("post_logout_redirect_uris")
358            .and_then(Value::as_array)
359            .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(uri)))
360        {
361            _ = state.insert("clientId".into(), json!(client.client_id));
362            _ = state.insert("postLogoutRedirectUri".into(), json!(uri));
363            if let Some(value) = input.get("state") {
364                _ = state.insert("state".into(), value.clone());
365            }
366        } else {
367            _ = state.insert("redirectInvalid".into(), json!(true));
368        }
369    }
370    let signed = alibi_core::utils::cookie_utils::sign_cookie_value(
371        &serde_json::to_string(&state)?,
372        ctx.config.current_secret(),
373    );
374    let url = format!(
375        "{}{}/oauth2/end-session/confirm",
376        ctx.config.base_url.trim_end_matches('/'),
377        ctx.config.base_path
378    );
379    Ok(page("Confirm logout",&format!("<main><h1>Confirm logout</h1><p>Do you want to log out of this account?</p><form method=\"post\" data-oidc-logout-confirmation action=\"{}\"><button type=\"submit\" name=\"action\" value=\"confirm\">Confirm logout</button></form></main>",escape(&url)),200).with_header("set-cookie",confirmation_cookie(ctx,&signed,false)?))
380}
381impl OAuthProviderPlugin {
382    pub(super) async fn confirm_logout<S: AuthSchema>(
383        &self,
384        req: &impl SessionRequest,
385        ctx: &AuthContext<S>,
386    ) -> AuthResult<EndpointResponse> {
387        let name = format!(
388            "{}.oauth_logout_confirmation",
389            related_cookie_name(&ctx.config, "session_token")
390        );
391        let value = req
392            .session_headers()
393            .iter()
394            .find(|(key, _)| key.eq_ignore_ascii_case("cookie"))
395            .and_then(|(_, header)| {
396                header
397                    .split(';')
398                    .filter_map(|part| part.trim().split_once('='))
399                    .find(|(key, _)| *key == name)
400                    .map(|(_, value)| value)
401            });
402        let state = value
403            .and_then(|value| {
404                alibi_core::utils::cookie_utils::verify_cookie_value(
405                    value,
406                    ctx.config.current_secret(),
407                )
408            })
409            .and_then(|value| serde_json::from_str::<Value>(&value).ok());
410        let Some(state) = state.filter(|state| {
411            state
412                .get("expiresAt")
413                .and_then(Value::as_i64)
414                .is_some_and(|time| time > chrono::Utc::now().timestamp_millis())
415        }) else {
416            return protocol_error(
417                req,
418                400,
419                "invalid_request",
420                "The logout confirmation is invalid or expired",
421            );
422        };
423        let manager = ctx.session_manager();
424        let current = if let Some(token) = manager.extract_session_token(req) {
425            manager.get_session(&token).await?
426        } else {
427            None
428        };
429        if let Some(current) = &current
430            && state
431                .get("sessionId")
432                .and_then(Value::as_str)
433                .is_some_and(|id| id != current.id().as_ref())
434        {
435            return protocol_error(
436                req,
437                400,
438                "invalid_request",
439                "The logout confirmation is invalid or expired",
440            );
441        }
442        let mut invalid_redirect = state.get("redirectInvalid") == Some(&Value::Bool(true));
443        let mut redirect = None;
444        if !invalid_redirect
445            && let (Some(id), Some(uri)) = (
446                state.get("clientId").and_then(Value::as_str),
447                state.get("postLogoutRedirectUri").and_then(Value::as_str),
448            )
449        {
450            let client = super::discovery::resolve(ctx, id).await?;
451            if client.is_some_and(|client| {
452                client.metadata.get("disabled") != Some(&Value::Bool(true))
453                    && client.metadata.get("enable_end_session") == Some(&Value::Bool(true))
454                    && client
455                        .metadata
456                        .get("post_logout_redirect_uris")
457                        .and_then(Value::as_array)
458                        .is_some_and(|values| {
459                            values.iter().any(|value| value.as_str() == Some(uri))
460                        })
461            }) {
462                let mut url = url::Url::parse(uri)
463                    .map_err(|_| alibi_core::AuthError::bad_request("Invalid logout redirect"))?;
464                if let Some(state) = state
465                    .get("state")
466                    .and_then(Value::as_str)
467                    .filter(|value| !value.is_empty())
468                {
469                    _ = url.query_pairs_mut().append_pair("state", state);
470                }
471                redirect = Some(url.to_string());
472            } else {
473                invalid_redirect = true;
474            }
475        }
476        if let Some(current) = &current {
477            ctx.database.delete_session(current.token()).await?;
478        }
479        let mut response = if let Some(uri) = redirect {
480            EndpointResponse::redirect(uri).with_header("content-type", "application/json")
481        } else if browser(req) {
482            success_page(invalid_redirect)
483        } else if current.is_none() {
484            protocol_error(
485                req,
486                400,
487                "invalid_request",
488                "No active session is available for logout",
489            )?
490        } else {
491            EndpointResponse::json(&Value::Null)?
492        };
493        if current.is_some() {
494            for header in delete_session_cookie_headers(&ctx.config)? {
495                response = response.with_header("set-cookie", header);
496            }
497        }
498        response = response.with_header("set-cookie", confirmation_cookie(ctx, "", true)?);
499        Ok(no_store(response))
500    }
501}