initial working version
This commit is contained in:
parent
db6b90134d
commit
d986a0b31a
19 changed files with 1430 additions and 0 deletions
174
cmd/login.go
Normal file
174
cmd/login.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/browser"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/config"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/netrc"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/token"
|
||||
)
|
||||
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate via browser (Authorization Code + PKCE)",
|
||||
Long: `Opens a browser for OIDC authentication with Keycloak.
|
||||
Uses Authorization Code flow with PKCE (S256). The access token
|
||||
is written to the netrc file for Nix to use.`,
|
||||
RunE: runLogin,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(loginCmd)
|
||||
}
|
||||
|
||||
func runLogin(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// OIDC discovery
|
||||
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OIDC discovery failed: %w", err)
|
||||
}
|
||||
|
||||
// Start listener on random port
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting callback server: %w", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
redirectURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
|
||||
oauthCfg := oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{gooidc.ScopeOpenID},
|
||||
}
|
||||
|
||||
// Generate PKCE verifier
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
|
||||
// Generate random state
|
||||
stateBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
return fmt.Errorf("generating state: %w", err)
|
||||
}
|
||||
state := hex.EncodeToString(stateBytes)
|
||||
|
||||
// Build auth URL
|
||||
authURL := oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
|
||||
// Channel to receive the token
|
||||
type result struct {
|
||||
token *oauth2.Token
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
// HTTP handler for callback
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("state") != state {
|
||||
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
|
||||
resultCh <- result{err: fmt.Errorf("state mismatch")}
|
||||
return
|
||||
}
|
||||
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
desc := r.URL.Query().Get("error_description")
|
||||
http.Error(w, fmt.Sprintf("Authentication failed: %s", desc), http.StatusBadRequest)
|
||||
resultCh <- result{err: fmt.Errorf("auth error: %s (%s)", errParam, desc)}
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "Missing code parameter", http.StatusBadRequest)
|
||||
resultCh <- result{err: fmt.Errorf("missing code parameter")}
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := oauthCfg.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
|
||||
resultCh <- result{err: fmt.Errorf("token exchange: %w", err)}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
||||
resultCh <- result{token: tok}
|
||||
})
|
||||
|
||||
server := &http.Server{Handler: mux}
|
||||
|
||||
// Start server in background
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
resultCh <- result{err: fmt.Errorf("callback server: %w", err)}
|
||||
}
|
||||
}()
|
||||
|
||||
// Open browser
|
||||
fmt.Fprintln(os.Stderr, "Opening browser for authentication...")
|
||||
if err := browser.Open(authURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", authURL)
|
||||
|
||||
// Wait for callback
|
||||
res := <-resultCh
|
||||
|
||||
// Shut down server
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(shutdownCtx)
|
||||
|
||||
if res.err != nil {
|
||||
return res.err
|
||||
}
|
||||
|
||||
// Write access token to netrc
|
||||
if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, res.token.AccessToken); err != nil {
|
||||
return fmt.Errorf("writing netrc: %w", err)
|
||||
}
|
||||
|
||||
// Store refresh token
|
||||
if res.token.RefreshToken != "" {
|
||||
rtPath := config.RefreshTokenPath()
|
||||
if err := os.MkdirAll(filepath.Dir(rtPath), 0700); err != nil {
|
||||
return fmt.Errorf("creating config directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(rtPath, []byte(res.token.RefreshToken), 0600); err != nil {
|
||||
return fmt.Errorf("writing refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode JWT to show expiry
|
||||
claims, err := token.DecodePayload(res.token.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Authenticated successfully (could not decode token expiry).")
|
||||
return nil
|
||||
}
|
||||
|
||||
exp, _ := token.ExpiryInfo(claims)
|
||||
if !exp.IsZero() {
|
||||
fmt.Fprintf(os.Stderr, "Authenticated. Token valid until %s.\n", exp.Local().Format(time.RFC1123))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Authenticated successfully.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
63
cmd/logout.go
Normal file
63
cmd/logout.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/config"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/netrc"
|
||||
)
|
||||
|
||||
var logoutCmd = &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove tokens and revoke session",
|
||||
Long: `Removes the access token from the netrc file, deletes the stored
|
||||
refresh token, and revokes the refresh token at Keycloak.`,
|
||||
RunE: runLogout,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(logoutCmd)
|
||||
}
|
||||
|
||||
func runLogout(cmd *cobra.Command, args []string) error {
|
||||
// Read and revoke refresh token if it exists
|
||||
rtPath := config.RefreshTokenPath()
|
||||
if rtData, err := os.ReadFile(rtPath); err == nil && len(rtData) > 0 {
|
||||
refreshToken := string(rtData)
|
||||
|
||||
// Revoke at Keycloak
|
||||
revokeURL := strings.TrimSuffix(cfg.Issuer, "/") + "/protocol/openid-connect/revoke"
|
||||
data := url.Values{
|
||||
"token": {refreshToken},
|
||||
"token_type_hint": {"refresh_token"},
|
||||
"client_id": {cfg.ClientID},
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(revokeURL, data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not revoke token at Keycloak: %v\n", err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: token revocation returned status %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete refresh token file
|
||||
os.Remove(rtPath)
|
||||
}
|
||||
|
||||
// Remove token from netrc
|
||||
if err := netrc.Remove(cfg.NetrcPath, cfg.CacheHost); err != nil {
|
||||
return fmt.Errorf("removing netrc entry: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "Logged out.")
|
||||
return nil
|
||||
}
|
||||
107
cmd/refresh.go
Normal file
107
cmd/refresh.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/config"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/netrc"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/token"
|
||||
)
|
||||
|
||||
var refreshCmd = &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Refresh the access token using stored refresh token",
|
||||
Long: `Reads the stored refresh token, exchanges it for a new access token,
|
||||
and updates the netrc file. Exits 0 on success, 1 on failure.
|
||||
Designed to be called from a systemd timer.`,
|
||||
RunE: runRefresh,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(refreshCmd)
|
||||
}
|
||||
|
||||
func runRefresh(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Read stored refresh token
|
||||
rtPath := config.RefreshTokenPath()
|
||||
rtData, err := os.ReadFile(rtPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "No refresh token found. Run `nix-cache-login login` first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
refreshToken := string(rtData)
|
||||
|
||||
if refreshToken == "" {
|
||||
fmt.Fprintln(os.Stderr, "Refresh token is empty. Run `nix-cache-login login` first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// OIDC discovery
|
||||
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "OIDC discovery failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
oauthCfg := oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{gooidc.ScopeOpenID},
|
||||
}
|
||||
|
||||
// Create a token source with the refresh token
|
||||
oldToken := &oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
Expiry: time.Now().Add(-1 * time.Hour), // force refresh
|
||||
}
|
||||
|
||||
ts := oauthCfg.TokenSource(ctx, oldToken)
|
||||
newToken, err := ts.Token()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Session expired. Run `nix-cache-login login`.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Update netrc with new access token
|
||||
if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, newToken.AccessToken); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to update netrc: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Update refresh token if rotated
|
||||
if newToken.RefreshToken != "" && newToken.RefreshToken != refreshToken {
|
||||
if err := os.MkdirAll(filepath.Dir(rtPath), 0700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create config directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(rtPath, []byte(newToken.RefreshToken), 0600); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to update refresh token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Show expiry info
|
||||
claims, err := token.DecodePayload(newToken.AccessToken)
|
||||
if err == nil {
|
||||
exp, remaining := token.ExpiryInfo(claims)
|
||||
if !exp.IsZero() {
|
||||
fmt.Fprintf(os.Stderr, "Token refreshed. Valid until %s (%s remaining).\n",
|
||||
exp.Local().Format(time.RFC1123),
|
||||
remaining.Round(time.Minute))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Token refreshed successfully.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
46
cmd/root.go
Normal file
46
cmd/root.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgPath string
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "nix-cache-login",
|
||||
Short: "Authenticate with a Nix binary cache via OIDC",
|
||||
Long: `nix-cache-login authenticates users and servers against a Keycloak OIDC
|
||||
provider and writes access tokens to a netrc file so Nix can use them
|
||||
when accessing an authenticated binary cache.`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
cfg, err = config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// Default to login when no subcommand is given
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return loginCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "path to config file (default: $XDG_CONFIG_HOME/nix-cache-login/config.toml)")
|
||||
}
|
||||
78
cmd/serviceAccount.go
Normal file
78
cmd/serviceAccount.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/netrc"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/token"
|
||||
)
|
||||
|
||||
var serviceAccountCmd = &cobra.Command{
|
||||
Use: "service-account",
|
||||
Short: "Authenticate using client credentials (for servers)",
|
||||
Long: `Authenticates using the OAuth2 client credentials flow for headless
|
||||
server environments. Requires client_secret in the config file.
|
||||
Exits 0 on success, 1 on failure. Designed to be called from a systemd timer.`,
|
||||
RunE: runServiceAccount,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serviceAccountCmd)
|
||||
}
|
||||
|
||||
func runServiceAccount(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if cfg.ClientSecret == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: client_secret is required in config for service-account mode.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// OIDC discovery
|
||||
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "OIDC discovery failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ccCfg := clientcredentials.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
TokenURL: provider.Endpoint().TokenURL,
|
||||
Scopes: []string{gooidc.ScopeOpenID},
|
||||
}
|
||||
|
||||
tok, err := ccCfg.Token(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to obtain token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Write access token to netrc
|
||||
if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, tok.AccessToken); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to update netrc: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Show expiry info
|
||||
claims, err := token.DecodePayload(tok.AccessToken)
|
||||
if err == nil {
|
||||
exp, remaining := token.ExpiryInfo(claims)
|
||||
if !exp.IsZero() {
|
||||
fmt.Fprintf(os.Stderr, "Token obtained. Valid until %s (%s remaining).\n",
|
||||
exp.Local().Format(time.RFC1123),
|
||||
remaining.Round(time.Minute))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Token obtained successfully.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
74
cmd/status.go
Normal file
74
cmd/status.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/config"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/netrc"
|
||||
"guardianproject.dev/ops/nix-cache-login/internal/token"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show current token status",
|
||||
Long: `Decodes the JWT from the netrc file (without verification) and shows expiry information.`,
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, args []string) error {
|
||||
// Read token from netrc
|
||||
pw, err := netrc.GetPassword(cfg.NetrcPath, cfg.CacheHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading netrc: %w", err)
|
||||
}
|
||||
|
||||
if pw == "" {
|
||||
fmt.Fprintln(os.Stderr, "No token found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode JWT payload
|
||||
claims, err := token.DecodePayload(pw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding token: %w", err)
|
||||
}
|
||||
|
||||
// Show claims
|
||||
if iss, ok := claims["iss"].(string); ok {
|
||||
fmt.Fprintf(os.Stdout, "Issuer: %s\n", iss)
|
||||
}
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
fmt.Fprintf(os.Stdout, "Subject: %s\n", sub)
|
||||
}
|
||||
if name, ok := claims["preferred_username"].(string); ok {
|
||||
fmt.Fprintf(os.Stdout, "User: %s\n", name)
|
||||
}
|
||||
|
||||
exp, remaining := token.ExpiryInfo(claims)
|
||||
if !exp.IsZero() {
|
||||
fmt.Fprintf(os.Stdout, "Expires: %s\n", exp.Local().Format(time.RFC1123))
|
||||
if remaining > 0 {
|
||||
fmt.Fprintf(os.Stdout, "Remaining: %s\n", remaining.Round(time.Second))
|
||||
} else {
|
||||
fmt.Fprintf(os.Stdout, "Status: EXPIRED (%s ago)\n", (-remaining).Round(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
// Check refresh token
|
||||
rtPath := config.RefreshTokenPath()
|
||||
if _, err := os.Stat(rtPath); err == nil {
|
||||
fmt.Fprintf(os.Stdout, "Refresh token: present\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stdout, "Refresh token: not found\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue