max / makenotwork
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 — 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 | + | } |