Skip to content

Commit 500010f

Browse files
committed
feat(BRE2-915): api key as auth method
1 parent 8e255d8 commit 500010f

21 files changed

Lines changed: 1019 additions & 104 deletions

File tree

pkg/auth/auth.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,43 @@ type Auth struct {
101101
shouldLogin func() (bool, error)
102102
}
103103

104+
const BrevAPIKeyPrefix = "brev_api_"
105+
106+
type TokenProvider interface {
107+
GetAccessToken() (string, error)
108+
}
109+
110+
type APIKeyOrgProvider interface {
111+
GetAuthTokens() (*entity.AuthTokens, error)
112+
}
113+
114+
func IsBrevAPIKey(token string) bool {
115+
return strings.HasPrefix(strings.TrimSpace(token), BrevAPIKeyPrefix)
116+
}
117+
118+
func IsAPIKeyAuthStore(tokenProvider TokenProvider) bool {
119+
token, err := tokenProvider.GetAccessToken()
120+
if err != nil {
121+
return false
122+
}
123+
return IsBrevAPIKey(token)
124+
}
125+
126+
func GetAPIKeyOrgID(authTokensProvider APIKeyOrgProvider) (string, error) {
127+
tokens, err := authTokensProvider.GetAuthTokens()
128+
if err != nil {
129+
return "", breverrors.WrapAndTrace(err)
130+
}
131+
if tokens == nil {
132+
return "", breverrors.NewValidationError("api key auth requires an org id; run brev login --api-key <api-key> --org-id <org-id>")
133+
}
134+
orgID := strings.TrimSpace(tokens.APIKeyOrgID)
135+
if orgID == "" {
136+
return "", breverrors.NewValidationError("api key auth requires an org id; run brev login --api-key <api-key> --org-id <org-id>")
137+
}
138+
return orgID, nil
139+
}
140+
104141
func NewAuth(authStore AuthStore, oauth OAuth) *Auth {
105142
return &Auth{
106143
authStore: authStore,
@@ -146,6 +183,14 @@ func (t Auth) GetFreshAccessTokenOrNil() (string, error) {
146183
return "", nil
147184
}
148185

186+
if tokens.APIKey != "" {
187+
apiKey := strings.TrimSpace(tokens.APIKey)
188+
if apiKey == "" {
189+
return "", breverrors.NewValidationError("api key is empty")
190+
}
191+
return apiKey, nil
192+
}
193+
149194
// should always at least have access token?
150195
if tokens.AccessToken == "" {
151196
breverrors.GetDefaultErrorReporter().ReportMessage("access token is an empty string but shouldn't be")
@@ -222,6 +267,36 @@ func (t Auth) LoginWithToken(token string) error {
222267
return nil
223268
}
224269

270+
func (t Auth) LoginWithAPIKey(apiKey string, orgID string) error {
271+
apiKey = strings.TrimSpace(apiKey)
272+
if apiKey == "" {
273+
return breverrors.NewValidationError("api key is empty")
274+
}
275+
if !strings.HasPrefix(apiKey, BrevAPIKeyPrefix) {
276+
return breverrors.NewValidationError(fmt.Sprintf("api key must start with %s", BrevAPIKeyPrefix))
277+
}
278+
orgID = strings.TrimSpace(orgID)
279+
if orgID == "" {
280+
return breverrors.NewValidationError("org-id is required with api-key")
281+
}
282+
283+
tokens, err := t.getSavedTokensOrNil()
284+
if err != nil {
285+
return breverrors.WrapAndTrace(err)
286+
}
287+
if tokens == nil {
288+
tokens = &entity.AuthTokens{}
289+
}
290+
tokens.APIKey = apiKey
291+
tokens.APIKeyOrgID = orgID
292+
293+
err = t.authStore.SaveAuthTokens(*tokens)
294+
if err != nil {
295+
return breverrors.WrapAndTrace(err)
296+
}
297+
return nil
298+
}
299+
225300
// showLoginURL displays the login link and CLI alternative for manual navigation.
226301
func showLoginURL(url string) {
227302
urlType := color.New(color.FgCyan, color.Bold).SprintFunc()
@@ -313,7 +388,7 @@ func (t Auth) getSavedTokensOrNil() (*entity.AuthTokens, error) {
313388
}
314389
return nil, breverrors.WrapAndTrace(err)
315390
}
316-
if tokens != nil && tokens.AccessToken == "" && tokens.RefreshToken == "" {
391+
if tokens != nil && tokens.AccessToken == "" && tokens.RefreshToken == "" && tokens.APIKey == "" {
317392
return nil, nil
318393
}
319394
return tokens, nil
@@ -415,7 +490,7 @@ func AuthProviderFlagToCredentialProvider(authProviderFlag string) entity.Creden
415490
func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens) OAuth {
416491
// Set KAS as the default authenticator
417492
shouldPromptEmail := false
418-
if email == "" && tokens != nil && tokens.AccessToken != "" {
493+
if email == "" && tokens != nil && tokens.AccessToken != "" && tokens.APIKey == "" {
419494
email = GetEmailFromToken(tokens.AccessToken)
420495
shouldPromptEmail = true
421496
}
@@ -445,7 +520,7 @@ func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens)
445520
kasAuthenticator,
446521
})
447522

448-
if tokens != nil && tokens.AccessToken != "" {
523+
if tokens != nil && tokens.AccessToken != "" && tokens.APIKey == "" {
449524
authenticatorFromToken, errr := authRetriever.GetByToken(tokens.AccessToken)
450525
if errr != nil {
451526
fmt.Printf("%v\n", errr)

pkg/auth/auth_test.go

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package auth
22

33
import (
4+
"io"
5+
"os"
46
"testing"
57

68
"github.com/brevdev/brev-cli/pkg/entity"
@@ -38,10 +40,12 @@ func TestIsAccessTokenValid(t *testing.T) {
3840

3941
type MockAuthStore struct {
4042
authTokens *entity.AuthTokens
43+
saved entity.AuthTokens
4144
didSave bool
4245
}
4346

44-
func (m *MockAuthStore) SaveAuthTokens(_ entity.AuthTokens) error {
47+
func (m *MockAuthStore) SaveAuthTokens(tokens entity.AuthTokens) error {
48+
m.saved = tokens
4549
m.didSave = true
4650
return nil
4751
}
@@ -79,6 +83,130 @@ func (m MockOauth) GetNewAuthTokensWithRefresh(_ string) (*entity.AuthTokens, er
7983

8084
const validToken = "abc"
8185

86+
func TestGetFreshAccessTokenOrNil_APIKeySkipsJWTValidationAndRefresh(t *testing.T) {
87+
s := MockAuthStore{authTokens: &entity.AuthTokens{
88+
AccessToken: "expired-jwt",
89+
APIKey: "brev_api_test-key",
90+
RefreshToken: "should-not-refresh",
91+
}}
92+
a := Auth{
93+
&s,
94+
&MockOauth{}, func(_ string) (bool, error) {
95+
t.Fatal("api keys must not be parsed as JWTs")
96+
return false, nil
97+
},
98+
func() (bool, error) {
99+
t.Fatal("api keys must not trigger login")
100+
return false, nil
101+
},
102+
}
103+
104+
res, err := a.GetFreshAccessTokenOrNil()
105+
assert.NoError(t, err)
106+
assert.Equal(t, "brev_api_test-key", res)
107+
assert.False(t, s.didSave)
108+
}
109+
110+
func TestGetFreshAccessTokenOrNil_APIKeyOnlyCredentialReturnsAPIKey(t *testing.T) {
111+
s := MockAuthStore{authTokens: &entity.AuthTokens{
112+
APIKey: "brev_api_test-key",
113+
}}
114+
a := Auth{
115+
&s,
116+
&MockOauth{}, func(_ string) (bool, error) {
117+
t.Fatal("api keys must not be parsed as JWTs")
118+
return false, nil
119+
},
120+
func() (bool, error) {
121+
t.Fatal("api keys must not trigger login")
122+
return false, nil
123+
},
124+
}
125+
126+
res, err := a.GetFreshAccessTokenOrNil()
127+
assert.NoError(t, err)
128+
assert.Equal(t, "brev_api_test-key", res)
129+
assert.False(t, s.didSave)
130+
}
131+
132+
func TestLoginWithAPIKey_SavesTypedCredential(t *testing.T) {
133+
s := MockAuthStore{}
134+
a := Auth{
135+
authStore: &s,
136+
oauth: &MockOauth{},
137+
}
138+
139+
err := a.LoginWithAPIKey("brev_api_test-key", "org-test")
140+
assert.NoError(t, err)
141+
assert.True(t, s.didSave)
142+
assert.Equal(t, entity.AuthTokens{
143+
APIKey: "brev_api_test-key",
144+
APIKeyOrgID: "org-test",
145+
}, s.saved)
146+
}
147+
148+
func TestLoginWithAPIKey_PreservesExistingJWT(t *testing.T) {
149+
s := MockAuthStore{authTokens: &entity.AuthTokens{
150+
AccessToken: "existing-jwt",
151+
RefreshToken: "existing-refresh",
152+
}}
153+
a := Auth{
154+
authStore: &s,
155+
oauth: &MockOauth{},
156+
}
157+
158+
err := a.LoginWithAPIKey("brev_api_test-key", "org-test")
159+
assert.NoError(t, err)
160+
assert.Equal(t, entity.AuthTokens{
161+
AccessToken: "existing-jwt",
162+
RefreshToken: "existing-refresh",
163+
APIKey: "brev_api_test-key",
164+
APIKeyOrgID: "org-test",
165+
}, s.saved)
166+
}
167+
168+
func TestLoginWithAPIKey_EmptyKeyReturnsError(t *testing.T) {
169+
s := MockAuthStore{}
170+
a := Auth{
171+
authStore: &s,
172+
oauth: &MockOauth{},
173+
}
174+
175+
err := a.LoginWithAPIKey("", "org-test")
176+
assert.Error(t, err)
177+
assert.False(t, s.didSave)
178+
}
179+
180+
func TestLoginWithAPIKey_EmptyOrgIDReturnsError(t *testing.T) {
181+
s := MockAuthStore{}
182+
a := Auth{
183+
authStore: &s,
184+
oauth: &MockOauth{},
185+
}
186+
187+
err := a.LoginWithAPIKey("brev_api_test-key", "")
188+
assert.Error(t, err)
189+
assert.False(t, s.didSave)
190+
}
191+
192+
func TestStandardLogin_APIKeyCredentialDoesNotProbeOAuthProviders(t *testing.T) {
193+
oldStdout := os.Stdout
194+
readPipe, writePipe, err := os.Pipe()
195+
assert.NoError(t, err)
196+
os.Stdout = writePipe
197+
198+
_ = StandardLogin("", "", &entity.AuthTokens{
199+
AccessToken: "existing-jwt",
200+
APIKey: "brev_api_test-key",
201+
})
202+
203+
assert.NoError(t, writePipe.Close())
204+
os.Stdout = oldStdout
205+
out, err := io.ReadAll(readPipe)
206+
assert.NoError(t, err)
207+
assert.Empty(t, string(out))
208+
}
209+
82210
func TestSuccessNoRefreshGetFreshAccessTokenOrLogin(t *testing.T) {
83211
s := MockAuthStore{authTokens: &entity.AuthTokens{
84212
AccessToken: validToken,

pkg/cmd/completions/completions.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package completions
22

33
import (
4+
"github.com/brevdev/brev-cli/pkg/auth"
45
"github.com/brevdev/brev-cli/pkg/entity"
56
"github.com/brevdev/brev-cli/pkg/store"
67
"github.com/brevdev/brev-cli/pkg/terminal"
@@ -18,12 +19,6 @@ type CompletionHandler func(cmd *cobra.Command, args []string, toComplete string
1819

1920
func GetAllWorkspaceNameCompletionHandler(completionStore CompletionStore, t *terminal.Terminal) CompletionHandler {
2021
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
21-
user, err := completionStore.GetCurrentUser()
22-
if err != nil {
23-
t.Errprint(err, "")
24-
return nil, cobra.ShellCompDirectiveError
25-
}
26-
2722
org, err := completionStore.GetActiveOrganizationOrDefault()
2823
if err != nil {
2924
t.Errprint(err, "")
@@ -33,7 +28,17 @@ func GetAllWorkspaceNameCompletionHandler(completionStore CompletionStore, t *te
3328
return []string{}, cobra.ShellCompDirectiveDefault
3429
}
3530

36-
workspaces, err := completionStore.GetWorkspaces(org.ID, &store.GetWorkspacesOptions{UserID: user.ID})
31+
var options *store.GetWorkspacesOptions
32+
if tokenProvider, ok := completionStore.(auth.TokenProvider); !ok || !auth.IsAPIKeyAuthStore(tokenProvider) {
33+
user, err := completionStore.GetCurrentUser()
34+
if err != nil {
35+
t.Errprint(err, "")
36+
return nil, cobra.ShellCompDirectiveError
37+
}
38+
options = &store.GetWorkspacesOptions{UserID: user.ID}
39+
}
40+
41+
workspaces, err := completionStore.GetWorkspaces(org.ID, options)
3742
if err != nil {
3843
t.Errprint(err, "")
3944
return nil, cobra.ShellCompDirectiveError

pkg/cmd/delete/delete.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"strings"
88

9+
"github.com/brevdev/brev-cli/pkg/auth"
910
"github.com/brevdev/brev-cli/pkg/cmd/completions"
1011
"github.com/brevdev/brev-cli/pkg/cmd/util"
1112
"github.com/brevdev/brev-cli/pkg/entity"
@@ -99,6 +100,9 @@ func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore Del
99100

100101
func handleAdminUser(err error, deleteStore DeleteStore, piped bool) error {
101102
if strings.Contains(err.Error(), "not found") {
103+
if tokenProvider, ok := deleteStore.(auth.TokenProvider); ok && auth.IsAPIKeyAuthStore(tokenProvider) {
104+
return breverrors.WrapAndTrace(err)
105+
}
102106
user, err1 := deleteStore.GetCurrentUser()
103107
if err1 != nil {
104108
return breverrors.WrapAndTrace(err1)

pkg/cmd/gpucreate/gpucreate.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"time"
1515
"unicode"
1616

17+
"github.com/brevdev/brev-cli/pkg/auth"
1718
"github.com/brevdev/brev-cli/pkg/cmd/gpusearch"
1819
"github.com/brevdev/brev-cli/pkg/cmd/util"
1920
"github.com/brevdev/brev-cli/pkg/config"
@@ -97,6 +98,7 @@ type GPUCreateStore interface {
9798
util.GetWorkspaceByNameOrIDErrStore
9899
gpusearch.GPUSearchStore
99100
GetActiveOrganizationOrDefault() (*entity.Organization, error)
101+
GetAccessToken() (string, error)
100102
GetCurrentUser() (*entity.User, error)
101103
GetWorkspace(workspaceID string) (*entity.Workspace, error)
102104
CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error)
@@ -720,10 +722,13 @@ func newCreateContext(t *terminal.Terminal, store GPUCreateStore, opts GPUCreate
720722
}
721723
}
722724

723-
// Get user
724-
user, err := store.GetCurrentUser()
725-
if err != nil {
726-
return nil, breverrors.WrapAndTrace(err)
725+
user := &entity.User{}
726+
if !auth.IsAPIKeyAuthStore(store) {
727+
var err error
728+
user, err = store.GetCurrentUser()
729+
if err != nil {
730+
return nil, breverrors.WrapAndTrace(err)
731+
}
727732
}
728733
ctx.user = user
729734

@@ -877,7 +882,11 @@ func (c *createContext) waitForInstances(workspaces []*entity.Workspace) {
877882
for _, ws := range workspaces {
878883
err := c.pollUntilReady(ws.ID)
879884
if err != nil {
880-
c.logf(" %s: Timeout waiting for ready state\n", ws.Name)
885+
if strings.Contains(err.Error(), "timeout waiting") {
886+
c.logf(" %s: Timeout waiting for ready state\n", ws.Name)
887+
} else {
888+
c.logf(" %s: %s\n", ws.Name, c.colorize(err.Error(), c.t.Red))
889+
}
881890
}
882891
}
883892
}
@@ -1220,6 +1229,9 @@ func (c *createContext) pollUntilReady(wsID string) error {
12201229
}
12211230

12221231
if ws.Status == entity.Failure {
1232+
if ws.StatusMessage != "" {
1233+
return breverrors.NewValidationError(fmt.Sprintf("instance %s failed: %s", ws.Name, ws.StatusMessage))
1234+
}
12231235
return breverrors.NewValidationError(fmt.Sprintf("instance %s failed", ws.Name))
12241236
}
12251237

0 commit comments

Comments
 (0)