Skip to content

Commit 13d0052

Browse files
jbguerrazclaude
andcommitted
feat: add OAuth authentication and git credential helper
- Add OIDC discovery via /.well-known/openid-configuration - Implement Authorization Code flow with PKCE (browser + local callback server) - Implement Device Authorization Grant flow (fallback for headless) - Add keyring:git command for git credential helper integration - Add keyring.GetCredential processor with auto-refresh - Extend CredentialsItem with OAuth fields (access_token, refresh_token, expires_at, etc.) - Add GetSecret() method returning appropriate credential for auth type - Auto-open browser for both auth flows Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 509ea19 commit 13d0052

File tree

6 files changed

+1160
-5
lines changed

6 files changed

+1160
-5
lines changed

action.git.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
runtime: plugin
2+
action:
3+
title: "Keyring: Git credential helper"
4+
description: >-
5+
Configure git to use keyring for credentials, or act as git credential helper.
6+
7+
Setup mode (user runs):
8+
plasmactl keyring:git # Configure for current repo
9+
plasmactl keyring:git --global # Configure globally
10+
plasmactl keyring:git https://... # Configure for specific URL
11+
12+
Credential helper mode (git calls):
13+
plasmactl keyring:git --credential get
14+
plasmactl keyring:git --credential store
15+
plasmactl keyring:git --credential erase
16+
arguments:
17+
- name: url
18+
title: URL
19+
description: URL pattern to configure credential helper for (optional)
20+
type: string
21+
default: ""
22+
options:
23+
- name: global
24+
title: Global
25+
description: Configure git credential helper globally (affects all repos)
26+
type: boolean
27+
default: false
28+
- name: credential
29+
title: Credential operation
30+
description: Git credential helper operation (get/store/erase). Used by git, not for manual use.
31+
type: string
32+
default: ""

action.login.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,22 @@ action:
33
title: "Keyring: Log in"
44
description: >-
55
Logs in to services like git, docker, etc.
6+
Automatically tries OAuth (OIDC discovery) first, falls back to username/password.
67
options:
78
- name: url
89
title: URL
10+
description: Service URL to authenticate with
911
default: ""
1012
- name: username
1113
title: Username
14+
description: Username for basic auth (skipped if OAuth succeeds)
1215
default: ""
1316
- name: password
1417
title: Password
15-
default: ""
18+
description: Password for basic auth (skipped if OAuth succeeds)
19+
default: ""
20+
- name: basic
21+
title: Force basic auth
22+
description: Skip OAuth discovery and use username/password directly
23+
type: boolean
24+
default: false

git.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package keyring
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"net/url"
9+
"os"
10+
"os/exec"
11+
"strings"
12+
13+
"github.com/launchrctl/launchr"
14+
)
15+
16+
// GitCredentialOp represents a git credential helper operation.
17+
type GitCredentialOp string
18+
19+
const (
20+
GitCredentialGet GitCredentialOp = "get"
21+
GitCredentialStore GitCredentialOp = "store"
22+
GitCredentialErase GitCredentialOp = "erase"
23+
)
24+
25+
// GitCredential represents a git credential request/response.
26+
type GitCredential struct {
27+
Protocol string
28+
Host string
29+
Path string
30+
Username string
31+
Password string
32+
}
33+
34+
// ParseGitCredential parses git credential helper input from stdin.
35+
func ParseGitCredential(r io.Reader) (*GitCredential, error) {
36+
cred := &GitCredential{}
37+
scanner := bufio.NewScanner(r)
38+
39+
for scanner.Scan() {
40+
line := scanner.Text()
41+
if line == "" {
42+
break // Empty line terminates input
43+
}
44+
45+
parts := strings.SplitN(line, "=", 2)
46+
if len(parts) != 2 {
47+
continue
48+
}
49+
50+
key, value := parts[0], parts[1]
51+
switch key {
52+
case "protocol":
53+
cred.Protocol = value
54+
case "host":
55+
cred.Host = value
56+
case "path":
57+
cred.Path = value
58+
case "username":
59+
cred.Username = value
60+
case "password":
61+
cred.Password = value
62+
}
63+
}
64+
65+
if err := scanner.Err(); err != nil {
66+
return nil, err
67+
}
68+
69+
return cred, nil
70+
}
71+
72+
// ToURL constructs a URL from the credential fields.
73+
func (c *GitCredential) ToURL() string {
74+
u := &url.URL{
75+
Scheme: c.Protocol,
76+
Host: c.Host,
77+
}
78+
if c.Path != "" {
79+
u.Path = "/" + c.Path
80+
}
81+
return u.String()
82+
}
83+
84+
// BaseURL returns the base URL (protocol://host) without path.
85+
func (c *GitCredential) BaseURL() string {
86+
return fmt.Sprintf("%s://%s", c.Protocol, c.Host)
87+
}
88+
89+
// Write outputs the credential in git credential helper format.
90+
func (c *GitCredential) Write(w io.Writer) error {
91+
if c.Protocol != "" {
92+
fmt.Fprintf(w, "protocol=%s\n", c.Protocol)
93+
}
94+
if c.Host != "" {
95+
fmt.Fprintf(w, "host=%s\n", c.Host)
96+
}
97+
if c.Username != "" {
98+
fmt.Fprintf(w, "username=%s\n", c.Username)
99+
}
100+
if c.Password != "" {
101+
fmt.Fprintf(w, "password=%s\n", c.Password)
102+
}
103+
fmt.Fprintln(w) // Empty line to terminate
104+
return nil
105+
}
106+
107+
// HandleGitCredential handles git credential helper operations.
108+
func HandleGitCredential(k Keyring, op GitCredentialOp, in io.Reader, out io.Writer) error {
109+
switch op {
110+
case GitCredentialGet:
111+
return handleGitCredentialGet(k, in, out)
112+
case GitCredentialStore:
113+
return handleGitCredentialStore(k, in)
114+
case GitCredentialErase:
115+
return handleGitCredentialErase(k, in)
116+
default:
117+
return fmt.Errorf("unknown git credential operation: %s", op)
118+
}
119+
}
120+
121+
func handleGitCredentialGet(k Keyring, in io.Reader, out io.Writer) error {
122+
cred, err := ParseGitCredential(in)
123+
if err != nil {
124+
return err
125+
}
126+
127+
// Try to find credentials for this URL
128+
baseURL := cred.BaseURL()
129+
storedCreds, err := k.GetForURL(baseURL)
130+
if err != nil {
131+
// Try with https:// prefix if not found
132+
if !strings.HasPrefix(baseURL, "https://") {
133+
baseURL = "https://" + cred.Host
134+
storedCreds, err = k.GetForURL(baseURL)
135+
}
136+
if err != nil {
137+
return nil // No credentials found, git will try other methods
138+
}
139+
}
140+
141+
// Check if OAuth token needs refresh
142+
if storedCreds.IsOAuth() && storedCreds.IsExpired() {
143+
refreshed, changed, refreshErr := RefreshCredentials(context.Background(), storedCreds)
144+
if refreshErr != nil {
145+
launchr.Log().Warn("token refresh failed", "error", refreshErr)
146+
// Continue with expired token, it might still work
147+
} else if changed {
148+
storedCreds = *refreshed
149+
// Save the refreshed credentials
150+
if err := k.AddItem(storedCreds); err == nil {
151+
_ = k.Save()
152+
}
153+
}
154+
}
155+
156+
// Return credentials to git
157+
response := &GitCredential{
158+
Protocol: cred.Protocol,
159+
Host: cred.Host,
160+
Username: storedCreds.Username,
161+
Password: storedCreds.GetSecret(),
162+
}
163+
164+
return response.Write(out)
165+
}
166+
167+
func handleGitCredentialStore(k Keyring, in io.Reader) error {
168+
cred, err := ParseGitCredential(in)
169+
if err != nil {
170+
return err
171+
}
172+
173+
// Don't overwrite OAuth credentials with basic auth from git
174+
baseURL := cred.BaseURL()
175+
existing, err := k.GetForURL(baseURL)
176+
if err == nil && existing.IsOAuth() {
177+
// Don't overwrite OAuth credentials
178+
return nil
179+
}
180+
181+
// Store as basic auth credentials
182+
item := CredentialsItem{
183+
URL: baseURL,
184+
Username: cred.Username,
185+
Password: cred.Password,
186+
AuthType: AuthTypeBasic,
187+
}
188+
189+
if err := k.AddItem(item); err != nil {
190+
return err
191+
}
192+
193+
return k.Save()
194+
}
195+
196+
func handleGitCredentialErase(k Keyring, in io.Reader) error {
197+
cred, err := ParseGitCredential(in)
198+
if err != nil {
199+
return err
200+
}
201+
202+
baseURL := cred.BaseURL()
203+
if err := k.RemoveByURL(baseURL); err != nil {
204+
return nil // Ignore not found errors
205+
}
206+
207+
return k.Save()
208+
}
209+
210+
// SetupGitCredentialHelper configures git to use plasmactl as credential helper.
211+
func SetupGitCredentialHelper(global bool, urlPattern string, printer *launchr.Terminal) error {
212+
// Get the path to the current executable
213+
execPath, err := os.Executable()
214+
if err != nil {
215+
return fmt.Errorf("failed to get executable path: %w", err)
216+
}
217+
218+
// Build the credential helper command
219+
// Git will call: plasmactl keyring:git --credential get
220+
helperCmd := fmt.Sprintf("!%s keyring:git --credential", execPath)
221+
222+
// Build git config command
223+
args := []string{"config"}
224+
if global {
225+
args = append(args, "--global")
226+
}
227+
228+
if urlPattern != "" {
229+
// URL-specific credential helper
230+
// git config credential.https://example.com.helper "!plasmactl keyring:git --credential"
231+
args = append(args, fmt.Sprintf("credential.%s.helper", urlPattern), helperCmd)
232+
} else {
233+
// Global credential helper
234+
args = append(args, "credential.helper", helperCmd)
235+
}
236+
237+
cmd := exec.Command("git", args...)
238+
cmd.Stdout = os.Stdout
239+
cmd.Stderr = os.Stderr
240+
241+
if err := cmd.Run(); err != nil {
242+
return fmt.Errorf("failed to configure git: %w", err)
243+
}
244+
245+
if urlPattern != "" {
246+
printer.Success().Printfln("Git credential helper configured for %s", urlPattern)
247+
} else if global {
248+
printer.Success().Println("Git credential helper configured globally")
249+
} else {
250+
printer.Success().Println("Git credential helper configured for this repository")
251+
}
252+
253+
return nil
254+
}

keyring.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package keyring
33
import (
44
"errors"
55
"reflect"
6+
"time"
67

78
"github.com/launchrctl/launchr"
89
)
@@ -25,14 +26,59 @@ type SecretItem interface {
2526
}
2627

2728
// CredentialsItem stores credentials.
29+
// Supports both basic auth (username/password) and OAuth (access_token/refresh_token).
2830
type CredentialsItem struct {
2931
URL string `yaml:"url"`
3032
Username string `yaml:"username"`
31-
Password string `yaml:"password"`
33+
34+
// AuthType distinguishes between "basic" and "oauth" credentials.
35+
// Empty string is treated as "basic" for backward compatibility.
36+
AuthType string `yaml:"auth_type,omitempty"`
37+
38+
// Basic auth fields
39+
Password string `yaml:"password,omitempty"`
40+
41+
// OAuth fields
42+
AccessToken string `yaml:"access_token,omitempty"`
43+
RefreshToken string `yaml:"refresh_token,omitempty"`
44+
ExpiresAt int64 `yaml:"expires_at,omitempty"`
45+
Issuer string `yaml:"issuer,omitempty"`
46+
TokenEndpoint string `yaml:"token_endpoint,omitempty"`
3247
}
3348

3449
func (i CredentialsItem) isEmpty() bool {
35-
return i.URL == "" || i.Username == "" || i.Password == ""
50+
if i.URL == "" || i.Username == "" {
51+
return true
52+
}
53+
// For OAuth, need access token; for basic, need password
54+
if i.AuthType == AuthTypeOAuth {
55+
return i.AccessToken == ""
56+
}
57+
return i.Password == ""
58+
}
59+
60+
// GetSecret returns the secret value for authentication.
61+
// For OAuth credentials, returns the access token.
62+
// For basic credentials, returns the password.
63+
func (i CredentialsItem) GetSecret() string {
64+
if i.AuthType == AuthTypeOAuth {
65+
return i.AccessToken
66+
}
67+
return i.Password
68+
}
69+
70+
// IsOAuth returns true if this is an OAuth credential.
71+
func (i CredentialsItem) IsOAuth() bool {
72+
return i.AuthType == AuthTypeOAuth
73+
}
74+
75+
// IsExpired returns true if OAuth token is expired (with 5 minute buffer).
76+
// Always returns false for basic credentials.
77+
func (i CredentialsItem) IsExpired() bool {
78+
if i.AuthType != AuthTypeOAuth || i.ExpiresAt == 0 {
79+
return false
80+
}
81+
return time.Now().Unix() >= i.ExpiresAt-300
3682
}
3783

3884
// KeyValueItem stores key-value pair.

0 commit comments

Comments
 (0)