dd4650a
//! OAuth 2.0 授权码流程 + OIDC 发现文档。
//!
//! 端点(`/api/v1` 之外只保留发现文档,遵循 OIDC / RFC 8414 的固定位置):
//! - `GET /api/v1/oauth/authorize` — 授权码 + PKCE(S256),支持 `prompt` / `max_age` / `nonce`
//! - `POST /api/v1/oauth/token` — `authorization_code` / `refresh_token` / `client_credentials`
//! - `POST /api/v1/oauth/revoke` — RFC 7009 令牌撤销
//! - `GET /.well-known/oauth-authorization-server` 与 `/.well-known/openid-configuration`
use std::sync::Arc;
use std::time::Duration;
use crate::config::auth::OAuthConfig;
use crate::config::web::WebConfig;
use actix_web::http::header::{self, HeaderValue};
use actix_web::{HttpRequest, HttpResponse, web};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use reqwest::Url;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::auth::client::ClientRecord;
use crate::auth::share::crypto::{constant_time_eq, random_b64url, sha256_hex};
use crate::auth::{AuthError, AuthResult, AuthSvc};
pub const MIN_RPC_KEY_LEN: usize = 32;
/// 配置示例中的占位符前缀:带着它启动说明密钥一直没换,直接拒绝。
fn is_placeholder(value: &str) -> bool {
value.trim().starts_with("REPLACE_WITH_")
}
pub fn validate_config(config: &OAuthConfig, issuer: &str) -> AuthResult<()> {
// gRPC 面只有这一道门禁,所以密钥必须在启动时就位。
if config.rpc_key.len() < MIN_RPC_KEY_LEN {
return Err(AuthError::Unavailable(format!(
"auth.oauth.rpc_key requires at least {MIN_RPC_KEY_LEN} characters"
)));
}
if is_placeholder(&config.rpc_key) {
return Err(AuthError::Unavailable(
"auth.oauth.rpc_key is still set to the placeholder from config.yaml.example".into(),
));
}
if config.public_url.is_empty() && config.clients.is_empty() && config.socket_origins.is_empty()
{
return Ok(());
}
let base = endpoint(&config.public_url).ok_or_else(|| {
AuthError::Unavailable(
"auth.oauth.public_url requires an https origin or loopback http origin".into(),
)
})?;
if base.origin().ascii_serialization() != config.public_url || issuer != config.public_url {
return Err(AuthError::Unavailable(
"JWT issuer must equal auth.oauth.public_url, with no trailing slash".into(),
));
}
for (index, client) in config.clients.iter().enumerate() {
let duplicate = config.clients[index + 1..]
.iter()
.any(|other| other.client_id == client.client_id);
if duplicate
|| crate::auth::client::registry::validate_client_id(&client.client_id).is_err()
// 仅跑 client_credentials 的机密客户端可以没有重定向地址。
|| (client.kind == crate::models::user::oauth_clients::ClientKind::Public
&& client.redirect_uris.is_empty())
|| client
.scopes
.iter()
.any(|scope| !crate::jwt::scope::is_known(scope))
|| client
.redirect_uris
.iter()
.any(|uri| {
crate::auth::client::registry::validate_uri(uri, config.allow_wildcard_redirect)
.is_err()
})
|| (client.kind == crate::models::user::oauth_clients::ClientKind::Confidential
&& client
.secret
.as_deref()
.is_none_or(
|secret| {
secret.len() < crate::auth::client::registry::MIN_SECRET_LEN
|| is_placeholder(secret)
},
))
{
return Err(AuthError::Unavailable(
"OAuth client registration is invalid".into(),
));
}
}
for origin in &config.socket_origins {
if endpoint(origin).is_none_or(|url| url.origin().ascii_serialization() != *origin) {
return Err(AuthError::Unavailable(
"Socket origins require exact http(s) origins".into(),
));
}
}
Ok(())
}
fn endpoint(raw: &str) -> Option<Url> {
crate::auth::client::registry::endpoint(raw)
}
/// 一次授权请求里 `prompt` 解析结果。
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Prompt {
pub login: bool,
pub consent: bool,
pub none: bool,
}
/// `prompt` 只支持 `none` / `login` / `consent`;`none` 必须单独出现。
pub fn parse_prompt(raw: Option<&str>) -> Result<Prompt, AuthError> {
let mut prompt = Prompt::default();
let Some(raw) = raw else {
return Ok(prompt);
};
let mut count = 0;
for token in raw.split_ascii_whitespace() {
count += 1;
match token {
"none" => prompt.none = true,
"login" => prompt.login = true,
"consent" => prompt.consent = true,
_ => return Err(AuthError::MalformedRequest),
}
}
if count == 0 || (prompt.none && count > 1) {
return Err(AuthError::MalformedRequest);
}
Ok(prompt)
}
#[utoipa::path(
get,
path = "/.well-known/oauth-authorization-server",
tag = "oauth",
operation_id = "oauth_metadata",
summary = "OAuth 授权服务器元数据(含 OIDC 字段)",
responses((status = 200, description = "RFC 8414 元数据"))
)]
pub async fn metadata(svc: web::Data<AuthSvc>) -> HttpResponse {
let issuer = svc.auth().oauth.public_url.clone();
if issuer.is_empty() {
return failure("temporarily_unavailable", true);
}
let config = &svc.auth().oauth;
HttpResponse::Ok().json(serde_json::json!({
"issuer": issuer,
"authorization_endpoint": format!("{issuer}/api/v1/oauth/authorize"),
"token_endpoint": format!("{issuer}/api/v1/oauth/token"),
"userinfo_endpoint": format!("{issuer}/api/v1/oauth/userinfo"),
"revocation_endpoint": format!("{issuer}/api/v1/oauth/revoke"),
"end_session_endpoint": format!("{issuer}/api/v1/oauth/logout"),
"jwks_uri": format!("{issuer}/.well-known/jwks.json"),
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"token_endpoint_auth_methods_supported": [
"none", "client_secret_basic", "client_secret_post"
],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true,
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": [crate::jwt::ALG],
"prompt_values_supported": ["none", "login", "consent"],
"claims_supported": [
"iss", "sub", "aud", "exp", "iat", "auth_time", "nonce", "at_hash", "sid",
"preferred_username", "name", "email", "email_verified"
],
"claims_parameter_supported": false,
"scopes_supported": crate::jwt::scope::supported(),
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"request_parameter_supported": false,
"request_uri_parameter_supported": false,
"access_token_ttl": config.access_token_ttl_secs,
"id_token_ttl": config.id_token_ttl_secs,
"refresh_token_ttl": config.refresh_token_ttl_secs,
}))
}
/// OIDC 发现文档与 OAuth AS 元数据内容一致(OIDC 只要求其中一部分字段)。
/// 单行转发 metadata:两个发现端点本就同内容(RFC 8414 vs OIDC Discovery),
/// 拆开只会复制整段 JSON;保留此风格并以此注释声明。
#[utoipa::path(
get,
path = "/.well-known/openid-configuration",
tag = "oauth",
operation_id = "oauth_openid_configuration",
summary = "OIDC 发现文档",
responses((status = 200, description = "OpenID Provider 元数据"))
)]
pub async fn openid_configuration(svc: web::Data<AuthSvc>) -> HttpResponse {
metadata(svc).await
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeRequest {
pub response_type: String,
pub client_id: String,
pub redirect_uri: String,
/// 可选(RFC 6749 §4.1.2.1);给出时原样回传,长度上限 2048 是实现约束。
#[serde(default)]
pub state: Option<String>,
pub code_challenge: String,
pub code_challenge_method: String,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub nonce: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub max_age: Option<i64>,
}
/// `state` 的实现上限:规范不约束长度,只拦超长值撑爆重定向 URL。
pub const MAX_STATE_LEN: usize = 2048;
pub fn valid_state(state: Option<&str>) -> bool {
state.is_none_or(|state| state.len() <= MAX_STATE_LEN)
}
/// 解析失败时的降级入参:只要 client_id / redirect_uri 能验证,就能把错误送回客户端。
#[derive(Debug, Deserialize)]
pub struct RawAuthorizeParams {
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub redirect_uri: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
impl AuthorizeRequest {
pub fn scopes(&self) -> Vec<String> {
let mut scopes: Vec<String> = self
.scope
.split_ascii_whitespace()
.map(str::to_owned)
.collect();
scopes.sort();
scopes.dedup();
scopes
}
}
#[derive(Serialize, Deserialize)]
struct AuthorizationCode {
user_id: i64,
version: i32,
session_id: String,
client_id: String,
redirect_uri: String,
challenge: String,
scopes: Vec<String>,
audience: String,
auth_time: i64,
#[serde(default)]
nonce: Option<String>,
expires_at: i64,
}
fn valid_challenge(challenge: &str) -> bool {
challenge.len() == 43
&& URL_SAFE_NO_PAD
.decode(challenge)
.is_ok_and(|value| value.len() == 32)
}
fn valid_nonce(nonce: &str) -> bool {
(1..=512).contains(&nonce.len()) && nonce.chars().all(|c| ('!'..='~').contains(&c))
}
fn unauthenticated(req: &HttpRequest) -> HttpResponse {
let callback = format!("/api/v1/oauth/authorize?{}", req.query_string());
let location = format!(
"/login?callback_url={}",
percent_encoding::utf8_percent_encode(&callback, percent_encoding::NON_ALPHANUMERIC)
);
HttpResponse::Found()
.insert_header(("location", location))
.insert_header(("cache-control", "no-store"))
.finish()
}
/// 同意页地址:原样带上授权查询串,页面把它原样传给 `/api/v1/oauth/consent`。
fn consent_url(req: &HttpRequest) -> String {
format!("/consent?{}", req.query_string())
}
#[utoipa::path(
get,
path = "/api/v1/oauth/authorize",
tag = "oauth",
operation_id = "oauth_authorize",
summary = "授权端点(授权码 + PKCE S256)",
description = "未登录跳到 `/login`;首次授权跳 `/consent`;`prompt=none` 直接返回 `login_required` / `consent_required`。",
params(
("response_type" = String, Query, description = "固定为 `code`"),
("client_id" = String, Query, description = "已注册的客户端 id"),
("redirect_uri" = String, Query, description = "已注册的回调地址,支持子域通配"),
("state" = String, Query, description = "16..=512 字符的不透明串"),
("code_challenge" = String, Query, description = "PKCE S256 challenge(43 字符 base64url)"),
("code_challenge_method" = String, Query, description = "固定为 `S256`"),
("scope" = Option<String>, Query, description = "空格分隔,必须 ⊆ 客户端登记 scope"),
("nonce" = Option<String>, Query, description = "OIDC nonce,原样写入 id_token"),
("prompt" = Option<String>, Query, description = "`none` / `login` / `consent`"),
("max_age" = Option<i64>, Query, description = "会话最大年龄(秒),超出就重新登录")
),
responses(
(status = 302, description = "重定向回 `redirect_uri`,带 `code` / `error` / `state` / `iss`"),
(status = 400, description = "`invalid_request`(客户端或回调未注册)")
)
)]
#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
pub async fn authorize(
svc: web::Data<AuthSvc>,
session: crate::session::Session,
request_id: crate::session::RequestId,
req: HttpRequest,
query: Result<web::Query<AuthorizeRequest>, actix_web::Error>,
) -> HttpResponse {
let Ok(query) = query else {
// 反序列化失败也尽量走重定向:client_id / redirect_uri 验证通过就 302 携 error 回客户端。
return authorize_parse_failure(&svc, req.query_string());
};
let Some(record) = svc.clients().get(&query.client_id) else {
return failure("invalid_request", false);
};
let wildcard = svc.auth().oauth.allow_wildcard_redirect;
if !record.redirect_allowed(&query.redirect_uri, wildcard) {
return failure("invalid_request", false);
}
if query.response_type != "code" {
return authorization_error(&svc, &query, "unsupported_response_type");
}
let prompt = match parse_prompt(query.prompt.as_deref()) {
Ok(prompt) => prompt,
Err(_) => return authorization_error(&svc, &query, "invalid_request"),
};
let scopes = query.scopes();
let nonce_ok = query.nonce.as_deref().is_none_or(valid_nonce);
if query.code_challenge_method != "S256"
|| !valid_challenge(&query.code_challenge)
|| !valid_state(query.state.as_deref())
|| query.scope.len() > 1024
|| !nonce_ok
{
return authorization_error(&svc, &query, "invalid_request");
}
if !record.scopes_allowed(scopes.iter().map(String::as_str)) {
return authorization_error(&svc, &query, "invalid_scope");
}
let mut identity = if prompt.login {
None
} else {
match svc
.require_session(&session, Some(request_id.as_str()))
.await
{
Ok(user_id) => Some(user_id),
Err(AuthError::Unauthenticated) => None,
Err(error) => return auth_failure(error),
}
};
if let (Some(_), Some(max_age)) = (identity, query.max_age)
&& session_expired(&svc, &session, max_age, Some(request_id.as_str())).await
{
identity = None;
}
let Some(user_id) = identity else {
return if prompt.none {
authorization_error(&svc, &query, "login_required")
} else {
unauthenticated(&req)
};
};
let needs_consent = prompt.consent
|| (svc.auth().oauth.require_consent
&& !crate::auth::oauth::consent::granted(
&svc,
user_id,
&record.client_id,
&scopes,
Some(request_id.as_str()),
)
.await
.unwrap_or(false));
if needs_consent {
return if prompt.none {
authorization_error(&svc, &query, "consent_required")
} else {
HttpResponse::Found()
.insert_header(("location", consent_url(&req)))
.insert_header(("cache-control", "no-store"))
.finish()
};
}
let result: AuthResult<String> = async {
let id = session
.get::<String>("auth_session_id")?
.ok_or(AuthError::Unauthenticated)?;
let version = session
.security_version()?
.ok_or(AuthError::Unauthenticated)?;
let auth_time = svc
.session_created_at(&session, Some(request_id.as_str()))
.await?
.map_or_else(crate::jwt::now_secs, |at| at.timestamp());
let ttl = svc.auth().oauth.code_ttl_secs.clamp(10, 600);
let code = random_b64url(32);
let grant = AuthorizationCode {
user_id,
version,
session_id: id,
client_id: query.client_id.clone(),
redirect_uri: query.redirect_uri.clone(),
challenge: query.code_challenge.clone(),
scopes,
audience: record.audience().to_owned(),
auth_time,
nonce: query.nonce.clone(),
expires_at: crate::jwt::now_secs() + ttl,
};
svc.cache()
.set_ex(
&format!("auth:oauth:code:{}", sha256_hex(code.as_bytes())),
serde_json::to_string(&grant)?,
Duration::from_secs(ttl as u64),
)
.await?;
Ok(code)
}
.await;
match result {
Ok(code) => {
let Ok(mut redirect) = Url::parse(&query.redirect_uri) else {
return failure("invalid_request", false);
};
redirect.query_pairs_mut().append_pair("code", &code);
if let Some(state) = &query.state {
redirect.query_pairs_mut().append_pair("state", state);
}
redirect
.query_pairs_mut()
.append_pair("iss", &svc.auth().oauth.public_url);
HttpResponse::Found()
.insert_header(("location", redirect.as_str()))
.insert_header(("cache-control", "no-store"))
.insert_header(("referrer-policy", "no-referrer"))
.finish()
}
Err(error) => auth_failure(error),
}
}
/// `max_age` 判定:会话建立时间超出窗口就要求重新认证。
async fn session_expired(
svc: &AuthSvc,
session: &crate::session::Session,
max_age: i64,
_request_id: Option<&str>,
) -> bool {
if max_age < 0 {
return true;
}
match svc.session_created_at(session, _request_id).await {
Ok(Some(created_at)) => {
Utc::now().signed_duration_since(created_at).num_seconds() > max_age
}
// 拿不到会话行就当作过期,交给登录页重来。
Ok(None) => true,
Err(_) => true,
}
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct TokenRequest {
pub grant_type: String,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub redirect_uri: Option<String>,
#[serde(default)]
pub code_verifier: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub scope: Option<String>,
}
#[derive(Debug, Clone, Copy)]
enum ClientAuthFailure {
/// 客户端不存在、被停用,或 secret 不对。
InvalidClient,
/// 客户端类型不允许这个 grant(例如 public 客户端走 client_credentials)。
UnauthorizedClient,
}
impl ClientAuthFailure {
fn code(self) -> &'static str {
match self {
Self::InvalidClient => "invalid_client",
Self::UnauthorizedClient => "unauthorized_client",
}
}
fn status(self) -> actix_web::http::StatusCode {
match self {
Self::InvalidClient => actix_web::http::StatusCode::UNAUTHORIZED,
Self::UnauthorizedClient => actix_web::http::StatusCode::BAD_REQUEST,
}
}
}
/// 解析客户端认证:支持 `client_secret_basic` 与 `client_secret_post`;public 客户端只认 `client_id`。
fn authenticate_client(
svc: &AuthSvc,
req: &GrantContext,
form: &TokenRequest,
) -> Result<Arc<ClientRecord>, ClientAuthFailure> {
let basic = req
.authorization
.as_deref()
.and_then(|value| {
let (scheme, rest) = value.split_once(' ')?;
scheme.eq_ignore_ascii_case("basic").then_some(rest.trim())
})
.and_then(|raw| {
URL_SAFE_NO_PAD
.decode(raw)
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(raw))
.ok()
})
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|decoded| {
decoded
.split_once(':')
.map(|(id, secret)| (id.to_owned(), secret.to_owned()))
});
let (client_id, secret) = match basic {
Some((id, secret)) => (id, Some(secret).filter(|value| !value.is_empty())),
None => (
form.client_id
.clone()
.ok_or(ClientAuthFailure::InvalidClient)?,
form.client_secret.clone().filter(|value| !value.is_empty()),
),
};
let record = svc
.clients()
.get(&client_id)
.ok_or(ClientAuthFailure::InvalidClient)?;
check_client_secret(&record, secret.as_deref())?;
Ok(record)
}
/// 客户端凭据判定:机密客户端必须给出正确 secret;public 客户端不得携带 secret
/// (RFC 6749 §2.3:客户端只能用注册的认证方式,拿错凭据一律 `invalid_client`)。
fn check_client_secret(
record: &ClientRecord,
secret: Option<&str>,
) -> Result<(), ClientAuthFailure> {
let secret = secret.filter(|value| !value.is_empty());
match (record.is_confidential(), secret) {
(true, Some(secret)) if record.verify_secret(secret) => Ok(()),
(true, _) => Err(ClientAuthFailure::InvalidClient),
(false, None) => Ok(()),
(false, Some(_)) => Err(ClientAuthFailure::InvalidClient),
}
}
fn verifier_matches(verifier: &str, challenge: &str) -> bool {
(43..=128).contains(&verifier.len())
&& verifier
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~'))
&& constant_time_eq(
URL_SAFE_NO_PAD
.encode(Sha256::digest(verifier.as_bytes()))
.as_bytes(),
challenge.as_bytes(),
)
}
#[utoipa::path(
post,
path = "/api/v1/oauth/token",
tag = "oauth",
operation_id = "oauth_token",
summary = "令牌端点",
description = "支持 `authorization_code`(PKCE)、`refresh_token`(轮换 + 重放检测)、`client_credentials`(机密客户端)。带 `openid` scope 时响应额外包含 `id_token`。",
request_body = TokenRequest,
responses(
(status = 200, description = "令牌响应", body = serde_json::Value),
(status = 400, description = "`invalid_grant` / `invalid_scope` / `unsupported_grant_type`"),
(status = 401, description = "`invalid_client`")
)
)]
#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
pub async fn token(
svc: web::Data<AuthSvc>,
web_cfg: web::Data<WebConfig>,
req: HttpRequest,
request_id: crate::session::RequestId,
form: Result<web::Form<TokenRequest>, actix_web::Error>,
) -> HttpResponse {
let Ok(form) = form else {
return cors(&svc, &web_cfg, &req, failure("invalid_request", false));
};
let context = GrantContext {
authorization: req
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::to_owned),
client_ip: client_ip(&req),
user_agent: user_agent(&req),
};
let response = exchange(&svc, &context, &form, Some(request_id.as_str())).await;
cors(&svc, &web_cfg, &req, response)
}
#[derive(Default)]
struct GrantContext {
authorization: Option<String>,
client_ip: Option<String>,
user_agent: Option<String>,
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct LocalGrantError {
pub status: u16,
pub message: String,
}
/// 在单体内复用 HTTP token 端点的客户端、PKCE 和刷新轮换校验。
pub async fn exchange_local(
svc: &AuthSvc,
form: &TokenRequest,
) -> Result<serde_json::Value, LocalGrantError> {
use actix_web::body::MessageBody;
let response = exchange(
svc,
&GrantContext::default(),
form,
Some(&uuid::Uuid::now_v7().to_string()),
)
.await;
let status = response.status();
let bytes = response
.into_body()
.try_into_bytes()
.map_err(|_| LocalGrantError {
status: 500,
message: "token response body is not buffered".to_owned(),
})?;
if !status.is_success() {
return Err(LocalGrantError {
status: status.as_u16(),
message: format!(
"token grant rejected ({status}): {}",
String::from_utf8_lossy(&bytes)
),
});
}
serde_json::from_slice(&bytes).map_err(|error| LocalGrantError {
status: 500,
message: error.to_string(),
})
}
#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
async fn exchange(
svc: &AuthSvc,
req: &GrantContext,
form: &TokenRequest,
request_id: Option<&str>,
) -> HttpResponse {
let record = match authenticate_client(svc, req, form) {
Ok(record) => record,
Err(error) => return client_failure(error),
};
match form.grant_type.as_str() {
"authorization_code" => exchange_code(svc, req, form, &record, request_id).await,
"refresh_token" => exchange_refresh(svc, req, form, &record, request_id).await,
"client_credentials" => exchange_client_credentials(svc, form, &record, request_id).await,
_ => failure("unsupported_grant_type", false),
}
}
fn client_failure(error: ClientAuthFailure) -> HttpResponse {
HttpResponse::build(error.status())
.insert_header(("cache-control", "no-store"))
.json(serde_json::json!({ "error": error.code() }))
}
struct AccessTokenInput<'a> {
svc: &'a AuthSvc,
record: &'a ClientRecord,
user_id: i64,
username: &'a str,
version: i32,
session_id: Option<String>,
mfa_verified: bool,
scopes: &'a [String],
}
/// 访问令牌实际生效的寿命:配置值 clamp 到 60..=86400,签发与 `expires_in` 都用它。
pub(crate) fn effective_access_ttl(svc: &AuthSvc) -> i64 {
clamp_access_ttl(svc.auth().oauth.access_token_ttl_secs)
}
fn clamp_access_ttl(raw: i64) -> i64 {
raw.clamp(60, 86_400)
}
fn access_claims(input: &AccessTokenInput<'_>) -> crate::jwt::AppClaims {
let now = crate::jwt::now_secs();
let ttl = effective_access_ttl(input.svc);
crate::jwt::AppClaims {
is_admin: false,
issuer: input.svc.signing().issuer().to_owned(),
app_id: input.record.client_id.clone(),
user_id: input.user_id,
audience: input.record.audience().to_owned(),
token_id: uuid::Uuid::now_v7().to_string(),
issued_at: now,
expires_at: now + ttl,
security_version: input.version,
session_id: input.session_id.clone(),
mfa_verified: input.mfa_verified,
scope: Some(input.scopes.to_vec()),
username: Some(input.username.to_owned()),
token_type: "access".into(),
}
}
#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
async fn exchange_code(
svc: &AuthSvc,
req: &GrantContext,
form: &TokenRequest,
record: &ClientRecord,
request_id: Option<&str>,
) -> HttpResponse {
let Some(code) = form.code.as_deref() else {
return failure("invalid_request", false);
};
let redirect_uri = form.redirect_uri.as_deref().unwrap_or_default();
if code.len() != 43 || redirect_uri.is_empty() {
return failure("invalid_grant", false);
}
let result: AuthResult<serde_json::Value> = async {
let raw: String = svc
.cache()
.get_del(&format!("auth:oauth:code:{}", sha256_hex(code.as_bytes())))
.await?
.ok_or(AuthError::InvalidCredentials)?;
let grant: AuthorizationCode = serde_json::from_str(&raw)?;
let verifier_ok = form
.code_verifier
.as_deref()
.is_some_and(|verifier| verifier_matches(verifier, &grant.challenge));
if grant.expires_at <= crate::jwt::now_secs()
|| grant.client_id != record.client_id
|| grant.redirect_uri != redirect_uri
|| !verifier_ok
{
return Err(AuthError::InvalidCredentials);
}
let access = svc
.check_session(grant.user_id, grant.version, &grant.session_id, request_id)
.await
.map_err(|_| AuthError::InvalidCredentials)?;
let user = crate::jwt::authority::user(grant.user_id)
.await
.map_err(|_| AuthError::InvalidCredentials)?;
let claims = access_claims(&AccessTokenInput {
svc,
record,
user_id: grant.user_id,
username: &access.username,
version: grant.version,
session_id: Some(grant.session_id.clone()),
mfa_verified: access.mfa_verified,
scopes: &grant.scopes,
});
let access_token = svc.signing().sign(&claims)?;
let mut body = serde_json::json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": effective_access_ttl(svc),
"scope": grant.scopes.join(" "),
});
if grant
.scopes
.iter()
.any(|scope| scope == crate::jwt::scope::OPENID)
{
let id_token = crate::auth::oauth::id_token::build(
svc,
&crate::auth::oauth::id_token::IdTokenInput {
client_id: &record.client_id,
user_id: grant.user_id,
username: &user.username,
displayname: user.displayname.as_deref(),
email: &user.email,
email_verified: user.email_verified_at.is_some(),
scopes: &grant.scopes,
access_token: &access_token,
session_id: Some(&grant.session_id),
auth_time: grant.auth_time,
nonce: grant.nonce.as_deref(),
},
)?;
body["id_token"] = serde_json::Value::String(id_token);
}
let issued = crate::auth::oauth::refresh::issue(
svc,
crate::auth::oauth::refresh::Grant {
user_id: grant.user_id,
session_id: &grant.session_id,
client_id: &record.client_id,
scopes: &grant.scopes,
ip_address: req.client_ip.clone(),
user_agent: req.user_agent.clone(),
},
request_id,
)
.await?;
body["refresh_token"] = serde_json::Value::String(issued.token);
Ok(body)
}
.await;
match result {
Ok(body) => token_response(body),
Err(error) => auth_failure(error),
}
}
#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
async fn exchange_refresh(
svc: &AuthSvc,
req: &GrantContext,
form: &TokenRequest,
record: &ClientRecord,
request_id: Option<&str>,
) -> HttpResponse {
let Some(refresh_token) = form.refresh_token.as_deref() else {
return failure("invalid_request", false);
};
let result: AuthResult<serde_json::Value> = async {
let rotated = crate::auth::oauth::refresh::rotate(
svc,
refresh_token,
&record.client_id,
req.client_ip.clone(),
req.user_agent.clone(),
request_id,
)
.await?;
// 允许 scope 收窄:请求的 scope 必须是原授权 scope 的子集。
let scopes = match form.scope.as_deref() {
Some(raw) if !raw.trim().is_empty() => {
let requested: Vec<String> =
raw.split_ascii_whitespace().map(str::to_owned).collect();
if !requested.iter().all(|scope| rotated.scopes.contains(scope)) {
return Err(AuthError::InvalidScope);
}
requested
}
_ => rotated.scopes.clone(),
};
let access = svc
.check_session(
rotated.user_id,
session_version(svc, rotated.session_id, request_id).await?,
&rotated.session_id.to_string(),
request_id,
)
.await
.map_err(|_| AuthError::InvalidCredentials)?;
let user = crate::jwt::authority::user(rotated.user_id)
.await
.map_err(|_| AuthError::InvalidCredentials)?;
let claims = access_claims(&AccessTokenInput {
svc,
record,
user_id: rotated.user_id,
username: &access.username,
version: access.security_version,
session_id: Some(rotated.session_id.to_string()),
mfa_verified: access.mfa_verified,
scopes: &scopes,
});
let access_token = svc.signing().sign(&claims)?;
let mut body = serde_json::json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": effective_access_ttl(svc),
"scope": scopes.join(" "),
"refresh_token": rotated.token,
});
if scopes
.iter()
.any(|scope| scope == crate::jwt::scope::OPENID)
{
let id_token = crate::auth::oauth::id_token::build(
svc,
&crate::auth::oauth::id_token::IdTokenInput {
client_id: &record.client_id,
user_id: rotated.user_id,
username: &user.username,
displayname: user.displayname.as_deref(),
email: &user.email,
email_verified: user.email_verified_at.is_some(),
scopes: &scopes,
access_token: &access_token,
session_id: Some(&rotated.session_id.to_string()),
auth_time: rotated.auth_time,
nonce: None,
},
)?;
body["id_token"] = serde_json::Value::String(id_token);
}
Ok(body)
}
.await;
match result {
Ok(body) => token_response(body),
Err(error) => auth_failure(error),
}
}
async fn session_version(
svc: &AuthSvc,
session_id: uuid::Uuid,
_request_id: Option<&str>,
) -> AuthResult<i32> {
use sea_orm::EntityTrait;
// security_version 判定走主库:副本延迟会让刚吊销的旧版本短暂放行。
let row = crate::models::user::user_sessions::Entity::find_by_id(session_id)
.one(svc.db().write())
.await?
.ok_or(AuthError::InvalidCredentials)?;
Ok(row.security_version)
}
/// client_credentials 的 scope:缺省只发独立配置的「服务令牌 scope」子集,
/// 显式请求也必须落在该子集内(`Err(())` 对应 `invalid_scope`)。
fn service_token_scopes(record: &ClientRecord, requested: Option<&str>) -> Result<Vec<String>, ()> {
let allowed = &record.service_scopes;
match requested.map(str::trim).filter(|raw| !raw.is_empty()) {
Some(raw) => {
let requested: Vec<String> = raw.split_ascii_whitespace().map(str::to_owned).collect();
if requested
.iter()
.all(|scope| allowed.iter().any(|allowed| allowed == scope))
{
Ok(requested)
} else {
Err(())
}
}
None => Ok(allowed.clone()),
}
}
#[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
async fn exchange_client_credentials(
svc: &AuthSvc,
form: &TokenRequest,
record: &ClientRecord,
request_id: Option<&str>,
) -> HttpResponse {
if !record.is_confidential() {
return client_failure(ClientAuthFailure::UnauthorizedClient);
}
let scopes = match service_token_scopes(record, form.scope.as_deref()) {
Ok(scopes) => scopes,
Err(()) => return failure("invalid_scope", false),
};
let now = crate::jwt::now_secs();
let ttl = effective_access_ttl(svc);
let claims = crate::jwt::AppClaims {
is_admin: false,
issuer: svc.signing().issuer().to_owned(),
app_id: record.client_id.clone(),
// 服务令牌没有用户主体;下游用 `token_type == "service"` 区分。
user_id: 0,
audience: record.audience().to_owned(),
token_id: uuid::Uuid::now_v7().to_string(),
issued_at: now,
expires_at: now + ttl,
security_version: 0,
session_id: None,
mfa_verified: false,
scope: Some(scopes.clone()),
username: None,
token_type: "service".into(),
};
match svc.signing().sign(&claims) {
Ok(token) => token_response(serde_json::json!({
"access_token": token,
"token_type": "Bearer",
"expires_in": effective_access_ttl(svc),
"scope": scopes.join(" "),
})),
Err(error) => auth_failure(error),
}
}
fn token_response(body: serde_json::Value) -> HttpResponse {
HttpResponse::Ok()
.insert_header(("cache-control", "no-store"))
.insert_header(("pragma", "no-cache"))
.json(body)
}
fn client_ip(req: &HttpRequest) -> Option<String> {
req.connection_info()
.realip_remote_addr()
.map(str::to_owned)
}
fn user_agent(req: &HttpRequest) -> Option<String> {
req.headers()
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RevokeRequest {
pub token: String,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
}
/// RFC 7009:撤销刷新令牌;未知令牌也返回 200,避免探测。
#[utoipa::path(
post,
path = "/api/v1/oauth/revoke",
tag = "oauth",
operation_id = "oauth_revoke",
summary = "令牌撤销(RFC 7009)",
request_body = RevokeRequest,
responses(
(status = 200, description = "已处理(无论令牌是否存在)"),
(status = 401, description = "`invalid_client`")
)
)]
#[tracing::instrument(skip_all, fields(request_id = %request_id.as_str()))]
pub async fn revoke(
svc: web::Data<AuthSvc>,
web_cfg: web::Data<WebConfig>,
request_id: crate::session::RequestId,
req: HttpRequest,
form: Result<web::Form<RevokeRequest>, actix_web::Error>,
) -> HttpResponse {
let Ok(form) = form else {
return cors(&svc, &web_cfg, &req, failure("invalid_request", false));
};
// RFC 7009:撤销也要过客户端认证,且只撤本客户端名下的令牌,禁止跨客户端撤销。
let Some(record) = form
.client_id
.as_deref()
.and_then(|client_id| svc.clients().get(client_id))
else {
return cors(
&svc,
&web_cfg,
&req,
client_failure(ClientAuthFailure::InvalidClient),
);
};
if let Err(error) = check_client_secret(&record, form.client_secret.as_deref()) {
return cors(&svc, &web_cfg, &req, client_failure(error));
}
if let Err(error) = crate::auth::oauth::refresh::revoke(
&svc,
&form.token,
&record.client_id,
Some(request_id.as_str()),
)
.await
{
return cors(&svc, &web_cfg, &req, auth_failure(error));
}
cors(
&svc,
&web_cfg,
&req,
HttpResponse::Ok()
.insert_header(("cache-control", "no-store"))
.finish(),
)
}
fn auth_failure(error: AuthError) -> HttpResponse {
if error.status_code().is_server_error() {
tracing::error!(%error, "OAuth operation failed");
failure("temporarily_unavailable", true)
} else {
failure("invalid_grant", false)
}
}
fn failure(error: &str, unavailable: bool) -> HttpResponse {
let mut response = if unavailable {
HttpResponse::ServiceUnavailable()
} else {
HttpResponse::BadRequest()
};
response
.insert_header(("cache-control", "no-store"))
.json(serde_json::json!({ "error": error }))
}
fn authorization_error(svc: &AuthSvc, query: &AuthorizeRequest, error: &str) -> HttpResponse {
redirect_error(
&svc.auth().oauth.public_url,
&query.redirect_uri,
query.state.as_deref(),
error,
)
}
/// 把错误 302 回客户端:`redirect_uri` 必须已通过注册校验,否则400(不向未验证地址重定向)。
fn redirect_error(
issuer: &str,
redirect_uri: &str,
state: Option<&str>,
error: &str,
) -> HttpResponse {
let Ok(mut redirect) = Url::parse(redirect_uri) else {
return failure("invalid_request", false);
};
redirect.query_pairs_mut().append_pair("error", error);
if let Some(state) = state.filter(|state| !state.is_empty()) {
redirect.query_pairs_mut().append_pair("state", state);
}
redirect.query_pairs_mut().append_pair("iss", issuer);
HttpResponse::Found()
.insert_header(("location", redirect.as_str()))
.insert_header(("cache-control", "no-store"))
.insert_header(("referrer-policy", "no-referrer"))
.finish()
}
/// 降级重定向目标:客户端存在且 `redirect_uri` 通过注册校验才重定向。
fn raw_error_target<'a>(
raw: &'a RawAuthorizeParams,
record: Option<&'a ClientRecord>,
wildcard: bool,
) -> Option<(&'a str, Option<&'a str>)> {
let redirect_uri = raw.redirect_uri.as_deref()?;
let record = record?;
if !record.redirect_allowed(redirect_uri, wildcard) {
return None;
}
Some((redirect_uri, raw.state.as_deref()))
}
/// 整体解析失败的降级路径:从原始查询串取 client_id / redirect_uri,
/// 验证通过就 302 携 `invalid_request` 回去,避免客户端只看到裸 400。
fn authorize_parse_failure(svc: &AuthSvc, query_string: &str) -> HttpResponse {
let Ok(raw) = web::Query::<RawAuthorizeParams>::from_query(query_string) else {
return failure("invalid_request", false);
};
let record = raw
.client_id
.as_deref()
.and_then(|client_id| svc.clients().get(client_id));
let wildcard = svc.auth().oauth.allow_wildcard_redirect;
match raw_error_target(&raw, record.as_deref(), wildcard) {
Some((redirect_uri, state)) => redirect_error(
&svc.auth().oauth.public_url,
redirect_uri,
state,
"invalid_request",
),
None => failure("invalid_request", false),
}
}
pub fn cors(
svc: &AuthSvc,
web_cfg: &WebConfig,
req: &HttpRequest,
mut response: HttpResponse,
) -> HttpResponse {
let Some(origin) = request_origin(req) else {
return response;
};
let wildcard = web_cfg.allow_any_origin();
if !wildcard && !web_allows(web_cfg, &origin) && !granted_origin(svc, req, &origin) {
return response;
}
let value = if wildcard { String::from("*") } else { origin };
let Ok(value) = HeaderValue::from_str(&value) else {
return response;
};
let headers = response.headers_mut();
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value);
if !wildcard {
headers.append(header::VARY, HeaderValue::from_static("origin"));
}
if web_cfg.cors_allow_credentials && !wildcard {
headers.insert(
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
);
}
let expose = web_cfg.expose_headers();
if !expose.is_empty()
&& let Ok(value) = HeaderValue::from_str(&expose.join(", "))
{
headers.insert(header::ACCESS_CONTROL_EXPOSE_HEADERS, value);
}
response
}
fn request_origin(req: &HttpRequest) -> Option<String> {
req.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|origin| !origin.is_empty())
.map(str::to_owned)
}
fn web_allows(web_cfg: &WebConfig, origin: &str) -> bool {
crate::envelope::cors::web_allows(web_cfg, origin)
}
fn granted_origin(svc: &AuthSvc, req: &HttpRequest, origin: &str) -> bool {
if req.path() == "/api/v1/oauth/socket-tickets" {
return svc
.auth()
.oauth
.socket_origins
.iter()
.any(|value| value == origin);
}
svc.clients()
.list()
.iter()
.flat_map(|client| client.redirect_uris.clone())
.filter_map(|raw| Url::parse(&raw.replace("*.", "")).ok())
.any(|url| url.origin().ascii_serialization() == origin)
}
pub async fn preflight(
svc: web::Data<AuthSvc>,
web_cfg: web::Data<WebConfig>,
req: HttpRequest,
) -> HttpResponse {
let web_cfg = web_cfg.get_ref().clone();
let response = HttpResponse::NoContent()
.insert_header((
header::ACCESS_CONTROL_ALLOW_METHODS.as_str(),
web_cfg.allow_methods().join(", "),
))
.insert_header((
header::ACCESS_CONTROL_ALLOW_HEADERS.as_str(),
web_cfg.allow_headers().join(", "),
))
.insert_header((
header::ACCESS_CONTROL_MAX_AGE.as_str(),
web_cfg.cors_max_age_secs.to_string(),
))
.finish();
cors(&svc, &web_cfg, &req, response)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::client::{ClientSpec, MIN_SECRET_LEN};
use crate::models::user::oauth_clients::{ClientKind, ClientSource};
fn make_record(kind: ClientKind, secret: Option<&str>, scopes: &[&str]) -> ClientRecord {
ClientRecord::from_spec(
ClientSpec {
client_id: "api",
name: "Api",
kind,
audience: None,
redirect_uris: vec!["https://app.example/callback".into()],
post_logout_redirect_uris: vec![],
backchannel_logout_uris: vec![],
scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
service_scopes: vec![],
source: ClientSource::Config,
enabled: true,
secret,
secret_hash: None,
},
false,
)
.unwrap()
}
#[test]
fn pkce_s256_uses_the_rfc7636_vector_and_rejects_changed_verifiers() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
assert!(valid_challenge(challenge));
assert!(verifier_matches(verifier, challenge));
assert!(!verifier_matches("short", challenge));
assert!(!verifier_matches(&format!("{verifier}x"), challenge));
}
#[test]
fn registration_checks_issuer_redirect_transport_and_unique_client_ids() {
let mut config = OAuthConfig {
public_url: "https://auth.example".into(),
clients: vec![crate::config::auth::OAuthClient {
client_id: "api".into(),
redirect_uris: vec!["https://app.example/oauth/callback".into()],
scopes: vec!["guild".into()],
..Default::default()
}],
socket_origins: vec!["https://app.example".into()],
rpc_key: "unit-test-rpc-key-0123456789abcd".into(),
..Default::default()
};
assert!(validate_config(&config, "https://auth.example").is_ok());
// 空或过短的共享密钥让服务直接起不来。
let configured = config.rpc_key.clone();
config.rpc_key = "short".into();
assert!(validate_config(&config, "https://auth.example").is_err());
config.rpc_key = configured;
assert!(validate_config(&config, "another-issuer").is_err());
config.clients[0].redirect_uris = vec!["http://external.example/callback".into()];
assert!(validate_config(&config, "https://auth.example").is_err());
config.clients[0].redirect_uris = vec!["https://app.example/callback#fragment".into()];
assert!(validate_config(&config, "https://auth.example").is_err());
// 通配 redirect 只允许 https,且可以关闭。
config.clients[0].redirect_uris = vec!["https://*.app.example/callback".into()];
assert!(validate_config(&config, "https://auth.example").is_ok());
config.allow_wildcard_redirect = false;
assert!(validate_config(&config, "https://auth.example").is_err());
}
#[test]
fn prompt_parsing_accepts_known_values_and_rejects_mixed_none() {
assert_eq!(parse_prompt(None).unwrap(), Prompt::default());
assert!(parse_prompt(Some("login")).unwrap().login);
assert!(parse_prompt(Some("consent")).unwrap().consent);
let both = parse_prompt(Some("login consent")).unwrap();
assert!(both.login && both.consent && !both.none);
assert!(parse_prompt(Some("none")).unwrap().none);
assert!(parse_prompt(Some("none login")).is_err());
assert!(parse_prompt(Some("select_account")).is_err());
assert!(parse_prompt(Some(" ")).is_err());
}
#[test]
fn access_ttl_is_clamped_to_the_supported_window() {
// 边界与非法输入:超界配置值收敛到 60..=86400,`expires_in` 必须回写生效值。
assert_eq!(clamp_access_ttl(0), 60);
assert_eq!(clamp_access_ttl(-5), 60);
assert_eq!(clamp_access_ttl(59), 60);
assert_eq!(clamp_access_ttl(600), 600);
assert_eq!(clamp_access_ttl(86_400), 86_400);
assert_eq!(clamp_access_ttl(86_401), 86_400);
}
#[test]
fn client_credentials_scope_defaults_to_the_service_subset() {
let mut record = make_record(
ClientKind::Confidential,
Some(&"s".repeat(MIN_SECRET_LEN)),
&["openid", "profile"],
);
record.service_scopes = vec!["openid".into()];
// 缺省下发独立配置的子集,不再发全部注册 scope。
assert_eq!(service_token_scopes(&record, None).unwrap(), vec!["openid"]);
assert_eq!(
service_token_scopes(&record, Some(" ")).unwrap(),
vec!["openid"]
);
// 显式请求也必须落在子集内。
assert_eq!(
service_token_scopes(&record, Some("openid")).unwrap(),
vec!["openid"]
);
assert!(service_token_scopes(&record, Some("profile")).is_err());
assert!(service_token_scopes(&record, Some("openid profile")).is_err());
assert!(service_token_scopes(&record, Some("unknown")).is_err());
// 未配置子集:不发任何 scope,也不接受显式请求。
let plain = make_record(
ClientKind::Confidential,
Some(&"s".repeat(MIN_SECRET_LEN)),
&["profile"],
);
assert!(service_token_scopes(&plain, None).unwrap().is_empty());
assert!(service_token_scopes(&plain, Some("profile")).is_err());
}
#[test]
fn client_secret_policy_rejects_wrong_or_unexpected_credentials() {
let secret = "s".repeat(MIN_SECRET_LEN);
let confidential = make_record(ClientKind::Confidential, Some(&secret), &["openid"]);
let public = make_record(ClientKind::Public, None, &["openid"]);
// 机密客户端:正确 secret 通过,错误/缺失/空一律 invalid_client。
assert!(check_client_secret(&confidential, Some(&secret)).is_ok());
assert!(check_client_secret(&confidential, Some("wrong-secret")).is_err());
assert!(check_client_secret(&confidential, None).is_err());
assert!(check_client_secret(&confidential, Some("")).is_err());
// public 客户端:只能裸 client_id,带任何 secret 都是 invalid_client(RFC 6749 §2.3)。
assert!(check_client_secret(&public, None).is_ok());
assert!(check_client_secret(&public, Some("")).is_ok());
assert!(check_client_secret(&public, Some("anything")).is_err());
assert!(check_client_secret(&public, Some(&secret)).is_err());
}
#[test]
fn authorize_state_is_optional_and_bounded() {
// 规范对齐:state 可选;实现只保留上限防撑爆重定向 URL。
assert!(valid_state(None));
assert!(valid_state(Some("x")));
assert!(valid_state(Some("")));
assert!(valid_state(Some(&"a".repeat(MAX_STATE_LEN))));
assert!(!valid_state(Some(&"a".repeat(MAX_STATE_LEN + 1))));
let base = "response_type=code&client_id=api&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback\
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256";
let parsed = web::Query::<AuthorizeRequest>::from_query(base).unwrap();
assert!(parsed.state.is_none());
let with_state = format!("{base}&state=abc");
let parsed = web::Query::<AuthorizeRequest>::from_query(&with_state).unwrap();
assert_eq!(parsed.state.as_deref(), Some("abc"));
}
#[test]
fn parse_failure_redirects_only_to_registered_redirect_uri() {
let record = make_record(ClientKind::Public, None, &["openid"]);
let raw = |query: &str| web::Query::<RawAuthorizeParams>::from_query(query).unwrap();
// 合法 client + 注册过的 redirect_uri → 允许302 回错误。
let ok = raw("client_id=api&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&state=s");
let target = raw_error_target(&ok, Some(&record), false);
assert_eq!(target, Some(("https://app.example/callback", Some("s"))));
// 非法输入:缺 redirect_uri、客户端未注册、redirect_uri 未注册 → 都不重定向。
let missing = raw("client_id=api");
assert_eq!(raw_error_target(&missing, Some(&record), false), None);
let unregistered = raw("client_id=api&redirect_uri=https%3A%2F%2Fevil.example%2Fx");
assert_eq!(raw_error_target(&unregistered, Some(&record), false), None);
let no_client = raw("redirect_uri=https%3A%2F%2Fapp.example%2Fcallback");
assert_eq!(raw_error_target(&no_client, None, false), None);
}
#[test]
fn redirect_error_echoes_state_and_iss() {
let response = redirect_error(
"https://auth.example",
"https://app.example/cb",
Some("s1"),
"invalid_request",
);
assert_eq!(response.status(), actix_web::http::StatusCode::FOUND);
let location = response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap();
assert!(location.starts_with("https://app.example/cb?"));
assert!(location.contains("error=invalid_request"));
assert!(location.contains("state=s1"));
assert!(location.contains("iss=https%3A%2F%2Fauth.example"));
// state 缺省时不回传空参数。
let response = redirect_error(
"https://auth.example",
"https://app.example/cb",
None,
"invalid_request",
);
let location = response
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap();
assert!(!location.contains("state="));
// 非法 redirect_uri 不重定向。
let response = redirect_error("https://auth.example", "not a url", None, "invalid_request");
assert_eq!(response.status(), actix_web::http::StatusCode::BAD_REQUEST);
}
}