Skip to content

Commit 2d5b913

Browse files
committed
initial codex api key as auth
1 parent a295d3a commit 2d5b913

6 files changed

Lines changed: 380 additions & 7 deletions

File tree

pkg/auth/auth.go

Lines changed: 31 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.CredentialType == entity.CredentialTypeAPIKey {
152+
apiKey := strings.TrimSpace(tokens.AccessToken)
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,25 @@ 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+
err := t.authStore.SaveAuthTokens(entity.AuthTokens{
245+
AccessToken: apiKey,
246+
CredentialType: entity.CredentialTypeAPIKey,
247+
})
248+
if err != nil {
249+
return breverrors.WrapAndTrace(err)
250+
}
251+
return nil
252+
}
253+
225254
// showLoginURL displays the login link and CLI alternative for manual navigation.
226255
func showLoginURL(url string) {
227256
urlType := color.New(color.FgCyan, color.Bold).SprintFunc()
@@ -415,7 +444,7 @@ func AuthProviderFlagToCredentialProvider(authProviderFlag string) entity.Creden
415444
func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens) OAuth {
416445
// Set KAS as the default authenticator
417446
shouldPromptEmail := false
418-
if email == "" && tokens != nil && tokens.AccessToken != "" {
447+
if email == "" && tokens != nil && tokens.AccessToken != "" && tokens.CredentialType != entity.CredentialTypeAPIKey {
419448
email = GetEmailFromToken(tokens.AccessToken)
420449
shouldPromptEmail = true
421450
}
@@ -445,7 +474,7 @@ func StandardLogin(authProvider string, email string, tokens *entity.AuthTokens)
445474
kasAuthenticator,
446475
})
447476

448-
if tokens != nil && tokens.AccessToken != "" {
477+
if tokens != nil && tokens.AccessToken != "" && tokens.CredentialType != entity.CredentialTypeAPIKey {
449478
authenticatorFromToken, errr := authRetriever.GetByToken(tokens.AccessToken)
450479
if errr != nil {
451480
fmt.Printf("%v\n", errr)

pkg/auth/auth_test.go

Lines changed: 75 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,76 @@ 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: "brev_api_test-key",
89+
CredentialType: entity.CredentialTypeAPIKey,
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+
AccessToken: "brev_api_test-key",
122+
CredentialType: entity.CredentialTypeAPIKey,
123+
}, s.saved)
124+
}
125+
126+
func TestLoginWithAPIKey_EmptyKeyReturnsError(t *testing.T) {
127+
s := MockAuthStore{}
128+
a := Auth{
129+
authStore: &s,
130+
oauth: &MockOauth{},
131+
}
132+
133+
err := a.LoginWithAPIKey("")
134+
assert.Error(t, err)
135+
assert.False(t, s.didSave)
136+
}
137+
138+
func TestStandardLogin_APIKeyCredentialDoesNotProbeOAuthProviders(t *testing.T) {
139+
oldStdout := os.Stdout
140+
readPipe, writePipe, err := os.Pipe()
141+
assert.NoError(t, err)
142+
os.Stdout = writePipe
143+
144+
_ = StandardLogin("", "", &entity.AuthTokens{
145+
AccessToken: "brev_api_test-key",
146+
CredentialType: entity.CredentialTypeAPIKey,
147+
})
148+
149+
assert.NoError(t, writePipe.Close())
150+
os.Stdout = oldStdout
151+
out, err := io.ReadAll(readPipe)
152+
assert.NoError(t, err)
153+
assert.Empty(t, string(out))
154+
}
155+
82156
func TestSuccessNoRefreshGetFreshAccessTokenOrLogin(t *testing.T) {
83157
s := MockAuthStore{authTokens: &entity.AuthTokens{
84158
AccessToken: validToken,

pkg/cmd/login/login.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type LoginStore interface {
4747
type Auth interface {
4848
Login(skipBrowser bool) (*auth.LoginTokens, error)
4949
LoginWithToken(token string) error
50+
LoginWithAPIKey(apiKey string) error
5051
}
5152

5253
// loginStore must be a no prompt store
@@ -57,6 +58,7 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
5758
}
5859

5960
var loginToken string
61+
var apiKey string
6062
var skipBrowser bool
6163
var emailFlag string
6264
var authProviderFlag string
@@ -69,6 +71,9 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
6971
Long: "Log into brev",
7072
Example: "brev login",
7173
PostRunE: func(cmd *cobra.Command, args []string) error {
74+
if strings.TrimSpace(apiKey) != "" {
75+
return nil
76+
}
7277
shouldWe := hello.ShouldWeRunOnboarding(loginStore)
7378
if shouldWe {
7479
user, err := loginStore.GetCurrentUser()
@@ -84,7 +89,7 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
8489
},
8590
Args: cmderrors.TransformToValidationError(cobra.NoArgs),
8691
RunE: func(cmd *cobra.Command, args []string) error {
87-
err := opts.RunLogin(t, loginToken, skipBrowser, emailFlag, authProviderFlag)
92+
err := opts.RunLogin(t, loginToken, apiKey, cmd.Flags().Changed("api-key"), skipBrowser, emailFlag, authProviderFlag)
8893
if err != nil {
8994
// if err is ImportIDEConfigError, log err with sentry but continue
9095
if _, ok := err.(*importideconfig.ImportIDEConfigError); !ok {
@@ -97,6 +102,9 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
97102
}
98103
return err //nolint:wrapcheck // we want to return the error from the login
99104
}
105+
if strings.TrimSpace(apiKey) != "" {
106+
return nil
107+
}
100108
// Offer Claude Code skill installation after successful login
101109
homeDir, homeErr := opts.LoginStore.UserHomeDir()
102110
if homeErr == nil {
@@ -106,6 +114,7 @@ func NewCmdLogin(t *terminal.Terminal, loginStore LoginStore, auth Auth) *cobra.
106114
},
107115
}
108116
cmd.Flags().StringVarP(&loginToken, "token", "", "", "token provided to auto login")
117+
cmd.Flags().StringVar(&apiKey, "api-key", "", "api key provided to authenticate CLI requests")
109118
cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, "print url instead of auto opening browser")
110119
cmd.Flags().StringVar(&emailFlag, "email", "", "email to use for authentication")
111120
cmd.Flags().StringVar(&authProviderFlag, "auth", "", "authentication provider to use (nvidia or legacy, default is nvidia)")
@@ -157,7 +166,25 @@ func (o LoginOptions) getOrCreateOrg(username string) (*entity.Organization, err
157166
return org, nil
158167
}
159168

160-
func (o LoginOptions) RunLogin(t *terminal.Terminal, loginToken string, skipBrowser bool, emailFlag string, authProviderFlag string) error {
169+
func (o LoginOptions) RunLogin(t *terminal.Terminal, loginToken string, apiKey string, apiKeySet bool, skipBrowser bool, emailFlag string, authProviderFlag string) error {
170+
apiKey = strings.TrimSpace(apiKey)
171+
if apiKeySet {
172+
if apiKey == "" {
173+
return breverrors.NewValidationError("api key is empty")
174+
}
175+
if loginToken != "" || skipBrowser || emailFlag != "" || authProviderFlag != "" {
176+
return breverrors.NewValidationError("api-key cannot be used with token, skip-browser, email, or auth flags")
177+
}
178+
if !strings.HasPrefix(apiKey, auth.BrevAPIKeyPrefix) {
179+
return breverrors.NewValidationError(fmt.Sprintf("api key must start with %s", auth.BrevAPIKeyPrefix))
180+
}
181+
if err := o.Auth.LoginWithAPIKey(apiKey); err != nil {
182+
return breverrors.WrapAndTrace(err)
183+
}
184+
t.Vprint(t.Green("API key saved"))
185+
return nil
186+
}
187+
161188
tokens, _ := o.LoginStore.GetAuthTokens()
162189

163190
if authProviderFlag != "" && authProviderFlag != "nvidia" && authProviderFlag != "legacy" {

0 commit comments

Comments
 (0)