Skip to content

Commit 6e60687

Browse files
Merge pull request #187 from overmindtech/permissions-error-improvements
Added scope checking to API keys
2 parents e9a7d3a + 376eb4d commit 6e60687

File tree

1 file changed

+132
-85
lines changed

1 file changed

+132
-85
lines changed

cmd/root.go

Lines changed: 132 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -146,31 +146,54 @@ func readLocalToken(homeDir string, expectedScopes []string) (string, []string,
146146
return token.AccessToken, currentScopes, nil
147147
}
148148

149-
// ensureToken
150-
func ensureToken(ctx context.Context, requiredScopes []string) (context.Context, error) {
151-
// get a token from the api key if present
152-
if viper.GetString("api-key") != "" {
153-
log.WithContext(ctx).Debug("using provided token for authentication")
154-
apiKey := viper.GetString("api-key")
155-
if strings.HasPrefix(apiKey, "ovm_api_") {
156-
// exchange api token for JWT
157-
client := UnauthenticatedApiKeyClient(ctx)
158-
resp, err := client.ExchangeKeyForToken(ctx, &connect.Request[sdp.ExchangeKeyForTokenRequest]{
159-
Msg: &sdp.ExchangeKeyForTokenRequest{
160-
ApiKey: apiKey,
161-
},
162-
})
163-
if err != nil {
164-
return ctx, fmt.Errorf("error authenticating the API token: %w", err)
165-
}
166-
log.WithContext(ctx).Debug("successfully authenticated")
167-
apiKey = resp.Msg.GetAccessToken()
168-
} else {
169-
return ctx, errors.New("OVM_API_KEY does not match pattern 'ovm_api_*'")
149+
// Check whether or not a token has all of the required scopes. Returns a
150+
// boolean and an error which will be populated if we couldn't read the token
151+
func tokenHasAllScopes(token string, requiredScopes []string) (bool, error) {
152+
claims, err := extractClaims(token)
153+
154+
if err != nil {
155+
return false, fmt.Errorf("error extracting claims from token: %w", err)
156+
}
157+
158+
// Check that the token has the right scopes
159+
for _, scope := range requiredScopes {
160+
if !claims.HasScope(scope) {
161+
return false, nil
162+
}
163+
}
164+
165+
return true, nil
166+
}
167+
168+
// Gets a token using an API key
169+
func getAPIKeyToken(ctx context.Context, apiKey string) (string, error) {
170+
log.WithContext(ctx).Debug("using provided token for authentication")
171+
172+
var accessToken string
173+
174+
if strings.HasPrefix(apiKey, "ovm_api_") {
175+
// exchange api token for JWT
176+
client := UnauthenticatedApiKeyClient(ctx)
177+
resp, err := client.ExchangeKeyForToken(ctx, &connect.Request[sdp.ExchangeKeyForTokenRequest]{
178+
Msg: &sdp.ExchangeKeyForTokenRequest{
179+
ApiKey: apiKey,
180+
},
181+
})
182+
if err != nil {
183+
return "", fmt.Errorf("error authenticating the API token: %w", err)
170184
}
171-
return context.WithValue(ctx, sdp.UserTokenContextKey{}, apiKey), nil
185+
log.WithContext(ctx).Debug("successfully authenticated")
186+
accessToken = resp.Msg.GetAccessToken()
187+
} else {
188+
return "", errors.New("OVM_API_KEY does not match pattern 'ovm_api_*'")
172189
}
173190

191+
return accessToken, nil
192+
}
193+
194+
// Gets a token from Oauth with the required scopes. This method will also cache
195+
// that token locally for use later, and will use the cached token if possible
196+
func getOauthToken(ctx context.Context, requiredScopes []string) (string, error) {
174197
var localScopes []string
175198

176199
// Check for a locally saved token in ~/.overmind
@@ -182,92 +205,116 @@ func ensureToken(ctx context.Context, requiredScopes []string) (context.Context,
182205
if err != nil {
183206
log.WithContext(ctx).Debugf("Error reading local token, ignoring: %v", err)
184207
} else {
185-
return context.WithValue(ctx, sdp.UserTokenContextKey{}, localToken), nil
208+
// If we already have the right scopes, return the token
209+
return localToken, nil
186210
}
187211
}
188212

213+
// If we need to get a new token, request the required scopes on top of
214+
// whatever ones the current local, valid token has so that we don't
215+
// keep replacing it
216+
189217
// Check to see if the URL is secure
190218
appurl := viper.GetString("url")
191219
parsed, err := url.Parse(appurl)
192220
if err != nil {
193221
log.WithContext(ctx).WithError(err).Error("Failed to parse --url")
194-
return ctx, fmt.Errorf("error parsing --url: %w", err)
195-
}
196-
197-
if parsed.Scheme == "wss" || parsed.Scheme == "https" || parsed.Hostname() == "localhost" {
198-
// If we need to get a new token, request the required scopes on top of
199-
// whatever ones the current local, valid token has so that we don't
200-
// keep replacing it
201-
requestScopes := append(requiredScopes, localScopes...)
202-
203-
// Authenticate using the oauth device authorization flow
204-
config := oauth2.Config{
205-
ClientID: viper.GetString("cli-auth0-client-id"),
206-
Endpoint: oauth2.Endpoint{
207-
AuthURL: fmt.Sprintf("https://%v/authorize", viper.GetString("cli-auth0-domain")),
208-
TokenURL: fmt.Sprintf("https://%v/oauth/token", viper.GetString("cli-auth0-domain")),
209-
DeviceAuthURL: fmt.Sprintf("https://%v/oauth/device/code", viper.GetString("cli-auth0-domain")),
210-
},
211-
Scopes: requestScopes,
212-
}
222+
return "", fmt.Errorf("error parsing --url: %w", err)
223+
}
213224

214-
deviceCode, err := config.DeviceAuth(ctx, oauth2.SetAuthURLParam("audience", "https://api.overmind.tech"))
215-
if err != nil {
216-
return ctx, fmt.Errorf("error getting device code: %w", err)
217-
}
225+
if !(parsed.Scheme == "wss" || parsed.Scheme == "https" || parsed.Hostname() == "localhost") {
226+
return "", fmt.Errorf("target URL (%v) is insecure", parsed)
227+
}
228+
// If we need to get a new token, request the required scopes on top of
229+
// whatever ones the current local, valid token has so that we don't
230+
// keep replacing it
231+
requestScopes := append(requiredScopes, localScopes...)
232+
233+
// Authenticate using the oauth device authorization flow
234+
config := oauth2.Config{
235+
ClientID: viper.GetString("cli-auth0-client-id"),
236+
Endpoint: oauth2.Endpoint{
237+
AuthURL: fmt.Sprintf("https://%v/authorize", viper.GetString("cli-auth0-domain")),
238+
TokenURL: fmt.Sprintf("https://%v/oauth/token", viper.GetString("cli-auth0-domain")),
239+
DeviceAuthURL: fmt.Sprintf("https://%v/oauth/device/code", viper.GetString("cli-auth0-domain")),
240+
},
241+
Scopes: requestScopes,
242+
}
243+
244+
deviceCode, err := config.DeviceAuth(ctx, oauth2.SetAuthURLParam("audience", "https://api.overmind.tech"))
245+
if err != nil {
246+
return "", fmt.Errorf("error getting device code: %w", err)
247+
}
248+
249+
fmt.Printf("Go to %v and verify this code: %v\n", deviceCode.VerificationURIComplete, deviceCode.UserCode)
250+
251+
token, err := config.DeviceAccessToken(ctx, deviceCode)
252+
if err != nil {
253+
fmt.Printf(": %v\n", err)
254+
return "", fmt.Errorf("Error exchanging Device Code for for access token: %w", err)
255+
}
218256

219-
fmt.Printf("Go to %v and verify this code: %v\n", deviceCode.VerificationURIComplete, deviceCode.UserCode)
257+
log.WithContext(ctx).Info("Authenticated successfully ✅")
220258

221-
token, err := config.DeviceAccessToken(ctx, deviceCode)
259+
// Save the token locally
260+
if home, err := os.UserHomeDir(); err == nil {
261+
// Create the directory if it doesn't exist
262+
err = os.MkdirAll(filepath.Join(home, ".overmind"), 0700)
222263
if err != nil {
223-
fmt.Printf(": %v\n", err)
224-
return ctx, fmt.Errorf("Error exchanging Device Code for for access token: %w", err)
264+
log.WithContext(ctx).WithError(err).Error("Failed to create ~/.overmind directory")
225265
}
226266

227-
// Check that we actually got the claims we asked for. If you don't have
228-
// permission auth0 will just not assign those scopes rather than fail
229-
claims, err := extractClaims(token.AccessToken)
230-
267+
// Write the token to a file
268+
path := filepath.Join(home, ".overmind", "token.json")
269+
file, err := os.Create(path)
231270
if err != nil {
232-
return ctx, fmt.Errorf("error extracting claims from token: %w", err)
271+
log.WithContext(ctx).WithError(err).Errorf("Failed to create token file at %v", path)
233272
}
234273

235-
for _, scope := range requiredScopes {
236-
if !claims.HasScope(scope) {
237-
return ctx, fmt.Errorf("authenticated successfully, but you don't have the required permission: '%v'", scope)
238-
}
274+
// Encode the token
275+
err = json.NewEncoder(file).Encode(token)
276+
if err != nil {
277+
log.WithContext(ctx).WithError(err).Errorf("Failed to encode token file at %v", path)
239278
}
240279

241-
log.WithContext(ctx).Info("Authenticated successfully ✅")
280+
log.WithContext(ctx).Debugf("Saved token to %v", path)
281+
}
242282

243-
// Save the token locally
244-
if home, err := os.UserHomeDir(); err == nil {
245-
// Create the directory if it doesn't exist
246-
err = os.MkdirAll(filepath.Join(home, ".overmind"), 0700)
247-
if err != nil {
248-
log.WithContext(ctx).WithError(err).Error("Failed to create ~/.overmind directory")
249-
}
283+
return token.AccessToken, nil
284+
}
250285

251-
// Write the token to a file
252-
path := filepath.Join(home, ".overmind", "token.json")
253-
file, err := os.Create(path)
254-
if err != nil {
255-
log.WithContext(ctx).WithError(err).Errorf("Failed to create token file at %v", path)
256-
}
286+
// ensureToken
287+
func ensureToken(ctx context.Context, requiredScopes []string) (context.Context, error) {
288+
var accessToken string
289+
var err error
257290

258-
// Encode the token
259-
err = json.NewEncoder(file).Encode(token)
260-
if err != nil {
261-
log.WithContext(ctx).WithError(err).Errorf("Failed to encode token file at %v", path)
262-
}
291+
// get a token from the api key if present
292+
if apiKey := viper.GetString("api-key"); apiKey != "" {
293+
accessToken, err = getAPIKeyToken(ctx, apiKey)
294+
} else {
295+
accessToken, err = getOauthToken(ctx, requiredScopes)
296+
}
263297

264-
log.WithContext(ctx).Debugf("Saved token to %v", path)
265-
}
298+
if err != nil {
299+
return ctx, fmt.Errorf("error getting token: %w", err)
300+
}
301+
302+
// Check that we actually got the claims we asked for. If you don't have
303+
// permission auth0 will just not assign those scopes rather than fail
304+
claims, err := extractClaims(accessToken)
266305

267-
// Set the token
268-
return context.WithValue(ctx, sdp.UserTokenContextKey{}, token.AccessToken), nil
306+
if err != nil {
307+
return ctx, fmt.Errorf("error extracting claims from token: %w", err)
308+
}
309+
310+
for _, scope := range requiredScopes {
311+
if !claims.HasScope(scope) {
312+
return ctx, fmt.Errorf("authenticated successfully, but you don't have the required permission: '%v'", scope)
313+
}
269314
}
270-
return ctx, fmt.Errorf("no OVM_API_KEY configured and target URL (%v) is insecure", parsed)
315+
316+
// Add the token to the context
317+
return context.WithValue(ctx, sdp.UserTokenContextKey{}, accessToken), nil
271318
}
272319

273320
// getChangeUuid returns the UUID of a change, as selected by --uuid or --change, or a state with the specified status and having --ticket-link
@@ -302,16 +349,16 @@ func getChangeUuid(ctx context.Context, expectedStatus sdp.ChangeStatus, errNotF
302349
// Finally look through all open changes to find one with a matching ticket link
303350
client := AuthenticatedChangesClient(ctx)
304351

305-
var maybeChangeUuid *uuid.UUID
306352
changesList, err := client.ListChangesByStatus(ctx, &connect.Request[sdp.ListChangesByStatusRequest]{
307353
Msg: &sdp.ListChangesByStatusRequest{
308354
Status: expectedStatus,
309355
},
310356
})
311357
if err != nil {
312-
return uuid.Nil, fmt.Errorf("failed to searching for existing changes: %w", err)
358+
return uuid.Nil, fmt.Errorf("failed to search for existing changes: %w", err)
313359
}
314360

361+
var maybeChangeUuid *uuid.UUID
315362
for _, c := range changesList.Msg.GetChanges() {
316363
if c.GetProperties().GetTicketLink() == ticketLink {
317364
maybeChangeUuid = c.GetMetadata().GetUUIDParsed()

0 commit comments

Comments
 (0)