//go:build unix package authn import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "golang.org/x/oauth2" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring" ) const ( githubWebURL = "https://github.com" githubAPIURL = "https://api.github.com" ) var ( // ErrGitHubUnauthorized means the cached GitHub token is no longer // usable and the next request should run a fresh browser login. ErrGitHubUnauthorized = errors.New("authn: GitHub token is unauthorized") // ErrGitHubOrganization means the authenticated user does not meet // the configured organization-membership requirement. ErrGitHubOrganization = errors.New("authn: GitHub organization membership denied") ) func githubEndpoint(issuer string) oauth2.Endpoint { base := strings.TrimRight(issuer, "/") if base == "" { base = githubWebURL } return oauth2.Endpoint{ AuthURL: base + "/login/oauth/authorize", TokenURL: base + "/login/oauth/access_token", AuthStyle: oauth2.AuthStyleInParams, } } func githubAPIBase(issuer string) string { if issuer == "" || strings.TrimRight(issuer, "/") == githubWebURL { return githubAPIURL } return strings.TrimRight(issuer, "/") } func providerScopes(cfg Config) []string { if cfg.ProviderType != ProviderGitHub { return append([]string(nil), cfg.Scopes...) } var scopes []string add := func(scope string) { for _, existing := range scopes { if existing == scope { return } } scopes = append(scopes, scope) } for _, scope := range cfg.Scopes { switch scope { case "openid": case "profile": add("read:user") case "email": add("user:email") default: add(scope) } } add("read:user") add("user:email") if cfg.Organization != "" { add("read:org") } add("offline_access") return scopes } func exchangeAndIdentifyGitHub(ctx context.Context, cfg Config, oauthCfg *oauth2.Config, pkce PKCE, code string, client *http.Client) (Result, error) { tok, err := oauthCfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", pkce.Verifier), ) if err != nil { return Result{}, fmt.Errorf("authn: GitHub token exchange: %w", err) } if tok.AccessToken == "" { return Result{}, errors.New("authn: GitHub token response missing access_token") } ts := keyring.TokenSet{ AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, AccessExpAt: tok.Expiry, RefreshExpAt: refreshExpiry(tok), ProviderType: ProviderGitHub, Organization: cfg.Organization, Issuer: githubIssuer(cfg.Issuer), ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Scopes: githubGrantedScopes(tok, providerScopes(cfg)), } if tok.RefreshToken == "" { ts.RefreshExpAt = time.Time{} } enriched, err := validateGitHubSession(ctx, cfg, ts, client) if err != nil { return Result{}, err } return Result{Tokens: enriched}, nil } func validateAndPersistGitHub(ctx context.Context, store keyring.Store, service string, cfg Config, ts keyring.TokenSet, client *http.Client) (keyring.TokenSet, error) { enriched, err := validateGitHubSession(ctx, cfg, ts, client) if err != nil { return ts, err } if err := store.Set(service, enriched); err != nil { return ts, fmt.Errorf("authn: persist GitHub session validation: %w", err) } return enriched, nil } func validateGitHubSession(ctx context.Context, cfg Config, ts keyring.TokenSet, client *http.Client) (keyring.TokenSet, error) { if client == nil { client = &http.Client{Timeout: 30 * time.Second} } apiBase := githubAPIBase(cfg.Issuer) var user struct { ID int64 `json:"id"` Login string `json:"login"` Name string `json:"name"` Email string `json:"email"` } if err := githubGet(ctx, client, ts.AccessToken, apiBase+"/user", &user); err != nil { return ts, fmt.Errorf("authn: GitHub user lookup: %w", err) } if user.ID == 0 || user.Login == "" { return ts, errors.New("authn: GitHub user response is missing id or login") } if user.Email == "" { var emails []struct { Email string `json:"email"` Primary bool `json:"primary"` Verified bool `json:"verified"` } if err := githubGet(ctx, client, ts.AccessToken, apiBase+"/user/emails", &emails); err != nil { return ts, fmt.Errorf("authn: GitHub email lookup: %w", err) } for _, email := range emails { if email.Primary && email.Verified { user.Email = email.Email break } } if user.Email == "" { for _, email := range emails { if email.Verified { user.Email = email.Email break } } } } if cfg.Organization != "" { var membership struct { State string `json:"state"` } endpoint := apiBase + "/user/memberships/orgs/" + url.PathEscape(cfg.Organization) if err := githubGet(ctx, client, ts.AccessToken, endpoint, &membership); err != nil { var statusErr *githubHTTPError if errors.As(err, &statusErr) && statusErr.Code == http.StatusNotFound { return ts, fmt.Errorf("%w: organization %q membership was not found", ErrGitHubOrganization, cfg.Organization) } return ts, fmt.Errorf("authn: GitHub organization %q membership check failed: %w", cfg.Organization, err) } if membership.State != "active" { return ts, fmt.Errorf("%w: GitHub user %q is not an active member of organization %q", ErrGitHubOrganization, user.Login, cfg.Organization) } } ts.ProviderType = ProviderGitHub ts.Organization = cfg.Organization ts.Issuer = githubIssuer(cfg.Issuer) ts.Subject = strconv.FormatInt(user.ID, 10) ts.Name = user.Name if ts.Name == "" { ts.Name = user.Login } ts.Email = user.Email ts.ValidatedAt = time.Now() return ts, nil } func githubGet(ctx context.Context, client *http.Client, token, endpoint string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2026-03-10") resp, err := client.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode == http.StatusUnauthorized { return fmt.Errorf("%w: HTTP %d: %s", ErrGitHubUnauthorized, resp.StatusCode, strings.TrimSpace(string(body))) } return &githubHTTPError{Code: resp.StatusCode, Body: strings.TrimSpace(string(body))} } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out); err != nil { return fmt.Errorf("decode response: %w", err) } return nil } type githubHTTPError struct { Code int Body string } func (e *githubHTTPError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.Code, e.Body) } func githubIssuer(issuer string) string { if issuer != "" { return strings.TrimRight(issuer, "/") } return githubWebURL } func githubGrantedScopes(tok *oauth2.Token, requested []string) []string { if scope, ok := tok.Extra("scope").(string); ok && scope != "" { return strings.FieldsFunc(scope, func(r rune) bool { return r == ' ' || r == ',' }) } return requested }