eleven / messenger Public

home: the GitHub door β€” code exchange, server-side (#1162)

GitHub mints no id_tokens, so github.go exchanges the code itself (the
one door whose client SECRET home holds; per-instance app, so the client
id rides the page as a substituted meta tag). The GET half of the
trampoline bounces the single-use code β€” worthless without the secret β€”
and the verify answers the exact oidcVerify contract into the same
anchor tables under provider "github". Client: OIDC_WEB entry with a
per-entry payload (code vs id_token+nonce), the #oauth= return leg, and
the octocat button below the divider. Faked-GitHub tests pin the
exchange, resolve-or-create on the numeric id, and both trampoline legs.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cb512e1888d70958ee6ae0058b0d12561adc0d9f parent 0012f85
6 files changed, +431 −17
  • changelog/2026-07-19-github-signin.md +4 −0
  • home/github.go +199 −0
  • home/github_test.go +130 −0
  • home/main.go +22 −0
  • home/web/pages/index.html +12 −5
  • home/web/static/home.js +64 −12
diff --git a/changelog/2026-07-19-github-signin.md b/changelog/2026-07-19-github-signin.md
new file mode 100644
index 0000000..4489d45
--- /dev/null
+++ b/changelog/2026-07-19-github-signin.md
@@ -0,0 +1,4 @@
+πŸ€– For the nerds: GitHub is now a sign-in door. Like every other way in,
+GitHub only confirms who you are β€” Eleven keeps a scrambled fingerprint of
+your account number, never your login, name, or email. Find it under
+"Other ways to sign in".
diff --git a/home/github.go b/home/github.go
new file mode 100644
index 0000000..b5fd7e4
--- /dev/null
+++ b/home/github.go
@@ -0,0 +1,199 @@
+package main
+
+// πŸ€– Sign in with GitHub β€” the code-exchange door (#1112). GitHub mints no
+// OIDC id_tokens, so unlike the Google/Microsoft table (oidc.go) the
+// exchange happens HERE, with the client secret this server holds. The
+// return trampoline stays blind: GET /oidc/return bounces the single-use
+// ?code= to the page fragment (worthless without the secret), the page
+// posts it to /api/oidc/github/verify, and this handler exchanges it,
+// reads the numeric user id from /user, and feeds HMAC(pepper, id) into
+// the SAME anchor machinery as every other provider β€” the access token
+// and profile are discarded on the spot; only the peppered hash survives.
+// Anchors store under provider name "github" in the oidc tables, so the
+// listing, removal, and merge paths need no new cases.
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Test seams β€” the exchange and user endpoints, swappable to an httptest rig.
+var (
+ githubTokenURL = "https://github.com/login/oauth/access_token"
+ githubUserURL = "https://api.github.com/user"
+)
+
+type githubDoor struct {
+ clientID string
+ secret string
+ pepper []byte
+}
+
+func (g *githubDoor) subHash(sub string) string {
+ m := hmac.New(sha256.New, g.pepper)
+ m.Write([]byte(sub))
+ return hex.EncodeToString(m.Sum(nil))
+}
+
+// resolveSub runs the whole GitHub side: code β†’ access token β†’ /user β†’ the
+// stable numeric id. Everything transient dies with this call.
+func (g *githubDoor) resolveSub(ctx context.Context, code string) (string, error) {
+ client := &http.Client{Timeout: 10 * time.Second}
+
+ form := url.Values{
+ "client_id": {g.clientID},
+ "client_secret": {g.secret},
+ "code": {code},
+ }
+ req, err := http.NewRequestWithContext(ctx, "POST", githubTokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ res, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ var tok struct {
+ AccessToken string `json:"access_token"`
+ }
+ if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&tok); err != nil {
+ return "", err
+ }
+ if tok.AccessToken == "" {
+ return "", errors.New("no access token") // bad/expired/replayed code
+ }
+
+ ureq, err := http.NewRequestWithContext(ctx, "GET", githubUserURL, nil)
+ if err != nil {
+ return "", err
+ }
+ ureq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ ureq.Header.Set("Accept", "application/vnd.github+json")
+ ures, err := client.Do(ureq)
+ if err != nil {
+ return "", err
+ }
+ defer ures.Body.Close()
+ var user struct {
+ ID int64 `json:"id"`
+ }
+ if err := json.NewDecoder(io.LimitReader(ures.Body, 64<<10)).Decode(&user); err != nil {
+ return "", err
+ }
+ if user.ID == 0 {
+ return "", errors.New("no user id")
+ }
+ // The numeric id is the stable subject (logins rename; ids don't).
+ return strconv.FormatInt(user.ID, 10), nil
+}
+
+// githubVerify is POST /api/oidc/github/verify β€” the code-flow sibling of
+// oidcVerify, answering the identical contract (token + proof + created;
+// link:true + X-Ceremony-Proof attaches; conflict is the combine 409). An
+// unconfigured door is a 404, indistinguishable from a nonexistent one.
+func (s *Server) githubVerify(w http.ResponseWriter, r *http.Request) {
+ if s.github == nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ var req struct {
+ Code string `json:"code"`
+ Link bool `json:"link"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil || req.Code == "" || len(req.Code) > 512 {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ sub, err := s.github.resolveSub(r.Context(), req.Code)
+ if err != nil {
+ http.Error(w, "that sign-in couldn't be confirmed", http.StatusUnauthorized)
+ return
+ }
+ subHash := s.github.subHash(sub)
+
+ if req.Link {
+ uid, ok := s.ceremonyUser(r)
+ if !ok {
+ http.Error(w, "confirm it's you first", http.StatusUnauthorized)
+ return
+ }
+ conflict, err := s.store.insertOidcAnchor(uid, "github", subHash)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if conflict {
+ http.Error(w, "that GitHub account already signs in somewhere else", http.StatusConflict)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"linked": true})
+ return
+ }
+
+ uid, ok := s.store.oidcUser("github", subHash)
+ created := false
+ if !ok {
+ id, err := randToken()
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if err := s.store.createUser(id); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if _, err := s.store.insertOidcAnchor(id, "github", subHash); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ uid, created = id, true
+ }
+ tok, err := s.store.createLinkToken(uid)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "token": tok,
+ "proof": s.keys.mintCeremonyProof(uid),
+ "created": created,
+ })
+}
+
+// oauthCodeReturn is GET /oidc/return β€” the code-flow half of the return
+// trampoline (the POST half stays form_post for the id_token providers).
+// Provider-blind like its sibling: it bounces ?code= onward in the fragment
+// and never touches it; only the page (which minted the state) knows which
+// door was opened. Gated on the code-flow door existing at all.
+func (s *Server) oauthCodeReturn(w http.ResponseWriter, r *http.Request) {
+ if s.github == nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ code := r.URL.Query().Get("code")
+ state := r.URL.Query().Get("state")
+ if code == "" || len(code) > 512 || len(state) > 512 {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if strings.HasPrefix(state, "web.") {
+ http.Redirect(w, r, "/#oauth="+url.QueryEscape(code)+
+ "&state="+url.QueryEscape(state), http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, oidcReturnScheme+"#state="+url.QueryEscape(state)+
+ "&code="+url.QueryEscape(code), http.StatusSeeOther)
+}
diff --git a/home/github_test.go b/home/github_test.go
new file mode 100644
index 0000000..51957a0
--- /dev/null
+++ b/home/github_test.go
@@ -0,0 +1,130 @@
+package main
+
+// πŸ€– GitHub code-flow door tests (github.go, #1112): a fake GitHub β€” one
+// httptest server playing both the token-exchange and /user endpoints β€”
+// swapped in via the package URL seams. The oidc suite covers the shared
+// anchor machinery; these pin the exchange contract, the resolve-or-create
+// path keying on the numeric id, and the GET half of the return trampoline.
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// fakeGithub answers the exchange with a token whose validity it controls,
+// and /user with a fixed numeric id. Codes prefixed "bad" refuse.
+func fakeGithub(t *testing.T, userID int64) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil || r.PostFormValue("client_secret") != "gh-secret" {
+ w.Write([]byte(`{"error":"bad_client"}`))
+ return
+ }
+ if strings.HasPrefix(r.PostFormValue("code"), "bad") {
+ w.Write([]byte(`{"error":"bad_verification_code"}`))
+ return
+ }
+ w.Write([]byte(`{"access_token":"gho_test","token_type":"bearer"}`))
+ })
+ mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer gho_test" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ fmt.Fprintf(w, `{"id":%d,"login":"someone"}`, userID)
+ })
+ fake := httptest.NewServer(mux)
+ t.Cleanup(fake.Close)
+ return fake
+}
+
+func githubServer(t *testing.T, userID int64) (*httptest.Server, *Server) {
+ t.Helper()
+ ts, s := newTestServer(t)
+ fake := fakeGithub(t, userID)
+ oldTok, oldUser := githubTokenURL, githubUserURL
+ githubTokenURL = fake.URL + "/login/oauth/access_token"
+ githubUserURL = fake.URL + "/user"
+ t.Cleanup(func() { githubTokenURL, githubUserURL = oldTok, oldUser })
+ s.github = &githubDoor{clientID: "gh-client", secret: "gh-secret", pepper: []byte("gh-pepper")}
+ return ts, s
+}
+
+func githubVerifyReq(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response {
+ t.Helper()
+ b, _ := json.Marshal(body)
+ return do(t, "POST", ts.URL+"/api/oidc/github/verify", "", headers, b)
+}
+
+func TestGithubDisabledIs404(t *testing.T) {
+ ts, _ := newTestServer(t)
+ if resp := githubVerifyReq(t, ts, nil, map[string]any{"code": "x"}); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("disabled verify: got %d, want 404", resp.StatusCode)
+ }
+ if resp := do(t, "GET", ts.URL+"/oidc/return?code=x&state=web.x", "", nil, nil); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("disabled GET return: got %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestGithubSignInCreatesThenResolvesAccount(t *testing.T) {
+ ts, _ := githubServer(t, 4242)
+ var first struct {
+ Token string `json:"token"`
+ Proof string `json:"proof"`
+ Created bool `json:"created"`
+ }
+ resp := githubVerifyReq(t, ts, nil, map[string]any{"code": "good-1"})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("first sign-in: got %d, want 200", resp.StatusCode)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&first); err != nil || first.Token == "" || first.Proof == "" || !first.Created {
+ t.Fatalf("first sign-in: bad body (%+v, err %v)", first, err)
+ }
+
+ // The same numeric id β€” through a different code β€” is the SAME account.
+ var second struct {
+ Token string `json:"token"`
+ Created bool `json:"created"`
+ }
+ resp = githubVerifyReq(t, ts, nil, map[string]any{"code": "good-2"})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("second sign-in: got %d, want 200", resp.StatusCode)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&second); err != nil || second.Created {
+ t.Fatalf("second sign-in should resolve, not create (%+v, err %v)", second, err)
+ }
+}
+
+func TestGithubBadCodeIs401(t *testing.T) {
+ ts, _ := githubServer(t, 4242)
+ if resp := githubVerifyReq(t, ts, nil, map[string]any{"code": "bad-code"}); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("bad code: got %d, want 401", resp.StatusCode)
+ }
+}
+
+func TestGithubReturnTrampoline(t *testing.T) {
+ ts, _ := githubServer(t, 4242)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := client.Get(ts.URL + "/oidc/return?code=abc&state=web.r1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("web state: got %d, want 303", resp.StatusCode)
+ }
+ if loc := resp.Header.Get("Location"); loc != "/#oauth=abc&state=web.r1" {
+ t.Fatalf("web state location: %q", loc)
+ }
+ resp, err = client.Get(ts.URL + "/oidc/return?code=abc&state=mac.r2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, oidcReturnScheme+"#state=mac.r2&code=abc") {
+ t.Fatalf("native state location: %q", loc)
+ }
+}
diff --git a/home/main.go b/home/main.go
index 88128e4..2104055 100644
--- a/home/main.go
+++ b/home/main.go
@@ -63,6 +63,7 @@ type Server struct {
// ids + pepper, both or neither); empty/nil β†’ every /api/oidc/* and
// /oidc/return answers 404. Apple stays on the dedicated fields above.
oidc map[string]*oidcProvider
+ github *githubDoor // the code-exchange door (github.go); nil = dark
// The email anchor (email.go): the SES mailer, the blind-index pepper,
// and the in-memory sign-in codes. mail nil or pepper empty β†’ off (404).
mail *sesMailer
@@ -201,6 +202,18 @@ func main() {
s.oidc[pc.name] = p
log.Printf("sign in with %s on (%d client ids)", p.display, len(p.clientIDs))
}
+ // GitHub is the one code-flow door (github.go): a single client id +
+ // SECRET per instance (the prod and dev homes are separate GitHub OAuth
+ // apps), plus the usual durable pepper. All three or none.
+ ghID, ghSecret, ghPepper := os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID"),
+ os.Getenv("LCHAT_HOME_GITHUB_CLIENT_SECRET"), os.Getenv("LCHAT_HOME_GITHUB_PEPPER")
+ if (ghID == "") != (ghSecret == "") || (ghID == "") != (ghPepper == "") {
+ log.Fatal("sign in with GitHub needs LCHAT_HOME_GITHUB_CLIENT_ID, _CLIENT_SECRET, and _PEPPER together (or none)")
+ }
+ if ghID != "" {
+ s.github = &githubDoor{clientID: ghID, secret: ghSecret, pepper: []byte(ghPepper)}
+ log.Printf("sign in with GitHub on")
+ }
if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil {
log.Printf("space provisioning via %s", s.cloud.url)
}
@@ -318,6 +331,10 @@ func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, er
if err != nil {
return nil, err
}
+ // The GitHub client id is per-instance (separate OAuth apps per home),
+ // so it rides the page as a meta tag; empty env leaves the door dark.
+ page = bytes.ReplaceAll(page, []byte("__GITHUB_CLIENT_ID__"),
+ []byte(os.Getenv("LCHAT_HOME_GITHUB_CLIENT_ID")))
enrollPage, err := webFS.ReadFile("web/pages/enroll.html")
if err != nil {
return nil, err
@@ -423,6 +440,11 @@ func (s *Server) handler() http.Handler {
// trampoline mirroring /siwa/return. Apple keeps its dedicated routes.
mux.HandleFunc("POST /api/oidc/{provider}/verify", s.rateLimited(s.oidcVerify))
mux.HandleFunc("POST /oidc/return", s.rateLimited(s.oidcReturn))
+ // GitHub (the code-flow door): a literal pattern wins over the
+ // {provider} wildcard above, and the GET return is the code-flow half
+ // of the trampoline (form_post keeps the POST half).
+ mux.HandleFunc("POST /api/oidc/github/verify", s.rateLimited(s.githubVerify))
+ mux.HandleFunc("GET /oidc/return", s.rateLimited(s.oauthCodeReturn))
// The email anchor (email.go): request a sign-in code, redeem it.
mux.HandleFunc("POST /api/email/request", s.rateLimited(s.emailRequest))
mux.HandleFunc("POST /api/email/verify", s.rateLimited(s.emailVerify))
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 79c1b6a..5a1d135 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="github-client-id" content="__GITHUB_CLIENT_ID__">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f6f7fb" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#15171c" media="(prefers-color-scheme: dark)">
<title>Sign in with Eleven</title>
@@ -56,6 +57,13 @@
position: relative; /* anchors the loading ring */
}
.google-btn.hidden, .ms-btn.hidden { display: none; }
+ .gh-btn {
+ display: flex; align-items: center; justify-content: center; gap: 8px;
+ background: #24292f; color: #fff;
+ border: 1px solid light-dark(#24292f, rgba(255,255,255,.28));
+ position: relative; /* anchors the loading ring */
+ }
+ .gh-btn.hidden { display: none; }
/* A working button wears a ring (Apple hand-over, Send code): the wait
needs a face even when the server is instant. White reads on both the
black and accent buttons β€” the only wearers. Reduced motion keeps a
@@ -754,7 +762,7 @@
// home.js lifts this at the end of module init on every path.
document.documentElement.classList.add("composing");
if (location.hash.indexOf("#siwa=") === 0 || location.hash.indexOf("#oidc=") === 0 ||
- location.hash.indexOf("#email=") === 0)
+ location.hash.indexOf("#oauth=") === 0 || location.hash.indexOf("#email=") === 0)
document.documentElement.classList.add("booting", "siwa-wait");
else if ((sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek")) ||
+localStorage.getItem("home_session_expires") > Date.now())
@@ -805,6 +813,7 @@
<button id="apple-signin" class="apple-btn" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
<button id="google-signin" class="google-btn hidden" type="button"><svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>Continue with Google</button>
<button id="microsoft-signin" class="ms-btn hidden" type="button"><svg viewBox="0 0 21 21" width="15" height="15" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>Continue with Microsoft</button>
+ <button id="github-signin" class="gh-btn hidden" type="button"><svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>Continue with GitHub</button>
<button id="email-signin" class="secondary" type="button">Continue with email</button>
<button id="signin" class="passkey-link" type="button"><svg class="pk-ico" viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><circle cx="6" cy="4.4" r="2.7"/><path d="M1.2 13.9a4.8 4.8 0 0 1 9.6 0z"/><path fill-rule="evenodd" d="M12.1 7.3a2.1 2.1 0 0 1 2.06 2.52l1.6 1.6-1.2 1.2-.7-.7-.65.65-.85-.85-.62.62a2.1 2.1 0 1 1 .36-4.94zm-.9 1.6a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6z"/></svg>Sign in with a passkey</button>
<button id="other-ways" class="passkey-link" type="button">Other ways to sign in</button>
@@ -866,9 +875,7 @@
<button id="otherways-apple" class="apple-btn" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
<button id="otherways-google" class="google-btn" type="button"><svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>Continue with Google</button>
<button id="otherways-microsoft" class="ms-btn" type="button"><svg viewBox="0 0 21 21" width="15" height="15" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>Continue with Microsoft</button>
- <!-- GitHub's door ("for the nerds"), all-ways only, never a platform
- default: DOORS.github plus
- <button id="otherways-github" class="secondary" type="button">Continue with GitHub</button> -->
+ <button id="otherways-github" class="gh-btn" type="button"><svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>Continue with GitHub</button>
</div>
</div>
diff --git a/home/web/static/home.js b/home/web/static/home.js
index e4f4df4..afe9a3b 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -217,9 +217,9 @@ async function saveVault() {
// door mid-code-round-trip only confuses. The passkey controls additionally
// stay off in a browser with no WebAuthn (the Apple/email doors still work).
function setSignedOutBusy(busy) {
- for (const id of ["apple-signin", "google-signin", "microsoft-signin", "email-signin",
- "email-send", "email-verify", "other-ways", "otherways-apple", "otherways-google",
- "otherways-microsoft", "otherways-email"]) $(id).disabled = busy;
+ for (const id of ["apple-signin", "google-signin", "microsoft-signin", "github-signin",
+ "email-signin", "email-send", "email-verify", "other-ways", "otherways-apple",
+ "otherways-google", "otherways-microsoft", "otherways-github", "otherways-email"]) $(id).disabled = busy;
for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) {
$(id).disabled = busy || !window.PublicKeyCredential;
}
@@ -254,8 +254,11 @@ const DOORS = {
passkey: true,
google: true, // Android/ChromeOS's platform door (client id below)
microsoft: true, // Windows's platform door (client id below)
- github: false, // all-ways only ("for the nerds") β€” never a platform default
+ github: false, // set below β€” lit by the page's per-instance client id
};
+const GITHUB_CLIENT_ID =
+ (document.querySelector('meta[name="github-client-id"]') || {}).content || "";
+DOORS.github = !!GITHUB_CLIENT_ID && GITHUB_CLIENT_ID !== "__GITHUB_CLIENT_ID__";
// platformDefault picks the door for a browser with no last-used marker: the
// OS vendor's account, since that's the one a person most likely holds.
@@ -294,6 +297,8 @@ function composeCard() {
const primary = primaryDoor();
const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin");
const google = $("google-signin"), microsoft = $("microsoft-signin");
+ const github = $("github-signin");
+ github.classList.toggle("hidden", primary !== "github");
apple.classList.toggle("hidden", primary !== "apple");
google.classList.toggle("hidden", primary !== "google");
microsoft.classList.toggle("hidden", primary !== "microsoft");
@@ -304,7 +309,7 @@ function composeCard() {
passkey.className = primary === "passkey" ? "" : "passkey-link hidden";
// DOM order is focus order: the one door, then the link. append MOVES the
// existing (already-wired) nodes; hidden doors stay parked, unfocusable.
- $("main-doors").append({ apple, email, passkey, google, microsoft }[primary], $("other-ways"));
+ $("main-doors").append({ apple, email, passkey, google, microsoft, github }[primary], $("other-ways"));
}
async function signIn() {
@@ -618,6 +623,17 @@ const GOOGLE_CLIENT_ID =
"981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com";
const MICROSOFT_CLIENT_ID = "25874cf3-213c-4124-9641-65de8a513787";
const OIDC_WEB = {
+ github: {
+ display: "GitHub",
+ verify: "/api/oidc/github/verify",
+ // Code flow, not OIDC: no nonce; the server exchanges the code with
+ // the secret it holds (github.go) and answers the same contract.
+ payload: (cred) => ({ code: cred }),
+ authorize: (st) => "https://github.com/login/oauth/authorize" +
+ "?client_id=" + encodeURIComponent(GITHUB_CLIENT_ID) +
+ "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") +
+ "&state=" + encodeURIComponent(st),
+ },
microsoft: {
display: "Microsoft",
verify: "/api/oidc/microsoft/verify",
@@ -676,14 +692,18 @@ function startOidcSignIn(provider, e, opts = {}) {
// finishOidc β€” finishSiwa's twin against /api/oidc/{provider}/verify: the
// same one-verifier answer (token + proof), the same anchor-session landing
// under the waiting face.
-async function finishOidc(rec, idToken, backToMethods = false, intentStr = "") {
+function oidcPayload(p, cred, rec) {
+ return p.payload ? p.payload(cred) : { identity_token: cred, nonce: rec.nonce };
+}
+
+async function finishOidc(rec, cred, backToMethods = false, intentStr = "") {
const p = OIDC_WEB[rec.provider];
setStatus("Confirming it’s you…", "busy");
try {
const res = await apiJSON(p.verify, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ identity_token: idToken, nonce: rec.nonce }),
+ body: JSON.stringify(oidcPayload(p, cred, rec)),
});
noteProof(res.proof);
markSignedIn(rec.provider);
@@ -717,7 +737,7 @@ async function finishOidc(rec, idToken, backToMethods = false, intentStr = "") {
// the stashed proof). The 409's combine offer is deliberately NOT wired for
// OIDC providers yet β€” the combine prove step speaks Apple and email today;
// the refusal shows verbatim and combining goes via those doors.
-async function finishOidcLink(rec, idToken, markRaw) {
+async function finishOidcLink(rec, cred, markRaw) {
const p = OIDC_WEB[rec.provider];
let mark = null;
try { mark = JSON.parse(markRaw); } catch { /* mangled marker β€” treat as lapsed */ }
@@ -729,7 +749,7 @@ async function finishOidcLink(rec, idToken, markRaw) {
const res = await fetch(p.verify, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof },
- body: JSON.stringify({ identity_token: idToken, nonce: rec.nonce, link: true }),
+ body: JSON.stringify({ ...oidcPayload(p, cred, rec), link: true }),
});
if (res.ok) {
outcome = { msg: p.display + " is now a way into your account.", kind: "ok" };
@@ -1142,6 +1162,8 @@ const METHOD_KINDS = [
enabled: () => DOORS.google, start: (e) => startAddGoogle(e) },
{ kind: "microsoft", name: "Microsoft", first: "Add Microsoft",
enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) },
+ { kind: "github", name: "GitHub", first: "Add GitHub",
+ enabled: () => DOORS.github, start: (e) => startAddOidc("github", e) },
{ kind: "email", name: "Email", first: "Add email",
enabled: () => DOORS.email, start: (e) => startAddEmail(e) },
];
@@ -1208,7 +1230,7 @@ function intentToken(intent) {
return intent.kind; // add-email | add-passkey | add-apple
}
function parseIntentToken(s) {
- if (["add-email", "add-passkey", "add-apple", "add-google", "add-microsoft"].includes(s)) return { kind: s };
+ if (["add-email", "add-passkey", "add-apple", "add-google", "add-microsoft", "add-github"].includes(s)) return { kind: s };
const m = /^remove:([^:]+):(.+)$/.exec(s || "");
return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null;
}
@@ -1222,6 +1244,7 @@ function runIntent(intent, proof) {
case "add-apple": startAddApple(null); return; // pocket is fresh β€” straight to Apple in link mode
case "add-google": startAddOidc("google", null); return;
case "add-microsoft": startAddOidc("microsoft", null); return;
+ case "add-github": startAddOidc("github", null); return;
case "verify-email":
// In-memory only (never serialized): retry the exact typed code β€” it
// survived, link mode gates on the proof BEFORE redeeming (email.go).
@@ -3623,6 +3646,7 @@ $("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError
$("apple-signin").addEventListener("click", startAppleSignIn);
$("google-signin").addEventListener("click", (e) => startOidcSignIn("google", e));
$("microsoft-signin").addEventListener("click", (e) => startOidcSignIn("microsoft", e));
+$("github-signin").addEventListener("click", (e) => startOidcSignIn("github", e));
// The instant the Apple confirmation proceeds, this page navigates away β€”
// the waiting face covers whatever frames remain (bfcache return releases it).
window.addEventListener("pagehide", engageSiwaCover);
@@ -3647,6 +3671,7 @@ $("otherways-back").addEventListener("click", closeOtherWays);
$("otherways-apple").addEventListener("click", startAppleSignIn);
$("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e));
$("otherways-microsoft").addEventListener("click", (e) => startOidcSignIn("microsoft", e));
+$("otherways-github").addEventListener("click", (e) => startOidcSignIn("github", e));
$("otherways-email").addEventListener("click", openEmailDoor);
$("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError));
@@ -3714,6 +3739,33 @@ if (!siwaBooting) {
}
}
+// The code-flow return leg (GitHub) lands as #oauth=<code>&state=web.<r>
+// (github.go's GET trampoline). Same one-shot consumption; the code is
+// single-use and worthless without the server-held secret either way.
+let oauthBooting = false; // claims the boot β€” the cold-open path below stands down
+if (!siwaBooting && !oidcBooting) {
+ const m = location.hash.match(/^#oauth=([^&]*)&state=([^&]*)$/);
+ if (m) {
+ oauthBooting = true;
+ history.replaceState(null, "", location.pathname + location.search);
+ let rec = null;
+ try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ }
+ sessionStorage.removeItem("home_oidc_state");
+ const linkMark = sessionStorage.getItem("home_siwa_link");
+ const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
+ sessionStorage.removeItem("home_siwa_link");
+ sessionStorage.removeItem("home_methods_return");
+ sessionStorage.removeItem("home_methods_intent");
+ if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) {
+ if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark);
+ else finishOidc(rec, decodeURIComponent(m[1]), false, methodsIntent);
+ } else {
+ finishHomeLoader({ immediate: true });
+ setStatus("That sign-in didn’t start here β€” try again.", "error");
+ }
+ }
+}
+
// The mail's magic link (email.go) lands here as #email=<address>&code=<6
// digits> β€” the same values the mail asks you to type, riding the fragment,
// which never reaches any server. It runs the ordinary /api/email/verify, so
@@ -3722,7 +3774,7 @@ if (!siwaBooting) {
// hash clears before the verify, so a reload can't rerun the (single-use
// anyway) code.
let emailLinkBooting = false; // claims the boot β€” the cold-open path below stands down
-if (!siwaBooting && !oidcBooting && location.hash.indexOf("#email=") === 0) {
+if (!siwaBooting && !oidcBooting && !oauthBooting && location.hash.indexOf("#email=") === 0) {
emailLinkBooting = true;
// The pre-paint gate already put the waiting face up (html.siwa-wait) β€”
// the clean card never shows.
@@ -3852,7 +3904,7 @@ if (state.token && state.dek) {
} catch { /* token or DEK went stale; the button still works */ }
finally { finishHomeLoader(); }
})();
-} else if (!siwaBooting && !oidcBooting && !emailLinkBooting) {
+} else if (!siwaBooting && !oidcBooting && !oauthBooting && !emailLinkBooting) {
// The cold open (stay signed in, Option A): no tab session, but a live
// persisted token in localStorage. Adopt it and land exactly like an anchor
// sign-in β€” the sealed prompt when a blob exists, the empty list when not;