Skip to main content

rws_server/sso/
server.rs

1//! `AuthServer` — a minimal OAuth 2.0 Authorization Server / token issuer.
2//!
3//! Lets `rws` be the identity provider for downstream services or
4//! single-page apps, rather than always delegating to an external OIDC
5//! provider (that's [`super::oidc_auth::OidcAuth`]'s job — this is the
6//! reverse role).
7//!
8//! # Usage
9//!
10//! ```rust,no_run
11//! # extern crate rws_server as rust_web_server;
12//! use std::sync::Arc;
13//! use std::time::Duration;
14//! use rust_web_server::app::App;
15//! use rust_web_server::core::New;
16//! use rust_web_server::session::SessionStore;
17//! use rust_web_server::sso::server::{AuthServer, AuthServerConfig};
18//! use rust_web_server::sso::client_store::{ClientStore, OAuthClient, GrantType};
19//!
20//! let auth_server = AuthServer::new(AuthServerConfig {
21//!     issuer:            "https://myapp.com".into(),
22//!     signing_secret:    std::env::var("RWS_AUTH_SIGNING_SECRET").unwrap(),
23//!     access_token_ttl:  Duration::from_secs(3600),
24//!     refresh_token_ttl: Duration::from_secs(86_400 * 30),
25//!     clients: ClientStore::new().add(OAuthClient {
26//!         client_id:     "backend-service".into(),
27//!         client_secret: Some("s3cr3t".into()),
28//!         redirect_uris: vec![],
29//!         grants:        vec![GrantType::ClientCredentials],
30//!         scopes:        vec!["api:read".into()],
31//!     }),
32//!     sessions: Arc::new(SessionStore::new(86_400)),
33//! });
34//!
35//! let app = App::new().wrap(auth_server);
36//! // Registers, and intercepts: POST /oauth/token, GET /oauth/authorize,
37//! // GET /.well-known/openid-configuration, GET /.well-known/jwks.json
38//! ```
39//!
40//! # Deviations from a full OAuth 2.0 Authorization Server
41//!
42//! - **HS256, not RSA/EC.** Tokens are signed with the existing
43//!   [`crate::auth::build_jwt`]/[`crate::auth::verify_jwt`] HS256 machinery
44//!   (`auth` feature) rather than an RSA/EC private key loaded from PEM.
45//!   This crate has no PEM/DER private-key parser; adding one — plus the
46//!   signing-side counterpart to [`super::jwks::JwksCache`]'s
47//!   verification-only RSA/ES code — would be a large addition for a
48//!   feature that already has a fully adequate, already-tested symmetric
49//!   alternative when the issuer and every resource server share one
50//!   secret (the common case for "rws issues its own tokens"). Consequently
51//!   `GET /.well-known/jwks.json` always returns an empty key set — there
52//!   is no public key to publish for a symmetric algorithm. Resource
53//!   servers must be configured with the same `signing_secret` directly
54//!   (e.g. `auth::JwtLayer` or `auth::verify_jwt`).
55//! - **`/oauth/authorize` trusts an existing application session**, not a
56//!   built-in login page. The spec for this feature says "redirect to
57//!   login page or IdP, then back with code" but building a username/
58//!   password login form is out of scope (the same boundary
59//!   [`super::oidc_auth::OidcAuth`] draws around needing an *external* IdP
60//!   to already exist). `AuthServer` instead checks the browser's session
61//!   (via the existing [`crate::session::SessionStore`]) for a configurable
62//!   key (default `"user_id"`, override with
63//!   [`AuthServer::subject_session_key`]) — if present, that value becomes
64//!   the issued token's `sub` claim and a code is minted immediately; if
65//!   absent, the browser is redirected to a configurable `login_url`
66//!   (default `/login`, override with [`AuthServer::login_url`]) with
67//!   `?return_to=<original authorize URL>`. Populating that session is the
68//!   embedding application's own responsibility.
69//! - **No `ClientStore::from_env()`/`::from_db()`.** Client secrets belong
70//!   in code or a real secret store, not flat env vars — and unlike
71//!   [`super::config::OidcConfig`] (one client per process), a `ClientStore`
72//!   is a *list* of clients with no natural flat-env-var encoding. The
73//!   builder (`ClientStore::new().add(...)`) already covers registering
74//!   clients from wherever the caller wants to load them.
75
76use std::collections::HashMap;
77use std::sync::{Arc, Mutex};
78use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
79
80use sha2::{Digest, Sha256};
81
82use crate::application::Application;
83use crate::body::form_urlencoded::FormUrlEncoded;
84use crate::core::New;
85use crate::extract::{FromRequest, Query};
86use crate::header::Header;
87use crate::middleware::Middleware;
88use crate::mime_type::MimeType;
89use crate::range::Range;
90use crate::request::{Request, METHOD};
91use crate::response::{Response, StatusCodeReasonPhrase, STATUS_CODE_REASON_PHRASE};
92use crate::server::ConnectionInfo;
93use crate::session::{session_id_from_request, SessionStore};
94
95use super::client_store::{ClientStore, GrantType};
96use super::pkce::base64url_encode;
97
98const TOKEN_PATH: &str = "/oauth/token";
99const AUTHORIZE_PATH: &str = "/oauth/authorize";
100const DISCOVERY_PATH: &str = "/.well-known/openid-configuration";
101const JWKS_PATH: &str = "/.well-known/jwks.json";
102const SESSION_COOKIE: &str = "_rws_authz_sid";
103const AUTH_CODE_TTL_SECS: u64 = 60;
104
105/// Configuration for [`AuthServer`].
106pub struct AuthServerConfig {
107    /// This server's issuer URL, embedded in every issued token's `iss`
108    /// claim and in the discovery document.
109    pub issuer: String,
110    /// HMAC-SHA256 (HS256) shared secret used to sign every issued JWT.
111    /// See the module-level docs for why this is HS256, not an RSA/EC key.
112    pub signing_secret: String,
113    /// Lifetime of an issued access token.
114    pub access_token_ttl: Duration,
115    /// Lifetime of an issued refresh token.
116    pub refresh_token_ttl: Duration,
117    /// Registered OAuth 2.0 clients.
118    pub clients: ClientStore,
119    /// Session store used to recognize an already-authenticated browser at
120    /// `/oauth/authorize`. See the module-level docs.
121    pub sessions: Arc<SessionStore>,
122}
123
124struct AuthCodeRecord {
125    client_id: String,
126    redirect_uri: String,
127    scope: String,
128    subject: String,
129    code_challenge: Option<String>,
130    expires_at: Instant,
131}
132
133#[derive(Clone)]
134struct RefreshTokenRecord {
135    client_id: String,
136    subject: String,
137    scope: String,
138    expires_at: Instant,
139}
140
141/// A minimal OAuth 2.0 Authorization Server — see the [module docs](self).
142pub struct AuthServer {
143    config: Arc<AuthServerConfig>,
144    login_url: String,
145    subject_session_key: String,
146    codes: Mutex<HashMap<String, AuthCodeRecord>>,
147    refresh_tokens: Mutex<HashMap<String, RefreshTokenRecord>>,
148}
149
150impl AuthServer {
151    /// Create a new `AuthServer`. Defaults `login_url` to `/login` and
152    /// `subject_session_key` to `"user_id"` — override with
153    /// [`AuthServer::login_url`] / [`AuthServer::subject_session_key`].
154    pub fn new(config: AuthServerConfig) -> Self {
155        AuthServer {
156            config: Arc::new(config),
157            login_url: "/login".to_string(),
158            subject_session_key: "user_id".to_string(),
159            codes: Mutex::new(HashMap::new()),
160            refresh_tokens: Mutex::new(HashMap::new()),
161        }
162    }
163
164    /// Override where an unauthenticated browser is sent from
165    /// `/oauth/authorize` (default: `/login`).
166    pub fn login_url(mut self, url: &str) -> Self {
167        self.login_url = url.to_string();
168        self
169    }
170
171    /// Override the session key read to identify the logged-in user at
172    /// `/oauth/authorize` (default: `"user_id"`).
173    pub fn subject_session_key(mut self, key: &str) -> Self {
174        self.subject_session_key = key.to_string();
175        self
176    }
177
178    // ── /oauth/authorize ─────────────────────────────────────────────────────
179
180    fn handle_authorize(&self, request: &Request) -> Response {
181        let query = match Query::from_request(request) {
182            Ok(q) => q,
183            Err(_) => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "malformed query string"),
184        };
185
186        if query.get("response_type").map(String::as_str) != Some("code") {
187            return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "unsupported_response_type", "only response_type=code is supported");
188        }
189        let client_id = match query.get("client_id") {
190            Some(c) => c.clone(),
191            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "missing client_id"),
192        };
193        let redirect_uri = match query.get("redirect_uri") {
194            Some(r) => r.clone(),
195            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "missing redirect_uri"),
196        };
197        let client = match self.config.clients.get(&client_id) {
198            Some(c) => c,
199            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_client", "unknown client_id"),
200        };
201        if !client.grants.contains(&GrantType::AuthorizationCode) {
202            return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "unauthorized_client", "client is not authorized for the authorization_code grant");
203        }
204        if !client.redirect_uris.iter().any(|u| u == &redirect_uri) {
205            return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "redirect_uri is not registered for this client");
206        }
207        let code_challenge = query.get("code_challenge").cloned();
208        if let Some(method) = query.get("code_challenge_method") {
209            if method != "S256" {
210                return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "only code_challenge_method=S256 is supported");
211            }
212        }
213        let scope = query.get("scope").cloned().unwrap_or_default();
214        let state = query.get("state").cloned();
215
216        let subject = session_id_from_request(request, SESSION_COOKIE)
217            .and_then(|sid| self.config.sessions.load(&sid))
218            .and_then(|s| s.get(&self.subject_session_key).map(|v| v.to_string()));
219
220        let subject = match subject {
221            Some(s) => s,
222            None => {
223                let return_to = super::client::url_encode(&request.request_uri);
224                return Self::redirect(&format!("{}?return_to={}", self.login_url, return_to));
225            }
226        };
227
228        let code = Self::random_token();
229        self.codes.lock().unwrap().insert(
230            code.clone(),
231            AuthCodeRecord {
232                client_id,
233                redirect_uri: redirect_uri.clone(),
234                scope,
235                subject,
236                code_challenge,
237                expires_at: Instant::now() + Duration::from_secs(AUTH_CODE_TTL_SECS),
238            },
239        );
240
241        let mut location = format!("{redirect_uri}?code={code}");
242        if let Some(s) = state {
243            location.push_str(&format!("&state={}", super::client::url_encode(&s)));
244        }
245        Self::redirect(&location)
246    }
247
248    // ── /oauth/token ─────────────────────────────────────────────────────────
249
250    fn handle_token(&self, request: &Request) -> Response {
251        let form = match FormUrlEncoded::parse(request.body.to_vec()) {
252            Ok(f) => f,
253            Err(_) => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "malformed request body"),
254        };
255        match form.get("grant_type").map(String::as_str) {
256            Some("client_credentials") => self.handle_client_credentials(&form),
257            Some("authorization_code") => self.handle_authorization_code(&form),
258            Some("refresh_token") => self.handle_refresh_token(&form),
259            _ => Self::oauth_error(
260                STATUS_CODE_REASON_PHRASE.n400_bad_request,
261                "unsupported_grant_type",
262                "grant_type must be client_credentials, authorization_code, or refresh_token",
263            ),
264        }
265    }
266
267    fn handle_client_credentials(&self, form: &HashMap<String, String>) -> Response {
268        let client_id = form.get("client_id").cloned().unwrap_or_default();
269        let client_secret = form.get("client_secret").cloned().unwrap_or_default();
270        let client = match self.config.clients.get(&client_id) {
271            Some(c) => c,
272            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "invalid_client", "unknown client_id"),
273        };
274        if client.client_secret.as_deref() != Some(client_secret.as_str()) {
275            return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "invalid_client", "client authentication failed");
276        }
277        if !client.grants.contains(&GrantType::ClientCredentials) {
278            return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "unauthorized_client", "client is not authorized for the client_credentials grant");
279        }
280        let scope = match Self::negotiate_scope(form.get("scope").map(String::as_str), &client.scopes) {
281            Some(s) => s,
282            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_scope", "requested scope exceeds client's authorized scopes"),
283        };
284
285        let access_token = self.issue_jwt(&client_id, &client_id, &scope);
286        Self::token_response(&access_token, self.config.access_token_ttl, None, None)
287    }
288
289    fn handle_authorization_code(&self, form: &HashMap<String, String>) -> Response {
290        let code = match form.get("code") {
291            Some(c) => c.clone(),
292            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "missing code"),
293        };
294        let redirect_uri = form.get("redirect_uri").cloned().unwrap_or_default();
295        let client_id = form.get("client_id").cloned().unwrap_or_default();
296
297        let record = self.codes.lock().unwrap().remove(&code);
298        let record = match record {
299            Some(r) if r.expires_at > Instant::now() => r,
300            _ => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_grant", "code is invalid, expired, or already used"),
301        };
302        if record.client_id != client_id || record.redirect_uri != redirect_uri {
303            return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_grant", "client_id or redirect_uri does not match the original request");
304        }
305        let client = match self.config.clients.get(&client_id) {
306            Some(c) => c,
307            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_client", "unknown client_id"),
308        };
309        if let Some(secret) = &client.client_secret {
310            if form.get("client_secret").map(String::as_str) != Some(secret.as_str()) {
311                return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "invalid_client", "client authentication failed");
312            }
313        }
314        if let Some(challenge) = &record.code_challenge {
315            let verifier = form.get("code_verifier").map(String::as_str).unwrap_or("");
316            let computed = base64url_encode(&Sha256::digest(verifier.as_bytes()));
317            if &computed != challenge {
318                return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_grant", "code_verifier does not match code_challenge");
319            }
320        }
321
322        // The id_token and access_token describe the same authenticated
323        // subject with no meaningfully different claim set here, so both
324        // fields carry the same signed JWT.
325        let token = self.issue_jwt(&record.subject, &client_id, &record.scope);
326        let refresh_token = Self::random_token();
327        self.refresh_tokens.lock().unwrap().insert(
328            refresh_token.clone(),
329            RefreshTokenRecord {
330                client_id,
331                subject: record.subject,
332                scope: record.scope,
333                expires_at: Instant::now() + self.config.refresh_token_ttl,
334            },
335        );
336        Self::token_response(&token, self.config.access_token_ttl, Some(&refresh_token), Some(&token))
337    }
338
339    fn handle_refresh_token(&self, form: &HashMap<String, String>) -> Response {
340        let token = match form.get("refresh_token") {
341            Some(t) => t.clone(),
342            None => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_request", "missing refresh_token"),
343        };
344        let record = self.refresh_tokens.lock().unwrap().get(&token).cloned();
345        let record = match record {
346            Some(r) if r.expires_at > Instant::now() => r,
347            _ => return Self::oauth_error(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid_grant", "refresh_token is invalid or expired"),
348        };
349        let access_token = self.issue_jwt(&record.subject, &record.client_id, &record.scope);
350        Self::token_response(&access_token, self.config.access_token_ttl, None, None)
351    }
352
353    fn negotiate_scope(requested: Option<&str>, allowed: &[String]) -> Option<String> {
354        match requested {
355            None => Some(allowed.join(" ")),
356            Some(r) if r.is_empty() => Some(allowed.join(" ")),
357            Some(r) => {
358                let tokens: Vec<&str> = r.split_whitespace().collect();
359                if tokens.iter().all(|t| allowed.iter().any(|a| a == t)) {
360                    Some(tokens.join(" "))
361                } else {
362                    None
363                }
364            }
365        }
366    }
367
368    fn issue_jwt(&self, sub: &str, aud: &str, scope: &str) -> String {
369        let now = unix_now();
370        let exp = now + self.config.access_token_ttl.as_secs();
371        let claims = format!(
372            r#"{{"sub":"{}","iss":"{}","aud":"{}","exp":{},"iat":{},"scope":"{}"}}"#,
373            json_escape(sub),
374            json_escape(&self.config.issuer),
375            json_escape(aud),
376            exp,
377            now,
378            json_escape(scope),
379        );
380        crate::auth::build_jwt(&claims, self.config.signing_secret.as_bytes())
381    }
382
383    // ── discovery / jwks ─────────────────────────────────────────────────────
384
385    fn discovery_document(&self) -> Response {
386        let issuer = &self.config.issuer;
387        let body = format!(
388            r#"{{"issuer":"{issuer}","authorization_endpoint":"{issuer}{AUTHORIZE_PATH}","token_endpoint":"{issuer}{TOKEN_PATH}","jwks_uri":"{issuer}{JWKS_PATH}","response_types_supported":["code"],"grant_types_supported":["authorization_code","client_credentials","refresh_token"]}}"#
389        );
390        Self::json_response(STATUS_CODE_REASON_PHRASE.n200_ok, &body)
391    }
392
393    fn jwks_document(&self) -> Response {
394        // HS256 is symmetric — there is no public key to publish. See the
395        // module-level docs.
396        Self::json_response(STATUS_CODE_REASON_PHRASE.n200_ok, r#"{"keys":[]}"#)
397    }
398
399    // ── response helpers ─────────────────────────────────────────────────────
400
401    fn random_token() -> String {
402        use rand_core::{OsRng, RngCore};
403        let mut bytes = [0u8; 24];
404        OsRng.fill_bytes(&mut bytes);
405        bytes.iter().map(|b| format!("{:02x}", b)).collect()
406    }
407
408    fn redirect(url: &str) -> Response {
409        let mut r = Response::new();
410        r.status_code = *STATUS_CODE_REASON_PHRASE.n302_found.status_code;
411        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase.to_string();
412        r.headers.push(Header { name: "Location".into(), value: url.to_string().into() });
413        r
414    }
415
416    fn json_response(status: &'static StatusCodeReasonPhrase, body: &str) -> Response {
417        let mut r = Response::new();
418        r.status_code = *status.status_code;
419        r.reason_phrase = status.reason_phrase.to_string();
420        r.content_range_list = vec![Range::get_content_range(body.as_bytes().to_vec(), MimeType::APPLICATION_JSON.to_string())];
421        r
422    }
423
424    fn oauth_error(status: &'static StatusCodeReasonPhrase, error: &str, description: &str) -> Response {
425        let body = format!(r#"{{"error":"{error}","error_description":"{}"}}"#, json_escape(description));
426        Self::json_response(status, &body)
427    }
428
429    fn token_response(access_token: &str, ttl: Duration, refresh_token: Option<&str>, id_token: Option<&str>) -> Response {
430        let mut body = format!(
431            r#"{{"access_token":"{}","token_type":"Bearer","expires_in":{}"#,
432            access_token,
433            ttl.as_secs(),
434        );
435        if let Some(rt) = refresh_token {
436            body.push_str(&format!(r#","refresh_token":"{rt}""#));
437        }
438        if let Some(idt) = id_token {
439            body.push_str(&format!(r#","id_token":"{idt}""#));
440        }
441        body.push('}');
442        Self::json_response(STATUS_CODE_REASON_PHRASE.n200_ok, &body)
443    }
444}
445
446impl Middleware for AuthServer {
447    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
448        let path = request.request_uri.split('?').next().unwrap_or("");
449        let is_get = request.method.eq_ignore_ascii_case(METHOD.get);
450        let is_post = request.method.eq_ignore_ascii_case(METHOD.post);
451
452        if path == AUTHORIZE_PATH && is_get {
453            return Ok(self.handle_authorize(request));
454        }
455        if path == TOKEN_PATH && is_post {
456            return Ok(self.handle_token(request));
457        }
458        if path == DISCOVERY_PATH && is_get {
459            return Ok(self.discovery_document());
460        }
461        if path == JWKS_PATH && is_get {
462            return Ok(self.jwks_document());
463        }
464
465        next.execute(request, connection)
466    }
467}
468
469fn unix_now() -> u64 {
470    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
471}
472
473fn json_escape(s: &str) -> String {
474    crate::core::json::escape(s)
475}