Skip to content

Commit 6237763

Browse files
committed
feat(BRE2-915): api key as auth method
1 parent a295d3a commit 6237763

8 files changed

Lines changed: 440 additions & 9 deletions

File tree

pkg/auth/auth.go

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

104+
const BrevAPIKeyPrefix = "brev_api_"
105+
104106
func NewAuth(authStore AuthStore, oauth OAuth) *Auth {
105107
return &Auth{
106108
authStore: authStore,
@@ -146,6 +148,14 @@ func (t Auth) GetFreshAccessTokenOrNil() (string, error) {
146148
return "", nil
147149
}
148150

151+
if tokens.APIKey != "" {
152+
apiKey := strings.TrimSpace(tokens.APIKey)
153+
if apiKey == "" {
154+
return "", breverrors.NewValidationError("api key is empty")
155+
}
156+
return apiKey, nil
157+
}
158+
149159
// should always at least have access token?
150160
if tokens.AccessToken == "" {
151161
breverrors.GetDefaultErrorReporter().ReportMessage("access token is an empty string but shouldn't be")
@@ -222,6 +232,31 @@ func (t Auth) LoginWithToken(token string) error {
222232
return nil
223233
}
224234

235+
func (t Auth) LoginWithAPIKey(apiKey string) error {
236+
apiKey = strings.TrimSpace(apiKey)
237+
if apiKey == "" {
238+
return breverrors.NewValidationError("api key is empty")
239+
}
240+
if !strings.HasPrefix(apiKey, BrevAPIKeyPrefix) {
241+
return breverrors.NewValidationError(fmt.Sprintf("api key must start with %s", BrevAPIKeyPrefix))
242+
}
243+
244+
tokens, err := t.getSavedTokensOrNil()
245+
if err != nil {
246+
return breverrors.WrapAndTrace(err)
247+
}
248+
if tokens == nil {
249+
tokens = &entity.AuthTokens{}
250+
}
251+
tokens.APIKey = apiKey
252+
253+
err = t.authStore.SaveAuthTokens(*tokens)
254+
if err != nil {
255+
return breverrors.WrapAndTrace(err)
256+
}
257+
return nil
258+
}
259+
225260
// showLoginURL displays the login link and CLI alternative for manual navigation.
226261
func showLoginURL(url string) {
227262
urlType := color.New(color.FgCyan, color.Bold).SprintFunc()
@@ -415,7 +450,7 @@ func AuthProviderFlagToCredentialProvider(authProviderFlag string) entity.Creden
415450
func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens) OAuth {
416451
// Set KAS as the default authenticator
417452
shouldPromptEmail := false
418-
if email == "" && tokens != nil && tokens.AccessToken != "" {
453+
if email == "" && tokens != nil && tokens.AccessToken != "" && tokens.APIKey == "" {
419454
email = GetEmailFromToken(tokens.AccessToken)
420455
shouldPromptEmail = true
421456
}
@@ -445,7 +480,7 @@ func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens)
445480
kasAuthenticator,
446481
})
447482

448-
if tokens != nil && tokens.AccessToken != "" {
483+
if tokens != nil && tokens.AccessToken != "" && tokens.APIKey == "" {
449484
authenticatorFromToken, errr := authRetriever.GetByToken(tokens.AccessToken)
450485
if errr != nil {
451486
fmt.Printf("%v\n", errr)

pkg/auth/auth_test.go

Lines changed: 93 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,94 @@ 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 TestLoginWithAPIKey_SavesTypedCredential(t *testing.T) {
111+
s := MockAuthStore{}
112+
a := Auth{
113+
authStore: &s,
114+
oauth: &MockOauth{},
115+
}
116+
117+
err := a.LoginWithAPIKey("brev_api_test-key")
118+
assert.NoError(t, err)
119+
assert.True(t, s.didSave)
120+
assert.Equal(t, entity.AuthTokens{
121+
APIKey: "brev_api_test-key",
122+
}, s.saved)
123+
}
124+
125+
func TestLoginWithAPIKey_PreservesExistingJWT(t *testing.T) {
126+
s := MockAuthStore{authTokens: &entity.AuthTokens{
127+
AccessToken: "existing-jwt",
128+
RefreshToken: "existing-refresh",
129+
}}
130+
a := Auth{
131+
authStore: &s,
132+
oauth: &MockOauth{},
133+
}
134+
135+
err := a.LoginWithAPIKey("brev_api_test-key")
136+
assert.NoError(t, err)
137+
assert.Equal(t, entity.AuthTokens{
138+
AccessToken: "existing-jwt",
139+
RefreshToken: "existing-refresh",
140+
APIKey: "brev_api_test-key",
141+
}, s.saved)
142+
}
143+
144+
func TestLoginWithAPIKey_EmptyKeyReturnsError(t *testing.T) {
145+
s := MockAuthStore{}
146+
a := Auth{
147+
authStore: &s,
148+
oauth: &MockOauth{},
149+
}
150+
151+
err := a.LoginWithAPIKey("")
152+
assert.Error(t, err)
153+
assert.False(t, s.didSave)
154+
}
155+
156+
func TestStandardLogin_APIKeyCredentialDoesNotProbeOAuthProviders(t *testing.T) {
157+
oldStdout := os.Stdout
158+
readPipe, writePipe, err := os.Pipe()
159+
assert.NoError(t, err)
160+
os.Stdout = writePipe
161+
162+
_ = StandardLogin("", "", &entity.AuthTokens{
163+
AccessToken: "existing-jwt",
164+
APIKey: "brev_api_test-key",
165+
})
166+
167+
assert.NoError(t, writePipe.Close())
168+
os.Stdout = oldStdout
169+
out, err := io.ReadAll(readPipe)
170+
assert.NoError(t, err)
171+
assert.Empty(t, string(out))
172+
}
173+
82174
func TestSuccessNoRefreshGetFreshAccessTokenOrLogin(t *testing.T) {
83175
s := MockAuthStore{authTokens: &entity.AuthTokens{
84176
AccessToken: validToken,

pkg/cmd/login/login.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type LoginStore interface {
3333
auth.AuthStore
3434
GetCurrentUser() (*entity.User, error)
3535
CreateUser(idToken string) (*entity.User, error)
36+
SetDefaultOrganization(org *entity.Organization) error
3637
GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error)
3738
GetActiveOrganizationOrDefault() (*entity.Organization, error)
3839
CreateOrganization(req store.CreateOrganizationRequest) (*entity.Organization, error)
@@ -47,6 +48,7 @@ type LoginStore interface {
4748
type Auth interface {
4849
Login(skipBrowser bool) (*auth.LoginTokens, error)
4950
LoginWithToken(token string) error
51+
LoginWithAPIKey(apiKey string) error
5052
}
5153

5254
// loginStore must be a no prompt store
@@ -57,9 +59,11 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
5759
}
5860

5961
var loginToken string
62+
var apiKey string
6063
var skipBrowser bool
6164
var emailFlag string
6265
var authProviderFlag string
66+
apiKeyLogin := false
6367

6468
cmd := &cobra.Command{
6569
Annotations: map[string]string{"configuration": ""},
@@ -69,6 +73,9 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
6973
Long: "Log into brev",
7074
Example: "brev login",
7175
PostRunE: func(cmd *cobra.Command, args []string) error {
76+
if apiKeyLogin {
77+
return nil
78+
}
7279
shouldWe := hello.ShouldWeRunOnboarding(loginStore)
7380
if shouldWe {
7481
user, err := loginStore.GetCurrentUser()
@@ -84,7 +91,8 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
8491
},
8592
Args: cmderrors.TransformToValidationError(cobra.NoArgs),
8693
RunE: func(cmd *cobra.Command, args []string) error {
87-
err := opts.RunLogin(t, loginToken, skipBrowser, emailFlag, authProviderFlag)
94+
apiKeyLogin = strings.TrimSpace(apiKey) != ""
95+
err := opts.RunLogin(t, loginToken, apiKey, skipBrowser, emailFlag, authProviderFlag)
8896
if err != nil {
8997
// if err is ImportIDEConfigError, log err with sentry but continue
9098
if _, ok := err.(*importideconfig.ImportIDEConfigError); !ok {
@@ -97,6 +105,9 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
97105
}
98106
return err //nolint:wrapcheck // we want to return the error from the login
99107
}
108+
if apiKeyLogin {
109+
return nil
110+
}
100111
// Offer Claude Code skill installation after successful login
101112
homeDir, homeErr := opts.LoginStore.UserHomeDir()
102113
if homeErr == nil {
@@ -106,6 +117,7 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
106117
},
107118
}
108119
cmd.Flags().StringVarP(&loginToken, "token", "", "", "token provided to auto login")
120+
cmd.Flags().StringVar(&apiKey, "api-key", "", "api key and org ID provided as <api-key>::<org-id> to authenticate CLI requests")
109121
cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "print url instead of auto opening browser")
110122
cmd.Flags().StringVar(&emailFlag, "email", "", "email to use for authentication")
111123
cmd.Flags().StringVar(&authProviderFlag, "auth", "", "authentication provider to use (nvidia or legacy, default is nvidia)")
@@ -157,7 +169,12 @@ func (o LoginOptions) getOrCreateOrg(username string) (*entity.Organization, err
157169
return org, nil
158170
}
159171

160-
func (o LoginOptions) RunLogin(t *terminal.Terminal, loginToken string, skipBrowser bool, emailFlag string, authProviderFlag string) error {
172+
func (o LoginOptions) RunLogin(t *terminal.Terminal, loginToken string, apiKey string, skipBrowser bool, emailFlag string, authProviderFlag string) error {
173+
apiKey = strings.TrimSpace(apiKey)
174+
if apiKey != "" {
175+
return o.doApiKeyLogin(t, loginToken, apiKey, skipBrowser, emailFlag, authProviderFlag)
176+
}
177+
161178
tokens, _ := o.LoginStore.GetAuthTokens()
162179

163180
if authProviderFlag != "" && authProviderFlag != "nvidia" && authProviderFlag != "legacy" {
@@ -200,6 +217,41 @@ func (o LoginOptions) RunLogin(t *terminal.Terminal, loginToken string, skipBrow
200217
return nil
201218
}
202219

220+
const apiKeyOrgIDSeparator = "::"
221+
222+
func parseAPIKeyAndOrgID(apiKeyArg string) (string, string, error) {
223+
apiKey, orgID, ok := strings.Cut(strings.TrimSpace(apiKeyArg), apiKeyOrgIDSeparator)
224+
if !ok {
225+
return "", "", breverrors.NewValidationError(fmt.Sprintf("api-key must be formatted <api-key>%s<org-id>", apiKeyOrgIDSeparator))
226+
}
227+
orgID = strings.TrimSpace(orgID)
228+
if orgID == "" {
229+
return "", "", breverrors.NewValidationError("org-id is required with api-key")
230+
}
231+
return strings.TrimSpace(apiKey), orgID, nil
232+
}
233+
234+
func (o LoginOptions) doApiKeyLogin(t *terminal.Terminal, loginToken string, apiKeyArg string, skipBrowser bool, emailFlag string, authProviderFlag string) error {
235+
if loginToken != "" || skipBrowser || emailFlag != "" || authProviderFlag != "" {
236+
return breverrors.NewValidationError("api-key cannot be used with token, skip-browser, email, or auth flags")
237+
}
238+
apiKey, orgID, err := parseAPIKeyAndOrgID(apiKeyArg)
239+
if err != nil {
240+
return breverrors.WrapAndTrace(err)
241+
}
242+
if err := o.Auth.LoginWithAPIKey(apiKey); err != nil {
243+
return breverrors.WrapAndTrace(err)
244+
}
245+
if err := o.LoginStore.SetDefaultOrganization(&entity.Organization{
246+
ID: orgID,
247+
Name: orgID,
248+
}); err != nil {
249+
return breverrors.WrapAndTrace(err)
250+
}
251+
t.Vprint(t.Green(fmt.Sprintf("API key saved for org %s", orgID)))
252+
return nil
253+
}
254+
203255
func (o LoginOptions) handleOnboarding(user *entity.User, _ *terminal.Terminal) error {
204256
// figure out if we should onboard the user
205257
currentOnboardingStatus, err := user.GetOnboardingData()

0 commit comments

Comments
 (0)