Sign in with GitLab, and branded sign-in buttons 810896d41be7
GitLab joins GitHub and Google: gitlab.com, or a self-managed GitLab at
an https address the administrator gives, through GitLab's OpenID
Connect endpoints (openid profile email); the username comes from
nickname, the email only when GitLab marks it verified. The providers
show as buttons beside the sign-in box in Gitter's style: GitLab
red-orange (darkened to 4.8:1 for white text), GitHub dark, Google
white, each with its logo (Simple Icons, CC0), and below the box with
an 'or' on a phone. The registration page offers them when registration
is open. Labels say the provider's name ('Sign in with GitHub'), not its
id.
Closes #213
- 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-15 15:15 UTC
- Signature
- ✔ signed by Claude Code
- Hash
810896d41be780e42f5a7b63a33edcf49b94a106fce7e31212202a5f922b13ac- Depends on
- 19bb31ff1f5c 34beb1a7e376 83940d53664f addec2bd6423 d23464c0ac60
- Depended on by
- 3ff52534f69b 2a0c09c09716
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.1 v0.5.2 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
- 43 atoms, 1 tree operation, 9 files
Browse the tree at this change
added templates/signin_buttons.html +9 −0
| 1 | + | {# Branded sign-in buttons (oxygenhub#213), in the style of Gitter's: the |
|
| 2 | + | provider's colour and logo (Simple Icons, CC0). Needs `providers` and |
|
| 3 | + | `verb` ("Sign in" or "Sign up"). #} |
|
| 4 | + | <div class="signin-with"> |
|
| 5 | + | {% for (id, name) in providers %}<a class="signin-btn {{ id }}" href="/login/{{ id }}"> |
|
| 6 | + | <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 | + | <span>{{ verb }} with {{ name }}</span> |
|
| 8 | + | </a> |
|
| 9 | + | {% endfor %}</div> |
modified crates/hub/src/signin.rs +84 −16
| 1 | 1 | //! Sign in with an external account (hub spec §5.1.2, oxygenhub#106): |
|
| 2 | - | //! GitHub and Google through OAuth 2.0's authorization code flow, set up |
|
| 2 | + | //! GitLab (gitlab.com or a self-managed GitLab), GitHub and Google |
|
| 3 | + | //! through OAuth 2.0's authorization code flow, set up |
|
| 3 | 4 | //! by an administrator per provider (client id, a sealed secret, on or |
|
| 4 | 5 | //! off). A first sign-in links the provider's subject to the account with |
|
| 5 | 6 | //! the same verified email, or makes an account when registration is |
|
| … | 12 unchanged lines |
||
| 18 | 19 | token: &'static str, |
|
| 19 | 20 | userinfo: &'static str, |
|
| 20 | 21 | pub scope: &'static str, |
|
| 22 | + | /// For a provider that can be self-hosted: the address used when the |
|
| 23 | + | /// administrator gives none. Its endpoints are then paths under the |
|
| 24 | + | /// address (oxygenhub#213). |
|
| 25 | + | pub default_base: Option<&'static str>, |
|
| 21 | 26 | } |
|
| 22 | 27 | ||
| 23 | - | pub const PROVIDERS: [Provider; 2] = [ |
|
| 28 | + | pub const PROVIDERS: [Provider; 3] = [ |
|
| 29 | + | // GitLab's OpenID Connect endpoints, the same on gitlab.com and on a |
|
| 30 | + | // self-managed GitLab. |
|
| 24 | 31 | Provider { |
|
| 32 | + | id: "gitlab", |
|
| 33 | + | name: "GitLab", |
|
| 34 | + | authorize: "/oauth/authorize", |
|
| 35 | + | token: "/oauth/token", |
|
| 36 | + | userinfo: "/oauth/userinfo", |
|
| 37 | + | scope: "openid profile email", |
|
| 38 | + | default_base: Some("https://gitlab.com"), |
|
| 39 | + | }, |
|
| 40 | + | Provider { |
|
| 25 | 41 | id: "github", |
|
| 26 | 42 | name: "GitHub", |
|
| 27 | 43 | authorize: "https://github.com/login/oauth/authorize", |
|
| 28 | 44 | token: "https://github.com/login/oauth/access_token", |
|
| 29 | 45 | userinfo: "https://api.github.com/user", |
|
| 30 | 46 | scope: "read:user user:email", |
|
| 47 | + | default_base: None, |
|
| 31 | 48 | }, |
|
| 32 | 49 | Provider { |
|
| 33 | 50 | id: "google", |
|
| … | 2 unchanged lines |
||
| 36 | 53 | token: "https://oauth2.googleapis.com/token", |
|
| 37 | 54 | userinfo: "https://openidconnect.googleapis.com/v1/userinfo", |
|
| 38 | 55 | scope: "openid email profile", |
|
| 56 | + | default_base: None, |
|
| 39 | 57 | }, |
|
| 40 | 58 | ]; |
|
| 41 | 59 | ||
| … | 4 unchanged lines |
||
| 46 | 64 | impl Provider { |
|
| 47 | 65 | /// The provider's endpoints, or a test double's when `[auth] |
|
| 48 | 66 | /// signin_mock` names one: `{mock}/{id}/authorize|token|userinfo`. |
|
| 49 | - | fn url(&self, app: &Shared, which: &str) -> String { |
|
| 67 | + | fn url(&self, app: &Shared, s: &Settings, which: &str) -> String { |
|
| 68 | + | let path = match which { |
|
| 69 | + | "authorize" => self.authorize, |
|
| 70 | + | "token" => self.token, |
|
| 71 | + | _ => self.userinfo, |
|
| 72 | + | }; |
|
| 50 | 73 | match &app.config.auth.signin_mock { |
|
| 51 | 74 | Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id), |
|
| 52 | - | None => match which { |
|
| 53 | - | "authorize" => self.authorize.to_string(), |
|
| 54 | - | "token" => self.token.to_string(), |
|
| 55 | - | _ => self.userinfo.to_string(), |
|
| 75 | + | None => match self.default_base { |
|
| 76 | + | Some(default) => format!("{}{path}", s.base_or(default)), |
|
| 77 | + | None => path.to_string(), |
|
| 56 | 78 | }, |
|
| 57 | 79 | } |
|
| 58 | 80 | } |
|
| … | 11 unchanged lines |
||
| 70 | 92 | /// The client secret, opened; empty when none is set. |
|
| 71 | 93 | pub secret: String, |
|
| 72 | 94 | pub enabled: bool, |
|
| 95 | + | /// A self-hostable provider's address (GitLab), empty for its default. |
|
| 96 | + | pub base: String, |
|
| 73 | 97 | } |
|
| 74 | 98 | ||
| 75 | 99 | impl Settings { |
|
| 76 | 100 | pub fn usable(&self) -> bool { |
|
| 77 | 101 | self.enabled && !self.client_id.is_empty() && !self.secret.is_empty() |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// The address to use: the one set, else `default`. |
|
| 105 | + | pub fn base_or<'a>(&'a self, default: &'a str) -> &'a str { |
|
| 106 | + | let b = self.base.trim().trim_end_matches('/'); |
|
| 107 | + | if b.is_empty() { |
|
| 108 | + | default |
|
| 109 | + | } else { |
|
| 110 | + | b |
|
| 111 | + | } |
|
| 78 | 112 | } |
|
| 79 | 113 | } |
|
| 80 | 114 | ||
| 115 | + | /// Whether `base` may be a self-hosted provider's address: https, a host, |
|
| 116 | + | /// nothing after it but an optional path prefix, no query. |
|
| 117 | + | pub fn valid_base(base: &str) -> bool { |
|
| 118 | + | let Some(rest) = base.strip_prefix("https://") else { return false }; |
|
| 119 | + | let host = rest.split('/').next().unwrap_or(""); |
|
| 120 | + | !host.is_empty() && !rest.contains(['?', '#', ' ']) && host.contains('.') |
|
| 121 | + | } |
|
| 122 | + | ||
| 81 | 123 | fn key(id: &str, what: &str) -> String { |
|
| 82 | 124 | format!("signin.{id}.{what}") |
|
| 83 | 125 | } |
|
| … | 3 unchanged lines |
||
| 87 | 129 | let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default(); |
|
| 88 | 130 | let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default(); |
|
| 89 | 131 | let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1"); |
|
| 90 | - | Ok(Settings { client_id, secret, enabled }) |
|
| 132 | + | let base = accounts::setting(&app.db, &key(id, "base")).await?.unwrap_or_default(); |
|
| 133 | + | Ok(Settings { client_id, secret, enabled, base }) |
|
| 91 | 134 | } |
|
| 92 | 135 | ||
| 93 | 136 | /// Save a provider's settings; `secret` is `None` to keep the stored one. |
|
| 94 | - | pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool) -> sqlx::Result<()> { |
|
| 137 | + | pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool, base: &str) -> sqlx::Result<()> { |
|
| 138 | + | accounts::set_setting(&app.db, &key(id, "base"), base.trim().trim_end_matches('/')).await?; |
|
| 95 | 139 | accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?; |
|
| 96 | 140 | if let Some(s) = secret { |
|
| 97 | 141 | accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?; |
|
| … | 35 unchanged lines |
||
| 133 | 177 | } |
|
| 134 | 178 | ||
| 135 | 179 | /// Where to send the person to sign in with `p`. |
|
| 136 | - | pub fn authorize_url(app: &Shared, p: &Provider, client_id: &str, state: &str) -> String { |
|
| 180 | + | pub fn authorize_url(app: &Shared, p: &Provider, s: &Settings, state: &str) -> String { |
|
| 137 | 181 | let q = |s: &str| crate::web::settings::urlencode(s); |
|
| 138 | 182 | format!( |
|
| 139 | 183 | "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}", |
|
| 140 | - | p.url(app, "authorize"), |
|
| 141 | - | q(client_id), |
|
| 184 | + | p.url(app, s, "authorize"), |
|
| 185 | + | q(&s.client_id), |
|
| 142 | 186 | q(&p.callback_url(app)), |
|
| 143 | 187 | q(p.scope), |
|
| 144 | 188 | q(state), |
|
| … | 20 unchanged lines |
||
| 165 | 209 | .build() |
|
| 166 | 210 | .map_err(|e| e.to_string())?; |
|
| 167 | 211 | let token: serde_json::Value = client |
|
| 168 | - | .post(p.url(app, "token")) |
|
| 212 | + | .post(p.url(app, s, "token")) |
|
| 169 | 213 | .header("accept", "application/json") |
|
| 170 | 214 | .form(&[ |
|
| 171 | 215 | ("client_id", s.client_id.as_str()), |
|
| … | 28 unchanged lines |
||
| 200 | 244 | .map_err(|_| format!("{} answered with something unexpected", p.name)) |
|
| 201 | 245 | } |
|
| 202 | 246 | }; |
|
| 203 | - | let user = get(p.url(app, "userinfo")).await?; |
|
| 247 | + | let user = get(p.url(app, s, "userinfo")).await?; |
|
| 204 | 248 | Ok(match p.id { |
|
| 205 | 249 | "github" => { |
|
| 206 | 250 | let subject = |
|
| 207 | 251 | user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?; |
|
| 208 | 252 | // The primary verified address is on its own endpoint. |
|
| 209 | - | let emails = get(format!("{}/emails", p.url(app, "userinfo"))).await.unwrap_or_default(); |
|
| 253 | + | let emails = get(format!("{}/emails", p.url(app, s, "userinfo"))).await.unwrap_or_default(); |
|
| 210 | 254 | let email = emails |
|
| 211 | 255 | .as_array() |
|
| 212 | 256 | .and_then(|list| { |
|
| … | 10 unchanged lines |
||
| 223 | 267 | name: user["name"].as_str().unwrap_or("").to_string(), |
|
| 224 | 268 | email, |
|
| 225 | 269 | } |
|
| 270 | + | } |
|
| 271 | + | // GitLab's OpenID Connect userinfo: the subject is its user id, the |
|
| 272 | + | // username is `nickname`. |
|
| 273 | + | "gitlab" => { |
|
| 274 | + | let subject = user["sub"] |
|
| 275 | + | .as_str() |
|
| 276 | + | .map(String::from) |
|
| 277 | + | .or_else(|| user["sub"].as_i64().map(|i| i.to_string())) |
|
| 278 | + | .ok_or("GitLab gave no subject")?; |
|
| 279 | + | let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from); |
|
| 280 | + | let login = user["nickname"].as_str().or(user["preferred_username"].as_str()).unwrap_or("").to_string(); |
|
| 281 | + | External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email } |
|
| 226 | 282 | } |
|
| 227 | 283 | _ => { |
|
| 228 | 284 | let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string(); |
|
| … | 82 unchanged lines |
||
| 311 | 367 | fn providers_are_known() { |
|
| 312 | 368 | assert_eq!(provider("github").map(|p| p.name), Some("GitHub")); |
|
| 313 | 369 | assert_eq!(provider("google").map(|p| p.name), Some("Google")); |
|
| 314 | - | assert!(provider("gitlab").is_none()); |
|
| 370 | + | assert_eq!(provider("gitlab").map(|p| (p.name, p.default_base)), Some(("GitLab", Some("https://gitlab.com")))); |
|
| 371 | + | assert!(provider("bitbucket").is_none()); |
|
| 372 | + | // A self-managed GitLab's address (#213). |
|
| 373 | + | assert!(valid_base("https://gitlab.example.org") && valid_base("https://code.example.org/gitlab")); |
|
| 374 | + | assert!( |
|
| 375 | + | !valid_base("http://gitlab.example.org") |
|
| 376 | + | && !valid_base("https://") |
|
| 377 | + | && !valid_base("https://gitlab.example.org/?x=1") |
|
| 378 | + | && !valid_base("https://localhost") |
|
| 379 | + | ); |
|
| 380 | + | let s = Settings { base: "https://gitlab.example.org/".into(), ..Settings::default() }; |
|
| 381 | + | assert_eq!(s.base_or("https://gitlab.com"), "https://gitlab.example.org"); |
|
| 382 | + | assert_eq!(Settings::default().base_or("https://gitlab.com"), "https://gitlab.com"); |
|
| 315 | 383 | } |
|
| 316 | 384 | } |
modified crates/hub/src/web/session.rs +14 −4
| … | 170 unchanged lines |
||
| 171 | 171 | mode: String, |
|
| 172 | 172 | first: bool, |
|
| 173 | 173 | invite: String, |
|
| 174 | + | /// Providers to sign up with, when registration is open (#213). |
|
| 175 | + | providers: Vec<(&'static str, &'static str)>, |
|
| 174 | 176 | } |
|
| 175 | 177 | ||
| 176 | 178 | /// The registration mode: the settings table overrides `hub.toml`. |
|
| … | 12 unchanged lines |
||
| 189 | 191 | } |
|
| 190 | 192 | let shell = Shell::new(&app, "/register", &viewer); |
|
| 191 | 193 | let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; |
|
| 192 | - | Ok(Page(RegisterPage { shell, error: None, mode: registration_mode(&app).await, first, invite: q.invite.unwrap_or_default() }) |
|
| 193 | - | .into_response()) |
|
| 194 | + | let mode = registration_mode(&app).await; |
|
| 195 | + | let providers = if mode == "open" && !first { super::signin::buttons(&app).await } else { vec![] }; |
|
| 196 | + | Ok(Page(RegisterPage { shell, error: None, mode, first, invite: q.invite.unwrap_or_default(), providers }).into_response()) |
|
| 194 | 197 | } |
|
| 195 | 198 | ||
| 196 | 199 | #[derive(Deserialize)] |
|
| … | 10 unchanged lines |
||
| 207 | 210 | let first = accounts::account_count(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))? == 0; |
|
| 208 | 211 | let mode = registration_mode(&app).await; |
|
| 209 | 212 | let fail = |msg: &str| { |
|
| 210 | - | Page(RegisterPage { shell: shell.clone(), error: Some(msg.into()), mode: mode.clone(), first, invite: f.invite.clone() }) |
|
| 211 | - | .into_response() |
|
| 213 | + | Page(RegisterPage { |
|
| 214 | + | shell: shell.clone(), |
|
| 215 | + | error: Some(msg.into()), |
|
| 216 | + | mode: mode.clone(), |
|
| 217 | + | first, |
|
| 218 | + | invite: f.invite.clone(), |
|
| 219 | + | providers: vec![], |
|
| 220 | + | }) |
|
| 221 | + | .into_response() |
|
| 212 | 222 | }; |
|
| 213 | 223 | let username = f.username.trim().to_string(); |
|
| 214 | 224 | if !crate::repos::names::valid_owner(&username) { |
|
| … | 49 unchanged lines |
modified crates/hub/src/web/signin.rs +17 −2
| … | 38 unchanged lines |
||
| 39 | 39 | return Err(format!("Signing in with {} is not set up on this hub.", p.name)); |
|
| 40 | 40 | } |
|
| 41 | 41 | let state = signin::state_value(app, mode); |
|
| 42 | - | let to = signin::authorize_url(app, p, &s.client_id, &state); |
|
| 42 | + | let to = signin::authorize_url(app, p, &s, &state); |
|
| 43 | 43 | Ok(([(header::SET_COOKIE, state_cookie(app, &state, 600))], Redirect::to(&to)).into_response()) |
|
| 44 | 44 | } |
|
| 45 | 45 | ||
| … | 178 unchanged lines |
||
| 224 | 224 | pub has_secret: bool, |
|
| 225 | 225 | pub enabled: bool, |
|
| 226 | 226 | pub callback: String, |
|
| 227 | + | /// For a self-hostable provider: its default address and the one set. |
|
| 228 | + | pub default_base: Option<&'static str>, |
|
| 229 | + | pub base: String, |
|
| 227 | 230 | } |
|
| 228 | 231 | ||
| 229 | 232 | pub async fn provider_views(app: &Shared) -> sqlx::Result<Vec<ProviderView>> { |
|
| … | 7 unchanged lines |
||
| 237 | 240 | has_secret: !s.secret.is_empty(), |
|
| 238 | 241 | enabled: s.enabled, |
|
| 239 | 242 | callback: p.callback_url(app), |
|
| 243 | + | default_base: p.default_base, |
|
| 244 | + | base: s.base, |
|
| 240 | 245 | }); |
|
| 241 | 246 | } |
|
| 242 | 247 | Ok(out) |
|
| … | 10 unchanged lines |
||
| 253 | 258 | pub secret: String, |
|
| 254 | 259 | #[serde(default)] |
|
| 255 | 260 | pub enabled: Option<String>, |
|
| 261 | + | /// A self-hostable provider's address (GitLab); empty for its default. |
|
| 262 | + | #[serde(default)] |
|
| 263 | + | pub base: String, |
|
| 256 | 264 | } |
|
| 257 | 265 | ||
| 258 | 266 | /// Apply the admin form: `Ok(notice)` or `Err(message)`. |
|
| … | 6 unchanged lines |
||
| 265 | 273 | let secret = f.secret.trim(); |
|
| 266 | 274 | let secret = if secret.is_empty() { None } else { Some(secret) }; |
|
| 267 | 275 | let enabled = f.enabled.is_some(); |
|
| 268 | - | signin::save(app, p.id, client_id, secret, enabled).await?; |
|
| 276 | + | let base = f.base.trim().trim_end_matches('/'); |
|
| 277 | + | if p.default_base.is_none() && !base.is_empty() { |
|
| 278 | + | return Ok(Err(format!("{} has no address to set.", p.name))); |
|
| 279 | + | } |
|
| 280 | + | if !base.is_empty() && !signin::valid_base(base) { |
|
| 281 | + | return Ok(Err(format!("The {} address is an https:// address, like https://gitlab.example.org.", p.name))); |
|
| 282 | + | } |
|
| 283 | + | signin::save(app, p.id, client_id, secret, enabled, base).await?; |
|
| 269 | 284 | let s = signin::settings(app, p.id).await?; |
|
| 270 | 285 | accounts::audit(&app.db, viewer.account(), "admin.signin", p.id, if s.usable() { "on" } else { "off" }).await.ok(); |
|
| 271 | 286 | Ok(Ok(if s.usable() { |
|
| … | 12 unchanged lines |
modified crates/hub/tests/accounts.rs +49 −1
| … | 897 unchanged lines |
||
| 898 | 898 | }), |
|
| 899 | 899 | ) |
|
| 900 | 900 | .route( |
|
| 901 | + | "/gitlab/token", |
|
| 902 | + | axum::routing::post(|| async { axum::Json(serde_json::json!({ "access_token": "gl-token", "token_type": "Bearer" })) }), |
|
| 903 | + | ) |
|
| 904 | + | .route( |
|
| 905 | + | "/gitlab/userinfo", |
|
| 906 | + | axum::routing::get(|headers: axum::http::HeaderMap| async move { |
|
| 907 | + | assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer gl-token")); |
|
| 908 | + | axum::Json(serde_json::json!({ "sub": "1234567", "nickname": "tanuki", "name": "The Tanuki", "email": "[email protected]", "email_verified": true })) |
|
| 909 | + | }), |
|
| 910 | + | ) |
|
| 911 | + | .route( |
|
| 901 | 912 | "/google/userinfo", |
|
| 902 | 913 | axum::routing::get(|| async { |
|
| 903 | 914 | axum::Json(serde_json::json!({ "sub": "1076915035000615071", "email": "[email protected]", "email_verified": true, "name": "Andy McCall" })) |
|
| … | 68 unchanged lines |
||
| 972 | 983 | let ok = send_with(app, "GET", &format!("/login/github/callback?code=c2&state={state}"), Some(&cookie), None).await; |
|
| 973 | 984 | assert_eq!(ok.location.as_deref(), Some("/")); |
|
| 974 | 985 | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2); |
|
| 986 | + | ||
| 987 | + | // GitLab (#213): a self-managed address is checked, then sign-in makes |
|
| 988 | + | // an account from the GitLab username. |
|
| 989 | + | let r = post( |
|
| 990 | + | app, |
|
| 991 | + | "/admin/signin", |
|
| 992 | + | Some(&andy), |
|
| 993 | + | &format!("_csrf={csrf}&provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=http%3A%2F%2Fgitlab.example.org"), |
|
| 994 | + | ) |
|
| 995 | + | .await; |
|
| 996 | + | assert!(r.body.contains("is an https:// address"), "{}", r.body); |
|
| 997 | + | let r = post( |
|
| 998 | + | app, |
|
| 999 | + | "/admin/signin", |
|
| 1000 | + | Some(&andy), |
|
| 1001 | + | &format!("_csrf={csrf}&provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=https%3A%2F%2Fgitlab.example.org%2F"), |
|
| 1002 | + | ) |
|
| 1003 | + | .await; |
|
| 1004 | + | assert!(r.body.contains("Signing in with GitLab is on.") && r.body.contains("value=\"https://gitlab.example.org\""), "{}", r.body); |
|
| 1005 | + | let r = post( |
|
| 1006 | + | app, |
|
| 1007 | + | "/admin/signin", |
|
| 1008 | + | Some(&andy), |
|
| 1009 | + | &format!("_csrf={csrf}&provider=github&client_id=gh-cid&enabled=1&base=https%3A%2F%2Fx.org"), |
|
| 1010 | + | ) |
|
| 1011 | + | .await; |
|
| 1012 | + | assert!(r.body.contains("GitHub has no address to set."), "{}", r.body); |
|
| 1013 | + | let login = get(app, "/login", None).await.body; |
|
| 1014 | + | assert!(login.contains("signin-btn gitlab") && login.contains("Sign in with GitLab"), "{login}"); |
|
| 1015 | + | let r = get(app, "/login/gitlab", None).await; |
|
| 1016 | + | assert!(r.location.clone().unwrap().contains("scope=openid%20profile%20email"), "{:?}", r.location); |
|
| 1017 | + | let (cookie, state) = state_of(&r); |
|
| 1018 | + | let ok = send_with(app, "GET", &format!("/login/gitlab/callback?code=c9&state={state}"), Some(&cookie), None).await; |
|
| 1019 | + | assert_eq!(ok.location.as_deref(), Some("/"), "{}", ok.body); |
|
| 1020 | + | let tanuki = db::accounts::account_by_name(&app.db, "tanuki").await.unwrap().expect("account made from the GitLab username"); |
|
| 1021 | + | assert!((tanuki.email.as_str(), tanuki.email_verified) == ("[email protected]", true), "{tanuki:?}"); |
|
| 1022 | + | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 3); |
|
| 975 | 1023 | ||
| 976 | 1024 | // Google, for an address Andy has verified: linked to Andy, signed in as Andy. |
|
| 977 | 1025 | let r = get(app, "/login/google", None).await; |
|
| … | 9 unchanged lines |
||
| 987 | 1035 | && sec.contains("GitHub: <a href=\"/settings/security/connect/github\">connect</a>"), |
|
| 988 | 1036 | "{sec}" |
|
| 989 | 1037 | ); |
|
| 990 | - | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2, "no third account"); |
|
| 1038 | + | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 3, "no fourth account"); |
|
| 991 | 1039 | // Disconnect, then connect again from Settings › Security. |
|
| 992 | 1040 | let csrf_s = csrf_of(&sec); |
|
| 993 | 1041 | let r = post(app, "/settings/security/disconnect/google", Some(&as_andy), &format!("_csrf={csrf_s}")).await; |
|
| … | 99 unchanged lines |
modified templates/admin.html +2 −0
| … | 64 unchanged lines |
||
| 65 | 65 | <h3>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h3> |
|
| 66 | 66 | <label for="cid-{{ p.id }}">Client id</label><input type="text" id="cid-{{ p.id }}" name="client_id" value="{{ p.client_id }}" autocomplete="off"> |
|
| 67 | 67 | <label for="sec-{{ p.id }}">Client secret</label><input type="password" id="sec-{{ p.id }}" name="secret" placeholder="{% if p.has_secret %}unchanged{% else %}none{% endif %}" autocomplete="new-password"> |
|
| 68 | + | {% if let Some(d) = p.default_base %}<label for="base-{{ p.id }}">{{ p.name }} address</label><input type="url" id="base-{{ p.id }}" name="base" value="{{ p.base }}" placeholder="{{ d }}" autocomplete="off"> |
|
| 69 | + | <p class="small mute">Empty for {{ d }}; or a self-managed {{ p.name }}, like https://gitlab.example.org. Register the hub there as an application with the scopes <code>openid profile email</code>.</p>{% endif %} |
|
| 68 | 70 | <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if p.enabled %} checked{% endif %}> on</label></div> |
|
| 69 | 71 | <p class="small mute">Callback URL to register with {{ p.name }}: <code>{{ p.callback }}</code></p> |
|
| 70 | 72 | <button type="submit">SAVE</button> |
|
| … | 29 unchanged lines |
modified templates/login.html +4 −1
| … | 6 unchanged lines |
||
| 7 | 7 | <div class="head"><h1>Sign in <small>{{ shell.site }}</small></h1></div> |
|
| 8 | 8 | {% if reset %}<p class="note">Your password is set. Sign in with it; every other session was signed out.</p>{% endif %} |
|
| 9 | 9 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 10 | + | <div class="signin-layout{% if !providers.is_empty() && totp_for.is_none() %} with-providers{% endif %}"> |
|
| 10 | 11 | <form class="card" method="post" action="/login"> |
|
| 11 | 12 | <input type="hidden" name="next" value="{{ next }}"> |
|
| 12 | 13 | {% if let Some(u) = totp_for %} |
|
| … | 7 unchanged lines |
||
| 20 | 21 | {% endif %} |
|
| 21 | 22 | <button type="submit">SIGN IN</button> |
|
| 22 | 23 | </form> |
|
| 23 | - | {% if !providers.is_empty() %}<div class="card signin-with"><p class="small mute">Or sign in with</p>{% for (id, name) in providers %}<a class="go" href="/login/{{ id }}">{{ name }}</a> {% endfor %}</div>{% endif %} |
|
| 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 %} |
|
| 26 | + | </div> |
|
| 24 | 27 | <p class="small mute"><a href="/login/reset">Forgot your password?</a> · No account? <a href="/register">Register</a>.</p> |
|
| 25 | 28 | {% endblock %} |
modified templates/register.html +4 −0
| … | 8 unchanged lines |
||
| 9 | 9 | {% if !first && mode == "closed" %} |
|
| 10 | 10 | <p class="empty">Registration is closed. Ask an administrator for an account.</p> |
|
| 11 | 11 | {% else %} |
|
| 12 | + | <div class="signin-layout"> |
|
| 12 | 13 | <form class="card" method="post" action="/register"> |
|
| 13 | 14 | <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}"> |
|
| 14 | 15 | <label for="email">Email</label><input type="email" id="email" name="email" autocomplete="email"> |
|
| … | 1 unchanged lines |
||
| 16 | 17 | {% if !first && mode == "invite" %}<label for="invite">Invitation code</label><input type="text" id="invite" name="invite" value="{{ invite }}" required>{% endif %} |
|
| 17 | 18 | <button type="submit">CREATE ACCOUNT</button> |
|
| 18 | 19 | </form> |
|
| 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 %} |
|
| 22 | + | </div> |
|
| 19 | 23 | {% endif %} |
|
| 20 | 24 | {% endblock %} |
modified templates/style.css +23 −0
| … | 1249 unchanged lines |
||
| 1250 | 1250 | .hash-tip { position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; cursor: help; border-bottom: 1px dotted currentColor; white-space: nowrap; } |
|
| 1251 | 1251 | .hash-tip::after { content: attr(data-full); position: absolute; z-index: 40; top: calc(100% + 6px); left: 0; width: min(34ch, 80vw); padding: 8px 10px; background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.15); white-space: normal; overflow-wrap: anywhere; word-break: break-all; line-height: 1.45; font-size: 12px; visibility: hidden; opacity: 0; transition: opacity .12s; pointer-events: none; text-transform: none; font-weight: 400; } |
|
| 1252 | 1252 | .hash-tip:hover::after, .hash-tip:focus::after { visibility: visible; opacity: 1; } |
|
| 1253 | + | /* Sign-in with a provider (oxygenhub#213): branded buttons beside the |
|
| 1254 | + | sign-in box, in the style of Gitter's (the provider's colour and logo). */ |
|
| 1255 | + | .signin-layout { display: flex; flex-wrap: wrap; align-items: center; gap: 24px; } |
|
| 1256 | + | .signin-layout > form.card { flex: 1 1 320px; margin: 0; } |
|
| 1257 | + | .signin-or { display: flex; flex-direction: column; align-items: center; align-self: stretch; color: var(--mute); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; } |
|
| 1258 | + | .signin-or::before, .signin-or::after { content: ""; flex: 1; width: 1px; background: var(--line); } |
|
| 1259 | + | .signin-or span { padding: 8px 0; } |
|
| 1260 | + | .signin-with { display: flex; flex-direction: column; gap: 10px; flex: 0 1 280px; min-width: 0; } |
|
| 1261 | + | /* `:root .signin-with` outranks the looks' link colours (assets/github.css). */ |
|
| 1262 | + | :root .signin-with a.signin-btn { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 11px 16px; border-radius: 4px; color: #fff; font-weight: 700; font-size: 13px; letter-spacing: .06em; text-transform: uppercase; text-decoration: none; border: 1px solid transparent; } |
|
| 1263 | + | :root .signin-with a.signin-btn svg { flex-shrink: 0; fill: currentColor; } |
|
| 1264 | + | :root .signin-with a.signin-btn:hover, :root .signin-with a.signin-btn:focus-visible { filter: brightness(1.08); text-decoration: none; color: #fff; } |
|
| 1265 | + | :root .signin-with a.signin-btn:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; } |
|
| 1266 | + | :root .signin-with a.signin-btn.gitlab { background: #cf3d1b; } /* GitLab's red-orange, dark enough for white text (4.8:1) */ |
|
| 1267 | + | :root .signin-with a.signin-btn.github { background: #24292f; } |
|
| 1268 | + | :root .signin-with a.signin-btn.google { background: #fff; color: #3c4043; border-color: #dadce0; } |
|
| 1269 | + | :root .signin-with a.signin-btn.google:hover, :root .signin-with a.signin-btn.google:focus-visible { color: #3c4043; background: #f8f9fa; filter: none; } |
|
| 1270 | + | @media (max-width: 700px) { |
|
| 1271 | + | .signin-or { flex-direction: row; width: 100%; } |
|
| 1272 | + | .signin-or::before, .signin-or::after { height: 1px; width: auto; } |
|
| 1273 | + | .signin-or span { padding: 0 8px; } |
|
| 1274 | + | .signin-with { flex-basis: 100%; } |
|
| 1275 | + | } |