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
254 lines
7.0 KiB
Go
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
|
|
}
|