max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 — 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(); |