Implement secure oidc logout endpoint and sql function (#1141)
* Checkpoint before follow-up message Co-authored-by: contact <contact@ophir.dev> * Checkpoint before follow-up message Co-authored-by: contact <contact@ophir.dev> * Checkpoint before follow-up message Co-authored-by: contact <contact@ophir.dev> * Checkpoint before follow-up message Co-authored-by: contact <contact@ophir.dev> * feat: Add OIDC logout functionality This commit introduces the `oidc_logout_url` function, allowing users to securely log out of OIDC-authenticated applications. It includes CSRF protection and handles redirection to the OIDC provider's logout endpoint. Co-authored-by: contact <contact@ophir.dev> * Refactor OIDC logout cookie removal Co-authored-by: contact <contact@ophir.dev> * feat: Implement OIDC logout with CSRF protection This commit implements secure OIDC logout by: - Using sqlpage.oidc_logout_url() to generate the logout URL. - Ensuring CSRF protection during the logout process. - Redirecting to the OIDC provider's logout endpoint. - Redirecting back to the homepage after logout. - Adding absolute URI for post logout redirect URI. * refactor: Enhance build_absolute_uri function to accept scheme parameter This commit modifies the build_absolute_uri function to include a scheme parameter, allowing for more flexible URL construction. The function now dynamically sets the URL scheme based on the request context, improving compatibility with different environments. * refactor: Simplify OIDC logout processing and enhance logout token handling This commit refactors the OIDC logout process by introducing a new function, `parse_logout_params`, to streamline the extraction of logout parameters from the request. It also updates the logout token creation and verification logic, improving security by ensuring the signature is computed correctly. Additionally, the `create_logout_url` function is modified to include a timestamp and signature in the generated URL, enhancing the logout flow's integrity. * refactor: Improve logout URL generation and parameter parsing This commit refines the `create_logout_url` function to utilize a query string builder for constructing the logout URL, enhancing readability and maintainability. Additionally, the `parse_logout_params` function is updated to use `Query::into_inner`, streamlining the extraction of logout parameters from the request. * refactor: Streamline cookie removal in OIDC logout process This commit simplifies the removal of authentication and nonce cookies during the OIDC logout process by consolidating the cookie removal logic into a single method call for each cookie, enhancing code clarity and maintainability. * refactor: Enhance cookie removal logic in OIDC logout process This commit updates the cookie removal process during OIDC logout by utilizing the `Cookie::build` method to specify cookie attributes, improving clarity and ensuring proper cookie handling. * chore: Update CHANGELOG for version 0.40.1 - Added new function `sqlpage.oidc_logout_url(redirect_uri)` to generate secure logout URLs for OIDC users, supporting RP-Initiated Logout. - Fixed compatibility issues with Auth0 for OpenID-Connect authentication.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# CHANGELOG.md
|
||||
|
||||
## 0.40.1
|
||||
- **New Function**: `sqlpage.oidc_logout_url(redirect_uri)` - Generates a secure logout URL for OIDC-authenticated users with support for [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout)
|
||||
- Fix compatibility with Auth0 for OpenID-Connect authentification. See https://github.com/ramosbugs/openidconnect-rs/issues/23
|
||||
|
||||
## 0.40.0 (2025-11-28)
|
||||
|
||||
@@ -169,4 +169,126 @@ VALUES
|
||||
'claim',
|
||||
'The name of the user information to retrieve. Common values include ''name'', ''email'', ''picture'', ''sub'', ''preferred_username'', ''given_name'', and ''family_name''. The exact values available depend on your OIDC provider and configuration.',
|
||||
'TEXT'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlpage_functions (
|
||||
"name",
|
||||
"introduced_in_version",
|
||||
"icon",
|
||||
"description_md"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'oidc_logout_url',
|
||||
'0.41.0',
|
||||
'logout',
|
||||
'# Secure OIDC Logout
|
||||
|
||||
The `sqlpage.oidc_logout_url` function generates a secure logout URL for users authenticated via [OIDC Single Sign-On](/sso).
|
||||
|
||||
When a user visits this URL, SQLPage will:
|
||||
1. Remove the authentication cookie
|
||||
2. Redirect the user to the OIDC provider''s logout endpoint (if available)
|
||||
3. Finally redirect back to the specified `redirect_uri`
|
||||
|
||||
## Security Features
|
||||
|
||||
This function provides protection against **Cross-Site Request Forgery (CSRF)** attacks:
|
||||
- The generated URL contains a cryptographically signed token
|
||||
- The token includes a timestamp and expires after 10 minutes
|
||||
- The token is signed using your OIDC client secret
|
||||
- Only relative URLs (starting with `/`) are allowed as redirect targets
|
||||
|
||||
This means that malicious websites cannot trick your users into logging out by simply including an image or link to your logout URL.
|
||||
|
||||
## How to Use
|
||||
|
||||
```sql
|
||||
select ''button'' as component;
|
||||
select
|
||||
''Logout'' as title,
|
||||
sqlpage.oidc_logout_url(''/'') as link,
|
||||
''logout'' as icon,
|
||||
''red'' as outline;
|
||||
```
|
||||
|
||||
This creates a logout button that, when clicked:
|
||||
1. Logs the user out of your SQLPage application
|
||||
2. Logs the user out of the OIDC provider (if the provider supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html))
|
||||
3. Redirects the user back to your homepage (`/`)
|
||||
|
||||
## Examples
|
||||
|
||||
### Logout Button in Navigation
|
||||
|
||||
```sql
|
||||
select ''shell'' as component,
|
||||
''My App'' as title,
|
||||
json_array(
|
||||
json_object(
|
||||
''title'', ''Logout'',
|
||||
''link'', sqlpage.oidc_logout_url(''/''),
|
||||
''icon'', ''logout''
|
||||
)
|
||||
) as menu_item;
|
||||
```
|
||||
|
||||
### Logout with Return to Current Page
|
||||
|
||||
```sql
|
||||
select ''button'' as component;
|
||||
select
|
||||
''Sign Out'' as title,
|
||||
sqlpage.oidc_logout_url(sqlpage.path()) as link;
|
||||
```
|
||||
|
||||
### Conditional Logout Link
|
||||
|
||||
```sql
|
||||
select ''button'' as component
|
||||
where sqlpage.user_info(''sub'') is not null;
|
||||
select
|
||||
''Logout '' || sqlpage.user_info(''name'') as title,
|
||||
sqlpage.oidc_logout_url(''/'') as link
|
||||
where sqlpage.user_info(''sub'') is not null;
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- OIDC must be [configured](/sso) in your `sqlpage.json`
|
||||
- If OIDC is not configured, this function returns NULL
|
||||
- The `redirect_uri` must be a relative path starting with `/`
|
||||
|
||||
## Provider Support
|
||||
|
||||
The logout behavior depends on your OIDC provider:
|
||||
|
||||
| Provider | Full Logout Support |
|
||||
|----------|-------------------|
|
||||
| Keycloak | ✅ Yes |
|
||||
| Auth0 | ✅ Yes |
|
||||
| Google | ❌ No (local logout only) |
|
||||
| Azure AD | ✅ Yes |
|
||||
| Okta | ✅ Yes |
|
||||
|
||||
When the provider doesn''t support RP-Initiated Logout, SQLPage will still remove the local authentication cookie and redirect to your specified URI.
|
||||
'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
sqlpage_function_parameters (
|
||||
"function",
|
||||
"index",
|
||||
"name",
|
||||
"description_md",
|
||||
"type"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'oidc_logout_url',
|
||||
1,
|
||||
'redirect_uri',
|
||||
'The relative URL path where the user should be redirected after logout. Must start with `/`. Defaults to `/` if not provided.',
|
||||
'TEXT'
|
||||
);
|
||||
@@ -14,5 +14,14 @@ WHERE $email IS NULL;
|
||||
-- For logged-in users
|
||||
SELECT 'text' as component,
|
||||
'Welcome back, ' || sqlpage.user_info('name') || '!' as title,
|
||||
'You are logged in as ' || sqlpage.user_info('email') || '. You can now access the [protected page](/protected) or [log out](/logout).' as contents_md
|
||||
'You are logged in as ' || sqlpage.user_info('email') ||
|
||||
'. You can now access the [protected page](/protected) or [log out](' ||
|
||||
-- Secure OIDC logout with CSRF protection
|
||||
-- This redirects to /sqlpage/oidc_logout which:
|
||||
-- 1. Verifies the CSRF token
|
||||
-- 2. Removes the auth cookies
|
||||
-- 3. Redirects to the OIDC provider's logout endpoint
|
||||
-- 4. Finally redirects back to the homepage
|
||||
sqlpage.oidc_logout_url()
|
||||
|| ').' as contents_md
|
||||
WHERE $email IS NOT NULL;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- remove the session cookie
|
||||
select
|
||||
'cookie' as component,
|
||||
'sqlpage_auth' as name,
|
||||
true as remove;
|
||||
|
||||
select
|
||||
'redirect' as component,
|
||||
sqlpage.link('http://localhost:8181/realms/sqlpage_demo/protocol/openid-connect/logout', json_object(
|
||||
'post_logout_redirect_uri', 'http://localhost:8080/',
|
||||
'id_token_hint', sqlpage.cookie('sqlpage_auth')
|
||||
)) as link;
|
||||
@@ -1,7 +1,10 @@
|
||||
set user_email = sqlpage.user_info('email');
|
||||
|
||||
select 'shell' as component, 'My secure app' as title,
|
||||
json_object('title', 'Log Out', 'link', '/logout') as menu_item;
|
||||
json_object(
|
||||
'title', 'Log Out',
|
||||
'link', sqlpage.oidc_logout_url()
|
||||
) as menu_item;
|
||||
|
||||
select 'text' as component,
|
||||
'You''re in, '|| sqlpage.user_info('name') || ' !' as title,
|
||||
|
||||
@@ -35,6 +35,8 @@ super::function_definition_macro::sqlpage_functions! {
|
||||
headers((&RequestInfo));
|
||||
hmac(data: Cow<str>, key: Cow<str>, algorithm: Option<Cow<str>>);
|
||||
|
||||
oidc_logout_url((&RequestInfo), redirect_uri: Option<Cow<str>>);
|
||||
|
||||
user_info_token((&RequestInfo));
|
||||
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
|
||||
|
||||
@@ -858,6 +860,31 @@ async fn user_info_token(request: &RequestInfo) -> anyhow::Result<Option<String>
|
||||
Ok(Some(serde_json::to_string(claims)?))
|
||||
}
|
||||
|
||||
async fn oidc_logout_url<'a>(
|
||||
request: &'a RequestInfo,
|
||||
redirect_uri: Option<Cow<'a, str>>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let Some(oidc_state) = &request.app_state.oidc_state else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let redirect_uri = redirect_uri.as_deref().unwrap_or("/");
|
||||
|
||||
if !redirect_uri.starts_with('/') || redirect_uri.starts_with("//") {
|
||||
anyhow::bail!(
|
||||
"oidc_logout_url: redirect_uri must be a relative path starting with '/'. Got: {redirect_uri}"
|
||||
);
|
||||
}
|
||||
|
||||
let logout_url = crate::webserver::oidc::create_logout_url(
|
||||
redirect_uri,
|
||||
&request.app_state.config.site_prefix,
|
||||
&oidc_state.config.client_secret,
|
||||
);
|
||||
|
||||
Ok(Some(logout_url))
|
||||
}
|
||||
|
||||
/// Returns a specific claim from the ID token.
|
||||
async fn user_info<'a>(
|
||||
request: &'a RequestInfo,
|
||||
|
||||
+209
-12
@@ -23,9 +23,11 @@ use openidconnect::core::{
|
||||
CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType,
|
||||
};
|
||||
use openidconnect::{
|
||||
core::CoreAuthenticationFlow, url::Url, AsyncHttpClient, Audience, CsrfToken, EndpointMaybeSet,
|
||||
EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope,
|
||||
TokenResponse,
|
||||
core::CoreAuthenticationFlow,
|
||||
url::{form_urlencoded, Url},
|
||||
AsyncHttpClient, Audience, CsrfToken, EndSessionUrl, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IssuerUrl, LogoutRequest, Nonce, OAuth2TokenResponse, PostLogoutRedirectUrl,
|
||||
ProviderMetadataWithLogout, RedirectUrl, Scope, TokenResponse,
|
||||
};
|
||||
use openidconnect::{
|
||||
EmptyExtraTokenFields, IdTokenFields, IdTokenVerifier, StandardErrorResponse,
|
||||
@@ -40,6 +42,7 @@ type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>;
|
||||
|
||||
const SQLPAGE_AUTH_COOKIE_NAME: &str = "sqlpage_auth";
|
||||
const SQLPAGE_REDIRECT_URI: &str = "/sqlpage/oidc_callback";
|
||||
const SQLPAGE_LOGOUT_URI: &str = "/sqlpage/oidc_logout";
|
||||
const SQLPAGE_NONCE_COOKIE_NAME: &str = "sqlpage_oidc_nonce";
|
||||
const SQLPAGE_TMP_LOGIN_STATE_COOKIE_PREFIX: &str = "sqlpage_oidc_state_";
|
||||
const OIDC_CLIENT_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
@@ -149,8 +152,19 @@ fn get_app_host(config: &AppConfig) -> String {
|
||||
host
|
||||
}
|
||||
|
||||
fn build_absolute_uri(app_host: &str, relative_path: &str, scheme: &str) -> anyhow::Result<String> {
|
||||
let mut base_url = Url::parse(&format!("{scheme}://{app_host}"))
|
||||
.with_context(|| format!("Failed to parse app_host: {app_host}"))?;
|
||||
base_url.set_path("");
|
||||
let absolute_url = base_url
|
||||
.join(relative_path)
|
||||
.with_context(|| format!("Failed to join path {relative_path}"))?;
|
||||
Ok(absolute_url.to_string())
|
||||
}
|
||||
|
||||
pub struct ClientWithTime {
|
||||
client: OidcClient,
|
||||
end_session_endpoint: Option<EndSessionUrl>,
|
||||
last_update: Instant,
|
||||
}
|
||||
|
||||
@@ -162,24 +176,25 @@ pub struct OidcState {
|
||||
impl OidcState {
|
||||
pub async fn new(oidc_cfg: OidcConfig, app_config: AppConfig) -> anyhow::Result<Self> {
|
||||
let http_client = make_http_client(&app_config)?;
|
||||
let client = build_oidc_client(&oidc_cfg, &http_client).await?;
|
||||
let (client, end_session_endpoint) = build_oidc_client(&oidc_cfg, &http_client).await?;
|
||||
|
||||
Ok(Self {
|
||||
config: oidc_cfg,
|
||||
client: RwLock::new(ClientWithTime {
|
||||
client,
|
||||
end_session_endpoint,
|
||||
last_update: Instant::now(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh(&self, service_request: &ServiceRequest) {
|
||||
// Obtain a write lock to prevent concurrent OIDC client refreshes.
|
||||
let mut write_guard = self.client.write().await;
|
||||
match build_oidc_client_from_appdata(&self.config, service_request).await {
|
||||
Ok(http_client) => {
|
||||
Ok((http_client, end_session_endpoint)) => {
|
||||
*write_guard = ClientWithTime {
|
||||
client: http_client,
|
||||
end_session_endpoint,
|
||||
last_update: Instant::now(),
|
||||
}
|
||||
}
|
||||
@@ -210,6 +225,10 @@ impl OidcState {
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_end_session_endpoint(&self) -> Option<EndSessionUrl> {
|
||||
self.client.read().await.end_session_endpoint.clone()
|
||||
}
|
||||
|
||||
/// Validate and decode the claims of an OIDC token, without refreshing the client.
|
||||
async fn get_token_claims(
|
||||
&self,
|
||||
@@ -243,7 +262,7 @@ pub async fn initialize_oidc_state(
|
||||
async fn build_oidc_client_from_appdata(
|
||||
cfg: &OidcConfig,
|
||||
req: &ServiceRequest,
|
||||
) -> anyhow::Result<OidcClient> {
|
||||
) -> anyhow::Result<(OidcClient, Option<EndSessionUrl>)> {
|
||||
let http_client = get_http_client_from_appdata(req)?;
|
||||
build_oidc_client(cfg, http_client).await
|
||||
}
|
||||
@@ -251,11 +270,15 @@ async fn build_oidc_client_from_appdata(
|
||||
async fn build_oidc_client(
|
||||
oidc_cfg: &OidcConfig,
|
||||
http_client: &Client,
|
||||
) -> anyhow::Result<OidcClient> {
|
||||
) -> anyhow::Result<(OidcClient, Option<EndSessionUrl>)> {
|
||||
let issuer_url = oidc_cfg.issuer_url.clone();
|
||||
let provider_metadata = discover_provider_metadata(http_client, issuer_url.clone()).await?;
|
||||
let end_session_endpoint = provider_metadata
|
||||
.additional_metadata()
|
||||
.end_session_endpoint
|
||||
.clone();
|
||||
let client = make_oidc_client(oidc_cfg, provider_metadata)?;
|
||||
Ok(client)
|
||||
Ok((client, end_session_endpoint))
|
||||
}
|
||||
|
||||
pub struct OidcMiddleware {
|
||||
@@ -273,15 +296,19 @@ impl OidcMiddleware {
|
||||
async fn discover_provider_metadata(
|
||||
http_client: &awc::Client,
|
||||
issuer_url: IssuerUrl,
|
||||
) -> anyhow::Result<openidconnect::core::CoreProviderMetadata> {
|
||||
) -> anyhow::Result<ProviderMetadataWithLogout> {
|
||||
log::debug!("Discovering provider metadata for {issuer_url}");
|
||||
let provider_metadata = openidconnect::core::CoreProviderMetadata::discover_async(
|
||||
let provider_metadata = ProviderMetadataWithLogout::discover_async(
|
||||
issuer_url,
|
||||
&AwcHttpClient::from_client(http_client),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Failed to discover OIDC provider metadata".to_string())?;
|
||||
log::debug!("Provider metadata discovered: {provider_metadata:?}");
|
||||
log::debug!(
|
||||
"end_session_endpoint: {:?}",
|
||||
provider_metadata.additional_metadata().end_session_endpoint
|
||||
);
|
||||
Ok(provider_metadata)
|
||||
}
|
||||
|
||||
@@ -337,6 +364,11 @@ async fn handle_request(oidc_state: &OidcState, request: ServiceRequest) -> Midd
|
||||
return MiddlewareResponse::Respond(response);
|
||||
}
|
||||
|
||||
if request.path() == SQLPAGE_LOGOUT_URI {
|
||||
let response = handle_oidc_logout(oidc_state, request).await;
|
||||
return MiddlewareResponse::Respond(response);
|
||||
}
|
||||
|
||||
match get_authenticated_user_info(oidc_state, &request).await {
|
||||
Ok(Some(claims)) => {
|
||||
log::trace!("Storing authenticated user info in request extensions: {claims:?}");
|
||||
@@ -384,6 +416,157 @@ async fn handle_oidc_callback(oidc_state: &OidcState, request: ServiceRequest) -
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_oidc_logout(oidc_state: &OidcState, request: ServiceRequest) -> ServiceResponse {
|
||||
match process_oidc_logout(oidc_state, &request).await {
|
||||
Ok(response) => request.into_response(response),
|
||||
Err(e) => {
|
||||
log::error!("Failed to process OIDC logout: {e:#}");
|
||||
request.into_response(
|
||||
HttpResponse::BadRequest()
|
||||
.content_type("text/plain")
|
||||
.body(format!("Logout failed: {e}")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LogoutParams {
|
||||
redirect_uri: String,
|
||||
timestamp: i64,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
const LOGOUT_TOKEN_VALIDITY_SECONDS: i64 = 600;
|
||||
|
||||
fn parse_logout_params(query: &str) -> anyhow::Result<LogoutParams> {
|
||||
Query::<LogoutParams>::from_query(query)
|
||||
.with_context(|| format!("{SQLPAGE_LOGOUT_URI}: missing required parameters"))
|
||||
.map(Query::into_inner)
|
||||
}
|
||||
|
||||
async fn process_oidc_logout(
|
||||
oidc_state: &OidcState,
|
||||
request: &ServiceRequest,
|
||||
) -> anyhow::Result<HttpResponse> {
|
||||
let params = parse_logout_params(request.query_string())?;
|
||||
|
||||
verify_logout_params(¶ms, &oidc_state.config.client_secret)?;
|
||||
|
||||
let id_token_cookie = request.cookie(SQLPAGE_AUTH_COOKIE_NAME);
|
||||
let id_token = id_token_cookie
|
||||
.as_ref()
|
||||
.map(|c| OidcToken::from_str(c.value()))
|
||||
.transpose()
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let scheme = request.connection_info().scheme().to_string();
|
||||
let mut response =
|
||||
if let Some(end_session_endpoint) = oidc_state.get_end_session_endpoint().await {
|
||||
let absolute_redirect_uri =
|
||||
build_absolute_uri(&oidc_state.config.app_host, ¶ms.redirect_uri, &scheme)?;
|
||||
let post_logout_redirect_uri =
|
||||
PostLogoutRedirectUrl::new(absolute_redirect_uri.clone()).with_context(|| {
|
||||
format!("Invalid post_logout_redirect_uri: {absolute_redirect_uri}")
|
||||
})?;
|
||||
|
||||
let mut logout_request = LogoutRequest::from(end_session_endpoint)
|
||||
.set_post_logout_redirect_uri(post_logout_redirect_uri);
|
||||
|
||||
if let Some(ref token) = id_token {
|
||||
logout_request = logout_request.set_id_token_hint(token);
|
||||
}
|
||||
|
||||
let logout_url = logout_request.http_get_url();
|
||||
log::info!("Redirecting to OIDC logout URL: {logout_url}");
|
||||
build_redirect_response(logout_url.to_string())
|
||||
} else {
|
||||
log::info!(
|
||||
"No end_session_endpoint, redirecting to {}",
|
||||
params.redirect_uri
|
||||
);
|
||||
build_redirect_response(params.redirect_uri)
|
||||
};
|
||||
|
||||
response.add_removal_cookie(
|
||||
&Cookie::build(SQLPAGE_AUTH_COOKIE_NAME, "")
|
||||
.path("/")
|
||||
.finish(),
|
||||
)?;
|
||||
response.add_removal_cookie(
|
||||
&Cookie::build(SQLPAGE_NONCE_COOKIE_NAME, "")
|
||||
.path("/")
|
||||
.finish(),
|
||||
)?;
|
||||
|
||||
log::debug!("User logged out successfully");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn compute_logout_signature(redirect_uri: &str, timestamp: i64, client_secret: &str) -> String {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(client_secret.as_bytes())
|
||||
.expect("HMAC accepts any key size");
|
||||
mac.update(redirect_uri.as_bytes());
|
||||
mac.update(×tamp.to_be_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
|
||||
}
|
||||
|
||||
fn verify_logout_params(params: &LogoutParams, client_secret: &str) -> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let expected_signature =
|
||||
compute_logout_signature(¶ms.redirect_uri, params.timestamp, client_secret);
|
||||
|
||||
let provided_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(¶ms.signature)
|
||||
.with_context(|| "Invalid logout signature encoding")?;
|
||||
|
||||
let expected_signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(&expected_signature)
|
||||
.with_context(|| "Failed to decode expected signature")?;
|
||||
|
||||
if expected_signature_bytes[..] != provided_signature[..] {
|
||||
anyhow::bail!("Invalid logout signature");
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if now - params.timestamp > LOGOUT_TOKEN_VALIDITY_SECONDS {
|
||||
anyhow::bail!("Logout token has expired");
|
||||
}
|
||||
if params.timestamp > now + 60 {
|
||||
anyhow::bail!("Logout token timestamp is in the future");
|
||||
}
|
||||
|
||||
if !params.redirect_uri.starts_with('/') || params.redirect_uri.starts_with("//") {
|
||||
anyhow::bail!("Invalid redirect URI");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn create_logout_url(redirect_uri: &str, site_prefix: &str, client_secret: &str) -> String {
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
let signature = compute_logout_signature(redirect_uri, timestamp, client_secret);
|
||||
let query = form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("timestamp", ×tamp.to_string())
|
||||
.append_pair("signature", &signature)
|
||||
.finish();
|
||||
format!(
|
||||
"{}{}?{}",
|
||||
site_prefix.trim_end_matches('/'),
|
||||
SQLPAGE_LOGOUT_URI,
|
||||
query
|
||||
)
|
||||
}
|
||||
|
||||
impl<S> Service<ServiceRequest> for OidcService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<BoxBody>, Error = Error> + 'static,
|
||||
@@ -630,7 +813,7 @@ impl std::error::Error for AwcWrapperError {
|
||||
|
||||
fn make_oidc_client(
|
||||
config: &OidcConfig,
|
||||
provider_metadata: openidconnect::core::CoreProviderMetadata,
|
||||
provider_metadata: ProviderMetadataWithLogout,
|
||||
) -> anyhow::Result<OidcClient> {
|
||||
let client_id = openidconnect::ClientId::new(config.client_id.clone());
|
||||
let client_secret = openidconnect::ClientSecret::new(config.client_secret.clone());
|
||||
@@ -840,6 +1023,7 @@ fn validate_redirect_url(url: String) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_web::http::StatusCode;
|
||||
use openidconnect::url::Url;
|
||||
|
||||
#[test]
|
||||
fn login_redirects_use_see_other() {
|
||||
@@ -868,4 +1052,17 @@ mod tests {
|
||||
.expect("Auth0 returns updated_at as RFC3339 string, not unix timestamp");
|
||||
assert!(claims.updated_at().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_url_generation_and_parsing_are_compatible() {
|
||||
let secret = "super_secret_key";
|
||||
let generated = create_logout_url("/after", "https://example.com", secret);
|
||||
|
||||
let parsed = Url::parse(&generated).expect("generated URL should be valid");
|
||||
assert_eq!(parsed.path(), SQLPAGE_LOGOUT_URI);
|
||||
|
||||
let params = parse_logout_params(parsed.query().expect("query string is present"))
|
||||
.expect("generated URL should parse");
|
||||
verify_logout_params(¶ms, secret).expect("generated URL should validate");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user