Skip to main content

max / makenotwork

server: delegated login ("Sign in with Makenot.work") for testnot Add an OAuth client (SSO_* config) so the testnot mirror's login page becomes a single "Sign in with Makenot.work" button: it redirects to the provider's /oauth/authorize (PKCE), the user authenticates THERE, and the callback exchanges the code, takes the verified user_id, and starts a local session from the mirrored account. A password is only ever entered on production. - config: SsoConfig (SSO_PROVIDER_URL/CLIENT_ID/KEY); None = local password form - routes/sso.rs: /sso/login (start) + /sso/callback (exchange + session) - login page renders the SSO button when configured, hides the password form - access gate allowlists /sso; 3 tests (button shown, authorize redirect, default form) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-07 22:44 UTC
Commit: 27e2541429c937fe9244c7ec6f46327b5a6276dc
Parent: a0b0f1d
14 files changed, +324 insertions, -5 deletions
@@ -43,6 +43,7 @@ fn path_is_exempt(path: &str) -> bool {
43 43 || hit(path, "/logout")
44 44 || hit(path, "/auth") // /auth/me, /auth/2fa, /auth/passkey/*
45 45 || hit(path, "/oauth") // MNW-as-OAuth-provider authorize/token/userinfo
46 + || hit(path, "/sso") // delegated "Sign in with Makenot.work" start + callback
46 47 // Static assets + browser chrome (the login page pulls CSS/JS/images).
47 48 || hit(path, "/static")
48 49 || hit(path, "/rustdoc")
@@ -105,6 +106,8 @@ mod tests {
105 106 "/auth/me",
106 107 "/auth/passkey/start",
107 108 "/oauth/authorize",
109 + "/sso/login",
110 + "/sso/callback",
108 111 "/static/style.css",
109 112 "/static/images/favicon.ico",
110 113 "/rustdoc/index.html",
@@ -677,6 +677,7 @@ mod tests {
677 677 cli_service_token: None,
678 678 wam_url: None,
679 679 access_gate: crate::config::AccessGate::Open,
680 + sso: None,
680 681 };
681 682 assert!(require_admin(&user, &config).is_ok());
682 683 }
@@ -745,6 +746,7 @@ mod tests {
745 746 cli_service_token: None,
746 747 wam_url: None,
747 748 access_gate: crate::config::AccessGate::Open,
749 + sso: None,
748 750 };
749 751 assert!(require_admin(&user, &config).is_err());
750 752 }
@@ -95,6 +95,42 @@ pub struct Config {
95 95 /// testnot.work staging mirror so it's reachable only by Fan+/creator
96 96 /// accounts. Off in production.
97 97 pub access_gate: AccessGate,
98 + /// Upstream SSO provider for "Sign in with Makenot.work" (optional). When
99 + /// set, the login page becomes a single button that authenticates against
100 + /// `provider_url`'s OAuth endpoints instead of a local password form — used
101 + /// on the testnot mirror so a password is only ever entered on production.
102 + pub sso: Option<SsoConfig>,
103 + }
104 +
105 + /// Upstream OAuth provider config for delegated login (`SSO_*`).
106 + #[derive(Clone)]
107 + pub struct SsoConfig {
108 + /// Base URL of the OAuth provider, e.g. `https://makenot.work` (no trailing slash).
109 + pub provider_url: String,
110 + /// `client_id` = the provider's registered `sync_apps.api_key` (raw key).
111 + pub client_id: String,
112 + /// SyncKit SDK key string sent on token exchange. Any non-empty string the
113 + /// provider's `validate_synckit_key` accepts; identifies no billing slot
114 + /// here — we discard the sync token and use only the returned `user_id`.
115 + pub key: String,
116 + }
117 +
118 + impl SsoConfig {
119 + /// Present only when all three `SSO_*` vars are set; otherwise `None`
120 + /// (login falls back to the local password form).
121 + pub fn from_env() -> Option<Self> {
122 + let provider_url = std::env::var("SSO_PROVIDER_URL").ok()?;
123 + let client_id = std::env::var("SSO_CLIENT_ID").ok()?;
124 + let key = std::env::var("SSO_KEY").ok()?;
125 + if provider_url.is_empty() || client_id.is_empty() || key.is_empty() {
126 + return None;
127 + }
128 + Some(Self {
129 + provider_url: provider_url.trim_end_matches('/').to_string(),
130 + client_id,
131 + key,
132 + })
133 + }
98 134 }
99 135
100 136 /// Site-wide access-gate mode (`ACCESS_GATE`).
@@ -301,6 +337,8 @@ impl Config {
301 337 _ => AccessGate::Open,
302 338 };
303 339
340 + let sso = SsoConfig::from_env();
341 +
304 342 Ok(Config {
305 343 host,
306 344 port,
@@ -333,6 +371,7 @@ impl Config {
333 371 cli_service_token,
334 372 wam_url,
335 373 access_gate,
374 + sso,
336 375 })
337 376 }
338 377
@@ -504,6 +543,7 @@ impl std::fmt::Debug for Config {
504 543 .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]"))
505 544 .field("wam_url", &self.wam_url)
506 545 .field("access_gate", &self.access_gate)
546 + .field("sso", &self.sso.as_ref().map(|s| &s.provider_url))
507 547 .finish()
508 548 }
509 549 }
@@ -578,6 +618,7 @@ mod tests {
578 618 "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN",
579 619 "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN",
580 620 "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", "WAM_URL", "ACCESS_GATE",
621 + "SSO_PROVIDER_URL", "SSO_CLIENT_ID", "SSO_KEY",
581 622 ];
582 623
583 624 /// RAII guard that snapshots config-related env vars on creation and restores
@@ -654,6 +695,7 @@ mod tests {
654 695 cli_service_token: None,
655 696 wam_url: None,
656 697 access_gate: AccessGate::Open,
698 + sso: None,
657 699 };
658 700 let addr = config.socket_addr();
659 701 assert_eq!(addr.port(), 8080);
@@ -63,7 +63,7 @@ use email::EmailClient;
63 63 use payments::PaymentProvider;
64 64 use routes::{
65 65 admin_routes, api_routes, auth_routes, build_routes, git_routes, git_issue_routes,
66 - oauth_routes, ota_routes, page_routes, postmark_routes, storage_routes, stripe_routes,
66 + oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes, storage_routes, stripe_routes,
67 67 synckit_routes,
68 68 };
69 69 use scanning::ScanPipeline;
@@ -167,6 +167,7 @@ pub fn build_app(
167 167 .finalize();
168 168 let mut app = Router::new()
169 169 .merge(page_routes())
170 + .merge(sso_routes())
170 171 .merge(csrf_routes)
171 172 .merge(git_routes())
172 173 .merge(routes::embed::embed_routes())
@@ -105,6 +105,7 @@ async fn login_handler(
105 105 crate::helpers::get_csrf_token(&session).await
106 106 };
107 107
108 + let sso_enabled = state.config.sso.is_some();
108 109 let return_error = |msg: &str| -> Result<Response> {
109 110 if is_htmx {
110 111 Ok(Html(LoginErrorTemplate {
@@ -119,6 +120,7 @@ async fn login_handler(
119 120 prefill_login: submitted_login.clone(),
120 121 error: Some(msg.to_string()),
121 122 notice: None,
123 + sso_enabled,
122 124 }.into_response())
123 125 }
124 126 };
@@ -11,6 +11,7 @@ pub mod storage;
11 11 pub mod stripe;
12 12 pub mod synckit;
13 13 pub mod oauth;
14 + pub mod sso;
14 15 pub mod postmark;
15 16 pub mod ota;
16 17 pub mod builds;
@@ -27,5 +28,6 @@ pub use storage::storage_routes;
27 28 pub use stripe::stripe_routes;
28 29 pub use synckit::synckit_routes;
29 30 pub use oauth::oauth_routes;
31 + pub use sso::sso_routes;
30 32 pub use ota::ota_routes;
31 33 pub use builds::build_routes;
@@ -394,14 +394,18 @@ pub(crate) struct LoginQuery {
394 394 /// Set by the site access gate (`?gate=fan_plus_or_creator`) to explain why
395 395 /// the visitor landed on login instead of the page they requested.
396 396 pub gate: Option<String>,
397 + /// Set by the SSO callback when delegated login fails; shown as an error.
398 + pub sso_error: Option<String>,
397 399 }
398 400
399 401 /// Render the login page.
400 402 #[tracing::instrument(skip_all, name = "landing::login_page")]
401 403 pub(crate) async fn login_page(
404 + State(state): State<AppState>,
402 405 session: Session,
403 406 Query(query): Query<LoginQuery>,
404 407 ) -> impl IntoResponse {
408 + let sso_enabled = state.config.sso.is_some();
405 409 let notice = match query.gate.as_deref() {
406 410 Some("fan_plus_or_creator") => Some(
407 411 "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
@@ -412,8 +416,9 @@ pub(crate) async fn login_page(
412 416 LoginTemplate {
413 417 csrf_token: get_csrf_token(&session).await,
414 418 prefill_login: String::new(),
415 - error: None,
419 + error: query.sso_error,
416 420 notice,
421 + sso_enabled,
417 422 }
418 423 }
419 424
@@ -0,0 +1,184 @@
1 + //! Delegated login: "Sign in with Makenot.work" (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 + extract::{Query, State},
16 + http::HeaderMap,
17 + response::{IntoResponse, Redirect, Response},
18 + routing::get,
19 + Router,
20 + };
21 + use base64::Engine;
22 + use rand::RngCore;
23 + use serde::Deserialize;
24 + use sha2::{Digest, Sha256};
25 + use tower_sessions::Session;
26 +
27 + use crate::{
28 + auth::{login_user, track_session, SessionUser},
29 + db::{self, UserId},
30 + error::{AppError, Result},
31 + AppState,
32 + };
33 +
34 + const SSO_STATE_KEY: &str = "sso_state";
35 + const SSO_VERIFIER_KEY: &str = "sso_pkce_verifier";
36 +
37 + fn b64url(data: &[u8]) -> String {
38 + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
39 + }
40 +
41 + /// GET /sso/login — begin the delegated-login flow.
42 + ///
43 + /// Generates PKCE + state, stashes them in the session, and redirects to the
44 + /// provider's authorize endpoint. No-op (404-ish redirect home) when SSO is off.
45 + #[tracing::instrument(skip_all, name = "sso::login")]
46 + async fn sso_login(State(state): State<AppState>, session: Session) -> Result<Response> {
47 + let Some(sso) = state.config.sso.as_ref() else {
48 + // SSO not configured — nothing to delegate to.
49 + return Ok(Redirect::to("/login").into_response());
50 + };
51 +
52 + // PKCE verifier (43-char base64url of 32 random bytes) + S256 challenge.
53 + let mut vbytes = [0u8; 32];
54 + rand::rng().fill_bytes(&mut vbytes);
55 + let verifier = b64url(&vbytes);
56 + let challenge = b64url(Sha256::digest(verifier.as_bytes()).as_ref());
57 +
58 + // CSRF-style state to bind the callback to this session.
59 + let mut sbytes = [0u8; 16];
60 + rand::rng().fill_bytes(&mut sbytes);
61 + let state_param = b64url(&sbytes);
62 +
63 + session.insert(SSO_STATE_KEY, &state_param).await.map_err(|e| AppError::Internal(e.into()))?;
64 + session.insert(SSO_VERIFIER_KEY, &verifier).await.map_err(|e| AppError::Internal(e.into()))?;
65 +
66 + let redirect_uri = format!("{}/sso/callback", state.config.host_url);
67 + let authorize = format!(
68 + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
69 + sso.provider_url,
70 + urlencoding::encode(&sso.client_id),
71 + urlencoding::encode(&redirect_uri),
72 + urlencoding::encode(&state_param),
73 + urlencoding::encode(&challenge),
74 + );
75 + Ok(Redirect::to(&authorize).into_response())
76 + }
77 +
78 + #[derive(Deserialize)]
79 + struct CallbackQuery {
80 + code: Option<String>,
81 + state: Option<String>,
82 + error: Option<String>,
83 + }
84 +
85 + /// Minimal view of the provider's token response — we only need the user id.
86 + #[derive(Deserialize)]
87 + struct TokenResponse {
88 + user_id: UserId,
89 + }
90 +
91 + /// GET /sso/callback — provider redirected back with `code` + `state`.
92 + #[tracing::instrument(skip_all, name = "sso::callback")]
93 + async fn sso_callback(
94 + State(state): State<AppState>,
95 + session: Session,
96 + headers: HeaderMap,
97 + Query(q): Query<CallbackQuery>,
98 + ) -> Result<Response> {
99 + let Some(sso) = state.config.sso.as_ref() else {
100 + return Ok(Redirect::to("/login").into_response());
101 + };
102 +
103 + let fail = |msg: &str| Ok(Redirect::to(&format!("/login?sso_error={}", urlencoding::encode(msg))).into_response());
104 +
105 + if let Some(err) = q.error.as_deref() {
106 + tracing::warn!(error = %err, "sso provider returned error");
107 + return fail("Sign-in was cancelled or denied.");
108 + }
109 + let (Some(code), Some(returned_state)) = (q.code.as_deref(), q.state.as_deref()) else {
110 + return fail("Sign-in response was incomplete. Please try again.");
111 + };
112 +
113 + // Validate state against the session, and consume the one-shot PKCE values.
114 + let expected_state: Option<String> = session.get(SSO_STATE_KEY).await.ok().flatten();
115 + let verifier: Option<String> = session.get(SSO_VERIFIER_KEY).await.ok().flatten();
116 + let _ = session.remove::<String>(SSO_STATE_KEY).await;
117 + let _ = session.remove::<String>(SSO_VERIFIER_KEY).await;
118 +
119 + let (Some(expected_state), Some(verifier)) = (expected_state, verifier) else {
120 + return fail("Your sign-in session expired. Please try again.");
121 + };
122 + if !crate::helpers::constant_time_compare(&expected_state, returned_state) {
123 + tracing::warn!("sso state mismatch");
124 + return fail("Sign-in could not be verified. Please try again.");
125 + }
126 +
127 + // Exchange the code at the provider's token endpoint.
128 + let redirect_uri = format!("{}/sso/callback", state.config.host_url);
129 + let resp = reqwest::Client::new()
130 + .post(format!("{}/oauth/token", sso.provider_url))
131 + .timeout(std::time::Duration::from_secs(10))
132 + .form(&[
133 + ("grant_type", "authorization_code"),
134 + ("code", code),
135 + ("redirect_uri", &redirect_uri),
136 + ("code_verifier", &verifier),
137 + ("client_id", &sso.client_id),
138 + ("key", &sso.key),
139 + ])
140 + .send()
141 + .await;
142 +
143 + let resp = match resp {
144 + Ok(r) if r.status().is_success() => r,
145 + Ok(r) => {
146 + tracing::warn!(status = %r.status(), "sso token exchange rejected");
147 + return fail("Sign-in failed at the provider. Please try again.");
148 + }
149 + Err(e) => {
150 + tracing::warn!(error = ?e, "sso token exchange request failed");
151 + return fail("Could not reach the sign-in provider. Please try again.");
152 + }
153 + };
154 +
155 + let token: TokenResponse = match resp.json().await {
156 + Ok(t) => t,
157 + Err(e) => {
158 + tracing::warn!(error = ?e, "sso token response parse failed");
159 + return fail("Sign-in failed at the provider. Please try again.");
160 + }
161 + };
162 +
163 + // Map the verified provider user id onto our mirrored account.
164 + let db_user = match db::users::get_user_by_id(&state.db, token.user_id).await? {
165 + Some(u) => u,
166 + None => return fail("Your account isn't in the preview yet — it syncs from production daily."),
167 + };
168 + if db_user.is_suspended() || db_user.is_deactivated() {
169 + return fail("This account is not active.");
170 + }
171 +
172 + let user_id = db_user.id;
173 + let session_user = SessionUser::from_db_user(db_user, &state.db, state.config.admin_user_id).await;
174 + login_user(&session, session_user).await?;
175 + track_session(&session, &state.db, user_id, &headers).await?;
176 +
177 + Ok(Redirect::to("/").into_response())
178 + }
179 +
180 + pub fn sso_routes() -> Router<AppState> {
181 + Router::new()
182 + .route("/sso/login", get(sso_login))
183 + .route("/sso/callback", get(sso_callback))
184 + }
@@ -135,6 +135,10 @@ pub struct LoginTemplate {
135 135 /// on the testnot staging mirror). None hides it. Separate from `error` so
136 136 /// it doesn't render as a failure.
137 137 pub notice: Option<String>,
138 + /// When true, the page shows a single "Sign in with Makenot.work" button
139 + /// (delegated SSO) instead of the local password form. Set on the testnot
140 + /// mirror where `[sso]` is configured.
141 + pub sso_enabled: bool,
138 142 }
139 143
140 144 // ============================================================================
@@ -12,6 +12,13 @@
12 12 {% if let Some(msg) = error %}<div class="alert alert-error">{{ msg }}</div>{% endif %}
13 13 </div>
14 14
15 + {% if sso_enabled %}
16 + <div class="sso-login">
17 + <h2 class="subtitle-h2">Log in</h2>
18 + <p class="login-prose">testnot.work is a preview of makenot.work. Sign in with your makenot.work account to continue &mdash; your password is only ever entered on makenot.work.</p>
19 + <a class="btn-primary btn--large" href="/sso/login">Sign in with Makenot<span class="dot">.</span>work</a>
20 + </div>
21 + {% else %}
15 22 <form class="login-form"
16 23 method="post"
17 24 action="/login"
@@ -71,6 +78,7 @@
71 78 </button>
72 79 <div id="passkey-login-error" class="login-passkey-error"></div>
73 80 </div>
81 + {% endif %}
74 82 </div>
75 83 {% endblock %}
76 84
@@ -85,9 +93,10 @@
85 93 </script>
86 94 <script src="/static/passkey.js"></script>
87 95 <script>
88 - // Show passkey button if browser supports WebAuthn
89 - if (window.PublicKeyCredential) {
90 - document.getElementById('passkey-login').classList.remove('hidden');
96 + // Show passkey button if browser supports WebAuthn (absent in SSO-only mode)
97 + var passkeyLogin = document.getElementById('passkey-login');
98 + if (window.PublicKeyCredential && passkeyLogin) {
99 + passkeyLogin.classList.remove('hidden');
91 100 }
92 101 </script>
93 102 {% endblock %}
@@ -79,6 +79,8 @@ pub struct BuildOptions {
79 79 /// Site access gate. Defaults to `Open`; set to `FanPlusOrCreator` to test
80 80 /// the testnot-style gate.
81 81 pub access_gate: makenotwork::config::AccessGate,
82 + /// Delegated-login (SSO) provider config. `None` = local password form.
83 + pub sso: Option<makenotwork::config::SsoConfig>,
82 84 }
83 85
84 86 /// Full test harness: isolated database, in-process app, cookie-aware client.
@@ -305,6 +307,7 @@ impl TestHarness {
305 307 cli_service_token: opts.cli_service_token.clone(),
306 308 wam_url: None,
307 309 access_gate: opts.access_gate,
310 + sso: opts.sso.clone(),
308 311 };
309 312
310 313 let mock_email_ref = opts.mock_email.clone();
@@ -81,6 +81,7 @@ pub async fn run(config: LoadConfig) {
81 81 cli_service_token: None,
82 82 wam_url: None,
83 83 access_gate: makenotwork::config::AccessGate::Open,
84 + sso: None,
84 85 };
85 86
86 87 let email = EmailClient::new(EmailConfig {
@@ -1,5 +1,6 @@
1 1 mod access_gate;
2 2 mod auth;
3 + mod sso;
3 4 mod discover;
4 5 mod embeds;
5 6 mod streaming;
@@ -0,0 +1,60 @@
1 + //! Delegated login ("Sign in with Makenot.work") — login page mode + the
2 + //! authorize redirect. The full token exchange needs a live provider, so it's
3 + //! exercised against prod manually; here we verify the client-side wiring.
4 +
5 + use crate::harness::{BuildOptions, TestHarness};
6 + use makenotwork::config::SsoConfig;
7 +
8 + fn sso_opts() -> BuildOptions {
9 + BuildOptions {
10 + sso: Some(SsoConfig {
11 + provider_url: "https://provider.example".to_string(),
12 + client_id: "test-client-id".to_string(),
13 + key: "test-sso-key".to_string(),
14 + }),
15 + ..Default::default()
16 + }
17 + }
18 +
19 + #[tokio::test]
20 + async fn sso_login_page_shows_provider_button() {
21 + let mut h = TestHarness::build(sso_opts()).await;
22 +
23 + let resp = h.client.get("/login").await;
24 + assert_eq!(resp.status.as_u16(), 200, "login page should render");
25 + assert!(
26 + resp.text.contains("Sign in with Makenot"),
27 + "SSO mode should show the provider button"
28 + );
29 + assert!(
30 + !resp.text.contains(r#"name="password""#),
31 + "SSO mode should not render the local password form"
32 + );
33 + }
34 +
35 + #[tokio::test]
36 + async fn sso_login_redirects_to_provider_authorize() {
37 + let mut h = TestHarness::build(sso_opts()).await;
38 +
39 + let resp = h.client.get("/sso/login").await;
40 + assert!(resp.status.is_redirection(), "/sso/login should redirect, got {}", resp.status);
41 + let loc = resp.headers.get("location").and_then(|v| v.to_str().ok()).unwrap_or("");
42 + assert!(
43 + loc.starts_with("https://provider.example/oauth/authorize"),
44 + "should redirect to the provider authorize endpoint, got {loc}"
45 + );
46 + assert!(loc.contains("client_id=test-client-id"), "carries client_id: {loc}");
47 + assert!(loc.contains("code_challenge_method=S256"), "uses PKCE S256: {loc}");
48 + assert!(loc.contains("response_type=code"), "auth-code flow: {loc}");
49 + assert!(loc.contains("redirect_uri="), "carries redirect_uri: {loc}");
50 + }
51 +
52 + #[tokio::test]
53 + async fn login_page_default_uses_password_form() {
54 + // Without SSO configured, the normal username/password form renders.
55 + let mut h = TestHarness::new().await;
56 + let resp = h.client.get("/login").await;
57 + assert_eq!(resp.status.as_u16(), 200);
58 + assert!(resp.text.contains(r#"name="password""#), "default mode shows password form");
59 + assert!(!resp.text.contains("Sign in with Makenot"), "no SSO button by default");
60 + }