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
15 changes: 15 additions & 0 deletions rest-api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,21 @@ All commits **must** meet the following signing requirement:
- Keep PRs focused on a single change.
- Do not land unused code unless the PR is too large to review otherwise.
- Ensure all CI checks pass before requesting review.
- Before requesting review for a Go change, run `make lint-go` and inspect its
complete analyzer output. Its `golangci-lint` command uses
`--issues-exit-code 0`, so a successful command does not mean the output is
clean. Run `go tool golangci-lint run <changed-packages>` without that
override and fix every finding in the changed package.
- When CLI flags override configuration, copy the complete configured object
first and overlay only explicitly set flags. Test no override, one override,
and unset configured fields so unrelated options cannot be discarded.
- Before using an authentication or protocol convenience API, read its current
documentation for protocol-specific encoding rules. Exercise delimiters,
percent signs, plus signs, and other reserved characters through the real
encode and decode boundary.
- Before requesting review, group every changed Go function's scenarios under
one table-driven top-level test. Treat scenario-specific top-level tests as a
review failure even when the test suite passes.

## CI / CD

Expand Down
13 changes: 12 additions & 1 deletion rest-api/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ auth:
token_url: http://localhost:8080/realms/nico-dev/protocol/openid-connect/token
client_id: nico-api
client_secret: nico-local-secret
# scopes: [openid]
# client_auth_method: client_secret_post
# token_parameters:
# audience: nico
# Run `nicocli login` to authenticate; it will prompt for username/password
# and persist the resulting bearer token (and refresh token) here.

Expand Down Expand Up @@ -159,7 +163,7 @@ These flags apply to every command and override the corresponding config values.

### Configuring with environment variables

Every field in `~/.nico/config.yaml` can also be set via a `NICO_*` environment variable. When both a config value and an env var are present, the env var wins; an explicit command-line flag still beats both. Set any of these in your shell instead of editing the config file:
The scalar fields listed below can also be set via a `NICO_*` environment variable. When both a config value and an env var are present, the env var wins; an explicit command-line flag still beats both. Set any of these in your shell instead of editing the config file:

| Env Var | Config field | Notes |
|---------|--------------|-------|
Expand All @@ -183,6 +187,13 @@ Every field in `~/.nico/config.yaml` can also be set via a `NICO_*` environment

`NICO_KEYCLOAK_URL` and `NICO_KEYCLOAK_REALM` do not map to a single config field; they feed the login command and construct the OIDC `token_url` at login time.

Client-credentials configurations can also set `auth.oidc.scopes` as a YAML list,
`auth.oidc.token_parameters` as a map of additional non-secret form parameters,
and `auth.oidc.client_auth_method` to `client_secret_post` or
`client_secret_basic`. The default remains `scope=openid` with
`client_secret_post`. Reserved OAuth and credential fields cannot be overridden
through `token_parameters`.

To see exactly which `NICO_*` variables are in use right now, pass `--debug` on any command:

```bash
Expand Down
78 changes: 68 additions & 10 deletions rest-api/cli/pkg/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ func LoginWithOIDCConfig(cfg *ConfigFile, configPath string) (string, error) {
tokenResp, err = refreshTokenGrant(oidc.TokenURL, oidc.ClientID, oidc.ClientSecret, oidc.RefreshToken)
}
if tokenResp == nil && oidc.Username == "" && oidc.ClientSecret != "" {
tokenResp, err = clientCredentialsGrant(oidc.TokenURL, oidc.ClientID, oidc.ClientSecret)
tokenResp, err = clientCredentialsGrant(oidc)
}
if tokenResp == nil && oidc.Username != "" && oidc.Password != "" {
tokenResp, err = passwordGrant(oidc.TokenURL, oidc.ClientID, oidc.ClientSecret, oidc.Username, oidc.Password)
Expand Down Expand Up @@ -393,7 +393,7 @@ func loginWithOIDCCmd(c *cli.Context, cfg *ConfigFile) error {
}

clientID := c.String("client-id")
if clientID == "" && cfg.Auth.OIDC != nil {
if cfg.Auth.OIDC != nil && cfg.Auth.OIDC.ClientID != "" && !cliFlagExplicitlySet(c, "client-id") {
clientID = cfg.Auth.OIDC.ClientID
}

Expand All @@ -416,11 +416,20 @@ func loginWithOIDCCmd(c *cli.Context, cfg *ConfigFile) error {
var err error

if username == "" && clientSecret != "" {
tokenResp, err = clientCredentialsGrant(tokenURL, clientID, clientSecret)
requestOIDC := ConfigOIDC{}
if cfg.Auth.OIDC != nil {
requestOIDC = *cfg.Auth.OIDC
}
requestOIDC.TokenURL = tokenURL
requestOIDC.ClientID = clientID
requestOIDC.ClientSecret = clientSecret
tokenResp, err = clientCredentialsGrant(&requestOIDC)
} else {
if username == "" {
fmt.Print("Username: ")
fmt.Scanln(&username)
if _, scanErr := fmt.Scanln(&username); scanErr != nil {
return fmt.Errorf("reading username: %w", scanErr)
}
}
if password == "" {
fmt.Print("Password: ")
Expand Down Expand Up @@ -468,14 +477,59 @@ func passwordGrant(tokenURL, clientID, clientSecret, username, password string)
return postToken(tokenURL, data)
}

func clientCredentialsGrant(tokenURL, clientID, clientSecret string) (*TokenResponse, error) {
func clientCredentialsGrant(oidc *ConfigOIDC) (*TokenResponse, error) {
const (
clientSecretPost = "client_secret_post"
clientSecretBasic = "client_secret_basic"
)
reserved := map[string]struct{}{
"grant_type": {}, "client_id": {}, "client_secret": {}, "scope": {},
"username": {}, "password": {}, "refresh_token": {},
"client_assertion": {}, "client_assertion_type": {},
}
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {clientID},
"client_secret": {clientSecret},
"scope": {"openid"},
"grant_type": {"client_credentials"},
}
scopes := oidc.Scopes
if len(scopes) == 0 {
scopes = []string{"openid"}
}
data.Set("scope", strings.Join(scopes, " "))
for name, value := range oidc.TokenParameters {
if _, blocked := reserved[name]; blocked {
return nil, fmt.Errorf("reserved token parameter %q cannot be configured", name)
}
data.Set(name, value)
}

method := oidc.ClientAuthMethod
if method == "" {
method = clientSecretPost
}
switch method {
case clientSecretPost:
data.Set("client_id", oidc.ClientID)
data.Set("client_secret", oidc.ClientSecret)
return postToken(oidc.TokenURL, data)
case clientSecretBasic:
return postTokenWithBasicAuth(oidc.TokenURL, data, oidc.ClientID, oidc.ClientSecret)
default:
return nil, fmt.Errorf("unsupported client_auth_method %q", method)
}
return postToken(tokenURL, data)
}

func postTokenWithBasicAuth(tokenURL string, data url.Values, clientID, clientSecret string) (*TokenResponse, error) {
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
return parseTokenResponse(resp)
}

func refreshTokenGrant(tokenURL, clientID, clientSecret, refreshToken string) (*TokenResponse, error) {
Expand All @@ -495,6 +549,10 @@ func postToken(tokenURL string, data url.Values) (*TokenResponse, error) {
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
return parseTokenResponse(resp)
}

func parseTokenResponse(resp *http.Response) (*TokenResponse, error) {
defer resp.Body.Close()

if resp.StatusCode != 200 {
Expand Down
147 changes: 147 additions & 0 deletions rest-api/cli/pkg/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"flag"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
Expand All @@ -17,6 +18,152 @@ import (
cli "github.com/urfave/cli/v2"
)

func TestLoginWithOIDCConfig(t *testing.T) {
tests := []struct {
name string
oidc ConfigOIDC
checkRequest func(*testing.T, *http.Request)
wantErr string
wantRequests int
wantSavedToken bool
}{
{
name: "custom client credentials request",
oidc: ConfigOIDC{
ClientID: "client:id",
ClientSecret: "client+secret%",
Scopes: []string{"carbide", "offline_access"},
TokenParameters: map[string]string{"audience": "nico"},
ClientAuthMethod: "client_secret_basic",
},
checkRequest: func(t *testing.T, r *http.Request) {
require.NoError(t, r.ParseForm())
require.Equal(t, "client_credentials", r.Form.Get("grant_type"))
require.Equal(t, "carbide offline_access", r.Form.Get("scope"))
require.Equal(t, "nico", r.Form.Get("audience"))
require.Empty(t, r.Form.Get("client_id"))
require.Empty(t, r.Form.Get("client_secret"))
clientID, clientSecret, ok := r.BasicAuth()
require.True(t, ok)
decodedClientID, err := url.QueryUnescape(clientID)
require.NoError(t, err)
decodedClientSecret, err := url.QueryUnescape(clientSecret)
require.NoError(t, err)
require.Equal(t, "client:id", decodedClientID)
require.Equal(t, "client+secret%", decodedClientSecret)
},
wantRequests: 1,
wantSavedToken: true,
},
{
name: "default client credentials request",
oidc: ConfigOIDC{ClientID: "client-id", ClientSecret: "client-secret"},
checkRequest: func(t *testing.T, r *http.Request) {
require.NoError(t, r.ParseForm())
require.Equal(t, "client_credentials", r.Form.Get("grant_type"))
require.Equal(t, "openid", r.Form.Get("scope"))
require.Equal(t, "client-id", r.Form.Get("client_id"))
require.Equal(t, "client-secret", r.Form.Get("client_secret"))
require.Empty(t, r.Header.Get("Authorization"))
},
wantRequests: 1,
},
{
name: "reserved token parameter",
oidc: ConfigOIDC{
ClientID: "client-id",
ClientSecret: "client-secret",
TokenParameters: map[string]string{"client_secret": "replacement"},
},
wantErr: "reserved token parameter",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
tt.checkRequest(t, r)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"new-token","expires_in":3600}`))
}))
defer server.Close()

tt.oidc.TokenURL = server.URL
cfg := &ConfigFile{Auth: ConfigAuth{OIDC: &tt.oidc}}
configPath := filepath.Join(t.TempDir(), "config.yaml")
token, err := LoginWithOIDCConfig(cfg, configPath)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
} else {
require.NoError(t, err)
require.Equal(t, "new-token", token)
}
require.Equal(t, tt.wantRequests, requests)
if tt.wantSavedToken {
loaded, err := LoadConfigFromPath(configPath)
require.NoError(t, err)
require.Equal(t, "new-token", loaded.Auth.OIDC.Token)
}
})
}
}

func TestLoginWithOIDCCmd(t *testing.T) {
tests := []struct {
name string
clientIDArgs []string
wantClientID string
}{
{
name: "preserves configured client ID",
wantClientID: "client-id",
},
{
name: "uses explicit client ID",
clientIDArgs: []string{"--client-id", "override-id"},
wantClientID: "override-id",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
require.Equal(t, "carbide", r.Form.Get("scope"))
require.Equal(t, "nico", r.Form.Get("audience"))
clientID, clientSecret, ok := r.BasicAuth()
require.True(t, ok)
require.Equal(t, tt.wantClientID, clientID)
require.Equal(t, "client-secret", clientSecret)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"new-token","expires_in":3600}`))
}))
defer server.Close()

configPath := filepath.Join(t.TempDir(), "config.yaml")
cfg := &ConfigFile{Auth: ConfigAuth{OIDC: &ConfigOIDC{
TokenURL: "https://auth.example.invalid/token",
ClientID: "client-id",
ClientSecret: "client-secret",
Scopes: []string{"carbide"},
TokenParameters: map[string]string{"audience": "nico"},
ClientAuthMethod: "client_secret_basic",
}}}
require.NoError(t, SaveConfigToPath(cfg, configPath))
SetConfigPath(configPath)
defer SetConfigPath("")
app, err := NewApp([]byte(`{"openapi":"3.0.0","info":{"title":"test","version":"test"},"paths":{}}`))
require.NoError(t, err)
args := append([]string{"nicocli", "--token-url", server.URL}, tt.clientIDArgs...)
args = append(args, "login")
withArgs(t, args...)
require.NoError(t, app.Run(os.Args))
})
}
}

func TestExtractNGCToken(t *testing.T) {
tests := []struct {
name string
Expand Down
31 changes: 21 additions & 10 deletions rest-api/cli/pkg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ type ConfigAuth struct {
}

type ConfigOIDC struct {
TokenURL string `yaml:"token_url,omitempty"`
ClientID string `yaml:"client_id,omitempty"`
ClientSecret string `yaml:"client_secret,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
Token string `yaml:"token,omitempty"`
RefreshToken string `yaml:"refresh_token,omitempty"`
ExpiresAt string `yaml:"expires_at,omitempty"`
TokenURL string `yaml:"token_url,omitempty"`
ClientID string `yaml:"client_id,omitempty"`
ClientSecret string `yaml:"client_secret,omitempty"`
ClientAuthMethod string `yaml:"client_auth_method,omitempty"`
Scopes []string `yaml:"scopes,omitempty"`
TokenParameters map[string]string `yaml:"token_parameters,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
Token string `yaml:"token,omitempty"`
RefreshToken string `yaml:"refresh_token,omitempty"`
ExpiresAt string `yaml:"expires_at,omitempty"`
}

type ConfigAPIKey struct {
Expand Down Expand Up @@ -113,7 +116,9 @@ func SaveConfigToPath(cfg *ConfigFile, path string) error {
// Load existing file as raw map to preserve unknown keys.
raw := make(map[string]interface{})
if existing, err := os.ReadFile(path); err == nil {
yaml.Unmarshal(existing, &raw)
if err := yaml.Unmarshal(existing, &raw); err != nil {
return fmt.Errorf("parsing existing config %s: %w", path, err)
}
}

// Marshal the struct and merge into the raw map.
Expand All @@ -122,7 +127,9 @@ func SaveConfigToPath(cfg *ConfigFile, path string) error {
return fmt.Errorf("marshaling config: %w", err)
}
var cfgMap map[string]interface{}
yaml.Unmarshal(structured, &cfgMap)
if err := yaml.Unmarshal(structured, &cfgMap); err != nil {
return fmt.Errorf("parsing marshaled config: %w", err)
}
for k, v := range cfgMap {
raw[k] = v
}
Expand Down Expand Up @@ -198,6 +205,10 @@ auth:
token_url: http://localhost:8080/realms/nico-dev/protocol/openid-connect/token
client_id: nico-api
client_secret: nico-local-secret
# scopes: [openid]
# client_auth_method: client_secret_post
# token_parameters:
# audience: nico
# Run 'nicocli login' to authenticate; it will prompt for username/password
# and persist the resulting bearer token (and refresh token) here.

Expand Down
Loading
Loading