diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0125314..4aaf5d0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,6 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - go-version: ['1.21', '1.22'] steps: - name: Checkout code @@ -24,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go-version }} + go-version-file: go.mod - name: Build run: go build -v -ldflags "-X github.com/ysoftdevs/otc-cli/cmd.Version=dev" ./... @@ -60,7 +59,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version-file: go.mod - name: Build binary env: diff --git a/README.md b/README.md index 016d203..3d68b65 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,21 @@ sudo mv ~/Downloads/otc-darwin-arm64 /usr/local/bin/otc Create a `clouds.yaml` file in your home directory (`~/.config/openstack/clouds.yaml`): +The file can contain short-lived authentication tokens after `otc login`, so it +must be readable only by the current user: + +```bash +chmod 0600 ~/.config/openstack/clouds.yaml +``` + +On Unix, `otc login` enforces mode `0600` whenever it updates the file. Windows +does not use POSIX file modes; the file inherits the ACLs of the current user's +profile. Updates are written to a temporary file, synced, and atomically renamed +so an interrupted write does not truncate the existing configuration. If +`clouds.yaml` is a symbolic link, its target is updated without replacing the +link. On Unix, run the command above once to protect an existing file before its +next update. + ```yaml clouds: my-cloud: @@ -58,6 +73,43 @@ clouds: expiration: 3600 ``` +For Entra ID / OpenID Connect federation, add an `oidc` block. This is the +preferred login mode when the selected OTC identity provider supports +programmatic OIDC access: + +```yaml +clouds: + otc-dev-eu-de: + region_name: eu-de + auth: + auth_url: https://iam.eu-de.otc.t-systems.com/v3 + project_name: eu-de_dev + domain_id: 57a0a4501de945d98fd366ab9dcf33cb + oidc: + tenant_id: + client_id: + idp: YS_OIDC_EID_DEV + scopes: + - openid + - profile + - email +``` + +The `oidc` block is optional. Clouds without `oidc` continue to use the legacy +`sso` configuration. + +Legacy SAML/SSO login has important limitations: + +- It depends on browser automation instead of a supported CLI token exchange. +- It is sensitive to the local browser implementation and can require a specific + browser/runtime setup. +- It relies on OTC console browser cookies to request temporary credentials. +- It is less portable across macOS/Linux environments than the OIDC flow. +- On macOS, the legacy default-browser credential extraction path supports + Safari only and is restricted to the standard `auth.otc.t-systems.com` and + `console.otc.t-systems.com` hosts. Custom `--url` or `sso.base_url` hosts are + rejected. Use OIDC for a browser-independent login flow. + ### Environment Variables You can override configuration using environment variables with the `OTC_` prefix: @@ -82,16 +134,68 @@ For non-interactive use (see [AK/SK Login](#aksk-login-cicd--automation) below), ### Authentication -Login using browser-based SSO: +Login using the selected cloud configuration: ```bash otc login ``` +When the selected cloud has an `oidc` block, `otc login` opens the OS default +browser, completes Entra ID login using authorization code + PKCE, exchanges the +resulting ID token for an OTC Keystone token, scopes it to the configured +project, and stores that short-lived token in `clouds.yaml`. + +The browser callback confirms only that Entra returned an authorization code. +The terminal reports the final result after the Entra and OTC token exchanges +complete. + +On macOS, `otc login` uses the OS default browser by default. Safari is +supported for the OIDC default-browser flow without enabling Apple Events. + +On Linux, OIDC default-browser login uses `xdg-open`. On Windows, it uses the +system URL handler. + +For OIDC login, you can choose which browser opens the Entra login URL: + +```bash +otc login --browser firefox +``` + +The `--browser` flag is only an opener for OIDC login. It does not automate the +browser, read cookies, or execute JavaScript in Safari/Firefox/Chrome. Legacy +SAML/SSO login does not support `--browser`. + +#### Hosts without a browser + +The default OIDC flow needs a browser that can reach a callback listener on the +same machine, so it does not work over plain SSH on a headless host. `otc login` +always prints the sign-in URL, but on such a host use the device-code flow +instead: + +```bash +otc login --device-code +``` + +This prints a short code to enter at a Microsoft verification URL from any other +device, and needs no local browser, no callback listener and no port forwarding. +It is still an interactive user login, intended for jump hosts and containers. +It is not workload identity for unattended CI/CD; use a dedicated workload +identity or service credential for automation. + With specific cloud configuration: ```bash -otc login --cloud my-cloud --domain-id YOUR_DOMAIN_ID +otc login --cloud my-cloud --domain-id YOUR_DOMAIN_ID --idp YOUR_IDP +``` + +If the selected cloud does not include SSO settings in `clouds.yaml`, pass them +explicitly: + +```bash +otc login \ + --cloud my-cloud \ + --domain-id YOUR_DOMAIN_ID \ + --idp YOUR_IDP ``` Custom authentication parameters: @@ -303,26 +407,100 @@ These flags are available for all commands: - `-c, --cloud`: Name of the cloud from clouds.yaml to use - `-r, --region`: Region to use for the cloud - `-p, --project`: Project name to use for authentication -- `-f, --format`: Changes output style, possible options: `yaml, json, table, value`. Defaults to `table`. +- `-f, --format`: Output format for formatted commands, possible options: `table`, `json`, `yaml`. Defaults to `table`. + +## Login Flags + +- `--debug`: Print OIDC login diagnostics without printing token values. +- `--browser`: Browser to open for OIDC login. Defaults to the OS default + browser. +- `--device-code`: Use the OIDC device-code flow instead of opening a browser. + This is an interactive, OIDC-only flow for hosts without a browser and cannot + be combined with an explicit `--browser`. ## Development ### Prerequisites -- Go 1.21 or higher +- Go 1.24 or higher ### Building +Compile everything for the current machine: + ```bash go build -v ./... ``` +Produce a runnable `otc` binary: + +```bash +go build -o otc . +``` + +#### Stamping the version + +`otc --version` reads `cmd.Version`, which defaults to `dev`. Set it at link +time: + +```bash +go build -ldflags "-X github.com/ysoftdevs/otc-cli/cmd.Version=$(git describe --tags --always)" -o otc . +``` + +#### Cross-compiling + +The binary is pure Go with no cgo dependency, so cross-compiling needs nothing +but `GOOS` and `GOARCH` — no toolchain, no C compiler: + +```bash +GOOS=darwin GOARCH=arm64 go build -o dist/otc-darwin-arm64 . # Apple Silicon +GOOS=darwin GOARCH=amd64 go build -o dist/otc-darwin-amd64 . # Intel Mac +GOOS=linux GOARCH=arm64 go build -o dist/otc-linux-arm64 . # ARM64 Linux +GOOS=linux GOARCH=amd64 go build -o dist/otc-linux-amd64 . # x86-64 Linux +GOOS=windows GOARCH=arm64 go build -o dist/otc-windows-arm64.exe . # ARM64 Windows +GOOS=windows GOARCH=amd64 go build -o dist/otc-windows-amd64.exe . # x86-64 Windows +``` + +All six targets in one go, version-stamped and size-reduced: + +```bash +VERSION=$(git describe --tags --always) +LDFLAGS="-s -w -X github.com/ysoftdevs/otc-cli/cmd.Version=$VERSION" +for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64 windows/arm64 windows/amd64; do + GOOS=${target%/*} GOARCH=${target#*/} \ + go build -ldflags "$LDFLAGS" -o "dist/otc-${target%/*}-${target#*/}$([ "${target%/*}" = windows ] && printf .exe)" . +done +``` + +Note that the loop above is written for `bash`. In `zsh` — the default shell on +macOS — an unquoted `$target` is not word-split, so build each target with its +own command or run the loop under `bash`. + +Useful build flags: + +| Flag | Effect | +|------|--------| +| `-ldflags "-s -w"` | Strips the symbol table and DWARF data, roughly 15% smaller binary (13.2 MB to 11.3 MB) | +| `-ldflags "-X .Version=..."` | Sets the version reported by `otc --version` | +| `-trimpath` | Removes local filesystem paths, making builds reproducible | +| `-o ` | Output file rather than the default package name | + +Platform-specific code is selected by build tags, so a cross-compiled binary +contains only the relevant legacy login backend: +`system_browser_darwin.go`, `system_browser_linux.go`, or +`system_browser_windows.go`. Compiling on one OS therefore does not type-check +the others; cross-build every release target before publishing. + ### Running Tests ```bash go test -v ./... ``` +Because of the build tags above, `go test` only exercises the backend for the +host OS. To check the other one, either cross-compile it as shown above or run +the suite on that platform. + ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/cmd/cce_list.go b/cmd/cce_list.go index 5376653..9729fbe 100644 --- a/cmd/cce_list.go +++ b/cmd/cce_list.go @@ -27,7 +27,6 @@ var listCmd = &cobra.Command{ func init() { cceCmd.AddCommand(listCmd) - initFlagFormat(listCmd) } func clustersTableView() formats.View[cce.Cluster] { diff --git a/cmd/ecs_list.go b/cmd/ecs_list.go index e1272d4..7caf8c3 100644 --- a/cmd/ecs_list.go +++ b/cmd/ecs_list.go @@ -35,7 +35,6 @@ func init() { ecsListCmd.Flags().StringVar(&ecsListArgs.Filter, "filter", ecsListArgs.Filter, "Filter servers by name") ecsListCmd.Flags().StringVar(&ecsListArgs.Status, "status", ecsListArgs.Status, "Filter servers by status (e.g. ACTIVE)") ecsListCmd.Flags().IntVar(&ecsListArgs.Limit, "limit", ecsListArgs.Limit, "Limit the number of servers listed") - initFlagFormat(ecsListCmd) } func serversTableView() formats.View[servers.Server] { diff --git a/cmd/ecs_show.go b/cmd/ecs_show.go index 6c80ab6..1a7f966 100644 --- a/cmd/ecs_show.go +++ b/cmd/ecs_show.go @@ -28,7 +28,6 @@ var ecsShowCmd = &cobra.Command{ func init() { ecsCmd.AddCommand(ecsShowCmd) - initFlagFormat(ecsShowCmd) } func extractAddresses(raw map[string]interface{}) string { diff --git a/cmd/elb_list.go b/cmd/elb_list.go index 260ed04..8f34317 100644 --- a/cmd/elb_list.go +++ b/cmd/elb_list.go @@ -28,7 +28,6 @@ var elbListArgs = elb.ListArgs{ func init() { elbCmd.AddCommand(elbListCmd) elbListCmd.Flags().StringVar(&elbListArgs.Filter, "filter", "", "Filter load balancers by name") - initFlagFormat(elbListCmd) } func elbTableView() formats.View[elb.LoadBalancerInfo] { diff --git a/cmd/elb_modify.go b/cmd/elb_modify.go index 091a8b6..002a329 100644 --- a/cmd/elb_modify.go +++ b/cmd/elb_modify.go @@ -31,5 +31,4 @@ var elbModifyCmd = &cobra.Command{ func init() { elbCmd.AddCommand(elbModifyCmd) elbModifyCmd.Flags().BoolVar(&elbModifyDeletionProtectionEnabled, "deletion-protection-enabled", false, "Enable or disable deletion protection") - initFlagFormat(elbModifyCmd) } diff --git a/cmd/elb_show.go b/cmd/elb_show.go index f69494b..a660884 100644 --- a/cmd/elb_show.go +++ b/cmd/elb_show.go @@ -22,5 +22,4 @@ var elbShowCmd = &cobra.Command{ func init() { elbCmd.AddCommand(elbShowCmd) - initFlagFormat(elbShowCmd) } diff --git a/cmd/login.go b/cmd/login.go index 0851abb..2209b3e 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -22,12 +22,14 @@ var loginCmd = &cobra.Command{ config.SetIfEmpty(&loginArgs.Idp, cloud.SSO.Idp) config.SetIfEmpty(&loginArgs.BaseURL, cloud.SSO.BaseURL) config.SetIfZero(&loginArgs.Expiration, cloud.SSO.Expiration) + + loginArgs.OIDC = cloud.OIDC } return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if err := login.BrowserLogin(loginArgs); err != nil { + if err := login.Login(loginArgs); err != nil { return fmt.Errorf("error during login: %w", err) } return nil @@ -39,6 +41,7 @@ var loginArgs = login.LoginArgs{ AuthURL: "https://iam.eu-de.otc.t-systems.com/v3", Protocol: "saml", Expiration: 3600, + Browser: "default", CommonConfig: commonConfig, } @@ -51,4 +54,7 @@ func init() { loginCmd.Flags().StringVar(&loginArgs.Idp, "idp", loginArgs.Idp, "Identity provider") loginCmd.Flags().StringVar(&loginArgs.Protocol, "protocol", loginArgs.Protocol, "Authentication protocol") loginCmd.Flags().IntVar(&loginArgs.Expiration, "expiration", loginArgs.Expiration, "Credential expiration time in seconds") + loginCmd.Flags().StringVar(&loginArgs.Browser, "browser", loginArgs.Browser, "Browser to open for OIDC login. Use default, or a browser name such as safari, firefox, or chromium") + loginCmd.Flags().BoolVar(&loginArgs.DeviceCode, "device-code", loginArgs.DeviceCode, "Use the interactive OIDC device-code flow on a host without a browser") + loginCmd.Flags().BoolVar(&loginArgs.Debug, "debug", loginArgs.Debug, "Print login debug information without credential values") } diff --git a/cmd/rds_list.go b/cmd/rds_list.go index cac2767..2671e73 100644 --- a/cmd/rds_list.go +++ b/cmd/rds_list.go @@ -30,7 +30,6 @@ func init() { rdsListCmd.Flags().StringVar(&rdsListArgs.Opts.Name, "filter", ecsListArgs.Filter, "Filter instances by name") rdsListCmd.Flags().IntVar(&rdsListArgs.Opts.Limit, "limit", ecsListArgs.Limit, "Limit the number of instances listed") - initFlagFormat(rdsListCmd) } func rdsInstancesTableView() formats.View[instances.InstanceResponse] { diff --git a/cmd/root.go b/cmd/root.go index e7d4157..bc3c29a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "os" "github.com/ysoftdevs/otc-cli/config" + "github.com/ysoftdevs/otc-cli/formats" "github.com/spf13/cobra" ) @@ -19,7 +20,21 @@ var rootCmd = &cobra.Command{ Version: Version, SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return commonConfig.AugmentFromFiles() + if err := formats.Validate(format); err != nil { + return err + } + if err := commonConfig.AugmentFromFiles(); err != nil { + return err + } + // Only enforce that the cloud actually exists in clouds.yaml when the + // user explicitly asked for it via --cloud. A cloud name coming from + // OTC_CLOUD or clouds.yaml's selected_cloud is commonly just a label + // used alongside env-based auth (e.g. OTC_AK/OTC_SK) with no matching + // clouds.yaml entry, which is a supported way to authenticate. + if cmd.Flags().Changed("cloud") { + return commonConfig.RequireCloudFound() + } + return nil }, } @@ -43,8 +58,5 @@ func init() { rootCmd.PersistentFlags().StringVarP(&commonConfig.CloudName, "cloud", "c", "", "Name of the cloud from clouds.yaml to use") rootCmd.PersistentFlags().StringVarP(&commonConfig.Region, "region", "r", "", "Region to use for the cloud") rootCmd.PersistentFlags().StringVarP(&commonConfig.ProjectName, "project", "p", "", "Project name to use for authentication") -} - -func initFlagFormat(cmd *cobra.Command) { - cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, yaml") + rootCmd.PersistentFlags().StringVarP(&format, "format", "f", "table", "Output format: table, json, yaml") } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..4e804fb --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPersistentPreRunRejectsFormatBeforeLoadingCloud(t *testing.T) { + originalFormat := format + originalConfig := *commonConfig + t.Cleanup(func() { + format = originalFormat + *commonConfig = originalConfig + }) + + format = "xml" + commonConfig.CloudName = "cloud-that-does-not-exist" + + err := rootCmd.PersistentPreRunE(rootCmd, nil) + if err == nil { + t.Fatal("expected unsupported output format to be rejected") + } + if !strings.Contains(err.Error(), "unsupported output format") { + t.Fatalf("format validation did not run before cloud loading: %v", err) + } +} + +func setRootTestHome(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + configDir := filepath.Join(home, ".config", "openstack") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } +} + +func TestPersistentPreRunAllowsUnknownCloudFromEnv(t *testing.T) { + setRootTestHome(t) + + originalFormat := format + originalConfig := *commonConfig + t.Cleanup(func() { + format = originalFormat + *commonConfig = originalConfig + }) + + format = "table" + // Simulates a cloud name supplied via OTC_CLOUD rather than --cloud: + // the --cloud flag itself was never touched, so it stays "unchanged". + commonConfig.CloudName = "ci-automation" + + if err := rootCmd.PersistentPreRunE(rootCmd, nil); err != nil { + t.Fatalf("expected env-supplied unknown cloud name to be allowed, got: %v", err) + } +} + +func TestPersistentPreRunRejectsUnknownCloudFromFlag(t *testing.T) { + setRootTestHome(t) + + originalFormat := format + originalConfig := *commonConfig + cloudFlag := rootCmd.PersistentFlags().Lookup("cloud") + originalChanged := cloudFlag.Changed + t.Cleanup(func() { + format = originalFormat + *commonConfig = originalConfig + cloudFlag.Changed = originalChanged + }) + + format = "table" + // ParseFlags mirrors what cobra's Execute() does before running the + // PersistentPreRunE hooks: it merges persistent flags into cmd.Flags() + // and marks "cloud" as Changed, which is what the code under test checks. + if err := rootCmd.ParseFlags([]string{"-c", "cloud-that-does-not-exist"}); err != nil { + t.Fatalf("failed to parse --cloud flag: %v", err) + } + + err := rootCmd.PersistentPreRunE(rootCmd, nil) + if err == nil { + t.Fatal("expected explicit --cloud with an unknown name to be rejected") + } + if !strings.Contains(err.Error(), `cloud "cloud-that-does-not-exist" was not found`) { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/sfs_list.go b/cmd/sfs_list.go index 1cea4c4..665ce9f 100644 --- a/cmd/sfs_list.go +++ b/cmd/sfs_list.go @@ -26,7 +26,6 @@ var sfsListArgs = sfs.ListArgs{ func init() { sfsCmd.AddCommand(sfsListCmd) sfsListCmd.Flags().StringVar(&sfsListArgs.Filter, "filter", "", "Filter shares by name (substring match)") - initFlagFormat(sfsListCmd) } func sfsTableView() formats.View[sfs.ShareInfo] { diff --git a/cmd/whoami.go b/cmd/whoami.go index cd19d44..6a51f0f 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -21,7 +21,6 @@ var whoamiCmd = &cobra.Command{ func init() { rootCmd.AddCommand(whoamiCmd) - initFlagFormat(whoamiCmd) } func whoamiTableView() formats.View[identity.CallerIdentity] { diff --git a/config/clouds.go b/config/clouds.go index 07e5763..4e06d9f 100644 --- a/config/clouds.go +++ b/config/clouds.go @@ -19,6 +19,7 @@ type CloudsYAML struct { type CloudConfig struct { Auth AuthConfig `yaml:"auth"` SSO SSOConfig `yaml:"sso,omitempty"` + OIDC OIDCConfig `yaml:"oidc,omitempty"` RegionName string `yaml:"region_name,omitempty"` Cloud string `yaml:"cloud,omitempty"` Interface string `yaml:"interface,omitempty"` @@ -58,6 +59,14 @@ type SSOConfig struct { Extra map[string]interface{} `yaml:",inline"` } +type OIDCConfig struct { + TenantID string `yaml:"tenant_id,omitempty"` + ClientID string `yaml:"client_id,omitempty"` + Idp string `yaml:"idp,omitempty"` + Scopes []string `yaml:"scopes,omitempty"` + Extra map[string]interface{} `yaml:",inline"` +} + func LoadCloudsYAMLFromDefaultLocation() (CloudsYAML, error) { cloudsPath, err := GetCloudsYAMLPath() if err != nil { @@ -112,8 +121,52 @@ func SaveCloudsYAML(path string, clouds *CloudsYAML) error { return fmt.Errorf("failed to marshal clouds.yaml: %w", err) } - if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("failed to write clouds.yaml: %w", err) + return writeFileAtomically(resolveSymlink(path), data, 0600, os.Rename) +} + +// resolveSymlink follows path when it points somewhere else, so that the atomic +// rename replaces the link target rather than the link itself. A file that does +// not exist yet keeps the original path. +func resolveSymlink(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + return resolved +} + +func writeFileAtomically(path string, data []byte, perm os.FileMode, renameFile func(string, string) error) error { + dir := filepath.Dir(path) + tempFile, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("failed to create temporary file for %s: %w", path, err) + } + + tempPath := tempFile.Name() + closed := false + defer func() { + if !closed { + _ = tempFile.Close() + } + _ = os.Remove(tempPath) + }() + + if err := tempFile.Chmod(perm); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", tempPath, err) + } + if _, err := tempFile.Write(data); err != nil { + return fmt.Errorf("failed to write %s: %w", tempPath, err) + } + if err := tempFile.Sync(); err != nil { + return fmt.Errorf("failed to sync %s: %w", tempPath, err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", tempPath, err) + } + closed = true + + if err := renameFile(tempPath, path); err != nil { + return fmt.Errorf("failed to replace %s: %w", path, err) } return nil @@ -168,4 +221,4 @@ func UpdateCloudConfig(cloudName string, updateFunc func(*CloudConfig)) error { clouds.Clouds[cloudName] = cloud return SaveCloudsYAMLToDefaultLocation(&clouds) -} \ No newline at end of file +} diff --git a/config/clouds_permissions_unix_test.go b/config/clouds_permissions_unix_test.go new file mode 100644 index 0000000..3b7cdf1 --- /dev/null +++ b/config/clouds_permissions_unix_test.go @@ -0,0 +1,31 @@ +//go:build unix + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveCloudsYAMLSetsMode0600(t *testing.T) { + path := filepath.Join(t.TempDir(), "clouds.yaml") + if err := os.WriteFile(path, []byte("old contents"), 0644); err != nil { + t.Fatalf("failed to create clouds.yaml: %v", err) + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatalf("failed to set initial permissions: %v", err) + } + + if err := SaveCloudsYAML(path, &CloudsYAML{Clouds: map[string]CloudConfig{}}); err != nil { + t.Fatalf("SaveCloudsYAML returned error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("failed to stat clouds.yaml: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("unexpected clouds.yaml permissions: got %04o, want 0600", got) + } +} diff --git a/config/clouds_test.go b/config/clouds_test.go new file mode 100644 index 0000000..3e692c5 --- /dev/null +++ b/config/clouds_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSaveCloudsYAMLReplacesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "clouds.yaml") + if err := os.WriteFile(path, []byte("old contents"), 0644); err != nil { + t.Fatalf("failed to create clouds.yaml: %v", err) + } + + clouds := &CloudsYAML{ + Clouds: map[string]CloudConfig{ + "otc-dev": { + Auth: AuthConfig{ + Token: "short-lived-token", + }, + AuthType: "token", + }, + }, + } + if err := SaveCloudsYAML(path, clouds); err != nil { + t.Fatalf("SaveCloudsYAML returned error: %v", err) + } + + saved, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read clouds.yaml: %v", err) + } + if !strings.Contains(string(saved), "short-lived-token") { + t.Fatalf("saved clouds.yaml does not contain the token: %q", saved) + } +} + +func TestSaveCloudsYAMLWritesThroughSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "clouds.real.yaml") + link := filepath.Join(dir, "clouds.yaml") + + if err := os.WriteFile(target, []byte("clouds: {}\n"), 0600); err != nil { + t.Fatalf("failed to create target: %v", err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + + clouds := &CloudsYAML{ + Clouds: map[string]CloudConfig{ + "otc-dev": {Auth: AuthConfig{Token: "short-lived-token"}}, + }, + } + if err := SaveCloudsYAML(link, clouds); err != nil { + t.Fatalf("SaveCloudsYAML returned error: %v", err) + } + + info, err := os.Lstat(link) + if err != nil { + t.Fatalf("failed to lstat link: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("symlink was replaced by a regular file") + } + + saved, err := os.ReadFile(target) + if err != nil { + t.Fatalf("failed to read symlink target: %v", err) + } + if !strings.Contains(string(saved), "short-lived-token") { + t.Fatalf("symlink target was not updated: %q", saved) + } +} + +func TestWriteFileAtomicallyPreservesExistingFileWhenRenameFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clouds.yaml") + original := []byte("original contents") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatalf("failed to create clouds.yaml: %v", err) + } + + renameErr := errors.New("forced rename failure") + err := writeFileAtomically(path, []byte("replacement contents"), 0600, func(string, string) error { + return renameErr + }) + if !errors.Is(err, renameErr) { + t.Fatalf("expected rename error, got %v", err) + } + + saved, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read original clouds.yaml: %v", err) + } + if string(saved) != string(original) { + t.Fatalf("clouds.yaml changed after failed rename: got %q, want %q", saved, original) + } + + tempFiles, err := filepath.Glob(filepath.Join(dir, ".clouds.yaml.tmp-*")) + if err != nil { + t.Fatalf("failed to search for temporary files: %v", err) + } + if len(tempFiles) != 0 { + t.Fatalf("temporary files were not cleaned up: %v", tempFiles) + } +} diff --git a/config/config.go b/config/config.go index 25a3849..a448d96 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,9 @@ package config -import "os" +import ( + "fmt" + "os" +) type CommonConfig struct { EnvPrefix string @@ -30,6 +33,20 @@ func (base *CommonConfig) AugmentFromFiles() error { return nil } +// RequireCloudFound reports an error if a cloud name was set but could not be +// resolved against clouds.yaml. It is deliberately not enforced by +// AugmentFromFiles itself: a cloud name is commonly supplied purely to carry +// env-based auth (e.g. OTC_AK/OTC_SK) with no matching clouds.yaml entry, which +// is a supported way to authenticate. Callers that require an explicit, +// user-selected cloud to actually exist (e.g. the --cloud flag) should call +// this after AugmentFromFiles. +func (base *CommonConfig) RequireCloudFound() error { + if base.CloudName != "" && base.SelectedCloud == nil { + return fmt.Errorf("cloud %q was not found in clouds.yaml", base.CloudName) + } + return nil +} + func SetIfEmpty(value *string, newValues ...string) { if *value == "" { for _, v := range newValues { diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..2d6b3fa --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) +} + +func TestAugmentFromFilesDoesNotRejectUnknownCloud(t *testing.T) { + // A cloud name with no matching clouds.yaml entry is a supported way to + // carry purely env-based auth (e.g. OTC_AK/OTC_SK) through the CLI, so + // AugmentFromFiles must not fail here on its own; see RequireCloudFound + // for the opt-in strict check used when --cloud is passed explicitly. + home := t.TempDir() + setTestHome(t, home) + + configDir := filepath.Join(home, ".config", "openstack") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + cloudsPath := filepath.Join(configDir, "clouds.yaml") + if err := os.WriteFile(cloudsPath, []byte(`clouds: + otc-prod-eu-de-prod: + region_name: eu-de + auth: + project_name: eu-de_prod +`), 0600); err != nil { + t.Fatalf("failed to write clouds.yaml: %v", err) + } + + cfg := CommonConfig{ + EnvPrefix: "OTC_", + CloudName: "otc-prod-eu-prod", + } + + if err := cfg.AugmentFromFiles(); err != nil { + t.Fatalf("AugmentFromFiles returned error: %v", err) + } + if cfg.SelectedCloud != nil { + t.Fatal("expected no selected cloud for an unknown cloud name") + } + + err := cfg.RequireCloudFound() + if err == nil { + t.Fatal("expected RequireCloudFound to reject an unknown cloud") + } + if !strings.Contains(err.Error(), `cloud "otc-prod-eu-prod" was not found`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAugmentFromFilesLoadsKnownCloud(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + + configDir := filepath.Join(home, ".config", "openstack") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + cloudsPath := filepath.Join(configDir, "clouds.yaml") + if err := os.WriteFile(cloudsPath, []byte(`clouds: + otc-prod-eu-de-prod: + region_name: eu-de + auth: + project_name: eu-de_prod +`), 0600); err != nil { + t.Fatalf("failed to write clouds.yaml: %v", err) + } + + cfg := CommonConfig{ + EnvPrefix: "OTC_", + CloudName: "otc-prod-eu-de-prod", + } + + if err := cfg.AugmentFromFiles(); err != nil { + t.Fatalf("AugmentFromFiles returned error: %v", err) + } + if cfg.SelectedCloud == nil { + t.Fatal("expected selected cloud") + } + if cfg.ProjectName != "eu-de_prod" { + t.Fatalf("unexpected project name: %q", cfg.ProjectName) + } +} diff --git a/formats/root.go b/formats/root.go index d51e1ef..073a920 100644 --- a/formats/root.go +++ b/formats/root.go @@ -7,7 +7,20 @@ import ( "github.com/jedib0t/go-pretty/table" ) +func Validate(format string) error { + switch format { + case "", "table", "json", "yaml": + return nil + default: + return fmt.Errorf("unsupported output format %q; supported formats are table, json, yaml", format) + } +} + func newRenderer[T any](format string) (Renderer[T], error) { + if err := Validate(format); err != nil { + return nil, err + } + switch format { case "json": return &JsonRenderer[T]{}, nil diff --git a/formats/root_test.go b/formats/root_test.go new file mode 100644 index 0000000..a1901a3 --- /dev/null +++ b/formats/root_test.go @@ -0,0 +1,36 @@ +package formats + +import ( + "strings" + "testing" +) + +func TestNewRendererAcceptsSupportedFormats(t *testing.T) { + formats := []string{"", "table", "json", "yaml"} + + for _, format := range formats { + if _, err := newRenderer[struct{}](format); err != nil { + t.Fatalf("newRenderer(%q) returned error: %v", format, err) + } + } +} + +func TestNewRendererRejectsUnsupportedFormat(t *testing.T) { + _, err := newRenderer[struct{}]("xml") + if err == nil { + t.Fatal("expected error for unsupported output format") + } + if !strings.Contains(err.Error(), "supported formats are table, json, yaml") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateRejectsUnsupportedFormat(t *testing.T) { + err := Validate("xml") + if err == nil { + t.Fatal("expected error for unsupported output format") + } + if !strings.Contains(err.Error(), "supported formats are table, json, yaml") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/services/browser/login/oidc.go b/services/browser/login/oidc.go new file mode 100644 index 0000000..c861069 --- /dev/null +++ b/services/browser/login/oidc.go @@ -0,0 +1,634 @@ +package login + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/ysoftdevs/otc-cli/config" +) + +const ( + oidcCallbackPath = "/otc-cli" + oidcHTTPTimeout = 30 * time.Second + oidcCallbackTimeout = 5 * time.Minute + oidcServerShutdownTimeout = 2 * time.Second + oidcMaxResponseBytes = 1 << 20 +) + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + Message string `json:"message"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type entraTokenResponse struct { + TokenType string `json:"token_type"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type scopedTokenResponse struct { + Token struct { + ExpiresAt string `json:"expires_at"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"project"` + } `json:"token"` +} + +type oidcCallbackResult struct { + code string + err error +} + +type oidcCallbackServer interface { + Shutdown(context.Context) error + Close() error +} + +func OIDCLogin(loginArgs LoginArgs) error { + httpClient := newOIDCHTTPClient(oidcHTTPTimeout) + scopes := loginArgs.OIDC.Scopes + if len(scopes) == 0 { + scopes = []string{"openid", "profile", "email"} + } + + var idToken string + var err error + if loginArgs.DeviceCode { + idToken, err = entraIDTokenWithDeviceCode(httpClient, loginArgs.OIDC.TenantID, loginArgs.OIDC.ClientID, scopes) + } else { + idToken, err = entraIDTokenWithBrowser(httpClient, loginArgs.OIDC.TenantID, loginArgs.OIDC.ClientID, scopes, loginArgs.Browser) + } + if err != nil { + return err + } + if loginArgs.Debug { + printIDTokenDebug(idToken) + } + + // OTC Keystone validates the ID token signature, issuer, audience, and + // mapping rules during OS-FEDERATION exchange; otc only forwards it there. + unscopedToken, err := otcUnscopedToken(httpClient, loginArgs.AuthURL, loginArgs.OIDC.Idp, idToken) + if err != nil { + return err + } + + scopedToken, expiresAt, err := otcScopedToken(httpClient, loginArgs.AuthURL, unscopedToken, loginArgs.CommonConfig.ProjectName, loginArgs.DomainID) + if err != nil { + return err + } + + if err := storeOIDCToken(scopedToken, expiresAt, &loginArgs); err != nil { + return err + } + + return nil +} + +// entraIDTokenWithDeviceCode signs in without a local browser or callback +// listener. It remains an interactive user flow intended for headless hosts, +// not unattended CI. +func entraIDTokenWithDeviceCode(httpClient *http.Client, tenantID, clientID string, scopes []string) (string, error) { + values := url.Values{} + values.Set("client_id", clientID) + values.Set("scope", strings.Join(scopes, " ")) + + tokenEndpoint := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID) + resp, err := httpClient.PostForm(fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/devicecode", tenantID), values) + if err != nil { + return "", fmt.Errorf("failed to start Entra device-code login: %w", err) + } + defer resp.Body.Close() + + body, err := readOIDCResponseBody(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read Entra device-code response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return "", fmt.Errorf("failed to start Entra device-code login: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var device deviceCodeResponse + if err := json.Unmarshal(body, &device); err != nil { + return "", fmt.Errorf("failed to parse Entra device-code response: %w", err) + } + if device.DeviceCode == "" { + return "", fmt.Errorf("device-code response from Entra did not contain device_code") + } + + if device.Message != "" { + fmt.Println(device.Message) + } else { + fmt.Printf("Open %s and enter code %s\n", device.VerificationURI, device.UserCode) + } + + interval := time.Duration(device.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second) + if device.ExpiresIn <= 0 { + deadline = time.Now().Add(15 * time.Minute) + } + + for time.Now().Before(deadline) { + time.Sleep(interval) + + values := url.Values{} + values.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + values.Set("client_id", clientID) + values.Set("device_code", device.DeviceCode) + + token, err := postEntraToken(httpClient, tokenEndpoint, values) + if err != nil { + return "", err + } + switch token.Error { + case "": + if token.IDToken == "" { + return "", fmt.Errorf("token response from Entra did not contain id_token") + } + return token.IDToken, nil + case "authorization_pending": + continue + case "slow_down": + interval += 5 * time.Second + continue + default: + return "", fmt.Errorf("device-code login via Entra failed: %s: %s", token.Error, token.ErrorDescription) + } + } + + return "", fmt.Errorf("timed out waiting for Entra device-code login") +} + +func entraIDTokenWithBrowser(httpClient *http.Client, tenantID, clientID string, scopes []string, browser string) (string, error) { + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + return "", fmt.Errorf("failed to start local login callback listener: %w", err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://localhost:%d%s", port, oidcCallbackPath) + state, err := randomURLToken(32) + if err != nil { + return "", err + } + codeVerifier, err := randomURLToken(64) + if err != nil { + return "", err + } + codeChallenge := pkceChallenge(codeVerifier) + + resultCh := make(chan oidcCallbackResult, 1) + server := &http.Server{ + Handler: oidcCallbackHandler(state, resultCh), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + sendOIDCCallbackResult(resultCh, oidcCallbackResult{err: err}) + } + }() + defer func() { _ = stopOIDCCallbackServer(server) }() + + authURL := entraAuthorizeURL(tenantID, clientID, redirectURI, scopes, state, codeChallenge) + + // Always print the URL: xdg-open exits 0 even when it cannot reach a + // browser, so a silent hand-off would leave a headless host with no way to + // reach the login page and no indication that anything went wrong. + fmt.Printf("Sign in to Entra at:\n\n %s\n\n", authURL) + if err := openLoginBrowser(browser, authURL); err != nil { + fmt.Printf("Could not open a browser automatically: %v\n", err) + fmt.Println("Open the URL above manually to continue.") + } + fmt.Printf("Waiting for the login callback on %s ...\n", redirectURI) + fmt.Println("On a machine without a browser, cancel and run 'otc login --device-code' instead.") + + var result oidcCallbackResult + select { + case result = <-resultCh: + case <-time.After(oidcCallbackTimeout): + return "", fmt.Errorf("timed out waiting for Entra browser login callback") + } + if result.err != nil { + return "", result.err + } + if err := stopOIDCCallbackServer(server); err != nil { + fmt.Printf("Warning: %v\n", err) + } + + values := url.Values{} + values.Set("grant_type", "authorization_code") + values.Set("client_id", clientID) + values.Set("code", result.code) + values.Set("redirect_uri", redirectURI) + values.Set("code_verifier", codeVerifier) + values.Set("scope", strings.Join(scopes, " ")) + + tokenEndpoint := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID) + token, err := postEntraToken(httpClient, tokenEndpoint, values) + if err != nil { + return "", err + } + if token.Error != "" { + return "", fmt.Errorf("authorization-code exchange with Entra failed: %s: %s", token.Error, token.ErrorDescription) + } + if token.IDToken == "" { + return "", fmt.Errorf("token response from Entra did not contain id_token") + } + return token.IDToken, nil +} + +func oidcCallbackHandler(expectedState string, resultCh chan<- oidcCallbackResult) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(oidcCallbackPath, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if got := r.URL.Query().Get("state"); got != expectedState { + http.Error(w, "invalid login state", http.StatusBadRequest) + return + } + if authErr := r.URL.Query().Get("error"); authErr != "" { + description := r.URL.Query().Get("error_description") + http.Error(w, "login failed", http.StatusBadRequest) + sendOIDCCallbackResult(resultCh, oidcCallbackResult{ + err: fmt.Errorf("browser login via Entra failed: %s: %s", authErr, description), + }) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + sendOIDCCallbackResult(resultCh, oidcCallbackResult{ + err: fmt.Errorf("callback from Entra did not contain an authorization code"), + }) + return + } + + fmt.Fprintln(w, "Entra sign-in received. Return to the terminal to complete OTC login.") + sendOIDCCallbackResult(resultCh, oidcCallbackResult{code: code}) + }) + return mux +} + +func sendOIDCCallbackResult(resultCh chan<- oidcCallbackResult, result oidcCallbackResult) { + select { + case resultCh <- result: + default: + } +} + +func stopOIDCCallbackServer(server oidcCallbackServer) error { + ctx, cancel := context.WithTimeout(context.Background(), oidcServerShutdownTimeout) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + if closeErr := server.Close(); closeErr != nil { + return fmt.Errorf("failed to stop local login callback listener gracefully: %v; force close also failed: %w", err, closeErr) + } + return fmt.Errorf("failed to stop local login callback listener gracefully; listener was force closed: %w", err) + } + return nil +} + +func entraAuthorizeURL(tenantID, clientID, redirectURI string, scopes []string, state, codeChallenge string) string { + values := url.Values{} + values.Set("client_id", clientID) + values.Set("response_type", "code") + values.Set("redirect_uri", redirectURI) + values.Set("response_mode", "query") + values.Set("scope", strings.Join(scopes, " ")) + values.Set("state", state) + values.Set("code_challenge", codeChallenge) + values.Set("code_challenge_method", "S256") + return fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s", tenantID, values.Encode()) +} + +func newOIDCHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func postEntraToken(httpClient *http.Client, endpoint string, values url.Values) (entraTokenResponse, error) { + resp, err := httpClient.PostForm(endpoint, values) + if err != nil { + return entraTokenResponse{}, fmt.Errorf("failed to call Entra token endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := readOIDCResponseBody(resp.Body) + if err != nil { + return entraTokenResponse{}, fmt.Errorf("failed to read Entra token response: %w", err) + } + + var token entraTokenResponse + if err := json.Unmarshal(body, &token); err != nil { + return entraTokenResponse{}, fmt.Errorf("failed to parse Entra token response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + if token.Error != "" { + return token, nil + } + return entraTokenResponse{}, fmt.Errorf("token endpoint of Entra returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return token, nil +} + +func readOIDCResponseBody(body io.Reader) ([]byte, error) { + limited := io.LimitReader(body, oidcMaxResponseBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if len(data) > oidcMaxResponseBytes { + return nil, fmt.Errorf("OIDC HTTP response exceeds %d bytes", oidcMaxResponseBytes) + } + return data, nil +} + +func otcUnscopedToken(httpClient *http.Client, authURL, idp, idToken string) (string, error) { + endpoint := fmt.Sprintf("%s/OS-FEDERATION/identity_providers/%s/protocols/oidc/auth", strings.TrimRight(authURL, "/"), url.PathEscape(idp)) + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+idToken) + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to exchange Entra ID token for OTC unscoped token: %w", err) + } + defer resp.Body.Close() + + body, err := readOIDCResponseBody(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read OTC OIDC token exchange response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return "", fmt.Errorf("OTC OIDC token exchange failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + token := resp.Header.Get("X-Subject-Token") + if token == "" { + return "", fmt.Errorf("OTC OIDC token exchange did not return X-Subject-Token") + } + return token, nil +} + +func otcScopedToken(httpClient *http.Client, authURL, unscopedToken, projectName, domainID string) (string, string, error) { + request := map[string]any{ + "auth": map[string]any{ + "identity": map[string]any{ + "methods": []string{"token"}, + "token": map[string]any{ + "id": unscopedToken, + }, + }, + "scope": map[string]any{ + "project": map[string]any{ + "name": projectName, + "domain": map[string]any{ + "id": domainID, + }, + }, + }, + }, + } + + body, err := json.Marshal(request) + if err != nil { + return "", "", err + } + + endpoint := fmt.Sprintf("%s/auth/tokens", strings.TrimRight(authURL, "/")) + req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(string(body))) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to scope OTC token to project %q: %w", projectName, err) + } + defer resp.Body.Close() + + responseBody, err := readOIDCResponseBody(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read OTC project scope response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return "", "", fmt.Errorf("failed to scope OTC token to project %q: HTTP %d: %s", projectName, resp.StatusCode, strings.TrimSpace(string(responseBody))) + } + + token := resp.Header.Get("X-Subject-Token") + if token == "" { + return "", "", fmt.Errorf("OTC project scope response did not return X-Subject-Token") + } + + var scoped scopedTokenResponse + _ = json.Unmarshal(responseBody, &scoped) + return token, scoped.Token.ExpiresAt, nil +} + +func storeOIDCToken(scopedToken, expiresAt string, loginArgs *LoginArgs) error { + commonConfig := loginArgs.CommonConfig + if err := config.UpdateCloudConfig(commonConfig.CloudName, func(cloud *config.CloudConfig) { + cloud.OIDC = loginArgs.OIDC + + cloud.Auth.AuthURL = loginArgs.AuthURL + cloud.Auth.DomainID = loginArgs.DomainID + cloud.Auth.ProjectName = commonConfig.ProjectName + cloud.Auth.Token = scopedToken + cloud.Auth.AccessKey = "" + cloud.Auth.SecretKey = "" + cloud.Auth.SecurityToken = "" + cloud.Auth.Password = "" + + cloud.AuthType = "token" + cloud.RegionName = commonConfig.Region + }); err != nil { + return err + } + + if expiresAt != "" { + fmt.Printf("OIDC token stored in clouds.yaml under cloud '%s' (expires at %s)\n", commonConfig.CloudName, expiresAt) + } else { + fmt.Printf("OIDC token stored in clouds.yaml under cloud '%s'\n", commonConfig.CloudName) + } + return nil +} + +func openLoginBrowser(browser, loginURL string) error { + if browser == "" || browser == "default" || browser == "system" { + return openDefaultBrowser(loginURL) + } + if strings.ContainsAny(browser, `/\`) { + return fmt.Errorf("browser must be default or an executable name from PATH, got %q", browser) + } + if runtime.GOOS == "darwin" && isMacOSApplicationBrowserName(browser) { + return runBrowserLauncher(exec.Command("open", "-a", macOSApplicationName(browser), loginURL)) + } + path, err := exec.LookPath(browser) + if err != nil { + return fmt.Errorf("failed to find browser executable %q in PATH: %w", browser, err) + } + return startBrowserProcess(exec.Command(path, loginURL)) +} + +func openDefaultBrowser(loginURL string) error { + switch runtime.GOOS { + case "darwin": + return runBrowserLauncher(exec.Command("open", loginURL)) + case "linux": + return runBrowserLauncher(exec.Command("xdg-open", loginURL)) + case "windows": + return runBrowserLauncher(exec.Command("rundll32", "url.dll,FileProtocolHandler", loginURL)) + default: + return fmt.Errorf("default browser login is not implemented for %s", runtime.GOOS) + } +} + +// runBrowserLauncher runs a launcher such as open or xdg-open, which hands the +// URL over and exits immediately, so waiting for it surfaces launch failures +// instead of leaving a zombie behind. Note that xdg-open still reports success +// on a host with no browser, which is why the caller prints the URL as well. +func runBrowserLauncher(cmd *exec.Cmd) error { + output, err := cmd.CombinedOutput() + if err != nil { + if message := strings.TrimSpace(string(output)); message != "" { + return fmt.Errorf("%s failed: %w: %s", cmd.Args[0], err, message) + } + return fmt.Errorf("%s failed: %w", cmd.Args[0], err) + } + return nil +} + +// startBrowserProcess starts a browser binary that keeps running for the whole +// browsing session, so it is reaped in the background rather than waited for. +func startBrowserProcess(cmd *exec.Cmd) error { + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start browser %q: %w", cmd.Path, err) + } + go func() { _ = cmd.Wait() }() + return nil +} + +func isMacOSApplicationBrowserName(browser string) bool { + switch strings.ToLower(browser) { + case "safari", "firefox", "chrome", "google-chrome", "chromium", "brave", "brave-browser", "microsoft-edge", "edge": + return true + default: + return false + } +} + +func macOSApplicationName(browser string) string { + switch strings.ToLower(browser) { + case "safari": + return "Safari" + case "firefox": + return "Firefox" + case "chrome", "google-chrome": + return "Google Chrome" + case "chromium": + return "Chromium" + case "brave", "brave-browser": + return "Brave Browser" + case "microsoft-edge", "edge": + return "Microsoft Edge" + default: + return browser + } +} + +func randomURLToken(size int) (string, error) { + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func printIDTokenDebug(idToken string) { + header, payload, err := decodeIDTokenDebug(idToken) + if err != nil { + fmt.Printf("Debug: failed to decode Entra ID token claims: %v\n", err) + return + } + fmt.Printf("Debug: Entra ID token header alg=%v kid=%v typ=%v x5t=%v\n", header["alg"], header["kid"], header["typ"], header["x5t"]) + fmt.Printf("Debug: Entra ID token claims iss=%v aud=%v tid=%v oid=%v preferred_username=%v email=%v roles=%v scp=%v exp=%v\n", + payload["iss"], + payload["aud"], + payload["tid"], + payload["oid"], + payload["preferred_username"], + payload["email"], + payload["roles"], + payload["scp"], + payload["exp"], + ) +} + +func decodeIDTokenDebug(idToken string) (map[string]any, map[string]any, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return nil, nil, fmt.Errorf("expected JWT with 3 parts, got %d", len(parts)) + } + + header, err := decodeJWTPart(parts[0]) + if err != nil { + return nil, nil, fmt.Errorf("header: %w", err) + } + payload, err := decodeJWTPart(parts[1]) + if err != nil { + return nil, nil, fmt.Errorf("payload: %w", err) + } + return header, payload, nil +} + +func decodeJWTPart(part string) (map[string]any, error) { + raw, err := base64.RawURLEncoding.DecodeString(part) + if err != nil { + return nil, err + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, err + } + return decoded, nil +} diff --git a/services/browser/login/oidc_test.go b/services/browser/login/oidc_test.go new file mode 100644 index 0000000..4cc6491 --- /dev/null +++ b/services/browser/login/oidc_test.go @@ -0,0 +1,317 @@ +package login + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestEntraAuthorizeURLIncludesStateAndPKCE(t *testing.T) { + rawURL := entraAuthorizeURL( + "tenant-id", + "client-id", + "http://localhost:12345/otc-cli", + []string{"openid", "profile", "email"}, + "state-value", + "challenge-value", + ) + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("failed to parse authorization URL: %v", err) + } + if parsed.Scheme != "https" || parsed.Host != "login.microsoftonline.com" { + t.Fatalf("unexpected authorization endpoint: %s", parsed) + } + if parsed.Path != "/tenant-id/oauth2/v2.0/authorize" { + t.Fatalf("unexpected authorization path: %q", parsed.Path) + } + + query := parsed.Query() + expected := map[string]string{ + "client_id": "client-id", + "response_type": "code", + "redirect_uri": "http://localhost:12345/otc-cli", + "response_mode": "query", + "scope": "openid profile email", + "state": "state-value", + "code_challenge": "challenge-value", + "code_challenge_method": "S256", + } + for key, value := range expected { + if got := query.Get(key); got != value { + t.Fatalf("unexpected %s query value: got %q, want %q", key, got, value) + } + } +} + +func TestOIDCCallbackHandlerReturnsAuthorizationCode(t *testing.T) { + resultCh := make(chan oidcCallbackResult, 1) + handler := oidcCallbackHandler("expected-state", resultCh) + request := httptest.NewRequest(http.MethodGet, oidcCallbackPath+"?state=expected-state&code=authorization-code", nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("unexpected callback status: %d", response.Code) + } + body := response.Body.String() + if !strings.Contains(body, "Return to the terminal") { + t.Fatalf("callback response does not describe the remaining step: %q", body) + } + if strings.Contains(body, "OTC login finished") { + t.Fatalf("callback response reports OTC success before token exchange: %q", body) + } + + result := <-resultCh + if result.err != nil { + t.Fatalf("unexpected callback error: %v", result.err) + } + if result.code != "authorization-code" { + t.Fatalf("unexpected authorization code: %q", result.code) + } +} + +func TestOIDCCallbackHandlerRejectsInvalidState(t *testing.T) { + resultCh := make(chan oidcCallbackResult, 1) + handler := oidcCallbackHandler("expected-state", resultCh) + request := httptest.NewRequest(http.MethodGet, oidcCallbackPath+"?state=wrong-state&code=authorization-code", nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("unexpected callback status: %d", response.Code) + } + select { + case result := <-resultCh: + t.Fatalf("invalid state aborted the legitimate login flow: %#v", result) + case <-time.After(20 * time.Millisecond): + } +} + +func TestOIDCCallbackHandlerDoesNotBlockOnDuplicateCallback(t *testing.T) { + resultCh := make(chan oidcCallbackResult, 1) + resultCh <- oidcCallbackResult{code: "first-code"} + handler := oidcCallbackHandler("expected-state", resultCh) + request := httptest.NewRequest(http.MethodGet, oidcCallbackPath+"?state=expected-state&code=duplicate-code", nil) + response := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + handler.ServeHTTP(response, request) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("duplicate callback blocked while reporting its result") + } +} + +func TestStopOIDCCallbackServerForceClosesAfterShutdownFailure(t *testing.T) { + server := &callbackServerStub{ + shutdownErr: errors.New("shutdown timed out"), + } + + err := stopOIDCCallbackServer(server) + if err == nil { + t.Fatal("expected graceful shutdown failure to be reported") + } + if !server.closeCalled { + t.Fatal("expected listener to be force closed after graceful shutdown failure") + } + if !strings.Contains(err.Error(), "listener was force closed") { + t.Fatalf("unexpected shutdown error: %v", err) + } +} + +func TestStopOIDCCallbackServerReportsForceCloseFailure(t *testing.T) { + server := &callbackServerStub{ + shutdownErr: errors.New("shutdown timed out"), + closeErr: errors.New("close failed"), + } + + err := stopOIDCCallbackServer(server) + if err == nil { + t.Fatal("expected callback listener cleanup failure") + } + if !strings.Contains(err.Error(), "force close also failed") { + t.Fatalf("unexpected shutdown error: %v", err) + } +} + +func TestPostEntraTokenHonorsHTTPTimeout(t *testing.T) { + httpClient := newOIDCHTTPClient(20 * time.Millisecond) + httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + <-request.Context().Done() + return nil, request.Context().Err() + }) + + started := time.Now() + _, err := postEntraToken(httpClient, "https://login.example.test/token", url.Values{}) + if err == nil { + t.Fatal("expected token request to time out") + } + if elapsed := time.Since(started); elapsed > 500*time.Millisecond { + t.Fatalf("token request ignored configured timeout: %s", elapsed) + } +} + +func TestOIDCHTTPClientDoesNotFollowRedirects(t *testing.T) { + var targetCalled atomic.Bool + httpClient := newOIDCHTTPClient(time.Second) + httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Host == "target.example.test" { + targetCalled.Store(true) + return jsonResponse(request, http.StatusOK, nil, `{"id_token":"unexpected-token"}`), nil + } + headers := http.Header{} + headers.Set("Location", "https://target.example.test/token") + return jsonResponse(request, http.StatusFound, headers, `{}`), nil + }) + + _, _ = postEntraToken(httpClient, "https://login.example.test/token", url.Values{}) + if targetCalled.Load() { + t.Fatal("OIDC HTTP client followed a redirect to another origin") + } +} + +func TestReadOIDCResponseBodyRejectsOversizedResponse(t *testing.T) { + body := strings.NewReader(strings.Repeat("x", oidcMaxResponseBytes+1)) + if _, err := readOIDCResponseBody(body); err == nil { + t.Fatal("expected oversized response to be rejected") + } +} + +func TestOTCTokenExchangeRequests(t *testing.T) { + var unscopedRequestSeen bool + var scopedRequestSeen bool + + httpClient := newOIDCHTTPClient(time.Second) + httpClient.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Path { + case "/v3/OS-FEDERATION/identity_providers/oidc-idp/protocols/oidc/auth": + unscopedRequestSeen = true + if r.Method != http.MethodPost { + t.Errorf("unexpected unscoped method: %s", r.Method) + } + if got := r.Header.Get("Authorization"); got != "Bearer entra-id-token" { + t.Errorf("unexpected authorization header: %q", got) + } + headers := http.Header{} + headers.Set("X-Subject-Token", "unscoped-token") + return jsonResponse(r, http.StatusCreated, headers, `{}`), nil + case "/v3/auth/tokens": + scopedRequestSeen = true + if r.Method != http.MethodPost { + t.Errorf("unexpected scoped method: %s", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("unexpected content type: %q", got) + } + + var request struct { + Auth struct { + Identity struct { + Token struct { + ID string `json:"id"` + } `json:"token"` + } `json:"identity"` + Scope struct { + Project struct { + Name string `json:"name"` + Domain struct { + ID string `json:"id"` + } `json:"domain"` + } `json:"project"` + } `json:"scope"` + } `json:"auth"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("failed to decode scoped request: %v", err) + } + if request.Auth.Identity.Token.ID != "unscoped-token" { + t.Errorf("unexpected unscoped token: %q", request.Auth.Identity.Token.ID) + } + if request.Auth.Scope.Project.Name != "eu-de_dev" { + t.Errorf("unexpected project: %q", request.Auth.Scope.Project.Name) + } + if request.Auth.Scope.Project.Domain.ID != "domain-id" { + t.Errorf("unexpected domain ID: %q", request.Auth.Scope.Project.Domain.ID) + } + + headers := http.Header{} + headers.Set("X-Subject-Token", "scoped-token") + return jsonResponse(r, http.StatusCreated, headers, `{"token":{"expires_at":"2026-07-28T17:03:48Z","project":{"id":"project-id","name":"eu-de_dev"}}}`), nil + default: + return jsonResponse(r, http.StatusNotFound, nil, `{}`), nil + } + }) + + unscopedToken, err := otcUnscopedToken(httpClient, "https://iam.example.test/v3", "oidc-idp", "entra-id-token") + if err != nil { + t.Fatalf("otcUnscopedToken returned error: %v", err) + } + if unscopedToken != "unscoped-token" { + t.Fatalf("unexpected unscoped token: %q", unscopedToken) + } + + scopedToken, expiresAt, err := otcScopedToken(httpClient, "https://iam.example.test/v3", unscopedToken, "eu-de_dev", "domain-id") + if err != nil { + t.Fatalf("otcScopedToken returned error: %v", err) + } + if scopedToken != "scoped-token" { + t.Fatalf("unexpected scoped token: %q", scopedToken) + } + if expiresAt != "2026-07-28T17:03:48Z" { + t.Fatalf("unexpected token expiration: %q", expiresAt) + } + if !unscopedRequestSeen || !scopedRequestSeen { + t.Fatalf("missing token exchange requests: unscoped=%t scoped=%t", unscopedRequestSeen, scopedRequestSeen) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func jsonResponse(request *http.Request, status int, headers http.Header, body string) *http.Response { + if headers == nil { + headers = http.Header{} + } + headers.Set("Content-Type", "application/json") + return &http.Response{ + StatusCode: status, + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + } +} + +type callbackServerStub struct { + shutdownErr error + closeErr error + closeCalled bool +} + +func (server *callbackServerStub) Shutdown(context.Context) error { + return server.shutdownErr +} + +func (server *callbackServerStub) Close() error { + server.closeCalled = true + return server.closeErr +} diff --git a/services/browser/login/root.go b/services/browser/login/root.go index 247f541..bcc9d65 100644 --- a/services/browser/login/root.go +++ b/services/browser/login/root.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "net/url" "os" "path/filepath" + "strings" "time" "github.com/ysoftdevs/otc-cli/config" @@ -14,12 +16,17 @@ import ( ) type LoginArgs struct { - BaseURL string - AuthURL string - DomainID string - Idp string - Protocol string - Expiration int + BaseURL string + AuthURL string + DomainID string + Idp string + Protocol string + Expiration int + Browser string + Debug bool + DeviceCode bool + OIDC config.OIDCConfig + browserPath string CommonConfig *config.CommonConfig } @@ -41,8 +48,12 @@ type STSCredential struct { } func (la LoginArgs) buildURL() string { - return fmt.Sprintf("%s?domain_id=%s&idp=%s&protocol=%s", - la.BaseURL, la.DomainID, la.Idp, la.Protocol) + values := url.Values{} + values.Set("domain_id", la.DomainID) + values.Set("idp", la.Idp) + values.Set("protocol", la.Protocol) + + return fmt.Sprintf("%s?%s", la.BaseURL, values.Encode()) } func getUserDataDir() (string, error) { @@ -53,16 +64,157 @@ func getUserDataDir() (string, error) { } // Create directory for storing browser data - userDataDir := filepath.Join(homeDir, ".otc-cli", "browser-data") - if err := os.MkdirAll(userDataDir, 0755); err != nil { + configDir := filepath.Join(homeDir, ".otc-cli") + if err := os.MkdirAll(configDir, 0700); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.Chmod(configDir, 0700); err != nil { + return "", fmt.Errorf("failed to secure config directory: %w", err) + } + + userDataDir := filepath.Join(configDir, "browser-data") + if err := os.MkdirAll(userDataDir, 0700); err != nil { return "", fmt.Errorf("failed to create user data directory: %w", err) } + if err := os.Chmod(userDataDir, 0700); err != nil { + return "", fmt.Errorf("failed to secure user data directory: %w", err) + } fmt.Printf("Using user data directory: %s\n", userDataDir) return userDataDir, nil } -func BrowserLogin(loginArgs LoginArgs) error { +func validateLoginArgs(loginArgs LoginArgs) error { + var missing []string + + if loginArgs.DeviceCode && !loginArgs.hasOIDC() { + cloudName := "" + if loginArgs.CommonConfig != nil { + cloudName = loginArgs.CommonConfig.CloudName + } + return fmt.Errorf("--device-code is supported only for OIDC login; configure an oidc block for cloud %q", cloudName) + } + if loginArgs.CommonConfig == nil || loginArgs.CommonConfig.CloudName == "" { + missing = append(missing, "--cloud") + } + if loginArgs.CommonConfig == nil || loginArgs.CommonConfig.ProjectName == "" { + missing = append(missing, "--project") + } + if loginArgs.AuthURL == "" { + missing = append(missing, "--auth-url") + } + if loginArgs.hasOIDC() { + if loginArgs.OIDC.TenantID == "" { + missing = append(missing, "oidc.tenant_id") + } + if loginArgs.OIDC.ClientID == "" { + missing = append(missing, "oidc.client_id") + } + if loginArgs.OIDC.Idp == "" { + missing = append(missing, "oidc.idp") + } + if loginArgs.DomainID == "" { + missing = append(missing, "auth.domain_id") + } + if len(missing) > 0 { + return fmt.Errorf("missing required login parameters: %s", strings.Join(missing, ", ")) + } + if err := validateOIDCAuthURL(loginArgs.AuthURL); err != nil { + return err + } + if loginArgs.DeviceCode && loginArgs.Browser != "" && loginArgs.Browser != "default" { + return fmt.Errorf("--browser cannot be combined with --device-code") + } + return nil + } + if loginArgs.BaseURL == "" { + missing = append(missing, "--url") + } + if loginArgs.DomainID == "" { + missing = append(missing, "--domain-id") + } + if loginArgs.Idp == "" { + missing = append(missing, "--idp") + } + if loginArgs.Protocol == "" { + missing = append(missing, "--protocol") + } + if loginArgs.Expiration <= 0 { + missing = append(missing, "--expiration") + } + + if len(missing) > 0 { + return fmt.Errorf("missing required login parameters: %s", strings.Join(missing, ", ")) + } + + return nil +} + +func validateOIDCAuthURL(authURL string) error { + parsed, err := url.Parse(authURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("OIDC authentication URL must be an HTTPS base URL without user information, query, or fragment, got %q", authURL) + } + return nil +} + +func Login(loginArgs LoginArgs) error { + if err := validateLoginArgs(loginArgs); err != nil { + return err + } + + if loginArgs.hasOIDC() { + return OIDCLogin(loginArgs) + } + if loginArgs.Browser != "" && loginArgs.Browser != "default" { + return fmt.Errorf("--browser is supported only for OIDC login; configure an oidc block for cloud %q", loginArgs.CommonConfig.CloudName) + } + + loginArgs.Browser = "default" + return SystemBrowserLogin(loginArgs) +} + +func (la LoginArgs) hasOIDC() bool { + return la.OIDC.TenantID != "" || la.OIDC.ClientID != "" || la.OIDC.Idp != "" +} + +func validateControlledBrowser(browserName string) error { + name := strings.ToLower(filepath.Base(browserName)) + if isFirefoxBrowserName(name) { + return fmt.Errorf("browser %q is not supported for legacy automated credential extraction; configure OIDC login or use a Chrome/Chromium-compatible default browser", browserName) + } + if isChromiumBrowserName(name) { + return nil + } + return fmt.Errorf("browser %q is not supported for legacy automated credential extraction; configure OIDC login or use a Chrome/Chromium-compatible default browser", browserName) +} + +func isChromiumBrowserName(name string) bool { + chromiumNames := []string{ + "brave", + "brave-browser", + "chrome", + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "microsoft-edge", + "microsoft-edge-stable", + "msedge", + } + for _, chromiumName := range chromiumNames { + if name == chromiumName { + return true + } + } + return false +} + +func isFirefoxBrowserName(name string) bool { + return strings.Contains(strings.ToLower(filepath.Base(name)), "firefox") +} + +func ManagedBrowserLogin(loginArgs LoginArgs) error { userDataDir, err := getUserDataDir() if err != nil { return err @@ -77,7 +229,9 @@ func BrowserLogin(loginArgs LoginArgs) error { chromedp.Flag("window-size", "800,900"), chromedp.UserDataDir(userDataDir), ) - if p := findChromePath(); p != "" { + if p := loginArgs.browserPath; p != "" { + allocOpts = append(allocOpts, chromedp.ExecPath(p)) + } else if p := findChromePath(); p != "" { allocOpts = append(allocOpts, chromedp.ExecPath(p)) } @@ -115,7 +269,7 @@ func BrowserLogin(loginArgs LoginArgs) error { } func loginInBrowser(ctx context.Context, loginArgs LoginArgs) (string, error) { - fmt.Println("Opening managed browser for login...") + fmt.Println("Opening controlled browser for login...") fmt.Println("Waiting for authentication...") err := chromedp.Run(ctx, @@ -127,17 +281,8 @@ func loginInBrowser(ctx context.Context, loginArgs LoginArgs) (string, error) { return "", err } - // Wait for user to complete login and be redirected to console fmt.Println("Please complete the login in the opened browser window.") - fmt.Println("Waiting for redirect to console...") - - err = chromedp.Run(ctx, - chromedp.WaitVisible("cf_logo", chromedp.ByID), - ) - if err != nil { - fmt.Printf("Login timeout or failed: %v\n", err) - return "", err - } + fmt.Println("Waiting for temporary credentials...") creds, err := fetchTempCredentials(ctx, loginArgs) if err != nil { @@ -152,10 +297,11 @@ func fetchTempCredentials(ctx context.Context, loginArgs LoginArgs) (string, err fmt.Println("Fetching credentials...") var creds string - var err error + var lastErr error - for range 10 { - err = chromedp.Run(ctx, + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(` __credentials__ = null; fetch('/iam/server/aklist?type=sts&duration=%d', { @@ -171,31 +317,34 @@ func fetchTempCredentials(ctx context.Context, loginArgs LoginArgs) (string, err ) if err == nil && creds != "" { - break + if err := validateCredentialResponse(creds); err == nil { + fmt.Printf("Credentials received\n") + return creds, nil + } else { + lastErr = err + } + } else if err != nil { + lastErr = err } - fmt.Println("Retrying to fetch credentials...") + fmt.Println("Waiting for login to complete...") time.Sleep(2 * time.Second) } - if err != nil { - fmt.Printf("Failed to fetch credentials: %v\n", err) - return "", err - } else { - fmt.Printf("Credentials received\n") - return creds, nil + if lastErr != nil { + return "", fmt.Errorf("timed out waiting for credentials: %w", lastErr) } + + return "", fmt.Errorf("timed out waiting for credentials") } func storeCredentials(creds string, loginArgs *LoginArgs) error { - var credResp STSCredentialResponse - if err := json.Unmarshal([]byte(creds), &credResp); err != nil { - return fmt.Errorf("failed to parse credential response: %w", err) + if err := validateCredentialResponse(creds); err != nil { + return err } - if credResp.RetInfo != "success" { - return fmt.Errorf("credential request failed: %s", credResp.RetInfo) - } + var credResp STSCredentialResponse + _ = json.Unmarshal([]byte(creds), &credResp) commonConfig := loginArgs.CommonConfig if err := config.UpdateCloudConfig(commonConfig.CloudName, func(cloud *config.CloudConfig) { @@ -221,6 +370,39 @@ func storeCredentials(creds string, loginArgs *LoginArgs) error { return nil } +func validateCredentialResponse(creds string) error { + var credResp STSCredentialResponse + if err := json.Unmarshal([]byte(creds), &credResp); err != nil { + return fmt.Errorf("failed to parse credential response: %w", err) + } + + if credResp.RetInfo != "success" { + return fmt.Errorf("credential request failed: %s", credResp.RetInfo) + } + + return validateCredentials(credResp.Data.Credential) +} + +func validateCredentials(creds STSCredential) error { + if creds.Access == "" || creds.Secret == "" || creds.SecurityToken == "" { + return fmt.Errorf("credential response is missing access key, secret key, or security token") + } + + if creds.ExpiresAt == "" { + return nil + } + + expiresAt, err := time.Parse(time.RFC3339, creds.ExpiresAt) + if err != nil { + return fmt.Errorf("failed to parse credential expiration %q: %w", creds.ExpiresAt, err) + } + if time.Now().After(expiresAt) { + return fmt.Errorf("credential response is already expired at %s", creds.ExpiresAt) + } + + return nil +} + func logf(format string, args ...any) { fmt.Printf(format+"\n", args...) } diff --git a/services/browser/login/root_test.go b/services/browser/login/root_test.go new file mode 100644 index 0000000..e433bb8 --- /dev/null +++ b/services/browser/login/root_test.go @@ -0,0 +1,246 @@ +package login + +import ( + "strings" + "testing" + "time" + + "github.com/ysoftdevs/otc-cli/config" +) + +func validLoginArgs() LoginArgs { + return LoginArgs{ + BaseURL: "https://auth.otc.t-systems.com/authui/federation/websso", + AuthURL: "https://iam.eu-de.otc.t-systems.com/v3", + DomainID: "domain-id", + Idp: "Y_Soft_Entra_ID_PROD", + Protocol: "saml", + Expiration: 3600, + CommonConfig: &config.CommonConfig{ + CloudName: "otc-prod", + ProjectName: "eu-de_prod", + }, + } +} + +func TestBuildURLEscapesQueryParameters(t *testing.T) { + args := validLoginArgs() + args.Idp = "Y Soft Entra ID PROD" + + got := args.buildURL() + want := "https://auth.otc.t-systems.com/authui/federation/websso?domain_id=domain-id&idp=Y+Soft+Entra+ID+PROD&protocol=saml" + if got != want { + t.Fatalf("unexpected login URL: %q", got) + } +} + +func TestValidateLoginArgsAcceptsCompleteConfiguration(t *testing.T) { + if err := validateLoginArgs(validLoginArgs()); err != nil { + t.Fatalf("validateLoginArgs returned error: %v", err) + } +} + +func TestValidateLoginArgsRejectsMissingSSOParameters(t *testing.T) { + args := validLoginArgs() + args.DomainID = "" + args.Idp = "" + + err := validateLoginArgs(args) + if err == nil { + t.Fatal("expected error for missing SSO parameters") + } + if !strings.Contains(err.Error(), "--domain-id") || !strings.Contains(err.Error(), "--idp") { + t.Fatalf("error does not list missing SSO parameters: %v", err) + } +} + +func TestValidateLoginArgsRejectsDeviceCodeForLegacySSO(t *testing.T) { + args := validLoginArgs() + args.DeviceCode = true + + err := validateLoginArgs(args) + if err == nil { + t.Fatal("expected error for device-code with legacy SSO") + } + if !strings.Contains(err.Error(), "supported only for OIDC login") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateLoginArgsRejectsBrowserWithDeviceCode(t *testing.T) { + args := validLoginArgs() + args.OIDC = config.OIDCConfig{ + TenantID: "tenant-id", + ClientID: "client-id", + Idp: "oidc-idp", + } + args.DeviceCode = true + args.Browser = "firefox" + + err := validateLoginArgs(args) + if err == nil { + t.Fatal("expected error for browser with device-code") + } + if !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateLoginArgsRequiresSecureOIDCAuthURL(t *testing.T) { + for _, authURL := range []string{ + "http://iam.example.test/v3", + "https://user@iam.example.test/v3", + "https://iam.example.test/v3?redirect=other", + "https://iam.example.test/v3#fragment", + "missing-url", + } { + t.Run(authURL, func(t *testing.T) { + args := validLoginArgs() + args.AuthURL = authURL + args.OIDC = config.OIDCConfig{ + TenantID: "tenant-id", + ClientID: "client-id", + Idp: "oidc-idp", + } + + err := validateLoginArgs(args) + if err == nil { + t.Fatal("expected insecure OIDC auth URL to be rejected") + } + if !strings.Contains(err.Error(), "must be an HTTPS base URL") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateLoginArgsAcceptsSecureOIDCConfiguration(t *testing.T) { + args := validLoginArgs() + args.BaseURL = "" + args.Browser = "default" + args.OIDC = config.OIDCConfig{ + TenantID: "tenant-id", + ClientID: "client-id", + Idp: "oidc-idp", + } + + if err := validateLoginArgs(args); err != nil { + t.Fatalf("validateLoginArgs returned error: %v", err) + } +} + +func TestOpenLoginBrowserRejectsBrowserValueWithPathSeparators(t *testing.T) { + err := openLoginBrowser("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "http://127.0.0.1/otc-cli") + if err == nil { + t.Fatal("expected error for browser path") + } + if !strings.Contains(err.Error(), "executable name from PATH") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestOpenLoginBrowserRejectsMissingBrowserExecutable(t *testing.T) { + err := openLoginBrowser("otc-browser-that-does-not-exist", "http://127.0.0.1/otc-cli") + if err == nil { + t.Fatal("expected error for missing browser executable") + } + if !strings.Contains(err.Error(), "failed to find browser executable") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoginRejectsBrowserFlagForLegacySSO(t *testing.T) { + args := validLoginArgs() + args.Browser = "firefox" + + err := Login(args) + if err == nil { + t.Fatal("expected error for browser flag with legacy SSO") + } + if !strings.Contains(err.Error(), "supported only for OIDC login") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateControlledBrowserAcceptsChromiumBrowsers(t *testing.T) { + browsers := []string{ + "brave-browser", + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "microsoft-edge", + } + + for _, browser := range browsers { + if err := validateControlledBrowser(browser); err != nil { + t.Fatalf("validateControlledBrowser(%q) returned error: %v", browser, err) + } + } +} + +func TestValidateControlledBrowserRejectsFirefox(t *testing.T) { + err := validateControlledBrowser("firefox") + if err == nil { + t.Fatal("expected error for Firefox") + } + if !strings.Contains(err.Error(), "not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateCredentialsRequiresTemporarySecrets(t *testing.T) { + err := validateCredentials(STSCredential{ + Access: "access", + Secret: "secret", + SecurityToken: "token", + }) + if err != nil { + t.Fatalf("validateCredentials returned error: %v", err) + } +} + +func TestValidateCredentialsRejectsMissingSecrets(t *testing.T) { + err := validateCredentials(STSCredential{ + Access: "access", + Secret: "secret", + }) + if err == nil { + t.Fatal("expected error for missing security token") + } +} + +func TestValidateCredentialsRejectsExpiredCredentials(t *testing.T) { + err := validateCredentials(STSCredential{ + Access: "access", + Secret: "secret", + SecurityToken: "token", + ExpiresAt: time.Now().Add(-time.Hour).Format(time.RFC3339), + }) + if err == nil { + t.Fatal("expected error for expired credentials") + } +} + +func TestValidateCredentialResponseAcceptsValidResponse(t *testing.T) { + response := `{ + "retinfo": "success", + "data": { + "credential": { + "access": "access", + "secret": "secret", + "securitytoken": "token" + } + } + }` + + if err := validateCredentialResponse(response); err != nil { + t.Fatalf("validateCredentialResponse returned error: %v", err) + } +} + +func TestValidateCredentialResponseRejectsHTML(t *testing.T) { + if err := validateCredentialResponse("login"); err == nil { + t.Fatal("expected error for non-JSON response") + } +} diff --git a/services/browser/login/system_browser_darwin.go b/services/browser/login/system_browser_darwin.go new file mode 100644 index 0000000..2f9ca57 --- /dev/null +++ b/services/browser/login/system_browser_darwin.go @@ -0,0 +1,447 @@ +//go:build darwin + +package login + +import ( + "fmt" + "net/url" + "os/exec" + "strings" + "time" +) + +const safariBundleID = "com.apple.safari" +const otcAuthHost = "auth.otc.t-systems.com" +const otcConsoleOrigin = "https://console.otc.t-systems.com" + +type fatalLoginError struct { + err error +} + +func (e fatalLoginError) Error() string { + return e.err.Error() +} + +func (e fatalLoginError) Unwrap() error { + return e.err +} + +func SystemBrowserLogin(loginArgs LoginArgs) error { + bundleID, err := defaultBrowserBundleID() + if err != nil { + return err + } + + switch bundleID { + case safariBundleID: + return SafariLogin(loginArgs) + default: + return fmt.Errorf("default browser %q is not supported for legacy credential extraction; configure OIDC login or use Safari as the macOS default browser", bundleID) + } +} + +func SafariLogin(loginArgs LoginArgs) error { + trustedOrigin, err := safariCredentialOrigin(loginArgs.BaseURL) + if err != nil { + return err + } + + if err := openSafariLoginURL(loginArgs.buildURL()); err != nil { + return err + } + + fmt.Println("Opened Safari for login.") + fmt.Println("Please complete the login in Safari.") + fmt.Println("Waiting for temporary credentials...") + + creds, err := fetchSafariCredentials(loginArgs.Expiration, 5*time.Minute, trustedOrigin, loginArgs.Debug) + if err != nil { + return err + } + + return storeCredentials(creds, &loginArgs) +} + +func openSafariLoginURL(loginURL string) error { + output, err := exec.Command("open", loginURL).CombinedOutput() + if err != nil { + if message := strings.TrimSpace(string(output)); message != "" { + return fmt.Errorf("failed to open login URL in the macOS default browser: %w: %s", err, message) + } + return fmt.Errorf("failed to open login URL in the macOS default browser: %w", err) + } + + return nil +} + +func fetchSafariCredentials(expiration int, timeout time.Duration, trustedOrigin string, debug bool) (string, error) { + deadline := time.Now().Add(timeout) + var lastErr error + printedWaiting := false + + for time.Now().Before(deadline) { + creds, probe, err := safariCredentialResponse(expiration, trustedOrigin) + if debug { + printSafariProbe(probe, err) + } + if err == nil { + if err := validateCredentialResponse(creds); err == nil { + fmt.Printf("Credentials received\n") + return creds, nil + } else { + lastErr = err + } + } else { + lastErr = err + if _, ok := err.(fatalLoginError); ok { + return "", err + } + } + + if !printedWaiting { + fmt.Println("Waiting for login to complete...") + printedWaiting = true + } + time.Sleep(2 * time.Second) + } + + if lastErr != nil { + return "", fmt.Errorf("timed out waiting for Safari credentials: %w", lastErr) + } + + return "", fmt.Errorf("timed out waiting for Safari credentials") +} + +func safariCredentialResponse(expiration int, trustedOrigin string) (string, safariProbe, error) { + javascript := safariCredentialJavaScript(expiration) + escapedTrustedOrigin := appleScriptString(trustedOrigin) + + script := fmt.Sprintf(` +tell application "Safari" + if not (exists document 1) then + error "Safari has no open document" + end if + + set probeOutput to "COUNT " & (count of documents) & linefeed + repeat with documentIndex from 1 to count of documents + set safariDocument to document documentIndex + set documentURL to "" + try + set documentURL to URL of safariDocument as text + on error errMsg + set documentURL to "" + end try + + if documentURL is %s or documentURL starts with (%s & "/") or documentURL starts with (%s & "?") or documentURL starts with (%s & "#") then + try + set credentialResponse to do JavaScript %s in safariDocument + on error errMsg + set credentialResponse to "appleevent-error " & errMsg + end try + else + set credentialResponse to "skipped-untrusted" + end if + + set probeOutput to probeOutput & "DOC " & documentIndex & " " & documentURL & " " & credentialResponse & linefeed + end repeat + + return probeOutput +end tell +`, escapedTrustedOrigin, escapedTrustedOrigin, escapedTrustedOrigin, escapedTrustedOrigin, appleScriptString(javascript)) + + output, err := runAppleScript(script) + if err != nil { + hintedErr := safariAutomationHint(err) + wrappedErr := fmt.Errorf("failed to query Safari for temporary credentials: %w", hintedErr) + if isFatalSafariAutomationError(hintedErr) { + return "", safariProbe{}, fatalLoginError{err: wrappedErr} + } + return "", safariProbe{}, wrappedErr + } + + probe := parseSafariProbe(output, trustedOrigin) + return probe.credentials, probe, nil +} + +func safariCredentialJavaScript(expiration int) string { + return fmt.Sprintf(` +(function() { + try { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/iam/server/aklist?type=sts&duration=%d', false); + xhr.withCredentials = true; + xhr.send(null); + + var response = xhr.responseText || ''; + if (response.indexOf('"retinfo"') !== -1 || response.indexOf('"credential"') !== -1) { + try { + response = JSON.stringify(JSON.parse(response)); + } catch (parseError) { + } + return 'credential\t' + response.length + '\t' + response; + } + return 'noncredential\t' + xhr.status + '\t' + response.length + '\t' + (xhr.getResponseHeader('content-type') || ''); + } catch (e) { + return 'error\t' + ((e && e.name) ? e.name : 'Error') + ': ' + ((e && e.message) ? e.message : String(e)); + } +})() +`, expiration) +} + +type safariProbe struct { + documentCount int + documents []safariDocumentProbe + credentials string +} + +type safariDocumentProbe struct { + index string + url string + status string + httpStatus string + length string + contentType string + errorText string +} + +func parseSafariProbe(output string, trustedOrigin string) safariProbe { + var probe safariProbe + + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + if line == "" { + continue + } + + if strings.HasPrefix(line, "COUNT\t") { + fmt.Sscanf(strings.TrimPrefix(line, "COUNT\t"), "%d", &probe.documentCount) + continue + } + + if !strings.HasPrefix(line, "DOC\t") { + continue + } + + fields := strings.SplitN(line, "\t", 6) + if len(fields) < 4 { + continue + } + + document := safariDocumentProbe{ + index: fields[1], + url: fields[2], + status: fields[3], + } + + switch document.status { + case "credential": + if len(fields) >= 5 { + document.length = fields[4] + } + if len(fields) == 6 && isSafariTrustedURL(document.url, trustedOrigin) { + probe.credentials = fields[5] + } + case "noncredential": + if len(fields) >= 5 { + document.httpStatus = fields[4] + } + if len(fields) >= 6 { + rest := strings.SplitN(fields[5], "\t", 2) + document.length = rest[0] + if len(rest) == 2 { + document.contentType = rest[1] + } + } + case "error", "appleevent-error": + if len(fields) >= 5 { + document.errorText = fields[4] + } + } + + probe.documents = append(probe.documents, document) + } + + return probe +} + +func safariCredentialOrigin(baseURL string) (string, error) { + parsed, err := url.Parse(baseURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil || parsed.Port() != "" { + return "", fmt.Errorf("failed to derive Safari credential origin from login URL %q", baseURL) + } + + host := strings.ToLower(parsed.Hostname()) + switch host { + case otcAuthHost, "console.otc.t-systems.com": + return otcConsoleOrigin, nil + default: + return "", fmt.Errorf("legacy credential extraction in Safari is supported only for OTC console SSO, not login host %q; configure OIDC login instead", parsed.Host) + } +} + +func isSafariTrustedURL(rawURL string, trustedOrigin string) bool { + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" || parsedURL.User != nil { + return false + } + parsedOrigin, err := url.Parse(trustedOrigin) + if err != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" || parsedOrigin.User != nil { + return false + } + + return strings.EqualFold(parsedURL.Scheme, parsedOrigin.Scheme) && + strings.EqualFold(parsedURL.Host, parsedOrigin.Host) +} + +func printSafariProbe(probe safariProbe, probeErr error) { + if probeErr != nil { + fmt.Printf("Debug: Safari credential probe error: %v\n", probeErr) + return + } + + fmt.Printf("Debug: Safari documents: %d\n", probe.documentCount) + for _, document := range probe.documents { + fmt.Printf("Debug: Safari document %s url=%s status=%s", document.index, redactURL(document.url), document.status) + if document.httpStatus != "" { + fmt.Printf(" http=%s", document.httpStatus) + } + if document.length != "" { + fmt.Printf(" bytes=%s", document.length) + } + if document.contentType != "" { + fmt.Printf(" content-type=%s", document.contentType) + } + if document.errorText != "" { + fmt.Printf(" error=%s", document.errorText) + } + fmt.Println() + } +} + +func redactURL(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return raw + } + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func safariAutomationHint(err error) error { + message := err.Error() + switch { + case strings.Contains(message, "Not authorized to send Apple events"): + return fmt.Errorf("%w; allow this terminal application to control Safari in macOS Privacy & Security > Automation", err) + case strings.Contains(message, "not allowed") && strings.Contains(message, "JavaScript"): + return fmt.Errorf("%w; enable Safari Develop > Allow JavaScript from Apple Events", err) + case strings.Contains(message, "JavaScript") && strings.Contains(message, "Apple Events"): + return fmt.Errorf("%w; enable Safari Develop > Allow JavaScript from Apple Events", err) + default: + return err + } +} + +func isFatalSafariAutomationError(err error) bool { + message := err.Error() + return strings.Contains(message, "Allow JavaScript from Apple Events") || + strings.Contains(message, "Privacy & Security > Automation") +} + +func appleScriptString(value string) string { + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) + return `"` + escaped + `"` +} + +func defaultBrowserBundleID() (string, error) { + output, err := exec.Command("defaults", "read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers").Output() + if err != nil { + return "", fmt.Errorf("failed to detect the macOS default browser: %w", err) + } + + return defaultBrowserBundleIDFromLaunchServices(string(output)) +} + +func defaultBrowserBundleIDFromLaunchServices(launchServicesHandlers string) (string, error) { + handlerBlocks := topLevelHandlerBlocks(launchServicesHandlers) + for _, block := range handlerBlocks { + if !strings.Contains(block, "LSHandlerURLScheme = https;") { + continue + } + + if bundleID := handlerRoleAll(block); bundleID != "" { + return bundleID, nil + } + } + + for _, block := range handlerBlocks { + if !strings.Contains(block, `LSHandlerContentType = "com.apple.default-app.web-browser";`) { + continue + } + + if bundleID := handlerRoleAll(block); bundleID != "" { + return bundleID, nil + } + } + + return "", fmt.Errorf("failed to find the macOS default browser in LaunchServices handlers") +} + +func topLevelHandlerBlocks(launchServicesHandlers string) []string { + var blocks []string + var current strings.Builder + depth := 0 + + for _, line := range strings.Split(launchServicesHandlers, "\n") { + opening := strings.Count(line, "{") + closing := strings.Count(line, "}") + + if depth > 0 { + current.WriteString(line) + current.WriteByte('\n') + } + + depth += opening - closing + if depth == 0 && current.Len() > 0 { + blocks = append(blocks, current.String()) + current.Reset() + } + } + + return blocks +} + +func handlerRoleAll(handlerBlock string) string { + depth := 1 + for _, line := range strings.Split(handlerBlock, "\n") { + depth += strings.Count(line, "{") + depth -= strings.Count(line, "}") + + if depth != 1 || !strings.Contains(line, "LSHandlerRoleAll =") { + continue + } + + value := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "LSHandlerRoleAll =")) + value = strings.TrimSuffix(value, ";") + value = strings.Trim(value, `"`) + return value + } + + return "" +} + +func runAppleScript(script string) (string, error) { + output, err := exec.Command("osascript", "-e", script).Output() + if err != nil { + stderr := "" + if exitErr, ok := err.(*exec.ExitError); ok { + stderr = strings.TrimSpace(string(exitErr.Stderr)) + } + if stderr == "" { + return "", err + } + return "", fmt.Errorf("%w: %s", err, stderr) + } + + return strings.TrimSpace(string(output)), nil +} diff --git a/services/browser/login/system_browser_darwin_test.go b/services/browser/login/system_browser_darwin_test.go new file mode 100644 index 0000000..f19e6e2 --- /dev/null +++ b/services/browser/login/system_browser_darwin_test.go @@ -0,0 +1,216 @@ +//go:build darwin + +package login + +import ( + "strings" + "testing" +) + +func TestDefaultBrowserBundleIDFromLaunchServicesPrefersHTTPSHandler(t *testing.T) { + handlers := ` +( + { + LSHandlerContentType = "com.apple.default-app.web-browser"; + LSHandlerRoleAll = "com.apple.safari"; + }, + { + LSHandlerRoleAll = "com.apple.SafariTechnologyPreview"; + LSHandlerURLScheme = http; + }, + { + LSHandlerRoleAll = "com.apple.safari"; + LSHandlerURLScheme = https; + } +) +` + + got, err := defaultBrowserBundleIDFromLaunchServices(handlers) + if err != nil { + t.Fatalf("defaultBrowserBundleIDFromLaunchServices returned error: %v", err) + } + if got != safariBundleID { + t.Fatalf("unexpected browser bundle ID: %q", got) + } +} + +func TestDefaultBrowserBundleIDFromLaunchServicesFallsBackToWebBrowserContentType(t *testing.T) { + handlers := ` +( + { + LSHandlerContentType = "com.apple.default-app.web-browser"; + LSHandlerRoleAll = "com.apple.safari"; + } +) +` + + got, err := defaultBrowserBundleIDFromLaunchServices(handlers) + if err != nil { + t.Fatalf("defaultBrowserBundleIDFromLaunchServices returned error: %v", err) + } + if got != safariBundleID { + t.Fatalf("unexpected browser bundle ID: %q", got) + } +} + +func TestDefaultBrowserBundleIDFromLaunchServicesRejectsMissingBrowser(t *testing.T) { + _, err := defaultBrowserBundleIDFromLaunchServices(`({ LSHandlerURLScheme = mailto; LSHandlerRoleAll = "com.apple.mail"; })`) + if err == nil { + t.Fatal("expected error for missing browser handler") + } + if !strings.Contains(err.Error(), "failed to find") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSafariAutomationHintAddsJavaScriptHint(t *testing.T) { + err := safariAutomationHint(assertableError("execution error: JavaScript execution through Apple Events is not allowed")) + if !strings.Contains(err.Error(), "Allow JavaScript from Apple Events") { + t.Fatalf("missing Safari JavaScript hint: %v", err) + } +} + +func TestIsFatalSafariAutomationError(t *testing.T) { + err := safariAutomationHint(assertableError("Safari got an error: You must enable 'Allow JavaScript from Apple Events'")) + if !isFatalSafariAutomationError(err) { + t.Fatalf("expected fatal Safari automation error: %v", err) + } +} + +func TestAppleScriptStringEscapesQuotesAndBackslashes(t *testing.T) { + got := appleScriptString(`https://example.test/?q="value"\next`) + want := `"https://example.test/?q=\"value\"\\next"` + if got != want { + t.Fatalf("unexpected AppleScript string: %q", got) + } +} + +func TestParseSafariProbeExtractsCredentialsWithoutLoggingThem(t *testing.T) { + output := strings.Join([]string{ + "COUNT\t2", + "DOC\t1\thttps://login.example.test/callback?token=secret\tnoncredential\t200\t128\ttext/html", + `DOC 2 https://console.example.test/console credential 97 {"retinfo":"success","data":{"credential":{"access":"a","secret":"s","securitytoken":"t"}}}`, + }, "\n") + + probe := parseSafariProbe(output, "https://console.example.test") + if probe.documentCount != 2 { + t.Fatalf("unexpected document count: %d", probe.documentCount) + } + if len(probe.documents) != 2 { + t.Fatalf("unexpected document probe count: %d", len(probe.documents)) + } + if probe.credentials == "" { + t.Fatal("expected credentials to be extracted") + } + if probe.documents[0].httpStatus != "200" || probe.documents[0].length != "128" || probe.documents[0].contentType != "text/html" { + t.Fatalf("unexpected noncredential probe: %#v", probe.documents[0]) + } + if probe.documents[1].status != "credential" || probe.documents[1].length != "97" { + t.Fatalf("unexpected credential probe: %#v", probe.documents[1]) + } +} + +func TestParseSafariProbeIgnoresCredentialsFromUntrustedDocuments(t *testing.T) { + trustedCredentials := `{"retinfo":"success","data":{"credential":{"access":"trusted","secret":"s","securitytoken":"t"}}}` + untrustedCredentials := `{"retinfo":"success","data":{"credential":{"access":"evil","secret":"s","securitytoken":"t"}}}` + output := strings.Join([]string{ + "COUNT\t2", + "DOC\t1\thttps://console.example.test/console\tcredential\t97\t" + trustedCredentials, + "DOC\t2\thttps://console.example.test.evil/iam/server/aklist\tcredential\t97\t" + untrustedCredentials, + }, "\n") + + probe := parseSafariProbe(output, "https://console.example.test") + if probe.credentials != trustedCredentials { + t.Fatalf("unexpected credentials: %q", probe.credentials) + } +} + +func TestParseSafariProbeRejectsOnlyUntrustedCredentials(t *testing.T) { + output := strings.Join([]string{ + "COUNT\t1", + `DOC 1 https://console.example.test.evil/iam/server/aklist credential 97 {"retinfo":"success","data":{"credential":{"access":"evil","secret":"s","securitytoken":"t"}}}`, + }, "\n") + + probe := parseSafariProbe(output, "https://console.example.test") + if probe.credentials != "" { + t.Fatalf("expected untrusted credentials to be ignored, got %q", probe.credentials) + } +} + +func TestSafariCredentialOriginDerivesConsoleOriginFromOTCAuthURL(t *testing.T) { + got, err := safariCredentialOrigin("https://auth.otc.t-systems.com/authui/federation/websso") + if err != nil { + t.Fatalf("safariCredentialOrigin returned error: %v", err) + } + if got != "https://console.otc.t-systems.com" { + t.Fatalf("unexpected origin: %q", got) + } +} + +func TestSafariCredentialOriginRejectsUnknownHost(t *testing.T) { + _, err := safariCredentialOrigin("https://auth.example.test/authui/federation/websso") + if err == nil { + t.Fatal("expected unknown host to be rejected") + } + if !strings.Contains(err.Error(), "configure OIDC login") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSafariCredentialOriginRequiresStandardHTTPSOrigin(t *testing.T) { + untrusted := []string{ + "http://console.otc.t-systems.com/console", + "https://console.otc.t-systems.com:8443/console", + "https://user@console.otc.t-systems.com/console", + } + for _, baseURL := range untrusted { + t.Run(baseURL, func(t *testing.T) { + if _, err := safariCredentialOrigin(baseURL); err == nil { + t.Fatalf("expected origin to be rejected: %s", baseURL) + } + }) + } +} + +func TestIsSafariTrustedURLRequiresSameOrigin(t *testing.T) { + trustedOrigin := "https://console.otc.t-systems.com" + trusted := []string{ + "https://console.otc.t-systems.com", + "https://console.otc.t-systems.com/", + "https://console.otc.t-systems.com/console?token=redacted", + } + for _, rawURL := range trusted { + if !isSafariTrustedURL(rawURL, trustedOrigin) { + t.Fatalf("expected trusted URL: %s", rawURL) + } + } + + untrusted := []string{ + "https://console.otc.t-systems.com.evil/console", + "https://console.otc.t-systems.com@evil.test/console", + "https://user@console.otc.t-systems.com/console", + "https://console.otc.t-systems.com:8443/console", + "http://console.otc.t-systems.com/console", + "https://auth.otc.t-systems.com/authui/federation/websso", + "missing value", + } + for _, rawURL := range untrusted { + if isSafariTrustedURL(rawURL, trustedOrigin) { + t.Fatalf("expected untrusted URL: %s", rawURL) + } + } +} + +func TestRedactURLRemovesQueryAndFragment(t *testing.T) { + got := redactURL("https://auth.example.test/path?token=secret#fragment") + want := "https://auth.example.test/path" + if got != want { + t.Fatalf("unexpected redacted URL: %q", got) + } +} + +type assertableError string + +func (e assertableError) Error() string { + return string(e) +} diff --git a/services/browser/login/system_browser_linux.go b/services/browser/login/system_browser_linux.go new file mode 100644 index 0000000..79b0c2b --- /dev/null +++ b/services/browser/login/system_browser_linux.go @@ -0,0 +1,128 @@ +//go:build linux + +package login + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var defaultBrowserDesktopIDFunc = defaultBrowserDesktopID + +func SystemBrowserLogin(loginArgs LoginArgs) error { + browserName, err := defaultBrowserExecutable() + if err != nil { + return err + } + if err := validateControlledBrowser(browserName); err != nil { + return fmt.Errorf("default browser %q is not supported for legacy automated credential extraction; configure OIDC login or set a Chrome/Chromium-compatible default browser", browserName) + } + + browserPath, err := exec.LookPath(browserName) + if err != nil { + return fmt.Errorf("default browser executable %q was not found in PATH; install it, set another default browser, or configure OIDC login: %w", browserName, err) + } + + loginArgs.Browser = browserName + loginArgs.browserPath = browserPath + return ManagedBrowserLogin(loginArgs) +} + +func defaultBrowserExecutable() (string, error) { + desktopID, err := defaultBrowserDesktopIDFunc() + if err != nil { + return "", err + } + + executable, err := desktopExecutable(desktopID) + if err != nil { + return "", err + } + return executable, nil +} + +func defaultBrowserDesktopID() (string, error) { + output, err := exec.Command("xdg-settings", "get", "default-web-browser").Output() + if err != nil { + return "", fmt.Errorf("failed to detect default browser with xdg-settings: %w", err) + } + + desktopID := strings.TrimSpace(string(output)) + if desktopID == "" { + return "", fmt.Errorf("default browser is not configured; set it with xdg-settings or configure OIDC login") + } + + return desktopID, nil +} + +func desktopExecutable(desktopID string) (string, error) { + desktopPath, err := findDesktopFile(desktopID) + if err != nil { + return "", err + } + + file, err := os.Open(desktopPath) + if err != nil { + return "", fmt.Errorf("failed to open desktop entry %q: %w", desktopPath, err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "Exec=") { + continue + } + executable := parseDesktopExec(strings.TrimPrefix(line, "Exec=")) + if executable == "" { + return "", fmt.Errorf("desktop entry %q has an empty Exec command", desktopPath) + } + return filepath.Base(executable), nil + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to read desktop entry %q: %w", desktopPath, err) + } + + return "", fmt.Errorf("desktop entry %q does not contain an Exec command", desktopPath) +} + +func findDesktopFile(desktopID string) (string, error) { + var dirs []string + if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" { + dirs = append(dirs, filepath.Join(dataHome, "applications")) + } else if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".local", "share", "applications")) + } + + dataDirs := os.Getenv("XDG_DATA_DIRS") + if dataDirs == "" { + dataDirs = "/usr/local/share:/usr/share" + } + for _, dir := range filepath.SplitList(dataDirs) { + dirs = append(dirs, filepath.Join(dir, "applications")) + } + + for _, dir := range dirs { + desktopPath := filepath.Join(dir, desktopID) + if _, err := os.Stat(desktopPath); err == nil { + return desktopPath, nil + } + } + + return "", fmt.Errorf("desktop entry %q was not found in XDG application directories", desktopID) +} + +func parseDesktopExec(execLine string) string { + fields := strings.Fields(execLine) + for _, field := range fields { + if strings.HasPrefix(field, "%") { + continue + } + return strings.Trim(field, `"`) + } + return "" +} diff --git a/services/browser/login/system_browser_linux_test.go b/services/browser/login/system_browser_linux_test.go new file mode 100644 index 0000000..0698672 --- /dev/null +++ b/services/browser/login/system_browser_linux_test.go @@ -0,0 +1,61 @@ +//go:build linux + +package login + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseDesktopExecReturnsExecutableName(t *testing.T) { + got := parseDesktopExec("google-chrome-stable %U") + if got != "google-chrome-stable" { + t.Fatalf("unexpected executable: %q", got) + } +} + +func TestParseDesktopExecPreservesExecutablePath(t *testing.T) { + got := parseDesktopExec("/usr/bin/chromium --new-window %U") + if got != "/usr/bin/chromium" { + t.Fatalf("unexpected executable: %q", got) + } +} + +func TestParseDesktopExecSkipsFieldCodes(t *testing.T) { + got := parseDesktopExec("%U chromium-browser") + if got != "chromium-browser" { + t.Fatalf("unexpected executable: %q", got) + } +} + +func TestSystemBrowserLoginRejectsDefaultFirefox(t *testing.T) { + dataHome := t.TempDir() + applicationsDir := filepath.Join(dataHome, "applications") + if err := os.MkdirAll(applicationsDir, 0755); err != nil { + t.Fatalf("failed to create applications dir: %v", err) + } + desktopPath := filepath.Join(applicationsDir, "firefox.desktop") + if err := os.WriteFile(desktopPath, []byte("[Desktop Entry]\nExec=firefox %u\n"), 0644); err != nil { + t.Fatalf("failed to write desktop entry: %v", err) + } + + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("PATH", t.TempDir()) + + defaultBrowserDesktopIDFunc = func() (string, error) { + return "firefox.desktop", nil + } + t.Cleanup(func() { + defaultBrowserDesktopIDFunc = defaultBrowserDesktopID + }) + + err := SystemBrowserLogin(validLoginArgs()) + if err == nil { + t.Fatal("expected error for default Firefox") + } + if !strings.Contains(err.Error(), `default browser "firefox" is not supported`) { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/services/browser/login/system_browser_unsupported.go b/services/browser/login/system_browser_unsupported.go new file mode 100644 index 0000000..f60afc8 --- /dev/null +++ b/services/browser/login/system_browser_unsupported.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux && !windows + +package login + +import ( + "fmt" + "runtime" +) + +func SystemBrowserLogin(LoginArgs) error { + return fmt.Errorf("legacy browser login is not implemented for %s; configure OIDC login instead", runtime.GOOS) +} diff --git a/services/browser/login/system_browser_windows.go b/services/browser/login/system_browser_windows.go new file mode 100644 index 0000000..4b331a2 --- /dev/null +++ b/services/browser/login/system_browser_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package login + +// SystemBrowserLogin preserves legacy SSO support on Windows by letting +// chromedp discover an installed Chrome or Edge executable. +func SystemBrowserLogin(loginArgs LoginArgs) error { + loginArgs.Browser = "managed Chromium" + return ManagedBrowserLogin(loginArgs) +}