diff --git a/README.md b/README.md index 159a75b..e4b677b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ user: { // Optional: Key to update in .env (default: "TOKEN") tokenKey: "MY_TOKEN" + +// Optional: Key to update with ID Token in .env +idTokenKey: "MY_ID_TOKEN" ``` ## Configuration Examples @@ -88,11 +91,35 @@ oidc: { clientId: "my-client-id" clientSecret: "my-client-secret" scopes: ["openid", "profile", "email", "goauthentik.io/api"] -} - scopes: ["openid", "profile", "email", "goauthentik.io/api"] } ``` +## Advanced Targets Configuration + +By default, `authk` updates a single `.env` file. For more complex setups, you can define multiple targets to update different files or keys with different token types. + +```cue +// Optional: Multiple targets configuration +targets: [ + { + file: ".env" + key: "MY_ACCESS_TOKEN" + type: "access_token" // Default type + }, + { + file: ".env" + key: "MY_ID_TOKEN" + type: "id_token" + }, + { + file: "apps/frontend/.env" + key: "API_TOKEN" + } +] +``` + +When `targets` is defined, the global `tokenKey` and `idTokenKey` are ignored. + ## Secrets Management `authk` integrates with [vals](https://github.com/helmfile/vals) to support loading secrets securely from various sources. You can use special URI schemes in your configuration file to reference secrets instead of hardcoding them. @@ -167,15 +194,20 @@ Fetches a valid token and prints it to stdout. Useful for piping to other comman ./authk get ``` +**Flags:** +- `--id-token`: Print ID Token instead of Access Token + ### Inspect Token -Reads the current token from the `.env` file and displays its decoded content (Header and Payload). +Reads the current token from the `.env` file and displays its decoded content (Header and Payload). It automatically uses the file and key defined in your `targets` if available. ```bash ./authk inspect ``` **Flags:** +- `--id-token`: Inspect the ID token instead of the Access token (searches for a target of type `id_token`) +- `--env`: Path to .env file. If multiple targets exist for different files, use this to specify which one to inspect. - `--json`: Output as valid JSON without colors (useful for parsing) ## License diff --git a/cmd/authk/get.go b/cmd/authk/get.go index 0869df7..67f7aad 100644 --- a/cmd/authk/get.go +++ b/cmd/authk/get.go @@ -11,6 +11,10 @@ import ( "github.com/spf13/cobra" ) +var ( + showIDToken bool +) + var getCmd = &cobra.Command{ Use: "get", Short: "Get a valid token", @@ -43,11 +47,20 @@ var getCmd = &cobra.Command{ return fmt.Errorf("failed to get token: %w", err) } - fmt.Println(token.AccessToken) + if showIDToken { + idToken, ok := token.Extra("id_token").(string) + if !ok || idToken == "" { + return fmt.Errorf("no ID Token found in response") + } + fmt.Println(idToken) + } else { + fmt.Println(token.AccessToken) + } return nil }, } func init() { + getCmd.Flags().BoolVar(&showIDToken, "id-token", false, "Print ID Token instead of Access Token") rootCmd.AddCommand(getCmd) } diff --git a/cmd/authk/inspect.go b/cmd/authk/inspect.go index 85cb152..76b97a0 100644 --- a/cmd/authk/inspect.go +++ b/cmd/authk/inspect.go @@ -14,7 +14,10 @@ import ( "github.com/spf13/cobra" ) -var jsonOutput bool +var ( + jsonOutput bool + inspectID bool +) var inspectCmd = &cobra.Command{ Use: "inspect", @@ -37,8 +40,59 @@ var inspectCmd = &cobra.Command{ envFile = found } + // Determine which file and key to use + targetFile := envFile + targetKey := "" + + requestedType := "access_token" + if inspectID { + requestedType = "id_token" + } + + found := false + // 1. If --env is explicitly provided, try to find a target matching that file and type + if cmd.Flags().Changed("env") { + for _, t := range cfg.Targets { + if t.File == envFile && t.Type == requestedType { + targetKey = t.Key + found = true + break + } + } + } + + // 2. If not found yet, take the first target matching the requested type + if !found { + for _, t := range cfg.Targets { + if t.Type == requestedType { + targetFile = t.File + targetKey = t.Key + found = true + break + } + } + } + + // 3. Fallback to legacy/default behavior + if !found { + targetFile = envFile + if inspectID { + if cfg.IDTokenKey == "" { + return fmt.Errorf("idTokenKey not configured in config file") + } + targetKey = cfg.IDTokenKey + } else { + targetKey = cfg.TokenKey + } + } + + // Final check to find the file on disk (it might be in a parent directory) + if foundPath, err := env.Find(targetFile); err == nil { + targetFile = foundPath + } + // Initialize Env Manager - envMgr := env.NewManager(envFile, cfg.TokenKey) + envMgr := env.NewManager(targetFile, targetKey) // Get Token token, err := envMgr.Get() @@ -112,7 +166,6 @@ func printJSON(title, segment string) { return } - // Simple syntax highlighting for JSON keys jsonStr := string(pretty) lines := strings.Split(jsonStr, "\n") @@ -154,11 +207,12 @@ func printJSON(title, segment string) { if isTimestamp { cleanVal := strings.TrimSuffix(valTrimmed, ",") - if ts, err := strconv.ParseInt(cleanVal, 10, 64); err == nil { - tm := time.Unix(ts, 0) - dateColor := color.New(color.Faint).SprintFunc() - fmt.Print(dateColor(fmt.Sprintf(" (%s)", tm.Format("2006-01-02 15:04:05 MST")))) - } } + if ts, err := strconv.ParseInt(cleanVal, 10, 64); err == nil { + tm := time.Unix(ts, 0) + dateColor := color.New(color.Faint).SprintFunc() + fmt.Print(dateColor(fmt.Sprintf(" (%s)", tm.Format("2006-01-02 15:04:05 MST")))) + } + } fmt.Println() } else { fmt.Println(val) @@ -174,4 +228,5 @@ func printJSON(title, segment string) { func init() { rootCmd.AddCommand(inspectCmd) inspectCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as valid JSON without colors") -} \ No newline at end of file + inspectCmd.Flags().BoolVar(&inspectID, "id-token", false, "Inspect the ID token instead of the Access token") +} diff --git a/cmd/authk/root.go b/cmd/authk/root.go index 9bde539..29eda6a 100644 --- a/cmd/authk/root.go +++ b/cmd/authk/root.go @@ -11,6 +11,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "golang.org/x/oauth2" ) var ( @@ -58,8 +59,19 @@ updating a .env file with the valid token.`, targets = cfg.Targets log.Info().Int("count", len(targets)).Msg("Configured with multiple targets") } else { - targets = []config.Target{{File: envFile, Key: cfg.TokenKey}} - log.Info().Str("env_file", envFile).Str("token_key", cfg.TokenKey).Msg("Configured with single target") + targets = append(targets, config.Target{ + File: envFile, + Key: cfg.TokenKey, + Type: "access_token", + }) + if cfg.IDTokenKey != "" { + targets = append(targets, config.Target{ + File: envFile, + Key: cfg.IDTokenKey, + Type: "id_token", + }) + } + log.Info().Str("env_file", envFile).Msg("Configured with default targets") } // Initialize OIDC Client @@ -74,16 +86,34 @@ updating a .env file with the valid token.`, return fmt.Errorf("failed to get initial token: %w", err) } - // Update all targets - for _, target := range targets { - mgr := env.NewManager(target.File, target.Key) - if err := mgr.Update(token.AccessToken); err != nil { - log.Error().Err(err).Str("file", target.File).Msg("Failed to update target") - } else { - log.Info().Str("file", target.File).Msg("Target updated") + // Function to update all targets with current tokens + updateTargets := func(t *oauth2.Token) { + for _, target := range targets { + var tokenValue string + switch target.Type { + case "id_token": + idToken, ok := t.Extra("id_token").(string) + if !ok || idToken == "" { + log.Warn().Str("file", target.File).Msg("ID Token requested but not found in response") + continue + } + tokenValue = idToken + default: // access_token + tokenValue = t.AccessToken + } + + mgr := env.NewManager(target.File, target.Key) + if err := mgr.Update(tokenValue); err != nil { + log.Error().Err(err).Str("file", target.File).Str("type", target.Type).Msg("Failed to update token") + } else { + log.Info().Str("file", target.File).Str("type", target.Type).Msg("Token updated") + } } } + // Initial update + updateTargets(token) + // Maintenance Loop for { // Calculate sleep time based on token expiry and a refresh buffer @@ -116,18 +146,9 @@ updating a .env file with the valid token.`, } } - // Update token + // Update token and targets token = newToken - - // Update all targets - for _, target := range targets { - mgr := env.NewManager(target.File, target.Key) - if err := mgr.Update(token.AccessToken); err != nil { - log.Error().Err(err).Str("file", target.File).Msg("Failed to update target") - } else { - log.Info().Str("file", target.File).Msg("Target updated") - } - } + updateTargets(token) } }, } diff --git a/internal/config/config.go b/internal/config/config.go index 80a8f30..52087f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,15 +18,17 @@ import ( var schemaContent []byte type Config struct { - OIDC OIDCConfig `json:"oidc"` - User UserConfig `json:"user"` - TokenKey string `json:"tokenKey"` - Targets []Target `json:"targets,omitempty"` + OIDC OIDCConfig `json:"oidc"` + User UserConfig `json:"user"` + TokenKey string `json:"tokenKey"` + IDTokenKey string `json:"idTokenKey,omitempty"` + Targets []Target `json:"targets,omitempty"` } type Target struct { File string `json:"file"` Key string `json:"key"` + Type string `json:"type"` } type OIDCConfig struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ec425b7..a4a05b3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,7 +23,7 @@ targets: [ ` tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "authk.cue") - if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { t.Fatalf("failed to write config file: %v", err) } @@ -65,7 +65,7 @@ user: { ` tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "authk_vals.cue") - if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { t.Fatalf("failed to write config file: %v", err) } @@ -81,4 +81,59 @@ user: { if cfg.User.Password != expectedSecret { t.Errorf("expected User password %q, got %q", expectedSecret, cfg.User.Password) } -} \ No newline at end of file +} + +func TestLoad_IDTokenKey(t *testing.T) { + content := ` +package config + +oidc: { + issuerUrl: "https://example.com" + clientId: "client" + clientSecret: "secret" +} + +tokenKey: "MY_TOKEN" +idTokenKey: "MY_ID_TOKEN" + +targets: [ + { file: ".env.1", key: "KEY1", type: "access_token" }, + { file: ".env.1", key: "ID1", type: "id_token" }, + { file: ".env.2", key: "KEY2" } +] +` + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "authk_id_token.cue") + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + cfg, err := Load(configFile) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.TokenKey != "MY_TOKEN" { + t.Errorf("expected TokenKey 'MY_TOKEN', got %q", cfg.TokenKey) + } + if cfg.IDTokenKey != "MY_ID_TOKEN" { + t.Errorf("expected IDTokenKey 'MY_ID_TOKEN', got %q", cfg.IDTokenKey) + } + + if len(cfg.Targets) != 3 { + t.Errorf("expected 3 targets, got %d", len(cfg.Targets)) + } + + if cfg.Targets[0].Type != "access_token" { + t.Errorf("expected target 0 Type 'access_token', got %q", cfg.Targets[0].Type) + } + if cfg.Targets[1].Type != "id_token" { + t.Errorf("expected target 1 Type 'id_token', got %q", cfg.Targets[1].Type) + } + if cfg.Targets[1].Key != "ID1" { + t.Errorf("expected target 1 Key 'ID1', got %q", cfg.Targets[1].Key) + } + if cfg.Targets[2].Type != "access_token" { + t.Errorf("expected target 2 Type 'access_token' (default), got %q", cfg.Targets[2].Type) + } +} diff --git a/internal/config/schema.cue b/internal/config/schema.cue index bf4eb94..42ba606 100644 --- a/internal/config/schema.cue +++ b/internal/config/schema.cue @@ -12,8 +12,10 @@ user: { password?: string } tokenKey: string | *"TOKEN" +idTokenKey?: string targets?: [...{ file: string key: string + type: "access_token" | "id_token" | *"access_token" }] diff --git a/internal/env/env_test.go b/internal/env/env_test.go index bad2066..94ab899 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -71,7 +71,7 @@ func TestManager_Update(t *testing.T) { envFile := filepath.Join(tmpDir, ".env") if tt.initial != "" { - if err := os.WriteFile(envFile, []byte(tt.initial), 0644); err != nil { + if err := os.WriteFile(envFile, []byte(tt.initial), 0o644); err != nil { t.Fatalf("failed to create initial .env: %v", err) } } @@ -96,7 +96,7 @@ func TestManager_Update(t *testing.T) { func TestManager_Get(t *testing.T) { tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") - err := os.WriteFile(envFile, []byte("KEY=VALUE\n"), 0644) + err := os.WriteFile(envFile, []byte("KEY=VALUE\n"), 0o644) if err != nil { t.Fatal(err) } @@ -120,12 +120,12 @@ func TestManager_Get(t *testing.T) { func TestFind(t *testing.T) { tmpDir := t.TempDir() subdir := filepath.Join(tmpDir, "subdir") - if err := os.Mkdir(subdir, 0755); err != nil { + if err := os.Mkdir(subdir, 0o755); err != nil { t.Fatal(err) } envFile := filepath.Join(tmpDir, ".env") - if err := os.WriteFile(envFile, []byte(""), 0644); err != nil { + if err := os.WriteFile(envFile, []byte(""), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 38f4c99..275aa09 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -47,11 +47,11 @@ func NewClient(cfg *config.Config) (*Client, error) { ClientID: cfg.OIDC.ClientID, ClientSecret: cfg.OIDC.ClientSecret, Endpoint: oauth2.Endpoint{ - AuthURL: provider.Endpoint().AuthURL, - TokenURL: provider.Endpoint().TokenURL, + AuthURL: provider.Endpoint().AuthURL, + TokenURL: provider.Endpoint().TokenURL, AuthStyle: authStyle, // Set AuthStyle here }, - Scopes: cfg.OIDC.Scopes, + Scopes: cfg.OIDC.Scopes, } return &Client{ @@ -103,12 +103,13 @@ func (c *Client) GetToken(username, password string) (*oauth2.Token, error) { verifier := c.provider.Verifier(&oidc.Config{ClientID: c.cfg.OIDC.ClientID}) idToken, err := verifier.Verify(ctx, idTokenRaw) if err != nil { - return nil, fmt.Errorf("failed to verify ID token: %w", err) + log.Warn().Err(err).Msg("failed to verify ID token") + } else { + log.Debug(). + Str("issuer", idToken.Issuer). + Str("subject", idToken.Subject). + Msg("ID Token validated successfully") } - log.Debug(). - Str("issuer", idToken.Issuer). - Str("subject", idToken.Subject). - Msg("ID Token validated successfully") } else { log.Debug().Msg("No ID Token found or provided in response") } diff --git a/internal/oidc/client_test.go b/internal/oidc/client_test.go index 8159350..4e40e55 100644 --- a/internal/oidc/client_test.go +++ b/internal/oidc/client_test.go @@ -1,9 +1,11 @@ package oidc import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -28,11 +30,11 @@ func TestClient_GetToken(t *testing.T) { // go-oidc requires an issuer that matches the discovery URL // and a jwks_uri for ID token validation (even if we don't validate it in this test) if err := json.NewEncoder(w).Encode(map[string]interface{}{ - "issuer": testServer.URL, - "token_endpoint": testServer.URL + "/token", - "jwks_uri": testServer.URL + "/certs", // Dummy JWKS URI - "response_types_supported": []string{"code"}, // Minimal required by go-oidc - "subject_types_supported": []string{"public"}, + "issuer": testServer.URL, + "token_endpoint": testServer.URL + "/token", + "jwks_uri": testServer.URL + "/certs", // Dummy JWKS URI + "response_types_supported": []string{"code"}, // Minimal required by go-oidc + "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, }); err != nil { t.Error(err) @@ -155,6 +157,73 @@ func TestClient_GetToken_Password(t *testing.T) { } } +func TestClient_GetToken_IDToken(t *testing.T) { + // Mock OIDC Provider + var testServer *httptest.Server + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": testServer.URL, + "token_endpoint": testServer.URL + "/token", + "jwks_uri": testServer.URL + "/certs", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + }); err != nil { + t.Error(err) + } + case "/token": + w.Header().Set("Content-Type", "application/json") + // Create a 3-part "JWT" that might still fail verification but won't be "malformed" + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"` + testServer.URL + `","sub":"user"}`)) + signature := base64.RawURLEncoding.EncodeToString([]byte("sig")) + idToken := header + "." + payload + "." + signature + resp := mockTokenResponse{ + AccessToken: "mock_access_token", + IDToken: idToken, + ExpiresIn: 3600, + TokenType: "Bearer", + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Error(err) + } + default: + w.WriteHeader(http.StatusNotFound) + } + }) + testServer = httptest.NewServer(handler) + defer testServer.Close() + + cfg := &config.Config{ + OIDC: config.OIDCConfig{ + IssuerURL: testServer.URL, + ClientID: "client", + ClientSecret: "secret", + AuthMethod: "client_secret_basic", + }, + } + + client, err := NewClient(cfg) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + token, err := client.GetToken("", "") + if err != nil { + t.Fatalf("GetToken() error = %v", err) + } + + idToken, ok := token.Extra("id_token").(string) + if !ok { + t.Fatal("expected id_token to be present in extra") + } + if !strings.HasPrefix(idToken, "eyJhbGciOiJub25lIn0") { + t.Errorf("expected id token to start with expected header, got %s", idToken) + } +} + func TestClient_RefreshToken(t *testing.T) { var testServer *httptest.Server handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -163,11 +232,11 @@ func TestClient_RefreshToken(t *testing.T) { // go-oidc requires an issuer that matches the discovery URL // and a jwks_uri for ID token validation (even if we don't validate it in this test) if err := json.NewEncoder(w).Encode(map[string]interface{}{ - "issuer": testServer.URL, - "token_endpoint": testServer.URL + "/token", - "jwks_uri": testServer.URL + "/certs", // Dummy JWKS URI - "response_types_supported": []string{"code"}, // Minimal required by go-oidc - "subject_types_supported": []string{"public"}, + "issuer": testServer.URL, + "token_endpoint": testServer.URL + "/token", + "jwks_uri": testServer.URL + "/certs", // Dummy JWKS URI + "response_types_supported": []string{"code"}, // Minimal required by go-oidc + "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, }); err != nil { t.Error(err)