Skip to content

Commit d07332f

Browse files
markturanskyAmbient Code Botclaude
authored
feat(cli): acpctl login --use-auth-code OAuth2 + PKCE flow (RHOAIENG-55812) (#1107)
## Summary - Adds `acpctl login --use-auth-code` for browser-based OAuth2 authorization code + PKCE login against Red Hat SSO (`redhat-external` realm) - Replaces the requirement to manually paste a pre-obtained bearer token - 18 unit tests covering PKCE generation, callback handler, token parsing, and error extraction ## What changed **`components/ambient-cli/cmd/acpctl/login/authcode.go`** (new) - Ephemeral loopback listener on a random port for the OAuth callback - RFC 7636 PKCE S256 (crypto/rand, SHA-256, base64url) - State parameter for CSRF protection - `srv.Shutdown` deferred — runs on all exit paths including timeout - RH SSO `error_description` extracted from non-200 token responses - Token response parsed with `encoding/json` (not hand-rolled) - No new dependencies — pure stdlib **`components/ambient-cli/cmd/acpctl/login/cmd.go`** (modified) - `--use-auth-code` flag (mutually exclusive with `--token`) - `--issuer-url` (default: `https://sso.redhat.com/auth/realms/redhat-external`) - `--client-id` (default: `ocm-cli` — TODO RHOAIENG-55817) - `--client-secret` (never persisted to config) **`components/ambient-cli/cmd/acpctl/login/authcode_test.go`** (new, 18 tests) ## Usage ```bash # Browser-based login (new) acpctl login --use-auth-code --url https://api.example.com # Static token login (unchanged) acpctl login --token <token> --url https://api.example.com ``` ## Test plan - [x] `go build ./...` passes - [x] `go vet ./...` passes - [x] `gofmt -l` clean - [x] `golangci-lint run` — 0 issues - [x] `go test ./cmd/acpctl/login/...` — 18/18 pass ## Related - Jira: RHOAIENG-55812 - Depends on: RHOAIENG-55817 (register `acpctl` public OIDC client in redhat-external realm) 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a4fb5f commit d07332f

3 files changed

Lines changed: 628 additions & 10 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package login
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"net"
13+
"net/http"
14+
"net/url"
15+
"os/exec"
16+
"runtime"
17+
"strings"
18+
"time"
19+
)
20+
21+
const (
22+
defaultIssuerURL = "https://sso.redhat.com/auth/realms/redhat-external"
23+
// TODO(RHOAIENG-56155): replace with a dedicated acpctl public client ID once registered in the redhat-external realm.
24+
defaultClientID = "ocm-cli"
25+
callbackTimeout = 5 * time.Minute
26+
callbackHTML = `<!DOCTYPE html><html><body>
27+
<h2>Login successful</h2>
28+
<p>You may close this tab and return to the terminal.</p>
29+
</body></html>`
30+
callbackHTMLError = `<!DOCTYPE html><html><body>
31+
<h2>Login failed</h2>
32+
<p>%s</p>
33+
<p>Please close this tab and check the terminal for details.</p>
34+
</body></html>`
35+
)
36+
37+
type authCodeResult struct {
38+
code string
39+
state string
40+
err error
41+
}
42+
43+
func runAuthCodeFlow(issuerURL, clientID, clientSecret string) (string, error) {
44+
issuerURL = strings.TrimRight(issuerURL, "/")
45+
authorizeURL := issuerURL + "/protocol/openid-connect/auth"
46+
tokenURL := issuerURL + "/protocol/openid-connect/token"
47+
48+
listener, err := net.Listen("tcp", "127.0.0.1:0")
49+
if err != nil {
50+
return "", fmt.Errorf("start local callback listener: %w", err)
51+
}
52+
defer listener.Close()
53+
54+
port := listener.Addr().(*net.TCPAddr).Port
55+
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
56+
57+
state, err := generateRandomState()
58+
if err != nil {
59+
return "", fmt.Errorf("generate state: %w", err)
60+
}
61+
62+
codeVerifier, codeChallenge, err := generatePKCE()
63+
if err != nil {
64+
return "", fmt.Errorf("generate PKCE: %w", err)
65+
}
66+
67+
authURL := buildAuthURL(authorizeURL, clientID, redirectURI, state, codeChallenge)
68+
69+
resultCh := make(chan authCodeResult, 1)
70+
srv := &http.Server{
71+
Handler: callbackHandler(state, resultCh),
72+
}
73+
74+
go func() {
75+
_ = srv.Serve(listener)
76+
}()
77+
78+
defer func() {
79+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
80+
defer cancel()
81+
_ = srv.Shutdown(shutdownCtx)
82+
}()
83+
84+
fmt.Printf("Opening browser for authentication...\nIf the browser does not open, visit:\n\n %s\n\n", authURL)
85+
_ = openBrowser(authURL)
86+
87+
ctx, cancel := context.WithTimeout(context.Background(), callbackTimeout)
88+
defer cancel()
89+
90+
var result authCodeResult
91+
select {
92+
case result = <-resultCh:
93+
case <-ctx.Done():
94+
return "", fmt.Errorf("timed out waiting for authorization callback (%.0fs)", callbackTimeout.Seconds())
95+
}
96+
97+
if result.err != nil {
98+
return "", fmt.Errorf("authorization failed: %w", result.err)
99+
}
100+
101+
token, err := exchangeCodeForToken(tokenURL, clientID, clientSecret, result.code, redirectURI, codeVerifier)
102+
if err != nil {
103+
return "", fmt.Errorf("exchange authorization code: %w", err)
104+
}
105+
106+
return token, nil
107+
}
108+
109+
func generateRandomState() (string, error) {
110+
b := make([]byte, 16)
111+
if _, err := rand.Read(b); err != nil {
112+
return "", err
113+
}
114+
return base64.RawURLEncoding.EncodeToString(b), nil
115+
}
116+
117+
func generatePKCE() (verifier, challenge string, err error) {
118+
b := make([]byte, 32)
119+
if _, err = rand.Read(b); err != nil {
120+
return
121+
}
122+
verifier = base64.RawURLEncoding.EncodeToString(b)
123+
h := sha256.Sum256([]byte(verifier))
124+
challenge = base64.RawURLEncoding.EncodeToString(h[:])
125+
return
126+
}
127+
128+
func buildAuthURL(authorizeURL, clientID, redirectURI, state, codeChallenge string) string {
129+
params := url.Values{
130+
"response_type": {"code"},
131+
"client_id": {clientID},
132+
"redirect_uri": {redirectURI},
133+
"state": {state},
134+
"code_challenge": {codeChallenge},
135+
"code_challenge_method": {"S256"},
136+
}
137+
return authorizeURL + "?" + params.Encode()
138+
}
139+
140+
func callbackHandler(expectedState string, resultCh chan<- authCodeResult) http.Handler {
141+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
142+
if r.URL.Path != "/callback" {
143+
http.NotFound(w, r)
144+
return
145+
}
146+
147+
q := r.URL.Query()
148+
149+
if errParam := q.Get("error"); errParam != "" {
150+
desc := q.Get("error_description")
151+
if desc == "" {
152+
desc = errParam
153+
}
154+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
155+
fmt.Fprintf(w, callbackHTMLError, desc)
156+
resultCh <- authCodeResult{err: errors.New(desc)}
157+
return
158+
}
159+
160+
gotState := q.Get("state")
161+
if gotState != expectedState {
162+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
163+
w.WriteHeader(http.StatusBadRequest)
164+
fmt.Fprintf(w, callbackHTMLError, "invalid state parameter")
165+
resultCh <- authCodeResult{err: errors.New("state mismatch: possible CSRF")}
166+
return
167+
}
168+
169+
code := q.Get("code")
170+
if code == "" {
171+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
172+
w.WriteHeader(http.StatusBadRequest)
173+
fmt.Fprintf(w, callbackHTMLError, "missing authorization code")
174+
resultCh <- authCodeResult{err: errors.New("missing authorization code in callback")}
175+
return
176+
}
177+
178+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
179+
fmt.Fprint(w, callbackHTML)
180+
resultCh <- authCodeResult{code: code, state: gotState}
181+
})
182+
}
183+
184+
func exchangeCodeForToken(tokenURL, clientID, clientSecret, code, redirectURI, codeVerifier string) (string, error) {
185+
params := url.Values{
186+
"grant_type": {"authorization_code"},
187+
"code": {code},
188+
"redirect_uri": {redirectURI},
189+
"client_id": {clientID},
190+
"code_verifier": {codeVerifier},
191+
}
192+
if clientSecret != "" {
193+
params.Set("client_secret", clientSecret)
194+
}
195+
196+
// Raw net/http is intentional here: this call goes to an external OIDC provider
197+
// (RH SSO), not the Ambient API server. The SDK client is for Ambient API calls only.
198+
httpClient := &http.Client{Timeout: 30 * time.Second}
199+
resp, err := httpClient.PostForm(tokenURL, params)
200+
if err != nil {
201+
return "", fmt.Errorf("POST to token endpoint: %w", err)
202+
}
203+
defer resp.Body.Close()
204+
205+
body, err := io.ReadAll(resp.Body)
206+
if err != nil {
207+
return "", fmt.Errorf("read token response: %w", err)
208+
}
209+
210+
if resp.StatusCode != http.StatusOK {
211+
return "", tokenEndpointError(resp.StatusCode, body)
212+
}
213+
214+
return parseTokenResponse(body)
215+
}
216+
217+
func tokenEndpointError(statusCode int, body []byte) error {
218+
var errResp struct {
219+
Error string `json:"error"`
220+
ErrorDescription string `json:"error_description"`
221+
}
222+
if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil {
223+
if errResp.ErrorDescription != "" {
224+
return fmt.Errorf("token endpoint: %s", errResp.ErrorDescription)
225+
}
226+
if errResp.Error != "" {
227+
return fmt.Errorf("token endpoint: %s", errResp.Error)
228+
}
229+
}
230+
return fmt.Errorf("token endpoint returned HTTP %d", statusCode)
231+
}
232+
233+
func parseTokenResponse(body []byte) (string, error) {
234+
var resp struct {
235+
AccessToken string `json:"access_token"`
236+
}
237+
if err := json.Unmarshal(body, &resp); err != nil {
238+
return "", fmt.Errorf("parse token response: %w", err)
239+
}
240+
if resp.AccessToken == "" {
241+
return "", errors.New("no access_token in token response")
242+
}
243+
return resp.AccessToken, nil
244+
}
245+
246+
func openBrowser(target string) error {
247+
var cmd string
248+
var cmdArgs []string
249+
250+
switch runtime.GOOS {
251+
case "darwin":
252+
cmd = "open"
253+
cmdArgs = []string{target}
254+
case "windows":
255+
cmd = "rundll32"
256+
cmdArgs = []string{"url.dll,FileProtocolHandler", target}
257+
default:
258+
cmd = "xdg-open"
259+
cmdArgs = []string{target}
260+
}
261+
262+
return exec.Command(cmd, cmdArgs...).Start()
263+
}

0 commit comments

Comments
 (0)