alibi_plugins/oauth_provider/
access_validation.rs1use super::OAuthProviderPlugin;
3use alibi_core::endpoint::EndpointError;
4use alibi_core::store::OAuthTokenKind;
5use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthSession};
6use serde_json::{Value, json};
7
8impl OAuthProviderPlugin {
9 pub async fn validate_access_token<S: AuthSchema>(
12 &self,
13 ctx: &AuthContext<S>,
14 token: &str,
15 client_id: Option<&str>,
16 ) -> Result<Value, EndpointError> {
17 if !self.config.disable_jwt_plugin
18 && let Some(jwt) = ctx.extensions.get::<crate::jwt::JwtPlugin>()
19 && let Some(mut claims) = jwt
20 .verify_signed_payload_with_optional_kid(token, None, ctx)
21 .await?
22 {
23 let now = serde_json::Number::from(chrono::Utc::now().timestamp())
24 .as_f64()
25 .ok_or_else(|| AuthError::internal("Invalid token validation time"))?;
26 let issuer = issuer(ctx, self.config.disable_jwt_plugin);
27 if claims.get("iss").and_then(Value::as_str) != Some(issuer.as_str()) {
28 return Err(AuthError::internal("unexpected \"iss\" claim value").into());
29 }
30 for field in ["exp", "nbf", "iat"] {
31 if claims.get(field).is_some_and(|value| !value.is_number()) {
32 return Ok(json!({"active":false}));
33 }
34 }
35 if claims
36 .get("nbf")
37 .and_then(Value::as_f64)
38 .is_some_and(|nbf| nbf > now)
39 {
40 return Err(AuthError::internal("unexpected \"nbf\" claim value").into());
41 }
42 if claims
43 .get("exp")
44 .and_then(Value::as_f64)
45 .is_some_and(|exp| exp <= now)
46 {
47 return Ok(json!({"active":false}));
48 }
49 let Some(resources) = audience_values(claims.get("aud")) else {
50 return Ok(json!({"active":false}));
51 };
52 if !audiences_allowed(ctx, &resources).await? {
53 return Ok(json!({"active":false}));
54 }
55 let Some(issuing_id) = claims
56 .get("azp")
57 .and_then(Value::as_str)
58 .filter(|id| !id.is_empty())
59 .map(str::to_owned)
60 else {
61 return Ok(json!({"active":false}));
62 };
63 let Some(client) = self.get_client(ctx, &issuing_id).await? else {
64 return Ok(json!({"active":false}));
65 };
66 if client.metadata.get("disabled") == Some(&Value::Bool(true))
67 || !authorized(ctx, client_id, &issuing_id, &resources).await?
68 {
69 return Ok(json!({"active":false}));
70 }
71 if let Some(sid) = claims.get("sid").and_then(Value::as_str)
72 && ctx
73 .database
74 .get_session_by_id(sid)
75 .await?
76 .is_none_or(|session| session.expires_at() < chrono::Utc::now())
77 {
78 return Ok(json!({"active":false}));
79 }
80 _ = claims.insert("client_id".into(), json!(issuing_id));
81 _ = claims.insert("active".into(), json!(true));
82 _ = claims.insert(
83 "token_type".into(),
84 json!(if claims
85 .get("cnf")
86 .and_then(|cnf| cnf.get("jkt"))
87 .and_then(Value::as_str)
88 .is_some_and(|jkt| !jkt.is_empty())
89 {
90 "DPoP"
91 } else {
92 "Bearer"
93 }),
94 );
95 return Ok(Value::Object(claims));
96 }
97 if !self.config.disable_jwt_plugin && compact_jwt(token) {
98 return Err(AuthError::internal("JWT verification failed").into());
99 }
100 let hash = self.hash_token(token, "access_token").await?;
101 let Some(row) = ctx
102 .database
103 .get_oauth_token(OAuthTokenKind::Access, &hash)
104 .await?
105 else {
106 return Err(invalid_access_token());
107 };
108 let Some(row) = super::userinfo::live_access_row(row, ctx, self).await? else {
109 return Ok(json!({"active":false}));
110 };
111 let resources = row
112 .metadata
113 .get("resources")
114 .and_then(Value::as_array)
115 .map(|values| {
116 values
117 .iter()
118 .filter_map(Value::as_str)
119 .map(str::to_owned)
120 .collect::<Vec<_>>()
121 })
122 .unwrap_or_default();
123 if !audiences_allowed(ctx, &resources).await?
124 || !authorized(ctx, client_id, &row.client_id, &resources).await?
125 {
126 return Ok(json!({"active":false}));
127 }
128 let scopes = row
129 .metadata
130 .get("scopes")
131 .and_then(Value::as_array)
132 .map(|values| {
133 values
134 .iter()
135 .filter_map(Value::as_str)
136 .collect::<Vec<_>>()
137 .join(" ")
138 });
139 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()});
140 let mut payload = payload
141 .as_object()
142 .cloned()
143 .ok_or_else(|| AuthError::internal("Invalid access-token payload"))?;
144 payload.extend(confirmation_claims(row.metadata.get("confirmation")));
145 if let Some(scope) = scopes {
146 _ = payload.insert("scope".into(), json!(scope));
147 }
148 if let Some(user_id) = row.user_id.as_deref()
149 && let Some(user) = ctx.database.get_user_by_id(user_id).await?
150 {
151 _ = payload.insert("sub".into(), json!(ctx.user_view(&user).id));
152 }
153 if let Some(sid) = row.session_id {
154 _ = payload.insert("sid".into(), json!(sid));
155 }
156 if !resources.is_empty() {
157 let mut audiences = resources;
158 let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
159 if scopes_contain(&row.metadata, "openid") && !audiences.contains(&userinfo) {
160 audiences.push(userinfo);
161 }
162 _ = payload.insert(
163 "aud".into(),
164 match audiences.as_slice() {
165 [one] => json!(one),
166 _ => json!(audiences),
167 },
168 );
169 }
170 Ok(Value::Object(payload))
171 }
172 pub async fn require_active_access_token<S: AuthSchema>(
174 &self,
175 ctx: &AuthContext<S>,
176 token: &str,
177 client_id: Option<&str>,
178 ) -> Result<Value, EndpointError> {
179 let payload = self.validate_access_token(ctx, token, client_id).await?;
180 if payload.get("active") == Some(&Value::Bool(true)) {
181 Ok(payload)
182 } else {
183 Err(invalid_access_token())
184 }
185 }
186}
187pub(super) fn confirmation_claims(confirmation: Option<&Value>) -> serde_json::Map<String, Value> {
191 let token_type = if confirmation
192 .and_then(crate::oauth2::get_confirmation_jkt)
193 .is_some()
194 {
195 "DPoP"
196 } else {
197 "Bearer"
198 };
199 let mut claims = serde_json::Map::from_iter([("token_type".into(), json!(token_type))]);
200 if let Some(confirmation) = confirmation
201 .filter(|value| alibi_core::utils::json::JsValue::from((**value).clone()).is_truthy())
202 {
203 _ = claims.insert("cnf".into(), confirmation.clone());
204 }
205 claims
206}
207fn base_url<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
208 format!(
209 "{}{}",
210 ctx.config.base_url.trim_end_matches('/'),
211 ctx.config.base_path
212 )
213}
214fn issuer<S: AuthSchema>(ctx: &AuthContext<S>, disabled: bool) -> String {
215 if !disabled
216 && let Some(value) = ctx
217 .extensions
218 .get::<crate::jwt::JwtDiscoveryOptions>()
219 .and_then(|jwt| jwt.issuer.clone())
220 {
221 return value;
222 }
223 base_url(ctx)
224}
225fn audience_values(value: Option<&Value>) -> Option<Vec<String>> {
226 match value {
227 None => Some(Vec::new()),
228 Some(Value::String(value)) => Some(vec![value.clone()]),
229 Some(Value::Array(values)) => values
230 .iter()
231 .map(|value| value.as_str().map(str::to_owned))
232 .collect(),
233 _ => None,
234 }
235}
236async fn audiences_allowed<S: AuthSchema>(
237 ctx: &AuthContext<S>,
238 audiences: &[String],
239) -> AuthResult<bool> {
240 if audiences.len() > 64 {
241 return Ok(false);
242 }
243 let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
244 for audience in audiences {
245 if audience != &userinfo && ctx.database.get_oauth_resource(audience).await?.is_none() {
246 return Ok(false);
247 }
248 }
249 Ok(true)
250}
251async fn authorized<S: AuthSchema>(
252 ctx: &AuthContext<S>,
253 caller: Option<&str>,
254 issuer: &str,
255 resources: &[String],
256) -> AuthResult<bool> {
257 if caller.is_none_or(|caller| caller.is_empty() || caller == issuer) {
258 return Ok(true);
259 }
260 let links = ctx
261 .database
262 .list_oauth_client_resources(caller.unwrap_or_default())
263 .await?;
264 Ok(links
265 .iter()
266 .any(|link| resources.contains(&link.resource_id)))
267}
268fn scopes_contain(metadata: &Value, scope: &str) -> bool {
269 metadata
270 .get("scopes")
271 .and_then(Value::as_array)
272 .is_some_and(|values| values.iter().any(|value| value == scope))
273}
274fn invalid_access_token() -> EndpointError {
275 let mut headers = alibi_core::Headers::new();
276 _ = headers.insert(
277 "WWW-Authenticate",
278 "Bearer error=\"invalid_token\", error_description=\"Invalid access token\"",
279 );
280 EndpointError {
281 error: AuthError::Api {
282 status: 401,
283 code: None,
284 message: String::new(),
285 },
286 headers: Some(headers),
287 body: Some(Box::new(alibi_core::utils::json::JsValue::from(
288 json!({"error":"invalid_token","error_description":"Invalid access token"}),
289 ))),
290 body_absent: false,
291 }
292}
293
294fn compact_jwt(token: &str) -> bool {
295 use base64::Engine as _;
296 let parts = token.split('.').collect::<Vec<_>>();
297 let [header, payload, signature] = parts.as_slice() else {
298 return false;
299 };
300 let decode = |text: &str| {
301 base64::engine::general_purpose::URL_SAFE_NO_PAD
302 .decode(text)
303 .ok()
304 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
305 };
306 decode(header).is_some_and(|value| value.get("alg").is_some_and(Value::is_string))
307 && decode(payload).is_some_and(|value| value.is_object())
308 && !signature.is_empty()
309}