Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ opkssh login

This opens a browser window to select which OpenID Provider you want to authenticate against.
After successfully authenticating opkssh generates an SSH public key in `~/.ssh/id_ecdsa` which contains your PK Token.
If the default key files already hold a different identity, for instance when you log in with a second account or provider, the new keys are written to the opkssh identity directory (`~/.ssh/opkssh/`) instead of overwriting them.
By default this ssh key expires after 24 hours and you must run `opkssh login` to generate a new ssh key.

Since your PK Token has been saved as an SSH key you can SSH as normal:
Expand Down Expand Up @@ -441,9 +442,9 @@ This alias to provider mapping be can configured using the OPKSSH_PROVIDERS envi

### 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}\.opk\config.yml` on windows and `~/.opk/config.yml` on linux.
You can then edit this config file to add your provider.
Rather than type in the provider each time, you can create a client config file by running `opkssh login --create-config`.
The config is created at `~/.config/opk/config.yml` on linux/macOS (or `$XDG_CONFIG_HOME/opk/config.yml` if set) and `%AppData%\opk\config.yml` on windows; an existing legacy `~/.opk/config.yml` keeps working and takes effect instead.
You can then edit this config file to add your provider. Run `opkssh login -v` to see which config file is in use.

<details>
<summary>config.yml</summary>
Expand Down
81 changes: 70 additions & 11 deletions commands/config/client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var DefaultClientConfig []byte
type ClientConfig struct {
DefaultProvider string `yaml:"default_provider"`
Providers []ProviderConfig `yaml:"providers"`
AgentLifetime string `yaml:"agent_lifetime,omitempty"`
}

func NewClientConfig(c []byte) (*ClientConfig, error) {
Expand All @@ -59,21 +60,78 @@ func (c *ClientConfig) GetByIssuer(issuer string) (*ProviderConfig, bool) {
return nil, false
}

func ResolveClientConfigPath(configPath *string) error {
if *configPath == "" {
dir, dirErr := os.UserHomeDir()
if dirErr != nil {
return fmt.Errorf("failed to get user config dir: %w", dirErr)
// ConfigPathFlagHelp documents the --config-path default resolution chain;
// shared by every command that carries the flag so the copies cannot drift.
const ConfigPathFlagHelp = "Path to the client config file. Default: the first existing of $XDG_CONFIG_HOME/opk/config.yml (~/.config/opk/config.yml on linux/macOS, %AppData%\\opk\\config.yml on windows) and the legacy ~/.opk/config.yml."

// clientConfigCandidatePaths returns the client config locations in
// resolution order:
//
// 1. <configDir>/opk/config.yml, where <configDir> is $XDG_CONFIG_HOME when
// set and absolute (the XDG Base Directory spec requires relative values
// to be ignored), otherwise the platform default (~/.config on Unix-like
// systems, %AppData% on Windows). Replacement semantics per the spec: a
// set variable replaces the platform default, it does not stack with it.
// 2. The legacy ~/.opk/config.yml.
func clientConfigCandidatePaths() ([]string, error) {
var configDir string
var platformDirErr error
if xdgDir := os.Getenv("XDG_CONFIG_HOME"); xdgDir != "" && filepath.IsAbs(xdgDir) {
configDir = xdgDir
} else if platformDir, err := userConfigDir(); err == nil {
configDir = platformDir
} else {
platformDirErr = err
}

var candidates []string
if configDir != "" {
candidates = append(candidates, filepath.Join(configDir, "opk", "config.yml"))
}
if homeDir, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(homeDir, ".opk", "config.yml"))
} else {
// Never drop the legacy candidate silently: a user whose only config
// is the legacy one would get an unexplained fresh-config resolution.
log.Printf("warning: could not determine home directory, ignoring legacy ~/.opk/config.yml: %v", err)
}
if len(candidates) == 0 {
return nil, fmt.Errorf("failed to determine the user config directory: %w", platformDirErr)
}
return candidates, nil
}

// ResolveClientConfigPath resolves the client config path and reports
// whether a config file exists there. An explicitly provided path is used
// as-is. Otherwise the first existing candidate wins (so a legacy
// ~/.opk/config.yml keeps working untouched), and when no config exists
// anywhere the path falls to the first candidate, the XDG-preferred
// location, which is where a new config is then created.
func ResolveClientConfigPath(fs afero.Fs, configPath *string) (bool, error) {
afs := &afero.Afero{Fs: fs}
if *configPath != "" {
found, err := afs.Exists(*configPath)
return err == nil && found, nil
}
candidates, err := clientConfigCandidatePaths()
if err != nil {
return false, err
}
for _, candidate := range candidates {
if exists, err := afs.Exists(candidate); err == nil && exists {
*configPath = candidate
return true, nil
}
*configPath = filepath.Join(dir, ".opk", "config.yml")
}
return nil
*configPath = candidates[0]
return false, nil
}

// GetClientConfigFromFile retrieves the client config from the configuration file at configPath.
// If configPath is not specified then the default configuration path is uses ~/.opk/config.yml
// If configPath is not specified it is resolved via ResolveClientConfigPath
// (see clientConfigCandidatePaths for the resolution order).
func GetClientConfigFromFile(configPath string, Fs afero.Fs) (*ClientConfig, error) {
if err := ResolveClientConfigPath(&configPath); err != nil {
if _, err := ResolveClientConfigPath(Fs, &configPath); err != nil {
return nil, err
}

Expand All @@ -93,10 +151,11 @@ func GetClientConfigFromFile(configPath string, Fs afero.Fs) (*ClientConfig, err

func CreateDefaultClientConfig(configPath string, Fs afero.Fs) error {
afs := &afero.Afero{Fs: Fs}
if err := afs.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
// 0700/0600: the client config can carry provider client_secret values.
if err := afs.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := afs.WriteFile(configPath, DefaultClientConfig, 0o644); err != nil {
if err := afs.WriteFile(configPath, DefaultClientConfig, 0o600); err != nil {
return fmt.Errorf("failed to write default config file: %w", err)
}
log.Printf("created client config file at %s", configPath)
Expand Down
141 changes: 141 additions & 0 deletions commands/config/client_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
package config

import (
"os"
"runtime"
"testing"

"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -76,3 +79,141 @@ providers:
require.NotNil(t, clientConfig)
require.Equal(t, clientConfig.Providers[0].SendAccessToken, true)
}

func TestParseConfigWithAgentLifetime(t *testing.T) {
// Both value shapes must parse into the string field: a duration string
// and a bare integer number of seconds.
for _, lifetime := range []string{"12h", "28800"} {
c := `---
agent_lifetime: ` + lifetime + `
providers:
- alias: google
issuer: https://accounts.google.com
client_id: test-client-id`

clientConfig, err := NewClientConfig([]byte(c))
require.NoError(t, err)
require.NotNil(t, clientConfig)
require.Equal(t, lifetime, clientConfig.AgentLifetime)
}
}

func TestResolveClientConfigPath(t *testing.T) {
const home = "/home/testuser"
xdgPath := "/xdg-config/opk/config.yml"
platformPath := home + "/.config/opk/config.yml"
legacyPath := home + "/.opk/config.yml"

tests := []struct {
name string
xdgEnv string
files []string
explicit string
expected string
wantFound bool
}{
{
name: "explicit path is used as-is",
explicit: "/tmp/custom.yml",
files: []string{legacyPath},
expected: "/tmp/custom.yml",
wantFound: false,
},
{
name: "XDG set and file exists there",
xdgEnv: "/xdg-config",
files: []string{xdgPath, legacyPath},
expected: xdgPath,
wantFound: true,
},
{
name: "XDG replaces the platform dir, it does not stack",
xdgEnv: "/xdg-config",
files: []string{platformPath},
expected: xdgPath, // ~/.config is never consulted; no legacy -> chain head
wantFound: false,
},
{
name: "relative XDG value is ignored per the spec",
xdgEnv: "relative/dir",
files: []string{platformPath},
expected: platformPath,
wantFound: true,
},
{
name: "platform dir file wins over legacy",
files: []string{platformPath, legacyPath},
expected: platformPath,
wantFound: true,
},
{
name: "legacy config keeps working when it is the only one",
files: []string{legacyPath},
expected: legacyPath,
wantFound: true,
},
{
// The common upgrade scenario: XDG in the environment, but the
// user's only config is the legacy one.
name: "XDG set but only legacy exists: legacy wins",
xdgEnv: "/xdg-config",
files: []string{legacyPath},
expected: legacyPath,
wantFound: true,
},
{
name: "no config anywhere resolves to the chain head for creation",
files: nil,
expected: platformPath,
wantFound: false,
},
{
name: "no config anywhere with XDG set resolves to the XDG head",
xdgEnv: "/xdg-config",
files: nil,
expected: xdgPath,
wantFound: false,
},
}

if runtime.GOOS == "windows" {
// Same guard as TestConfigureSSHHomeDirError: the home directory is
// not resolved via HOME on Windows, and unix-style absolute paths
// are not absolute there, so the table's paths cannot apply.
t.Skip("home directory is not resolved via HOME on Windows")
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", tt.xdgEnv)

fs := afero.NewMemMapFs()
for _, f := range tt.files {
require.NoError(t, afero.WriteFile(fs, f, []byte("---\n"), 0o600))
}

configPath := tt.explicit
found, err := ResolveClientConfigPath(fs, &configPath)
require.NoError(t, err)
require.Equal(t, tt.expected, configPath)
require.Equal(t, tt.wantFound, found)
})
}
}

func TestCreateDefaultClientConfigPerms(t *testing.T) {
// The client config can carry provider client_secret values, so new
// creates must be 0700 (dir) / 0600 (file).
fs := afero.NewMemMapFs()
configPath := "/home/testuser/.config/opk/config.yml"
require.NoError(t, CreateDefaultClientConfig(configPath, fs))

fileInfo, err := fs.Stat(configPath)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm())

dirInfo, err := fs.Stat("/home/testuser/.config/opk")
require.NoError(t, err)
require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
}
36 changes: 36 additions & 0 deletions commands/config/config_path_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//go:build !windows
// +build !windows

// Copyright 2026 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"
"path/filepath"
)

// userConfigDir is the fallback when $XDG_CONFIG_HOME is unset. It is
// deliberately ~/.config on every Unix-like system, macOS included, because
// CLI tools follow the XDG default, not ~/Library/Application Support.
func userConfigDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".config"), nil
}
28 changes: 28 additions & 0 deletions commands/config/config_path_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build windows
// +build windows

// Copyright 2026 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"

// userConfigDir returns the platform's user config directory used when
// $XDG_CONFIG_HOME is not set: %AppData% on Windows.
func userConfigDir() (string, error) {
return os.UserConfigDir()
}
5 changes: 5 additions & 0 deletions commands/config/default-client-config.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
---
default_provider: webchooser

# How long ssh-agent retains the certificate added by opkssh login, as a
# duration (12h, 45m) or in seconds (28800). Defaults to 24h, matching the
# server's default certificate expiration policy.
# agent_lifetime: 24h

providers:
- alias: google
issuer: https://accounts.google.com
Expand Down
Loading
Loading