1use 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('&', "&")
271 .replace('<', "<")
272 .replace('>', ">")
273 .replace('"', """)
274 .replace('\'', "'")
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) = ¤t
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) = ¤t {
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}