|
| 1 | +# Proposal: `mxcli auth` — Mendix Platform Authentication |
| 2 | + |
| 3 | +**Status:** Draft |
| 4 | +**Date:** 2026-04-14 |
| 5 | +**Author:** Generated with Claude Code |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +A growing set of `mxcli` features need to talk to Mendix platform APIs on behalf of the user: |
| 10 | + |
| 11 | +- **Marketplace** — downloading `.mpk` files (see `PROPOSAL_marketplace_modules.md`) |
| 12 | +- **Deploy API** — building, packaging, and deploying apps to Mendix Cloud |
| 13 | +- **Team Server, Content API, Platform APIs** — future features (project metadata, users, environments) |
| 14 | + |
| 15 | +Each of these APIs uses a different authentication header scheme, and today `mxcli` has no concept of stored credentials. Hard-coding auth into each feature would produce duplicated credential-storage code, inconsistent UX, and no single place for the user to run `mxcli auth login`. |
| 16 | + |
| 17 | +This proposal specifies a shared `internal/auth` package and `mxcli auth` command set that every platform-API consumer can use. |
| 18 | + |
| 19 | +## Mendix Authentication Schemes |
| 20 | + |
| 21 | +Based on current Mendix documentation (2026-04): |
| 22 | + |
| 23 | +| Scheme | Header(s) | APIs | Obtained From | |
| 24 | +|---|---|---|---| |
| 25 | +| **PAT** (Personal Access Token) | `Authorization: MxToken <pat>` | Content API (marketplace/modules), Platform APIs | Mendix portal → Developer Settings → Personal Access Tokens | |
| 26 | +| **API Key** | `Mendix-UserName: <email>`<br>`Mendix-ApiKey: <key>` | Deploy API, Team Server | Mendix portal → [Profile → API Keys](https://docs.mendix.com/portal/user-settings/#profile-api-keys) | |
| 27 | + |
| 28 | +References: |
| 29 | +- Content API / marketplace: <https://docs.mendix.com/apidocs-mxsdk/apidocs/content-api/> (PAT) |
| 30 | +- Deploy API: requires API key per <https://docs.mendix.com/portal/user-settings/#profile-api-keys> |
| 31 | + |
| 32 | +API keys "allow apps using them to act on behalf of the user who created the key" with the same privileges. Revocation may take time to propagate due to caching. |
| 33 | + |
| 34 | +### Azure AD / Entra ID — future consideration |
| 35 | + |
| 36 | +Mendix's portal login uses Azure AD SSO. This does **not** mean third-party CLIs can authenticate against AAD directly — doing so requires a public AAD app registration that Mendix has authorized for CLI use, and no such app ID is publicly documented today. For v1, the realistic flow is: |
| 37 | + |
| 38 | +1. User signs into the portal (AAD SSO in the browser, Mendix's problem) |
| 39 | +2. User creates a PAT or API key in the portal |
| 40 | +3. User pastes the token into `mxcli auth login` |
| 41 | + |
| 42 | +An AAD device-code flow (`mxcli login` opens a browser, no token pasting) remains a Phase 5 stretch goal contingent on Mendix publishing a CLI client ID. The package is designed so this can drop in later without breaking callers. |
| 43 | + |
| 44 | +## Design |
| 45 | + |
| 46 | +### Package Layout |
| 47 | + |
| 48 | +``` |
| 49 | +internal/auth/ |
| 50 | +├── credential.go # Credential struct, Scheme enum |
| 51 | +├── scheme.go # Host → scheme mapping |
| 52 | +├── resolver.go # Env vars → keychain → file priority |
| 53 | +├── client.go # *http.Client factory that injects the right headers |
| 54 | +├── store.go # Store interface |
| 55 | +├── store_file.go # ~/.mxcli/auth.json (chmod 0600), always available |
| 56 | +├── store_keyring.go # OS keychain via zalando/go-keyring (pure Go, no CGO) |
| 57 | +└── errors.go # ErrUnauthenticated, ErrNoCredential, ErrSchemeMismatch |
| 58 | +
|
| 59 | +cmd/mxcli/ |
| 60 | +└── auth.go # login / logout / status / list subcommands |
| 61 | +``` |
| 62 | + |
| 63 | +### Credential Model |
| 64 | + |
| 65 | +```go |
| 66 | +package auth |
| 67 | + |
| 68 | +type Scheme string |
| 69 | + |
| 70 | +const ( |
| 71 | + SchemePAT Scheme = "pat" // MxToken header |
| 72 | + SchemeAPIKey Scheme = "apikey" // Mendix-UserName + Mendix-ApiKey |
| 73 | +) |
| 74 | + |
| 75 | +type Credential struct { |
| 76 | + Profile string `json:"profile"` // "default", "deploy-ci", ... |
| 77 | + Scheme Scheme `json:"scheme"` |
| 78 | + Username string `json:"username,omitempty"` // required for apikey |
| 79 | + Token string `json:"token"` // never logged |
| 80 | + CreatedAt time.Time `json:"created_at"` |
| 81 | + Label string `json:"label,omitempty"` // free-form note |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +**Named profiles from day one.** Users with multiple Mendix tenants, or who need separate personal vs. CI credentials, need profile support, and retrofitting it is painful. Default profile name is `default`. |
| 86 | + |
| 87 | +A single profile holds **one** credential. Users who need both a PAT (marketplace) and an API key (deploy) under the same identity create two profiles (`--profile deploy`) — or we store them as a pair under one profile if field testing shows that's common. Starting with one-credential-per-profile keeps the model simple. |
| 88 | + |
| 89 | +### Scheme Routing (Host → Scheme) |
| 90 | + |
| 91 | +Callers shouldn't know which header to set. The client picks based on target hostname: |
| 92 | + |
| 93 | +```go |
| 94 | +// internal/auth/scheme.go |
| 95 | +var hostSchemes = map[string]Scheme{ |
| 96 | + "appstore.home.mendix.com": SchemePAT, |
| 97 | + "cloud.home.mendix.com": SchemePAT, |
| 98 | + "deploy.mendix.com": SchemeAPIKey, |
| 99 | + // ...add as new APIs are integrated |
| 100 | +} |
| 101 | + |
| 102 | +func schemeForHost(host string) (Scheme, bool) { |
| 103 | + s, ok := hostSchemes[host] |
| 104 | + return s, ok |
| 105 | +} |
| 106 | +``` |
| 107 | + |
| 108 | +If a request targets a host whose scheme doesn't match the resolved credential's scheme, the client returns `ErrSchemeMismatch` with a hint: "host deploy.mendix.com needs an API key; profile 'default' has a PAT. Run `mxcli auth login --profile deploy --api-key`." |
| 109 | + |
| 110 | +### Authenticated HTTP Client |
| 111 | + |
| 112 | +```go |
| 113 | +// internal/auth/client.go |
| 114 | +func ClientFor(ctx context.Context, profile string) (*http.Client, error) { |
| 115 | + cred, err := Resolve(ctx, profile) |
| 116 | + if err != nil { |
| 117 | + return nil, err |
| 118 | + } |
| 119 | + return &http.Client{ |
| 120 | + Transport: &authTransport{cred: cred, inner: http.DefaultTransport}, |
| 121 | + Timeout: 30 * time.Second, |
| 122 | + }, nil |
| 123 | +} |
| 124 | + |
| 125 | +type authTransport struct { |
| 126 | + cred *Credential |
| 127 | + inner http.RoundTripper |
| 128 | +} |
| 129 | + |
| 130 | +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
| 131 | + scheme, known := schemeForHost(req.URL.Host) |
| 132 | + if !known { |
| 133 | + return nil, fmt.Errorf("unknown Mendix host: %s", req.URL.Host) |
| 134 | + } |
| 135 | + if scheme != t.cred.Scheme { |
| 136 | + return nil, &ErrSchemeMismatch{Host: req.URL.Host, Need: scheme, Have: t.cred.Scheme} |
| 137 | + } |
| 138 | + req = req.Clone(req.Context()) |
| 139 | + switch scheme { |
| 140 | + case SchemePAT: |
| 141 | + req.Header.Set("Authorization", "MxToken "+t.cred.Token) |
| 142 | + case SchemeAPIKey: |
| 143 | + req.Header.Set("Mendix-UserName", t.cred.Username) |
| 144 | + req.Header.Set("Mendix-ApiKey", t.cred.Token) |
| 145 | + } |
| 146 | + resp, err := t.inner.RoundTrip(req) |
| 147 | + if resp != nil && resp.StatusCode == 401 { |
| 148 | + // Wrap as typed error so callers can show a helpful hint |
| 149 | + return resp, &ErrUnauthenticated{Profile: t.cred.Profile} |
| 150 | + } |
| 151 | + return resp, err |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +### Storage Priority |
| 156 | + |
| 157 | +The resolver walks these in order, first match wins: |
| 158 | + |
| 159 | +1. **Environment variables** (highest priority, for CI): |
| 160 | + - `MENDIX_PAT` → PAT credential |
| 161 | + - `MENDIX_USERNAME` + `MENDIX_API_KEY` → API key credential |
| 162 | + - `MXCLI_PROFILE` selects which profile env vars populate (default: `default`) |
| 163 | +2. **OS keychain** via `github.com/zalando/go-keyring` (pure Go, no CGO — matches CLAUDE.md). Service name `mxcli`, account = profile name. Works on macOS Keychain, Linux Secret Service, Windows Credential Manager. |
| 164 | +3. **File fallback** at `~/.mxcli/auth.json`, `chmod 0600`. Necessary for environments without a keyring backend (devcontainers, many CI runners). |
| 165 | + |
| 166 | +Users can force the file backend with `mxcli auth login --no-keychain` — useful for shared devcontainers. |
| 167 | + |
| 168 | +File format (supports multiple profiles): |
| 169 | + |
| 170 | +```json |
| 171 | +{ |
| 172 | + "version": 1, |
| 173 | + "profiles": { |
| 174 | + "default": { |
| 175 | + "scheme": "pat", |
| 176 | + "token": "xxxxxxxx…", |
| 177 | + "created_at": "2026-04-14T12:00:00Z" |
| 178 | + }, |
| 179 | + "deploy-ci": { |
| 180 | + "scheme": "apikey", |
| 181 | + "username": "ci@company.com", |
| 182 | + "token": "xxxxxxxx…", |
| 183 | + "created_at": "2026-04-14T12:05:00Z" |
| 184 | + } |
| 185 | + } |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +### CLI Commands |
| 190 | + |
| 191 | +``` |
| 192 | +mxcli auth login [--pat | --api-key] [--profile NAME] [--no-keychain] |
| 193 | + [--token TOKEN] [--username EMAIL] # non-interactive |
| 194 | +mxcli auth logout [--profile NAME] [--all] |
| 195 | +mxcli auth status [--profile NAME] [--json] |
| 196 | +mxcli auth list # all profiles |
| 197 | +``` |
| 198 | + |
| 199 | +#### `mxcli auth login` (interactive) |
| 200 | + |
| 201 | +``` |
| 202 | +$ mxcli auth login |
| 203 | +Mendix authentication scheme: |
| 204 | + [1] Personal Access Token (PAT) — recommended for marketplace, content API |
| 205 | + [2] API Key — required for Deploy API, Team Server |
| 206 | +Choice [1]: 2 |
| 207 | +
|
| 208 | +Email: user@company.com |
| 209 | +API Key: **************************** |
| 210 | +Validating... ✓ authenticated as user@company.com |
| 211 | +Store in: [1] OS keychain [2] ~/.mxcli/auth.json |
| 212 | +Choice [1]: 1 |
| 213 | +Saved credential to profile 'default' (scheme: apikey) |
| 214 | +``` |
| 215 | + |
| 216 | +- Token is read with `golang.org/x/term` (no echo). |
| 217 | +- Validation hits a cheap, idempotent authenticated endpoint on the scheme's primary host, e.g. `GET https://deploy.mendix.com/api/1/apps` (just to verify the credential works, discarding the response). The exact validation endpoint per scheme is picked during implementation — anything that returns 200 on success and 401 on bad creds. |
| 218 | +- Validation failures do not store the credential. |
| 219 | + |
| 220 | +#### `mxcli auth login` (non-interactive, for CI) |
| 221 | + |
| 222 | +```bash |
| 223 | +mxcli auth login --pat --token "$MENDIX_PAT" --profile default |
| 224 | +mxcli auth login --api-key --username ci@company.com --token "$MENDIX_API_KEY" --profile deploy |
| 225 | +``` |
| 226 | + |
| 227 | +Or skip `login` entirely and rely on `MENDIX_PAT` / `MENDIX_USERNAME`+`MENDIX_API_KEY` env vars — the resolver picks them up without any stored credential. |
| 228 | + |
| 229 | +#### `mxcli auth status` |
| 230 | + |
| 231 | +``` |
| 232 | +$ mxcli auth status |
| 233 | +Profile: default |
| 234 | +Scheme: pat |
| 235 | +Source: keychain |
| 236 | +Created: 2026-04-14 12:00:00 UTC |
| 237 | +Identity: user@company.com (verified 2s ago) |
| 238 | +``` |
| 239 | + |
| 240 | +With `--json` for scripting. Performs a live credential check unless `--offline` is passed. |
| 241 | + |
| 242 | +### Consumer Example (Marketplace) |
| 243 | + |
| 244 | +```go |
| 245 | +// cmd/mxcli/cmd_marketplace.go |
| 246 | +func installModule(ctx context.Context, componentID string) error { |
| 247 | + client, err := auth.ClientFor(ctx, auth.ProfileFromEnv()) |
| 248 | + if err != nil { |
| 249 | + return fmt.Errorf("not authenticated: %w\nrun: mxcli auth login", err) |
| 250 | + } |
| 251 | + |
| 252 | + url := fmt.Sprintf("https://appstore.home.mendix.com/rest/packagesapi/v2/packages/%s", componentID) |
| 253 | + resp, err := client.Get(url) |
| 254 | + // ... |
| 255 | +} |
| 256 | +``` |
| 257 | + |
| 258 | +No consumer code knows about PATs, API keys, env vars, keychains, or header names. |
| 259 | + |
| 260 | +## Phased Rollout |
| 261 | + |
| 262 | +| Phase | Deliverable | Unblocks | |
| 263 | +|---|---|---| |
| 264 | +| **1** | `internal/auth` package: Credential, file store, env-var resolver, Authenticator with scheme routing. Keychain store is optional (file fallback covers all environments). | Everything downstream | |
| 265 | +| **1.5** | Discovery spike: validate that `appstore.home.mendix.com` accepts `MxToken` PATs as documented (and that `deploy.mendix.com` accepts `Mendix-ApiKey`). One day, parallel to Phase 1. | De-risks Phase 3/4 | |
| 266 | +| **2** | `mxcli auth login/logout/status/list` commands. Interactive scheme selection, validation ping before store. | Users can authenticate | |
| 267 | +| **3** | Keychain store (`zalando/go-keyring`), prompt-based migration from file to keychain. | Better security on laptops | |
| 268 | +| **4** | Wire into `mxcli marketplace` commands (see `PROPOSAL_marketplace_modules.md` Phase 3). | Marketplace install works | |
| 269 | +| **5** | Wire into Deploy API (`mxcli deploy` — separate future proposal). | App deployment works | |
| 270 | +| **6** (stretch) | AAD device-code flow via MSAL Go, contingent on Mendix publishing a public CLI client ID. Drop-in replacement for the PAT-paste flow in `login`. | `mxcli auth login` without leaving the terminal | |
| 271 | + |
| 272 | +Phases 1 and 2 are independent of the marketplace/deploy features and can ship first. The `internal/auth` package design does not change if Phase 6 later adds AAD — AAD tokens are still credentials, stored with the same profile mechanism; only the acquisition path is new. |
| 273 | + |
| 274 | +## Security Considerations |
| 275 | + |
| 276 | +- **File storage**: `chmod 0600` on create; refuse to read if permissions are more permissive (warn the user to `chmod 0600` the file). |
| 277 | +- **Token redaction**: Tokens must never appear in logs, `status` output, `--verbose` traces, or error messages. `Credential.String()` returns `"<scheme> token=REDACTED"`. |
| 278 | +- **Process env leakage**: When a credential comes from `MENDIX_PAT`, do not shell out with the env var inherited unless the child process is a trusted Mendix tool (`mx`). Wrap `exec.Command` callers to scrub these vars by default. |
| 279 | +- **Revocation caching**: Mendix notes that API key revocation may take time to propagate. `auth status` should surface this ("credential may be cached upstream; revoke in portal for immediate effect") so users aren't surprised. |
| 280 | +- **No telemetry**: The auth package does not emit session logs containing credentials or user identity. Only anonymized metrics (success/failure counts) if anything. |
| 281 | + |
| 282 | +## Open Questions |
| 283 | + |
| 284 | +1. **One credential per profile, or both PAT + API key per profile?** One-per-profile is simpler; real usage will tell us if users routinely juggle both under one identity. Revisit after Phase 4. |
| 285 | +2. **Project-local profile override?** A `./.mxcli/config.yaml` with `auth_profile: deploy-ci` lets teams pin project-to-profile without env vars. Defer until we see the need. |
| 286 | +3. **Credential rotation UX.** `mxcli auth rotate` that walks the user through revoking an old PAT and generating a new one via the portal. Nice-to-have, not v1. |
| 287 | +4. **AAD client ID.** Ask Mendix DevRel whether a public CLI app registration exists or is planned. If yes, Phase 6 becomes a real v1 feature; if no, we ship without it and users generate PATs in the portal (the status quo). |
| 288 | +5. **Encrypted file backend?** If keychain isn't available, should the file backend encrypt at rest (e.g., age/libsodium with a passphrase)? Adds complexity; most CLIs (`gh`, `doctl`, `az`) rely on filesystem perms alone. Defer. |
| 289 | + |
| 290 | +## References |
| 291 | + |
| 292 | +- Content API authentication: <https://docs.mendix.com/apidocs-mxsdk/apidocs/content-api/> |
| 293 | +- Profile API keys: <https://docs.mendix.com/portal/user-settings/#profile-api-keys> |
| 294 | +- Marketplace modules proposal: `PROPOSAL_marketplace_modules.md` |
| 295 | +- RFC 8628 (device authorization grant, relevant for Phase 6): <https://datatracker.ietf.org/doc/html/rfc8628> |
| 296 | +- `zalando/go-keyring`: <https://github.com/zalando/go-keyring> |
0 commit comments