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 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('&', "&")
275 .replace('<', "<")
276 .replace('>', ">")
277 .replace('"', """)
278 .replace('\'', "'")
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) = ¤t
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) = ¤t {
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}