Skip to main content

soma_auth/
authelia.rs

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);
12/// Authelia's OpenID Connect 1.0 Provider mounts its endpoints at these
13/// fixed paths off the issuer. The JWKS document is served at `/jwks.json`,
14/// matching the provider's `jwks_uri` discovery metadata.
15const 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        // RFC 3986 relative-URL resolution: `Url::join` replaces the LAST
58        // path segment of a URL that doesn't end in `/`, not just appends.
59        // For a bare-origin issuer (`https://auth.example.com`) that's a
60        // no-op, but for a path-prefixed issuer without a trailing slash
61        // (`https://example.com/authelia`) it silently drops `/authelia`,
62        // producing `https://example.com/api/oidc/token` instead of
63        // `https://example.com/authelia/api/oidc/token` — wrong URL, no
64        // error. Guarantee a trailing slash before joining so `.join(...)`
65        // always appends under the full issuer path.
66        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    /// Test-only accessor proving `AutheliaProvider::new`'s endpoint
131    /// construction resolves correctly against path-prefixed issuers — see
132    /// `authelia_token_endpoint_preserves_issuer_path_prefix_without_trailing_slash`.
133    #[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    /// Regression test: a path-prefixed issuer WITHOUT a trailing slash must
227    /// not lose its path prefix during endpoint construction. RFC 3986
228    /// relative-URL resolution replaces the last path segment of a URL that
229    /// doesn't end in `/`, so `Url::join` against a bare
230    /// `https://example.com/authelia` issuer would silently produce
231    /// `https://example.com/api/oidc/token` instead of
232    /// `https://example.com/authelia/api/oidc/token`.
233    #[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    /// Authelia advertises its signing keys at `<issuer>/jwks.json`. Keep the
249    /// provider's default endpoint aligned with the discovery document so a
250    /// normal, non-test provider can verify the ID token returned by the
251    /// token endpoint.
252    #[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    /// End-to-end coverage for `AutheliaProvider::refresh` — new token
319    /// exchange, `refresh_token`/`expires_in` propagation, and
320    /// re-verification of a fresh `id_token` via `OidcVerifier`. Google has
321    /// refresh coverage via `test_auth_state_with_mock_google`; GitHub's
322    /// refresh always errors by design (tested); Authelia's refresh path had
323    /// no test driving it end to end before this.
324    #[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    /// Authelia's OIDC provider defaults confidential clients to
370    /// `client_secret_basic` unless the operator explicitly opts a client
371    /// into `client_secret_post`. This mock only matches a token request
372    /// that authenticates via the `Authorization: Basic` header (not a
373    /// `client_secret` form field) — if `AutheliaProvider` ever regresses to
374    /// posting the secret in the body instead, the request matches no mock,
375    /// wiremock returns its default 404, and the exchange fails.
376    #[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    /// Negative-path coverage that only Authelia can exercise: unlike
412    /// Google's hardcoded issuer constant, Authelia's issuer is
413    /// operator-configured and `.trim_end_matches('/')`-normalized, so a
414    /// bug in that normalization or in `OidcVerifier::verify`'s issuer
415    /// comparison would not be caught by any of Google's negative tests.
416    /// Mirrors `google_exchange_rejects_wrong_issuer_in_id_token`.
417    #[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}