Skip to content

Commit 042ac8e

Browse files
feat(auth): add OAuth PKCE browser flow
1 parent 0c974f8 commit 042ac8e

22 files changed

Lines changed: 1658 additions & 18 deletions

File tree

.github/workflows/releases.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ jobs:
3838
env:
3939
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
4040
CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
41+
ALGOLIA_DASHBOARD_URL: ${{ secrets.ALGOLIA_DASHBOARD_URL }}
42+
ALGOLIA_API_URL: ${{ secrets.ALGOLIA_API_URL }}
43+
ALGOLIA_OAUTH_CLIENT_ID: ${{ secrets.ALGOLIA_OAUTH_CLIENT_ID }}
44+
ALGOLIA_OAUTH_SCOPE: ${{ secrets.ALGOLIA_OAUTH_SCOPE }}
4145
- name: Docs checkout
4246
uses: actions/checkout@v4
4347
with:

.goreleaser.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ builds:
1616
binary: algolia
1717
main: ./cmd/algolia/main.go
1818
ldflags:
19-
- -s -w -X github.com/algolia/cli/pkg/version.Version={{.Version}}
19+
- -s -w
20+
-X github.com/algolia/cli/pkg/version.Version={{.Version}}
21+
-X github.com/algolia/cli/api/dashboard.DefaultDashboardURL={{ .Env.ALGOLIA_DASHBOARD_URL }}
22+
-X github.com/algolia/cli/api/dashboard.DefaultAPIURL={{ .Env.ALGOLIA_API_URL }}
23+
-X github.com/algolia/cli/pkg/cmd/auth/login.DefaultOAuthClientID={{ .Env.ALGOLIA_OAUTH_CLIENT_ID }}
24+
-X github.com/algolia/cli/api/dashboard.DefaultOAuthScope={{ .Env.ALGOLIA_OAUTH_SCOPE }}
2025
id: macos
2126
goos: [darwin]
2227
goarch: [amd64, arm64]

api/dashboard/client.go

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
package dashboard
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"strings"
12+
)
13+
14+
// DefaultDashboardURL and DefaultAPIURL are empty by default and must be
15+
// injected at build time via ldflags, e.g.:
16+
//
17+
// go build -ldflags "-X github.com/algolia/cli/api/dashboard.DefaultDashboardURL=https://..."
18+
//
19+
// They can also be overridden at runtime with ALGOLIA_DASHBOARD_URL / ALGOLIA_API_URL / ALGOLIA_OAUTH_SCOPE
20+
// environment variables.
21+
var (
22+
DefaultDashboardURL = ""
23+
DefaultAPIURL = ""
24+
DefaultOAuthScope = ""
25+
)
26+
27+
// Client interacts with the Algolia Dashboard OAuth endpoint and the Public API.
28+
type Client struct {
29+
DashboardURL string
30+
APIURL string
31+
OAuthScope string
32+
ClientID string
33+
client *http.Client
34+
}
35+
36+
// NewClient creates a new dashboard client with the given OAuth client ID.
37+
// Respects ALGOLIA_DASHBOARD_URL, ALGOLIA_API_URL, and ALGOLIA_OAUTH_SCOPE
38+
// environment variables, falling back to the compiled-in defaults (set via ldflags).
39+
func NewClient(clientID string) *Client {
40+
dashboardURL := DefaultDashboardURL
41+
if v := os.Getenv("ALGOLIA_DASHBOARD_URL"); v != "" {
42+
dashboardURL = strings.TrimRight(v, "/")
43+
}
44+
if dashboardURL == "" {
45+
fmt.Fprintln(os.Stderr, "fatal: ALGOLIA_DASHBOARD_URL is not set and no default was compiled in")
46+
os.Exit(1)
47+
}
48+
49+
apiURL := DefaultAPIURL
50+
if v := os.Getenv("ALGOLIA_API_URL"); v != "" {
51+
apiURL = strings.TrimRight(v, "/")
52+
}
53+
if apiURL == "" {
54+
fmt.Fprintln(os.Stderr, "fatal: ALGOLIA_API_URL is not set and no default was compiled in")
55+
os.Exit(1)
56+
}
57+
58+
oauthScope := DefaultOAuthScope
59+
if v := os.Getenv("ALGOLIA_OAUTH_SCOPE"); v != "" {
60+
oauthScope = v
61+
}
62+
if oauthScope == "" {
63+
fmt.Fprintln(os.Stderr, "fatal: ALGOLIA_OAUTH_SCOPE is not set and no default was compiled in")
64+
os.Exit(1)
65+
}
66+
67+
return &Client{
68+
DashboardURL: dashboardURL,
69+
APIURL: apiURL,
70+
OAuthScope: oauthScope,
71+
ClientID: clientID,
72+
client: http.DefaultClient,
73+
}
74+
}
75+
76+
// NewClientWithHTTPClient creates a new dashboard client with a custom HTTP client.
77+
// Used primarily in tests; callers must set DashboardURL and APIURL explicitly.
78+
func NewClientWithHTTPClient(clientID string, httpClient *http.Client) *Client {
79+
return &Client{
80+
ClientID: clientID,
81+
client: httpClient,
82+
}
83+
}
84+
85+
// 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)
88+
}
89+
90+
// SignupAuthorizeURL builds an OAuth 2.0 authorization URL that opens the
91+
// 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"})
94+
}
95+
96+
func (c *Client) buildAuthorizeURL(codeChallenge string, extra map[string]string) string {
97+
params := url.Values{
98+
"client_id": {c.ClientID},
99+
"response_type": {"code"},
100+
"code_challenge": {codeChallenge},
101+
"code_challenge_method": {"S256"},
102+
"scope": {c.OAuthScope},
103+
"redirect_uri": {"urn:ietf:wg:oauth:2.0:oob"},
104+
}
105+
for k, v := range extra {
106+
params.Set(k, v)
107+
}
108+
return c.DashboardURL + "/2/oauth/authorize?" + params.Encode()
109+
}
110+
111+
// AuthorizationCodeGrant exchanges an authorization code + PKCE code_verifier
112+
// for an access token.
113+
func (c *Client) AuthorizationCodeGrant(code, codeVerifier string) (*OAuthTokenResponse, error) {
114+
form := url.Values{
115+
"grant_type": {"authorization_code"},
116+
"client_id": {c.ClientID},
117+
"code": {code},
118+
"code_verifier": {codeVerifier},
119+
"redirect_uri": {"urn:ietf:wg:oauth:2.0:oob"},
120+
}
121+
122+
req, err := http.NewRequest(http.MethodPost, c.DashboardURL+"/2/oauth/token", strings.NewReader(form.Encode()))
123+
if err != nil {
124+
return nil, err
125+
}
126+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
127+
req.Header.Set("Accept", "application/json")
128+
129+
resp, err := c.client.Do(req)
130+
if err != nil {
131+
return nil, fmt.Errorf("token request failed: %w", err)
132+
}
133+
defer resp.Body.Close()
134+
135+
if resp.StatusCode != http.StatusOK {
136+
return nil, parseOAuthError(resp, "authorization code exchange")
137+
}
138+
139+
var tokenResp OAuthTokenResponse
140+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
141+
return nil, fmt.Errorf("failed to parse token response: %w", err)
142+
}
143+
144+
return &tokenResp, nil
145+
}
146+
147+
// RefreshToken uses a refresh token to obtain a new access token.
148+
func (c *Client) RefreshToken(refreshToken string) (*OAuthTokenResponse, error) {
149+
form := url.Values{
150+
"grant_type": {"refresh_token"},
151+
"client_id": {c.ClientID},
152+
"refresh_token": {refreshToken},
153+
}
154+
155+
req, err := http.NewRequest(http.MethodPost, c.DashboardURL+"/2/oauth/token", strings.NewReader(form.Encode()))
156+
if err != nil {
157+
return nil, err
158+
}
159+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
160+
req.Header.Set("Accept", "application/json")
161+
162+
resp, err := c.client.Do(req)
163+
if err != nil {
164+
return nil, fmt.Errorf("token refresh request failed: %w", err)
165+
}
166+
defer resp.Body.Close()
167+
168+
if resp.StatusCode != http.StatusOK {
169+
return nil, parseOAuthError(resp, "token refresh")
170+
}
171+
172+
var tokenResp OAuthTokenResponse
173+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
174+
return nil, fmt.Errorf("failed to parse refresh token response: %w", err)
175+
}
176+
177+
return &tokenResp, nil
178+
}
179+
180+
// RevokeToken revokes an OAuth access or refresh token via POST /2/oauth/revoke.
181+
func (c *Client) RevokeToken(token string) error {
182+
form := url.Values{
183+
"client_id": {c.ClientID},
184+
"token": {token},
185+
}
186+
187+
req, err := http.NewRequest(http.MethodPost, c.DashboardURL+"/2/oauth/revoke", strings.NewReader(form.Encode()))
188+
if err != nil {
189+
return err
190+
}
191+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
192+
req.Header.Set("Accept", "application/json")
193+
194+
resp, err := c.client.Do(req)
195+
if err != nil {
196+
return fmt.Errorf("token revocation request failed: %w", err)
197+
}
198+
defer resp.Body.Close()
199+
200+
if resp.StatusCode != http.StatusOK {
201+
return parseOAuthError(resp, "token revocation")
202+
}
203+
204+
return nil
205+
}
206+
207+
// ListApplications returns all applications for the authenticated user.
208+
func (c *Client) ListApplications(accessToken string) ([]Application, error) {
209+
req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/applications", nil)
210+
if err != nil {
211+
return nil, err
212+
}
213+
c.setAPIHeaders(req, accessToken)
214+
215+
resp, err := c.client.Do(req)
216+
if err != nil {
217+
return nil, fmt.Errorf("list applications request failed: %w", err)
218+
}
219+
defer resp.Body.Close()
220+
221+
if resp.StatusCode == http.StatusUnauthorized {
222+
return nil, ErrSessionExpired
223+
}
224+
225+
if resp.StatusCode != http.StatusOK {
226+
return nil, fmt.Errorf("list applications failed with status: %d", resp.StatusCode)
227+
}
228+
229+
var appsResp ApplicationsResponse
230+
if err := json.NewDecoder(resp.Body).Decode(&appsResp); err != nil {
231+
return nil, fmt.Errorf("failed to parse applications response: %w", err)
232+
}
233+
234+
apps := make([]Application, len(appsResp.Data))
235+
for i := range appsResp.Data {
236+
apps[i] = appsResp.Data[i].toApplication()
237+
}
238+
239+
return apps, nil
240+
}
241+
242+
// GetApplication returns a single application by its ID.
243+
func (c *Client) GetApplication(accessToken, appID string) (*Application, error) {
244+
req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/application/"+url.PathEscape(appID), nil)
245+
if err != nil {
246+
return nil, err
247+
}
248+
c.setAPIHeaders(req, accessToken)
249+
250+
resp, err := c.client.Do(req)
251+
if err != nil {
252+
return nil, fmt.Errorf("get application request failed: %w", err)
253+
}
254+
defer resp.Body.Close()
255+
256+
if resp.StatusCode == http.StatusUnauthorized {
257+
return nil, ErrSessionExpired
258+
}
259+
if resp.StatusCode != http.StatusOK {
260+
return nil, fmt.Errorf("get application failed with status: %d", resp.StatusCode)
261+
}
262+
263+
var singleResp SingleApplicationResponse
264+
if err := json.NewDecoder(resp.Body).Decode(&singleResp); err != nil {
265+
return nil, fmt.Errorf("failed to parse application response: %w", err)
266+
}
267+
268+
app := singleResp.Data.toApplication()
269+
return &app, nil
270+
}
271+
272+
// CreateApplication creates a new application for the authenticated user.
273+
func (c *Client) CreateApplication(accessToken, region, name string) (*Application, error) {
274+
payload := CreateApplicationRequest{RegionCode: region, Name: name}
275+
body, err := json.Marshal(payload)
276+
if err != nil {
277+
return nil, err
278+
}
279+
280+
req, err := http.NewRequest(http.MethodPost, c.APIURL+"/1/applications", bytes.NewReader(body))
281+
if err != nil {
282+
return nil, err
283+
}
284+
c.setAPIHeaders(req, accessToken)
285+
req.Header.Set("Content-Type", "application/json")
286+
287+
resp, err := c.client.Do(req)
288+
if err != nil {
289+
return nil, fmt.Errorf("create application request failed: %w", err)
290+
}
291+
defer resp.Body.Close()
292+
293+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
294+
respBody, _ := io.ReadAll(resp.Body)
295+
respStr := string(respBody)
296+
297+
if strings.Contains(strings.ToLower(respStr), "cluster") && strings.Contains(strings.ToLower(respStr), "not available") ||
298+
strings.Contains(strings.ToLower(respStr), "no cluster") {
299+
return nil, &ErrClusterUnavailable{Region: region, Message: fmt.Sprintf("no cluster available in region %q", region)}
300+
}
301+
302+
return nil, fmt.Errorf("create application failed with status %d: %s", resp.StatusCode, respStr)
303+
}
304+
305+
var singleResp SingleApplicationResponse
306+
if err := json.NewDecoder(resp.Body).Decode(&singleResp); err != nil {
307+
return nil, fmt.Errorf("failed to parse application response: %w", err)
308+
}
309+
310+
app := singleResp.Data.toApplication()
311+
return &app, nil
312+
}
313+
314+
// ListRegions returns the allowed hosting regions for application creation.
315+
func (c *Client) ListRegions(accessToken string) ([]Region, error) {
316+
req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/hosting/regions", nil)
317+
if err != nil {
318+
return nil, err
319+
}
320+
c.setAPIHeaders(req, accessToken)
321+
322+
resp, err := c.client.Do(req)
323+
if err != nil {
324+
return nil, fmt.Errorf("list regions request failed: %w", err)
325+
}
326+
defer resp.Body.Close()
327+
328+
if resp.StatusCode != http.StatusOK {
329+
return nil, fmt.Errorf("list regions failed with status: %d", resp.StatusCode)
330+
}
331+
332+
var regionsResp RegionsResponse
333+
if err := json.NewDecoder(resp.Body).Decode(&regionsResp); err != nil {
334+
return nil, fmt.Errorf("failed to parse regions response: %w", err)
335+
}
336+
337+
return regionsResp.RegionCodes, nil
338+
}
339+
340+
func (c *Client) setAPIHeaders(req *http.Request, accessToken string) {
341+
req.Header.Set("Authorization", "Bearer "+accessToken)
342+
req.Header.Set("Accept", "application/vnd.api+json")
343+
}
344+
345+
func parseOAuthError(resp *http.Response, context string) error {
346+
body, err := io.ReadAll(resp.Body)
347+
if err != nil {
348+
return fmt.Errorf("%s failed with status %d", context, resp.StatusCode)
349+
}
350+
351+
var oauthErr OAuthErrorResponse
352+
if json.Unmarshal(body, &oauthErr) == nil && oauthErr.ErrorDescription != "" {
353+
return fmt.Errorf("%s: %s", context, oauthErr.ErrorDescription)
354+
}
355+
356+
return fmt.Errorf("%s failed with status %d: %s", context, resp.StatusCode, string(body))
357+
}

0 commit comments

Comments
 (0)