Repository: openpubkey/opkssh
Pull request #143: Creates yaml client config
Author: login: EthanHeilman
Labels: name: enhancement
description: New feature or request
Created: 2025-04-17T00:00:39Z | Merged: 2025-04-21T17:34:19Z

Creates a yaml config file for the opkssh client which lives at `~/.opk/config.yml`. 

You can create it by running `opkssh login --create-config`.

# Design

## Format

```yaml
---
default_provider: webchooser

providers:
  - alias: google
    issuer: https://accounts.google.com
    client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com
    client_secret: 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
    scopes: openid email profile
    access_type: offline
    prompt: consent
    redirect_uris:
      - http://localhost:3000/login-callback
      - http://localhost:10001/login-callback
      - http://localhost:11110/login-callback

  - alias: azure microsoft
    issuer: https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0
    client_id: 096ce0a3-5e72-4da8-9c86-12924b294a01
    scopes: openid profile email offline_access
    access_type: offline
    prompt: consent
    redirect_uris:
      - http://localhost:3000/login-callback
      - http://localhost:10001/login-callback
      - http://localhost:11110/login-callback
      
  - alias: okta
    issuer: https://subdomain.okta.com
    client_secret: 03015ee2ff6e17d61546e061b440eebb
    scopes: openid email profile groups
```

There is a set of default values allowing the config to be shorter unless those values are needed - suggested by @yonatan-sevenai 

When we wish to add new types of providers such as workflow providers like github actions and gitlab-ci they will have their own provider list.

```yaml
providers:
 - alias: google
    issuer: https://accounts.google.com
    client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com
    ...
workflow_providers:
  - alias: github
    issuer: https://token.actions.githubusercontent.com
    audience_prefix: "opkssh:"
```

## Why not XDG Basedir

I had originally planned to XDG Basedir standard for config files as provided by https://pkg.go.dev/os#UserConfigDir for [XDG Basedir](https://specifications.freedesktop.org/basedir-spec/latest/) and proposed in https://github.com/openpubkey/opkssh/issues/94 

I opted not to use XDG Basedir for the config. Instead using `~/.opk/config.yml` for all OSes. My reasons are as follows:

### XDG Basedir Config uses a different path for each OS

**Windows:**  `~\AppData\Roaming\.opk\config.yml`

**OSX:** `~/Library/Application\ Support/.opk/config.yml`

**Linux:** `~/.config/.opk/config.yml`

This means that anytime I am debugging an issue with a user I will need to first determine what OS they are running. Additionally this longer path means they will likely type it wrong when trying to find the config. **This is especially true with OSX where the path as an space in it!!!** Nothing makes debugging worse than having to ensure that someone escapes a space in a path

###  This config file should be easily found and edited by the user

XDG Basedir Config is a long path which is hard to type correctly and hard to remember. Is it `~\AppData\Local` or `~\AppData\Roaming`? On windows and OSX it isn't very discoverable.

### No one respects XDG Basedir Config (a standard that isn't standard)

On windows, microsoft's vscode uses `~\.vscode` not `~\AppData\Roaming\.vscode`, microsofts dotnet uses `~\.dotnet`. Microsoft does not even follow the XDG basedir config path that microsoft choose.

On OSX there is a similar pattern

- rust is `~/.rustup`
- cargo is `~/.cargo`
- git is `~/.gitconfig`
- nvm is `~/.nvm`
- npm is `~/.npm`
- vscode is `~/.vscode`
- bash is `~/.bashrc`
- golang `~/go` (when installed by brew)

## Open questions

### Should we automatically create the config on first login?

I had originally been against this idea, but given time I've come around to it. This PR allows a user to create a config by typing `opkssh login --create-config` but it does not automatically create the config.

# TODOs
- [x] Parse yaml config
- [x] Use in login.go
- [x] Figure out the plan if this file should be automatically created or not
- [x] Create docs
- [x] Create config argument
- [x] Unittests

## Deferred TODOs
- CLI commands to manage this file and detect errors - https://github.com/openpubkey/opkssh/issues/153

## Tests

Added many unittests to lock down this feature. Also manually tested the config, by editing it and seeing behavior change.


Diff:
diff --git a/README.md b/README.md
index 1f8bbc8d..c684378c 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,7 @@ sftp root@example.com
 <summary>Instructions</summary>
 
 #### SSH command
+
 Tell opkssh to store the name the key-pair `opkssh_server_group1`
 
 ```cmd
@@ -123,7 +124,6 @@ We recommend specifying `-o "IdentitiesOnly=yes"` as it tells ssh to only use th
 
 </details>
 
-
 ### Installing on a Server
 
 To configure a linux server to use opkssh simply run (with root level privileges):
@@ -312,7 +312,9 @@ For example if the issuer is `https://authentik.local/application/o/opkssh/` and
 ```bash
 opkssh login --provider="https://authentik.local/application/o/opkssh/,ClientID123"
 ```
+
 to specify scopes
+
 ```bash
 opkssh login --provider="https://authentik.local/application/o/opkssh/,ClientID123,,openid profile email groups"
 ```
@@ -323,7 +325,72 @@ You can use this shortcut which will use a provider alias to find the provider.
 opkssh login authentik
 ```
 
-This alias to provider mapping can configured using the OPKSSH_PROVIDERS environment variables.
+This alias to provider mapping be can configured using the OPKSSH_PROVIDERS environment variables.
+
+### Client Config File
+
+Rather than type in the provider each time, you can create a client config file by running `opkssh login --create-config` at
+`C:\Users\{USER}\AppData\Roaming\.opk\config.yml` on windows and `~/.opk/config.yml` on linux.
+You can then edit this config file to add your provider.
+
+<details>
+<summary>config.yml</summary>
+
+You can delete any providers you don't plan on using.
+If you have a provider you want to open by default, change `default_provider` to the name of your alias of your custom provider.
+
+```yaml
+---
+default_provider: webchooser
+
+providers:
+  - alias: google
+    issuer: https://accounts.google.com
+    client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com
+    client_secret: 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
+    scopes: openid email profile
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
+  - alias: azure microsoft
+    issuer: https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0
+    client_id: 096ce0a3-5e72-4da8-9c86-12924b294a01
+    scopes: openid profile email offline_access
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
+  - alias: gitlab
+    issuer: https://gitlab.com
+    client_id: 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923
+    scopes: openid email
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
+  - alias: hello
+    issuer: https://issuer.hello.coop
+    client_id: app_xejobTKEsDNSRd5vofKB2iay_2rN
+    scopes: openid email
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+```
+
+</details>
 
 ### Environment Variables
 
diff --git a/commands/client-config/config.go b/commands/client-config/config.go
new file mode 100644
index 00000000..b682801b
--- /dev/null
+++ b/commands/client-config/config.go
@@ -0,0 +1,44 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+	_ "embed"
+
+	"gopkg.in/yaml.v3"
+)
+
+//go:embed default-client-config.yml
+var DefaultClientConfig []byte
+
+type ClientConfig struct {
+	DefaultProvider string           `yaml:"default_provider"`
+	Providers       []ProviderConfig `yaml:"providers"`
+}
+
+func NewClientConfig(c []byte) (*ClientConfig, error) {
+	var config ClientConfig
+	if err := yaml.Unmarshal(c, &config); err != nil {
+		return nil, err
+	}
+
+	return &config, nil
+}
+
+func (c *ClientConfig) GetProvidersMap() (map[string]ProviderConfig, error) {
+	return CreateProvidersMap(c.Providers)
+}
diff --git a/commands/client-config/config_test.go b/commands/client-config/config_test.go
new file mode 100644
index 00000000..ce685216
--- /dev/null
+++ b/commands/client-config/config_test.go
@@ -0,0 +1,41 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseConfig(t *testing.T) {
+	clientConfigDefault, err := NewClientConfig(DefaultClientConfig)
+	require.NoError(t, err)
+	require.NotNil(t, clientConfigDefault)
+	require.Equal(t, clientConfigDefault.DefaultProvider, "webchooser")
+	require.Equal(t, 4, len(clientConfigDefault.Providers))
+
+	providerMap, err := clientConfigDefault.GetProvidersMap()
+	require.NoError(t, err)
+	// This is 5 rather than 4 because one of the providers has 2 aliases
+	require.Equal(t, 5, len(providerMap))
+
+	// Test failure
+	clientConfigDefault, err = NewClientConfig([]byte("invalid yaml"))
+	require.ErrorContains(t, err, "yaml: unmarshal errors")
+	require.Nil(t, clientConfigDefault)
+}
diff --git a/commands/client-config/default-client-config.yml b/commands/client-config/default-client-config.yml
new file mode 100644
index 00000000..dfc330fc
--- /dev/null
+++ b/commands/client-config/default-client-config.yml
@@ -0,0 +1,49 @@
+---
+default_provider: webchooser
+
+providers:
+  - alias: google
+    issuer: https://accounts.google.com
+    client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com
+    client_secret: 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
+    scopes: openid email profile
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
+  - alias: azure microsoft
+    issuer: https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0
+    client_id: 096ce0a3-5e72-4da8-9c86-12924b294a01
+    scopes: openid profile email offline_access
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
+  - alias: gitlab
+    issuer: https://gitlab.com
+    client_id: 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923
+    scopes: openid email
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
+  - alias: hello
+    issuer: https://issuer.hello.coop
+    client_id: app_xejobTKEsDNSRd5vofKB2iay_2rN
+    scopes: openid email
+    access_type: offline
+    prompt: consent
+    redirect_uris:
+      - http://localhost:3000/login-callback
+      - http://localhost:10001/login-callback
+      - http://localhost:11110/login-callback
+
diff --git a/commands/client-config/providerconfig.go b/commands/client-config/providerconfig.go
new file mode 100644
index 00000000..a77662e9
--- /dev/null
+++ b/commands/client-config/providerconfig.go
@@ -0,0 +1,266 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/openpubkey/openpubkey/providers"
+	"gopkg.in/yaml.v3"
+)
+
+const WEBCHOOSER_ALIAS = "WEBCHOOSER"
+const OPKSSH_DEFAULT_ENVVAR = "OPKSSH_DEFAULT"
+const OPKSSH_PROVIDERS_ENVVAR = "OPKSSH_PROVIDERS"
+
+type ProviderConfig struct {
+	AliasList    []string `yaml:"alias"`
+	Issuer       string   `yaml:"issuer"`
+	ClientID     string   `yaml:"client_id"`
+	ClientSecret string   `yaml:"client_secret,omitempty"`
+	Scopes       []string `yaml:"scopes"`
+	AccessType   string   `yaml:"access_type,omitempty"`
+	Prompt       string   `yaml:"prompt,omitempty"`
+	RedirectURIs []string `yaml:"redirect_uris"`
+}
+
+func (p *ProviderConfig) UnmarshalYAML(value *yaml.Node) error {
+	var tmp struct {
+		AliasList    string   `yaml:"alias"`
+		Issuer       string   `yaml:"issuer"`
+		ClientID     string   `yaml:"client_id"`
+		ClientSecret string   `yaml:"client_secret"`
+		Scopes       string   `yaml:"scopes"`
+		AccessType   string   `yaml:"access_type"`
+		Prompt       string   `yaml:"prompt"`
+		RedirectURIs []string `yaml:"redirect_uris"`
+	}
+
+	// Set default values
+	tmp.Scopes = "openid profile email"
+	tmp.AccessType = "offline"
+	tmp.Prompt = "consent"
+	tmp.RedirectURIs = []string{
+		"http://localhost:3000/login-callback",
+		"http://localhost:10001/login-callback",
+		"http://localhost:11110/login-callback",
+	}
+
+	if err := value.Decode(&tmp); err != nil {
+		return err
+	}
+	*p = ProviderConfig{
+		AliasList:    strings.Fields(tmp.AliasList),
+		Issuer:       tmp.Issuer,
+		ClientID:     tmp.ClientID,
+		ClientSecret: tmp.ClientSecret,
+		Scopes:       strings.Fields(tmp.Scopes),
+		AccessType:   tmp.AccessType,
+		Prompt:       tmp.Prompt,
+		RedirectURIs: tmp.RedirectURIs,
+	}
+	return nil
+}
+
+// TODO: Move this into OpenPubkey providers package
+func DefaultProviderConfig() ProviderConfig {
+	return ProviderConfig{
+		AliasList:    []string{},
+		Issuer:       "",
+		ClientID:     "",
+		ClientSecret: "",
+		Scopes:       []string{"openid", "profile", "email"},
+		AccessType:   "offline",
+		RedirectURIs: []string{
+			"http://localhost:3000/login-callback",
+			"http://localhost:10001/login-callback",
+			"http://localhost:11110/login-callback",
+		},
+		Prompt: "consent",
+	}
+}
+
+// NewProviderConfigFromString is a function to create the provider config from a string of the format
+// {alias},{provider_url},{client_id},{client_secret},{scopes}
+func NewProviderConfigFromString(configStr string, hasAlias bool) (ProviderConfig, error) {
+	parts := strings.Split(configStr, ",")
+	alias := ""
+	if hasAlias {
+		// If the config string has an alias, we need to remove it from the parts
+		alias = parts[0]
+		parts = parts[1:]
+	}
+	if len(parts) < 2 {
+		if hasAlias {
+			return ProviderConfig{}, fmt.Errorf("invalid provider config string. Expected format <alias>,<issuer>,<client_id> or <alias>,<issuer>,<client_id>,<client_secret> or <alias>,<issuer>,<client_id>,<client_secret>,<scopes>")
+		}
+		return ProviderConfig{}, fmt.Errorf("invalid provider config string. Expected format <issuer>,<client_id> or <issuer>,<client_id>,<client_secret> or <issuer>,<client_id>,<client_secret>,<scopes>")
+	}
+
+	providerConfig := DefaultProviderConfig()
+	providerConfig.AliasList = []string{alias}
+	providerConfig.Issuer = parts[0]
+	providerConfig.ClientID = parts[1]
+
+	if providerConfig.ClientID == "" {
+		return ProviderConfig{}, fmt.Errorf("invalid provider client-ID value got (%s)", providerConfig.ClientID)
+	}
+
+	if len(parts) > 2 {
+		providerConfig.ClientSecret = parts[2]
+	} else {
+		providerConfig.ClientSecret = ""
+	}
+
+	if len(parts) > 3 {
+		providerConfig.Scopes = strings.Split(parts[3], " ")
+	} else {
+		providerConfig.Scopes = []string{"openid", "profile", "email"}
+	}
+
+	if strings.HasPrefix(providerConfig.Issuer, "https://accounts.google.com") {
+		// The Google OP is strange in that it requires a client secret even if this is a public OIDC App.
+		// Despite its name the Google OP client secret is a public value.
+		if providerConfig.ClientSecret == "" {
+			if hasAlias {
+				return ProviderConfig{}, fmt.Errorf("invalid provider argument format. Expected format for google: <alias>,<issuer>,<client_id>,<client_secret>")
+			} else {
+				return ProviderConfig{}, fmt.Errorf("invalid provider argument format. Expected format for google: <issuer>,<client_id>,<client_secret>")
+			}
+		}
+	}
+	return providerConfig, nil
+}
+
+// NewProviderFromConfig is a function to create the provider from the config
+func (p *ProviderConfig) ToProvider(openBrowser bool) (providers.OpenIdProvider, error) {
+
+	if p.Issuer == "" {
+		return nil, fmt.Errorf("invalid provider issuer value got (%s)", p.Issuer)
+	}
+
+	if !strings.HasPrefix(p.Issuer, "https://") {
+		return nil, fmt.Errorf("invalid provider issuer value. Expected issuer to start with 'https://' got (%s)", p.Issuer)
+	}
+
+	if p.ClientID == "" {
+		return nil, fmt.Errorf("invalid provider client-ID value got (%s)", p.ClientID)
+	}
+	var provider providers.OpenIdProvider
+
+	if strings.HasPrefix(p.Issuer, "https://accounts.google.com") {
+		opts := providers.GetDefaultGoogleOpOptions()
+		opts.Issuer = p.Issuer
+		opts.ClientID = p.ClientID
+		opts.ClientSecret = p.ClientSecret
+		opts.GQSign = false
+		opts.Scopes = p.Scopes
+		opts.PromptType = p.Prompt
+		opts.AccessType = p.AccessType
+		opts.RedirectURIs = p.RedirectURIs
+		opts.OpenBrowser = openBrowser
+		provider = providers.NewGoogleOpWithOptions(opts)
+	} else if strings.HasPrefix(p.Issuer, "https://login.microsoftonline.com") {
+		opts := providers.GetDefaultAzureOpOptions()
+		opts.Issuer = p.Issuer
+		opts.ClientID = p.ClientID
+		opts.GQSign = false
+		opts.PromptType = p.Prompt
+		opts.AccessType = p.AccessType
+		opts.RedirectURIs = p.RedirectURIs
+		opts.OpenBrowser = openBrowser
+		provider = providers.NewAzureOpWithOptions(opts)
+	} else if strings.HasPrefix(p.Issuer, "https://gitlab.com") {
+		opts := providers.GetDefaultGitlabOpOptions()
+		opts.Issuer = p.Issuer
+		opts.ClientID = p.ClientID
+		opts.GQSign = false
+		opts.PromptType = p.Prompt
+		opts.AccessType = p.AccessType
+		opts.RedirectURIs = p.RedirectURIs
+		opts.OpenBrowser = openBrowser
+		provider = providers.NewGitlabOpWithOptions(opts)
+	} else if p.Issuer == "https://issuer.hello.coop" {
+		opts := providers.GetDefaultHelloOpOptions()
+		opts.Issuer = p.Issuer
+		opts.ClientID = p.ClientID
+		opts.GQSign = false
+		opts.PromptType = p.Prompt
+		opts.AccessType = p.AccessType
+		opts.RedirectURIs = p.RedirectURIs
+		opts.OpenBrowser = openBrowser
+		provider = providers.NewHelloOpWithOptions(opts)
+	} else {
+		// Generic provider
+		opts := providers.GetDefaultGoogleOpOptions()
+		opts.Issuer = p.Issuer
+		opts.ClientID = p.ClientID
+		opts.ClientSecret = p.ClientSecret
+		opts.PromptType = p.Prompt
+		opts.AccessType = p.AccessType
+		opts.RedirectURIs = p.RedirectURIs
+		opts.GQSign = false
+		opts.OpenBrowser = openBrowser
+		provider = providers.NewGoogleOpWithOptions(opts)
+	}
+
+	return provider, nil
+}
+
+// GetProvidersConfigFromEnv is a function to retrieve the config from the env variables
+// OPKSSH_DEFAULT can be set to an alias
+// OPKSSH_PROVIDERS is a ; separated list of providers of the format <alias>,<issuer>,<client_id>,<client_secret>,<scopes>;<alias>,<issuer>,<client_id>,<client_secret>,<scopes>
+func GetProvidersConfigFromEnv() ([]ProviderConfig, error) {
+	// Get the providers from the env variable
+	providerList, ok := os.LookupEnv(OPKSSH_PROVIDERS_ENVVAR)
+	if !ok || providerList == "" {
+		return nil, nil
+	}
+	if providerConfigList, err := ProvidersConfigListFromStrings(providerList); err != nil {
+		return nil, fmt.Errorf("error getting provider config from env: %w", err)
+	} else {
+		return providerConfigList, nil
+	}
+}
+
+func ProvidersConfigListFromStrings(providerList string) ([]ProviderConfig, error) {
+	providerConfigList := make([]ProviderConfig, 0)
+	for _, providerStr := range strings.Split(providerList, ";") {
+		providerConfig, err := NewProviderConfigFromString(providerStr, true)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing provider config string: %w", err)
+		}
+		providerConfigList = append(providerConfigList, providerConfig)
+	}
+	return providerConfigList, nil
+}
+
+func CreateProvidersMap(providerConfigList []ProviderConfig) (map[string]ProviderConfig, error) {
+	providersConfig := make(map[string]ProviderConfig)
+	for _, providerConfig := range providerConfigList {
+		for _, alias := range providerConfig.AliasList {
+			// If alias already exists, return an error
+			if _, ok := providersConfig[alias]; ok {
+				return nil, fmt.Errorf("duplicate provider alias found: %s", alias)
+			}
+			providersConfig[alias] = providerConfig
+		}
+	}
+	return providersConfig, nil
+}
diff --git a/commands/client-config/providerconfig_test.go b/commands/client-config/providerconfig_test.go
new file mode 100644
index 00000000..fd4dec03
--- /dev/null
+++ b/commands/client-config/providerconfig_test.go
@@ -0,0 +1,204 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestProvidersConfigFromStrings(t *testing.T) {
+	providersString := "google,https://accounts.google.com,206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com,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;" +
+		"microsoft,https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,096ce0a3-5e72-4da8-9c86-12924b294a01;" +
+		"gitlab,https://gitlab.com,8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923;" +
+		"hello,https://issuer.hello.coop,app_xejobTKEsDNSRd5vofKB2iay_2rN"
+
+	providerConfigs, err := ProvidersConfigListFromStrings(providersString)
+	require.NoError(t, err)
+	require.NotNil(t, providerConfigs)
+	require.Equal(t, len(providerConfigs), 4)
+
+	providersStringRepeatsAlias := "google,https://accounts.google.com,1234,4566;" +
+		"fakeOP1,https://fake1.example.com,abcde,,openid email;" +
+		"fakeOP2,https://fake2.example.com,abcde,,openid email;" +
+		"fakeOP1,https://fake3.example.com,xyz"
+
+	providerConfigs, err = ProvidersConfigListFromStrings(providersStringRepeatsAlias)
+	require.NoError(t, err)
+	providerMap, err := CreateProvidersMap(providerConfigs)
+	require.ErrorContains(t, err, "duplicate provider alias found: fakeOP1")
+	require.Nil(t, providerMap)
+
+	providersStringNoClientID := "fakeOP1,https://fake1.example.com,,,"
+	providerConfigs, err = ProvidersConfigListFromStrings(providersStringNoClientID)
+	require.ErrorContains(t, err, "invalid provider client-ID value got ()")
+	require.Nil(t, providerConfigs)
+
+	providersStringInvalidFormat := "fakeOP1,https://fake1.example.com"
+	providerConfigs, err = ProvidersConfigListFromStrings(providersStringInvalidFormat)
+	require.ErrorContains(t, err, "invalid provider config string")
+	require.Nil(t, providerConfigs)
+}
+
+func TestProvidersConfigFromEnv(t *testing.T) {
+
+	tests := []struct {
+		name        string
+		envVar      string
+		envValue    string
+		expectedLen int
+		wantOutput  string
+		wantError   string
+	}{
+		{
+			name:   "Set OPKSSH_PROVIDERS to good value",
+			envVar: "OPKSSH_PROVIDERS",
+			envValue: "google,https://accounts.google.com,1234,4566;" +
+				"fakeOP1,https://fake1.example.com,abcde,,openid email",
+			expectedLen: 2,
+			wantError:   "",
+		},
+		{
+			name:      "Set OPKSSH_PROVIDERS to emptye",
+			envVar:    "OPKSSH_PROVIDERS",
+			envValue:  "",
+			wantError: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_ = os.Setenv(tt.envVar, tt.envValue)
+			defer func(key string) {
+				_ = os.Unsetenv(key)
+			}(tt.envVar)
+
+			providerConfigs, err := GetProvidersConfigFromEnv()
+			if tt.wantError != "" {
+				require.ErrorContains(t, err, tt.wantError)
+				require.Nil(t, providerConfigs)
+			} else {
+				require.NoError(t, err)
+				if tt.expectedLen > 0 {
+					require.Equal(t, tt.expectedLen, len(providerConfigs))
+				} else {
+					// If no providers, this this should be nil
+					require.Nil(t, providerConfigs)
+				}
+			}
+		})
+	}
+}
+
+func TestProviderConfigFromString(t *testing.T) {
+	providerAlias := "op1"
+	providerIssuer := "https://example.com/tokens-1/"
+	providerScopes := "openid profile email"
+	providerArg := providerIssuer + ",client-id1234," + "," + "" + "," + providerScopes
+	providerStr := providerAlias + "," + providerArg
+
+	tests := []struct {
+		name           string
+		configString   string
+		hasAlias       bool
+		expectedIssuer string
+		wantError1     bool
+		errorString1   string
+		wantError2     bool
+		errorString2   string
+	}{
+		{
+			name:           "Good path with test providerStr",
+			configString:   providerStr,
+			hasAlias:       true,
+			expectedIssuer: providerIssuer,
+		},
+		{
+			name:           "Good path with test authentik OP",
+			configString:   "authentik,https://authentik.io/application/o/opkssh/,client_id,,openid profile email",
+			hasAlias:       true,
+			expectedIssuer: "https://authentik.io/application/o/opkssh/",
+		},
+		{
+			name:           "Good path with test Google OP",
+			configString:   "https://accounts.google.com,206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com,NOT-aREAL_3a_GOOGLE-CLIENTSECRET",
+			hasAlias:       false,
+			expectedIssuer: "https://accounts.google.com",
+		},
+		{
+			name:           "Good path with test microsoft OP",
+			configString:   "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,096ce0a3-5e72-4da8-9c86-12924b294a01",
+			hasAlias:       false,
+			expectedIssuer: "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
+		},
+		{
+			name:           "Good path with test microsoft OP",
+			configString:   "https://gitlab.com,8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923",
+			hasAlias:       false,
+			expectedIssuer: "https://gitlab.com",
+		},
+		{
+			name:           "Good path with test hello OP",
+			configString:   "https://issuer.hello.coop,client-id,,openid email",
+			hasAlias:       false,
+			expectedIssuer: "https://issuer.hello.coop",
+		},
+		{
+			name:           "Alias set but no alias expected",
+			configString:   "exampleOp,https://token.example.com/,client_id,,openid profile email,",
+			hasAlias:       false,
+			expectedIssuer: "https://token.example.com/",
+			wantError2:     true,
+			errorString2:   "invalid provider issuer value. Expected issuer to start with 'https://'",
+		},
+		{
+			name:           "No alias set but alias expected",
+			configString:   "https://token.example.com/,client_id,,openid profile email,",
+			hasAlias:       true,
+			expectedIssuer: "https://token.example.com/",
+			wantError1:     true,
+			errorString1:   "invalid provider client-ID value got ()",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			providerConfig, err := NewProviderConfigFromString(tt.configString, tt.hasAlias)
+			if tt.wantError1 {
+				require.Error(t, err, "Expected error but got none")
+				if tt.errorString1 != "" {
+					require.ErrorContains(t, err, tt.errorString1, "Got a wrong error message")
+				}
+
+			} else {
+				require.NoError(t, err)
+				provider, err := providerConfig.ToProvider(false)
+				if tt.wantError2 {
+					require.Error(t, err, "Expected error but got none")
+					if tt.errorString2 != "" {
+						require.ErrorContains(t, err, tt.errorString2, "Got a wrong error message")
+					}
+				} else {
+					require.NoError(t, err)
+					require.Equal(t, tt.expectedIssuer, provider.Issuer())
+				}
+			}
+		})
+	}
+}
diff --git a/commands/login.go b/commands/login.go
index 247794d8..5fff743a 100644
--- a/commands/login.go
+++ b/commands/login.go
@@ -40,23 +40,18 @@ import (
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/providers"
 	"github.com/openpubkey/openpubkey/util"
+	config "github.com/openpubkey/opkssh/commands/client-config"
 	"github.com/openpubkey/opkssh/sshcert"
 	"github.com/spf13/afero"
 	"golang.org/x/crypto/ssh"
 )
 
-const WEBCHOOSER_ALIAS = "WEBCHOOSER"
-const OPKSSH_DEFAULT_ENVVAR = "OPKSSH_DEFAULT"
-const OPKSSH_PROVIDERS_ENVVAR = "OPKSSH_PROVIDERS"
-
-var DefaultProviderList = "google,https://accounts.google.com,206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com,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;" +
-	"microsoft,https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,096ce0a3-5e72-4da8-9c86-12924b294a01;" +
-	"gitlab,https://gitlab.com,8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923;" +
-	"hello,https://issuer.hello.coop,app_xejobTKEsDNSRd5vofKB2iay_2rN"
-
 type LoginCmd struct {
+	// Inputs
 	Fs                    afero.Fs
 	autoRefreshArg        bool
+	configPathArg         string
+	createConfigArg       bool
 	logDirArg             string
 	disableBrowserOpenArg bool
 	printIdTokenArg       bool
@@ -65,19 +60,26 @@ type LoginCmd struct {
 	providerAliasArg      string
 	verbosity             int                       // Default verbosity is 0, 1 is verbose, 2 is debug
 	overrideProvider      *providers.OpenIdProvider // Used in tests to override the provider to inject a mock provider
-	pkt                   *pktoken.PKToken
-	signer                crypto.Signer
-	alg                   jwa.SignatureAlgorithm
-	client                *client.OpkClient
-	principals            []string
+
+	// State
+	config *config.ClientConfig
+
+	// Outputs
+	pkt        *pktoken.PKToken
+	signer     crypto.Signer
+	alg        jwa.SignatureAlgorithm
+	client     *client.OpkClient
+	principals []string
 }
 
-func NewLogin(autoRefreshArg bool, logDirArg string, disableBrowserOpenArg bool, printIdTokenArg bool,
+func NewLogin(autoRefreshArg bool, configPathArg string, createConfigArg bool, logDirArg string, disableBrowserOpenArg bool, printIdTokenArg bool,
 	providerArg string, keyPathArg string, providerAliasArg string) *LoginCmd {
 
 	return &LoginCmd{
 		Fs:                    afero.NewOsFs(),
 		autoRefreshArg:        autoRefreshArg,
+		configPathArg:         configPathArg,
+		createConfigArg:       createConfigArg,
 		logDirArg:             logDirArg,
 		disableBrowserOpenArg: disableBrowserOpenArg,
 		printIdTokenArg:       printIdTokenArg,
@@ -106,6 +108,50 @@ func (l *LoginCmd) Run(ctx context.Context) error {
 		log.Printf("DEBUG: running login command with args: %+v", *l)
 	}
 
+	if l.configPathArg == "" {
+		dir, dirErr := os.UserHomeDir()
+		if dirErr != nil {
+			return fmt.Errorf("failed to get user config dir: %w", dirErr)
+		}
+		l.configPathArg = filepath.Join(dir, ".opk", "config.yml")
+	}
+
+	var configBytes []byte
+	if _, err := l.Fs.Stat(l.configPathArg); err == nil {
+		if l.createConfigArg {
+			log.Printf("--create-config=true but config file already exists at %s", l.configPathArg)
+		}
+
+		// Load the file from the filesystem
+		afs := &afero.Afero{Fs: l.Fs}
+		configBytes, err = afs.ReadFile(l.configPathArg)
+		if err != nil {
+			return fmt.Errorf("failed to read config file: %w", err)
+		}
+		l.config, err = config.NewClientConfig(configBytes)
+		if err != nil {
+			return fmt.Errorf("failed to parse config file: %w", err)
+		}
+	} else {
+		if l.createConfigArg {
+			afs := &afero.Afero{Fs: l.Fs}
+			if err := l.Fs.MkdirAll(filepath.Dir(l.configPathArg), 0755); err != nil {
+				return fmt.Errorf("failed to create config directory: %w", err)
+			}
+			if err := afs.WriteFile(l.configPathArg, config.DefaultClientConfig, 0644); err != nil {
+				return fmt.Errorf("failed to write default config file: %w", err)
+			}
+			log.Printf("created client config file at %s", l.configPathArg)
+			return nil
+		} else {
+			log.Printf("failed to find client config file to generate a default config, run `opkssh login --create-config` to create a default config file")
+		}
+		l.config, err = config.NewClientConfig(config.DefaultClientConfig)
+		if err != nil {
+			return fmt.Errorf("failed to parse default config file: %w", err)
+		}
+	}
+
 	var provider providers.OpenIdProvider
 	if l.overrideProvider != nil {
 		provider = *l.overrideProvider
@@ -148,70 +194,80 @@ func (l *LoginCmd) Run(ctx context.Context) error {
 func (l *LoginCmd) determineProvider() (providers.OpenIdProvider, *choosers.WebChooser, error) {
 	openBrowser := !l.disableBrowserOpenArg
 
-	// If the user has supplied commandline arguments for the provider, use those instead of the web chooser
+	var defaultProviderAlias string
+	var providerConfigs []config.ProviderConfig
 	var provider providers.OpenIdProvider
+	var err error
+
+	// If the user has supplied commandline arguments for the provider, short circuit and use providerArg
 	if l.providerArg != "" {
-		config, err := NewProviderConfigFromString(l.providerArg, false)
+		providerConfig, err := config.NewProviderConfigFromString(l.providerArg, false)
 		if err != nil {
 			return nil, nil, fmt.Errorf("error parsing provider argument: %w", err)
 		}
 
-		provider, err = NewProviderFromConfig(config, openBrowser)
-
-		if err != nil {
+		if provider, err = providerConfig.ToProvider(openBrowser); err != nil {
 			return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
+		} else {
+			return provider, nil, nil
 		}
+	}
+
+	// Set the default provider from the env variable if specified
+	defaultProviderEnv, _ := os.LookupEnv(config.OPKSSH_DEFAULT_ENVVAR)
+	providerConfigsEnv, err := config.GetProvidersConfigFromEnv()
+	if err != nil {
+		return nil, nil, fmt.Errorf("error getting provider config from env: %w", err)
+	}
+
+	if l.providerAliasArg != "" {
+		defaultProviderAlias = l.providerAliasArg
+	} else if defaultProviderEnv != "" {
+		defaultProviderAlias = defaultProviderEnv
+	} else if l.config.DefaultProvider != "" {
+		defaultProviderAlias = l.config.DefaultProvider
 	} else {
-		var err error
+		defaultProviderAlias = config.WEBCHOOSER_ALIAS
+	}
 
-		// Get the default provider from the env variable
-		defaultProvider, ok := os.LookupEnv(OPKSSH_DEFAULT_ENVVAR)
-		if !ok || defaultProvider == "" {
-			defaultProvider = WEBCHOOSER_ALIAS
-		}
-		providerConfigs, err := GetProvidersConfigFromEnv()
+	if providerConfigsEnv != nil {
+		providerConfigs = providerConfigsEnv
+	} else if len(l.config.Providers) > 0 {
+		providerConfigs = l.config.Providers
+	} else {
+		return nil, nil, fmt.Errorf("no providers specified")
+	}
 
+	if strings.ToUpper(defaultProviderAlias) != config.WEBCHOOSER_ALIAS {
+		providerMap, err := config.CreateProvidersMap(providerConfigs)
 		if err != nil {
-			return nil, nil, fmt.Errorf("error getting provider config from env: %w", err)
+			return nil, nil, fmt.Errorf("error creating provider map: %w", err)
 		}
-
-		if l.providerAliasArg != "" && l.providerAliasArg != WEBCHOOSER_ALIAS {
-			config, ok := providerConfigs[l.providerAliasArg]
-			if !ok {
-				return nil, nil, fmt.Errorf("error getting provider config for alias %s", l.providerAliasArg)
-			}
-			provider, err = NewProviderFromConfig(config, openBrowser)
+		providerConfig, ok := providerMap[defaultProviderAlias]
+		if !ok {
+			return nil, nil, fmt.Errorf("error getting provider config for alias %s", defaultProviderAlias)
+		}
+		provider, err = providerConfig.ToProvider(openBrowser)
+		if err != nil {
+			return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
+		}
+		return provider, nil, nil
+	} else {
+		// If the default provider is WEBCHOOSER, we need to create a chooser and return it
+		var providerList []providers.BrowserOpenIdProvider
+		for _, providerConfig := range providerConfigs {
+			op, err := providerConfig.ToProvider(openBrowser)
 			if err != nil {
 				return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
 			}
-		} else {
-			if defaultProvider != WEBCHOOSER_ALIAS {
-				config, ok := providerConfigs[defaultProvider]
-				if !ok {
-					return nil, nil, fmt.Errorf("error getting provider config for alias %s", defaultProvider)
-				}
-				provider, err = NewProviderFromConfig(config, openBrowser)
-				if err != nil {
-					return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
-				}
-			} else {
-				var providerList []providers.BrowserOpenIdProvider
-				for _, config := range providerConfigs {
-					op, err := NewProviderFromConfig(config, openBrowser)
-					if err != nil {
-						return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
-					}
-					providerList = append(providerList, op.(providers.BrowserOpenIdProvider))
-				}
-
-				chooser := choosers.NewWebChooser(
-					providerList, openBrowser,
-				)
-				return nil, chooser, nil
-			}
+			providerList = append(providerList, op.(providers.BrowserOpenIdProvider))
 		}
+
+		chooser := choosers.NewWebChooser(
+			providerList, openBrowser,
+		)
+		return nil, chooser, nil
 	}
-	return provider, nil, nil
 }
 
 func (l *LoginCmd) login(ctx context.Context, provider providers.OpenIdProvider, printIdToken bool, seckeyPath string) (*LoginCmd, error) {
@@ -473,159 +529,6 @@ func IdentityString(pkt pktoken.PKToken) (string, error) {
 	}
 }
 
-// ProviderConfig is the representation of the provider config:
-// {alias},{provider_url},{client_id},{client_secret},{scopes}
-// client secret is optional, as well as scopes, if not provided, the default for secret is an empty string, for scopes is "openid profile email"
-type ProviderConfig struct {
-	Alias        string
-	Issuer       string
-	ClientID     string
-	ClientSecret string
-	Scopes       []string
-}
-
-// NewProviderConfigFromString is a function to create the provider config from a string of the format
-// {alias},{provider_url},{client_id},{client_secret},{scopes}
-func NewProviderConfigFromString(configStr string, hasAlias bool) (ProviderConfig, error) {
-	parts := strings.Split(configStr, ",")
-	alias := ""
-	if hasAlias {
-		// If the config string has an alias, we need to remove it from the parts
-		alias = parts[0]
-		parts = parts[1:]
-	}
-	if len(parts) < 2 {
-		if hasAlias {
-			return ProviderConfig{}, fmt.Errorf("invalid provider config string. Expected format <alias>,<issuer>,<client_id> or <alias>,<issuer>,<client_id>,<client_secret> or <alias>,<issuer>,<client_id>,<client_secret>,<scopes>")
-		}
-		return ProviderConfig{}, fmt.Errorf("invalid provider config string. Expected format <issuer>,<client_id> or <issuer>,<client_id>,<client_secret> or <issuer>,<client_id>,<client_secret>,<scopes>")
-	}
-
-	providerConfig := ProviderConfig{
-		Alias:    alias,
-		Issuer:   parts[0],
-		ClientID: parts[1],
-	}
-
-	if providerConfig.ClientID == "" {
-		return ProviderConfig{}, fmt.Errorf("invalid provider client-ID value got (%s)", providerConfig.ClientID)
-	}
-
-	if len(parts) > 2 {
-		providerConfig.ClientSecret = parts[2]
-	} else {
-		providerConfig.ClientSecret = ""
-	}
-
-	if len(parts) > 3 {
-		providerConfig.Scopes = strings.Split(parts[3], " ")
-	} else {
-		providerConfig.Scopes = []string{"openid", "profile", "email"}
-	}
-
-	if strings.HasPrefix(providerConfig.Issuer, "https://accounts.google.com") {
-		// The Google OP is strange in that it requires a client secret even if this is a public OIDC App.
-		// Despite its name the Google OP client secret is a public value.
-		if providerConfig.ClientSecret == "" {
-			if hasAlias {
-				return ProviderConfig{}, fmt.Errorf("invalid provider argument format. Expected format for google: <alias>,<issuer>,<client_id>,<client_secret>")
-			} else {
-				return ProviderConfig{}, fmt.Errorf("invalid provider argument format. Expected format for google: <issuer>,<client_id>,<client_secret>")
-			}
-
-		}
-	}
-	return providerConfig, nil
-}
-
-// NewProviderFromConfig is a function to create the provider from the config
-func NewProviderFromConfig(config ProviderConfig, openBrowser bool) (providers.OpenIdProvider, error) {
-
-	if config.Issuer == "" {
-		return nil, fmt.Errorf("invalid provider issuer value got (%s)", config.Issuer)
-	}
-
-	if !strings.HasPrefix(config.Issuer, "https://") {
-		return nil, fmt.Errorf("invalid provider issuer value. Expected issuer to start with 'https://' got (%s)", config.Issuer)
-	}
-
-	if config.ClientID == "" {
-		return nil, fmt.Errorf("invalid provider client-ID value got (%s)", config.ClientID)
-	}
-	var provider providers.OpenIdProvider
-
-	if strings.HasPrefix(config.Issuer, "https://accounts.google.com") {
-		opts := providers.GetDefaultGoogleOpOptions()
-		opts.Issuer = config.Issuer
-		opts.ClientID = config.ClientID
-		opts.ClientSecret = config.ClientSecret
-		opts.GQSign = false
-		opts.OpenBrowser = openBrowser
-		provider = providers.NewGoogleOpWithOptions(opts)
-	} else if strings.HasPrefix(config.Issuer, "https://login.microsoftonline.com") {
-		opts := providers.GetDefaultAzureOpOptions()
-		opts.Issuer = config.Issuer
-		opts.ClientID = config.ClientID
-		opts.GQSign = false
-		opts.OpenBrowser = openBrowser
-		provider = providers.NewAzureOpWithOptions(opts)
-	} else if strings.HasPrefix(config.Issuer, "https://gitlab.com") {
-		opts := providers.GetDefaultGitlabOpOptions()
-		opts.Issuer = config.Issuer
-		opts.ClientID = config.ClientID
-		opts.GQSign = false
-		opts.OpenBrowser = openBrowser
-		provider = providers.NewGitlabOpWithOptions(opts)
-	} else if config.Issuer == "https://issuer.hello.coop" {
-		opts := providers.GetDefaultHelloOpOptions()
-		opts.Issuer = config.Issuer
-		opts.ClientID = config.ClientID
-		opts.GQSign = false
-		opts.OpenBrowser = openBrowser
-		provider = providers.NewHelloOpWithOptions(opts)
-	} else {
-		// Generic provider - Need signing, no encryption
-		opts := providers.GetDefaultGoogleOpOptions()
-		opts.Issuer = config.Issuer
-		opts.ClientID = config.ClientID
-		opts.GQSign = false
-		opts.ClientSecret = config.ClientSecret
-		opts.Scopes = config.Scopes
-		opts.OpenBrowser = openBrowser
-
-		provider = providers.NewGoogleOpWithOptions(opts)
-	}
-
-	return provider, nil
-}
-
-// GetProvidersConfigFromEnv is a function to retrieve the config from the env variables
-// OPKSSH_DEFAULT can be set to an alias
-// OPKSSH_PROVIDERS is a ; separated list of providers of the format <alias>,<issuer>,<client_id>,<client_secret>,<scopes>;<alias>,<issuer>,<client_id>,<client_secret>,<scopes>
-func GetProvidersConfigFromEnv() (map[string]ProviderConfig, error) {
-	providersConfig := make(map[string]ProviderConfig)
-
-	// Get the providers from the env variable
-	providerList, ok := os.LookupEnv(OPKSSH_PROVIDERS_ENVVAR)
-	if !ok {
-		providerList = DefaultProviderList
-	}
-
-	for _, providerStr := range strings.Split(providerList, ";") {
-		config, err := NewProviderConfigFromString(providerStr, true)
-		if err != nil {
-			return nil, fmt.Errorf("error parsing provider config string: %w", err)
-		}
-		// If alias already exists, return an error
-		if _, ok := providersConfig[config.Alias]; ok {
-			return nil, fmt.Errorf("duplicate provider alias found: %s", config.Alias)
-		}
-		providersConfig[config.Alias] = config
-	}
-
-	return providersConfig, nil
-}
-
 func PrettyIdToken(pkt pktoken.PKToken) (string, error) {
 	idt, err := oidc.NewJwt(pkt.OpToken)
 	if err != nil {
diff --git a/commands/login_test.go b/commands/login_test.go
index c2a4ebf8..77d4f959 100644
--- a/commands/login_test.go
+++ b/commands/login_test.go
@@ -29,6 +29,7 @@ import (
 	"github.com/openpubkey/openpubkey/pktoken"
 	"github.com/openpubkey/openpubkey/providers"
 	"github.com/openpubkey/openpubkey/util"
+	config "github.com/openpubkey/opkssh/commands/client-config"
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/crypto/ssh"
@@ -73,14 +74,6 @@ func Mocks(t *testing.T) (*pktoken.PKToken, crypto.Signer, providers.OpenIdProvi
 	return pkt, signer, op
 }
 
-func ProviderFromString(t *testing.T, providerString string) providers.OpenIdProvider {
-	providerConfig3, err := NewProviderConfigFromString(providerStr3, true)
-	require.NoError(t, err)
-	provider3, err := NewProviderFromConfig(providerConfig3, false)
-	require.NoError(t, err)
-	return provider3
-}
-
 func TestLoginCmd(t *testing.T) {
 	_, _, mockOp := Mocks(t)
 
@@ -158,7 +151,7 @@ func TestDetermineProvider(t *testing.T) {
 			wantIssuer:    "",
 			wantError:     false,
 			errorString:   "",
-			wantChooser:   `[{"Scopes":[""],"RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000}]`,
+			wantChooser:   `[{"ClientSecret":"","Scopes":["openid profile email"],"PromptType":"consent","AccessType":"offline","RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000}]`,
 		},
 		{
 			name:          "Good path with env vars many providers and no default",
@@ -167,7 +160,7 @@ func TestDetermineProvider(t *testing.T) {
 			providerAlias: "",
 			wantIssuer:    "",
 			wantError:     false,
-			wantChooser:   `[{"Scopes":[""],"RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000},{"Scopes":[""],"RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000},{"Scopes":[""],"RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000}]`,
+			wantChooser:   `[{"ClientSecret":"","Scopes":["openid profile email"],"PromptType":"consent","AccessType":"offline","RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000},{"ClientSecret":"","Scopes":["openid profile email"],"PromptType":"consent","AccessType":"offline","RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000},{"ClientSecret":"","Scopes":["openid profile email"],"PromptType":"consent","AccessType":"offline","RedirectURIs":["http://localhost:3000/login-callback","http://localhost:10001/login-callback","http://localhost:11110/login-callback"],"GQSign":false,"OpenBrowser":false,"HttpClient":null,"IssuedAtOffset":60000000000}]`,
 		},
 		{
 			name:          "Good path with env vars many providers and providerAlias",
@@ -197,11 +190,15 @@ func TestDetermineProvider(t *testing.T) {
 				}(k)
 			}
 
+			defaultConfig, err := config.NewClientConfig(config.DefaultClientConfig)
+			require.NoError(t, err, "Failed to get default client config")
+
 			loginCmd := LoginCmd{
 				disableBrowserOpenArg: true,
 				providerArg:           tt.providerArg,
 				providerAliasArg:      tt.providerAlias,
 				printIdTokenArg:       true,
+				config:                defaultConfig,
 			}
 
 			provider, chooser, err := loginCmd.determineProvider()
@@ -236,101 +233,10 @@ func TestDetermineProvider(t *testing.T) {
 	}
 }
 
-func TestProviderConfigFromString(t *testing.T) {
-
-	tests := []struct {
-		name           string
-		configString   string
-		hasAlias       bool
-		expectedIssuer string
-		wantError1     bool
-		errorString1   string
-		wantError2     bool
-		errorString2   string
-	}{
-		{
-			name:           "Good path with test providerStr3",
-			configString:   providerStr3,
-			hasAlias:       true,
-			expectedIssuer: providerIssuer3,
-		},
-		{
-			name:           "Good path with test authentik OP",
-			configString:   "authentik,https://authentik.io/application/o/opkssh/,client_id,,openid profile email",
-			hasAlias:       true,
-			expectedIssuer: "https://authentik.io/application/o/opkssh/",
-		},
-		{
-			name:           "Good path with test Google OP",
-			configString:   "https://accounts.google.com,206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com,NOT-aREAL_3a_GOOGLE-CLIENTSECRET",
-			hasAlias:       false,
-			expectedIssuer: "https://accounts.google.com",
-		},
-		{
-			name:           "Good path with test microsoft OP",
-			configString:   "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,096ce0a3-5e72-4da8-9c86-12924b294a01",
-			hasAlias:       false,
-			expectedIssuer: "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0",
-		},
-		{
-			name:           "Good path with test microsoft OP",
-			configString:   "https://gitlab.com,8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923",
-			hasAlias:       false,
-			expectedIssuer: "https://gitlab.com",
-		},
-		{
-			name:           "Good path with test hello OP",
-			configString:   "https://issuer.hello.coop,client-id,,openid email",
-			hasAlias:       false,
-			expectedIssuer: "https://issuer.hello.coop",
-		},
-		{
-			name:           "Alias set but no alias expected",
-			configString:   "exampleOp,https://token.example.com/,client_id,,openid profile email,",
-			hasAlias:       false,
-			expectedIssuer: "https://token.example.com/",
-			wantError2:     true,
-			errorString2:   "invalid provider issuer value. Expected issuer to start with 'https://'",
-		},
-		{
-			name:           "No alias set but alias expected",
-			configString:   "https://token.example.com/,client_id,,openid profile email,",
-			hasAlias:       true,
-			expectedIssuer: "https://token.example.com/",
-			wantError1:     true,
-			errorString1:   "invalid provider client-ID value got ()",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			providerConfig, err := NewProviderConfigFromString(tt.configString, tt.hasAlias)
-			if tt.wantError1 {
-				require.Error(t, err, "Expected error but got none")
-				if tt.errorString1 != "" {
-					require.ErrorContains(t, err, tt.errorString1, "Got a wrong error message")
-				}
-
-			} else {
-				require.NoError(t, err)
-				provider, err := NewProviderFromConfig(providerConfig, false)
-				if tt.wantError2 {
-					require.Error(t, err, "Expected error but got none")
-					if tt.errorString2 != "" {
-						require.ErrorContains(t, err, tt.errorString2, "Got a wrong error message")
-					}
-				} else {
-					require.NoError(t, err)
-					require.Equal(t, tt.expectedIssuer, provider.Issuer())
-				}
-			}
-		})
-	}
-
-}
-
 func TestNewLogin(t *testing.T) {
 	autoRefresh := false
+	configPathArg := filepath.Join("..", "default-client-config.yml")
+	createConfig := false
 	logDir := "./testdata"
 	disableBrowserOpenArg := true
 	printIdTokenArg := false
@@ -338,7 +244,8 @@ func TestNewLogin(t *testing.T) {
 	keyPathArg := ""
 	providerAlias := ""
 
-	loginCmd := NewLogin(autoRefresh, logDir, disableBrowserOpenArg, printIdTokenArg, providerArg, keyPathArg, providerAlias)
+	loginCmd := NewLogin(autoRefresh, configPathArg, createConfig, logDir,
+		disableBrowserOpenArg, printIdTokenArg, providerArg, keyPathArg, providerAlias)
 	require.NotNil(t, loginCmd)
 }
 
diff --git a/go.mod b/go.mod
index 5c0fb463..9fa7466d 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/jeremija/gosubmit v0.2.7
 	github.com/lestrrat-go/jwx/v2 v2.0.21
 	github.com/melbahja/goph v1.4.0
-	github.com/openpubkey/openpubkey v0.10.0
+	github.com/openpubkey/openpubkey v0.12.0
 	github.com/spf13/cobra v1.9.1
 	github.com/stretchr/testify v1.10.0
 	github.com/testcontainers/testcontainers-go v0.35.0
@@ -92,5 +92,5 @@ require (
 	golang.org/x/oauth2 v0.25.0 // indirect
 	golang.org/x/sys v0.31.0 // indirect
 	golang.org/x/text v0.23.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
+	gopkg.in/yaml.v3 v3.0.1
 )
diff --git a/go.sum b/go.sum
index e3e3018d..e2372a53 100644
--- a/go.sum
+++ b/go.sum
@@ -125,8 +125,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
 github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
-github.com/openpubkey/openpubkey v0.10.0 h1:gkQkhN0qJpW2QZY4wL6LsxNTkMdT5tZT8JxVgyfL8sA=
-github.com/openpubkey/openpubkey v0.10.0/go.mod h1:Y/dYOw3jbBJcuWQ0j7J8FSvZbtWiu56CcAK6QIsMJoo=
+github.com/openpubkey/openpubkey v0.12.0 h1:fo93VE+wCWIBnQnYdCSSzh+yJR2ff8bxFMuC6/+oqg0=
+github.com/openpubkey/openpubkey v0.12.0/go.mod h1:TnLOp59RU6DoaBSI5H4ssh60xigm/nVXqg+2pO8btDg=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
diff --git a/main.go b/main.go
index db5cf83f..5b7d33aa 100644
--- a/main.go
+++ b/main.go
@@ -116,6 +116,8 @@ Arguments:
 	rootCmd.AddCommand(addCmd)
 
 	var autoRefreshArg bool
+	var configPathArg string
+	var createConfigArg bool
 	var logDirArg string
 	var providerArg string
 	var disableBrowserOpenArg bool
@@ -151,7 +153,7 @@ Arguments:
 				providerAliasArg = args[0]
 			}
 
-			login := commands.NewLogin(autoRefreshArg, logDirArg, disableBrowserOpenArg, printIdTokenArg, providerArg, keyPathArg, providerAliasArg)
+			login := commands.NewLogin(autoRefreshArg, configPathArg, createConfigArg, logDirArg, disableBrowserOpenArg, printIdTokenArg, providerArg, keyPathArg, providerAliasArg)
 			if err := login.Run(ctx); err != nil {
 				log.Println("Error executing login command:", err)
 				return err
@@ -163,6 +165,8 @@ Arguments:
 
 	// Define flags for login.
 	loginCmd.Flags().BoolVar(&autoRefreshArg, "auto-refresh", false, "Automatically refresh PK token after login")
+	loginCmd.Flags().StringVar(&configPathArg, "config-path", "", "Path to the client config file. Default: ~/.opk/config.yml on linux and %APPDATA%\\.opk\\config.yml on windows.")
+	loginCmd.Flags().BoolVar(&createConfigArg, "create-config", false, "Creates a client config file if it does not exist")
 	loginCmd.Flags().StringVar(&logDirArg, "log-dir", "", "Directory to write output logs")
 	loginCmd.Flags().BoolVar(&disableBrowserOpenArg, "disable-browser-open", false, "Set this flag to disable opening the browser. Useful for choosing the browser you want to use.")
 	loginCmd.Flags().BoolVar(&printIdTokenArg, "print-id-token", false, "Set this flag to print out the contents of the id_token. Useful for inspecting claims.")
diff --git a/main_test.go b/main_test.go
index ee5ed446..2b487526 100644
--- a/main_test.go
+++ b/main_test.go
@@ -292,7 +292,7 @@ func TestWithEnvVars(t *testing.T) {
 			envVar:     "OPKSSH_PROVIDERS",
 			envValue:   "alias1,https://accounts.google.com,client_id,client_secret,scope1;alias1,https://accounts.google.com,client_id,client_secret,scope2",
 			args:       []string{"opkssh", "login"},
-			wantOutput: "error getting provider config from env: duplicate provider alias found: alias1",
+			wantOutput: "provider in web chooser found with duplicate issuer",
 			wantExit:   1,
 		},
 		{
@@ -303,7 +303,6 @@ func TestWithEnvVars(t *testing.T) {
 			wantOutput: "error creating provider from config: invalid provider issuer value. Expected issuer to start with 'https://' got (http://insecure.badprovider.com)",
 			wantExit:   1,
 		},
-
 		{
 			name:       "Set OPKSSH_PROVIDERS with good provider but asking for wrong alias",
 			envVar:     "OPKSSH_PROVIDERS",


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a YAML-based client configuration mechanism and integrates it into the login flow while introducing flags to optionally create the default config file automatically.  
- Introduces new flags (config-path and create-config) and updates login command behavior to load or create a config file.  
- Refactors provider configuration parsing, loading defaults, and error handling for duplicate/provider alias issues.  
- Adds comprehensive tests for client configuration and provider config parsing.

### Reviewed Changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                            | Description                                                             |
| ----------------------------------------------- | ----------------------------------------------------------------------- |
| main_test.go                                    | Updated expected error messages in output diff.                         |
| main.go                                         | Introduced new flags for config path and creation of a default config.    |
| commands/login.go                               | Updated login flow to load or create a YAML client config file.           |
| commands/login_test.go                          | Updated tests to use the new config parameters and updated chooser output.|
| commands/client-config/*                        | New implementation and tests for provider config parsing and client config.|
</details>


<details>
<summary>Files not reviewed (1)</summary>

* **go.mod**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T01:00:56Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a YAML client configuration mechanism for opkssh and integrates it into the login command. Key changes include:
- Adding flags for specifying the config path and auto-creating the config file.
- Implementing functions to parse, validate, and load the client config file as well as provider configurations.
- Updating tests and documentation to reflect the new config file usage.

### Reviewed Changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

| File                                             | Description                                                  |
| ------------------------------------------------ | ------------------------------------------------------------ |
| main.go, commands/login.go                       | Updated login command to support config file path and creation via new flags. |
| commands/client-config/*                         | New code to parse and validate YAML config and provider configurations. |
| main_test.go, login_test.go                      | Updated tests to match new configuration-based provider initialization. |
| README.md                                        | Updated documentation to include instructions on using the new client config file. |

<details>
<summary>Files not reviewed (1)</summary>

* **go.mod**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**README.md:127**
* The documentation contains a typo. It should read 'can be configured' instead of 'can configured'.
```
This alias to provider mapping can configured using the OPKSSH_PROVIDERS environment variables.
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T16:38:50Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T16:45:01Z

Review comments:
diff_hunk: @@ -106,6 +108,50 @@ func (l *LoginCmd) Run(ctx context.Context) error {
 		log.Printf("DEBUG: running login command with args: %+v", *l)
 	}
 
+	if l.configPathArg == "" {
+		dir, dirErr := os.UserConfigDir()
+		if dirErr != nil {
+			return fmt.Errorf("failed to get user config dir: %w", dirErr)
+		}
+		l.configPathArg = filepath.Join(dir, ".opk", "config.yml")
+	}
+
+	var configBytes []byte
+	if _, err := l.Fs.Stat(l.configPathArg); err == nil {
+		if l.createConfigArg {
+			log.Printf("--create-config=true but config file already exists at %s", l.configPathArg)
+		}
+
+		// Load the file from the filesystem
+		afs := &afero.Afero{Fs: l.Fs}
+		configBytes, err = afs.ReadFile(l.configPathArg)
+		if err != nil {
+			return fmt.Errorf("failed to read config file: %w", err)
+		}
+		l.config, err = config.NewClientConfig(configBytes)
+		if err != nil {
+			return fmt.Errorf("failed to parse config file: %w", err)
+		}
+	} else {
+		if l.createConfigArg {
+			afs := &afero.Afero{Fs: l.Fs}
+			if err := l.Fs.MkdirAll(filepath.Dir(l.configPathArg), 0644); err != nil {
path: commands/login.go
user: login: Copilot
body: The directory creation mode is set to 0644, which does not grant execute permission; consider using 0755 (or a similar mode) for directories to ensure proper traversal.
```suggestion
			if err := l.Fs.MkdirAll(filepath.Dir(l.configPathArg), 0755); err != nil {
```
created_at: 2025-04-21T01:00:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,266 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/openpubkey/openpubkey/providers"
+	"gopkg.in/yaml.v3"
+)
+
+const WEBCHOOSER_ALIAS = "WEBCHOOSER"
+const OPKSSH_DEFAULT_ENVVAR = "OPKSSH_DEFAULT"
+const OPKSSH_PROVIDERS_ENVVAR = "OPKSSH_PROVIDERS"
+
+type ProviderConfig struct {
+	AliasList    []string `yaml:"alias"`
+	Issuer       string   `yaml:"issuer"`
+	ClientID     string   `yaml:"client_id"`
+	ClientSecret string   `yaml:"client_secret,omitempty"`
+	Scopes       []string `yaml:"scopes"`
+	AccessType   string   `yaml:"access_type,omitempty"`
+	Prompt       string   `yaml:"prompt,omitempty"`
+	RedirectURIs []string `yaml:"redirect_uris"`
+}
+
+func (p *ProviderConfig) UnmarshalYAML(value *yaml.Node) error {
+	var tmp struct {
+		AliasList    string   `yaml:"alias"`
+		Issuer       string   `yaml:"issuer"`
+		ClientID     string   `yaml:"client_id"`
+		ClientSecret string   `yaml:"client_secret"`
+		Scopes       string   `yaml:"scopes"`
+		AccessType   string   `yaml:"access_type"`
+		Prompt       string   `yaml:"prompt"`
+		RedirectURIs []string `yaml:"redirect_uris"`
+	}
+
+	// Set default values
+	tmp.Scopes = "openid email"
path: commands/client-config/providerconfig.go
user: login: Copilot
body: The default scopes string set during YAML unmarshalling ('openid email') is missing 'profile', which is inconsistent with other defaults (e.g., 'openid profile email'). Consider updating it to include 'profile'.
```suggestion
	tmp.Scopes = "openid profile email"
```
created_at: 2025-04-21T16:38:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,266 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package config
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/openpubkey/openpubkey/providers"
+	"gopkg.in/yaml.v3"
+)
+
+const WEBCHOOSER_ALIAS = "WEBCHOOSER"
+const OPKSSH_DEFAULT_ENVVAR = "OPKSSH_DEFAULT"
+const OPKSSH_PROVIDERS_ENVVAR = "OPKSSH_PROVIDERS"
+
+type ProviderConfig struct {
+	AliasList    []string `yaml:"alias"`
+	Issuer       string   `yaml:"issuer"`
+	ClientID     string   `yaml:"client_id"`
+	ClientSecret string   `yaml:"client_secret,omitempty"`
+	Scopes       []string `yaml:"scopes"`
+	AccessType   string   `yaml:"access_type,omitempty"`
+	Prompt       string   `yaml:"prompt,omitempty"`
+	RedirectURIs []string `yaml:"redirect_uris"`
+}
+
+func (p *ProviderConfig) UnmarshalYAML(value *yaml.Node) error {
+	var tmp struct {
+		AliasList    string   `yaml:"alias"`
+		Issuer       string   `yaml:"issuer"`
+		ClientID     string   `yaml:"client_id"`
+		ClientSecret string   `yaml:"client_secret"`
+		Scopes       string   `yaml:"scopes"`
+		AccessType   string   `yaml:"access_type"`
+		Prompt       string   `yaml:"prompt"`
+		RedirectURIs []string `yaml:"redirect_uris"`
+	}
+
+	// Set default values
+	tmp.Scopes = "openid email"
path: commands/client-config/providerconfig.go
user: login: EthanHeilman
body: I would like the scopes to be "openid email" but I shouldn't make this change here in case something breaks. It would just be another confounding factor.
Created github issue to track it
https://github.com/openpubkey/opkssh/issues/154
created_at: 2025-04-21T16:45:01Z
author_association: MEMBER

PR comments:
user: login: markafarrell
created_at: 2025-04-17T04:47:22Z
body: Would you consider something similar to what I proposed for the server config.

A single file per provider 
in `~/.opkssh/providers.d/`

with `default_provider` in `~/.opkssh/providers.yml`

It would make it easy to add and remove providers rather than having to edit the client config file.
author_association: CONTRIBUTOR
user: login: EthanHeilman
created_at: 2025-04-17T13:51:38Z
body: @markafarrell Currently I'm leaning toward having one client config file on the client and a directory on the server, but I want to understand your usecase for having a directory on the client. It is important to get this right today.

My current thoughts are as follows (apologies for the wall of text):

- **Corruption of the client config is low impact, corruption of the providers on the server has very high impact:** If a user corrupts their local client config the damage is minimal, they can just fix it. We should have tooling to help users find errors in the client config, but it isn't the end of the world. The providers config on the server is a different story because if you don't have another way onto that machine and you mess up the providers config you lock yourself out at the next login. This really argues for a strong firewall between providers on the server providers config.
  - The one argument against this is that users are likely to muck about in the client config and try things. Having all the config packaged together makes it more likely they will break a provider.

- **Lower cognitive burden:** A single client config lets you manage more than just providers but other options as well, such as if auto-refresh is turned on, additional authentication steps, where ssh keys are stored etc... Having a single file to manage all the config for the client  has a number of advantages:
  - We need a config file for other things as well as providers so it is nice to just have one. 
  - Users have not been trained to think about directories config in the `dir.d/` pattern. Many users will not immediately understand that all of these files are included regardless of name.
  - The user learns the location of the file the first time they need to edit it and sees the other config values they can change
  - It provides a single place for a user to look when something goes wrong.
  - Easier to debug over email by saying "Send me the file at ~/.opk/config.yml" is much easier than "send me all the files at ~/.opk/user-settings.yml and ~/.opk/providers.d/*".
  - Consider the hard to find bugs if two client config provider files exist for the same provider or use the same alias. The user only sends the provider file they think is breaking.

- **Easy setup:** If is far easier for an enterprise or user to download a single config file to everyone's machine then to manage a directory of files. 
  - In most circumstances copying a single file is atomic, if the wifi breaks halfway through the download the download doesn't happen and the user can try again. If wifi breaks halfway through a download of multiple files in multiple directories, it can mostly work but then sometimes not work because a particular provider is missing. Or someone updates one provider but not another provider.
  - Asking all your users to update a single file is hard, asking them to update many files is difficult.

Most of these problems are not an issue on the server and fault isolation on the server is much more important.


author_association: MEMBER
user: login: markafarrell
created_at: 2025-04-18T05:01:57Z
body: @EthanHeilman 

To address:

* Users have not been trained to think about directories config in the dir.d/ pattern. Many users will not immediately understand that all of these files are included regardless of name.

* The user learns the location of the file the first time they need to edit it and sees the other config values they can change

I was thinking we could do something like:

## Add a new provider
```bash
opkssh provider add google \
  --issuer=https://accounts.google.com \
  --client_id=206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com \
  --client_secret=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 \
  --scopes="openid email profile" \
  --access_type=offline \
  --prompt=consent \
  --redirect_uri=http://localhost:3000/login-callback \
  --redirect-uri=http://localhost:10001/login-callback \
  --redirect-uri=http://localhost:11110/login-callback
```
This creates or replaces `~/.okpssh/providers.d/google.yml` with:

```yaml
google:
  issuer: https://accounts.google.com
  client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com
  client_secret: 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
  scopes: openid email profile
  access_type: offline
  prompt: consent
  redirect_uris:
    - http://localhost:3000/login-callback
    - http://localhost:10001/login-callback
    - http://localhost:11110/login-callback
```

## Remove provider

```bash
opkssh provider delete google
```
This removes the file `~/.opkssh/providers.d/google.yml`

## Edit provider

```bash
opkssh provider edit google
```

This opens `~/.opkssh/providers.d/google.yml` in the default editor

## Modify provider
```bash
opkssh provider edit google --set scopes="openid email"
```

This modifies `~/.opkssh/providers.d/google.yml` to set `google.scope` to `openid email` (other fields are left unmodified)

Having the primary method of interacting with the provider config be `opkssh` itself allows us to do more sanity checking on inputs and also ensures that what is stored is valid yaml (protecting new users).
author_association: CONTRIBUTOR
user: login: markafarrell
created_at: 2025-04-18T05:07:09Z
body: Another feature I think would be really nice would be an `opkssh test bob bob@gmail.com google` command which would do the following.

1. Do an `opkssh login` and write keys to a tmp location
2. Do an `opkssh add bob bob@gmail.com google` but write to a temp auth_id instead of the real one.
3. Run `opkssh verify` using temp keys `auth_id` and client provider config as the server provider config.

This would essentially enable you to test with the client ONLY if a login would work.

This would only be possible if we kept the provider config for server and client the same
author_association: CONTRIBUTOR
user: login: EthanHeilman
created_at: 2025-04-18T13:14:06Z
body: @markafarrell Would love to see a `opkssh test` PR

> Having the primary method of interacting with the provider config be opkssh itself allows us to do more sanity checking on inputs and also ensures that what is stored is valid yaml (protecting new users).

This makes sense on the server because errors are expensive and bad but the client the user just opkssh login and should get a useful error message. 

Typing out a giant command like that is much more likely to result in an error. Opening the yaml file and editing the yaml is a better experience.

`opkssh provider edit google` this is a neat idea. Would it work well on windows?

author_association: MEMBER
user: login: yonatan-sevenai
created_at: 2025-04-18T15:14:27Z
body: My 2c, I think opkssh users will be okay with a providers.d folder, this also makes it play nice(r) with MDM solutions that would allow MDM users to distribute their opkssh providers config to users centrally, which makes for nice ergonomics in enterprise settings.
One critical key is proper default values. I'd love to minimally support a yaml file with

 ```yaml 
 google:
  issuer: https://accounts.google.com
  client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com
  client_secret: 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
```
or
```yaml
okta:
  issuer: https://subdomain.okta.com
  client_secret: 03015ee2ff6e17d61546e061b440eebb
  scopes: openid email profile groups
```

One small source of confusion would be the name of the file vs the entry in the yaml. 
Should we consider the name of the file as the alias, and the content of the file to be just the provider context (not under a `google` key?)

author_association: NONE
user: login: EthanHeilman
created_at: 2025-04-20T17:54:31Z
body: @yonatan-sevenai I'm open to adding a providers.d on the client at a point in the future, but right now want things to be as simple as possible. Having the providers in the client config does not lock out a providers.d strategy since we can always just have the providers.d extend the providers in the client config allowing backwards compatibility.
author_association: MEMBER