use super::OAuthProviderPlugin;
use alibi_core::endpoint::EndpointError;
use alibi_core::store::OAuthTokenKind;
use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthSession};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde_json::{Value, json};
#[derive(Debug)]
pub(super) struct AccessTokenValidation {
pub payload: Value,
pub requested_user_info_claims: Vec<String>,
}
impl From<Value> for AccessTokenValidation {
fn from(payload: Value) -> Self {
Self {
payload,
requested_user_info_claims: Vec::new(),
}
}
}
impl OAuthProviderPlugin {
pub async fn validate_access_token<S: AuthSchema>(
&self,
ctx: &AuthContext<S>,
token: &str,
client_id: Option<&str>,
) -> Result<Value, EndpointError> {
Ok(self
.resolve_access_token_validation(ctx, token, client_id)
.await?
.payload)
}
async fn resolve_access_token_validation<S: AuthSchema>(
&self,
ctx: &AuthContext<S>,
token: &str,
client_id: Option<&str>,
) -> Result<AccessTokenValidation, EndpointError> {
let decoded = self.decode_token_prefix(token, OAuthTokenKind::Access);
let prefixed_opaque = self
.config
.opaque_access_token_prefix
.as_deref()
.is_some_and(|prefix| !prefix.is_empty())
&& token
.split('.')
.next()
.and_then(|header| URL_SAFE_NO_PAD.decode(header).ok())
.and_then(|header| serde_json::from_slice::<Value>(&header).ok())
.is_none_or(|header| !header.is_object());
let now = serde_json::Number::from(chrono::Utc::now().timestamp())
.as_f64()
.ok_or_else(|| AuthError::internal("Invalid token validation time"))?;
let expected_issuer = issuer(ctx, self.config.disable_jwt_plugin);
let verified = if !self.config.disable_jwt_plugin
&& !prefixed_opaque
&& let Some(jwt) = ctx.extensions.get::<crate::jwt::JwtPlugin>()
{
Box::pin(jwt.verify_signed_payload_with_optional_kid(token, None, ctx))
.await?
.filter(|claims| {
claims.get("iss").and_then(Value::as_str) == Some(expected_issuer.as_str())
&& ["iat", "nbf", "exp"]
.iter()
.all(|field| claims.get(*field).is_none_or(Value::is_number))
&& claims
.get("nbf")
.and_then(Value::as_f64)
.is_none_or(|nbf| nbf <= now)
})
} else {
None
};
if let Some(mut claims) = verified {
if claims
.get("exp")
.and_then(Value::as_f64)
.is_some_and(|exp| exp <= now)
{
return Ok(json!({"active":false}).into());
}
let Some(resources) = audience_values(claims.get("aud")) else {
return Ok(json!({"active":false}).into());
};
if !audiences_allowed(self, ctx, &resources).await? {
return Ok(json!({"active":false}).into());
}
let Some(issuing_id) = claims
.get("azp")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_owned)
else {
return Ok(json!({"active":false}).into());
};
let Some(client) = self.get_client(ctx, &issuing_id).await? else {
return Ok(json!({"active":false}).into());
};
if client.metadata.get("disabled") == Some(&Value::Bool(true))
|| !authorized(ctx, client_id, &issuing_id, &resources).await?
{
return Ok(json!({"active":false}).into());
}
if let Some(sid) = claims.get("sid").and_then(Value::as_str)
&& ctx
.database
.get_session_by_id(sid)
.await?
.is_none_or(|session| session.expires_at() < chrono::Utc::now())
{
return Ok(json!({"active":false}).into());
}
_ = claims.insert("client_id".into(), json!(issuing_id));
_ = claims.insert("active".into(), json!(true));
_ = claims.insert(
"token_type".into(),
json!(if claims
.get("cnf")
.and_then(|cnf| cnf.get("jkt"))
.and_then(Value::as_str)
.is_some_and(|jkt| !jkt.is_empty())
{
"DPoP"
} else {
"Bearer"
}),
);
return Ok(Value::Object(claims).into());
}
let Some(token) = decoded else {
return Err(invalid_access_token());
};
let hash = self.hash_token(token, "access_token").await?;
let Some(row) = ctx
.database
.get_oauth_token(OAuthTokenKind::Access, &hash)
.await?
else {
return Err(invalid_access_token());
};
let Some((row, client)) = super::userinfo::live_access_row(row, ctx, self).await? else {
return Ok(json!({"active":false}).into());
};
let resources = row
.metadata
.get("resources")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !audiences_allowed(self, ctx, &resources).await?
|| !authorized(ctx, client_id, &row.client_id, &resources).await?
{
return Ok(json!({"active":false}).into());
}
let scopes = row
.metadata
.get("scopes")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>()
});
let mut params =
super::OAuthTokenIssueParams::new(client, scopes.clone().unwrap_or_default());
if let Some(user_id) = row.user_id.as_deref()
&& let Some(user) = ctx.database.get_user_by_id(user_id).await?
{
params.user = Some(ctx.trusted_user_view(&user));
}
params.reference_id = row
.metadata
.get("referenceId")
.and_then(Value::as_str)
.map(str::to_owned);
params.resources = row
.metadata
.get("resources")
.and_then(Value::as_array)
.map(|_| resources.clone());
let mut resource_claims = serde_json::Map::new();
for resource in &resources {
if let Some(resource) = ctx.database.get_oauth_resource(resource).await?
&& let Some(claims) = resource
.metadata
.get("customClaims")
.and_then(Value::as_object)
{
resource_claims.extend(claims.clone());
}
}
let claims = self
.resolve_access_token_claims(alibi_core::CallbackContext::new(ctx, None), ¶ms, None)
.await?;
let payload = json!({"active":true,"iss":issuer(ctx,self.config.disable_jwt_plugin),"client_id":row.client_id,"azp":row.client_id,"exp":row.expires_at.timestamp(),"iat":row.created_at.timestamp()});
let core = payload
.as_object()
.cloned()
.ok_or_else(|| AuthError::internal("Invalid access-token payload"))?;
let mut payload = super::extension_claims::compose_access_claims(claims, resource_claims);
payload.extend(core);
payload.extend(confirmation_claims(row.metadata.get("confirmation")));
if let Some(scope) = scopes {
_ = payload.insert("scope".into(), json!(scope.join(" ")));
}
if let Some(user) = params.user.as_ref() {
_ = payload.insert("sub".into(), json!(user.id));
}
if let Some(sid) = row.session_id {
_ = payload.insert("sid".into(), json!(sid));
}
if !resources.is_empty() {
let mut audiences = resources;
let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
if scopes_contain(&row.metadata, "openid") && !audiences.contains(&userinfo) {
audiences.push(userinfo);
}
_ = payload.insert(
"aud".into(),
match audiences.as_slice() {
[one] => json!(one),
_ => json!(audiences),
},
);
}
let requested_user_info_claims = row
.metadata
.get("requestedUserInfoClaims")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
Ok(AccessTokenValidation {
payload: Value::Object(payload),
requested_user_info_claims,
})
}
pub async fn require_active_access_token<S: AuthSchema>(
&self,
ctx: &AuthContext<S>,
token: &str,
client_id: Option<&str>,
) -> Result<Value, EndpointError> {
Ok(self
.require_active_access_token_with_claims(ctx, token, client_id)
.await?
.payload)
}
pub(super) async fn require_active_access_token_with_claims<S: AuthSchema>(
&self,
ctx: &AuthContext<S>,
token: &str,
client_id: Option<&str>,
) -> Result<AccessTokenValidation, EndpointError> {
let validation = self
.resolve_access_token_validation(ctx, token, client_id)
.await?;
if validation.payload.get("active") == Some(&Value::Bool(true)) {
Ok(validation)
} else {
Err(invalid_access_token())
}
}
}
pub(super) fn confirmation_claims(confirmation: Option<&Value>) -> serde_json::Map<String, Value> {
let token_type = if confirmation
.and_then(crate::oauth2::get_confirmation_jkt)
.is_some()
{
"DPoP"
} else {
"Bearer"
};
let mut claims = serde_json::Map::from_iter([("token_type".into(), json!(token_type))]);
if let Some(confirmation) = confirmation
.filter(|value| alibi_core::utils::json::JsValue::from((**value).clone()).is_truthy())
{
_ = claims.insert("cnf".into(), confirmation.clone());
}
claims
}
fn base_url<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
format!(
"{}{}",
ctx.config.base_url.trim_end_matches('/'),
ctx.config.base_path
)
}
fn issuer<S: AuthSchema>(ctx: &AuthContext<S>, disabled: bool) -> String {
if !disabled
&& let Some(value) = ctx
.extensions
.get::<crate::jwt::JwtDiscoveryOptions>()
.and_then(|jwt| jwt.issuer.clone())
{
return value;
}
base_url(ctx)
}
fn audience_values(value: Option<&Value>) -> Option<Vec<String>> {
match value {
None => Some(Vec::new()),
Some(Value::String(value)) => Some(vec![value.clone()]),
Some(Value::Array(values)) => values
.iter()
.map(|value| value.as_str().map(str::to_owned))
.collect(),
_ => None,
}
}
async fn audiences_allowed<S: AuthSchema>(
provider: &OAuthProviderPlugin,
ctx: &AuthContext<S>,
audiences: &[String],
) -> AuthResult<bool> {
if audiences.len() > 64 {
return Ok(false);
}
let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
for audience in audiences {
if audience != &userinfo && provider.get_resource(ctx, audience).await?.is_none() {
return Ok(false);
}
}
Ok(true)
}
async fn authorized<S: AuthSchema>(
ctx: &AuthContext<S>,
caller: Option<&str>,
issuer: &str,
resources: &[String],
) -> AuthResult<bool> {
if caller.is_none_or(|caller| caller.is_empty() || caller == issuer) {
return Ok(true);
}
let links = ctx
.database
.list_oauth_client_resources(caller.unwrap_or_default())
.await?;
Ok(links
.iter()
.any(|link| resources.contains(&link.resource_id)))
}
fn scopes_contain(metadata: &Value, scope: &str) -> bool {
metadata
.get("scopes")
.and_then(Value::as_array)
.is_some_and(|values| values.iter().any(|value| value == scope))
}
fn invalid_access_token() -> EndpointError {
let mut headers = alibi_core::Headers::new();
_ = headers.insert(
"WWW-Authenticate",
"Bearer error=\"invalid_token\", error_description=\"Invalid access token\"",
);
EndpointError {
error: AuthError::Api {
status: 401,
code: None,
message: String::new(),
},
headers: Some(headers),
body: Some(Box::new(alibi_core::utils::json::JsValue::from(
json!({"error":"invalid_token","error_description":"Invalid access token"}),
))),
body_absent: false,
}
}