Sign up through an invitation with GitHub, GitLab or Google 3ff52534f69b
The registration page shows the branded buttons for a usable invitation;
/login/{provider}?invite=CODE carries the code through the provider round
trip in the signed state (purpose invite:CODE); on a hub that registers by
invitation the callback makes the account only with an unused code and uses
it up (atomically). A spent or unknown code is refused plainly at the start
and the buttons are not shown. Tested with the mock provider.
Closes #214
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 02:28 UTC
- Signature
- ✔ signed by Claude Code
- Hash
3ff52534f69b33516f451f62e6e48260d233489b84aa92d9309400979016c155- Depends on
- 0024ccb1b050 19bb31ff1f5c 810896d41be7 83940d53664f 93be9ce4653e
- Depended on by
- 8a48dd830178 c8684d44d549 3b1bc27fe805 5d90e0e045e2
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 24 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified crates/hub/src/db/accounts.rs +10 −0
| … | 749 unchanged lines |
||
| 750 | 750 | Ok(n > 0) |
|
| 751 | 751 | } |
|
| 752 | 752 | ||
| 753 | + | /// Use an invitation up if it is still unused; whether it was. |
|
| 754 | + | pub async fn claim_invitation(pool: &Pool, code: &str, account_id: i64) -> sqlx::Result<bool> { |
|
| 755 | + | let r = sqlx::query("UPDATE invitations SET used_by = ? WHERE code = ? AND used_by IS NULL") |
|
| 756 | + | .bind(account_id) |
|
| 757 | + | .bind(code) |
|
| 758 | + | .execute(pool) |
|
| 759 | + | .await?; |
|
| 760 | + | Ok(r.rows_affected() > 0) |
|
| 761 | + | } |
|
| 762 | + | ||
| 753 | 763 | pub async fn use_invitation(pool: &Pool, code: &str, account_id: i64) -> sqlx::Result<()> { |
|
| 754 | 764 | sqlx::query("UPDATE invitations SET used_by = ? WHERE code = ?").bind(account_id).bind(code).execute(pool).await?; |
|
| 755 | 765 | Ok(()) |
|
| … | 190 unchanged lines |
modified crates/hub/src/web/session.rs +12 −3
| … | 205 unchanged lines |
||
| 206 | 206 | mode: String, |
|
| 207 | 207 | first: bool, |
|
| 208 | 208 | invite: String, |
|
| 209 | - | /// Providers to sign up with, when registration is open (#213). |
|
| 209 | + | /// Providers to sign up with, when registration is open (#213) or |
|
| 210 | + | /// the page has a usable invitation (oxygenhub#214). |
|
| 210 | 211 | providers: Vec<(&'static str, &'static str)>, |
|
| 212 | + | /// What the provider buttons carry: `?invite=CODE`, or nothing. |
|
| 213 | + | provider_query: String, |
|
| 211 | 214 | } |
|
| 212 | 215 | ||
| 213 | 216 | /// The registration mode: the settings table overrides `hub.toml`. |
|
| … | 13 unchanged lines |
||
| 227 | 230 | let shell = Shell::new(&app, "/register", &viewer); |
|
| 228 | 231 | let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; |
|
| 229 | 232 | let mode = registration_mode(&app).await; |
|
| 230 | - | let providers = if mode == "open" && !first { super::signin::buttons(&app).await } else { vec![] }; |
|
| 231 | - | Ok(Page(RegisterPage { shell, error: None, mode, first, invite: q.invite.unwrap_or_default(), providers }).into_response()) |
|
| 233 | + | let invite = q.invite.unwrap_or_default().trim().to_string(); |
|
| 234 | + | let usable_invite = mode == "invite" |
|
| 235 | + | && super::signin::invite_code_ok(&invite) |
|
| 236 | + | && accounts::invitation_unused(&app.db, &invite).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 237 | + | let providers = if !first && (mode == "open" || usable_invite) { super::signin::buttons(&app).await } else { vec![] }; |
|
| 238 | + | let provider_query = if usable_invite { format!("?invite={invite}") } else { String::new() }; |
|
| 239 | + | Ok(Page(RegisterPage { shell, error: None, mode, first, invite, providers, provider_query }).into_response()) |
|
| 232 | 240 | } |
|
| 233 | 241 | ||
| 234 | 242 | #[derive(Deserialize)] |
|
| … | 17 unchanged lines |
||
| 252 | 260 | first, |
|
| 253 | 261 | invite: f.invite.clone(), |
|
| 254 | 262 | providers: vec![], |
|
| 263 | + | provider_query: String::new(), |
|
| 255 | 264 | }) |
|
| 256 | 265 | .into_response() |
|
| 257 | 266 | }; |
|
| … | 51 unchanged lines |
modified crates/hub/src/web/signin.rs +53 −6
| … | 42 unchanged lines |
||
| 43 | 43 | Ok(([(header::SET_COOKIE, state_cookie(app, &state, 600))], Redirect::to(&to)).into_response()) |
|
| 44 | 44 | } |
|
| 45 | 45 | ||
| 46 | - | /// `GET /login/{provider}`: sign in with the provider. |
|
| 47 | - | pub async fn start(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Result<Response, Error> { |
|
| 46 | + | #[derive(Deserialize)] |
|
| 47 | + | pub struct StartQuery { |
|
| 48 | + | #[serde(default)] |
|
| 49 | + | invite: String, |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// An invitation code as it may ride in the signed state: letters and |
|
| 53 | + | /// digits only (the state's parts are separated by dots). |
|
| 54 | + | pub fn invite_code_ok(code: &str) -> bool { |
|
| 55 | + | !code.is_empty() && code.len() <= 64 && code.chars().all(|c| c.is_ascii_alphanumeric()) |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// `GET /login/{provider}`: sign in with the provider; with `?invite=`, |
|
| 59 | + | /// sign up with it through that invitation (oxygenhub#214), which rides |
|
| 60 | + | /// through the round trip in the signed state. |
|
| 61 | + | pub async fn start( |
|
| 62 | + | State(app): State<Shared>, |
|
| 63 | + | viewer: Viewer, |
|
| 64 | + | Path(id): Path<String>, |
|
| 65 | + | Query(q): Query<StartQuery>, |
|
| 66 | + | ) -> Result<Response, Error> { |
|
| 48 | 67 | let shell = Shell::new(&app, "/login", &viewer); |
|
| 49 | 68 | let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 50 | 69 | if viewer.signed_in() { |
|
| 51 | 70 | return Ok(Redirect::to("/").into_response()); |
|
| 71 | + | } |
|
| 72 | + | let invite = q.invite.trim(); |
|
| 73 | + | if invite.is_empty() { |
|
| 74 | + | return begin(&app, p, "in").await.map_err(|m| Error::bad(shell.clone(), m)); |
|
| 52 | 75 | } |
|
| 53 | - | begin(&app, p, "in").await.map_err(|m| Error::bad(shell.clone(), m)) |
|
| 76 | + | let unused = |
|
| 77 | + | invite_code_ok(invite) && accounts::invitation_unused(&app.db, invite).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 78 | + | if !unused { |
|
| 79 | + | return Err(Error::bad(shell, "That invitation has been used or does not exist. Ask whoever invited you for a new one.")); |
|
| 80 | + | } |
|
| 81 | + | begin(&app, p, &format!("invite:{invite}")).await.map_err(|m| Error::bad(shell.clone(), m)) |
|
| 54 | 82 | } |
|
| 55 | 83 | ||
| 56 | 84 | /// `GET /settings/security/connect/{provider}`: link the provider to the |
|
| … | 101 unchanged lines |
||
| 158 | 186 | return Ok(with_clear(signed_in(&app, &account, "/").await)); |
|
| 159 | 187 | } |
|
| 160 | 188 | } |
|
| 161 | - | let mode = super::session::registration_mode(&app).await; |
|
| 162 | - | if mode != "open" { |
|
| 189 | + | // A new account: when registration is open, or by invitation with an |
|
| 190 | + | // invitation that came through the round trip and is still unused. |
|
| 191 | + | let registration = super::session::registration_mode(&app).await; |
|
| 192 | + | let invite = mode.strip_prefix("invite:").filter(|c| invite_code_ok(c)); |
|
| 193 | + | let allowed = match registration.as_str() { |
|
| 194 | + | "open" => true, |
|
| 195 | + | "invite" => invite.is_some(), |
|
| 196 | + | _ => false, |
|
| 197 | + | }; |
|
| 198 | + | if !allowed { |
|
| 163 | 199 | return Ok(with_clear(to_login(&format!( |
|
| 164 | 200 | "No account here is connected to that {} account, and registration is {}. Register or sign in first, then connect it under Settings › Security.", |
|
| 165 | 201 | p.name, |
|
| 166 | - | if mode == "invite" { "by invitation" } else { "closed" } |
|
| 202 | + | if registration == "invite" { "by invitation" } else { "closed" } |
|
| 167 | 203 | )))); |
|
| 168 | 204 | } |
|
| 205 | + | let by_invitation = (registration == "invite").then_some(invite).flatten(); |
|
| 206 | + | if let Some(code) = by_invitation { |
|
| 207 | + | if !accounts::invitation_unused(&app.db, code).await.map_err(e)? { |
|
| 208 | + | return Ok(with_clear(to_login("That invitation was used meanwhile. Ask whoever invited you for a new one."))); |
|
| 209 | + | } |
|
| 210 | + | } |
|
| 169 | 211 | let account = register(&app, p, &who).await.map_err(e)?; |
|
| 212 | + | if let Some(code) = by_invitation { |
|
| 213 | + | if !accounts::claim_invitation(&app.db, code, account.id).await.map_err(e)? { |
|
| 214 | + | tracing::warn!("invitation {code} was used by someone else while {} signed up", account.username); |
|
| 215 | + | } |
|
| 216 | + | } |
|
| 170 | 217 | Ok(with_clear(signed_in(&app, &account, "/").await)) |
|
| 171 | 218 | } |
|
| 172 | 219 | ||
| … | 126 unchanged lines |
modified crates/hub/tests/accounts.rs +53 −0
| … | 1395 unchanged lines |
||
| 1396 | 1396 | // A cookie of the old form (name.mac) is nobody. |
|
| 1397 | 1397 | assert!(!signed_in("andy.0123456789abcdef".into()).await); |
|
| 1398 | 1398 | } |
|
| 1399 | + | ||
| 1400 | + | #[tokio::test] |
|
| 1401 | + | async fn an_invitation_signs_up_through_a_provider_once() { |
|
| 1402 | + | hermetic(); |
|
| 1403 | + | let (mock, _) = mock_provider().await; |
|
| 1404 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1405 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 1406 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 1407 | + | let port = listener.local_addr().unwrap().port(); |
|
| 1408 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 1409 | + | config.registration.mode = "invite".into(); |
|
| 1410 | + | config.auth.signin_mock = Some(mock.clone()); |
|
| 1411 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 1412 | + | let router = oxygenhub::router(app.clone()); |
|
| 1413 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 1414 | + | let app = &app; |
|
| 1415 | + | let andy = register(app, "andy", "").await; |
|
| 1416 | + | let admin = get(app, "/admin", Some(&andy)).await.body; |
|
| 1417 | + | let csrf = csrf_of(&admin); |
|
| 1418 | + | post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=github&client_id=gh-cid&secret=gh-sek&enabled=1")).await; |
|
| 1419 | + | ||
| 1420 | + | // Without an invitation: no buttons, and the provider alone makes no account. |
|
| 1421 | + | assert!(!get(app, "/register", None).await.body.contains("/login/github")); |
|
| 1422 | + | let r = get(app, "/login/github", None).await; |
|
| 1423 | + | let (cookie, state) = state_of(&r); |
|
| 1424 | + | let no = send_with(app, "GET", &format!("/login/github/callback?code=c1&state={state}"), Some(&cookie), None).await; |
|
| 1425 | + | assert!(no.location.as_deref().unwrap_or("").contains("by%20invitation"), "{:?}", no.location); |
|
| 1426 | + | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 1); |
|
| 1427 | + | ||
| 1428 | + | // With one: the branded buttons carry it, it rides the round trip, and |
|
| 1429 | + | // the new account uses it up. |
|
| 1430 | + | let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 1431 | + | let code = r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 1432 | + | let page = get(app, &format!("/register?invite={code}"), None).await.body; |
|
| 1433 | + | assert!(page.contains(&format!("href=\"/login/github?invite={code}\"")) && page.contains("Sign up with GitHub"), "{page}"); |
|
| 1434 | + | let r = get(app, &format!("/login/github?invite={code}"), None).await; |
|
| 1435 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 1436 | + | let (cookie, state) = state_of(&r); |
|
| 1437 | + | let ok = send_with(app, "GET", &format!("/login/github/callback?code=c2&state={state}"), Some(&cookie), None).await; |
|
| 1438 | + | assert_eq!(ok.location.as_deref(), Some("/"), "{}", ok.body); |
|
| 1439 | + | let octo = db::accounts::account_by_name(&app.db, "octocat").await.unwrap().expect("signed up through the invitation"); |
|
| 1440 | + | assert!(!db::accounts::invitation_unused(&app.db, &code).await.unwrap(), "the invitation is spent"); |
|
| 1441 | + | let used_by: Option<i64> = |
|
| 1442 | + | sqlx::query_scalar("SELECT used_by FROM invitations WHERE code = ?").bind(&code).fetch_one(&app.db).await.unwrap(); |
|
| 1443 | + | assert_eq!(used_by, Some(octo.id)); |
|
| 1444 | + | ||
| 1445 | + | // Spent: no buttons, and starting with it is refused plainly. |
|
| 1446 | + | assert!(!get(app, &format!("/register?invite={code}"), None).await.body.contains("/login/github")); |
|
| 1447 | + | let r = get(app, &format!("/login/github?invite={code}"), None).await; |
|
| 1448 | + | assert_eq!(r.status, StatusCode::BAD_REQUEST); |
|
| 1449 | + | assert!(r.body.contains("That invitation has been used or does not exist."), "{}", r.body); |
|
| 1450 | + | assert_eq!(get(app, "/login/github?invite=not-a.code", None).await.status, StatusCode::BAD_REQUEST); |
|
| 1451 | + | } |
modified docs/spec.md +1 −1
| … | 174 unchanged lines |
||
| 175 | 175 | ||
| 176 | 176 | #### 5.1.2 Sign in with GitHub or Google (done 2026-10-04) |
|
| 177 | 177 | ||
| 178 | - | An administrator sets up each provider under Administration › Sign in with: the OAuth client id, the client secret (sealed under `keys/secrets.key`, never shown again) and a switch; the page shows the callback URL to register with the provider (`/login/{provider}/callback`). The login page then offers the usable providers (oxygenhub#106). The flow is OAuth 2.0's authorization code: `/login/{provider}` sets a state cookie (a nonce and the purpose, MACed with the cookie key, ten minutes, path `/login`) and sends the person to the provider; the callback checks the state against the cookie, swaps the code for a token server to server, and asks who it is (GitHub: `/user` and the primary verified address from `/user/emails`; Google: the OpenID `userinfo`, the address only when `email_verified`). Then: a subject already linked signs in; else an account with that verified email is linked and signed in; else, when registration is open, an account is made from the provider's login name (made valid and free), with an unusable password and the email verified when the provider vouched for it; otherwise the person is told to register or sign in and connect. A signed-in person connects or disconnects a provider under Settings › Security; a subject can be linked to one account. Links live in `account_logins`. Audited as `admin.signin`, `signin.connected`, `signin.disconnected`, `login` and `register` with the provider. For tests, `[auth] signin_mock` names a base URL that stands in for every provider. |
|
| 178 | + | An administrator sets up each provider under Administration › Sign in with: the OAuth client id, the client secret (sealed under `keys/secrets.key`, never shown again) and a switch; the page shows the callback URL to register with the provider (`/login/{provider}/callback`). The login page then offers the usable providers (oxygenhub#106). The flow is OAuth 2.0's authorization code: `/login/{provider}` sets a state cookie (a nonce and the purpose, MACed with the cookie key, ten minutes, path `/login`) and sends the person to the provider; the callback checks the state against the cookie, swaps the code for a token server to server, and asks who it is (GitHub: `/user` and the primary verified address from `/user/emails`; Google: the OpenID `userinfo`, the address only when `email_verified`). Then: a subject already linked signs in; else an account with that verified email is linked and signed in; else, when registration is open, or it is by invitation and an unused invitation came through the round trip (`/login/{provider}?invite=CODE`, carried in the signed state as the purpose `invite:CODE`, used up when the account is made; the registration page shows the provider buttons for a usable invitation, oxygenhub#214), an account is made from the provider's login name (made valid and free), with an unusable password and the email verified when the provider vouched for it; otherwise the person is told to register or sign in and connect. A signed-in person connects or disconnects a provider under Settings › Security; a subject can be linked to one account. Links live in `account_logins`. Audited as `admin.signin`, `signin.connected`, `signin.disconnected`, `login` and `register` with the provider. For tests, `[auth] signin_mock` names a base URL that stands in for every provider. |
|
| 179 | 179 | ||
| 180 | 180 | #### 5.1.1 Email verification and password reset |
|
| 181 | 181 | ||
| … | 578 unchanged lines |
modified templates/login.html +1 −1
| … | 21 unchanged lines |
||
| 22 | 22 | <button type="submit">SIGN IN</button> |
|
| 23 | 23 | </form> |
|
| 24 | 24 | {% if !providers.is_empty() && totp_for.is_none() %}<div class="signin-or" aria-hidden="true"><span>or</span></div> |
|
| 25 | - | {% let verb = "Sign in" %}{% include "signin_buttons.html" %}{% endif %} |
|
| 25 | + | {% let verb = "Sign in" %}{% let suffix = "" %}{% include "signin_buttons.html" %}{% endif %} |
|
| 26 | 26 | </div> |
|
| 27 | 27 | <p class="small mute"><a href="/login/reset">Forgot your password?</a> · No account? <a href="/register">Register</a>.</p> |
|
| 28 | 28 | {% endblock %} |
modified templates/register.html +1 −1
| … | 17 unchanged lines |
||
| 18 | 18 | <button type="submit">CREATE ACCOUNT</button> |
|
| 19 | 19 | </form> |
|
| 20 | 20 | {% if !providers.is_empty() %}<div class="signin-or" aria-hidden="true"><span>or</span></div> |
|
| 21 | - | {% let verb = "Sign up" %}{% include "signin_buttons.html" %}{% endif %} |
|
| 21 | + | {% let verb = "Sign up" %}{% let suffix = provider_query.as_str() %}{% include "signin_buttons.html" %}{% endif %} |
|
| 22 | 22 | </div> |
|
| 23 | 23 | {% endif %} |
|
| 24 | 24 | {% endblock %} |
modified templates/signin_buttons.html +3 −2
| 1 | 1 | {# Branded sign-in buttons (oxygenhub#213), in the style of Gitter's: the |
|
| 2 | 2 | provider's colour and logo (Simple Icons, CC0). Needs `providers` and |
|
| 3 | - | `verb` ("Sign in" or "Sign up"). #} |
|
| 3 | + | `verb` ("Sign in" or "Sign up"), and `suffix` (a query the links |
|
| 4 | + | carry: `?invite=CODE` on an invitation, oxygenhub#214). #} |
|
| 4 | 5 | <div class="signin-with"> |
|
| 5 | - | {% for (id, name) in providers %}<a class="signin-btn {{ id }}" href="/login/{{ id }}"> |
|
| 6 | + | {% for (id, name) in providers %}<a class="signin-btn {{ id }}" href="/login/{{ id }}{{ suffix }}"> |
|
| 6 | 7 | <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">{% if *id == "gitlab" %}<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/>{% else if *id == "github" %}<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>{% else if *id == "google" %}<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/>{% endif %}</svg> |
|
| 7 | 8 | <span>{{ verb }} with {{ name }}</span> |
|
| 8 | 9 | </a> |
|
| … | 1 unchanged lines |