1use std::time::Duration;
2
3use async_trait::async_trait;
4use reqwest::Url;
5
6use crate::error::AuthError;
7use crate::oauth_provider::{AuthorizeUrlRequest, OAuthProvider, ProviderExchange};
8use crate::oidc::{OidcVerifier, TokenAuthMethod};
9use crate::provider_http::build_authorize_url;
10
11const AUTHELIA_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
12const AUTHELIA_AUTHORIZE_PATH: &str = "api/oidc/authorization";
16const AUTHELIA_TOKEN_PATH: &str = "api/oidc/token";
17const AUTHELIA_JWKS_PATH: &str = "jwks.json";
18
19#[derive(Clone)]
20pub struct AutheliaProvider {
21 pub client_id: String,
22 pub client_secret: String,
23 pub redirect_uri: Url,
24 pub scopes: Vec<String>,
25 pub http: reqwest::Client,
26 issuer: Url,
27 authorize_endpoint: Url,
28 token_endpoint: Url,
29 verifier: OidcVerifier,
30}
31
32impl std::fmt::Debug for AutheliaProvider {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("AutheliaProvider")
35 .field("client_id", &self.client_id)
36 .field("issuer", &self.issuer)
37 .field("redirect_uri", &self.redirect_uri)
38 .field("scopes", &self.scopes)
39 .finish_non_exhaustive()
40 }
41}
42
43impl AutheliaProvider {
44 pub fn new(
45 issuer: Url,
46 client_id: String,
47 client_secret: String,
48 redirect_uri: Url,
49 ) -> Result<Self, AuthError> {
50 crate::provider_http::install_rustls_default_once();
51 let http = reqwest::Client::builder()
52 .timeout(AUTHELIA_HTTP_TIMEOUT)
53 .build()
54 .map_err(|error| {
55 AuthError::Storage(format!("build authelia oauth http client: {error}"))
56 })?;
57 let issuer_base = if issuer.as_str().ends_with('/') {
67 issuer.clone()
68 } else {
69 Url::parse(&format!("{}/", issuer.as_str())).map_err(|error| {
70 AuthError::Config(format!("normalize authelia issuer url: {error}"))
71 })?
72 };
73 let authorize_endpoint = issuer_base.join(AUTHELIA_AUTHORIZE_PATH).map_err(|error| {
74 AuthError::Config(format!("build authelia authorize endpoint: {error}"))
75 })?;
76 let token_endpoint = issuer_base.join(AUTHELIA_TOKEN_PATH).map_err(|error| {
77 AuthError::Config(format!("build authelia token endpoint: {error}"))
78 })?;
79 let jwks_endpoint = issuer_base
80 .join(AUTHELIA_JWKS_PATH)
81 .map_err(|error| AuthError::Config(format!("build authelia jwks endpoint: {error}")))?;
82 let verifier = OidcVerifier::new(
83 "authelia",
84 issuer.as_str().trim_end_matches('/').to_string(),
85 jwks_endpoint,
86 http.clone(),
87 )
88 .with_token_auth_method(TokenAuthMethod::ClientSecretBasic);
89
90 Ok(Self {
91 client_id,
92 client_secret,
93 redirect_uri,
94 scopes: vec![
95 "openid".to_string(),
96 "email".to_string(),
97 "profile".to_string(),
98 "offline_access".to_string(),
99 ],
100 http,
101 issuer,
102 authorize_endpoint,
103 token_endpoint,
104 verifier,
105 })
106 }
107
108 #[cfg(test)]
109 #[must_use]
110 pub fn with_endpoints(
111 mut self,
112 issuer: Url,
113 authorize_endpoint: Url,
114 token_endpoint: Url,
115 jwks_endpoint: Url,
116 ) -> Self {
117 self.authorize_endpoint = authorize_endpoint;
118 self.token_endpoint = token_endpoint;
119 self.verifier = OidcVerifier::new(
120 "authelia",
121 issuer.as_str().trim_end_matches('/').to_string(),
122 jwks_endpoint,
123 self.http.clone(),
124 )
125 .with_token_auth_method(TokenAuthMethod::ClientSecretBasic);
126 self.issuer = issuer;
127 self
128 }
129
130 #[cfg(test)]
134 pub(crate) fn token_endpoint(&self) -> &Url {
135 &self.token_endpoint
136 }
137}
138
139#[async_trait]
140impl OAuthProvider for AutheliaProvider {
141 fn provider_id(&self) -> &'static str {
142 "authelia"
143 }
144
145 fn callback_path(&self) -> &str {
146 self.redirect_uri.path()
147 }
148
149 fn authorize_url(&self, request: &AuthorizeUrlRequest) -> Result<Url, AuthError> {
150 Ok(build_authorize_url(
151 &self.authorize_endpoint,
152 &self.client_id,
153 &self.redirect_uri,
154 &self.scopes,
155 request,
156 &[],
157 ))
158 }
159
160 async fn exchange_code(
161 &self,
162 code: &str,
163 code_verifier: &str,
164 ) -> Result<ProviderExchange, AuthError> {
165 self.verifier
166 .exchange_code(
167 &self.http,
168 &self.token_endpoint,
169 &self.client_id,
170 &self.client_secret,
171 &self.redirect_uri,
172 code,
173 code_verifier,
174 )
175 .await
176 }
177
178 async fn refresh(&self, refresh_token: &str) -> Result<ProviderExchange, AuthError> {
179 self.verifier
180 .refresh(
181 &self.http,
182 &self.token_endpoint,
183 &self.client_id,
184 &self.client_secret,
185 refresh_token,
186 )
187 .await
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use base64::Engine;
194 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
195 use jsonwebtoken::{Algorithm, Header, encode};
196 use rsa::RsaPrivateKey;
197 use rsa::pkcs8::EncodePrivateKey;
198 use rsa::rand_core::{TryCryptoRng, TryRng, UnwrapErr};
199 use rsa::traits::PublicKeyParts;
200 use serde_json::json;
201 use std::sync::OnceLock;
202 use wiremock::matchers::{basic_auth, method, path};
203 use wiremock::{Mock, MockServer, ResponseTemplate};
204
205 use super::{AutheliaProvider, AuthorizeUrlRequest};
206 use crate::oauth_provider::OAuthProvider;
207
208 #[test]
209 fn authelia_authorize_url_requests_offline_access_via_scope_not_access_type() {
210 let provider = test_authelia_provider();
211 let request = AuthorizeUrlRequest {
212 state: "state-123".to_string(),
213 code_challenge: "challenge".to_string(),
214 code_challenge_method: "S256".to_string(),
215 force_consent: true,
216 };
217 let url = provider.authorize_url(&request).unwrap();
218 assert!(
219 url.as_str()
220 .contains("scope=openid+email+profile+offline_access")
221 );
222 assert!(!url.as_str().contains("access_type="));
223 assert!(url.as_str().contains("prompt=consent"));
224 }
225
226 #[test]
234 fn authelia_token_endpoint_preserves_issuer_path_prefix_without_trailing_slash() {
235 let provider = AutheliaProvider::new(
236 Url::parse("https://example.com/authelia").unwrap(),
237 "client-id".to_string(),
238 "client-secret".to_string(),
239 Url::parse("https://lab.example.com/auth/authelia/callback").unwrap(),
240 )
241 .unwrap();
242 assert_eq!(
243 provider.token_endpoint().as_str(),
244 "https://example.com/authelia/api/oidc/token"
245 );
246 }
247
248 #[tokio::test]
253 async fn authelia_default_jwks_endpoint_matches_discovery_document() {
254 let server = MockServer::start().await;
255 let issuer = Url::parse(&server.uri()).unwrap();
256 Mock::given(method("POST"))
257 .and(path("/api/oidc/token"))
258 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
259 "access_token": "authelia-access-token",
260 "expires_in": 3600,
261 "id_token": signed_test_id_token(&issuer, "client-id"),
262 })))
263 .mount(&server)
264 .await;
265 Mock::given(method("GET"))
266 .and(path("/jwks.json"))
267 .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
268 .mount(&server)
269 .await;
270
271 let provider = AutheliaProvider::new(
272 issuer,
273 "client-id".to_string(),
274 "client-secret".to_string(),
275 Url::parse("https://soma.example.com/auth/authelia/callback").unwrap(),
276 )
277 .unwrap();
278
279 let exchange = provider.exchange_code("code", "verifier").await.unwrap();
280 assert_eq!(exchange.subject, "authelia-subject-123");
281 }
282
283 #[tokio::test]
284 async fn authelia_exchange_parses_subject_from_id_token() {
285 let server = MockServer::start().await;
286 let issuer = Url::parse(&server.uri()).unwrap();
287 Mock::given(method("POST"))
288 .and(path("/api/oidc/token"))
289 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
290 "access_token": "authelia-access-token",
291 "refresh_token": "authelia-refresh-token",
292 "expires_in": 3600,
293 "id_token": signed_test_id_token(&issuer, "client-id"),
294 })))
295 .mount(&server)
296 .await;
297 Mock::given(method("GET"))
298 .and(path("/api/oidc/jwks"))
299 .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
300 .mount(&server)
301 .await;
302
303 let provider = test_authelia_provider().with_endpoints(
304 issuer.clone(),
305 issuer.join("api/oidc/authorization").unwrap(),
306 issuer.join("api/oidc/token").unwrap(),
307 issuer.join("api/oidc/jwks").unwrap(),
308 );
309
310 let exchange = provider.exchange_code("code", "verifier").await.unwrap();
311 assert_eq!(exchange.subject, "authelia-subject-123");
312 assert_eq!(
313 exchange.refresh_token.as_deref(),
314 Some("authelia-refresh-token")
315 );
316 }
317
318 #[tokio::test]
325 async fn authelia_refresh_parses_subject_and_propagates_new_tokens() {
326 let server = MockServer::start().await;
327 let issuer = Url::parse(&server.uri()).unwrap();
328 Mock::given(method("POST"))
329 .and(path("/api/oidc/token"))
330 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
331 "access_token": "authelia-refreshed-access-token",
332 "refresh_token": "authelia-refreshed-refresh-token",
333 "expires_in": 7200,
334 "id_token": signed_test_id_token(&issuer, "client-id"),
335 })))
336 .mount(&server)
337 .await;
338 Mock::given(method("GET"))
339 .and(path("/api/oidc/jwks"))
340 .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
341 .mount(&server)
342 .await;
343
344 let provider = test_authelia_provider().with_endpoints(
345 issuer.clone(),
346 issuer.join("api/oidc/authorization").unwrap(),
347 issuer.join("api/oidc/token").unwrap(),
348 issuer.join("api/oidc/jwks").unwrap(),
349 );
350
351 let exchange = provider
352 .refresh("authelia-existing-refresh-token")
353 .await
354 .unwrap();
355 assert_eq!(exchange.subject, "authelia-subject-123");
356 assert_eq!(exchange.email.as_deref(), Some("user@example.com"));
357 assert_eq!(exchange.email_verified, Some(true));
358 assert_eq!(
359 exchange.access_token,
360 "authelia-refreshed-access-token".to_string()
361 );
362 assert_eq!(
363 exchange.refresh_token.as_deref(),
364 Some("authelia-refreshed-refresh-token")
365 );
366 assert_eq!(exchange.expires_in, Some(7200));
367 }
368
369 #[tokio::test]
377 async fn authelia_exchange_authenticates_via_http_basic_not_a_body_secret() {
378 let server = MockServer::start().await;
379 let issuer = Url::parse(&server.uri()).unwrap();
380 Mock::given(method("POST"))
381 .and(path("/api/oidc/token"))
382 .and(basic_auth("client-id", "client-secret"))
383 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
384 "access_token": "authelia-access-token",
385 "refresh_token": "authelia-refresh-token",
386 "expires_in": 3600,
387 "id_token": signed_test_id_token(&issuer, "client-id"),
388 })))
389 .mount(&server)
390 .await;
391 Mock::given(method("GET"))
392 .and(path("/api/oidc/jwks"))
393 .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
394 .mount(&server)
395 .await;
396
397 let provider = test_authelia_provider().with_endpoints(
398 issuer.clone(),
399 issuer.join("api/oidc/authorization").unwrap(),
400 issuer.join("api/oidc/token").unwrap(),
401 issuer.join("api/oidc/jwks").unwrap(),
402 );
403
404 let exchange = provider
405 .exchange_code("code", "verifier")
406 .await
407 .expect("token request must authenticate via HTTP Basic to match Authelia's default");
408 assert_eq!(exchange.subject, "authelia-subject-123");
409 }
410
411 #[tokio::test]
418 async fn authelia_exchange_rejects_wrong_issuer_in_id_token() {
419 let server = MockServer::start().await;
420 let issuer = Url::parse(&server.uri()).unwrap();
421 Mock::given(method("POST"))
422 .and(path("/api/oidc/token"))
423 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
424 "access_token": "authelia-access-token",
425 "refresh_token": "authelia-refresh-token",
426 "expires_in": 3600,
427 "id_token": signed_test_id_token_with_raw_issuer(
428 "https://evil.example.com",
429 "client-id",
430 ),
431 })))
432 .mount(&server)
433 .await;
434 Mock::given(method("GET"))
435 .and(path("/api/oidc/jwks"))
436 .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
437 .mount(&server)
438 .await;
439
440 let provider = test_authelia_provider().with_endpoints(
441 issuer.clone(),
442 issuer.join("api/oidc/authorization").unwrap(),
443 issuer.join("api/oidc/token").unwrap(),
444 issuer.join("api/oidc/jwks").unwrap(),
445 );
446
447 let error = provider
448 .exchange_code("code", "verifier")
449 .await
450 .unwrap_err();
451 assert!(
452 error.to_string().contains("issuer"),
453 "unexpected error: {error}"
454 );
455 }
456
457 use reqwest::Url;
458
459 fn test_authelia_provider() -> AutheliaProvider {
460 AutheliaProvider::new(
461 Url::parse("https://auth.example.com").unwrap(),
462 "client-id".to_string(),
463 "client-secret".to_string(),
464 Url::parse("https://lab.example.com/auth/authelia/callback").unwrap(),
465 )
466 .unwrap()
467 }
468
469 fn signed_test_id_token(issuer: &Url, client_id: &str) -> String {
470 signed_test_id_token_with_raw_issuer(issuer.as_str().trim_end_matches('/'), client_id)
471 }
472
473 fn signed_test_id_token_with_raw_issuer(issuer: &str, client_id: &str) -> String {
474 let claims = json!({
475 "iss": issuer,
476 "aud": client_id,
477 "sub": "authelia-subject-123",
478 "email": "user@example.com",
479 "email_verified": true,
480 "iat": (unix_now() - 10) as usize,
481 "exp": (unix_now() + 3600) as usize,
482 });
483 let mut header = Header::new(Algorithm::RS256);
484 header.kid = Some("test-kid".to_string());
485 encode(&header, &claims, &test_encoding_key()).unwrap()
486 }
487
488 fn test_jwks() -> serde_json::Value {
489 let key = test_rsa_key();
490 let public_key = key.to_public_key();
491 json!({
492 "keys": [{
493 "kid": "test-kid",
494 "alg": "RS256",
495 "kty": "RSA",
496 "use": "sig",
497 "n": URL_SAFE_NO_PAD.encode(public_key.n_bytes()),
498 "e": URL_SAFE_NO_PAD.encode(public_key.e_bytes()),
499 }]
500 })
501 }
502
503 fn test_rsa_key() -> &'static RsaPrivateKey {
504 static TEST_RSA_KEY: OnceLock<RsaPrivateKey> = OnceLock::new();
505 TEST_RSA_KEY.get_or_init(|| {
506 let mut rng = UnwrapErr(TestRng);
507 RsaPrivateKey::new(&mut rng, 2048).unwrap()
508 })
509 }
510
511 fn test_encoding_key() -> jsonwebtoken::EncodingKey {
512 let pem = test_rsa_key().to_pkcs8_pem(Default::default()).unwrap();
513 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap()
514 }
515
516 fn unix_now() -> i64 {
517 std::time::SystemTime::now()
518 .duration_since(std::time::UNIX_EPOCH)
519 .unwrap()
520 .as_secs() as i64
521 }
522
523 struct TestRng;
524
525 impl TryRng for TestRng {
526 type Error = getrandom::Error;
527
528 fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
529 let mut bytes = [0u8; 4];
530 getrandom::fill(&mut bytes)?;
531 Ok(u32::from_le_bytes(bytes))
532 }
533
534 fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
535 let mut bytes = [0u8; 8];
536 getrandom::fill(&mut bytes)?;
537 Ok(u64::from_le_bytes(bytes))
538 }
539
540 fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
541 getrandom::fill(dst)
542 }
543 }
544
545 impl TryCryptoRng for TestRng {}
546}