From f3b9f6f2866d886362f702f6d7ac687dab57e2a7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:48:19 +0100 Subject: [PATCH] feat: add GitHub OAuth2 provider branch --- server/internal/auth/github.go | 88 ++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/server/internal/auth/github.go b/server/internal/auth/github.go index d8284f2..287f125 100644 --- a/server/internal/auth/github.go +++ b/server/internal/auth/github.go @@ -2,19 +2,97 @@ package auth import ( "context" + "encoding/json" "errors" + "net/http" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" "golang.org/x/oauth2" ) -// githubOAuthConfig and githubIdentity are temporary stubs. Task 7 replaces -// this file with the real GitHub OAuth2 provider implementation. +// githubAPIBase is a variable rather than a constant so tests can point it at +// an httptest server. Nothing in production reassigns it. +var githubAPIBase = "https://api.github.com" +const ( + githubAuthURL = "https://github.com/login/oauth/authorize" + githubTokenURL = "https://github.com/login/oauth/access_token" +) + +type githubEmail struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` +} + +// githubOAuthConfig builds the OAuth2 config for a GitHub provider. GitHub has +// no discovery document, so the endpoints are constants rather than fetched. func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oauth2.Config { - return nil + return &oauth2.Config{ + ClientID: p.ClientID, + ClientSecret: secret, + RedirectURL: redirectURL, + Scopes: p.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: githubAuthURL, + TokenURL: githubTokenURL, + }, + } } -func githubIdentity(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (string, string, error) { - return "", "", errors.New("not implemented") +// selectGitHubEmail requires an address that is both primary and verified. +// +// Verified alone is not enough: a non-primary address is one the person happens +// to have proved, not the one they present as themselves. Primary alone is far +// worse — an unverified address is not proof of control at all, and accepting +// one would let anyone with a GitHub account claim any address in the instance. +func selectGitHubEmail(emails []githubEmail) (string, error) { + for _, e := range emails { + if e.Primary && e.Verified && e.Email != "" { + return e.Email, nil + } + } + return "", errors.New("no primary verified email address on the GitHub account") +} + +func githubGetJSON(ctx context.Context, client *http.Client, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New("github api returned " + resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// githubIdentity resolves the signed-in GitHub account to an email and a name. +// +// The name is best-effort: it is cosmetic, and a failing /user must not fail a +// sign-in whose identity is already established. +func githubIdentity(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (string, string, error) { + client := cfg.Client(ctx, token) + + var emails []githubEmail + if err := githubGetJSON(ctx, client, githubAPIBase+"/user/emails", &emails); err != nil { + return "", "", err + } + email, err := selectGitHubEmail(emails) + if err != nil { + return "", "", err + } + + var user struct { + Name string `json:"name"` + } + if err := githubGetJSON(ctx, client, githubAPIBase+"/user", &user); err != nil { + return email, "", nil + } + return email, user.Name, nil }