Skip to content

Commit 90afa65

Browse files
committed
Merge branch 'misc'
2 parents 193d305 + a5afe46 commit 90afa65

20 files changed

Lines changed: 1781 additions & 21 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati
446446
- CREATE/DROP/DESCRIBE PUBLISHED REST SERVICE with resources, operations, path params, CREATE OR REPLACE
447447
- Integration catalog tables (rest_clients, rest_operations, published_rest_services, external_entities, external_actions, business_events)
448448
- Contract catalog tables (contract_entities, contract_actions, contract_messages — parsed from cached $metadata and AsyncAPI)
449+
- Platform authentication (`mxcli auth login/logout/status/list`) with PAT scheme for marketplace-api.mendix.com and catalog.mendix.com; credentials stored at ~/.mxcli/auth.json (mode 0600), MENDIX_PAT env override
449450

450451
**Not Yet Implemented:**
451452
- 47 of 52 metamodel domains (REST, etc.)

cmd/mxcli/cmd_auth.go

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package main
4+
5+
import (
6+
"bufio"
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"strings"
14+
"text/tabwriter"
15+
"time"
16+
17+
"github.com/mendixlabs/mxcli/internal/auth"
18+
"github.com/spf13/cobra"
19+
"golang.org/x/term"
20+
)
21+
22+
// validateURL is the endpoint hit to check whether a stored PAT is valid.
23+
// GET returns 200 on a working credential and 401/403 on rejection.
24+
// Variable (not const) so tests can point it at a local httptest.Server.
25+
var validateURL = "https://marketplace-api.mendix.com/v1/content?limit=1"
26+
27+
var authCmd = &cobra.Command{
28+
Use: "auth",
29+
Short: "Manage Mendix platform credentials",
30+
Long: `Manage Personal Access Tokens for Mendix platform APIs.
31+
32+
Credentials are used by marketplace (module install), catalog, and other
33+
platform API features. A Personal Access Token (PAT) is created at:
34+
35+
https://user-settings.mendix.com/
36+
37+
Storage priority (highest first):
38+
1. MENDIX_PAT env var (set MXCLI_PROFILE to target a non-default profile)
39+
2. ~/.mxcli/auth.json (mode 0600)
40+
41+
Multiple named profiles are supported for users with multiple Mendix tenants
42+
or separate personal and CI credentials. The default profile name is "default".`,
43+
}
44+
45+
var authLoginCmd = &cobra.Command{
46+
Use: "login",
47+
Short: "Store a Personal Access Token",
48+
Long: `Store a Mendix Personal Access Token for use by other mxcli commands.
49+
50+
Create a PAT at https://user-settings.mendix.com/ (Developer Settings →
51+
Personal Access Tokens) and paste it when prompted. The token is validated
52+
against the Mendix marketplace API before being stored.
53+
54+
Examples:
55+
mxcli auth login # interactive
56+
mxcli auth login --token "$MENDIX_PAT" # non-interactive, for CI
57+
mxcli auth login --profile ci --token ... # named profile`,
58+
RunE: runAuthLogin,
59+
}
60+
61+
var authLogoutCmd = &cobra.Command{
62+
Use: "logout",
63+
Short: "Remove a stored credential",
64+
RunE: runAuthLogout,
65+
}
66+
67+
var authStatusCmd = &cobra.Command{
68+
Use: "status",
69+
Short: "Show the current credential's status",
70+
RunE: runAuthStatus,
71+
}
72+
73+
var authListCmd = &cobra.Command{
74+
Use: "list",
75+
Short: "List all stored profiles",
76+
RunE: runAuthList,
77+
}
78+
79+
func init() {
80+
authLoginCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name")
81+
authLoginCmd.Flags().String("token", "", "PAT value (non-interactive; omit to be prompted)")
82+
authLoginCmd.Flags().Bool("no-validate", false, "skip validating the PAT against the marketplace API before storing")
83+
84+
authLogoutCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name")
85+
authLogoutCmd.Flags().Bool("all", false, "remove all stored profiles")
86+
87+
authStatusCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name")
88+
authStatusCmd.Flags().Bool("json", false, "emit machine-readable JSON")
89+
authStatusCmd.Flags().Bool("offline", false, "skip validation against the marketplace API")
90+
91+
authCmd.AddCommand(authLoginCmd)
92+
authCmd.AddCommand(authLogoutCmd)
93+
authCmd.AddCommand(authStatusCmd)
94+
authCmd.AddCommand(authListCmd)
95+
96+
rootCmd.AddCommand(authCmd)
97+
}
98+
99+
func runAuthLogin(cmd *cobra.Command, _ []string) error {
100+
profile, _ := cmd.Flags().GetString("profile")
101+
token, _ := cmd.Flags().GetString("token")
102+
noValidate, _ := cmd.Flags().GetBool("no-validate")
103+
104+
if token == "" {
105+
var err error
106+
token, err = promptForToken(cmd.OutOrStdout())
107+
if err != nil {
108+
return err
109+
}
110+
}
111+
token = strings.TrimSpace(token)
112+
if token == "" {
113+
return fmt.Errorf("no token provided")
114+
}
115+
116+
if !noValidate {
117+
fmt.Fprint(cmd.OutOrStdout(), "Validating... ")
118+
if err := validatePAT(cmd.Context(), token); err != nil {
119+
fmt.Fprintln(cmd.OutOrStdout(), "✗")
120+
return fmt.Errorf("PAT rejected by Mendix: %w\nhint: confirm the token at https://user-settings.mendix.com/", err)
121+
}
122+
fmt.Fprintln(cmd.OutOrStdout(), "✓")
123+
}
124+
125+
store, err := auth.DefaultFileStore()
126+
if err != nil {
127+
return err
128+
}
129+
cred := &auth.Credential{
130+
Scheme: auth.SchemePAT,
131+
Token: token,
132+
CreatedAt: time.Now().UTC(),
133+
}
134+
if err := store.Put(profile, cred); err != nil {
135+
return fmt.Errorf("save credential: %w", err)
136+
}
137+
fmt.Fprintf(cmd.OutOrStdout(), "Saved credential to profile %q (scheme: %s)\n", profile, cred.Scheme)
138+
return nil
139+
}
140+
141+
func runAuthLogout(cmd *cobra.Command, _ []string) error {
142+
profile, _ := cmd.Flags().GetString("profile")
143+
all, _ := cmd.Flags().GetBool("all")
144+
145+
store, err := auth.DefaultFileStore()
146+
if err != nil {
147+
return err
148+
}
149+
150+
if all {
151+
profiles, err := store.List()
152+
if err != nil {
153+
return err
154+
}
155+
for _, p := range profiles {
156+
if err := store.Delete(p); err != nil {
157+
return fmt.Errorf("delete profile %q: %w", p, err)
158+
}
159+
fmt.Fprintf(cmd.OutOrStdout(), "Removed profile %q\n", p)
160+
}
161+
if len(profiles) == 0 {
162+
fmt.Fprintln(cmd.OutOrStdout(), "No stored profiles.")
163+
}
164+
return nil
165+
}
166+
167+
if err := store.Delete(profile); err != nil {
168+
return fmt.Errorf("delete profile %q: %w", profile, err)
169+
}
170+
fmt.Fprintf(cmd.OutOrStdout(), "Removed profile %q\n", profile)
171+
return nil
172+
}
173+
174+
func runAuthStatus(cmd *cobra.Command, _ []string) error {
175+
profile, _ := cmd.Flags().GetString("profile")
176+
asJSON, _ := cmd.Flags().GetBool("json")
177+
offline, _ := cmd.Flags().GetBool("offline")
178+
179+
cred, err := auth.Resolve(cmd.Context(), profile)
180+
if err != nil {
181+
return err
182+
}
183+
184+
source := "file (~/.mxcli/auth.json)"
185+
if os.Getenv(auth.EnvPAT) != "" && envProfileMatches(profile) {
186+
source = "env (MENDIX_PAT)"
187+
}
188+
189+
valid := ""
190+
if !offline {
191+
if err := validatePAT(cmd.Context(), cred.Token); err == nil {
192+
valid = "ok"
193+
} else {
194+
valid = fmt.Sprintf("rejected (%v)", err)
195+
}
196+
}
197+
198+
if asJSON {
199+
out := map[string]any{
200+
"profile": profile,
201+
"scheme": string(cred.Scheme),
202+
"source": source,
203+
"created_at": cred.CreatedAt,
204+
}
205+
if valid != "" {
206+
out["valid"] = valid == "ok"
207+
out["valid_detail"] = valid
208+
}
209+
enc := json.NewEncoder(cmd.OutOrStdout())
210+
enc.SetIndent("", " ")
211+
return enc.Encode(out)
212+
}
213+
214+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
215+
fmt.Fprintf(w, "Profile:\t%s\n", profile)
216+
fmt.Fprintf(w, "Scheme:\t%s\n", cred.Scheme)
217+
fmt.Fprintf(w, "Source:\t%s\n", source)
218+
fmt.Fprintf(w, "Created:\t%s\n", cred.CreatedAt.Format(time.RFC3339))
219+
if valid != "" {
220+
fmt.Fprintf(w, "Validated:\t%s\n", valid)
221+
}
222+
return w.Flush()
223+
}
224+
225+
func runAuthList(cmd *cobra.Command, _ []string) error {
226+
store, err := auth.DefaultFileStore()
227+
if err != nil {
228+
return err
229+
}
230+
profiles, err := store.List()
231+
if err != nil {
232+
return err
233+
}
234+
if len(profiles) == 0 {
235+
fmt.Fprintln(cmd.OutOrStdout(), "No stored profiles. Run: mxcli auth login")
236+
return nil
237+
}
238+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
239+
fmt.Fprintln(w, "PROFILE\tSCHEME\tCREATED")
240+
for _, p := range profiles {
241+
cred, err := store.Get(p)
242+
if err != nil {
243+
fmt.Fprintf(w, "%s\t(error: %v)\t\n", p, err)
244+
continue
245+
}
246+
fmt.Fprintf(w, "%s\t%s\t%s\n", p, cred.Scheme, cred.CreatedAt.Format(time.RFC3339))
247+
}
248+
return w.Flush()
249+
}
250+
251+
// promptForToken reads a PAT from stdin. Uses term.ReadPassword (no echo)
252+
// when stdin is a TTY; falls back to echoed bufio.Scanner with a warning
253+
// when stdin is piped (common in devcontainers and CI that still want
254+
// interactive-ish usage).
255+
func promptForToken(out io.Writer) (string, error) {
256+
fmt.Fprintln(out, "Create a PAT at: https://user-settings.mendix.com/")
257+
fmt.Fprintln(out, " (Developer Settings → Personal Access Tokens)")
258+
fmt.Fprintln(out)
259+
fmt.Fprint(out, "PAT: ")
260+
261+
fd := int(os.Stdin.Fd())
262+
if term.IsTerminal(fd) {
263+
b, err := term.ReadPassword(fd)
264+
fmt.Fprintln(out)
265+
if err != nil {
266+
return "", fmt.Errorf("read token: %w", err)
267+
}
268+
return string(b), nil
269+
}
270+
// Non-TTY: read a line from stdin. Warn that echo is on since we can't
271+
// disable it, so the user knows not to type the token interactively.
272+
fmt.Fprintln(out)
273+
fmt.Fprintln(out, "(stdin is not a terminal — token will be echoed; prefer --token for scripts)")
274+
sc := bufio.NewScanner(os.Stdin)
275+
if !sc.Scan() {
276+
if err := sc.Err(); err != nil {
277+
return "", err
278+
}
279+
return "", fmt.Errorf("no input on stdin")
280+
}
281+
return sc.Text(), nil
282+
}
283+
284+
// validatePAT makes a single GET to the marketplace-api content endpoint
285+
// with the given PAT. Returns nil on 200. Wraps 401/403 as
286+
// *auth.ErrUnauthenticated. Network errors and other statuses are returned
287+
// as plain errors.
288+
func validatePAT(ctx context.Context, token string) error {
289+
req, err := http.NewRequestWithContext(ctx, "GET", validateURL, nil)
290+
if err != nil {
291+
return err
292+
}
293+
req.Header.Set("Authorization", "MxToken "+token)
294+
req.Header.Set("Accept", "application/json")
295+
296+
client := &http.Client{Timeout: 15 * time.Second}
297+
resp, err := client.Do(req)
298+
if err != nil {
299+
return fmt.Errorf("network: %w", err)
300+
}
301+
defer resp.Body.Close()
302+
303+
switch {
304+
case resp.StatusCode >= 200 && resp.StatusCode < 300:
305+
return nil
306+
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
307+
return &auth.ErrUnauthenticated{}
308+
default:
309+
return fmt.Errorf("unexpected status %d from %s", resp.StatusCode, validateURL)
310+
}
311+
}
312+
313+
// envProfileMatches reports whether env vars are currently populating the
314+
// given profile. Mirrors the resolver's internal logic.
315+
func envProfileMatches(profile string) bool {
316+
env := strings.TrimSpace(os.Getenv(auth.EnvProfile))
317+
if env == "" {
318+
env = auth.ProfileDefault
319+
}
320+
return env == profile
321+
}
322+
323+
// Compile-time interface assertion so we notice if auth.ErrUnauthenticated
324+
// stops satisfying error.
325+
var _ error = (*auth.ErrUnauthenticated)(nil)

0 commit comments

Comments
 (0)