228 lines
7.8 KiB
Go
228 lines
7.8 KiB
Go
|
|
package authclient
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Run executes a caller command. Output contains only instructions or status.
|
||
|
|
func Run(ctx context.Context, args []string, stderr io.Writer) error {
|
||
|
|
if len(args) == 0 {
|
||
|
|
return errors.New("expected login or service-token")
|
||
|
|
}
|
||
|
|
mode := args[0]
|
||
|
|
if mode != "login" && mode != "service-token" {
|
||
|
|
return errors.New("unknown authentication command")
|
||
|
|
}
|
||
|
|
fs := flag.NewFlagSet(mode, flag.ContinueOnError)
|
||
|
|
fs.SetOutput(stderr)
|
||
|
|
issuer := fs.String("issuer", "", "HTTPS issuer")
|
||
|
|
id := fs.String("client-id", "", "registered client ID")
|
||
|
|
audience := fs.String("audience", "", "expected access audience (defaults to client ID)")
|
||
|
|
scope := fs.String("scope", "", "space-separated registered scopes")
|
||
|
|
secretEnv := fs.String("secret-env", "", "environment variable containing service client secret")
|
||
|
|
out := fs.String("out", "", "new private JSON token file outside Git")
|
||
|
|
redirect := fs.String("redirect-uri", "", "exact registered HTTP loopback callback (login only)")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if fs.NArg() != 0 || *id == "" || *out == "" || strings.TrimSpace(*scope) == "" {
|
||
|
|
return errors.New("client-id, scope and out are required; positional arguments are not accepted")
|
||
|
|
}
|
||
|
|
if *audience == "" {
|
||
|
|
*audience = *id
|
||
|
|
}
|
||
|
|
if mode == "login" && (*secretEnv != "" || !hasScope(*scope, "openid")) {
|
||
|
|
return errors.New("login requires openid scope and a public PKCE client")
|
||
|
|
}
|
||
|
|
if mode == "service-token" && (*secretEnv == "" || *redirect != "") {
|
||
|
|
return errors.New("service-token requires secret-env and does not accept redirect-uri")
|
||
|
|
}
|
||
|
|
c, err := New(*issuer)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
file, err := reserveOutput(*out)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
success := false
|
||
|
|
defer func() {
|
||
|
|
file.Close()
|
||
|
|
if !success {
|
||
|
|
os.Remove(file.Name())
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||
|
|
defer cancel()
|
||
|
|
d, err := c.Discover(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var tokens Tokens
|
||
|
|
if mode == "service-token" {
|
||
|
|
secret := os.Getenv(*secretEnv)
|
||
|
|
if secret == "" {
|
||
|
|
return errors.New("client secret environment variable is empty")
|
||
|
|
}
|
||
|
|
tokens, err = c.Exchange(ctx, d, url.Values{"grant_type": {"client_credentials"}, "scope": {*scope}}, *id, secret, *audience, "")
|
||
|
|
} else {
|
||
|
|
tokens, err = c.Login(ctx, d, *id, *audience, *scope, *redirect, stderr)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err = json.NewEncoder(file).Encode(tokens); err != nil {
|
||
|
|
return errors.New("could not write token file")
|
||
|
|
}
|
||
|
|
if err = file.Sync(); err != nil {
|
||
|
|
return errors.New("could not sync token file")
|
||
|
|
}
|
||
|
|
if err = file.Close(); err != nil {
|
||
|
|
return errors.New("could not close token file")
|
||
|
|
}
|
||
|
|
success = true
|
||
|
|
fmt.Fprintln(stderr, "Verified tokens saved to the requested private file.")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func reserveOutput(path string) (*os.File, error) {
|
||
|
|
absolute, err := filepath.Abs(path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("invalid output path")
|
||
|
|
}
|
||
|
|
parent, err := filepath.EvalSymlinks(filepath.Dir(absolute))
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("output directory must already exist")
|
||
|
|
}
|
||
|
|
for dir := parent; ; dir = filepath.Dir(dir) {
|
||
|
|
marker := filepath.Join(dir, ".git")
|
||
|
|
info, statErr := os.Lstat(marker)
|
||
|
|
if statErr == nil {
|
||
|
|
// A worktree uses a .git file; normal repositories have .git/HEAD.
|
||
|
|
// An empty directory alone is not a Git repository.
|
||
|
|
if !info.IsDir() {
|
||
|
|
return nil, errors.New("token output must be outside Git worktrees")
|
||
|
|
}
|
||
|
|
if _, err := os.Lstat(filepath.Join(marker, "HEAD")); err == nil {
|
||
|
|
return nil, errors.New("token output must be outside Git worktrees")
|
||
|
|
} else if !os.IsNotExist(err) {
|
||
|
|
return nil, errors.New("could not inspect Git directory")
|
||
|
|
}
|
||
|
|
} else if !os.IsNotExist(statErr) {
|
||
|
|
return nil, errors.New("could not inspect output directory")
|
||
|
|
}
|
||
|
|
if filepath.Dir(dir) == dir {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
file, err := os.OpenFile(filepath.Join(parent, filepath.Base(absolute)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("output file must be new and writable")
|
||
|
|
}
|
||
|
|
return file, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Login binds the exact registered IPv4/IPv6 loopback address before offering
|
||
|
|
// the browser URL. State, nonce and PKCE are independently generated per attempt.
|
||
|
|
func (c *Client) Login(ctx context.Context, d Discovery, id, audience, scope, redirect string, output io.Writer) (Tokens, error) {
|
||
|
|
callback, err := url.Parse(redirect)
|
||
|
|
if err != nil || callback.Scheme != "http" || callback.User != nil || callback.RawQuery != "" || callback.Fragment != "" || callback.Port() == "" || callback.Port() == "0" || callback.Path == "" {
|
||
|
|
return Tokens{}, errors.New("redirect-uri must be an exact HTTP loopback URL with a fixed port and path")
|
||
|
|
}
|
||
|
|
ip := net.ParseIP(callback.Hostname())
|
||
|
|
if ip == nil || !ip.IsLoopback() {
|
||
|
|
return Tokens{}, errors.New("callback must use a literal loopback IP address")
|
||
|
|
}
|
||
|
|
listener, err := net.Listen("tcp", callback.Host)
|
||
|
|
if err != nil {
|
||
|
|
return Tokens{}, errors.New("could not bind registered callback")
|
||
|
|
}
|
||
|
|
defer listener.Close()
|
||
|
|
state, err := randomValue()
|
||
|
|
if err != nil {
|
||
|
|
return Tokens{}, err
|
||
|
|
}
|
||
|
|
nonce, err := randomValue()
|
||
|
|
if err != nil {
|
||
|
|
return Tokens{}, err
|
||
|
|
}
|
||
|
|
verifier, err := randomValue()
|
||
|
|
if err != nil {
|
||
|
|
return Tokens{}, err
|
||
|
|
}
|
||
|
|
challenge := sha256.Sum256([]byte(verifier))
|
||
|
|
type callbackResult struct {
|
||
|
|
code string
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
result := make(chan callbackResult, 1)
|
||
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Cache-Control", "no-store")
|
||
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
|
|
if r.Method != http.MethodGet || r.URL.Path != callback.Path || r.Host != callback.Host {
|
||
|
|
http.Error(w, "Invalid callback", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
q, err := url.ParseQuery(r.URL.RawQuery)
|
||
|
|
if err != nil || len(q["state"]) != 1 || subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state)) != 1 {
|
||
|
|
http.Error(w, "Invalid callback state", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var value callbackResult
|
||
|
|
if q.Get("error") != "" {
|
||
|
|
value.err = errors.New("login was declined by the identity provider")
|
||
|
|
} else if len(q["code"]) != 1 || q.Get("code") == "" {
|
||
|
|
http.Error(w, "Missing authorization code", 400)
|
||
|
|
return
|
||
|
|
} else {
|
||
|
|
value.code = q.Get("code")
|
||
|
|
}
|
||
|
|
select {
|
||
|
|
case result <- value:
|
||
|
|
fmt.Fprintln(w, "Callback received. Return to the terminal.")
|
||
|
|
default:
|
||
|
|
http.Error(w, "Callback already received", 409)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, MaxHeaderBytes: 16384}
|
||
|
|
defer server.Close()
|
||
|
|
serveErrors := make(chan error, 1)
|
||
|
|
go func() { serveErrors <- server.Serve(listener) }()
|
||
|
|
authorize, err := url.Parse(d.Authorization)
|
||
|
|
if err != nil {
|
||
|
|
return Tokens{}, errors.New("invalid authorization endpoint")
|
||
|
|
}
|
||
|
|
q := authorize.Query()
|
||
|
|
for name, value := range map[string]string{"client_id": id, "redirect_uri": redirect, "response_type": "code", "scope": scope, "state": state, "nonce": nonce, "code_challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "code_challenge_method": "S256"} {
|
||
|
|
q.Set(name, value)
|
||
|
|
}
|
||
|
|
authorize.RawQuery = q.Encode()
|
||
|
|
fmt.Fprintf(output, "Open this URL in your browser to authenticate and complete MFA:\n%s\n", authorize.String())
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return Tokens{}, errors.New("login timed out or was cancelled")
|
||
|
|
case <-serveErrors:
|
||
|
|
return Tokens{}, errors.New("callback listener stopped")
|
||
|
|
case value := <-result:
|
||
|
|
if value.err != nil {
|
||
|
|
return Tokens{}, value.err
|
||
|
|
}
|
||
|
|
return c.Exchange(ctx, d, url.Values{"grant_type": {"authorization_code"}, "client_id": {id}, "code": {value.code}, "code_verifier": {verifier}, "redirect_uri": {redirect}, "scope": {scope}}, id, "", audience, nonce)
|
||
|
|
}
|
||
|
|
}
|