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
Signed with PGP, not checked
Commit: 27e2541429c937fe9244c7ec6f46327b5a6276dc
Parent: a0b0f1d
14 files changed, +324 insertions, -5 deletions
@@ -43,6 +43,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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;
@@ -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 @@
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 @@
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();