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            ctx.database.get_oauth_client(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), Some(subject)) = (
162            claims.get("sid").and_then(Value::as_str),
163            claims.get("sub").and_then(Value::as_str),
164        ) else {
165            return protocol_error(req, 401, "invalid_token", "The id_token_hint is invalid");
166        };
167        let matches = current
168            .as_ref()
169            .is_some_and(|session| session.id().as_ref() == sid);
170        if current.is_some() && !matches {
171            return confirmation(req, current.as_ref(), Some(&client), &input, ctx);
172        }
173        let sessions = ctx.database.get_user_sessions(subject).await?;
174        if let Some(session) = sessions.iter().find(|session| session.id().as_ref() == sid) {
175            ctx.database.delete_session(session.token()).await?;
176        }
177        let mut response = if let Some(uri) = input
178            .get("post_logout_redirect_uri")
179            .and_then(Value::as_str)
180            .filter(|uri| !uri.is_empty())
181            && client
182                .metadata
183                .get("post_logout_redirect_uris")
184                .and_then(Value::as_array)
185                .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(uri)))
186        {
187            let location = if let Some(state) = input
188                .get("state")
189                .and_then(Value::as_str)
190                .filter(|state| !state.is_empty())
191            {
192                let mut url = url::Url::parse(uri)
193                    .map_err(|_| alibi_core::AuthError::bad_request("Invalid logout redirect"))?;
194                _ = url.query_pairs_mut().append_pair("state", state);
195                url.to_string()
196            } else {
197                uri.to_owned()
198            };
199            EndpointResponse::redirect(location).with_header("content-type", "application/json")
200        } else if browser(req) {
201            success_page(false)
202        } else {
203            EndpointResponse::json(&Value::Null)?
204        };
205        if matches {
206            for header in delete_session_cookie_headers(&ctx.config)? {
207                response = response.with_header("set-cookie", header);
208            }
209        }
210        response = response.with_header("set-cookie", confirmation_cookie(ctx, "", true)?);
211        Ok(no_store(response))
212    }
213}
214fn no_store(response: EndpointResponse) -> EndpointResponse {
215    response
216        .with_header("cache-control", "no-store")
217        .with_header("pragma", "no-cache")
218}
219fn protocol_error(
220    req: &impl SessionRequest,
221    status: u16,
222    error: &str,
223    description: &str,
224) -> AuthResult<EndpointResponse> {
225    if browser(req) {
226        return Ok(page(
227            "Logout error",
228            &format!(
229                "<main><h1>Logout error</h1><p data-oidc-logout-state=\"error\">{}</p></main>",
230                escape(description)
231            ),
232            status,
233        ));
234    }
235    super::clients::status_protocol_error(status, error, description).map(no_store)
236}
237pub(super) fn validate(input: Option<&JsValue>, location: &str) -> AuthResult<Option<JsValue>> {
238    input
239        .map(|input| {
240            crate::endpoint::validate_fields(
241                Some(input),
242                location,
243                &[
244                    crate::authentication_helpers::JsonField::string("id_token_hint", false),
245                    crate::authentication_helpers::JsonField::string("client_id", false),
246                    crate::authentication_helpers::JsonField::string(
247                        "post_logout_redirect_uri",
248                        false,
249                    ),
250                    crate::authentication_helpers::JsonField::string("state", false),
251                ],
252            )
253        })
254        .transpose()
255}
256
257fn browser(req: &impl SessionRequest) -> bool {
258    let headers = req
259        .physical_request()
260        .map_or_else(|| req.session_headers(), |request| &request.headers);
261    let header = |key: &str| {
262        headers
263            .iter()
264            .find(|(name, _)| name.eq_ignore_ascii_case(key))
265            .map_or("", |(_, value)| value.as_str())
266    };
267    header("sec-fetch-mode") != "cors"
268        && (header("sec-fetch-mode") == "navigate"
269            || header("accept").contains("text/html")
270            || header("accept").contains("application/xhtml+xml"))
271}
272fn escape(value: &str) -> String {
273    value
274        .replace('&', "&amp;")
275        .replace('<', "&lt;")
276        .replace('>', "&gt;")
277        .replace('"', "&quot;")
278        .replace('\'', "&#39;")
279}
280fn page(title: &str, body: &str, status: u16) -> EndpointResponse {
281    let mut response = alibi_core::AuthResponse::new(status);
282    response.body=format!("<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title></head><body>{body}</body></html>",escape(title)).into_bytes();
283    _ = response
284        .headers
285        .insert("content-type", "text/html; charset=utf-8");
286    _ = response.headers.insert(
287        "content-security-policy",
288        "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
289    );
290    _ = response.headers.insert("x-content-type-options", "nosniff");
291    no_store(EndpointResponse::from_http_response(response))
292}
293fn success_page(invalid_redirect: bool) -> EndpointResponse {
294    page(
295        "Logged out",
296        if invalid_redirect {
297            "<main><p data-oidc-logout-state=\"logged-out\">Logged out. The requested post-logout redirect was not registered.</p></main>"
298        } else {
299            "<main><p data-oidc-logout-state=\"logged-out\">Logged out.</p></main>"
300        },
301        200,
302    )
303}
304fn confirmation_cookie<S: AuthSchema>(
305    ctx: &AuthContext<S>,
306    value: &str,
307    clear: bool,
308) -> AuthResult<String> {
309    use alibi_core::utils::cookie_utils::create_derived_session_cookie;
310    let name = format!(
311        "{}.oauth_logout_confirmation",
312        related_cookie_name(&ctx.config, "session_token")
313    );
314    let raw = create_derived_session_cookie(&name, value, clear, &ctx.config)?;
315    let mut cookie = cookie::Cookie::parse(raw)
316        .map_err(|error| alibi_core::AuthError::internal(error.to_string()))?
317        .into_owned();
318    cookie.set_path(format!(
319        "{}/oauth2/end-session/confirm",
320        ctx.config.base_path
321    ));
322    cookie.set_max_age(cookie::time::Duration::seconds(if clear { 0 } else { 300 }));
323    cookie.set_http_only(true);
324    cookie.set_same_site(cookie::SameSite::Lax);
325    Ok(cookie.to_string())
326}
327fn confirmation<S: AuthSchema>(
328    req: &impl SessionRequest,
329    current: Option<&S::Session>,
330    client: Option<&alibi_core::store::OAuthClient>,
331    input: &Value,
332    ctx: &AuthContext<S>,
333) -> AuthResult<EndpointResponse> {
334    if !browser(req) {
335        return protocol_error(
336            req,
337            400,
338            "invalid_request",
339            if current.is_some() {
340                "User confirmation is required to complete logout"
341            } else {
342                "No active session is available for logout"
343            },
344        );
345    }
346    let mut state = serde_json::Map::from_iter([(
347        "expiresAt".into(),
348        json!(chrono::Utc::now().timestamp_millis() + 300_000),
349    )]);
350    if let Some(current) = current {
351        _ = state.insert("sessionId".into(), json!(current.id()));
352    }
353    if let Some(client) = client
354        && let Some(uri) = input
355            .get("post_logout_redirect_uri")
356            .and_then(Value::as_str)
357            .filter(|value| !value.is_empty())
358    {
359        if client
360            .metadata
361            .get("post_logout_redirect_uris")
362            .and_then(Value::as_array)
363            .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(uri)))
364        {
365            _ = state.insert("clientId".into(), json!(client.client_id));
366            _ = state.insert("postLogoutRedirectUri".into(), json!(uri));
367            if let Some(value) = input.get("state") {
368                _ = state.insert("state".into(), value.clone());
369            }
370        } else {
371            _ = state.insert("redirectInvalid".into(), json!(true));
372        }
373    }
374    let signed = alibi_core::utils::cookie_utils::sign_cookie_value(
375        &serde_json::to_string(&state)?,
376        ctx.config.current_secret(),
377    );
378    let url = format!(
379        "{}{}/oauth2/end-session/confirm",
380        ctx.config.base_url.trim_end_matches('/'),
381        ctx.config.base_path
382    );
383    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)?))
384}
385impl OAuthProviderPlugin {
386    pub(super) async fn confirm_logout<S: AuthSchema>(
387        &self,
388        req: &impl SessionRequest,
389        ctx: &AuthContext<S>,
390    ) -> AuthResult<EndpointResponse> {
391        let name = format!(
392            "{}.oauth_logout_confirmation",
393            related_cookie_name(&ctx.config, "session_token")
394        );
395        let value = req
396            .session_headers()
397            .iter()
398            .find(|(key, _)| key.eq_ignore_ascii_case("cookie"))
399            .and_then(|(_, header)| {
400                header
401                    .split(';')
402                    .filter_map(|part| part.trim().split_once('='))
403                    .find(|(key, _)| *key == name)
404                    .map(|(_, value)| value)
405            });
406        let state = value
407            .and_then(|value| {
408                alibi_core::utils::cookie_utils::verify_cookie_value(
409                    value,
410                    ctx.config.current_secret(),
411                )
412            })
413            .and_then(|value| serde_json::from_str::<Value>(&value).ok());
414        let Some(state) = state.filter(|state| {
415            state
416                .get("expiresAt")
417                .and_then(Value::as_i64)
418                .is_some_and(|time| time > chrono::Utc::now().timestamp_millis())
419        }) else {
420            return protocol_error(
421                req,
422                400,
423                "invalid_request",
424                "The logout confirmation is invalid or expired",
425            );
426        };
427        let manager = ctx.session_manager();
428        let current = if let Some(token) = manager.extract_session_token(req) {
429            manager.get_session(&token).await?
430        } else {
431            None
432        };
433        if let Some(current) = &current
434            && state
435                .get("sessionId")
436                .and_then(Value::as_str)
437                .is_some_and(|id| id != current.id().as_ref())
438        {
439            return protocol_error(
440                req,
441                400,
442                "invalid_request",
443                "The logout confirmation is invalid or expired",
444            );
445        }
446        let mut invalid_redirect = state.get("redirectInvalid") == Some(&Value::Bool(true));
447        let mut redirect = None;
448        if !invalid_redirect
449            && let (Some(id), Some(uri)) = (
450                state.get("clientId").and_then(Value::as_str),
451                state.get("postLogoutRedirectUri").and_then(Value::as_str),
452            )
453        {
454            let client = ctx.database.get_oauth_client(id).await?;
455            if client.is_some_and(|client| {
456                client.metadata.get("disabled") != Some(&Value::Bool(true))
457                    && client.metadata.get("enable_end_session") == Some(&Value::Bool(true))
458                    && client
459                        .metadata
460                        .get("post_logout_redirect_uris")
461                        .and_then(Value::as_array)
462                        .is_some_and(|values| {
463                            values.iter().any(|value| value.as_str() == Some(uri))
464                        })
465            }) {
466                let mut url = url::Url::parse(uri)
467                    .map_err(|_| alibi_core::AuthError::bad_request("Invalid logout redirect"))?;
468                if let Some(state) = state
469                    .get("state")
470                    .and_then(Value::as_str)
471                    .filter(|value| !value.is_empty())
472                {
473                    _ = url.query_pairs_mut().append_pair("state", state);
474                }
475                redirect = Some(url.to_string());
476            } else {
477                invalid_redirect = true;
478            }
479        }
480        if let Some(current) = &current {
481            ctx.database.delete_session(current.token()).await?;
482        }
483        let mut response = if let Some(uri) = redirect {
484            EndpointResponse::redirect(uri).with_header("content-type", "application/json")
485        } else if browser(req) {
486            success_page(invalid_redirect)
487        } else if current.is_none() {
488            protocol_error(
489                req,
490                400,
491                "invalid_request",
492                "No active session is available for logout",
493            )?
494        } else {
495            EndpointResponse::json(&Value::Null)?
496        };
497        if current.is_some() {
498            for header in delete_session_cookie_headers(&ctx.config)? {
499                response = response.with_header("set-cookie", header);
500            }
501        }
502        response = response.with_header("set-cookie", confirmation_cookie(ctx, "", true)?);
503        Ok(no_store(response))
504    }
505}