Files
Alexandru Macocian c73305589e
Charlie/project-charlie: Build image / config (push) Successful in 1s
Charlie/project-charlie: Build image / build (push) Skipped
Charlie/project-charlie: Deploy runners / config (push) Successful in 2s
Charlie/project-charlie: Deploy runners / deploy-morgott (push) Skipped
Charlie/project-charlie: Deploy runners / deploy-melina (push) Skipped
Charlie/project-charlie: Deploy stack / config (push) Successful in 1s
Release / release (push) Failing after 7s
Charlie/project-charlie: Deploy stack / deploy (push) Skipped
Support for GH
2026-09-07 18:25:16 +02:00

254 lines
7.0 KiB
Go

//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
}