diff --git a/internal/authutil/scopes.go b/internal/authutil/scopes.go index 4b5de73af..c53c098b0 100644 --- a/internal/authutil/scopes.go +++ b/internal/authutil/scopes.go @@ -27,10 +27,16 @@ func UnionScopes(existing, challenged []string) []string { // ScopesFromToken extracts the granted scopes from an OAuth2 token response. // Per RFC 6749 ยง5.1, the scope parameter is optional; returns nil if absent. +// An empty scope names no scope-token, so it is treated as absent too: +// callers take nil to mean the requested scopes were granted. func ScopesFromToken(token *oauth2.Token) []string { scope, ok := token.Extra("scope").(string) if !ok { return nil } - return strings.Fields(scope) + scopes := strings.Fields(scope) + if len(scopes) == 0 { + return nil + } + return scopes } diff --git a/internal/authutil/scopes_test.go b/internal/authutil/scopes_test.go index 1fdfd7d60..f442570d4 100644 --- a/internal/authutil/scopes_test.go +++ b/internal/authutil/scopes_test.go @@ -4,10 +4,12 @@ package authutil import ( + "net/url" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "golang.org/x/oauth2" ) func TestUnionScopes(t *testing.T) { @@ -70,3 +72,59 @@ func TestUnionScopes(t *testing.T) { }) } } + +func TestScopesFromToken(t *testing.T) { + tests := []struct { + name string + token *oauth2.Token + want []string + }{ + { + name: "no scope", + token: &oauth2.Token{AccessToken: "t"}, + want: nil, + }, + { + name: "single scope", + token: (&oauth2.Token{AccessToken: "t"}).WithExtra(map[string]any{"scope": "read"}), + want: []string{"read"}, + }, + { + name: "space-delimited scopes", + token: (&oauth2.Token{AccessToken: "t"}).WithExtra(map[string]any{"scope": "read write admin"}), + want: []string{"read", "write", "admin"}, + }, + { + name: "form-encoded response", + token: (&oauth2.Token{AccessToken: "t"}).WithExtra(url.Values{"scope": {"read write"}}), + want: []string{"read", "write"}, + }, + { + name: "non-string scope", + token: (&oauth2.Token{AccessToken: "t"}).WithExtra(map[string]any{"scope": 42.0}), + want: nil, + }, + // An empty scope names no scope-token (RFC 6749 section 3.3), so it + // carries no more information than an absent one. Callers rely on nil + // to fall back to the requested scopes. + { + name: "empty scope", + token: (&oauth2.Token{AccessToken: "t"}).WithExtra(map[string]any{"scope": ""}), + want: nil, + }, + { + name: "whitespace-only scope", + token: (&oauth2.Token{AccessToken: "t"}).WithExtra(map[string]any{"scope": " \t "}), + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ScopesFromToken(tt.token) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ScopesFromToken() mismatch (-want +got):\n%s", diff) + } + }) + } +}