Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion internal/authutil/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
58 changes: 58 additions & 0 deletions internal/authutil/scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}
Loading