Skip to content

Commit 69caf6f

Browse files
fix(oauth): replace OOB OAuth flow with local callback server
1 parent 0b4f7f1 commit 69caf6f

8 files changed

Lines changed: 192 additions & 128 deletions

File tree

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ api-specs-pr:
6161
# Build the binary
6262
build:
6363
go generate ./...
64-
go build -ldflags "-s -w -X=github.com/algolia/cli/pkg/version.Version=$(VERSION)" -o algolia cmd/algolia/main.go
64+
go build -ldflags "\
65+
-s -w \
66+
-X=github.com/algolia/cli/pkg/version.Version=$(VERSION) \
67+
-X=github.com/algolia/cli/api/dashboard.DefaultDashboardURL=$(ALGOLIA_DASHBOARD_URL) \
68+
-X=github.com/algolia/cli/api/dashboard.DefaultAPIURL=$(ALGOLIA_API_URL) \
69+
-X=github.com/algolia/cli/pkg/cmd/auth/login.DefaultOAuthClientID=$(ALGOLIA_OAUTH_CLIENT_ID) \
70+
-X 'github.com/algolia/cli/api/dashboard.DefaultOAuthScope=$(ALGOLIA_OAUTH_SCOPE)'" \
71+
-o algolia cmd/algolia/main.go
6572
.PHONY: build
6673

6774
## Install & uninstall tasks are here for use on *nix platform only.

api/dashboard/client.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,24 +83,24 @@ func NewClientWithHTTPClient(clientID string, httpClient *http.Client) *Client {
8383
}
8484

8585
// AuthorizeURL builds the OAuth 2.0 authorization URL for the browser-based sign-in flow.
86-
func (c *Client) AuthorizeURL(codeChallenge string) string {
87-
return c.buildAuthorizeURL(codeChallenge, nil)
86+
func (c *Client) AuthorizeURL(codeChallenge, redirectURI string) string {
87+
return c.buildAuthorizeURL(codeChallenge, redirectURI, nil)
8888
}
8989

9090
// SignupAuthorizeURL builds an OAuth 2.0 authorization URL that opens the
9191
// sign-up page instead of the default sign-in page.
92-
func (c *Client) SignupAuthorizeURL(codeChallenge string) string {
93-
return c.buildAuthorizeURL(codeChallenge, map[string]string{"screen": "signup"})
92+
func (c *Client) SignupAuthorizeURL(codeChallenge, redirectURI string) string {
93+
return c.buildAuthorizeURL(codeChallenge, redirectURI, map[string]string{"screen": "signup"})
9494
}
9595

96-
func (c *Client) buildAuthorizeURL(codeChallenge string, extra map[string]string) string {
96+
func (c *Client) buildAuthorizeURL(codeChallenge, redirectURI string, extra map[string]string) string {
9797
params := url.Values{
9898
"client_id": {c.ClientID},
9999
"response_type": {"code"},
100100
"code_challenge": {codeChallenge},
101101
"code_challenge_method": {"S256"},
102102
"scope": {c.OAuthScope},
103-
"redirect_uri": {"urn:ietf:wg:oauth:2.0:oob"},
103+
"redirect_uri": {redirectURI},
104104
}
105105
for k, v := range extra {
106106
params.Set(k, v)
@@ -109,14 +109,14 @@ func (c *Client) buildAuthorizeURL(codeChallenge string, extra map[string]string
109109
}
110110

111111
// AuthorizationCodeGrant exchanges an authorization code + PKCE code_verifier
112-
// for an access token.
113-
func (c *Client) AuthorizationCodeGrant(code, codeVerifier string) (*OAuthTokenResponse, error) {
112+
// for an access token. The redirectURI must match the one used in the authorize URL.
113+
func (c *Client) AuthorizationCodeGrant(code, codeVerifier, redirectURI string) (*OAuthTokenResponse, error) {
114114
form := url.Values{
115115
"grant_type": {"authorization_code"},
116116
"client_id": {c.ClientID},
117117
"code": {code},
118118
"code_verifier": {codeVerifier},
119-
"redirect_uri": {"urn:ietf:wg:oauth:2.0:oob"},
119+
"redirect_uri": {redirectURI},
120120
}
121121

122122
req, err := http.NewRequest(http.MethodPost, c.DashboardURL+"/2/oauth/token", strings.NewReader(form.Encode()))

api/dashboard/client_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ func TestAuthorizeURL(t *testing.T) {
2626
OAuthScope: "scope:test",
2727
}
2828

29-
url := client.AuthorizeURL("test-challenge")
29+
url := client.AuthorizeURL("test-challenge", "http://localhost:12345")
3030
assert.Contains(t, url, "https://dashboard.example.com/2/oauth/authorize?")
3131
assert.Contains(t, url, "client_id=my-client-id")
3232
assert.Contains(t, url, "response_type=code")
3333
assert.Contains(t, url, "code_challenge=test-challenge")
3434
assert.Contains(t, url, "code_challenge_method=S256")
35-
assert.Contains(t, url, "redirect_uri=urn")
35+
assert.Contains(t, url, "redirect_uri=http")
3636
}
3737

3838
func TestAuthorizationCodeGrant_Success(t *testing.T) {
@@ -64,7 +64,7 @@ func TestAuthorizationCodeGrant_Success(t *testing.T) {
6464
ts, client := newTestClient(mux)
6565
defer ts.Close()
6666

67-
resp, err := client.AuthorizationCodeGrant("auth-code-123", "verifier-xyz")
67+
resp, err := client.AuthorizationCodeGrant("auth-code-123", "verifier-xyz", "http://localhost:12345")
6868
require.NoError(t, err)
6969
assert.Equal(t, "access-token-123", resp.AccessToken)
7070
assert.Equal(t, "refresh-token-456", resp.RefreshToken)
@@ -84,7 +84,7 @@ func TestAuthorizationCodeGrant_InvalidGrant(t *testing.T) {
8484
ts, client := newTestClient(mux)
8585
defer ts.Close()
8686

87-
_, err := client.AuthorizationCodeGrant("expired-code", "verifier")
87+
_, err := client.AuthorizationCodeGrant("expired-code", "verifier", "http://localhost:12345")
8888
assert.Error(t, err)
8989
assert.Contains(t, err.Error(), "Authorization code has expired")
9090
}

pkg/auth/authenticate.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func EnsureAuthenticated(
2323
cs := io.ColorScheme()
2424
fmt.Fprintf(io.Out, "%s %s\n", cs.WarningIcon(), err)
2525

26-
return RunInteractiveOAuth(io, client, false, nil)
26+
return RunOAuth(io, client, false, true)
2727
}
2828

2929
// ReauthenticateIfExpired checks if err is a session-expired error from the API.
@@ -41,5 +41,5 @@ func ReauthenticateIfExpired(
4141
ClearToken()
4242
fmt.Fprintf(io.Out, "%s Session expired.\n", cs.WarningIcon())
4343

44-
return RunInteractiveOAuth(io, client, false, nil)
44+
return RunOAuth(io, client, false, true)
4545
}

pkg/auth/callback_server.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"time"
9+
)
10+
11+
const callbackShutdownTimeout = 5 * time.Second
12+
13+
const successHTML = `<!DOCTYPE html>
14+
<html>
15+
<head>
16+
<meta charset="utf-8">
17+
<title>Algolia CLI</title>
18+
<style>
19+
body {
20+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
21+
display: flex; flex-direction: column;
22+
justify-content: center; align-items: center;
23+
height: 100vh; margin: 0;
24+
background: #f5f5fa; color: #21243d;
25+
position: relative;
26+
}
27+
.brand {
28+
font-size: 1.5rem; font-weight: 700; color: #003dff;
29+
margin-bottom: 1.5rem;
30+
}
31+
.card {
32+
margin-top: 0.25rem;
33+
background: #fff;
34+
border-radius: 4px;
35+
box-shadow: 0 0 0 1px rgba(35,38,59,.05),0 1px 3px 0 rgba(35,38,59,.15);
36+
overflow: hidden;
37+
min-width: 400px;
38+
}
39+
.card-header {
40+
text-align: center; font-size: 1.25rem;
41+
padding: 1.5rem 2rem 1rem;
42+
}
43+
.card-header h2 { margin: 0; font-size: 1.25rem; font-weight: 600; }
44+
.card-body {
45+
padding: 0 2rem 1.5rem;
46+
text-align: center;
47+
color: #5a5e9a; font-size: 0.95rem;
48+
}
49+
.card-body p { margin: 0; }
50+
</style>
51+
</head>
52+
<body>
53+
<h1>Algolia CLI</h1>
54+
<div class="card">
55+
<div class="card-header">
56+
<h2>Authentication successful</h2>
57+
</div>
58+
<div class="card-body">
59+
<p>You can close this tab and return to your terminal.</p>
60+
</div>
61+
</div>
62+
</body>
63+
</html>`
64+
65+
// CallbackResult holds the authorization code (or an error description)
66+
// received from the OAuth redirect.
67+
type CallbackResult struct {
68+
Code string
69+
Error string
70+
}
71+
72+
// StartCallbackServer starts a local HTTP server on a random available port.
73+
// It returns the redirect URI (http://127.0.0.1:{port}) and a channel that
74+
// will receive exactly one CallbackResult when the OAuth redirect arrives.
75+
// The server shuts itself down after handling the first request.
76+
func StartCallbackServer() (redirectURI string, result <-chan CallbackResult, err error) {
77+
listener, err := net.Listen("tcp", "127.0.0.1:0")
78+
if err != nil {
79+
return "", nil, fmt.Errorf("failed to start callback server: %w", err)
80+
}
81+
82+
port := listener.Addr().(*net.TCPAddr).Port
83+
redirectURI = fmt.Sprintf("http://127.0.0.1:%d", port)
84+
85+
ch := make(chan CallbackResult, 1)
86+
87+
mux := http.NewServeMux()
88+
srv := &http.Server{Handler: mux}
89+
90+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
91+
q := r.URL.Query()
92+
93+
if errParam := q.Get("error"); errParam != "" {
94+
desc := q.Get("error_description")
95+
if desc == "" {
96+
desc = errParam
97+
}
98+
http.Error(w, desc, http.StatusBadRequest)
99+
ch <- CallbackResult{Error: desc}
100+
} else {
101+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
102+
fmt.Fprint(w, successHTML)
103+
ch <- CallbackResult{Code: q.Get("code")}
104+
}
105+
106+
go func() {
107+
ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownTimeout)
108+
defer cancel()
109+
_ = srv.Shutdown(ctx)
110+
}()
111+
})
112+
113+
go func() { _ = srv.Serve(listener) }()
114+
115+
return redirectURI, ch, nil
116+
}

pkg/auth/oauth_flow.go

Lines changed: 33 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,28 @@ import (
44
"fmt"
55
"os/exec"
66
"runtime"
7-
"strings"
8-
9-
"github.com/AlecAivazis/survey/v2"
107

118
"github.com/algolia/cli/api/dashboard"
129
"github.com/algolia/cli/pkg/iostreams"
13-
"github.com/algolia/cli/pkg/prompt"
1410
)
1511

16-
// OAuthOptions configures the behaviour of RunOAuthFlow.
17-
type OAuthOptions struct {
18-
// Code, if non-empty, skips the interactive browser+prompt step and
19-
// exchanges this authorization code directly. The caller must also
20-
// supply CodeVerifier so the PKCE handshake succeeds.
21-
Code string
22-
CodeVerifier string
23-
}
24-
25-
// RunInteractiveOAuth runs the browser-based OAuth PKCE flow and returns
26-
// a valid access token. This is the authentication-only portion — it does
27-
// not handle application selection or profile setup.
28-
// If signup is true, the browser opens to the sign-up page.
29-
func RunInteractiveOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup bool, opts *OAuthOptions) (string, error) {
30-
if opts == nil {
31-
opts = &OAuthOptions{}
32-
}
33-
34-
// Fast path: caller already has an authorization code + verifier.
35-
if opts.Code != "" {
36-
return exchangeCode(io, client, opts.Code, opts.CodeVerifier)
37-
}
12+
// RunOAuth runs the OAuth PKCE flow with a local callback server and returns
13+
// a valid access token. A local HTTP server is started on a random port to
14+
// receive the authorization code via redirect — no copy-paste required.
15+
//
16+
// When openBrowser is true the authorize URL is opened automatically;
17+
// otherwise only the URL is printed (useful when the browser can't be
18+
// launched, e.g. SSH / containers).
19+
//
20+
// If signup is true the browser opens to the sign-up page.
21+
func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBrowser bool) (string, error) {
22+
cs := io.ColorScheme()
3823

39-
if !io.CanPrompt() {
40-
return "", fmt.Errorf("not logged in — run `algolia auth login` interactively first, or use --print-url and --code for non-interactive mode")
24+
redirectURI, resultCh, err := StartCallbackServer()
25+
if err != nil {
26+
return "", err
4127
}
4228

43-
cs := io.ColorScheme()
44-
4529
codeVerifier, err := GenerateCodeVerifier()
4630
if err != nil {
4731
return "", fmt.Errorf("failed to generate PKCE verifier: %w", err)
@@ -50,58 +34,35 @@ func RunInteractiveOAuth(io *iostreams.IOStreams, client *dashboard.Client, sign
5034

5135
var authorizeURL string
5236
if signup {
53-
authorizeURL = client.SignupAuthorizeURL(codeChallenge)
54-
fmt.Fprintf(io.Out, "Opening browser to create an account...\n")
37+
authorizeURL = client.SignupAuthorizeURL(codeChallenge, redirectURI)
5538
} else {
56-
authorizeURL = client.AuthorizeURL(codeChallenge)
57-
fmt.Fprintf(io.Out, "Opening browser to sign in...\n")
39+
authorizeURL = client.AuthorizeURL(codeChallenge, redirectURI)
5840
}
59-
fmt.Fprintf(io.Out, "If the browser doesn't open, visit:\n %s\n\n", cs.Bold(authorizeURL))
60-
_ = OpenBrowser(authorizeURL)
6141

62-
var code string
63-
err = prompt.SurveyAskOne(
64-
&survey.Input{Message: "Paste the authorization code:"},
65-
&code,
66-
survey.WithValidator(survey.Required),
67-
)
68-
if err != nil {
69-
return "", err
42+
if openBrowser {
43+
if signup {
44+
fmt.Fprintf(io.Out, "Opening browser to create an account...\n")
45+
} else {
46+
fmt.Fprintf(io.Out, "Opening browser to sign in...\n")
47+
}
48+
fmt.Fprintf(io.Out, "If the browser doesn't open, visit:\n %s\n\n", cs.Bold(authorizeURL))
49+
_ = OpenBrowser(authorizeURL)
50+
} else {
51+
fmt.Fprintf(io.Out, "Open this URL in your browser to authenticate:\n\n %s\n\n", cs.Bold(authorizeURL))
7052
}
71-
code = strings.TrimSpace(code)
7253

73-
return exchangeCode(io, client, code, codeVerifier)
74-
}
75-
76-
// PrintAuthorizeURL generates a PKCE challenge, prints the authorize URL,
77-
// and returns the code verifier so the caller can later exchange the code
78-
// in a separate invocation via --code / --code-verifier.
79-
func PrintAuthorizeURL(io *iostreams.IOStreams, client *dashboard.Client, signup bool) (codeVerifier string, err error) {
80-
cs := io.ColorScheme()
54+
fmt.Fprintf(io.Out, "Waiting for authentication...\n")
55+
cbResult := <-resultCh
8156

82-
codeVerifier, err = GenerateCodeVerifier()
83-
if err != nil {
84-
return "", fmt.Errorf("failed to generate PKCE verifier: %w", err)
57+
if cbResult.Error != "" {
58+
return "", fmt.Errorf("authorization failed: %s", cbResult.Error)
8559
}
86-
codeChallenge := CodeChallenge(codeVerifier)
87-
88-
var authorizeURL string
89-
if signup {
90-
authorizeURL = client.SignupAuthorizeURL(codeChallenge)
91-
} else {
92-
authorizeURL = client.AuthorizeURL(codeChallenge)
60+
if cbResult.Code == "" {
61+
return "", fmt.Errorf("no authorization code received")
9362
}
9463

95-
fmt.Fprintf(io.Out, "Open this URL in your browser to authenticate:\n\n %s\n\n", cs.Bold(authorizeURL))
96-
fmt.Fprintf(io.Out, "Then run:\n\n algolia auth login --code <AUTHORIZATION_CODE> --code-verifier %s\n\n", codeVerifier)
97-
return codeVerifier, nil
98-
}
99-
100-
func exchangeCode(io *iostreams.IOStreams, client *dashboard.Client, code, codeVerifier string) (string, error) {
101-
cs := io.ColorScheme()
102-
10364
io.StartProgressIndicatorWithLabel("Exchanging code for tokens")
104-
tokenResp, err := client.AuthorizationCodeGrant(code, codeVerifier)
65+
tokenResp, err := client.AuthorizationCodeGrant(cbResult.Code, codeVerifier, redirectURI)
10566
io.StopProgressIndicator()
10667
if err != nil {
10768
return "", err

0 commit comments

Comments
 (0)