Skip to content
Merged
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
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,16 @@ make test-e2e
- Supports table, JSON, and YAML output formats
- Implements `Formatter` interface

- **internal/auth/**: OIDC authentication
- `auth.go`: Device Authorization Grant flow (RFC 8628), token revocation
- `token.go`: Token storage (OS keyring primary, file fallback), JWT expiry checking
- `transport.go`: Authenticated HTTP RoundTripper with lazy token loading and refresh

- **internal/commands/**: Cobra command definitions
- `root.go`: Root command with global flags
- `helpers.go`: Client constructors, HTTP/TLS helpers, input file parsing
- `helpers.go`: Client constructors, HTTP/TLS helpers, input file parsing, auth transport wiring
- `login.go`: `dcm login` - OIDC device authorization flow
- `logout.go`: `dcm logout` - token revocation and credential cleanup
- `policy.go`: Policy CRUD commands
- `catalog_service_type.go`: Service type list/get commands
- `catalog_item.go`: Catalog item create/list/get/delete commands
Expand Down Expand Up @@ -89,7 +96,9 @@ E2E tests live under `test/e2e/` and use the `e2e` build tag (`//go:build e2e`).

2. **Generated clients**: Import from `github.com/dcm-project/control-plane/pkg/...` (see links in Project Overview). Client constructors live in `helpers.go`. No hand-written HTTP client code.

3. **Configuration precedence**: CLI flags > environment variables (`DCM_CONTROL_PLANE_URL`, `DCM_OUTPUT_FORMAT`, `DCM_TIMEOUT`, `DCM_CONFIG`) > config file (`~/.dcm/config.yaml`) > built-in defaults.
3. **Configuration precedence**: CLI flags > environment variables (`DCM_CONTROL_PLANE_URL`, `DCM_OUTPUT_FORMAT`, `DCM_TIMEOUT`, `DCM_CONFIG`, `DCM_ISSUER_URL`, `DCM_TOKEN`) > config file (`~/.dcm/config.yaml`) > built-in defaults.

3a. **Authentication**: When `--issuer-url` is set (or `DCM_ISSUER_URL`), the HTTP client wraps its transport with an `AuthTransport` that injects Bearer tokens. `dcm login` / `dcm logout` use a plain (non-auth) HTTP client for OIDC protocol traffic, with TLS derived from the issuer URL. `dcm login` persists tokens (keyring or `~/.dcm/tokens.json`) and writes `issuer-url` to the active config file (`--config` / `DCM_CONFIG` or `~/.dcm/config.yaml`); `control-plane-url` is written only when explicitly set via `--control-plane-url` or `DCM_CONTROL_PLANE_URL`. `DCM_TOKEN` / `--token` bypasses the OIDC flow with a static Bearer token for CI. Client ID (`dcm-cli`) is hardcoded.

4. **Output formatting**: All commands support `--output/-o` flag with `table` (default), `json`, and `yaml` formats.

Expand Down
260 changes: 239 additions & 21 deletions README.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,31 @@ module github.com/dcm-project/cli
go 1.25.5

require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/dcm-project/control-plane v0.0.0-20260617094433-e4374fc25292
github.com/onsi/ginkgo/v2 v2.29.0
github.com/onsi/gomega v1.41.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/zalando/go-keyring v0.2.6
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/oauth2 v0.36.0
)

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/getkin/kin-openapi v0.139.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

182 changes: 182 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Package auth implements OIDC device authorization flow, token storage, and
// authenticated HTTP transport for the DCM CLI.
package auth

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"

"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)

const ClientID = "dcm-cli"

var scopes = []string{oidc.ScopeOpenID, "profile", "email", "offline_access"}

// DeviceLogin performs the OAuth 2.0 Device Authorization Grant (RFC 8628)
// against the given OIDC issuer. It prints the verification URL and user code
// to w, attempts to open a browser, and polls until the user completes
// authentication.
func DeviceLogin(ctx context.Context, issuerURL string, httpClient *http.Client, w io.Writer) (*TokenData, error) {
oidcCtx := oidc.ClientContext(ctx, httpClient)

provider, err := oidc.NewProvider(oidcCtx, issuerURL)
if err != nil {
return nil, fmt.Errorf("OIDC discovery failed for %s: %w", issuerURL, err)
}

endpoint := provider.Endpoint()
oauthCfg := &oauth2.Config{
ClientID: ClientID,
Endpoint: endpoint,
Scopes: scopes,
}

devAuth, err := oauthCfg.DeviceAuth(oidcCtx)
if err != nil {
return nil, fmt.Errorf("device authorization request failed: %w", err)
}

openURL := devAuth.VerificationURI
if devAuth.VerificationURIComplete != "" {
openURL = devAuth.VerificationURIComplete
}

if _, err := fmt.Fprintf(w, "Open %s in your browser\n", openURL); err != nil {
return nil, fmt.Errorf("writing login output: %w", err)
}
if devAuth.VerificationURIComplete != "" {
if _, err := fmt.Fprintf(w, "Or visit %s and enter code: %s\n", devAuth.VerificationURI, devAuth.UserCode); err != nil {
return nil, fmt.Errorf("writing login output: %w", err)
}
} else {
if _, err := fmt.Fprintf(w, "Enter code: %s\n", devAuth.UserCode); err != nil {
return nil, fmt.Errorf("writing login output: %w", err)
}
}

_ = openBrowser(openURL)

token, err := oauthCfg.DeviceAccessToken(oidcCtx, devAuth)
if err != nil {
return nil, fmt.Errorf("device authorization failed: %w", err)
}

idToken, _ := token.Extra("id_token").(string)

return &TokenData{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
IDToken: idToken,
Expiry: token.Expiry,
TokenEndpoint: endpoint.TokenURL,
}, nil
}

// RevokeToken revokes the given refresh token at the OIDC provider's
// revocation endpoint.
func RevokeToken(ctx context.Context, issuerURL string, refreshToken string, httpClient *http.Client) error {
oidcCtx := oidc.ClientContext(ctx, httpClient)

provider, err := oidc.NewProvider(oidcCtx, issuerURL)
if err != nil {
return fmt.Errorf("OIDC discovery failed for %s: %w", issuerURL, err)
}

var metadata struct {
RevocationEndpoint string `json:"revocation_endpoint"`
}
if err := provider.Claims(&metadata); err != nil {
return fmt.Errorf("reading provider metadata: %w", err)
}
if metadata.RevocationEndpoint == "" {
return nil
}

data := url.Values{
"token": {refreshToken},
"token_type_hint": {"refresh_token"},
"client_id": {ClientID},
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.RevocationEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("creating revocation request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("revocation request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode >= 400 {
return fmt.Errorf("token revocation failed with status %d", resp.StatusCode)
}

return nil
}

// PreferredUsername extracts the preferred_username claim from the access
// token's JWT payload without signature verification.
func PreferredUsername(accessToken string) string {
parts := strings.SplitN(accessToken, ".", 3)
if len(parts) != 3 {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
var claims struct {
PreferredUsername string `json:"preferred_username"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
}
return claims.PreferredUsername
}

func openBrowser(rawURL string) error {
cmd, err := browserCommand(rawURL)
if err != nil {
return err
}
return cmd.Start()
}

// browserCommand builds the OS-specific command used to open a URL.
// Only http and https schemes are allowed. On Windows it uses rundll32
// instead of cmd.exe to avoid shell metacharacter injection.
func browserCommand(rawURL string) (*exec.Cmd, error) {
return browserCommandFor(runtime.GOOS, rawURL)
}

func browserCommandFor(goos, rawURL string) (*exec.Cmd, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid browser URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("unsupported browser URL scheme %q", u.Scheme)
}

switch goos {
case "darwin":
return exec.Command("open", rawURL), nil
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL), nil
default:
return exec.Command("xdg-open", rawURL), nil
}
}
13 changes: 13 additions & 0 deletions internal/auth/auth_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package auth_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestAuth(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Auth Suite")
}
Loading