Skip to content
Draft
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
29 changes: 16 additions & 13 deletions coderd/oauth2provider/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ var (
// still granted when a listed composite already confers it.
errScopeNotAllowed = xerrors.New("scope requests permissions beyond this app's allowed scopes")
// A comparison that failed outright. The underlying error names RBAC
// internals, so it is logged rather than rendered.
errCoverageUndecidable = xerrors.New("scope coverage against this app's allowed scopes could not be determined")
// internals, so it is logged rather than rendered. It names no ceiling
// because the comparison runs against the app's allowlist at authorization
// and against the token's own grant at refresh.
errCoverageUndecidable = xerrors.New("scope coverage could not be determined")
)

// canonicalScopes rewrites each name to the spelling the api_key_scope enum
Expand Down Expand Up @@ -87,23 +89,24 @@ func grantableScopes(appScope string) []string {
return canonicalScopes(filtered)
}

// firstScopeOutsideAllowlist returns the first scope in granted that the
// allowlist does not confer, or "" when it confers all of them. The check is
// coverage rather than membership: an app allowed `coder:workspaces.access`
// covers `workspace:read`. Both arguments must already be canonical, and an
// undecidable comparison refuses rather than grants.
func firstScopeOutsideAllowlist(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, allowlist, granted []string) (string, error) {
allowedNames := make([]rbac.ScopeName, 0, len(allowlist))
for _, a := range allowlist {
// firstScopeNotCovered returns the first scope in requested that the ceiling
// does not confer, or "" when it confers all of them. The check is coverage
// rather than membership: a ceiling of `coder:workspaces.access` covers
// `workspace:read`. Both arguments must already be canonical, and an
// undecidable comparison refuses rather than grants. The ceiling is the app's
// allowlist at authorization and the token's own grant at refresh.
func firstScopeNotCovered(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, ceiling, requested []string) (string, error) {
allowedNames := make([]rbac.ScopeName, 0, len(ceiling))
for _, a := range ceiling {
allowedNames = append(allowedNames, rbac.ScopeName(a))
}
for _, s := range granted {
for _, s := range requested {
covered, err := rbac.ScopesCover(allowedNames, rbac.ScopeName(s))
if err != nil {
logger.Warn(ctx, "oauth2 scope coverage could not be determined",
slog.Error(err),
slog.F("app_id", app.ID.String()),
slog.F("app_scope", app.Scope.String),
slog.F("ceiling", strings.Join(ceiling, " ")),
slog.F("scope", s))
return "", xerrors.Errorf("%q: %w", s, errCoverageUndecidable)
}
Expand Down Expand Up @@ -160,7 +163,7 @@ func negotiateScope(ctx context.Context, logger slog.Logger, app database.OAuth2
return strings.Join(allowlist, " "), nil // RFC 6749 §3.3 default
}

outside, err := firstScopeOutsideAllowlist(ctx, logger, app, allowlist, granted)
outside, err := firstScopeNotCovered(ctx, logger, app, allowlist, granted)
if err != nil {
return "", err
}
Expand Down
1 change: 1 addition & 0 deletions coderd/oauth2provider/authorize_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ var (
ReasonNoGrantableScope = errNoGrantableScope.Error()
ReasonScopeNotAllowed = errScopeNotAllowed.Error()
ReasonStaleScope = errStaleScope.Error()
ReasonScopeNotGranted = errScopeNotGranted.Error()
)

func TestNoScopeAllowlist(t *testing.T) {
Expand Down
66 changes: 53 additions & 13 deletions coderd/oauth2provider/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ var (
// errStaleScope means the app's registered scopes narrowed after its
// authorization code was issued and no longer cover the code's scope.
errStaleScope = xerrors.New("scope is no longer allowed by this app's registered scopes")
// errScopeNotGranted means a refresh asked for a scope the original grant
// does not confer. Distinct from errScopeNotAllowed, whose ceiling is the
// app's current allowlist: a refresh is bounded by what was granted.
errScopeNotGranted = xerrors.New("scope requests permissions beyond the scope originally granted")
)

// scopeStillCoveredByAllowlist re-checks the scope a grant was issued with
Expand All @@ -68,7 +72,7 @@ func scopeStillCoveredByAllowlist(ctx context.Context, logger slog.Logger, app d

// Canonicalized rather than assumed: the row may have been written by
// another version of this server.
outside, err := firstScopeOutsideAllowlist(ctx, logger, app, allowlist, canonicalScopes(strings.Fields(granted)))
outside, err := firstScopeNotCovered(ctx, logger, app, allowlist, canonicalScopes(strings.Fields(granted)))
if err != nil {
return err
}
Expand All @@ -78,6 +82,39 @@ func scopeStillCoveredByAllowlist(ctx context.Context, logger slog.Logger, app d
return nil
}

// narrowGrantedScope decides the scope a refreshed token carries. RFC 6749 §6
// bounds a refresh by the scope originally granted, so the request may only
// give authority up; an omitted request keeps the grant as it stands.
//
// The comparison is coverage rather than membership, as in negotiateScope: a
// grant of `coder:workspaces.access` confers `workspace:read`, and an
// unrestricted `coder:all` grant confers every scope, which membership could
// never narrow.
func narrowGrantedScope(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, granted string, requested []string) (string, error) {
if len(requested) == 0 {
return granted, nil
}

// Checked before the comparison so a typo reads as an unknown scope rather
// than as a coverage RBAC could not expand.
for _, s := range requested {
if !rbac.IsExternalScope(rbac.ScopeName(s)) {
return "", xerrors.Errorf("'%s': %w", s, errUnknownScope)
}
}

narrowed := canonicalScopes(requested)
// Canonicalized rather than assumed, as in scopeStillCoveredByAllowlist.
outside, err := firstScopeNotCovered(ctx, logger, app, canonicalScopes(strings.Fields(granted)), narrowed)
if err != nil {
return "", err
}
if outside != "" {
return "", xerrors.Errorf("'%s': %w", outside, errScopeNotGranted)
}
return strings.Join(narrowed, " "), nil
}

// scopeStringToAPIKeyScopes converts the scope persisted on an authorization
// code or refresh token into the scope list an API key is minted with. Names
// are checked here, not in apikey.Generate, whose error would surface as a 500;
Expand Down Expand Up @@ -251,7 +288,7 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
switch req.GrantType {
// TODO: Client creds, device code.
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
token, err = refreshTokenGrant(ctx, db, app, lifetimes, req)
token, err = refreshTokenGrant(ctx, db, logger, app, lifetimes, req)
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
token, err = authorizationCodeGrant(ctx, db, logger, app, lifetimes, req)
default:
Expand Down Expand Up @@ -280,10 +317,12 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired")
return
}
// The grant is well-formed and its stored scope cannot be honored, so a
// defined OAuth2 failure beats a 500.
// The grant is well-formed and either its stored scope cannot be honored
// or the request asked beyond it, so a defined OAuth2 failure beats a
// 500.
if errors.Is(err, errUnmintableScope) || errors.Is(err, errStaleScope) ||
errors.Is(err, errNoGrantableScope) || errors.Is(err, errCoverageUndecidable) {
errors.Is(err, errNoGrantableScope) || errors.Is(err, errCoverageUndecidable) ||
errors.Is(err, errUnknownScope) || errors.Is(err, errScopeNotGranted) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidScope, err.Error())
return
}
Expand Down Expand Up @@ -540,7 +579,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.
}, nil
}

func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logger, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
// Validate the token.
token, err := ParseFormattedSecret(req.RefreshToken)
if err != nil {
Expand Down Expand Up @@ -582,6 +621,11 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
}
}

grantedScope, err := narrowGrantedScope(ctx, logger, app, dbToken.Scope, strings.Fields(req.Scope))
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}

// Grab the user roles so we can perform the refresh as the user.
//nolint:gocritic // OAuth2 system context, need to read the previous API key
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID)
Expand All @@ -601,8 +645,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
return codersdk.OAuth2TokenResponse{}, err
}

// A refresh neither widens nor narrows the original grant.
scopes, err := scopeStringToAPIKeyScopes(dbToken.Scope)
scopes, err := scopeStringToAPIKeyScopes(grantedScope)
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
Expand Down Expand Up @@ -652,10 +695,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
APIKeyID: newKey.ID,
UserID: dbToken.UserID,
Audience: dbToken.Audience,
// RFC 6749 §6: a refresh with no scope parameter is granted the
// originally granted scope. Later phases narrow this against
// req.Scope; they never widen it.
Scope: dbToken.Scope,
Scope: grantedScope,
})
if err != nil {
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
Expand All @@ -671,7 +711,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
Scope: dbToken.Scope,
Scope: grantedScope,
Expiry: &key.ExpiresAt,
}, nil
}
Expand Down
106 changes: 106 additions & 0 deletions coderd/oauth2provider/tokens_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,112 @@ func TestScopeStillCoveredByAllowlist(t *testing.T) {
}
}

func TestNarrowGrantedScope(t *testing.T) {
t.Parallel()

const (
inCatalog = "coder:workspaces.access"
alsoInCatalog = "coder:templates.build"
)

tests := []struct {
name string
granted string
requested []string
want string
wantErr error
}{
{
name: "OmittedRequestKeepsTheGrant",
granted: inCatalog + " " + alsoInCatalog,
requested: nil,
want: inCatalog + " " + alsoInCatalog,
},
{
name: "GenuineSubsetAccepted",
granted: inCatalog + " " + alsoInCatalog,
requested: []string{inCatalog},
want: inCatalog,
},
{
name: "ConstituentOfCompositeAccepted",
granted: inCatalog,
requested: []string{"workspace:ssh"},
want: "workspace:ssh",
},
{
// coder:all is a member of no other set, so membership would leave
// an unrestricted grant unnarrowable.
name: "UnrestrictedGrantNarrowed",
granted: string(database.ApiKeyScopeCoderAll),
requested: []string{"workspace:read"},
want: "workspace:read",
},
{
name: "ExpansionRejected",
granted: inCatalog,
requested: []string{alsoInCatalog},
wantErr: errScopeNotGranted,
},
{
name: "PartiallyCoveredRequestRejectedWhole",
granted: inCatalog,
requested: []string{"workspace:ssh", alsoInCatalog},
wantErr: errScopeNotGranted,
},
{
name: "UnknownRequestedScopeRejectedAsUnknown",
granted: string(database.ApiKeyScopeCoderAll),
requested: []string{"not_a_real_scope"},
wantErr: errUnknownScope,
},
{
// RBAC expands debug_info:read; only the catalog keeps it internal.
name: "InternalOnlyScopeRejected",
granted: string(database.ApiKeyScopeCoderAll),
requested: []string{"debug_info:read"},
wantErr: errUnknownScope,
},
{
name: "LegacyAliasCanonicalized",
granted: string(database.ApiKeyScopeCoderAll),
requested: []string{"all"},
want: "coder:all",
},
{
name: "DuplicateRequestedScopesDeduplicated",
granted: inCatalog,
requested: []string{"workspace:ssh", "workspace:ssh"},
want: "workspace:ssh",
},
{
name: "GrantOutsideTheCatalogUndecidable",
granted: "some_removed_scope",
requested: []string{"workspace:ssh"},
wantErr: errCoverageUndecidable,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

app := database.OAuth2ProviderApp{ID: uuid.New()}
got, err := narrowGrantedScope(t.Context(), slogtest.Make(t, nil), app, test.granted, test.requested)
if test.wantErr != nil {
require.ErrorIs(t, err, test.wantErr)
assert.Empty(t, got, "a rejected refresh must not return a persistable scope")
assert.Equal(t, 1, strings.Count(err.Error(), test.wantErr.Error()),
"the rejection reason must appear once, not doubled by the wrap")
return
}
require.NoError(t, err)
assert.Equal(t, test.want, got)
requirePersistableScope(t, got)
})
}
}

// TestExtractTokenParams_Scopes tests OAuth2 scope parameter parsing
// to ensure RFC 6749 compliance where scopes are space-delimited
func TestExtractTokenParams_Scopes(t *testing.T) {
Expand Down
Loading
Loading