Skip to content

Commit c097c8d

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

19 files changed

Lines changed: 840 additions & 103 deletions

File tree

pkg/auth/auth.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ 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+
func IsBrevAPIKey(token string) bool {
111+
return strings.HasPrefix(strings.TrimSpace(token), BrevAPIKeyPrefix)
112+
}
113+
114+
func IsAPIKeyAuthStore(tokenProvider TokenProvider) bool {
115+
token, err := tokenProvider.GetAccessToken()
116+
if err != nil {
117+
return false
118+
}
119+
return IsBrevAPIKey(token)
120+
}
121+
104122
func NewAuth(authStore AuthStore, oauth OAuth) *Auth {
105123
return &Auth{
106124
authStore: authStore,
@@ -146,6 +164,14 @@ func (t Auth) GetFreshAccessTokenOrNil() (string, error) {
146164
return "", nil
147165
}
148166

167+
if tokens.APIKey != "" {
168+
apiKey := strings.TrimSpace(tokens.APIKey)
169+
if apiKey == "" {
170+
return "", breverrors.NewValidationError("api key is empty")
171+
}
172+
return apiKey, nil
173+
}
174+
149175
// should always at least have access token?
150176
if tokens.AccessToken == "" {
151177
breverrors.GetDefaultErrorReporter().ReportMessage("access token is an empty string but shouldn't be")
@@ -222,6 +248,31 @@ func (t Auth) LoginWithToken(token string) error {
222248
return nil
223249
}
224250

251+
func (t Auth) LoginWithAPIKey(apiKey string) error {
252+
apiKey = strings.TrimSpace(apiKey)
253+
if apiKey == "" {
254+
return breverrors.NewValidationError("api key is empty")
255+
}
256+
if !strings.HasPrefix(apiKey, BrevAPIKeyPrefix) {
257+
return breverrors.NewValidationError(fmt.Sprintf("api key must start with %s", BrevAPIKeyPrefix))
258+
}
259+
260+
tokens, err := t.getSavedTokensOrNil()
261+
if err != nil {
262+
return breverrors.WrapAndTrace(err)
263+
}
264+
if tokens == nil {
265+
tokens = &entity.AuthTokens{}
266+
}
267+
tokens.APIKey = apiKey
268+
269+
err = t.authStore.SaveAuthTokens(*tokens)
270+
if err != nil {
271+
return breverrors.WrapAndTrace(err)
272+
}
273+
return nil
274+
}
275+
225276
// showLoginURL displays the login link and CLI alternative for manual navigation.
226277
func showLoginURL(url string) {
227278
urlType := color.New(color.FgCyan, color.Bold).SprintFunc()
@@ -313,7 +364,7 @@ func (t Auth) getSavedTokensOrNil() (*entity.AuthTokens, error) {
313364
}
314365
return nil, breverrors.WrapAndTrace(err)
315366
}
316-
if tokens != nil && tokens.AccessToken == "" && tokens.RefreshToken == "" {
367+
if tokens != nil && tokens.AccessToken == "" && tokens.RefreshToken == "" && tokens.APIKey == "" {
317368
return nil, nil
318369
}
319370
return tokens, nil
@@ -415,7 +466,7 @@ func AuthProviderFlagToCredentialProvider(authProviderFlag string) entity.Creden
415466
func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens) OAuth {
416467
// Set KAS as the default authenticator
417468
shouldPromptEmail := false
418-
if email == "" && tokens != nil && tokens.AccessToken != "" {
469+
if email == "" && tokens != nil && tokens.AccessToken != "" && tokens.APIKey == "" {
419470
email = GetEmailFromToken(tokens.AccessToken)
420471
shouldPromptEmail = true
421472
}
@@ -445,7 +496,7 @@ func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens)
445496
kasAuthenticator,
446497
})
447498

448-
if tokens != nil && tokens.AccessToken != "" {
499+
if tokens != nil && tokens.AccessToken != "" && tokens.APIKey == "" {
449500
authenticatorFromToken, errr := authRetriever.GetByToken(tokens.AccessToken)
450501
if errr != nil {
451502
fmt.Printf("%v\n", errr)

pkg/auth/auth_test.go

Lines changed: 115 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,116 @@ 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")
140+
assert.NoError(t, err)
141+
assert.True(t, s.didSave)
142+
assert.Equal(t, entity.AuthTokens{
143+
APIKey: "brev_api_test-key",
144+
}, s.saved)
145+
}
146+
147+
func TestLoginWithAPIKey_PreservesExistingJWT(t *testing.T) {
148+
s := MockAuthStore{authTokens: &entity.AuthTokens{
149+
AccessToken: "existing-jwt",
150+
RefreshToken: "existing-refresh",
151+
}}
152+
a := Auth{
153+
authStore: &s,
154+
oauth: &MockOauth{},
155+
}
156+
157+
err := a.LoginWithAPIKey("brev_api_test-key")
158+
assert.NoError(t, err)
159+
assert.Equal(t, entity.AuthTokens{
160+
AccessToken: "existing-jwt",
161+
RefreshToken: "existing-refresh",
162+
APIKey: "brev_api_test-key",
163+
}, s.saved)
164+
}
165+
166+
func TestLoginWithAPIKey_EmptyKeyReturnsError(t *testing.T) {
167+
s := MockAuthStore{}
168+
a := Auth{
169+
authStore: &s,
170+
oauth: &MockOauth{},
171+
}
172+
173+
err := a.LoginWithAPIKey("")
174+
assert.Error(t, err)
175+
assert.False(t, s.didSave)
176+
}
177+
178+
func TestStandardLogin_APIKeyCredentialDoesNotProbeOAuthProviders(t *testing.T) {
179+
oldStdout := os.Stdout
180+
readPipe, writePipe, err := os.Pipe()
181+
assert.NoError(t, err)
182+
os.Stdout = writePipe
183+
184+
_ = StandardLogin("", "", &entity.AuthTokens{
185+
AccessToken: "existing-jwt",
186+
APIKey: "brev_api_test-key",
187+
})
188+
189+
assert.NoError(t, writePipe.Close())
190+
os.Stdout = oldStdout
191+
out, err := io.ReadAll(readPipe)
192+
assert.NoError(t, err)
193+
assert.Empty(t, string(out))
194+
}
195+
82196
func TestSuccessNoRefreshGetFreshAccessTokenOrLogin(t *testing.T) {
83197
s := MockAuthStore{authTokens: &entity.AuthTokens{
84198
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

pkg/cmd/gpucreate/gpucreate_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package gpucreate
33
import (
44
"strings"
55
"testing"
6+
"time"
67

78
"github.com/brevdev/brev-cli/pkg/cmd/gpusearch"
89
"github.com/brevdev/brev-cli/pkg/entity"
@@ -44,6 +45,10 @@ func (m *MockGPUCreateStore) GetCurrentUser() (*entity.User, error) {
4445
return m.User, nil
4546
}
4647

48+
func (m *MockGPUCreateStore) GetAccessToken() (string, error) {
49+
return "", nil
50+
}
51+
4752
func (m *MockGPUCreateStore) GetActiveOrganizationOrDefault() (*entity.Organization, error) {
4853
return m.Org, nil
4954
}
@@ -641,3 +646,22 @@ func TestFormatInstanceSpecs(t *testing.T) {
641646
result := formatInstanceSpecs(specs)
642647
assert.Equal(t, "g5.xlarge (1000GB disk), p4d.24xlarge, g6.xlarge (500GB disk)", result)
643648
}
649+
650+
func TestPollUntilReadyReportsWorkspaceFailureMessage(t *testing.T) {
651+
store := NewMockGPUCreateStore()
652+
store.Workspaces["ws-failed"] = &entity.Workspace{
653+
ID: "ws-failed",
654+
Name: "test",
655+
Status: entity.Failure,
656+
StatusMessage: "unexpected end of JSON input",
657+
}
658+
659+
ctx := &createContext{
660+
store: store,
661+
opts: GPUCreateOptions{Timeout: time.Second},
662+
}
663+
664+
err := ctx.pollUntilReady("ws-failed")
665+
666+
assert.ErrorContains(t, err, "instance test failed: unexpected end of JSON input")
667+
}

0 commit comments

Comments
 (0)