diff --git a/cmd/cli/README.md b/cmd/cli/README.md new file mode 100644 index 00000000..8fe88cb2 --- /dev/null +++ b/cmd/cli/README.md @@ -0,0 +1,221 @@ +# ondx CLI + +A Go CLI for OpenNDX management operations — members, schemas, applications, and policies — against the Portal Backend API. + +## Overview + +`ondx` logs an operator in via a browser-based OAuth2 login (Authorization Code + PKCE, like `gh auth login`), caches the resulting token, and uses it to call Portal Backend's management endpoints: creating members, schemas, and applications, listing/inspecting applications, and updating an application's policy (its granted schema fields). + +It works against any OIDC-compatible identity provider PB is configured to trust. Today that's usually one of two setups: + +- **ThunderID** (local dev) — the IDP this repo's `docker compose` stack runs locally. Since ThunderID's admin API isn't wired into Portal Backend's own outbound IDP calls yet (see [Limitations](#limitations)), member/application creation against ThunderID means onboarding the user/OAuth2 client manually via its console first, then registering it with `--idp-user-id` / `--idp-application-id --idp-client-id`. +- **Asgardeo (WSO2)** — the identity provider Portal Backend's own outbound calls (creating IDP users/OAuth2 clients on your behalf) are actually implemented against. Against Asgardeo, member/application creation can provision the IDP side automatically — omit the `--idp-*` flags. + +## Quick Start + +### 1. Build + +```bash +go build -o ondx ./cmd/cli +# or run directly with `go run ./cmd/cli ...` for any command below +``` + +### 2. Log in + +```bash +./ondx login +``` + +`ondx` ships with a built-in `local` profile matching this repo's `docker compose` local-dev stack (ThunderID as IDP on `https://localhost:8090`, client `NDX_CLI`, port `8765`, Portal Backend at `http://localhost:8083`), so a bare `ondx login` works out of the box against it — see [Profiles](#profiles) to add other environments (staging, a partner's deployment, ...) or override any of these values. + +This opens your browser to the identity provider's login page, catches the redirect on a local callback server, exchanges the code for a token, and caches it at `~/.openndx/credentials.json`. Every other command reuses that cached token automatically, refreshing it via its `refresh_token` when it's expired — you only need to log in again if the refresh token itself is no longer valid. + +Every flag below can still be passed explicitly, which overrides whatever the active profile sets, e.g. to point at a different issuer without touching your profile: + +```bash +./ondx login --issuer https://localhost:8090 --client-id NDX_CLI --callback-port 8765 --scopes "openid roles email" --insecure +``` + +`--issuer` fetches `authorization_endpoint`/`token_endpoint` from the identity provider's `{issuer}/.well-known/openid-configuration` (OIDC Discovery), so most standards-compliant IDPs only need a base URL. If an IDP doesn't serve that document at the standard root path, fall back to `--auth-url`/`--token-url` (which take precedence over discovery when set): + +```bash +./ondx login --auth-url https://localhost:8090/oauth2/authorize --token-url https://localhost:8090/oauth2/token --client-id NDX_CLI --callback-port 8765 --scopes "openid roles email" --insecure +``` + +This walkthrough uses two members: **DRP** (Department of Registrar of Persons), which owns and provides a schema, and **DIE** (Department of Immigration and Emigration), which registers an application and requests access to DRP's schema fields. + +### 3. Onboard the schema-owning member (DRP) + +```bash +./ondx members create --name "Department of Registrar of Persons" --email drp@drp.gov.lk --phone "+1234567890" \ + --idp-user-id "01900000-0000-7000-8000-000000000031" --pb-url http://localhost:8083 +# → prints memberId (drpMemberId) +``` + +### 4. Register DRP's schema + +Fields taken from `cmd/oe/schema.graphql`'s `PersonInfo` type — the ones sourced from `drp` (`providerKey: "drp"`, `schemaId: "drp-schema-v1"`): + +```bash +./ondx schemas create --name "DRP Person Registry" --endpoint http://drp.example.gov.lk/graphql \ + --member-id \ + --field person.fullName:public:primary --field person.otherNames:public:primary \ + --field person.permanentAddress:restricted:primary --field person.profession:restricted:primary \ + --pb-url http://localhost:8083 +# → prints schemaId, and each field as schemaId:fieldName +``` + +A field must exist as a schema field before it can be granted to an application — `applications create`/`policy update --field schemaId:fieldName` will fail with "policy metadata not found" for any field that was never declared via `schemas create` first. + +### 5. Onboard the application-owning member (DIE) + +```bash +./ondx members create --name "Department of Immigration and Emigration" --email die@die.gov.lk --phone "+1234567890" \ + --idp-user-id "01900000-0000-7000-8000-000000000032" --pb-url http://localhost:8083 +# → prints memberId (dieMemberId) +``` + +### 6. Register DIE's application and grant it DRP's schema fields + +The passport application needs the applicant's full name and permanent address to process an application: + +```bash +./ondx applications create --name "Passport Application" --member-id \ + --field :person.fullName --field :person.permanentAddress \ + --idp-application-id --idp-client-id \ + --pb-url http://localhost:8083 +# → prints applicationId +``` + +`applications create` grants only the fields passed at creation time. To change an existing application's granted fields later, use `policy update` — a full replace, so pass every field it should end up with. For example, to additionally grant `person.profession` while keeping the two fields already granted: + +```bash +./ondx policy update --app-id \ + --field :person.fullName --field :person.permanentAddress --field :person.profession \ + --pb-url http://localhost:8083 +``` + +## Commands + +### `ondx login` + +Browser-based OAuth2 Authorization Code + PKCE login (RFC 8252). Caches the resulting token. + +| Flag | Env var | Description | +|----------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--issuer` | `NDX_ISSUER` | Identity provider issuer/base URL; `--auth-url`/`--token-url` are discovered from `{issuer}/.well-known/openid-configuration` when they're not set explicitly | +| `--auth-url` | `NDX_AUTH_URL` | Identity provider authorization endpoint. Required unless `--issuer` supports discovery; overrides discovery when both are set | +| `--token-url` | `NDX_TOKEN_URL` | Identity provider token endpoint. Required unless `--issuer` supports discovery; overrides discovery when both are set | +| `--client-id` | `NDX_CLIENT_ID` | OAuth2 public client ID (required) | +| `--scopes` | `NDX_SCOPES` | Space-separated scopes to request | +| `--extra key=value` | — | Extra authorization *and* token-request query param, repeatable — see [ThunderID resource binding](#thunderid-resource-binding) for what this is for and why it's not needed against today's local-dev Portal Backend. Persisted with the cached token, so it's reused automatically on refresh too. | +| `--callback-port` | — | Fixed local port for the redirect callback (0 = OS-assigned random port; see [Callback port](#callback-port)) | +| `--no-browser` | — | Print the login URL instead of opening a browser | +| `--insecure` | — | Skip TLS certificate verification (local dev only — see [TLS](#tls-and---insecure)) | +| `--credentials-path` | — | Where to cache the token (default `~/.openndx/credentials.json`, or `credentials-.json` for a non-`local` profile) | +| `--profile` | `NDX_PROFILE` | Named profile to source defaults from for this invocation (default: the config file's current profile) — see [Profiles](#ondx-profile-list--ondx-profile-use-name--ondx-profile-set-name-flags) | + +### `ondx members create` + +Registers a member. `--name`, `--email`, `--phone` are always required. + +| Flag | Description | +|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--idp-user-id` | A user ID already provisioned in the IDP. If omitted, Portal Backend provisions the user itself — **Asgardeo only** (see [Limitations](#limitations)). | +| `--pb-url` (env `NDX_PB_URL`) | Portal Backend base URL | + +### `ondx schemas create` + +Registers a schema and its grantable fields directly, skipping GraphQL SDL/directive parsing entirely. `--name`, `--endpoint`, `--member-id`, and at least one `--field fieldName:accessControlType:source` are always required. + +| Flag | Description | +|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--field fieldName:accessControlType:source` | One field per flag, repeatable. `accessControlType` is `public` or `restricted`; `source` is `primary` or `fallback`. Note this is a **different 3-part format** from `applications create`/`policy update`'s 2-part `schemaId:fieldName`. | +| `--description` | Schema description | +| `--pb-url` (env `NDX_PB_URL`) | Portal Backend base URL | + +Prints each field as `schemaId:fieldName` — the `--field` shape `applications create`/`policy update` expect, so you can copy straight from here. + +### `ondx applications create` + +Registers an application. `--name`, `--member-id`, and at least one `--field schemaId:fieldName` are always required. + +| Flag | Description | +|-------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--description` | Application description | +| `--idp-application-id`, `--idp-client-id` | An OAuth2 client already provisioned in the IDP — must be given together. If both omitted, Portal Backend provisions the client itself — **Asgardeo only**. | +| `--pb-url` (env `NDX_PB_URL`) | Portal Backend base URL | + +### `ondx applications list [--member-id ...]` + +Lists applications, optionally filtered to one member's. Add `--json` for the raw response. + +### `ondx applications get --app-id ...` + +Shows one application's details and current policy (its granted fields, printed as `schemaId:fieldName` — the same shape `--field` expects, so you can copy straight from here into `policy update`). Add `--json` for the raw response. + +### `ondx policy update --app-id ... --field schemaId:fieldName ...` + +Replaces an application's policy (its granted schema fields). **This is a full replace, not an append** — pass every field the application should end up with, including ones it already has; anything you omit gets revoked. Run `applications get` first to see the current set. + +| Flag | Description | +|-------------------------------|------------------------------------------------| +| `--grant-duration` | e.g. `30d` or `365d` (default: server default) | +| `--pb-url` (env `NDX_PB_URL`) | Portal Backend base URL | + +Every command above also accepts `--credentials-path` and `--insecure`. + +### `ondx profile list` / `ondx profile use ` / `ondx profile set [flags]` + +Every command above also accepts `--profile ` (env `NDX_PROFILE`) to source its flag defaults — `--issuer`/`--auth-url`/`--token-url`/`--client-id`/`--scopes`/`--callback-port` (login only), and `--pb-url`/`--insecure` (every command) — from a named profile instead of retyping them each time. Precedence is: explicit flag > `--profile`/`NDX_PROFILE` for that one invocation > the config file's current profile > (nothing). + +Profiles are stored in `~/.openndx/config.json`. A built-in `local` profile (see [Log in](#2-log-in)) always exists even before that file does, so `ondx login` works with zero setup; anything you `ondx profile set local ...` overrides it. + +```bash +./ondx profile set staging --issuer https://idp.staging.example.com --client-id ondx-cli-staging --pb-url https://pb.staging.example.com +./ondx profile use staging # makes staging the default for every subsequent command +./ondx profile list # * marks the current profile +./ondx login --profile local # one-off override back to local without switching the default +``` + +`profile set` only changes the flags you pass — omitted flags keep their existing value in that profile. Each non-`local` profile also gets its own credentials cache (`~/.openndx/credentials-.json`) so switching profiles can't pick up a token cached against a different identity provider. + +## Notes + +### TLS and `--insecure` + +ThunderID's local-dev instance serves `https://localhost:8090` with a self-signed certificate (its own `docker compose` healthcheck uses `curl -k` for the same reason). `--insecure` skips TLS verification for that reason and is safe only for local dev — never pass it against a real deployment. + +### Callback port + +`ondx login` opens a short-lived local HTTP server to receive the OAuth2 redirect. RFC 8252 recommends a random OS-assigned port (the default here, `--callback-port 0`) since a fixed port can collide with another process or a previous unclean exit. Use a fixed `--callback-port` only when the identity provider's registered redirect URI requires an exact match rather than a wildcard port — which is the case for ThunderID's `NDX_CLI` client today (`thunderid/bootstrap/application.yaml`, pinned to `http://127.0.0.1:8765/callback`). Logging in with `--client-id NDX_CLI` enforces this: `ondx login` rejects any `--callback-port` other than `8765` for that client (including the `0` default above) instead of attempting a login ThunderID would reject anyway. + +### ThunderID resource binding + +ThunderID can bind an access token's `aud` claim to a resource server requested via a `resource=` parameter (`--extra resource=...`), instead of a fixed audience per client. **Do not use this against this repo's local-dev Portal Backend today** — `compose.yml` sets `IDP_ADMIN_PORTAL_CLIENT_ID=NDX_CLI`, so PB validates `aud` as the `NDX_CLI` client ID (which is what `ondx login`'s default local profile already produces without `--extra`); passing `resource=http://pb.openndx.local` would instead set `aud` to that URL and PB would reject the token. This only becomes relevant if PB's JWT config is changed to validate `aud` against a resource-server identifier instead. + +### Limitations + +- **Member/application creation against ThunderID always needs the manual-onboarding flags.** Portal Backend's own outbound calls to the IDP (creating a user, an OAuth2 client, and group/role assignments) only implement Asgardeo's (WSO2) admin API (`internal/pb/idp/idpfactory`) — not ThunderID's. Onboard the user/client manually via ThunderID's console first, then pass its ID(s) with `--idp-user-id` / `--idp-application-id --idp-client-id` to register it in Portal Backend without Portal Backend trying (and failing) to provision it itself. +- **No delete or update commands yet, and no `schemas list`/`get`.** Only `members create`, `schemas create`, `applications create`/`list`/`get`, and `policy update` are implemented. +- **`ondx applications get`/`list` output for `--field` values always reflects the full current policy** — there's no "show me the diff" helper; compare manually before running `policy update`. + +## Development + +```bash +go build ./cmd/cli/... ./internal/cli/... +go vet ./cmd/cli/... ./internal/cli/... +go test ./internal/cli/... +``` + +### Project Structure + +``` +cmd/cli/ +└── main.go # Entry point: flag parsing and subcommand dispatch + +internal/cli/ +├── auth/ # PKCE, browser-based login flow, token cache/refresh +├── pbclient/ # Portal Backend API client (reuses internal/pb/v1/models types) +└── profile/ # Named profiles (issuer/client-id/scopes/pb-url/...) cached at ~/.openndx/config.json +``` diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 00000000..ca353241 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,951 @@ +// Command ondx is a CLI for OpenNDX management operations against the Portal Backend. +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "sort" + "strings" + "syscall" + "time" + + "github.com/openndx/openndx-core/internal/cli/auth" + "github.com/openndx/openndx-core/internal/cli/pbclient" + "github.com/openndx/openndx-core/internal/cli/profile" + "github.com/openndx/openndx-core/internal/pb/v1/models" +) + +// newHTTPClient builds an HTTP client for talking to the identity provider or +// Portal Backend. insecureSkipVerify exists for local dev against ThunderID's +// self-signed dev certificate (its own compose healthcheck uses `curl -k` for +// the same reason) — never pass true against a real deployment. +func newHTTPClient(insecureSkipVerify bool) *http.Client { + client := &http.Client{Timeout: 15 * time.Second} + if insecureSkipVerify { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // opt-in local-dev flag only + } + } + return client +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + var err error + switch os.Args[1] { + case "login": + err = runLogin(ctx, os.Args[2:]) + case "profile": + err = runProfile(os.Args[2:]) + case "policy": + err = runPolicy(ctx, os.Args[2:]) + case "applications": + err = runApplications(ctx, os.Args[2:]) + case "members": + err = runMembers(ctx, os.Args[2:]) + case "schemas": + err = runSchemas(ctx, os.Args[2:]) + case "-h", "--help", "help": + printUsage() + return + default: + fmt.Fprintf(os.Stderr, "ondx: unknown command %q\n\n", os.Args[1]) + printUsage() + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "ondx: %v\n", err) + os.Exit(1) + } +} + +func printUsage() { + fmt.Fprint(os.Stderr, `ondx - OpenNDX management CLI + +Usage: + ondx login [flags] Log in via your browser and cache a token + ondx profile list List configured profiles + ondx profile use Switch the active profile + ondx profile set [flags] Create or update a named profile + ondx members create [flags] Register a new member + ondx schemas create [flags] Register a new schema and its grantable fields + ondx applications create [flags] Register a new application + ondx applications list [flags] List applications + ondx applications get [flags] Show an application's current details and policy + ondx policy update [flags] Update an existing application's policy + +Every command above accepts --profile (env NDX_PROFILE) to use a +profile other than the current one for that invocation - see +'ondx profile list' and 'ondx profile set -h'. A "local" profile matching this +repo's docker-compose local-dev stack is built in, so 'ondx login' works with +no flags at all until you configure other profiles. + +Run 'ondx -h' for flags on a specific command. +`) +} + +// stringSlice implements flag.Value to collect a repeated flag into a slice. +type stringSlice []string + +func (s *stringSlice) String() string { return strings.Join(*s, ",") } +func (s *stringSlice) Set(v string) error { + *s = append(*s, v) + return nil +} + +// keyValueMap implements flag.Value to collect repeated "key=value" flags into a map. +type keyValueMap map[string]string + +func (m keyValueMap) String() string { return fmt.Sprintf("%v", map[string]string(m)) } +func (m keyValueMap) Set(v string) error { + key, value, found := strings.Cut(v, "=") + if !found { + return fmt.Errorf("expected key=value, got %q", v) + } + m[key] = value + return nil +} + +func defaultCredentialsPathOrExit(profileName string) string { + path, err := auth.DefaultCredentialsPath(profileName) + if err != nil { + fmt.Fprintf(os.Stderr, "ondx: %v\n", err) + os.Exit(1) + } + return path +} + +// firstNonEmpty returns the first non-empty string, in priority order. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// extractFlagValue scans args for a flag's value without fully parsing the +// set. It exists to resolve --profile before a command's real flag.FlagSet +// is built, since that set's other flags need the resolved profile's values +// as their defaults, and flag defaults must be fixed before flag.Parse runs. +func extractFlagValue(args []string, name string) string { + for i := 0; i < len(args); i++ { + a := args[i] + for _, prefix := range []string{"--" + name + "=", "-" + name + "="} { + if strings.HasPrefix(a, prefix) { + return strings.TrimPrefix(a, prefix) + } + } + if (a == "--"+name || a == "-"+name) && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// resolveActiveProfile determines which profile a command should use - an +// explicit --profile flag or NDX_PROFILE if given, otherwise the config +// file's current profile - and returns its values to seed the command's +// other flag defaults. +func resolveActiveProfile(args []string) (name string, active profile.Profile, err error) { + name = firstNonEmpty(extractFlagValue(args, "profile"), os.Getenv("NDX_PROFILE")) + + path, err := profile.DefaultConfigPath() + if err != nil { + return "", profile.Profile{}, err + } + cfg, err := profile.Load(path) + if err != nil { + return "", profile.Profile{}, err + } + if name == "" { + name = cfg.CurrentProfile + } + active, err = cfg.Get(name) + if err != nil { + return "", profile.Profile{}, err + } + return name, active, nil +} + +func runProfile(args []string) error { + if len(args) < 1 { + return fmt.Errorf("expected a subcommand: 'list', 'use', or 'set' (usage: ondx profile list|use|set [flags])") + } + switch args[0] { + case "list": + return runProfileList() + case "use": + return runProfileUse(args[1:]) + case "set": + return runProfileSet(args[1:]) + default: + return fmt.Errorf("unknown profile subcommand %q (expected 'list', 'use', or 'set')", args[0]) + } +} + +func runProfileList() error { + path, err := profile.DefaultConfigPath() + if err != nil { + return err + } + cfg, err := profile.Load(path) + if err != nil { + return err + } + + names := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + marker := " " + if name == cfg.CurrentProfile { + marker = "*" + } + fmt.Printf("%s %s\n", marker, name) + } + return nil +} + +func runProfileUse(args []string) error { + if len(args) < 1 { + return fmt.Errorf("expected a profile name (usage: ondx profile use )") + } + name := args[0] + + path, err := profile.DefaultConfigPath() + if err != nil { + return err + } + cfg, err := profile.Load(path) + if err != nil { + return err + } + if _, err := cfg.Get(name); err != nil { + return err + } + + cfg.CurrentProfile = name + if err := profile.Save(path, cfg); err != nil { + return err + } + fmt.Printf("Now using profile %q.\n", name) + return nil +} + +func runProfileSet(args []string) error { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + return fmt.Errorf("expected a profile name (usage: ondx profile set [flags])") + } + name := args[0] + + fs := flag.NewFlagSet("profile set", flag.ExitOnError) + issuer := fs.String("issuer", "", "Identity provider issuer/base URL") + authURL := fs.String("auth-url", "", "Identity provider authorization endpoint (overrides issuer-based discovery)") + tokenURL := fs.String("token-url", "", "Identity provider token endpoint (overrides issuer-based discovery)") + clientID := fs.String("client-id", "", "OAuth2 public client ID") + scopes := fs.String("scopes", "", "Space-separated OAuth2 scopes to request") + callbackPort := fs.Int("callback-port", 0, "Fixed local port for the OAuth2 redirect callback") + pbURL := fs.String("pb-url", "", "Portal Backend base URL") + insecure := fs.Bool("insecure", false, "Skip TLS certificate verification") + fs.Usage = func() { + fmt.Fprintln(fs.Output(), "Usage of profile set :") + fs.PrintDefaults() + fmt.Fprint(fs.Output(), "\nOnly flags actually passed are changed - existing values for any flag\n"+ + "you omit are left as they are.\n") + } + if err := fs.Parse(args[1:]); err != nil { + return err + } + + path, err := profile.DefaultConfigPath() + if err != nil { + return err + } + cfg, err := profile.Load(path) + if err != nil { + return err + } + if cfg.Profiles == nil { + cfg.Profiles = map[string]profile.Profile{} + } + + p := cfg.Profiles[name] + changed := false + fs.Visit(func(f *flag.Flag) { + changed = true + switch f.Name { + case "issuer": + p.Issuer = *issuer + case "auth-url": + p.AuthURL = *authURL + case "token-url": + p.TokenURL = *tokenURL + case "client-id": + p.ClientID = *clientID + case "scopes": + p.Scopes = *scopes + case "callback-port": + p.CallbackPort = *callbackPort + case "pb-url": + p.PBURL = *pbURL + case "insecure": + p.Insecure = *insecure + } + }) + if !changed { + return fmt.Errorf("at least one flag is required to set a value, e.g. --issuer https://idp.example.com") + } + + cfg.Profiles[name] = p + if err := profile.Save(path, cfg); err != nil { + return err + } + fmt.Printf("Profile %q saved.\n", name) + return nil +} + +func runLogin(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("login", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + issuer := fs.String("issuer", firstNonEmpty(os.Getenv("NDX_ISSUER"), active.Issuer), "Identity provider issuer/base URL (env NDX_ISSUER) - auth-url/token-url are discovered from {issuer}/.well-known/openid-configuration when set") + authURL := fs.String("auth-url", firstNonEmpty(os.Getenv("NDX_AUTH_URL"), active.AuthURL), "Identity provider authorization endpoint (env NDX_AUTH_URL). Overrides discovery from --issuer if both are set") + tokenURL := fs.String("token-url", firstNonEmpty(os.Getenv("NDX_TOKEN_URL"), active.TokenURL), "Identity provider token endpoint (env NDX_TOKEN_URL). Overrides discovery from --issuer if both are set") + clientID := fs.String("client-id", firstNonEmpty(os.Getenv("NDX_CLIENT_ID"), active.ClientID), "OAuth2 public client ID registered with the identity provider (env NDX_CLIENT_ID)") + scopes := fs.String("scopes", firstNonEmpty(os.Getenv("NDX_SCOPES"), active.Scopes), "Space-separated OAuth2 scopes to request (env NDX_SCOPES)") + noBrowser := fs.Bool("no-browser", false, "Print the login URL instead of opening a browser automatically") + credentialsPath := fs.String("credentials-path", "", "Path to store the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + callbackPort := fs.Int("callback-port", active.CallbackPort, "Fixed local port for the OAuth2 redirect callback (0 = OS-assigned random port). Required if the identity provider's redirect URI allow-list needs an exact match rather than a wildcard port") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + extraParams := keyValueMap{} + fs.Var(extraParams, "extra", "Extra authorization query param as key=value (repeatable, e.g. resource=http://api.openndx.local)") + if err := fs.Parse(args); err != nil { + return err + } + + if *clientID == "" { + return fmt.Errorf("--client-id is required (or set NDX_CLIENT_ID, or configure it in a profile)") + } + + if *clientID == profile.ThunderIDCLIClientID && *callbackPort != profile.ThunderIDCallbackPort { + return fmt.Errorf("--callback-port must be %d for client %q: ThunderID only accepts the exact redirect URI registered for it in thunderid/bootstrap/application.yaml (http://127.0.0.1:%d/callback)", profile.ThunderIDCallbackPort, profile.ThunderIDCLIClientID, profile.ThunderIDCallbackPort) + } + + httpClient := newHTTPClient(*insecure) + + if *issuer != "" && (*authURL == "" || *tokenURL == "") { + discoveredAuthURL, discoveredTokenURL, err := auth.DiscoverEndpoints(ctx, httpClient, *issuer) + if err != nil { + return fmt.Errorf("OIDC discovery failed for issuer %s: %w (pass --auth-url and --token-url explicitly if this identity provider doesn't support discovery at that path)", *issuer, err) + } + if *authURL == "" { + *authURL = discoveredAuthURL + } + if *tokenURL == "" { + *tokenURL = discoveredTokenURL + } + } + + if *authURL == "" || *tokenURL == "" { + return fmt.Errorf("--auth-url and --token-url are required unless --issuer supports OIDC discovery (or set NDX_AUTH_URL/NDX_TOKEN_URL/NDX_ISSUER, or configure them in a profile)") + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + token, err := auth.Login(ctx, auth.LoginOptions{ + AuthURL: *authURL, + TokenURL: *tokenURL, + ClientID: *clientID, + Scopes: *scopes, + ExtraParams: extraParams, + OpenBrowser: !*noBrowser, + CallbackPort: *callbackPort, + HTTPClient: httpClient, + }) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + if err := auth.SaveToken(path, token); err != nil { + return fmt.Errorf("login succeeded but failed to save credentials: %w", err) + } + + fmt.Printf("Logged in (profile %q). Credentials cached at %s\n", profileName, path) + return nil +} + +func runPolicy(ctx context.Context, args []string) error { + if len(args) < 1 { + return fmt.Errorf("expected a subcommand: 'update' (usage: ondx policy update [flags])") + } + switch args[0] { + case "update": + return runPolicyUpdate(ctx, args[1:]) + default: + return fmt.Errorf("unknown policy subcommand %q (expected 'update')", args[0]) + } +} + +func runPolicyUpdate(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("policy update", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + appID := fs.String("app-id", "", "Application ID to update (required)") + grantDuration := fs.String("grant-duration", "", "Grant duration for the granted fields, e.g. 30d or 365d (default: server default)") + pbURL := fs.String("pb-url", firstNonEmpty(os.Getenv("NDX_PB_URL"), active.PBURL), "Portal Backend base URL (env NDX_PB_URL)") + credentialsPath := fs.String("credentials-path", "", "Path to the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + var fields stringSlice + fs.Var(&fields, "field", "Field to grant, as schemaId:fieldName (repeatable, required)") + if err := fs.Parse(args); err != nil { + return err + } + + if *appID == "" { + return fmt.Errorf("--app-id is required") + } + if len(fields) == 0 { + return fmt.Errorf("at least one --field schemaId:fieldName is required") + } + if *pbURL == "" { + return fmt.Errorf("--pb-url is required (or set NDX_PB_URL, or configure it in a profile)") + } + + selectedFields := make([]models.SelectedFieldRecord, 0, len(fields)) + for _, f := range fields { + schemaID, fieldName, found := strings.Cut(f, ":") + if !found || schemaID == "" || fieldName == "" { + return fmt.Errorf("invalid --field %q: expected schemaId:fieldName", f) + } + selectedFields = append(selectedFields, models.SelectedFieldRecord{ + SchemaID: schemaID, + FieldName: fieldName, + }) + } + + req := &models.UpdateApplicationPolicyRequest{SelectedFields: selectedFields} + if *grantDuration != "" { + gd := models.GrantDurationType(*grantDuration) + req.GrantDuration = &gd + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + httpClient := newHTTPClient(*insecure) + + token, err := auth.EnsureFreshToken(ctx, path, httpClient) + if err != nil { + return err + } + + client := pbclient.NewClient(*pbURL, token.AccessToken) + client.HTTPClient = httpClient + app, err := client.UpdateApplicationPolicy(ctx, *appID, req) + if err != nil { + return fmt.Errorf("failed to update application policy: %w", err) + } + + fmt.Printf("Updated policy for application %s (%s):\n", app.ApplicationID, app.ApplicationName) + for _, f := range app.SelectedFields { + fmt.Printf(" - %s (schema %s)\n", f.FieldName, f.SchemaID) + } + return nil +} + +func runMembers(ctx context.Context, args []string) error { + if len(args) < 1 { + return fmt.Errorf("expected a subcommand: 'create' (usage: ondx members create [flags])") + } + switch args[0] { + case "create": + return runMembersCreate(ctx, args[1:]) + default: + return fmt.Errorf("unknown members subcommand %q (expected 'create')", args[0]) + } +} + +func runMembersCreate(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("members create", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + name := fs.String("name", "", "Member name (required)") + email := fs.String("email", "", "Member email (required)") + phone := fs.String("phone", "", "Member phone number (required)") + idpUserID := fs.String("idp-user-id", "", "Pre-provisioned IDP user ID (see note below)") + pbURL := fs.String("pb-url", firstNonEmpty(os.Getenv("NDX_PB_URL"), active.PBURL), "Portal Backend base URL (env NDX_PB_URL)") + credentialsPath := fs.String("credentials-path", "", "Path to the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + fs.Usage = func() { + fmt.Fprintln(fs.Output(), "Usage of members create:") + fs.PrintDefaults() + fmt.Fprint(fs.Output(), "\nIf -idp-user-id is omitted, Portal Backend provisions the user in the IDP\n"+ + "itself (create account + assign to the member group) - this only works\n"+ + "against an Asgardeo (WSO2) IDP today, not ThunderID. Against ThunderID,\n"+ + "create the user manually via its console first (and assign whatever\n"+ + "group/role that person needs), then pass their user id here.\n") + } + if err := fs.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("--name is required") + } + if *email == "" { + return fmt.Errorf("--email is required") + } + if *phone == "" { + return fmt.Errorf("--phone is required") + } + if *pbURL == "" { + return fmt.Errorf("--pb-url is required (or set NDX_PB_URL, or configure it in a profile)") + } + + req := &models.CreateMemberRequest{ + Name: *name, + Email: *email, + PhoneNumber: *phone, + } + if *idpUserID != "" { + req.IdpUserID = idpUserID + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + httpClient := newHTTPClient(*insecure) + + token, err := auth.EnsureFreshToken(ctx, path, httpClient) + if err != nil { + return err + } + + client := pbclient.NewClient(*pbURL, token.AccessToken) + client.HTTPClient = httpClient + member, err := client.CreateMember(ctx, req) + if err != nil { + return fmt.Errorf("failed to create member: %w", err) + } + + fmt.Printf("Created member %s (%s)\n", member.MemberID, member.Name) + fmt.Printf("Email: %s\n", member.Email) + fmt.Printf("IdP User: %s\n", member.IdpUserID) + return nil +} + +func runSchemas(ctx context.Context, args []string) error { + if len(args) < 1 { + return fmt.Errorf("expected a subcommand: 'create' (usage: ondx schemas create [flags])") + } + switch args[0] { + case "create": + return runSchemasCreate(ctx, args[1:]) + default: + return fmt.Errorf("unknown schemas subcommand %q (expected 'create')", args[0]) + } +} + +func runSchemasCreate(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("schemas create", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + name := fs.String("name", "", "Schema name (required)") + description := fs.String("description", "", "Schema description") + endpoint := fs.String("endpoint", "", "Provider GraphQL endpoint (required)") + memberID := fs.String("member-id", "", "Owning member ID (required)") + pbURL := fs.String("pb-url", firstNonEmpty(os.Getenv("NDX_PB_URL"), active.PBURL), "Portal Backend base URL (env NDX_PB_URL)") + credentialsPath := fs.String("credentials-path", "", "Path to the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + var fields stringSlice + fs.Var(&fields, "field", "Grantable field, as fieldName:accessControlType:source[:isOwner] (repeatable, required). accessControlType is 'public' or 'restricted'; source is 'primary' or 'fallback'; append ':isOwner' to mark this field as the record's owner-identifying field.") + fs.Usage = func() { + fmt.Fprintln(fs.Output(), "Usage of schemas create:") + fs.PrintDefaults() + fmt.Fprint(fs.Output(), "\nEach -field declares one policy-metadata record directly, skipping GraphQL\n"+ + "SDL parsing entirely - the fieldName is used as-is (no typename. prefix).\n"+ + "Example: -field email:public:primary\n"+ + "By default, isOwner is false and owner is set to \"citizen\" (the only\n"+ + "owner value this system currently supports). Append \":isOwner\" to mark\n"+ + "a field as the owner-identifying field instead, e.g. -field nic:public:primary:isOwner\n"+ + "(isOwner true and owner unset are mutually exclusive - the PDP rejects\n"+ + "records that don't follow exactly one of these two shapes).\n") + } + if err := fs.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("--name is required") + } + if *endpoint == "" { + return fmt.Errorf("--endpoint is required") + } + if *memberID == "" { + return fmt.Errorf("--member-id is required") + } + if len(fields) == 0 { + return fmt.Errorf("at least one --field fieldName:accessControlType:source is required") + } + if *pbURL == "" { + return fmt.Errorf("--pb-url is required (or set NDX_PB_URL, or configure it in a profile)") + } + + records := make([]models.PolicyMetadataCreateRequestRecord, 0, len(fields)) + for _, f := range fields { + parts := strings.Split(f, ":") + if len(parts) < 3 || len(parts) > 4 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return fmt.Errorf("invalid --field %q: expected fieldName:accessControlType:source[:isOwner]", f) + } + isOwner := false + if len(parts) == 4 { + if parts[3] != "isOwner" { + return fmt.Errorf("invalid --field %q: the optional 4th part must be exactly \"isOwner\"", f) + } + isOwner = true + } + + record := models.PolicyMetadataCreateRequestRecord{ + FieldName: parts[0], + AccessControlType: models.AccessControlType(parts[1]), + Source: models.Source(parts[2]), + IsOwner: isOwner, + } + // The PDP requires owner to be set when isOwner is false, and unset + // when isOwner is true - "citizen" is the only owner value this + // system currently supports. + if !isOwner { + owner := models.OwnerCitizen + record.Owner = &owner + } + records = append(records, record) + } + + req := &models.CreateSchemaRequest{ + SchemaName: *name, + Endpoint: *endpoint, + MemberID: *memberID, + Fields: records, + } + if *description != "" { + req.SchemaDescription = description + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + httpClient := newHTTPClient(*insecure) + + token, err := auth.EnsureFreshToken(ctx, path, httpClient) + if err != nil { + return err + } + + client := pbclient.NewClient(*pbURL, token.AccessToken) + client.HTTPClient = httpClient + schema, err := client.CreateSchema(ctx, req) + if err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + + fmt.Printf("Created schema %s (%s)\n", schema.SchemaID, schema.SchemaName) + fmt.Println("Fields:") + for _, r := range records { + fmt.Printf(" - %s:%s (%s)\n", schema.SchemaID, r.FieldName, r.AccessControlType) + } + return nil +} + +func runApplications(ctx context.Context, args []string) error { + if len(args) < 1 { + return fmt.Errorf("expected a subcommand: 'create', 'get', or 'list' (usage: ondx applications create|get|list [flags])") + } + switch args[0] { + case "create": + return runApplicationsCreate(ctx, args[1:]) + case "get": + return runApplicationsGet(ctx, args[1:]) + case "list": + return runApplicationsList(ctx, args[1:]) + default: + return fmt.Errorf("unknown applications subcommand %q (expected 'create', 'get', or 'list')", args[0]) + } +} + +func runApplicationsCreate(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("applications create", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + name := fs.String("name", "", "Application name (required)") + description := fs.String("description", "", "Application description") + memberID := fs.String("member-id", "", "Owning member ID (required)") + idpApplicationID := fs.String("idp-application-id", "", "Pre-provisioned IDP application ID (must be paired with --idp-client-id; see note below)") + idpClientID := fs.String("idp-client-id", "", "Pre-provisioned IDP OAuth2 client ID (must be paired with --idp-application-id; see note below)") + pbURL := fs.String("pb-url", firstNonEmpty(os.Getenv("NDX_PB_URL"), active.PBURL), "Portal Backend base URL (env NDX_PB_URL)") + credentialsPath := fs.String("credentials-path", "", "Path to the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + var fields stringSlice + fs.Var(&fields, "field", "Field to grant, as schemaId:fieldName (repeatable, required)") + fs.Usage = func() { + fmt.Fprintln(fs.Output(), "Usage of applications create:") + fs.PrintDefaults() + fmt.Fprint(fs.Output(), "\nIf -idp-application-id/-idp-client-id are both omitted, Portal Backend\n"+ + "provisions the OAuth2 client itself - this only works against an Asgardeo\n"+ + "(WSO2) IDP today, not ThunderID. Against ThunderID, onboard the OAuth2 client\n"+ + "manually via its console first, then pass its resource id and clientId here\n"+ + "together.\n") + } + if err := fs.Parse(args); err != nil { + return err + } + + if *name == "" { + return fmt.Errorf("--name is required") + } + if *memberID == "" { + return fmt.Errorf("--member-id is required") + } + if len(fields) == 0 { + return fmt.Errorf("at least one --field schemaId:fieldName is required") + } + if (*idpApplicationID == "") != (*idpClientID == "") { + return fmt.Errorf("--idp-application-id and --idp-client-id must both be set, or both omitted") + } + if *pbURL == "" { + return fmt.Errorf("--pb-url is required (or set NDX_PB_URL, or configure it in a profile)") + } + + selectedFields := make([]models.SelectedFieldRecord, 0, len(fields)) + for _, f := range fields { + schemaID, fieldName, found := strings.Cut(f, ":") + if !found || schemaID == "" || fieldName == "" { + return fmt.Errorf("invalid --field %q: expected schemaId:fieldName", f) + } + selectedFields = append(selectedFields, models.SelectedFieldRecord{ + SchemaID: schemaID, + FieldName: fieldName, + }) + } + + req := &models.CreateApplicationRequest{ + ApplicationName: *name, + SelectedFields: selectedFields, + MemberID: *memberID, + } + if *description != "" { + req.ApplicationDescription = description + } + if *idpApplicationID != "" { + req.IdpApplicationID = idpApplicationID + req.IdpClientID = idpClientID + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + httpClient := newHTTPClient(*insecure) + + token, err := auth.EnsureFreshToken(ctx, path, httpClient) + if err != nil { + return err + } + + client := pbclient.NewClient(*pbURL, token.AccessToken) + client.HTTPClient = httpClient + app, err := client.CreateApplication(ctx, req) + if err != nil { + return fmt.Errorf("failed to create application: %w", err) + } + + fmt.Printf("Created application %s (%s)\n", app.ApplicationID, app.ApplicationName) + if app.IdpClientID != nil { + fmt.Printf("IdP Client: %s\n", *app.IdpClientID) + } + fmt.Println("Selected fields (current policy):") + for _, f := range app.SelectedFields { + fmt.Printf(" - %s:%s\n", f.SchemaID, f.FieldName) + } + return nil +} + +func runApplicationsList(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("applications list", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + memberID := fs.String("member-id", "", "Filter to a single member's applications (admins see all applications if omitted)") + pbURL := fs.String("pb-url", firstNonEmpty(os.Getenv("NDX_PB_URL"), active.PBURL), "Portal Backend base URL (env NDX_PB_URL)") + credentialsPath := fs.String("credentials-path", "", "Path to the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + jsonOutput := fs.Bool("json", false, "Print the raw JSON response instead of a formatted summary") + if err := fs.Parse(args); err != nil { + return err + } + + if *pbURL == "" { + return fmt.Errorf("--pb-url is required (or set NDX_PB_URL, or configure it in a profile)") + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + httpClient := newHTTPClient(*insecure) + + token, err := auth.EnsureFreshToken(ctx, path, httpClient) + if err != nil { + return err + } + + client := pbclient.NewClient(*pbURL, token.AccessToken) + client.HTTPClient = httpClient + + var memberFilter *string + if *memberID != "" { + memberFilter = memberID + } + apps, err := client.ListApplications(ctx, memberFilter) + if err != nil { + return fmt.Errorf("failed to list applications: %w", err) + } + + if *jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(apps) + } + + if len(apps.Items) == 0 { + fmt.Println("No applications found.") + return nil + } + fmt.Printf("%-40s %-30s %-40s %s\n", "APPLICATION ID", "NAME", "MEMBER ID", "FIELDS") + for _, app := range apps.Items { + fmt.Printf("%-40s %-30s %-40s %d\n", app.ApplicationID, app.ApplicationName, app.MemberID, len(app.SelectedFields)) + } + return nil +} + +func runApplicationsGet(ctx context.Context, args []string) error { + profileName, active, err := resolveActiveProfile(args) + if err != nil { + return err + } + + fs := flag.NewFlagSet("applications get", flag.ExitOnError) + fs.String("profile", profileName, "Named profile to use for defaults (env NDX_PROFILE; see 'ondx profile list')") + appID := fs.String("app-id", "", "Application ID to fetch (required)") + pbURL := fs.String("pb-url", firstNonEmpty(os.Getenv("NDX_PB_URL"), active.PBURL), "Portal Backend base URL (env NDX_PB_URL)") + credentialsPath := fs.String("credentials-path", "", "Path to the cached token (default ~/.openndx/credentials.json, or credentials-.json for a non-default profile)") + insecure := fs.Bool("insecure", active.Insecure, "Skip TLS certificate verification (local dev only, e.g. against ThunderID's self-signed cert)") + jsonOutput := fs.Bool("json", false, "Print the raw JSON response instead of a formatted summary") + if err := fs.Parse(args); err != nil { + return err + } + + if *appID == "" { + return fmt.Errorf("--app-id is required") + } + if *pbURL == "" { + return fmt.Errorf("--pb-url is required (or set NDX_PB_URL, or configure it in a profile)") + } + + path := *credentialsPath + if path == "" { + path = defaultCredentialsPathOrExit(profileName) + } + + httpClient := newHTTPClient(*insecure) + + token, err := auth.EnsureFreshToken(ctx, path, httpClient) + if err != nil { + return err + } + + client := pbclient.NewClient(*pbURL, token.AccessToken) + client.HTTPClient = httpClient + app, err := client.GetApplication(ctx, *appID) + if err != nil { + return fmt.Errorf("failed to fetch application: %w", err) + } + + if *jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(app) + } + + fmt.Printf("Application: %s (%s)\n", app.ApplicationName, app.ApplicationID) + if app.ApplicationDescription != nil && *app.ApplicationDescription != "" { + fmt.Printf("Description: %s\n", *app.ApplicationDescription) + } + fmt.Printf("Member ID: %s\n", app.MemberID) + fmt.Printf("Version: %s\n", app.Version) + if app.IdpClientID != nil { + fmt.Printf("IdP Client: %s\n", *app.IdpClientID) + } + fmt.Printf("Created: %s\n", app.CreatedAt) + fmt.Printf("Updated: %s\n", app.UpdatedAt) + fmt.Println("Selected fields (current policy):") + if len(app.SelectedFields) == 0 { + fmt.Println(" (none)") + } + for _, f := range app.SelectedFields { + fmt.Printf(" - %s:%s\n", f.SchemaID, f.FieldName) + } + return nil +} diff --git a/internal/cli/auth/discovery.go b/internal/cli/auth/discovery.go new file mode 100644 index 00000000..ea953767 --- /dev/null +++ b/internal/cli/auth/discovery.go @@ -0,0 +1,65 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// oidcDiscoveryDocument mirrors the subset of an OpenID Connect discovery +// document (OIDC Discovery 1.0 / RFC 8414) this CLI needs. +type oidcDiscoveryDocument struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` +} + +// maxDiscoveryResponseBytes bounds how much of a discovery response gets +// read into memory - real discovery documents are a few KB, so this is +// generous headroom against a misbehaving or malicious server sending an +// oversized or non-terminating response. +const maxDiscoveryResponseBytes = 1 << 20 // 1 MiB + +// DiscoverEndpoints fetches the OIDC discovery document at +// issuer + "/.well-known/openid-configuration" and returns its authorization +// and token endpoints. This lets callers configure just an issuer/base URL, +// as most standards-compliant identity providers support discovery, instead +// of every individual endpoint. +func DiscoverEndpoints(ctx context.Context, httpClient *http.Client, issuer string) (authURL, tokenURL string, err error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + discoveryURL := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return "", "", fmt.Errorf("failed to create discovery request: %w", err) + } + resp, err := httpClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to reach discovery endpoint %s: %w", discoveryURL, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryResponseBytes+1)) + if err != nil { + return "", "", fmt.Errorf("failed to read discovery response: %w", err) + } + if len(body) > maxDiscoveryResponseBytes { + return "", "", fmt.Errorf("discovery endpoint %s returned a response larger than %d bytes", discoveryURL, maxDiscoveryResponseBytes) + } + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("discovery endpoint %s returned status %d: %s", discoveryURL, resp.StatusCode, string(body)) + } + + var doc oidcDiscoveryDocument + if err := json.Unmarshal(body, &doc); err != nil { + return "", "", fmt.Errorf("failed to parse discovery document from %s: %w", discoveryURL, err) + } + if doc.AuthorizationEndpoint == "" || doc.TokenEndpoint == "" { + return "", "", fmt.Errorf("discovery document from %s is missing authorization_endpoint or token_endpoint", discoveryURL) + } + return doc.AuthorizationEndpoint, doc.TokenEndpoint, nil +} diff --git a/internal/cli/auth/discovery_test.go b/internal/cli/auth/discovery_test.go new file mode 100644 index 00000000..363c2d11 --- /dev/null +++ b/internal/cli/auth/discovery_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDiscoverEndpoints_Success(t *testing.T) { + var requestedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authorization_endpoint":"https://idp.example.com/oauth2/authorize","token_endpoint":"https://idp.example.com/oauth2/token"}`)) + })) + defer server.Close() + + authURL, tokenURL, err := DiscoverEndpoints(context.Background(), server.Client(), server.URL) + assert.NoError(t, err) + assert.Equal(t, "https://idp.example.com/oauth2/authorize", authURL) + assert.Equal(t, "https://idp.example.com/oauth2/token", tokenURL) + assert.Equal(t, "/.well-known/openid-configuration", requestedPath) +} + +func TestDiscoverEndpoints_TrailingSlashOnIssuer(t *testing.T) { + var requestedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authorization_endpoint":"https://idp.example.com/oauth2/authorize","token_endpoint":"https://idp.example.com/oauth2/token"}`)) + })) + defer server.Close() + + _, _, err := DiscoverEndpoints(context.Background(), server.Client(), server.URL+"/") + assert.NoError(t, err) + assert.Equal(t, "/.well-known/openid-configuration", requestedPath) +} + +func TestDiscoverEndpoints_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer server.Close() + + _, _, err := DiscoverEndpoints(context.Background(), server.Client(), server.URL) + assert.Error(t, err) + assert.ErrorContains(t, err, "status 404") +} + +func TestDiscoverEndpoints_ResponseTooLarge(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + oversized := make([]byte, maxDiscoveryResponseBytes+1) + for i := range oversized { + oversized[i] = ' ' + } + _, _ = w.Write(oversized) + })) + defer server.Close() + + _, _, err := DiscoverEndpoints(context.Background(), server.Client(), server.URL) + assert.Error(t, err) + assert.ErrorContains(t, err, "larger than") +} + +func TestDiscoverEndpoints_MissingEndpoints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issuer":"https://idp.example.com"}`)) + })) + defer server.Close() + + _, _, err := DiscoverEndpoints(context.Background(), server.Client(), server.URL) + assert.Error(t, err) + assert.ErrorContains(t, err, "missing authorization_endpoint or token_endpoint") +} diff --git a/internal/cli/auth/login.go b/internal/cli/auth/login.go new file mode 100644 index 00000000..16a0b3a3 --- /dev/null +++ b/internal/cli/auth/login.go @@ -0,0 +1,321 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "sync" + "time" +) + +// LoginOptions configures a browser-based OAuth2 Authorization Code + PKCE login. +type LoginOptions struct { + AuthURL string + TokenURL string + ClientID string + Scopes string + ExtraParams map[string]string // IDP-specific extras, e.g. "resource" or "audience" + HTTPClient *http.Client + OpenBrowser bool + Timeout time.Duration + // CallbackPort pins the local redirect listener to a specific loopback + // port, for identity providers (like ThunderID today) whose redirectUris + // allow-list requires an exact match rather than a wildcard/any port. 0 + // means let the OS assign a free ephemeral port (the RFC 8252-preferred + // default, used whenever the IDP supports it). + CallbackPort int +} + +// openBrowser launches the system's default browser at the given URL. It is +// a variable so tests can stub it out without actually opening a browser. +var openBrowser = defaultOpenBrowser + +func defaultOpenBrowser(rawURL string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + cmd = exec.Command("xdg-open", rawURL) + } + return cmd.Start() +} + +type callbackResult struct { + code string + err error +} + +// Login runs a browser-based OAuth2 Authorization Code + PKCE flow and returns +// the resulting token. Per RFC 8252 (OAuth for native apps), it starts a +// short-lived local HTTP server on a loopback, OS-assigned port to receive +// the redirect, opens the system browser to the authorization URL, waits for +// the callback, then exchanges the code for a token. +func Login(ctx context.Context, opts LoginOptions) (*Token, error) { + if opts.HTTPClient == nil { + opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} + } + if opts.Timeout == 0 { + opts.Timeout = 5 * time.Minute + } + + pkce, err := NewPKCE() + if err != nil { + return nil, err + } + state, err := NewState() + if err != nil { + return nil, err + } + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", opts.CallbackPort)) + if err != nil { + return nil, fmt.Errorf("failed to start local callback listener on port %d: %w", opts.CallbackPort, err) + } + port := listener.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port) + + // resultCh has room for exactly one result, since Login only ever reads + // one; callbackOnce keeps a second (e.g. duplicate or retried) callback + // hit from blocking on that full channel forever - srv.Shutdown doesn't + // interrupt an in-flight handler, so a blocked send would otherwise wait + // out its whole shutdown deadline. + resultCh := make(chan callbackResult, 1) + var callbackOnce sync.Once + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + var result callbackResult + switch { + case q.Get("error") != "": + result = callbackResult{err: fmt.Errorf("authorization failed: %s: %s", q.Get("error"), q.Get("error_description"))} + writeCallbackResponse(w, false) + case q.Get("state") != state: + result = callbackResult{err: fmt.Errorf("state mismatch in callback: possible CSRF, aborting login")} + writeCallbackResponse(w, false) + case q.Get("code") == "": + result = callbackResult{err: fmt.Errorf("no authorization code returned by identity provider")} + writeCallbackResponse(w, false) + default: + result = callbackResult{code: q.Get("code")} + writeCallbackResponse(w, true) + } + callbackOnce.Do(func() { resultCh <- result }) + }) + + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(listener) }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + authorizeURL, err := buildAuthorizeURL(opts, redirectURI, state, pkce.Challenge) + if err != nil { + return nil, err + } + + if opts.OpenBrowser { + if err := openBrowser(authorizeURL); err != nil { + fmt.Printf("Could not open a browser automatically (%v).\nPlease open this URL manually:\n%s\n\n", err, authorizeURL) + } else { + fmt.Printf("Opening your browser to log in. If it doesn't open automatically, visit:\n%s\n\n", authorizeURL) + } + } else { + fmt.Printf("Open this URL to log in:\n%s\n\n", authorizeURL) + } + + select { + case res := <-resultCh: + if res.err != nil { + return nil, res.err + } + return exchangeCode(ctx, opts, res.code, redirectURI, pkce.Verifier) + case <-time.After(opts.Timeout): + return nil, fmt.Errorf("login timed out after %s waiting for browser callback", opts.Timeout) + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func buildAuthorizeURL(opts LoginOptions, redirectURI, state, challenge string) (string, error) { + u, err := url.Parse(opts.AuthURL) + if err != nil { + return "", fmt.Errorf("invalid authorization URL: %w", err) + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", opts.ClientID) + q.Set("redirect_uri", redirectURI) + if opts.Scopes != "" { + q.Set("scope", opts.Scopes) + } + q.Set("state", state) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + for k, v := range opts.ExtraParams { + q.Set(k, v) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +// withQueryParams returns rawURL with params merged into its query string, +// added on top of (and overriding on key collision) whatever query the URL +// already carries. +func withQueryParams(rawURL string, params map[string]string) (string, error) { + if len(params) == 0 { + return rawURL, nil + } + u, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("invalid URL %q: %w", rawURL, err) + } + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func writeCallbackResponse(w http.ResponseWriter, ok bool) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + const bodyStyle = "display:flex;flex-direction:column;align-items:center;justify-content:center;" + + "height:100vh;margin:0;text-align:center;font-family:sans-serif" + if ok { + // window.close() only works on a tab the browser considers + // script-opened; since this tab was opened by the OS's "open URL" + // command instead, some browsers will ignore it and leave the tab + // open - hence the fallback text still being shown underneath. + _, _ = io.WriteString(w, ` +

Login successful.

+

You may close this window and return to the terminal.

+ +`) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, ` +

Login failed.

+

You may close this window and return to the terminal.

+`) +} + +// tokenResponse mirrors the standard OAuth2 token endpoint JSON response. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +func exchangeCode(ctx context.Context, opts LoginOptions, code, redirectURI, verifier string) (*Token, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("client_id", opts.ClientID) + form.Set("code_verifier", verifier) + + return doTokenRequest(ctx, opts, form) +} + +// RefreshToken exchanges a refresh token for a new access token, using the +// token endpoint and client ID recorded on the token itself. +func RefreshToken(ctx context.Context, httpClient *http.Client, token *Token) (*Token, error) { + if token.RefreshToken == "" { + return nil, fmt.Errorf("cached token has no refresh token; run login again") + } + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", token.RefreshToken) + form.Set("client_id", token.ClientID) + + newToken, err := doTokenRequest(ctx, LoginOptions{ + TokenURL: token.TokenURL, + ClientID: token.ClientID, + HTTPClient: httpClient, + ExtraParams: token.ExtraParams, // e.g. ThunderID's resource=... requirement, remembered from login + }, form) + if err != nil { + return nil, err + } + // Not every IDP rotates refresh tokens on use; if the response omitted a + // new one, keep using the one we already have instead of losing it. + if newToken.RefreshToken == "" { + newToken.RefreshToken = token.RefreshToken + } + return newToken, nil +} + +func doTokenRequest(ctx context.Context, opts LoginOptions, form url.Values) (*Token, error) { + if opts.HTTPClient == nil { + opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} + } + + // Some IDPs (e.g. ThunderID, which binds an access token's audience to + // whatever resource server is requested) need extras like resource=... + // on the /token call too, not just the /authorize call. + tokenURL, err := withQueryParams(opts.TokenURL, opts.ExtraParams) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := opts.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to reach token endpoint: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("token endpoint response did not include an access token") + } + + tokenType := tr.TokenType + if tokenType == "" { + tokenType = "Bearer" + } + expiresIn := tr.ExpiresIn + if expiresIn == 0 { + expiresIn = 3600 + } + + return &Token{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + TokenType: tokenType, + ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second), + TokenURL: opts.TokenURL, + ClientID: opts.ClientID, + ExtraParams: opts.ExtraParams, + }, nil +} diff --git a/internal/cli/auth/login_test.go b/internal/cli/auth/login_test.go new file mode 100644 index 00000000..7f2332cc --- /dev/null +++ b/internal/cli/auth/login_test.go @@ -0,0 +1,375 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// withStubbedBrowser replaces the package-level openBrowser var with fn for +// the duration of the test, restoring the original afterwards. +func withStubbedBrowser(t *testing.T, fn func(rawURL string) error) { + t.Helper() + original := openBrowser + openBrowser = fn + t.Cleanup(func() { openBrowser = original }) +} + +// simulateBrowserCallback acts as a stand-in for a human completing the +// login in a real browser: it parses the authorization URL the CLI would +// have opened, and issues the same redirect a real IDP would send back to +// the local callback server. +func simulateBrowserCallback(t *testing.T, authorizeURL string, overrideCode, overrideState *string) error { + t.Helper() + u, err := url.Parse(authorizeURL) + assert.NoError(t, err) + q := u.Query() + + code := "test-auth-code" + if overrideCode != nil { + code = *overrideCode + } + state := q.Get("state") + if overrideState != nil { + state = *overrideState + } + + redirectURI := q.Get("redirect_uri") + cbURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + resp, err := http.Get(cbURL) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil +} + +func newTestTokenServer(t *testing.T, capturedForm chan<- url.Values, response string, statusCode int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, r.ParseForm()) + if capturedForm != nil { + capturedForm <- r.Form + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(response)) + })) +} + +func TestLogin_Success(t *testing.T) { + formCh := make(chan url.Values, 1) + tokenServer := newTestTokenServer(t, formCh, `{"access_token":"at-123","refresh_token":"rt-456","token_type":"Bearer","expires_in":3600}`, http.StatusOK) + defer tokenServer.Close() + + withStubbedBrowser(t, func(rawURL string) error { + return simulateBrowserCallback(t, rawURL, nil, nil) + }) + + token, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + Scopes: "openid", + OpenBrowser: true, + Timeout: 5 * time.Second, + }) + + assert.NoError(t, err) + if assert.NotNil(t, token) { + assert.Equal(t, "at-123", token.AccessToken) + assert.Equal(t, "rt-456", token.RefreshToken) + assert.Equal(t, "Bearer", token.TokenType) + assert.Equal(t, tokenServer.URL, token.TokenURL) + assert.Equal(t, "cli-client", token.ClientID) + assert.False(t, token.Expired()) + } + + select { + case form := <-formCh: + assert.Equal(t, "authorization_code", form.Get("grant_type")) + assert.Equal(t, "test-auth-code", form.Get("code")) + assert.Equal(t, "cli-client", form.Get("client_id")) + assert.NotEmpty(t, form.Get("redirect_uri")) + verifier := form.Get("code_verifier") + assert.GreaterOrEqual(t, len(verifier), 43) + default: + t.Fatal("token endpoint was never called") + } +} + +func TestLogin_ExtraParamsAppliedToTokenRequestAndPersisted(t *testing.T) { + var capturedTokenURL *url.URL + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := *r.URL + capturedTokenURL = &u + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at-123","refresh_token":"rt-456","token_type":"Bearer","expires_in":3600}`)) + })) + defer tokenServer.Close() + + withStubbedBrowser(t, func(rawURL string) error { + return simulateBrowserCallback(t, rawURL, nil, nil) + }) + + token, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + OpenBrowser: true, + Timeout: 5 * time.Second, + ExtraParams: map[string]string{"resource": "http://pb.openndx.local"}, + }) + + assert.NoError(t, err) + if assert.NotNil(t, capturedTokenURL) { + // Thunder-style IDPs expect `resource` as a query param on /token, not just /authorize. + assert.Equal(t, "http://pb.openndx.local", capturedTokenURL.Query().Get("resource")) + } + if assert.NotNil(t, token) { + // It must also be persisted on the cached token so a later refresh (a + // separate process invocation) can reuse it without being told again. + assert.Equal(t, "http://pb.openndx.local", token.ExtraParams["resource"]) + } +} + +func TestLogin_RepeatedCallbacksDoNotBlock(t *testing.T) { + tokenServer := newTestTokenServer(t, nil, `{"access_token":"at-123","refresh_token":"rt-456","token_type":"Bearer","expires_in":3600}`, http.StatusOK) + defer tokenServer.Close() + + withStubbedBrowser(t, func(rawURL string) error { + // A flaky browser/IDP redelivering the redirect must not deadlock + // the handler on the already-drained, capacity-1 resultCh - each of + // these is a synchronous call, so a blocked handler here would hang + // this stub (and therefore Login, which is still inside its + // openBrowser call) forever rather than surfacing as a timeout. + for i := 0; i < 3; i++ { + if err := simulateBrowserCallback(t, rawURL, nil, nil); err != nil { + return err + } + } + return nil + }) + + done := make(chan error, 1) + go func() { + _, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + OpenBrowser: true, + Timeout: 5 * time.Second, + }) + done <- err + }() + + select { + case err := <-done: + assert.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Login did not return - a repeated callback likely deadlocked the handler") + } +} + +func TestLogin_StateMismatch(t *testing.T) { + tokenServer := newTestTokenServer(t, nil, `{}`, http.StatusOK) + defer tokenServer.Close() + + badState := "not-the-real-state" + withStubbedBrowser(t, func(rawURL string) error { + return simulateBrowserCallback(t, rawURL, nil, &badState) + }) + + _, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + OpenBrowser: true, + Timeout: 5 * time.Second, + }) + + assert.Error(t, err) + assert.ErrorContains(t, err, "state mismatch") +} + +func TestLogin_AuthorizationDenied(t *testing.T) { + tokenServer := newTestTokenServer(t, nil, `{}`, http.StatusOK) + defer tokenServer.Close() + + withStubbedBrowser(t, func(rawURL string) error { + u, err := url.Parse(rawURL) + assert.NoError(t, err) + redirectURI := u.Query().Get("redirect_uri") + resp, err := http.Get(fmt.Sprintf("%s?error=access_denied&error_description=user+cancelled", redirectURI)) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil + }) + + _, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + OpenBrowser: true, + Timeout: 5 * time.Second, + }) + + assert.Error(t, err) + assert.ErrorContains(t, err, "access_denied") +} + +func TestLogin_TokenEndpointFailure(t *testing.T) { + tokenServer := newTestTokenServer(t, nil, `{"error":"invalid_grant"}`, http.StatusBadRequest) + defer tokenServer.Close() + + withStubbedBrowser(t, func(rawURL string) error { + return simulateBrowserCallback(t, rawURL, nil, nil) + }) + + _, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + OpenBrowser: true, + Timeout: 5 * time.Second, + }) + + assert.Error(t, err) + assert.ErrorContains(t, err, "status 400") +} + +func TestLogin_Timeout(t *testing.T) { + tokenServer := newTestTokenServer(t, nil, `{}`, http.StatusOK) + defer tokenServer.Close() + + // Stub never hits the callback, simulating a user who never completes login. + withStubbedBrowser(t, func(rawURL string) error { return nil }) + + _, err := Login(context.Background(), LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + OpenBrowser: true, + Timeout: 50 * time.Millisecond, + }) + + assert.Error(t, err) + assert.ErrorContains(t, err, "timed out") +} + +func TestRefreshToken_PreservesRefreshTokenWhenOmitted(t *testing.T) { + formCh := make(chan url.Values, 1) + var capturedTokenURL *url.URL + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, r.ParseForm()) + u := *r.URL + capturedTokenURL = &u + formCh <- r.Form + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"new-access","token_type":"Bearer","expires_in":3600}`)) + })) + defer tokenServer.Close() + + original := &Token{ + AccessToken: "old-access", + RefreshToken: "keep-me", + TokenURL: tokenServer.URL, + ClientID: "cli-client", + ExpiresAt: time.Now().Add(-time.Minute), + ExtraParams: map[string]string{"resource": "http://pb.openndx.local"}, + } + + refreshed, err := RefreshToken(context.Background(), nil, original) + assert.NoError(t, err) + if assert.NotNil(t, refreshed) { + assert.Equal(t, "new-access", refreshed.AccessToken) + // The IDP didn't return a new refresh token, so the old one must survive. + assert.Equal(t, "keep-me", refreshed.RefreshToken) + // The resource param must carry forward too, for the next refresh after this one. + assert.Equal(t, "http://pb.openndx.local", refreshed.ExtraParams["resource"]) + } + + select { + case form := <-formCh: + assert.Equal(t, "refresh_token", form.Get("grant_type")) + assert.Equal(t, "keep-me", form.Get("refresh_token")) + default: + t.Fatal("token endpoint was never called") + } + if assert.NotNil(t, capturedTokenURL) { + assert.Equal(t, "http://pb.openndx.local", capturedTokenURL.Query().Get("resource")) + } +} + +func TestRefreshToken_NoRefreshTokenCached(t *testing.T) { + _, err := RefreshToken(context.Background(), nil, &Token{AccessToken: "at"}) + assert.Error(t, err) + assert.ErrorContains(t, err, "run login again") +} + +func TestBuildAuthorizeURL_IncludesExtraParams(t *testing.T) { + rawURL, err := buildAuthorizeURL(LoginOptions{ + AuthURL: "https://idp.example.com/oauth2/authorize", + ClientID: "cli-client", + Scopes: "openid profile", + ExtraParams: map[string]string{ + "resource": "http://api.openndx.local", + }, + }, "http://127.0.0.1:12345/callback", "state-123", "challenge-abc") + + assert.NoError(t, err) + u, err := url.Parse(rawURL) + assert.NoError(t, err) + q := u.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Equal(t, "cli-client", q.Get("client_id")) + assert.Equal(t, "http://127.0.0.1:12345/callback", q.Get("redirect_uri")) + assert.Equal(t, "openid profile", q.Get("scope")) + assert.Equal(t, "state-123", q.Get("state")) + assert.Equal(t, "challenge-abc", q.Get("code_challenge")) + assert.Equal(t, "S256", q.Get("code_challenge_method")) + assert.Equal(t, "http://api.openndx.local", q.Get("resource")) +} + +func TestWithQueryParams(t *testing.T) { + t.Run("adds params", func(t *testing.T) { + out, err := withQueryParams("https://idp.example.com/oauth2/token", map[string]string{"resource": "http://pb.openndx.local"}) + assert.NoError(t, err) + u, err := url.Parse(out) + assert.NoError(t, err) + assert.Equal(t, "http://pb.openndx.local", u.Query().Get("resource")) + }) + + t.Run("no params leaves URL untouched", func(t *testing.T) { + out, err := withQueryParams("https://idp.example.com/oauth2/token", nil) + assert.NoError(t, err) + assert.Equal(t, "https://idp.example.com/oauth2/token", out) + }) + + t.Run("merges with existing query", func(t *testing.T) { + out, err := withQueryParams("https://idp.example.com/oauth2/token?foo=bar", map[string]string{"resource": "http://pb.openndx.local"}) + assert.NoError(t, err) + u, err := url.Parse(out) + assert.NoError(t, err) + assert.Equal(t, "bar", u.Query().Get("foo")) + assert.Equal(t, "http://pb.openndx.local", u.Query().Get("resource")) + }) +} + +// sanity check that the test JSON helper produces what the real IDP would. +func TestTokenResponseDecoding(t *testing.T) { + var tr tokenResponse + err := json.Unmarshal([]byte(`{"access_token":"a","refresh_token":"r","token_type":"Bearer","expires_in":60}`), &tr) + assert.NoError(t, err) + assert.Equal(t, "a", tr.AccessToken) + assert.Equal(t, int64(60), tr.ExpiresIn) +} diff --git a/internal/cli/auth/pkce.go b/internal/cli/auth/pkce.go new file mode 100644 index 00000000..2fa02d99 --- /dev/null +++ b/internal/cli/auth/pkce.go @@ -0,0 +1,41 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// generateRandomURLSafeString returns a cryptographically random, base64url +// (no padding) encoded string derived from n random bytes. +func generateRandomURLSafeString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// PKCE holds an OAuth2 PKCE (RFC 7636) verifier/challenge pair using the +// S256 challenge method. +type PKCE struct { + Verifier string + Challenge string +} + +// NewPKCE generates a new PKCE verifier/challenge pair. +func NewPKCE() (*PKCE, error) { + verifier, err := generateRandomURLSafeString(64) + if err != nil { + return nil, err + } + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return &PKCE{Verifier: verifier, Challenge: challenge}, nil +} + +// NewState generates a random CSRF state parameter for the authorization request. +func NewState() (string, error) { + return generateRandomURLSafeString(24) +} diff --git a/internal/cli/auth/pkce_test.go b/internal/cli/auth/pkce_test.go new file mode 100644 index 00000000..36301fd5 --- /dev/null +++ b/internal/cli/auth/pkce_test.go @@ -0,0 +1,45 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPKCE(t *testing.T) { + pkce, err := NewPKCE() + assert.NoError(t, err) + assert.NotEmpty(t, pkce.Verifier) + assert.NotEmpty(t, pkce.Challenge) + + // RFC 7636 requires the verifier to be 43-128 characters. + assert.GreaterOrEqual(t, len(pkce.Verifier), 43) + assert.LessOrEqual(t, len(pkce.Verifier), 128) + + // Challenge must be the base64url(SHA256(verifier)) of the verifier. + sum := sha256.Sum256([]byte(pkce.Verifier)) + expected := base64.RawURLEncoding.EncodeToString(sum[:]) + assert.Equal(t, expected, pkce.Challenge) +} + +func TestNewPKCE_Unique(t *testing.T) { + a, err := NewPKCE() + assert.NoError(t, err) + b, err := NewPKCE() + assert.NoError(t, err) + + assert.NotEqual(t, a.Verifier, b.Verifier) + assert.NotEqual(t, a.Challenge, b.Challenge) +} + +func TestNewState(t *testing.T) { + a, err := NewState() + assert.NoError(t, err) + assert.NotEmpty(t, a) + + b, err := NewState() + assert.NoError(t, err) + assert.NotEqual(t, a, b) +} diff --git a/internal/cli/auth/resolve.go b/internal/cli/auth/resolve.go new file mode 100644 index 00000000..5ce2e9fc --- /dev/null +++ b/internal/cli/auth/resolve.go @@ -0,0 +1,32 @@ +package auth + +import ( + "context" + "fmt" + "net/http" +) + +// EnsureFreshToken loads the cached token from path and, if it has expired, +// refreshes it and persists the refreshed token back to the same path. +func EnsureFreshToken(ctx context.Context, path string, httpClient *http.Client) (*Token, error) { + token, err := LoadToken(path) + if err != nil { + return nil, fmt.Errorf("not logged in (run 'ondx login' first): %w", err) + } + + if !token.Expired() { + return token, nil + } + + refreshed, err := RefreshToken(ctx, httpClient, token) + if err != nil { + return nil, fmt.Errorf("session expired and could not be refreshed (run 'ondx login' again): %w", err) + } + + if err := SaveToken(path, refreshed); err != nil { + // Non-fatal: we still have a usable in-memory token for this run. + fmt.Printf("Warning: failed to persist refreshed token: %v\n", err) + } + + return refreshed, nil +} diff --git a/internal/cli/auth/store.go b/internal/cli/auth/store.go new file mode 100644 index 00000000..74b7c0e2 --- /dev/null +++ b/internal/cli/auth/store.go @@ -0,0 +1,83 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Token represents a cached OAuth2 token set, along with the token endpoint +// and client ID needed to refresh it later without the caller re-specifying flags. +type Token struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresAt time.Time `json:"expires_at"` + TokenURL string `json:"token_url"` + ClientID string `json:"client_id"` + // ExtraParams remembers IDP-specific query params required at login (e.g. + // ThunderID's resource=... audience binding) so a later refresh reuses + // them automatically instead of silently dropping them. + ExtraParams map[string]string `json:"extra_params,omitempty"` +} + +// Expired reports whether the access token has passed its expiry, with a +// small safety margin so a request doesn't race the real expiry. +func (t *Token) Expired() bool { + return time.Now().Add(30 * time.Second).After(t.ExpiresAt) +} + +// DefaultCredentialsPath returns the default location for cached CLI +// credentials for the given profile name. Each non-default profile gets its +// own file - they're logins against different identity providers/clients, +// so caching them together would let switching profiles silently pick up +// the wrong token. The default profile keeps the original, un-suffixed path +// for backward compatibility with credentials cached before profiles existed. +func DefaultCredentialsPath(profileName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to determine home directory: %w", err) + } + if profileName == "" || profileName == "local" { + return filepath.Join(home, ".openndx", "credentials.json"), nil + } + // profileName can come from a config file, --profile flag, or NDX_PROFILE + // env var - reject path separators so it can't be used to write the + // cached token outside the .openndx directory (e.g. "../../.ssh/foo"). + if strings.ContainsAny(profileName, "/\\") { + return "", fmt.Errorf("invalid profile name %q: must not contain path separators", profileName) + } + return filepath.Join(home, ".openndx", fmt.Sprintf("credentials-%s.json", profileName)), nil +} + +// SaveToken writes the token to the given path, creating parent directories +// as needed. The file is written with 0600 permissions since it holds secrets. +func SaveToken(path string, token *Token) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("failed to create credentials directory: %w", err) + } + data, err := json.MarshalIndent(token, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + return nil +} + +// LoadToken reads a cached token from the given path. +func LoadToken(path string) (*Token, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read credentials file: %w", err) + } + var token Token + if err := json.Unmarshal(data, &token); err != nil { + return nil, fmt.Errorf("failed to parse credentials file: %w", err) + } + return &token, nil +} diff --git a/internal/cli/auth/store_test.go b/internal/cli/auth/store_test.go new file mode 100644 index 00000000..e72b7b4d --- /dev/null +++ b/internal/cli/auth/store_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSaveAndLoadToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "credentials.json") + + token := &Token{ + AccessToken: "access-123", + RefreshToken: "refresh-456", + TokenType: "Bearer", + ExpiresAt: time.Now().Add(time.Hour).UTC().Truncate(time.Second), + TokenURL: "https://idp.example.com/oauth2/token", + ClientID: "client-abc", + } + + err := SaveToken(path, token) + assert.NoError(t, err) + + loaded, err := LoadToken(path) + assert.NoError(t, err) + assert.Equal(t, token.AccessToken, loaded.AccessToken) + assert.Equal(t, token.RefreshToken, loaded.RefreshToken) + assert.Equal(t, token.TokenType, loaded.TokenType) + assert.True(t, token.ExpiresAt.Equal(loaded.ExpiresAt)) + assert.Equal(t, token.TokenURL, loaded.TokenURL) + assert.Equal(t, token.ClientID, loaded.ClientID) +} + +func TestLoadToken_MissingFile(t *testing.T) { + _, err := LoadToken(filepath.Join(t.TempDir(), "does-not-exist.json")) + assert.Error(t, err) +} + +func TestToken_Expired(t *testing.T) { + expired := &Token{ExpiresAt: time.Now().Add(-time.Minute)} + assert.True(t, expired.Expired()) + + valid := &Token{ExpiresAt: time.Now().Add(time.Hour)} + assert.False(t, valid.Expired()) + + // Within the 30s safety margin should count as expired. + almostExpired := &Token{ExpiresAt: time.Now().Add(10 * time.Second)} + assert.True(t, almostExpired.Expired()) +} + +func TestDefaultCredentialsPath(t *testing.T) { + t.Run("default profile keeps the original unsuffixed path", func(t *testing.T) { + path, err := DefaultCredentialsPath("local") + assert.NoError(t, err) + assert.Contains(t, path, ".openndx") + assert.True(t, strings.HasSuffix(path, "credentials.json")) + }) + + t.Run("empty profile name also falls back to the unsuffixed path", func(t *testing.T) { + path, err := DefaultCredentialsPath("") + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(path, "credentials.json")) + }) + + t.Run("named profile gets its own file", func(t *testing.T) { + path, err := DefaultCredentialsPath("staging") + assert.NoError(t, err) + assert.Contains(t, path, ".openndx") + assert.True(t, strings.HasSuffix(path, "credentials-staging.json")) + }) + + t.Run("rejects profile names containing a path separator", func(t *testing.T) { + _, err := DefaultCredentialsPath("../../.ssh/id_rsa") + assert.Error(t, err) + assert.ErrorContains(t, err, "must not contain path separators") + + _, err = DefaultCredentialsPath(`staging\..\..\secrets`) + assert.Error(t, err) + assert.ErrorContains(t, err, "must not contain path separators") + }) +} diff --git a/internal/cli/pbclient/client.go b/internal/cli/pbclient/client.go new file mode 100644 index 00000000..91828f9d --- /dev/null +++ b/internal/cli/pbclient/client.go @@ -0,0 +1,161 @@ +// Package pbclient is a minimal Portal Backend API client for CLI management commands. +package pbclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/openndx/openndx-core/internal/pb/v1/models" +) + +// Client calls the Portal Backend management API using a bearer token. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// NewClient creates a Portal Backend client for the given base URL and bearer token. +func NewClient(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimSuffix(baseURL, "/"), + Token: token, + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +// doJSON sends a request to path with reqBody marshaled as the JSON body (or +// no body, if reqBody is nil), and returns the raw response body on success. +func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any) ([]byte, error) { + var bodyReader io.Reader + if reqBody != nil { + b, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + httpReq, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + if reqBody != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + httpReq.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to reach portal backend: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("portal backend returned status %d: %s", resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +// CreateApplication calls POST /api/v1/applications. +func (c *Client) CreateApplication(ctx context.Context, req *models.CreateApplicationRequest) (*models.ApplicationResponse, error) { + body, err := c.doJSON(ctx, http.MethodPost, "/api/v1/applications", req) + if err != nil { + return nil, err + } + var app models.ApplicationResponse + if err := json.Unmarshal(body, &app); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &app, nil +} + +// CreateSchema calls POST /api/v1/schemas. +func (c *Client) CreateSchema(ctx context.Context, req *models.CreateSchemaRequest) (*models.SchemaResponse, error) { + body, err := c.doJSON(ctx, http.MethodPost, "/api/v1/schemas", req) + if err != nil { + return nil, err + } + var schema models.SchemaResponse + if err := json.Unmarshal(body, &schema); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &schema, nil +} + +// CreateMember calls POST /api/v1/members. +func (c *Client) CreateMember(ctx context.Context, req *models.CreateMemberRequest) (*models.MemberResponse, error) { + body, err := c.doJSON(ctx, http.MethodPost, "/api/v1/members", req) + if err != nil { + return nil, err + } + var member models.MemberResponse + if err := json.Unmarshal(body, &member); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &member, nil +} + +// GetApplication calls GET /api/v1/applications/{applicationId}. +func (c *Client) GetApplication(ctx context.Context, applicationID string) (*models.ApplicationResponse, error) { + body, err := c.doJSON(ctx, http.MethodGet, "/api/v1/applications/"+url.PathEscape(applicationID), nil) + if err != nil { + return nil, err + } + var app models.ApplicationResponse + if err := json.Unmarshal(body, &app); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &app, nil +} + +// ApplicationCollection is the GET /api/v1/applications response envelope. +type ApplicationCollection struct { + Items []models.ApplicationResponse `json:"items"` + Count int `json:"count"` +} + +// ListApplications calls GET /api/v1/applications, optionally filtered to one +// member's applications. +func (c *Client) ListApplications(ctx context.Context, memberID *string) (*ApplicationCollection, error) { + path := "/api/v1/applications" + if memberID != nil && *memberID != "" { + path += "?memberId=" + url.QueryEscape(*memberID) + } + + body, err := c.doJSON(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + var collection ApplicationCollection + if err := json.Unmarshal(body, &collection); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &collection, nil +} + +// UpdateApplicationPolicy calls PUT /api/v1/applications/{applicationId}/policy +// to replace an existing application's allow-list. +func (c *Client) UpdateApplicationPolicy(ctx context.Context, applicationID string, req *models.UpdateApplicationPolicyRequest) (*models.ApplicationResponse, error) { + body, err := c.doJSON(ctx, http.MethodPut, "/api/v1/applications/"+url.PathEscape(applicationID)+"/policy", req) + if err != nil { + return nil, err + } + var app models.ApplicationResponse + if err := json.Unmarshal(body, &app); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &app, nil +} diff --git a/internal/cli/pbclient/client_test.go b/internal/cli/pbclient/client_test.go new file mode 100644 index 00000000..b1e05b93 --- /dev/null +++ b/internal/cli/pbclient/client_test.go @@ -0,0 +1,411 @@ +package pbclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openndx/openndx-core/internal/pb/v1/models" + "github.com/stretchr/testify/assert" +) + +func TestCreateSchema_Success(t *testing.T) { + var capturedMethod, capturedPath string + var capturedBody models.CreateSchemaRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + assert.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody)) + + resp := models.SchemaResponse{ + SchemaID: "sch_new", + SchemaName: capturedBody.SchemaName, + Endpoint: capturedBody.Endpoint, + MemberID: capturedBody.MemberID, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) // PB returns 201 for creation + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + req := &models.CreateSchemaRequest{ + SchemaName: "Citizen Info", + Endpoint: "http://example.com/graphql", + MemberID: "member-1", + Fields: []models.PolicyMetadataCreateRequestRecord{ + {FieldName: "email", AccessControlType: models.AccessControlTypePublic, Source: models.SourcePrimary}, + }, + } + + resp, err := client.CreateSchema(context.Background(), req) + + assert.NoError(t, err) + if assert.NotNil(t, resp) { + assert.Equal(t, "sch_new", resp.SchemaID) + } + assert.Equal(t, http.MethodPost, capturedMethod) + assert.Equal(t, "/api/v1/schemas", capturedPath) + if assert.Len(t, capturedBody.Fields, 1) { + assert.Equal(t, "email", capturedBody.Fields[0].FieldName) + } +} + +func TestCreateSchema_ErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "exactly one of sdl or fields must be provided"}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + resp, err := client.CreateSchema(context.Background(), &models.CreateSchemaRequest{ + SchemaName: "Bad Schema", + Endpoint: "http://example.com/graphql", + MemberID: "member-1", + }) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.ErrorContains(t, err, "status 400") +} + +func TestCreateMember_Success(t *testing.T) { + var capturedMethod, capturedPath string + var capturedBody models.CreateMemberRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + assert.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody)) + + resp := models.MemberResponse{ + MemberID: "mem_new", + Name: capturedBody.Name, + Email: capturedBody.Email, + } + if capturedBody.IdpUserID != nil { + resp.IdpUserID = *capturedBody.IdpUserID + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) // PB returns 201 for creation + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + idpUserID := "thunder-user-1" + req := &models.CreateMemberRequest{ + Name: "New Member", + Email: "new@example.com", + PhoneNumber: "+1234567890", + IdpUserID: &idpUserID, + } + + resp, err := client.CreateMember(context.Background(), req) + + assert.NoError(t, err) + if assert.NotNil(t, resp) { + assert.Equal(t, "mem_new", resp.MemberID) + assert.Equal(t, idpUserID, resp.IdpUserID) + } + assert.Equal(t, http.MethodPost, capturedMethod) + assert.Equal(t, "/api/v1/members", capturedPath) + assert.Equal(t, idpUserID, *capturedBody.IdpUserID) +} + +func TestCreateMember_ErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Insufficient permissions"}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + resp, err := client.CreateMember(context.Background(), &models.CreateMemberRequest{ + Name: "New Member", + Email: "new@example.com", + PhoneNumber: "+1234567890", + }) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.ErrorContains(t, err, "status 403") +} + +func TestCreateApplication_Success(t *testing.T) { + var capturedMethod, capturedPath string + var capturedBody models.CreateApplicationRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedPath = r.URL.Path + assert.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody)) + + resp := models.ApplicationResponse{ + ApplicationID: "app_new", + ApplicationName: capturedBody.ApplicationName, + SelectedFields: capturedBody.SelectedFields, + MemberID: capturedBody.MemberID, + } + if capturedBody.IdpClientID != nil { + resp.IdpClientID = capturedBody.IdpClientID + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) // PB returns 201 for creation + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + idpAppID := "thunder-app-1" + idpClientID := "THUNDER_CLIENT" + req := &models.CreateApplicationRequest{ + ApplicationName: "New App", + SelectedFields: []models.SelectedFieldRecord{ + {FieldName: "email", SchemaID: "schema-1"}, + }, + MemberID: "member-1", + IdpApplicationID: &idpAppID, + IdpClientID: &idpClientID, + } + + resp, err := client.CreateApplication(context.Background(), req) + + assert.NoError(t, err) + if assert.NotNil(t, resp) { + assert.Equal(t, "app_new", resp.ApplicationID) + assert.Equal(t, idpClientID, *resp.IdpClientID) + } + assert.Equal(t, http.MethodPost, capturedMethod) + assert.Equal(t, "/api/v1/applications", capturedPath) + assert.Equal(t, idpAppID, *capturedBody.IdpApplicationID) +} + +func TestCreateApplication_ErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "idpApplicationId and idpClientId must both be provided together, or both omitted"}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + resp, err := client.CreateApplication(context.Background(), &models.CreateApplicationRequest{ + ApplicationName: "New App", + SelectedFields: []models.SelectedFieldRecord{{FieldName: "email", SchemaID: "schema-1"}}, + MemberID: "member-1", + }) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.ErrorContains(t, err, "status 400") +} + +func TestGetApplication_Success(t *testing.T) { + var capturedAuth, capturedPath, capturedMethod string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedPath = r.URL.Path + capturedMethod = r.Method + + resp := models.ApplicationResponse{ + ApplicationID: "app_123", + ApplicationName: "Test App", + SelectedFields: []models.SelectedFieldRecord{ + {FieldName: "email", SchemaID: "schema-1"}, + }, + MemberID: "member-1", + Version: "1.0.0", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + resp, err := client.GetApplication(context.Background(), "app_123") + + assert.NoError(t, err) + if assert.NotNil(t, resp) { + assert.Equal(t, "app_123", resp.ApplicationID) + assert.Equal(t, "Test App", resp.ApplicationName) + assert.Len(t, resp.SelectedFields, 1) + } + + assert.Equal(t, "Bearer test-token", capturedAuth) + assert.Equal(t, "/api/v1/applications/app_123", capturedPath) + assert.Equal(t, http.MethodGet, capturedMethod) +} + +func TestGetApplication_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "application not found"}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + resp, err := client.GetApplication(context.Background(), "does-not-exist") + + assert.Error(t, err) + assert.Nil(t, resp) + assert.ErrorContains(t, err, "status 404") +} + +func TestListApplications_Success(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.String() + resp := map[string]any{ + "items": []models.ApplicationResponse{ + {ApplicationID: "app_1", ApplicationName: "App One", MemberID: "member-1"}, + {ApplicationID: "app_2", ApplicationName: "App Two", MemberID: "member-1"}, + }, + "count": 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + apps, err := client.ListApplications(context.Background(), nil) + + assert.NoError(t, err) + assert.Equal(t, "/api/v1/applications", capturedPath) + assert.Equal(t, 2, apps.Count) + if assert.Len(t, apps.Items, 2) { + assert.Equal(t, "app_1", apps.Items[0].ApplicationID) + assert.Equal(t, "app_2", apps.Items[1].ApplicationID) + } +} + +func TestListApplications_WithMemberFilter(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []models.ApplicationResponse{}, "count": 0}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + memberID := "member-42" + _, err := client.ListApplications(context.Background(), &memberID) + + assert.NoError(t, err) + assert.Equal(t, "/api/v1/applications?memberId=member-42", capturedPath) +} + +func TestListApplications_EmptyResult(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []models.ApplicationResponse{}, "count": 0}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + apps, err := client.ListApplications(context.Background(), nil) + + assert.NoError(t, err) + assert.Empty(t, apps.Items) + assert.Equal(t, 0, apps.Count) +} + +func TestUpdateApplicationPolicy_Success(t *testing.T) { + var capturedAuth, capturedPath, capturedMethod string + var capturedBody models.UpdateApplicationPolicyRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedPath = r.URL.Path + capturedMethod = r.Method + assert.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody)) + + resp := models.ApplicationResponse{ + ApplicationID: "app_123", + ApplicationName: "Test App", + SelectedFields: capturedBody.SelectedFields, + MemberID: "member-1", + Version: "1.0.0", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + grantDuration := models.GrantDurationTypeOneYear + req := &models.UpdateApplicationPolicyRequest{ + SelectedFields: []models.SelectedFieldRecord{ + {FieldName: "email", SchemaID: "schema-1"}, + }, + GrantDuration: &grantDuration, + } + + resp, err := client.UpdateApplicationPolicy(context.Background(), "app_123", req) + + assert.NoError(t, err) + if assert.NotNil(t, resp) { + assert.Equal(t, "app_123", resp.ApplicationID) + assert.Equal(t, req.SelectedFields, []models.SelectedFieldRecord(resp.SelectedFields)) + } + + assert.Equal(t, "Bearer test-token", capturedAuth) + assert.Equal(t, "/api/v1/applications/app_123/policy", capturedPath) + assert.Equal(t, http.MethodPut, capturedMethod) + assert.Equal(t, req.SelectedFields, capturedBody.SelectedFields) +} + +func TestUpdateApplicationPolicy_TrimsTrailingSlashInBaseURL(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(models.ApplicationResponse{ApplicationID: "app_1"}) + })) + defer server.Close() + + client := NewClient(server.URL+"/", "token") + _, err := client.UpdateApplicationPolicy(context.Background(), "app_1", &models.UpdateApplicationPolicyRequest{ + SelectedFields: []models.SelectedFieldRecord{{FieldName: "f", SchemaID: "s"}}, + }) + + assert.NoError(t, err) + assert.Equal(t, "/api/v1/applications/app_1/policy", capturedPath) +} + +func TestUpdateApplicationPolicy_ErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Insufficient permissions"}) + })) + defer server.Close() + + client := NewClient(server.URL, "token") + resp, err := client.UpdateApplicationPolicy(context.Background(), "app_1", &models.UpdateApplicationPolicyRequest{ + SelectedFields: []models.SelectedFieldRecord{{FieldName: "f", SchemaID: "s"}}, + }) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.ErrorContains(t, err, "status 403") + assert.ErrorContains(t, err, "Insufficient permissions") +} + +func TestUpdateApplicationPolicy_Unreachable(t *testing.T) { + client := NewClient("http://127.0.0.1:1", "token") + resp, err := client.UpdateApplicationPolicy(context.Background(), "app_1", &models.UpdateApplicationPolicyRequest{ + SelectedFields: []models.SelectedFieldRecord{{FieldName: "f", SchemaID: "s"}}, + }) + + assert.Error(t, err) + assert.Nil(t, resp) +} diff --git a/internal/cli/profile/profile.go b/internal/cli/profile/profile.go new file mode 100644 index 00000000..06a5c775 --- /dev/null +++ b/internal/cli/profile/profile.go @@ -0,0 +1,128 @@ +// Package profile manages named, reusable sets of ndx CLI flag defaults +// (identity provider issuer, client ID, scopes, callback port, Portal +// Backend URL, TLS verification) cached at ~/.openndx/config.json. Profiles let +// an operator switch between environments (local dev, staging, a partner's +// deployment) without retyping every flag on every command; any flag passed +// explicitly on the command line still overrides the active profile. +package profile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// DefaultName is the profile ondx falls back to when no config file exists +// yet and none is named explicitly, pre-populated with this repo's +// docker-compose local-dev stack (ThunderID as IDP, Portal Backend on +// :8083) so `ondx login` works with zero flags out of the box. +const DefaultName = "local" + +// ThunderIDCLIClientID and ThunderIDCallbackPort are the ndx CLI's OAuth2 +// client ID and redirect callback port as registered with this repo's local +// ThunderID instance (thunderid/bootstrap/application.yaml). ThunderID +// rejects any redirect URI other than the exact one registered there +// (http://127.0.0.1:8765/callback) - a wildcard/wrong port fails login - so +// `ondx login` must reject any other --callback-port when logging in as this +// client, rather than silently trying (and failing) against ThunderID. +const ( + ThunderIDCLIClientID = "NDX_CLI" + ThunderIDCallbackPort = 8765 +) + +var defaultLocalProfile = Profile{ + Issuer: "https://localhost:8090", + ClientID: ThunderIDCLIClientID, + Scopes: "openid roles email", + CallbackPort: ThunderIDCallbackPort, + PBURL: "http://localhost:8083", + Insecure: true, +} + +// Profile is one named set of flag defaults. Every field is optional - +// callers use it purely to seed a flag's default value, so a zero value +// simply falls through to whatever the caller does when a flag is unset. +type Profile struct { + Issuer string `json:"issuer,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + Scopes string `json:"scopes,omitempty"` + CallbackPort int `json:"callback_port,omitempty"` + PBURL string `json:"pb_url,omitempty"` + Insecure bool `json:"insecure,omitempty"` +} + +// Config is the on-disk shape of ~/.openndx/config.json. +type Config struct { + CurrentProfile string `json:"current_profile"` + Profiles map[string]Profile `json:"profiles"` +} + +// DefaultConfigPath returns the default location for the profile config file. +func DefaultConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to determine home directory: %w", err) + } + return filepath.Join(home, ".openndx", "config.json"), nil +} + +// Load reads the config file at path. A missing file is not an error - it +// yields a config with just the built-in default profile, so a first-time +// user gets working defaults without ever running `ondx profile set`. The +// default profile is likewise injected whenever the file exists but doesn't +// define one of its own under DefaultName. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &Config{ + CurrentProfile: DefaultName, + Profiles: map[string]Profile{DefaultName: defaultLocalProfile}, + }, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + if cfg.Profiles == nil { + cfg.Profiles = map[string]Profile{} + } + if _, ok := cfg.Profiles[DefaultName]; !ok { + cfg.Profiles[DefaultName] = defaultLocalProfile + } + if cfg.CurrentProfile == "" { + cfg.CurrentProfile = DefaultName + } + return &cfg, nil +} + +// Save writes cfg to path, creating parent directories as needed. +func Save(path string, cfg *Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// Get returns the named profile, or an error if it isn't defined. +func (c *Config) Get(name string) (Profile, error) { + p, ok := c.Profiles[name] + if !ok { + return Profile{}, fmt.Errorf("no such profile %q (run 'ondx profile list' to see available profiles)", name) + } + return p, nil +} diff --git a/internal/cli/profile/profile_test.go b/internal/cli/profile/profile_test.go new file mode 100644 index 00000000..2db8703f --- /dev/null +++ b/internal/cli/profile/profile_test.go @@ -0,0 +1,76 @@ +package profile + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoad_MissingFileYieldsBuiltinDefault(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.json")) + assert.NoError(t, err) + assert.Equal(t, DefaultName, cfg.CurrentProfile) + local, err := cfg.Get(DefaultName) + assert.NoError(t, err) + assert.Equal(t, "https://localhost:8090", local.Issuer) + assert.Equal(t, "NDX_CLI", local.ClientID) + assert.Equal(t, 8765, local.CallbackPort) + assert.True(t, local.Insecure) +} + +func TestSaveAndLoad_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + + cfg := &Config{ + CurrentProfile: "staging", + Profiles: map[string]Profile{ + "staging": { + Issuer: "https://idp.staging.example.com", + ClientID: "ndx-staging", + PBURL: "https://pb.staging.example.com", + }, + }, + } + assert.NoError(t, Save(path, cfg)) + + loaded, err := Load(path) + assert.NoError(t, err) + assert.Equal(t, "staging", loaded.CurrentProfile) + + staging, err := loaded.Get("staging") + assert.NoError(t, err) + assert.Equal(t, "https://idp.staging.example.com", staging.Issuer) + assert.Equal(t, "ndx-staging", staging.ClientID) + + // The built-in default profile is still injected even though the file + // only ever defined "staging". + local, err := loaded.Get(DefaultName) + assert.NoError(t, err) + assert.Equal(t, "https://localhost:8090", local.Issuer) +} + +func TestLoad_PreservesUserOverriddenLocalProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + cfg := &Config{ + CurrentProfile: DefaultName, + Profiles: map[string]Profile{ + DefaultName: {Issuer: "https://custom.local.example.com"}, + }, + } + assert.NoError(t, Save(path, cfg)) + + loaded, err := Load(path) + assert.NoError(t, err) + local, err := loaded.Get(DefaultName) + assert.NoError(t, err) + // Must not be clobbered back to the built-in default. + assert.Equal(t, "https://custom.local.example.com", local.Issuer) +} + +func TestGet_UnknownProfile(t *testing.T) { + cfg := &Config{Profiles: map[string]Profile{}} + _, err := cfg.Get("nope") + assert.Error(t, err) + assert.ErrorContains(t, err, "no such profile") +} diff --git a/thunderid/bootstrap/application.yaml b/thunderid/bootstrap/application.yaml index 2524c3e3..9e313f43 100644 --- a/thunderid/bootstrap/application.yaml +++ b/thunderid/bootstrap/application.yaml @@ -106,3 +106,63 @@ inboundAuthConfig: phone: - phone_number - phone_number_verified +--- +# LOCAL-DEV ONLY: the `ndx` management CLI's OAuth2 client, so it can log admin +# operators in against a local ThunderID instance and call Portal Backend's +# management API. Do not mount into a shared or production deployment. +# +# type/pkceRequired/tokenEndpointAuthMethod/publicClient mirror CONSENT_PORTAL +# above — the only other confirmed-working public-client config in this repo. +# "native" isn't used here since it's not a documented type in this repo; if +# ThunderID does support it, prefer it over reusing "browser". +# +# redirectUris is a single literal loopback URI, not a wildcard/regex, because +# nothing in this repo's bootstrap config demonstrates ThunderID accepting a +# pattern here (contrast thunderid/bootstrap/cors.yaml's allowedOrigins, which +# does support a `regex:` entry — untested whether redirectUris shares that). +# `ndx login` must be run with --callback-port 8765 to match. If ThunderID +# does support a wildcard/regex loopback redirect URI (per RFC 8252 §7.3), +# this can be relaxed and --callback-port dropped in favor of a random port. +resource_type: application +id: 01900000-0000-7000-8000-0000000000b0 +name: NDX CLI +description: ndx management CLI — admin operator login for Portal Backend +type: browser +ouId: 01900000-0000-7000-8000-000000000001 +authFlowId: 01900000-0000-7000-8000-000000000061 +registrationFlowId: 01900000-0000-7000-8000-000000000064 +isRegistrationFlowEnabled: false +signOutFlowId: 01900000-0000-7000-8000-00000000006a +recoveryFlowId: 01900000-0000-7000-8000-000000000067 +isRecoveryFlowEnabled: false +allowedUserTypes: + - Person +inboundAuthConfig: + - type: oauth2 + config: + clientId: NDX_CLI + redirectUris: + - "http://127.0.0.1:8765/callback" + grantTypes: + - authorization_code + - refresh_token + responseTypes: + - code + pkceRequired: true + tokenEndpointAuthMethod: none + publicClient: true + token: + accessToken: + userConfig: + validityPeriod: 3600 + attributes: + - given_name + - family_name + - email + - roles + idToken: + validityPeriod: 3600 + scopeClaims: + roles: + - roles + - email diff --git a/thunderid/bootstrap/cors.yaml b/thunderid/bootstrap/cors.yaml index e4401bbf..51714179 100644 --- a/thunderid/bootstrap/cors.yaml +++ b/thunderid/bootstrap/cors.yaml @@ -4,3 +4,4 @@ name: cors value: allowedOrigins: - http://localhost:5173 + - http://127.0.0.1:8765 diff --git a/thunderid/bootstrap/resource.yaml b/thunderid/bootstrap/resource.yaml index 2cfd0091..9b73cf2d 100644 --- a/thunderid/bootstrap/resource.yaml +++ b/thunderid/bootstrap/resource.yaml @@ -48,3 +48,48 @@ resources: - name: Portal handle: access description: Consent Portal citizen access +--- +# Minimal resource server to give the OpenNDX_Admin role below a valid +# `permissions` grant — every role example we have (this file's +# DataConsumerGetData, and ThunderID's own built-in Administrator role) ties +# permissions to a resource server, so this exists to satisfy that shape. +# NOT the same thing as wiring Portal Backend to actually trust ThunderID for +# JWT validation (PB's IDP_* env vars, and its idpfactory only supporting +# Asgardeo today) — that remains separate, open work. +resource_type: resource_server +id: 01900000-0000-7000-8000-0000000000b3 +name: Portal Backend Resource Server +description: Placeholder resource server so the OpenNDX_Admin role has a permissions grant +identifier: http://pb.openndx.local +ouHandle: default +resources: + - name: Admin + handle: manage + description: OpenNDX administrative access + +--- +# Grants members of the OpenNDX Admins group (thunderid/bootstrap/users.yaml, +# id ...b2) a `roles` claim value of "OpenNDX_Admin" once NDX_CLI requests the +# `roles` scope (see that client's scopeClaims in application.yaml). The NAME +# here is load-bearing: Portal Backend's authorization middleware +# (internal/pb/v1/models/authorization.go) does an exact string match against +# "OpenNDX_Admin" — not against role IDs or permissions — so this role must be +# named exactly that, unlike ThunderID's own built-in "Administrator" role +# (a different name, for a different authorization domain — see users.yaml). +# +# VERIFIED: confirmed against a running instance by logging in as ndx-admin +# via `ondx login` and decoding the access token — the `roles` claim carries +# role NAMES like this one (not resourceServer:permission strings the way the +# `permissions` block below is scoped). +resource_type: role +id: 01900000-0000-7000-8000-0000000000b4 +name: OpenNDX_Admin +description: Grants OpenNDX Portal Backend admin access via the CLI +ouHandle: default +permissions: + - resourceServerId: 01900000-0000-7000-8000-0000000000b3 + permissions: + - manage +assignments: + - id: 01900000-0000-7000-8000-0000000000b2 + type: group diff --git a/thunderid/bootstrap/users.yaml b/thunderid/bootstrap/users.yaml index b41c2905..653646cd 100644 --- a/thunderid/bootstrap/users.yaml +++ b/thunderid/bootstrap/users.yaml @@ -13,4 +13,36 @@ attributes: family_name: Fernando mobile_number: "+234564444456" credentials: - password: "Test@1234" + password: "1234" +--- +# LOCAL-DEV ONLY: the operator account for logging in via the ndx CLI +# (thunderid/bootstrap/application.yaml, client NDX_CLI). Deliberately separate +# from ThunderID's own built-in "admin"/"Administrators"/"Administrator" trio +# (which gate ThunderID's own Console management UI, on the System resource +# server) — that's a different authorization domain from OpenNDX's own +# application-level roles, so we don't reuse it here. +resource_type: user +id: 01900000-0000-7000-8000-0000000000b1 +type: Person +ouHandle: default +attributes: + username: ndx-admin + sub: ndx-admin + email: ndx-admin@openndx.local + name: NDX Admin + given_name: NDX + family_name: Admin +credentials: + password: "1234" +--- +# Membership group the OpenNDX_Admin role (thunderid/bootstrap/resource.yaml) is +# assigned to — ThunderID assigns roles to groups, not directly to users (see +# that file's comment for why). +resource_type: group +id: 01900000-0000-7000-8000-0000000000b2 +name: OpenNDX Admins +description: Members of this group get the OpenNDX_Admin role via ThunderID's group-based role assignment +ouHandle: default +members: + - id: 01900000-0000-7000-8000-0000000000b1 + type: user