OXYGENHUB

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