diff --git a/.env.example b/.env.example index 42a32af..2b4f701 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ DEV_DEMO_USER=demo DEV_DEMO_EMAIL=demo@example.local AUTH_HEADER_USER=X-authentik-username AUTH_HEADER_EMAIL=X-authentik-email +OIDC_ISSUER_URL= +OIDC_AUDIENCE= FORGEJO_BASE_URL= FORGEJO_TOKEN= FORGEJO_GIT_USERNAME=oauth2 diff --git a/cmd/boxctl/main.go b/cmd/boxctl/main.go new file mode 100644 index 0000000..74f659d --- /dev/null +++ b/cmd/boxctl/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "log" + "os" + + "box/internal/app" +) + +func main() { + if err := app.RunCLI(os.Args[1:]); err != nil { + log.Fatal(err) + } +} diff --git a/internal/app/cli.go b/internal/app/cli.go new file mode 100644 index 0000000..92096de --- /dev/null +++ b/internal/app/cli.go @@ -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 ] [--unix-user ] + go run ./cmd/boxctl provision --name [--repo-url ] [--description ] [--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 +` +} diff --git a/internal/app/config.go b/internal/app/config.go index 39af1d2..231ec4a 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -17,6 +17,8 @@ func loadConfig() (Config, error) { DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), + OIDCIssuerURL: strings.TrimRight(getenv("OIDC_ISSUER_URL", ""), "/"), + OIDCAudience: strings.TrimSpace(getenv("OIDC_AUDIENCE", "")), ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), ForgejoToken: getenv("FORGEJO_TOKEN", ""), ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"), diff --git a/internal/app/db.go b/internal/app/db.go index e0688a7..1c295f1 100644 --- a/internal/app/db.go +++ b/internal/app/db.go @@ -41,6 +41,11 @@ func (a *App) migrate(ctx context.Context) error { value TEXT NOT NULL, UNIQUE(project_id, key) );`, + `CREATE TABLE IF NOT EXISTS user_webhook_tokens ( + user_id TEXT PRIMARY KEY, + token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );`, } for _, q := range ddl { if _, err := a.db.ExecContext(ctx, q); err != nil { diff --git a/internal/app/db_provisioning.go b/internal/app/db_provisioning.go index 2535841..d138ea0 100644 --- a/internal/app/db_provisioning.go +++ b/internal/app/db_provisioning.go @@ -51,7 +51,7 @@ END $$;`, sqlQuoteLiteral(p.DBUser), sqlQuoteIdent(p.DBUser), sqlQuoteLiteral(p. return fmt.Errorf("check database existence failed: %w", err) } if !dbExists { - if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, sqlQuoteIdent(p.DBName), sqlQuoteIdent(p.DBUser))); err != nil { + if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`CREATE DATABASE %s OWNER %s TEMPLATE template0`, sqlQuoteIdent(p.DBName), sqlQuoteIdent(p.DBUser))); err != nil { return fmt.Errorf("create database failed: %w", err) } } diff --git a/internal/app/deploy_service.go b/internal/app/deploy_service.go index e979b96..ecc479b 100644 --- a/internal/app/deploy_service.go +++ b/internal/app/deploy_service.go @@ -38,6 +38,12 @@ func (a *App) handleDeployWebhook(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + userToken := strings.TrimSpace(r.URL.Query().Get("ut")) + ok, err := a.validateUserWebhookToken(r.Context(), p.UserID, userToken) + if err != nil || !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } ref, err := parseWebhookRef(r) if err != nil { http.Error(w, "invalid webhook payload", http.StatusBadRequest) @@ -98,7 +104,11 @@ func (a *App) getProjectForWebhook(ctx context.Context, projectID int64, token s } return Project{}, err } - p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + tok, err := a.ensureUserWebhookToken(ctx, p.UserID) + if err != nil { + return Project{}, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token) return p, nil } diff --git a/internal/app/middleware.go b/internal/app/middleware.go index c99b5c3..88bf63c 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -4,6 +4,7 @@ import ( "context" "log" "net/http" + "strings" "time" ) @@ -27,6 +28,28 @@ func (a *App) withAuth(next func(http.ResponseWriter, *http.Request, User)) http } } +func (a *App) withAPIUser(next func(http.ResponseWriter, *http.Request, User)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if a.cfg.DevMode { + u := User{Username: a.cfg.DemoUser, Email: a.cfg.DemoEmail} + next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)), u) + return + } + authz := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(strings.ToLower(authz), "bearer ") { + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + token := strings.TrimSpace(authz[len("Bearer "):]) + u, err := a.validateBearerToken(token) + if err != nil { + http.Error(w, "invalid bearer token", http.StatusUnauthorized) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)), u) + } +} + func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() diff --git a/internal/app/oidc_auth.go b/internal/app/oidc_auth.go new file mode 100644 index 0000000..dd35ca5 --- /dev/null +++ b/internal/app/oidc_auth.go @@ -0,0 +1,118 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type oidcDiscovery struct { + Issuer string `json:"issuer"` + UserInfo string `json:"userinfo_endpoint"` + TokenURL string `json:"token_endpoint"` + AuthURL string `json:"authorization_endpoint"` +} + +func (a *App) fetchOIDCDiscovery() (oidcDiscovery, error) { + if strings.TrimSpace(a.cfg.OIDCIssuerURL) == "" { + return oidcDiscovery{}, errors.New("OIDC_ISSUER_URL is required") + } + u := a.cfg.OIDCIssuerURL + "/.well-known/openid-configuration" + resp, err := http.Get(u) + 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 strings.TrimSpace(d.UserInfo) == "" { + return oidcDiscovery{}, errors.New("oidc discovery missing userinfo_endpoint") + } + return d, nil +} + +func (a *App) validateBearerToken(token string) (User, error) { + d, err := a.fetchOIDCDiscovery() + if err != nil { + return User{}, err + } + req, err := http.NewRequest(http.MethodGet, d.UserInfo, nil) + if err != nil { + return User{}, err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return User{}, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return User{}, fmt.Errorf("oidc userinfo failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var claims map[string]any + if err := json.Unmarshal(body, &claims); err != nil { + return User{}, err + } + if aud := strings.TrimSpace(a.cfg.OIDCAudience); aud != "" { + if !claimContainsAudience(claims["aud"], aud) { + return User{}, errors.New("token audience does not match configured OIDC_AUDIENCE") + } + } + username := firstStringClaim(claims, "preferred_username", "username", "sub") + if strings.TrimSpace(username) == "" { + return User{}, errors.New("oidc token missing preferred_username/username/sub") + } + email := firstStringClaim(claims, "email") + return User{Username: username, Email: email}, nil +} + +func claimContainsAudience(v any, expected string) bool { + switch t := v.(type) { + case string: + return strings.EqualFold(strings.TrimSpace(t), expected) + case []any: + for _, item := range t { + if s, ok := item.(string); ok && strings.EqualFold(strings.TrimSpace(s), expected) { + return true + } + } + } + return false +} + +func firstStringClaim(claims map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := claims[k]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + } + return "" +} + +func postForm(urlStr string, values url.Values) ([]byte, int, error) { + req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(values.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return body, resp.StatusCode, nil +} diff --git a/internal/app/project_service.go b/internal/app/project_service.go index f169e64..add6bed 100644 --- a/internal/app/project_service.go +++ b/internal/app/project_service.go @@ -28,7 +28,11 @@ func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error t := deployedAt.Time.UTC() p.LastDeployedAt = &t } - p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + tok, err := a.ensureUserWebhookToken(ctx, p.UserID) + if err != nil { + return nil, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token) p.AppURL = a.appURL(p.RouteHost) out = append(out, p) } @@ -80,7 +84,11 @@ func (a *App) createProject(ctx context.Context, user User, workspaceID int64, n p.Workspace = workspace.Name p.UnixUser = workspace.UnixUser - p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + tok, err := a.ensureUserWebhookToken(ctx, p.UserID) + if err != nil { + return Project{}, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token) p.AppURL = a.appURL(p.RouteHost) return p, a.provisionProject(ctx, &p) } @@ -168,7 +176,11 @@ func (a *App) getProject(ctx context.Context, userID string, projectID int64) (P t := deployedAt.Time.UTC() p.LastDeployedAt = &t } - p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + tok, err := a.ensureUserWebhookToken(ctx, p.UserID) + if err != nil { + return Project{}, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken, tok.Token) p.AppURL = a.appURL(p.RouteHost) return p, nil } diff --git a/internal/app/routes_handlers.go b/internal/app/routes_handlers.go index 9f816c2..2e191bd 100644 --- a/internal/app/routes_handlers.go +++ b/internal/app/routes_handlers.go @@ -12,11 +12,19 @@ import ( func (a *App) routes() http.Handler { mux := http.NewServeMux() mux.Handle("/", http.FileServer(http.Dir("./web"))) - mux.HandleFunc("/api/me", a.withAuth(a.handleMe)) - mux.HandleFunc("/api/workspaces", a.withAuth(a.handleWorkspaces)) - mux.HandleFunc("/api/projects", a.withAuth(a.handleProjects)) - mux.HandleFunc("/api/projects/", a.withAuth(a.handleProjectSubroutes)) - mux.HandleFunc("/api/caddy/config", a.withAuth(a.handleCaddyConfig)) + mux.HandleFunc("/web-api/me", a.withAuth(a.handleMe)) + mux.HandleFunc("/web-api/workspaces", a.withAuth(a.handleWorkspaces)) + mux.HandleFunc("/web-api/projects", a.withAuth(a.handleProjects)) + mux.HandleFunc("/web-api/projects/", a.withAuth(a.handleProjectSubroutes)) + mux.HandleFunc("/web-api/caddy/config", a.withAuth(a.handleCaddyConfig)) + mux.HandleFunc("/web-api/tokens/webhook", a.withAuth(a.handleWebhookToken)) + + mux.HandleFunc("/api/me", a.withAPIUser(a.handleMe)) + mux.HandleFunc("/api/workspaces", a.withAPIUser(a.handleWorkspaces)) + mux.HandleFunc("/api/projects", a.withAPIUser(a.handleProjects)) + mux.HandleFunc("/api/projects/", a.withAPIUser(a.handleProjectSubroutes)) + mux.HandleFunc("/api/caddy/config", a.withAPIUser(a.handleCaddyConfig)) + mux.HandleFunc("/api/tokens/webhook", a.withAPIUser(a.handleWebhookToken)) mux.HandleFunc("/api/webhooks/deploy/", a.handleDeployWebhook) return loggingMiddleware(mux) } @@ -111,6 +119,9 @@ func (a *App) handleProjects(w http.ResponseWriter, r *http.Request, user User) func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, user User) { path := strings.TrimPrefix(r.URL.Path, "/api/projects/") + if path == r.URL.Path { + path = strings.TrimPrefix(r.URL.Path, "/web-api/projects/") + } parts := strings.Split(path, "/") if len(parts) < 1 || parts[0] == "" { http.Error(w, "bad path", http.StatusBadRequest) @@ -297,3 +308,24 @@ func (a *App) handleCaddyConfig(w http.ResponseWriter, r *http.Request, _ User) "content": mustJSONPretty(cfg), }) } + +func (a *App) handleWebhookToken(w http.ResponseWriter, r *http.Request, user User) { + switch r.Method { + case http.MethodGet: + tok, err := a.ensureUserWebhookToken(r.Context(), user.Username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, tok) + case http.MethodPost: + tok, err := a.rotateUserWebhookToken(r.Context(), user.Username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, tok) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/app/run.go b/internal/app/run.go index 63227e1..e218917 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -14,32 +14,43 @@ import ( "github.com/joho/godotenv" ) -func Run() error { +func initApp() (*App, func(), error) { _ = godotenv.Load() cfg, err := loadConfig() if err != nil { - return fmt.Errorf("config error: %w", err) + return nil, nil, fmt.Errorf("config error: %w", err) } db, err := sql.Open("pgx", cfg.DatabaseURL) if err != nil { - return fmt.Errorf("database open error: %w", err) + return nil, nil, fmt.Errorf("database open error: %w", err) } - defer db.Close() + cleanup := func() { _ = db.Close() } if err := db.Ping(); err != nil { - return fmt.Errorf("database ping error: %w", err) + cleanup() + return nil, nil, fmt.Errorf("database ping error: %w", err) } - app := &App{cfg: cfg, db: db} - if err := app.migrate(context.Background()); err != nil { - return fmt.Errorf("migration error: %w", err) + a := &App{cfg: cfg, db: db} + if err := a.migrate(context.Background()); err != nil { + cleanup() + return nil, nil, fmt.Errorf("migration error: %w", err) } + return a, cleanup, nil +} + +func Run() error { + app, cleanup, err := initApp() + if err != nil { + return err + } + defer cleanup() handler := app.routes() - servers, cleanup, err := startServers(cfg, handler) + servers, sockCleanup, err := startServers(app.cfg, handler) if err != nil { return fmt.Errorf("server start error: %w", err) } - defer cleanup() + defer sockCleanup() - log.Printf("server started: tcp=%q unix=%q dev_mode=%t", cfg.ListenAddr, cfg.UnixSocketPath, cfg.DevMode) + log.Printf("server started: tcp=%q unix=%q dev_mode=%t", app.cfg.ListenAddr, app.cfg.UnixSocketPath, app.cfg.DevMode) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) <-sigCh diff --git a/internal/app/types.go b/internal/app/types.go index c1f5f8d..4d59b07 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -14,6 +14,8 @@ type Config struct { DemoEmail string AuthHeaderUser string AuthHeaderEmail string + OIDCIssuerURL string + OIDCAudience string ForgejoBaseURL string ForgejoToken string ForgejoGitUsername string @@ -83,3 +85,9 @@ type Workspace struct { Name string `json:"name"` UnixUser string `json:"unix_user"` } + +type UserWebhookToken struct { + UserID string `json:"-"` + Token string `json:"token"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/app/user_tokens_service.go b/internal/app/user_tokens_service.go new file mode 100644 index 0000000..e50c462 --- /dev/null +++ b/internal/app/user_tokens_service.go @@ -0,0 +1,65 @@ +package app + +import ( + "context" + "database/sql" + "errors" +) + +func (a *App) getUserWebhookToken(ctx context.Context, userID string) (UserWebhookToken, error) { + var t UserWebhookToken + err := a.db.QueryRowContext(ctx, `SELECT user_id, token, created_at FROM user_webhook_tokens WHERE user_id=$1`, userID). + Scan(&t.UserID, &t.Token, &t.CreatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return UserWebhookToken{}, errors.New("token not found") + } + return UserWebhookToken{}, err + } + return t, nil +} + +func (a *App) ensureUserWebhookToken(ctx context.Context, userID string) (UserWebhookToken, error) { + t, err := a.getUserWebhookToken(ctx, userID) + if err == nil { + return t, nil + } + token, err := newDeployToken() + if err != nil { + return UserWebhookToken{}, err + } + var out UserWebhookToken + err = a.db.QueryRowContext(ctx, `INSERT INTO user_webhook_tokens (user_id, token) VALUES ($1,$2) + ON CONFLICT (user_id) DO UPDATE SET token=user_webhook_tokens.token + RETURNING user_id, token, created_at`, userID, token). + Scan(&out.UserID, &out.Token, &out.CreatedAt) + if err != nil { + return UserWebhookToken{}, err + } + return out, nil +} + +func (a *App) rotateUserWebhookToken(ctx context.Context, userID string) (UserWebhookToken, error) { + token, err := newDeployToken() + if err != nil { + return UserWebhookToken{}, err + } + var out UserWebhookToken + err = a.db.QueryRowContext(ctx, `INSERT INTO user_webhook_tokens (user_id, token, created_at) VALUES ($1,$2,NOW()) + ON CONFLICT (user_id) DO UPDATE SET token=EXCLUDED.token, created_at=NOW() + RETURNING user_id, token, created_at`, userID, token). + Scan(&out.UserID, &out.Token, &out.CreatedAt) + if err != nil { + return UserWebhookToken{}, err + } + return out, nil +} + +func (a *App) validateUserWebhookToken(ctx context.Context, userID, token string) (bool, error) { + if userID == "" || token == "" { + return false, nil + } + var exists bool + err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM user_webhook_tokens WHERE user_id=$1 AND token=$2)`, userID, token).Scan(&exists) + return exists, err +} diff --git a/internal/app/util.go b/internal/app/util.go index d993d31..0a99a4a 100644 --- a/internal/app/util.go +++ b/internal/app/util.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "os/exec" "regexp" "strconv" @@ -47,11 +48,11 @@ func mustJSONPretty(data any) string { return string(b) } -func (a *App) deployWebhookURL(projectID int64, token string) string { - if token == "" { +func (a *App) deployWebhookURL(projectID int64, token, userToken string) string { + if token == "" || userToken == "" { return "" } - path := fmt.Sprintf("/api/webhooks/deploy/%d/%s", projectID, token) + path := fmt.Sprintf("/api/webhooks/deploy/%d/%s?ut=%s", projectID, token, url.QueryEscape(userToken)) if a.cfg.WebhookBaseURL != "" { return a.cfg.WebhookBaseURL + path } diff --git a/web/app.js b/web/app.js index 9f3522a..1fbe829 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,7 @@ +const API_BASE = '/web-api'; + async function api(path, options = {}) { - const res = await fetch(path, { + const res = await fetch(`${API_BASE}${path}`, { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); @@ -10,7 +12,7 @@ async function api(path, options = {}) { const state = { projects: [], workspaces: [] }; async function init() { - const me = await api('/api/me'); + const me = await api('/me'); document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`; document.getElementById('workspaceForm').addEventListener('submit', createWorkspace); @@ -22,7 +24,7 @@ async function init() { } async function refreshWorkspaces() { - state.workspaces = await api('/api/workspaces'); + state.workspaces = await api('/workspaces'); const select = document.getElementById('workspaceSelect'); if (state.workspaces.length === 0) { select.innerHTML = ''; @@ -34,7 +36,7 @@ async function refreshWorkspaces() { } async function refreshProjects() { - state.projects = await api('/api/projects'); + state.projects = await api('/projects'); renderProjects(); } @@ -80,7 +82,7 @@ function renderProjects() { div.querySelector('[data-add]').addEventListener('click', async () => { const key = div.querySelector('[data-k]').value.trim(); const value = div.querySelector('[data-v]').value; - await api(`/api/projects/${p.id}/env`, { + await api(`/projects/${p.id}/env`, { method: 'POST', body: JSON.stringify({ key, value }) }); @@ -88,7 +90,7 @@ function renderProjects() { }); div.querySelector('[data-regen]').addEventListener('click', async () => { - await api(`/api/projects/${p.id}/service`, { method: 'POST' }); + await api(`/projects/${p.id}/service`, { method: 'POST' }); alert('Service regenerated and systemctl --user reload attempted.'); }); div.querySelector('[data-status]').addEventListener('click', async () => { @@ -98,7 +100,7 @@ function renderProjects() { await loadLogs(div, p.id); }); div.querySelector('[data-reprovision]').addEventListener('click', async () => { - await api(`/api/projects/${p.id}/reprovision`, { method: 'POST' }); + await api(`/projects/${p.id}/reprovision`, { method: 'POST' }); await refreshProjects(); }); @@ -109,7 +111,7 @@ function renderProjects() { } async function loadEnvList(projectNode, projectID) { - const envs = await api(`/api/projects/${projectID}/env`); + const envs = await api(`/projects/${projectID}/env`); const envRoot = projectNode.querySelector('[data-env]'); if (envs.length === 0) { envRoot.innerHTML = 'No env vars'; @@ -122,7 +124,7 @@ async function loadEnvList(projectNode, projectID) { envRoot.querySelectorAll('[data-del]').forEach((btn) => { btn.addEventListener('click', async () => { const key = btn.getAttribute('data-del'); - await api(`/api/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); + await api(`/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); await loadEnvList(projectNode, projectID); }); }); @@ -132,7 +134,7 @@ async function loadStatus(projectNode, projectID) { const out = projectNode.querySelector('[data-status-out]'); out.textContent = 'status: loading...'; try { - const s = await api(`/api/projects/${projectID}/status`); + const s = await api(`/projects/${projectID}/status`); out.textContent = `status: ${s.active_state}/${s.sub_state} pid=${s.main_pid} result=${s.result || 'n/a'} exit=${s.exec_status}`; } catch (err) { out.textContent = `status error: ${err.message}`; @@ -143,7 +145,7 @@ async function loadLogs(projectNode, projectID) { const out = projectNode.querySelector('[data-logs-out]'); out.textContent = 'loading logs...'; try { - const res = await api(`/api/projects/${projectID}/logs?lines=100`); + const res = await api(`/projects/${projectID}/logs?lines=100`); out.textContent = res.logs || '(no logs)'; } catch (err) { out.textContent = `logs error: ${err.message}`; @@ -163,7 +165,7 @@ async function createProject(e) { const msg = document.getElementById('createMsg'); msg.textContent = 'Creating project...'; try { - await api('/api/projects', { + await api('/projects', { method: 'POST', body: JSON.stringify(payload) }); @@ -186,7 +188,7 @@ async function createWorkspace(e) { const msg = document.getElementById('workspaceMsg'); msg.textContent = 'Creating workspace...'; try { - await api('/api/workspaces', { + await api('/workspaces', { method: 'POST', body: JSON.stringify(payload) }); @@ -202,7 +204,7 @@ async function loadCaddyConfig() { const out = document.getElementById('caddyConfigOut'); out.textContent = 'Loading Caddy config...'; try { - const cfg = await api('/api/caddy/config'); + const cfg = await api('/caddy/config'); out.textContent = cfg.content || JSON.stringify(cfg, null, 2); } catch (err) { out.textContent = `Error: ${err.message}`;