auth+cli
This commit is contained in:
parent
314cbd37c7
commit
07522043b4
16 changed files with 870 additions and 38 deletions
527
internal/app/cli.go
Normal file
527
internal/app/cli.go
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type cliState struct {
|
||||
APIURL string `json:"api_url"`
|
||||
OIDCIssuerURL string `json:"oidc_issuer_url"`
|
||||
OIDCClientID string `json:"oidc_client_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenExpiryUTC string `json:"token_expiry_utc"`
|
||||
Workspace string `json:"workspace"`
|
||||
UnixUser string `json:"unix_user"`
|
||||
}
|
||||
|
||||
type cliClient struct {
|
||||
baseURL string
|
||||
state *cliState
|
||||
}
|
||||
|
||||
func RunCLI(args []string) error {
|
||||
_ = godotenv.Load()
|
||||
if len(args) == 0 {
|
||||
return cliUsage()
|
||||
}
|
||||
switch args[0] {
|
||||
case "provision":
|
||||
return runCLIProvision(args[1:])
|
||||
case "login":
|
||||
return runCLILogin(args[1:])
|
||||
case "help", "--help", "-h":
|
||||
return cliUsage()
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q\n\n%s", args[0], cliUsageText())
|
||||
}
|
||||
}
|
||||
|
||||
func runCLIProvision(args []string) error {
|
||||
state, _ := loadCLIState()
|
||||
fs := flag.NewFlagSet("provision", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var apiURL, workspace, unixUser, name, description, repoURL string
|
||||
var repoPrivate bool
|
||||
fs.StringVar(&apiURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL")
|
||||
fs.StringVar(&workspace, "workspace", state.Workspace, "workspace name (required)")
|
||||
fs.StringVar(&unixUser, "unix-user", state.UnixUser, "unix user for workspace (required)")
|
||||
fs.StringVar(&name, "name", "", "project name (required)")
|
||||
fs.StringVar(&description, "description", "", "project description")
|
||||
fs.StringVar(&repoURL, "repo-url", "", "existing repository URL (optional)")
|
||||
fs.BoolVar(&repoPrivate, "private", true, "create Forgejo repo as private when repo-url is empty")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
workspace = strings.TrimSpace(workspace)
|
||||
unixUser = strings.TrimSpace(unixUser)
|
||||
name = strings.TrimSpace(name)
|
||||
description = strings.TrimSpace(description)
|
||||
repoURL = strings.TrimSpace(repoURL)
|
||||
if apiURL == "" || workspace == "" || unixUser == "" || name == "" {
|
||||
return errors.New("required flags: --api-url, --workspace, --unix-user, --name")
|
||||
}
|
||||
if repoURL != "" && !isValidRepoURL(repoURL) {
|
||||
return errors.New("repo-url must be a valid repo URL (https://..., ssh://..., or git@...)")
|
||||
}
|
||||
|
||||
state.APIURL = apiURL
|
||||
state.Workspace = workspace
|
||||
state.UnixUser = unixUser
|
||||
c := cliClient{baseURL: apiURL, state: &state}
|
||||
if err := c.ensureAccessToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveCLIState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wsID, err := c.ensureWorkspace(workspace, unixUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slug := slugify(name)
|
||||
existing, err := c.findProjectBySlug(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
p, err := c.reprovisionProject(existing.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printCLIProject("reprovisioned", p)
|
||||
return nil
|
||||
}
|
||||
p, err := c.createProject(wsID, name, description, repoPrivate, repoURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printCLIProject("provisioned", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCLILogin(args []string) error {
|
||||
state, _ := loadCLIState()
|
||||
fs := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.StringVar(&state.APIURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL")
|
||||
fs.StringVar(&state.OIDCIssuerURL, "issuer", firstNonEmpty(state.OIDCIssuerURL, getenv("OIDC_ISSUER_URL", "")), "OIDC issuer URL (authentik)")
|
||||
fs.StringVar(&state.OIDCClientID, "client-id", firstNonEmpty(state.OIDCClientID, getenv("OIDC_CLIENT_ID", "")), "OIDC client ID")
|
||||
fs.StringVar(&state.Workspace, "workspace", state.Workspace, "default workspace name")
|
||||
fs.StringVar(&state.UnixUser, "unix-user", state.UnixUser, "default workspace unix user")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
state.APIURL = strings.TrimRight(strings.TrimSpace(state.APIURL), "/")
|
||||
state.OIDCIssuerURL = strings.TrimRight(strings.TrimSpace(state.OIDCIssuerURL), "/")
|
||||
state.OIDCClientID = strings.TrimSpace(state.OIDCClientID)
|
||||
state.Workspace = strings.TrimSpace(state.Workspace)
|
||||
state.UnixUser = strings.TrimSpace(state.UnixUser)
|
||||
if state.APIURL == "" || state.OIDCIssuerURL == "" || state.OIDCClientID == "" {
|
||||
return errors.New("login requires --api-url, --issuer, and --client-id")
|
||||
}
|
||||
d, err := fetchOIDCDiscoveryForCLI(state.OIDCIssuerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, expiry, refresh, err := runPKCEBrowserLogin(d, state.OIDCClientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.AccessToken = token
|
||||
state.RefreshToken = refresh
|
||||
state.TokenExpiryUTC = expiry.UTC().Format(time.RFC3339)
|
||||
|
||||
c := cliClient{baseURL: state.APIURL, state: &state}
|
||||
me, err := c.me()
|
||||
if err != nil {
|
||||
return fmt.Errorf("login token verification failed: %w", err)
|
||||
}
|
||||
if err := saveCLIState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("saved login config at %s\n", cliStatePath())
|
||||
fmt.Printf("authenticated as username=%s email=%s\n", me.Username, me.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cliClient) ensureAccessToken() error {
|
||||
if strings.TrimSpace(c.state.AccessToken) == "" {
|
||||
return errors.New("not logged in: run `boxctl login`")
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(c.state.TokenExpiryUTC))
|
||||
if err == nil && time.Now().UTC().Before(exp.Add(-1*time.Minute)) {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(c.state.RefreshToken) == "" {
|
||||
return errors.New("access token expired and refresh token missing; run `boxctl login`")
|
||||
}
|
||||
d, err := fetchOIDCDiscoveryForCLI(c.state.OIDCIssuerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("client_id", c.state.OIDCClientID)
|
||||
form.Set("refresh_token", c.state.RefreshToken)
|
||||
body, status, err := postForm(d.TokenURL, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status >= 300 {
|
||||
return fmt.Errorf("token refresh failed status=%d body=%s", status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var tr tokenResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(tr.AccessToken) == "" {
|
||||
return errors.New("token refresh returned empty access_token")
|
||||
}
|
||||
c.state.AccessToken = tr.AccessToken
|
||||
if strings.TrimSpace(tr.RefreshToken) != "" {
|
||||
c.state.RefreshToken = tr.RefreshToken
|
||||
}
|
||||
c.state.TokenExpiryUTC = time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||
return nil
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
func runPKCEBrowserLogin(d oidcDiscovery, clientID string) (accessToken string, expiry time.Time, refreshToken string, err error) {
|
||||
verifier, err := randomURLSafe(64)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
state, err := randomURLSafe(24)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
challenge := pkceS256(verifier)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
defer ln.Close()
|
||||
redirectURI := "http://" + ln.Addr().String() + "/callback"
|
||||
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("state") != state {
|
||||
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||||
errCh <- errors.New("state mismatch")
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
errCh <- errors.New("missing authorization code")
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("Login successful. You can close this window."))
|
||||
codeCh <- code
|
||||
})
|
||||
server := &http.Server{Handler: mux}
|
||||
go func() { _ = server.Serve(ln) }()
|
||||
defer server.Close()
|
||||
|
||||
authURL, err := url.Parse(d.AuthURL)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
q := authURL.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", clientID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
q.Set("scope", "openid profile email offline_access")
|
||||
q.Set("code_challenge", challenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
q.Set("state", state)
|
||||
authURL.RawQuery = q.Encode()
|
||||
|
||||
fmt.Printf("Open this URL to log in:\n%s\n", authURL.String())
|
||||
_ = openBrowser(authURL.String())
|
||||
|
||||
var code string
|
||||
select {
|
||||
case code = <-codeCh:
|
||||
case err = <-errCh:
|
||||
return "", time.Time{}, "", err
|
||||
case <-time.After(180 * time.Second):
|
||||
return "", time.Time{}, "", errors.New("login timeout waiting for callback")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("code_verifier", verifier)
|
||||
body, status, err := postForm(d.TokenURL, form)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
if status >= 300 {
|
||||
return "", time.Time{}, "", fmt.Errorf("token exchange failed status=%d body=%s", status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var tr tokenResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
if strings.TrimSpace(tr.AccessToken) == "" {
|
||||
return "", time.Time{}, "", errors.New("token exchange returned empty access_token")
|
||||
}
|
||||
return tr.AccessToken, time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second), tr.RefreshToken, nil
|
||||
}
|
||||
|
||||
func openBrowser(u string) error {
|
||||
for _, cmd := range [][]string{
|
||||
{"xdg-open", u},
|
||||
{"open", u},
|
||||
} {
|
||||
if err := exec.Command(cmd[0], cmd[1:]...).Start(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomURLSafe(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func pkceS256(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func fetchOIDCDiscoveryForCLI(issuer string) (oidcDiscovery, error) {
|
||||
resp, err := http.Get(strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration")
|
||||
if err != nil {
|
||||
return oidcDiscovery{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return oidcDiscovery{}, fmt.Errorf("oidc discovery failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var d oidcDiscovery
|
||||
if err := json.Unmarshal(body, &d); err != nil {
|
||||
return oidcDiscovery{}, err
|
||||
}
|
||||
if d.AuthURL == "" || d.TokenURL == "" {
|
||||
return oidcDiscovery{}, errors.New("oidc discovery missing authorization_endpoint/token_endpoint")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (c *cliClient) ensureWorkspace(name, unixUser string) (int64, error) {
|
||||
var workspaces []Workspace
|
||||
if err := c.getJSON("/api/workspaces", &workspaces); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, w := range workspaces {
|
||||
if w.Name == name {
|
||||
if w.UnixUser != unixUser {
|
||||
return 0, fmt.Errorf("workspace %q already exists with unix_user=%q (requested=%q)", name, w.UnixUser, unixUser)
|
||||
}
|
||||
return w.ID, nil
|
||||
}
|
||||
}
|
||||
var created Workspace
|
||||
if err := c.postJSON("/api/workspaces", map[string]any{"name": name, "unix_user": unixUser}, &created); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func (c *cliClient) findProjectBySlug(slug string) (*Project, error) {
|
||||
var projects []Project
|
||||
if err := c.getJSON("/api/projects", &projects); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range projects {
|
||||
if projects[i].Slug == slug {
|
||||
return &projects[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *cliClient) reprovisionProject(projectID int64) (Project, error) {
|
||||
var p Project
|
||||
err := c.postJSON(fmt.Sprintf("/api/projects/%d/reprovision", projectID), map[string]any{}, &p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (c *cliClient) createProject(workspaceID int64, name, description string, repoPrivate bool, repoURL string) (Project, error) {
|
||||
var p Project
|
||||
err := c.postJSON("/api/projects", map[string]any{
|
||||
"workspace_id": workspaceID,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"repo_private": repoPrivate,
|
||||
"repo_url": repoURL,
|
||||
}, &p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (c *cliClient) me() (User, error) {
|
||||
var u User
|
||||
err := c.getJSON("/api/me", &u)
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (c *cliClient) getJSON(path string, out any) error {
|
||||
if err := c.ensureAccessToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.state.AccessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (c *cliClient) postJSON(path string, payload any, out any) error {
|
||||
if err := c.ensureAccessToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.state.AccessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func printCLIProject(action string, p Project) {
|
||||
fmt.Printf("project %s id=%d name=%s slug=%s\n", action, p.ID, p.Name, p.Slug)
|
||||
fmt.Printf("workspace=%s unix_user=%s\n", p.Workspace, p.UnixUser)
|
||||
fmt.Printf("route_host=%s\n", p.RouteHost)
|
||||
if p.AppURL != "" {
|
||||
fmt.Printf("app_url=%s\n", p.AppURL)
|
||||
}
|
||||
if p.RepoURL != "" {
|
||||
fmt.Printf("repo_url=%s\n", p.RepoURL)
|
||||
}
|
||||
if p.WebhookURL != "" {
|
||||
fmt.Printf("webhook_url=%s\n", p.WebhookURL)
|
||||
}
|
||||
}
|
||||
|
||||
func cliStatePath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return ".boxctl.json"
|
||||
}
|
||||
return filepath.Join(home, ".config", "boxctl", "config.json")
|
||||
}
|
||||
|
||||
func loadCLIState() (cliState, error) {
|
||||
path := cliStatePath()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cliState{}, nil
|
||||
}
|
||||
return cliState{}, err
|
||||
}
|
||||
var s cliState
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return cliState{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func saveCLIState(s cliState) error {
|
||||
path := cliStatePath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.MarshalIndent(s, "", " ")
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cliUsage() error {
|
||||
fmt.Fprint(os.Stderr, cliUsageText())
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliUsageText() string {
|
||||
return `Usage:
|
||||
go run . # server
|
||||
go run ./cmd/boxctl login --api-url https://box.example.com --issuer https://auth.example.com/application/o/box --client-id box-cli [--workspace <name>] [--unix-user <unix_user>]
|
||||
go run ./cmd/boxctl provision --name <project_name> [--repo-url <url>] [--description <text>] [--private=true|false]
|
||||
|
||||
Commands:
|
||||
login OAuth browser login (PKCE) and save tokens/defaults in ~/.config/boxctl/config.json
|
||||
provision Upsert project via API using bearer token
|
||||
`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue