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