Skip to main content

max / makenotwork

7.0 KB · 195 lines History Blame Raw
1 //! Delegated login: "Sign in with Makenotwork" (OAuth client side).
2 //!
3 //! Used on the testnot.work staging mirror. Instead of a local password form,
4 //! the login page redirects to an upstream MNW provider (production), where the
5 //! user authenticates, so a password is only ever entered on the real site.
6 //! On callback we exchange the code for the provider's response, take the
7 //! verified `user_id`, look that user up in our own (mirrored) DB, and start a
8 //! local session. The provider's OAuth flow is the SyncKit one
9 //! (`src/routes/oauth.rs`); we discard its sync token and use only `user_id`.
10 //!
11 //! Active only when `[sso]` is configured (the three `SSO_*` vars). Routes are
12 //! allowlisted in the access gate so an unauthenticated visitor can reach them.
13
14 use axum::{
15 Router,
16 extract::{Query, State},
17 http::HeaderMap,
18 response::{IntoResponse, Redirect, Response},
19 routing::get,
20 };
21 use base64::Engine;
22 use rand::Rng;
23 use serde::Deserialize;
24 use sha2::{Digest, Sha256};
25 use tower_sessions::Session;
26
27 use crate::{
28 AppState,
29 auth::{SessionUser, login_user, track_session},
30 config::Config,
31 db::{self, UserId},
32 error::{AppError, Result},
33 };
34 use sqlx::PgPool;
35
36 const SSO_STATE_KEY: &str = "sso_state";
37 const SSO_VERIFIER_KEY: &str = "sso_pkce_verifier";
38
39 fn b64url(data: &[u8]) -> String {
40 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
41 }
42
43 /// GET /sso/login, begin the delegated-login flow.
44 ///
45 /// Generates PKCE + state, stashes them in the session, and redirects to the
46 /// provider's authorize endpoint. No-op (404-ish redirect home) when SSO is off.
47 #[tracing::instrument(skip_all, name = "sso::login")]
48 async fn sso_login(State(config): State<Config>, session: Session) -> Result<Response> {
49 let Some(sso) = config.sso.as_ref() else {
50 // SSO not configured, nothing to delegate to.
51 return Ok(Redirect::to("/login").into_response());
52 };
53
54 // PKCE verifier (43-char base64url of 32 random bytes) + S256 challenge.
55 let mut vbytes = [0u8; 32];
56 rand::rng().fill_bytes(&mut vbytes);
57 let verifier = b64url(&vbytes);
58 let challenge = b64url(Sha256::digest(verifier.as_bytes()).as_ref());
59
60 // CSRF-style state to bind the callback to this session.
61 let mut sbytes = [0u8; 16];
62 rand::rng().fill_bytes(&mut sbytes);
63 let state_param = b64url(&sbytes);
64
65 session
66 .insert(SSO_STATE_KEY, &state_param)
67 .await
68 .map_err(|e| AppError::Internal(e.into()))?;
69 session
70 .insert(SSO_VERIFIER_KEY, &verifier)
71 .await
72 .map_err(|e| AppError::Internal(e.into()))?;
73
74 let redirect_uri = format!("{}/sso/callback", config.host_url);
75 let authorize = format!(
76 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
77 sso.provider_url,
78 urlencoding::encode(&sso.client_id),
79 urlencoding::encode(&redirect_uri),
80 urlencoding::encode(&state_param),
81 urlencoding::encode(&challenge),
82 );
83 Ok(Redirect::to(&authorize).into_response())
84 }
85
86 #[derive(Deserialize)]
87 struct CallbackQuery {
88 code: Option<String>,
89 state: Option<String>,
90 error: Option<String>,
91 }
92
93 /// Minimal view of the provider's token response, we only need the user id.
94 #[derive(Deserialize)]
95 struct TokenResponse {
96 user_id: UserId,
97 }
98
99 /// GET /sso/callback, provider redirected back with `code` + `state`.
100 #[tracing::instrument(skip_all, name = "sso::callback")]
101 async fn sso_callback(
102 State(db): State<PgPool>,
103 State(config): State<Config>,
104 session: Session,
105 headers: HeaderMap,
106 Query(q): Query<CallbackQuery>,
107 ) -> Result<Response> {
108 let Some(sso) = config.sso.as_ref() else {
109 return Ok(Redirect::to("/login").into_response());
110 };
111
112 let fail = |msg: &str| {
113 Ok(Redirect::to(&format!("/login?sso_error={}", urlencoding::encode(msg))).into_response())
114 };
115
116 if let Some(err) = q.error.as_deref() {
117 tracing::warn!(error = %err, "sso provider returned error");
118 return fail("Sign-in was cancelled or denied.");
119 }
120 let (Some(code), Some(returned_state)) = (q.code.as_deref(), q.state.as_deref()) else {
121 return fail("Sign-in response was incomplete. Please try again.");
122 };
123
124 // Validate state against the session, and consume the one-shot PKCE values.
125 let expected_state: Option<String> = session.get(SSO_STATE_KEY).await.ok().flatten();
126 let verifier: Option<String> = session.get(SSO_VERIFIER_KEY).await.ok().flatten();
127 let _ = session.remove::<String>(SSO_STATE_KEY).await;
128 let _ = session.remove::<String>(SSO_VERIFIER_KEY).await;
129
130 let (Some(expected_state), Some(verifier)) = (expected_state, verifier) else {
131 return fail("Your sign-in session expired. Please try again.");
132 };
133 if !crate::helpers::constant_time_compare(&expected_state, returned_state) {
134 tracing::warn!("sso state mismatch");
135 return fail("Sign-in could not be verified. Please try again.");
136 }
137
138 // Exchange the code at the provider's token endpoint.
139 let redirect_uri = format!("{}/sso/callback", config.host_url);
140 let resp = crate::helpers::HTTP_CLIENT
141 .post(format!("{}/oauth/token", sso.provider_url))
142 .timeout(std::time::Duration::from_secs(10))
143 .form(&[
144 ("grant_type", "authorization_code"),
145 ("code", code),
146 ("redirect_uri", &redirect_uri),
147 ("code_verifier", &verifier),
148 ("client_id", &sso.client_id),
149 ("key", &sso.key),
150 ])
151 .send()
152 .await;
153
154 let resp = match resp {
155 Ok(r) if r.status().is_success() => r,
156 Ok(r) => {
157 tracing::warn!(status = %r.status(), "sso token exchange rejected");
158 return fail("Sign-in failed at the provider. Please try again.");
159 }
160 Err(e) => {
161 tracing::warn!(error = ?e, "sso token exchange request failed");
162 return fail("Could not reach the sign-in provider. Please try again.");
163 }
164 };
165
166 let token: TokenResponse = match resp.json().await {
167 Ok(t) => t,
168 Err(e) => {
169 tracing::warn!(error = ?e, "sso token response parse failed");
170 return fail("Sign-in failed at the provider. Please try again.");
171 }
172 };
173
174 // Map the verified provider user id onto our mirrored account.
175 let Some(db_user) = db::users::get_user_by_id(&db, token.user_id).await? else {
176 return fail("This account can't sign in here.");
177 };
178 if db_user.is_suspended() || db_user.is_deactivated() {
179 return fail("This account is not active.");
180 }
181
182 let user_id = db_user.id;
183 let session_user = SessionUser::from_db_user(db_user, &db, config.admin_user_id).await;
184 login_user(&session, session_user).await?;
185 track_session(&session, &db, user_id, &headers).await?;
186
187 Ok(Redirect::to("/").into_response())
188 }
189
190 pub fn sso_routes() -> Router<AppState> {
191 Router::new()
192 .route("/sso/login", get(sso_login))
193 .route("/sso/callback", get(sso_callback))
194 }
195