use crate::api::{ApiResponse, ApiState, extract_bearer_token, validate_api_token};
use crate::oauth2_server::AuthorizationRequest;
pub use crate::oauth2_server::{
AuthorizationRequest as AuthorizeRequest, TokenRequest, TokenResponse,
};
use axum::{
Json,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use url::Url;
use uuid::Uuid;
fn redirect_uri_matches(candidate: &str, registered: &str) -> bool {
match (Url::parse(candidate), Url::parse(registered)) {
(Ok(a), Ok(b)) => {
if a.fragment().is_some() || b.fragment().is_some() {
return false;
}
a.scheme() == b.scheme()
&& a.host_str() == b.host_str()
&& a.port_or_known_default() == b.port_or_known_default()
&& a.path() == b.path()
&& a.query() == b.query()
}
_ => candidate == registered,
}
}
#[derive(Debug, Serialize)]
pub struct OAuthError {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
impl OAuthError {
pub fn new(error: impl Into<String>) -> Self {
Self {
error: error.into(),
error_description: None,
error_uri: None,
state: None,
}
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.error_description = Some(desc.into());
self
}
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
pub fn maybe_state(mut self, state: Option<String>) -> Self {
self.state = state;
self
}
pub fn error_uri(mut self, uri: impl Into<String>) -> Self {
self.error_uri = Some(uri.into());
self
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ClientInfo {
pub client_id: String,
pub name: String,
pub description: String,
pub redirect_uris: Vec<String>,
pub scopes: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct RevokeRequest {
pub token: String,
#[serde(default)]
pub token_type_hint: Option<String>, }
#[derive(Debug, Serialize)]
pub struct UserInfoResponse {
pub sub: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}
pub async fn authorize(
State(state): State<ApiState>,
headers: HeaderMap,
Query(params): Query<AuthorizationRequest>,
) -> impl IntoResponse {
if params.response_type != "code" {
let error = OAuthError::new("unsupported_response_type")
.description("Only 'code' response type is supported")
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
if params.client_id.is_empty() {
let error = OAuthError::new("invalid_request")
.description("client_id is required")
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
if params.redirect_uri.is_empty() {
let error = OAuthError::new("invalid_request")
.description("redirect_uri is required")
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
let user_id = {
let token_str = match extract_bearer_token(&headers) {
Some(t) => t,
None => {
let error = OAuthError::new("unauthorized_client")
.description(
"User authentication required: supply your access token as \
'Authorization: Bearer <token>'",
)
.maybe_state(params.state);
return (StatusCode::UNAUTHORIZED, Json(error)).into_response();
}
};
match validate_api_token(&state.auth_framework, &token_str).await {
Ok(auth_token) => auth_token.user_id,
Err(_) => {
let error = OAuthError::new("unauthorized_client")
.description("Invalid or expired user access token")
.maybe_state(params.state);
return (StatusCode::UNAUTHORIZED, Json(error)).into_response();
}
}
};
let client_key = format!("oauth2_client:{}", params.client_id);
match state.auth_framework.storage().get_kv(&client_key).await {
Ok(Some(data)) => {
let client_data: serde_json::Value = serde_json::from_slice(&data).unwrap_or_default();
let registered_uris: Vec<String> = client_data["redirect_uris"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
if !registered_uris
.iter()
.any(|r| redirect_uri_matches(¶ms.redirect_uri, r))
{
tracing::warn!(
client_id = %params.client_id,
redirect_uri = %params.redirect_uri,
"OAuth authorize: redirect_uri not registered for client"
);
let error = OAuthError::new("invalid_request")
.description("redirect_uri is not registered for this client")
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
let is_public_client = client_data
.get("client_secret")
.and_then(|v| v.as_str())
.map_or(true, |s| s.is_empty());
if is_public_client && params.code_challenge.is_none() {
let error = OAuthError::new("invalid_request")
.description("Public clients must use PKCE: code_challenge is required")
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
}
Ok(None) => {
tracing::warn!(client_id = %params.client_id, "OAuth authorize: unknown client_id");
let error = OAuthError::new("invalid_client")
.description("Unknown client_id")
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
Err(e) => {
tracing::error!(
client_id = %params.client_id,
error = %e,
"OAuth authorize: storage error looking up client"
);
let error = OAuthError::new("server_error")
.description("Authorization server error")
.maybe_state(params.state);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(error)).into_response();
}
}
let auth_code = format!("ac_{}", uuid::Uuid::new_v4().to_string().replace("-", ""));
if let Some(ref resources) = params.resource {
if let Err(e) = crate::server::oauth::resource_indicators::validate_resource_indicators(resources) {
let error = OAuthError::new("invalid_target")
.description(e.to_string())
.maybe_state(params.state);
return (StatusCode::BAD_REQUEST, Json(error)).into_response();
}
}
let code_data = serde_json::json!({
"client_id": params.client_id,
"redirect_uri": params.redirect_uri,
"scope": params.scope.clone().unwrap_or_else(|| "openid profile email".to_string()),
"state": params.state.clone(),
"code_challenge": params.code_challenge,
"code_challenge_method": params.code_challenge_method,
"user_id": user_id,
"resource": params.resource,
"created_at": chrono::Utc::now().to_rfc3339(),
"expires_at": (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339(),
"used": false,
});
let storage_key = format!("oauth2_code:{}", auth_code);
let code_data_str = match serde_json::to_string(&code_data) {
Ok(s) => s,
Err(e) => {
tracing::error!("Failed to serialize OAuth authorization code data: {:?}", e);
let error = OAuthError::new("server_error")
.description("Authorization server internal error");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(error)).into_response();
}
};
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&storage_key,
code_data_str.as_bytes(),
Some(std::time::Duration::from_secs(600)),
)
.await
{
tracing::error!("Failed to store OAuth authorization code: {:?}", e);
let error = OAuthError::new("server_error")
.description("Authorization server error")
.maybe_state(params.state);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(error)).into_response();
}
let encoded_state: Option<String> = params.state.as_deref().map(|st| {
st.bytes()
.flat_map(|b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
vec![b as char]
} else {
format!("%{:02X}", b).chars().collect()
}
})
.collect()
});
let mut redirect_url = params.redirect_uri;
redirect_url.push_str(&format!("?code={}", auth_code));
if let Some(ref st) = encoded_state {
redirect_url.push_str(&format!("&state={}", st));
}
tracing::info!(
client_id = %params.client_id,
user_id = %user_id,
"OAuth authorization code issued"
);
Redirect::to(&redirect_url).into_response()
}
pub async fn token(
State(state): State<ApiState>,
Json(req): Json<TokenRequest>,
) -> ApiResponse<TokenResponse> {
match req.grant_type.as_str() {
"authorization_code" => handle_authorization_code_grant(state, req).await,
"refresh_token" => handle_refresh_token_grant(state, req).await,
_ => ApiResponse::error_typed(
"unsupported_grant_type",
"Supported grant types: authorization_code, refresh_token",
),
}
}
async fn handle_authorization_code_grant(
state: ApiState,
req: TokenRequest,
) -> ApiResponse<TokenResponse> {
let code = match req.code {
Some(c) => c,
None => {
return ApiResponse::validation_error_typed(
"code is required for authorization_code grant",
);
}
};
let client_id = match req.client_id {
Some(c) => c,
None => return ApiResponse::validation_error_typed("client_id is required"),
};
let consumed_key = format!("oauth2_code_consumed:{}", code);
if let Ok(Some(_)) = state.auth_framework.storage().get_kv(&consumed_key).await {
return ApiResponse::error_typed("invalid_grant", "Authorization code already used");
}
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&consumed_key,
b"1",
Some(std::time::Duration::from_secs(600)),
)
.await
{
tracing::warn!("Failed to store code consumed marker: {:?}", e);
}
let storage_key = format!("oauth2_code:{}", code);
let code_data = match state.auth_framework.storage().get_kv(&storage_key).await {
Ok(Some(data)) => match serde_json::from_slice::<serde_json::Value>(&data) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse stored authorization code data: {:?}", e);
return ApiResponse::error_typed("invalid_grant", "Invalid authorization code");
}
},
Ok(None) => {
return ApiResponse::error_typed(
"invalid_grant",
"Authorization code not found or expired",
);
}
Err(e) => {
tracing::error!("Failed to retrieve authorization code: {:?}", e);
return ApiResponse::error_typed(
"server_error",
"Failed to validate authorization code",
);
}
};
if let Err(e) = state.auth_framework.storage().delete_kv(&storage_key).await {
tracing::error!("Failed to delete authorization code from storage: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to process authorization code");
}
if code_data["client_id"].as_str() != Some(&client_id) {
return ApiResponse::error_typed("invalid_grant", "client_id mismatch");
}
if let Some(redirect_uri) = &req.redirect_uri {
let stored_uri = code_data["redirect_uri"].as_str().unwrap_or_default();
if !redirect_uri_matches(redirect_uri, stored_uri) {
return ApiResponse::error_typed("invalid_grant", "redirect_uri mismatch");
}
}
let stored_challenge = code_data["code_challenge"].as_str();
let challenge_method = code_data["code_challenge_method"]
.as_str()
.unwrap_or("plain");
if let Some(stored) = stored_challenge {
let code_verifier = match &req.code_verifier {
Some(verifier) => verifier,
None => {
return ApiResponse::error_typed(
"invalid_request",
"code_verifier is required when PKCE challenge was provided",
);
}
};
let computed_challenge = match challenge_method {
"S256" => {
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
"plain" => code_verifier.clone(),
_ => {
return ApiResponse::error_typed(
"invalid_request",
"Unsupported code_challenge_method",
);
}
};
if computed_challenge != stored {
return ApiResponse::error_typed("invalid_grant", "PKCE verification failed");
}
} else if req.code_verifier.is_some() {
return ApiResponse::error_typed(
"invalid_request",
"code_verifier provided but no PKCE challenge was used in authorization",
);
} else if req.client_secret.is_none() {
return ApiResponse::error_typed(
"invalid_request",
"Public clients must use PKCE: provide code_challenge in authorization and code_verifier in token request",
);
}
let authz_resources: Vec<String> = code_data["resource"]
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
if let Some(ref token_resources) = req.resource {
if let Err(e) = crate::server::oauth::resource_indicators::validate_resource_indicators(token_resources) {
return ApiResponse::error_typed("invalid_target", &e.to_string());
}
if let Err(e) = crate::server::oauth::resource_indicators::validate_token_resource_subset(token_resources, &authz_resources) {
return ApiResponse::error_typed("invalid_target", &e.to_string());
}
}
let scope = code_data["scope"]
.as_str()
.unwrap_or("openid profile email");
let scopes: Vec<String> = scope.split_whitespace().map(|s| s.to_string()).collect();
let user_id = match code_data["user_id"].as_str() {
Some(uid) if !uid.is_empty() => uid.to_string(),
_ => {
tracing::error!("Authorization code missing user_id field");
return ApiResponse::error_typed("server_error", "Malformed authorization code");
}
};
let token = match state.auth_framework.token_manager().create_auth_token(
&user_id,
scopes.clone(),
"oauth2",
None,
) {
Ok(token) => token,
Err(e) => {
tracing::error!("Failed to create access token: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to create access token");
}
};
let refresh_token_value = uuid::Uuid::new_v4().to_string().replace("-", "");
let refresh_data = serde_json::json!({
"user_id": user_id,
"client_id": client_id,
"scopes": scope,
});
let refresh_key = format!("oauth2_refresh_token:{}", refresh_token_value);
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&refresh_key,
serde_json::to_string(&refresh_data)
.unwrap_or_default()
.as_bytes(),
Some(std::time::Duration::from_secs(30 * 24 * 3600)),
)
.await
{
tracing::warn!("Failed to store refresh token: {:?}", e);
}
let expires_in = (token.expires_at - token.issued_at).num_seconds().max(0) as u64;
let response = TokenResponse {
access_token: token.access_token,
token_type: "Bearer".to_string(),
expires_in,
refresh_token: Some(refresh_token_value),
scope: Some(scope.to_string()),
id_token: None,
};
tracing::info!("OAuth2 tokens issued for client: {}", client_id);
ApiResponse::success(response)
}
async fn handle_refresh_token_grant(
state: ApiState,
req: TokenRequest,
) -> ApiResponse<TokenResponse> {
let refresh_token_str = match req.refresh_token {
Some(t) => t,
None => return ApiResponse::validation_error_typed("refresh_token is required"),
};
let consumed_key = format!("oauth2_refresh_consumed:{}", refresh_token_str);
if let Ok(Some(_)) = state.auth_framework.storage().get_kv(&consumed_key).await {
return ApiResponse::error_typed("invalid_grant", "Refresh token already consumed");
}
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&consumed_key,
b"1",
Some(std::time::Duration::from_secs(600)),
)
.await
{
tracing::warn!("Failed to store refresh consumed marker: {:?}", e);
}
let refresh_key = format!("oauth2_refresh_token:{}", refresh_token_str);
let stored = match state.auth_framework.storage().get_kv(&refresh_key).await {
Ok(Some(data)) => match serde_json::from_slice::<serde_json::Value>(&data) {
Ok(v) => v,
Err(_) => return ApiResponse::error_typed("invalid_grant", "Invalid refresh token"),
},
Ok(None) => {
return ApiResponse::error_typed("invalid_grant", "Refresh token not found or expired");
}
Err(e) => {
tracing::error!("Failed to retrieve refresh token: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to validate refresh token");
}
};
let user_id = match stored["user_id"].as_str() {
Some(u) => u.to_string(),
None => return ApiResponse::error_typed("invalid_grant", "Malformed refresh token data"),
};
let scope = stored["scopes"]
.as_str()
.unwrap_or("openid profile email")
.to_string();
let scopes: Vec<String> = scope.split_whitespace().map(|s| s.to_string()).collect();
let token = match state
.auth_framework
.token_manager()
.create_auth_token(&user_id, scopes, "oauth2", None)
{
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to create access token on refresh: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to issue access token");
}
};
let new_refresh_token = uuid::Uuid::new_v4().to_string().replace("-", "");
let new_refresh_data = serde_json::json!({ "user_id": user_id, "scopes": scope });
let new_refresh_key = format!("oauth2_refresh_token:{}", new_refresh_token);
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&new_refresh_key,
serde_json::to_string(&new_refresh_data)
.unwrap_or_default()
.as_bytes(),
Some(std::time::Duration::from_secs(30 * 24 * 3600)),
)
.await
{
tracing::error!("Failed to store new refresh token: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to issue refresh token");
}
if let Err(e) = state.auth_framework.storage().delete_kv(&refresh_key).await {
tracing::warn!(
"Failed to delete old refresh token during rotation: {:?}",
e
);
}
let expires_in = (token.expires_at - token.issued_at).num_seconds().max(0) as u64;
let response = TokenResponse {
access_token: token.access_token,
token_type: "Bearer".to_string(),
expires_in,
refresh_token: Some(new_refresh_token),
scope: Some(scope),
id_token: None,
};
tracing::info!("OAuth2 token refreshed for user: {}", user_id);
ApiResponse::success(response)
}
pub async fn revoke(
State(state): State<ApiState>,
Json(req): Json<RevokeRequest>,
) -> ApiResponse<serde_json::Value> {
let revoked_token_key = format!("oauth2_revoked_token:{}", req.token);
let revoked_data = serde_json::json!({
"token": req.token,
"revoked_at": chrono::Utc::now().to_rfc3339(),
"token_type_hint": req.token_type_hint
});
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&revoked_token_key,
serde_json::to_string(&revoked_data)
.unwrap_or_default()
.as_bytes(),
Some(std::time::Duration::from_secs(86400 * 7)),
)
.await
{
tracing::error!("Failed to store revoked token: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to revoke token");
}
if let Ok(claims) = state
.auth_framework
.token_manager()
.validate_jwt_token(&req.token)
{
let now = chrono::Utc::now().timestamp();
let remaining_secs = (claims.exp - now + 60).max(60) as u64;
let jti_key = format!("revoked_token:{}", claims.jti);
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&jti_key,
b"1",
Some(std::time::Duration::from_secs(remaining_secs)),
)
.await
{
tracing::warn!("Failed to store JWT JTI revocation entry: {:?}", e);
} else {
tracing::info!("JWT token revoked via jti: {}", claims.jti);
}
}
tracing::info!(
"OAuth2 token revoked: {}",
&req.token[..10.min(req.token.len())]
);
ApiResponse::success(serde_json::json!({
"message": "Token revoked successfully"
}))
}
pub async fn userinfo(
State(state): State<ApiState>,
headers: HeaderMap,
) -> ApiResponse<UserInfoResponse> {
let token = match extract_bearer_token(&headers) {
Some(t) => t,
None => {
return ApiResponse::error_typed("invalid_token", "Authorization header required");
}
};
let revoked_token_key = format!("oauth2_revoked_token:{}", token);
if let Ok(Some(_)) = state
.auth_framework
.storage()
.get_kv(&revoked_token_key)
.await
{
return ApiResponse::error_typed("invalid_token", "Token has been revoked");
}
let claims = match state
.auth_framework
.token_manager()
.validate_jwt_token(&token)
{
Ok(c) => c,
Err(_) => {
return ApiResponse::error_typed("invalid_token", "Access token is invalid");
}
};
let user_profile = match state.auth_framework.get_user_profile(&claims.sub).await {
Ok(profile) => profile,
Err(e) => {
tracing::error!("Failed to get user profile: {:?}", e);
return ApiResponse::error_typed("server_error", "Failed to retrieve user information");
}
};
let userinfo = UserInfoResponse {
sub: claims.sub.clone(),
name: user_profile.username.clone(),
email: if claims.scope.split_whitespace().any(|s| s == "email") {
user_profile.email.clone()
} else {
None
},
picture: user_profile.picture.clone(),
updated_at: Some(chrono::Utc::now().timestamp()),
};
tracing::info!("OAuth2 UserInfo requested for user: {}", claims.sub);
ApiResponse::success(userinfo)
}
pub async fn get_client_info(
State(state): State<ApiState>,
Path(client_id): Path<String>,
) -> impl IntoResponse {
let client_key = format!("oauth2_client:{}", client_id);
match state.auth_framework.storage().get_kv(&client_key).await {
Ok(Some(data)) => match serde_json::from_slice::<ClientInfo>(&data) {
Ok(client) => (
StatusCode::OK,
Json(serde_json::json!({ "success": true, "data": client })),
)
.into_response(),
Err(e) => {
tracing::error!(client_id = %client_id, error = %e, "Failed to deserialize client record");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"success": false,
"error": "server_error",
"message": "Failed to read client record"
})),
)
.into_response()
}
},
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false,
"error": "invalid_client",
"message": "Unknown client_id"
})),
)
.into_response(),
Err(e) => {
tracing::error!(client_id = %client_id, error = %e, "Storage error looking up client");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"success": false,
"error": "server_error",
"message": "Authorization server error"
})),
)
.into_response()
}
}
}
pub async fn openid_configuration(State(state): State<ApiState>) -> impl IntoResponse {
let issuer = {
let configured = state.auth_framework.config().issuer.clone();
if configured.is_empty() {
"https://auth.example.com".to_string()
} else {
configured
}
};
let config = serde_json::json!({
"issuer": issuer,
"authorization_endpoint": format!("{}/api/v1/oauth/authorize", issuer),
"token_endpoint": format!("{}/api/v1/oauth/token", issuer),
"userinfo_endpoint": format!("{}/api/v1/oauth/userinfo", issuer),
"revocation_endpoint": format!("{}/api/v1/oauth/revoke", issuer),
"introspection_endpoint": format!("{}/api/v1/oauth/introspect", issuer),
"jwks_uri": format!("{}/api/v1/.well-known/jwks.json", issuer),
"end_session_endpoint": format!("{}/api/v1/oauth/end_session", issuer),
"pushed_authorization_request_endpoint": format!("{}/api/v1/oauth/par", issuer),
"registration_endpoint": format!("{}/api/v1/oauth/register", issuer),
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token"
],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["HS256"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "profile", "email"]
});
(StatusCode::OK, Json(config))
}
pub async fn jwks(State(_state): State<ApiState>) -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "keys": [] })))
}
#[derive(Debug, Deserialize)]
pub struct EndSessionRequest {
pub id_token_hint: Option<String>,
pub post_logout_redirect_uri: Option<String>,
pub state: Option<String>,
}
pub async fn end_session(
State(state): State<ApiState>,
Query(params): Query<EndSessionRequest>,
) -> impl IntoResponse {
if let Some(ref token) = params.id_token_hint {
if let Ok(claims) = state
.auth_framework
.token_manager()
.validate_jwt_token(token)
{
let revoked_key = format!("oauth2_revoked_token:{}", token);
if let Err(e) = state
.auth_framework
.storage()
.store_kv(
&revoked_key,
b"revoked",
Some(std::time::Duration::from_secs(86400 * 7)),
)
.await
{
tracing::warn!("Failed to revoke token during OIDC end_session: {}", e);
}
tracing::info!("OIDC end_session: revoked token for user {}", claims.sub);
}
}
if let Some(ref redirect_uri) = params.post_logout_redirect_uri {
let allowed = if let Some(ref token) = params.id_token_hint {
let client_id = state
.auth_framework
.token_manager()
.validate_jwt_token(token)
.ok()
.and_then(|claims| {
if let Some(ref cid) = claims.client_id {
if !cid.is_empty() {
return Some(cid.clone());
}
}
if !claims.aud.is_empty() {
Some(claims.aud.clone())
} else {
None
}
});
if let Some(cid) = client_id {
let client_key = format!("oauth2_client:{}", cid);
match state.auth_framework.storage().get_kv(&client_key).await {
Ok(Some(data)) => {
let client: serde_json::Value =
serde_json::from_slice(&data).unwrap_or_default();
let uris: Vec<String> = client["redirect_uris"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
uris.iter().any(|r| redirect_uri_matches(redirect_uri, r))
}
_ => false,
}
} else {
false
}
} else {
false
};
if allowed {
if let Ok(mut parsed) = Url::parse(redirect_uri) {
if let Some(ref st) = params.state {
parsed.query_pairs_mut().append_pair("state", st);
}
return Redirect::to(parsed.as_str()).into_response();
}
} else {
tracing::warn!(
"end_session: post_logout_redirect_uri rejected — not registered for the client"
);
}
}
(
StatusCode::OK,
Json(serde_json::json!({ "status": "logged_out" })),
)
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct ClientRegistrationRequest {
pub redirect_uris: Vec<String>,
#[serde(default)]
pub client_name: Option<String>,
#[serde(default)]
pub token_endpoint_auth_method: Option<String>,
#[serde(default)]
pub grant_types: Option<Vec<String>>,
#[serde(default)]
pub response_types: Option<Vec<String>>,
#[serde(default)]
pub scope: Option<String>,
}
pub async fn register_client(
State(state): State<ApiState>,
headers: HeaderMap,
Json(req): Json<ClientRegistrationRequest>,
) -> impl IntoResponse {
let token_str = match extract_bearer_token(&headers) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_token",
"error_description": "A valid Bearer token is required for dynamic client registration"
})),
)
.into_response();
}
};
let is_initial_access_token = match state
.auth_framework
.storage()
.get_kv("oauth2_initial_access_token")
.await
{
Ok(Some(stored)) => {
let stored_str = String::from_utf8_lossy(&stored);
subtle::ConstantTimeEq::ct_eq(token_str.as_bytes(), stored_str.trim().as_bytes()).into()
}
_ => false,
};
let is_admin = if !is_initial_access_token {
match validate_api_token(&state.auth_framework, &token_str).await {
Ok(auth_token) => auth_token.roles.contains(&"admin".to_string()),
Err(_) => false,
}
} else {
false
};
if !is_initial_access_token && !is_admin {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "insufficient_scope",
"error_description": "Dynamic client registration requires admin privileges or a valid Initial Access Token"
})),
)
.into_response();
}
for uri in &req.redirect_uris {
match Url::parse(uri) {
Ok(parsed) => {
match parsed.scheme() {
"https" => {}
"http" => {
if !matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_redirect_uri",
"error_description": format!("Non-loopback HTTP redirect_uri not allowed: {}", uri)
})),
)
.into_response();
}
}
scheme => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_redirect_uri",
"error_description": format!("Disallowed URI scheme '{}' in redirect_uri: {}", scheme, uri)
})),
)
.into_response();
}
}
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_redirect_uri",
"error_description": format!("Invalid redirect_uri: {}", uri)
})),
)
.into_response();
}
}
}
if req.redirect_uris.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_client_metadata",
"error_description": "At least one redirect_uri is required"
})),
)
.into_response();
}
let client_id = Uuid::new_v4().to_string();
let client_secret = Uuid::new_v4().to_string();
let client_data = serde_json::json!({
"client_id": client_id,
"client_secret": client_secret,
"client_name": req.client_name,
"redirect_uris": req.redirect_uris,
"token_endpoint_auth_method": req.token_endpoint_auth_method.as_deref().unwrap_or("client_secret_basic"),
"grant_types": req.grant_types.as_deref().unwrap_or(&["authorization_code".to_string()]),
"response_types": req.response_types.as_deref().unwrap_or(&["code".to_string()]),
"scope": req.scope.as_deref().unwrap_or("openid"),
});
let key = format!("oauth2_client:{}", client_id);
if let Err(e) = state
.auth_framework
.storage()
.store_kv(&key, client_data.to_string().as_bytes(), None)
.await
{
tracing::error!("Failed to store client registration: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to register client"
})),
)
.into_response();
}
tracing::info!("Dynamic client registered: client_id={}", client_id);
(
StatusCode::CREATED,
Json(serde_json::json!({
"client_id": client_id,
"client_secret": client_secret,
"client_name": req.client_name,
"redirect_uris": req.redirect_uris,
"token_endpoint_auth_method": req.token_endpoint_auth_method.as_deref().unwrap_or("client_secret_basic"),
"grant_types": req.grant_types.as_deref().unwrap_or(&["authorization_code".to_string()]),
"response_types": req.response_types.as_deref().unwrap_or(&["code".to_string()]),
"scope": req.scope.as_deref().unwrap_or("openid"),
})),
)
.into_response()
}
pub async fn users_me(state: State<ApiState>, headers: HeaderMap) -> impl IntoResponse {
userinfo(state, headers).await
}