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>
6 files changed,
+431
−17
changelog/2026-07-19-github-signin.md+4 −0home/github.go+199 −0home/github_test.go+130 −0home/main.go+22 −0home/web/pages/index.html+12 −5home/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; |