Thanks to visit codestin.com
Credit goes to github.com

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
35 changes: 30 additions & 5 deletions coderd/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,9 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
require.NoError(t, err)
//nolint:gocritic // OAuth2 app management requires owner permission.
noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
require.NoError(t, err)

// One path not tested here is when the token is empty, because Go's OAuth2
// client library will not even try to make the request.
Expand All @@ -735,6 +738,8 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
// a different app's client_id is rejected outright, rather than
// silently re-parenting the token to the presented client_id.
refreshAsApp *codersdk.OAuth2ProviderApp
// refreshSecret, if set, is presented instead of apps.Default's.
refreshSecret string
// If null, assume the token should be valid.
defaultToken *string
error string
Expand Down Expand Up @@ -777,16 +782,32 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
error: "The refresh token is invalid or expired",
},
{
// The token belongs to apps.Default, but the refresh request
// presents apps.NoPort's client_id. This must be rejected
// The token belongs to apps.Default, but apps.NoPort, correctly
// authenticated as itself, presents it. This must be rejected
// outright: silently accepting it (and re-parenting the
// token's app_id to whatever client_id is presented) would let
// a stolen refresh token be laundered to a different app,
// after which the issuing app could no longer revoke it.
name: "WrongApp",
name: "WrongAppOwnSecret",
app: apps.Default,
refreshAsApp: &apps.NoPort,
refreshSecret: noPortSecret.ClientSecretFull,
error: "The refresh token is invalid or expired",
},
{
// The advisory's shape: any client_id, without that client's
// secret. Client authentication refuses before the token is
// examined, so the answer names the credentials, not the token.
name: "WrongAppOtherSecret",
app: apps.Default,
refreshAsApp: &apps.NoPort,
error: "The refresh token is invalid or expired",
error: "The client credentials are invalid",
},
{
name: "WrongSecret",
app: apps.Default,
refreshSecret: secret.ClientSecretFull + "x",
error: "The client credentials are invalid",
},
{
name: "OK",
Expand Down Expand Up @@ -844,9 +865,13 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
if test.refreshAsApp != nil {
refreshAsApp = *test.refreshAsApp
}
refreshSecret := secret.ClientSecretFull
if test.refreshSecret != "" {
refreshSecret = test.refreshSecret
}
cfg := &oauth2.Config{
ClientID: refreshAsApp.ID.String(),
ClientSecret: secret.ClientSecretFull,
ClientSecret: refreshSecret,
Endpoint: oauth2.Endpoint{
AuthURL: refreshAsApp.Endpoints.Authorization,
DeviceAuthURL: refreshAsApp.Endpoints.DeviceAuth,
Expand Down
12 changes: 12 additions & 0 deletions coderd/oauth2provider/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
}

if errors.Is(err, errBadSecret) {
// A missing, wrong, or foreign secret is what a replayed stolen token
// looks like, so the refusal is logged. The request body stays out of
// the log: the caller never proved its credential, and the token it
// sent should not be recorded.
logger.Warn(ctx, "oauth2 token request refused: client authentication failed",
Comment thread
BobbyHo marked this conversation as resolved.
slog.F("grant_type", req.GrantType), slog.F("app_id", app.ID))
writeTokenError(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
return
}
Expand Down Expand Up @@ -675,6 +681,12 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, logger slog.
}

func refreshTokenGrant(ctx context.Context, db database.Store, logger slog.Logger, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
if !app.IsPublic() {
Comment thread
BobbyHo marked this conversation as resolved.
if _, err := authenticateClient(ctx, db, app, req.ClientSecret); err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
}

// Validate the token.
token, err := ParseFormattedSecret(req.RefreshToken)
if err != nil {
Expand Down
220 changes: 204 additions & 16 deletions coderd/oauth2provider/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ func requireExactlyOneAccepted(ctx context.Context, t *testing.T, client *coders
send := func() attempt {
start.Done()
start.Wait()
status, body, err := tryTokenRequest(ctx, t, client, form)
status, _, body, err := tryTokenRequest(ctx, t, client, form)
return attempt{status: status, body: body, err: err}
}

Expand Down Expand Up @@ -643,8 +643,10 @@ func TestOAuth2RefreshRevokedToken(t *testing.T) {

require.NoError(t, client.DeleteOAuth2ProviderAppSecret(ctx, app.ID, app.SecretID))

// The cascade removed the token row too, but the deleted secret fails
// client authentication first, so the answer names the credentials.
status, body := postTokenRequest(ctx, t, client, refreshForm(app, token.RefreshToken))
requireTokenGrantError(t, status, body)
requireTokenClientError(t, status, body)
})

// The third revocation path FR12 names. It never reaches the grant: the
Expand All @@ -665,12 +667,169 @@ func TestOAuth2RefreshRevokedToken(t *testing.T) {
})
}

// A token row whose api_key_id names no key. The FK cascade makes that
// unreachable through any API, so the constraints come off to seed it, and
// this test takes a database of its own because disabling them applies to
// every table in it. The refresh reads nothing from api_keys before the
// returning-row delete, so the missing key surfaces there as invalid_grant;
// a read of the key ahead of the delete answered HTTP 500 here.
// A confidential client authenticates on refresh as on the code exchange
// (RFC 6749 §6). A refusal leaves the token usable for a correct retry.
func TestOAuth2RefreshClientAuthentication(t *testing.T) {
t.Parallel()

db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
})
owner := coderdtest.CreateFirstUser(t, client)

// Keeps the response headers and lets the request carry HTTP Basic
// credentials.
postForm := func(ctx context.Context, t *testing.T, form url.Values, opts ...func(*http.Request)) (status int, header http.Header, body string) {
Comment thread
BobbyHo marked this conversation as resolved.
t.Helper()

status, header, body, err := tryTokenRequest(ctx, t, client, form, opts...)
require.NoError(t, err)
return status, header, body
}

// The token must survive the refusal and redeem on the next, correct
// attempt: nothing was consumed.
requireRefused := func(ctx context.Context, t *testing.T, app appWithSecret, refreshToken string, status int, body string) {
t.Helper()

requireTokenClientError(t, status, body)
_ = tokenRow(ctx, t, db, refreshToken)
status, body = postTokenRequest(ctx, t, client, refreshForm(app, refreshToken))
requireTokenResponse(t, status, body)
}

t.Run("MissingSecret", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedAppWithSecret(t, db, sql.NullString{})
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Del("client_secret")
status, header, body := postForm(ctx, t, form)
require.Equal(t, `Basic realm="coder"`, header.Get("WWW-Authenticate"))
requireRefused(ctx, t, app, refreshToken, status, body)
})

t.Run("WrongSecret", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedAppWithSecret(t, db, sql.NullString{})
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Set("client_secret", app.ClientSecret+"x")
status, body := postTokenRequest(ctx, t, client, form)
requireRefused(ctx, t, app, refreshToken, status, body)
})

// Right hash, wrong app: the secret is valid, but not for the client_id
// it is presented under. This exercises the secret's app-binding check
// in authenticateClient, the one a copy of the check would be most
// likely to lose.
t.Run("SecretOfAnotherApp", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedAppWithSecret(t, db, sql.NullString{})
other := seedAppWithSecret(t, db, sql.NullString{})
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Set("client_secret", other.ClientSecret)
status, body := postTokenRequest(ctx, t, client, form)
requireRefused(ctx, t, app, refreshToken, status, body)
})

// Authentication runs before the token is examined, so a caller without
// the secret cannot learn whether a token is live: 401 either way, never
// the invalid_grant an authenticated caller gets for a dead token.
t.Run("WrongSecretUnknownToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedAppWithSecret(t, db, sql.NullString{})

form := refreshForm(app, "coder_notreal_notreal")
form.Set("client_secret", app.ClientSecret+"x")
status, body := postTokenRequest(ctx, t, client, form)
requireTokenClientError(t, status, body)
})

t.Run("BasicAuth", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedAppWithSecret(t, db, sql.NullString{})
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Del("client_id")
form.Del("client_secret")
status, _, body := postForm(ctx, t, form, func(r *http.Request) {
r.SetBasicAuth(app.ID.String(), app.ClientSecret)
})
requireTokenResponse(t, status, body)
})

t.Run("BasicAndBodyConflict", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedAppWithSecret(t, db, sql.NullString{})
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Set("client_secret", app.ClientSecret+"x")
status, _, body := postForm(ctx, t, form, func(r *http.Request) {
r.SetBasicAuth(app.ID.String(), app.ClientSecret)
})
desc := requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidRequest)
require.Contains(t, desc, "Conflicting client credentials")
_ = tokenRow(ctx, t, db, refreshToken)
})

// A public client has no secret to check; the token's app binding and
// single-use rotation are what tie its refresh to the client.
t.Run("PublicClientNoSecret", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedPublicApp(t, db)
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Del("client_secret")
status, body := postTokenRequest(ctx, t, client, form)
token := requireTokenResponse(t, status, body)
require.False(t, tokenRow(ctx, t, db, token.RefreshToken).AppSecretID.Valid,
"a public client's refreshed token must reference no secret")
})

// No secret to compare against, so one sent anyway is ignored rather than
// rejected, as on the code exchange.
t.Run("PublicClientIgnoresSecret", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

app := seedPublicApp(t, db)
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, "workspace:ssh")

form := refreshForm(app, refreshToken)
form.Set("client_secret", "coder_unnecessary_secret")
status, body := postTokenRequest(ctx, t, client, form)
requireTokenResponse(t, status, body)
})
}

// A token row whose api_key_id names no key is unreachable through the API
// because of the FK cascade, so the constraints come off to seed it. That
// applies to every table, hence the private database. The missing key must
// surface as invalid_grant, not HTTP 500.
func TestOAuth2RefreshKeyMissing(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -812,6 +971,19 @@ func seedAppWithSecret(t *testing.T, db database.Store, allowlist sql.NullString
}
}

// seedPublicApp seeds a public client, which holds no secret. The zero
// SecretID tells seedRefreshToken to leave app_secret_id NULL.
func seedPublicApp(t *testing.T, db database.Store) appWithSecret {
Comment thread
BobbyHo marked this conversation as resolved.
t.Helper()

app := dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{
Name: testutil.GetRandomName(t),
CallbackURL: appCallbackURL,
ClientType: database.OAuth2ProviderAppClientTypePublic,
})
return appWithSecret{OAuth2ProviderApp: app}
}

// setAppAllowlist rewrites an app's registered scopes, leaving every other
// column as seeded.
func setAppAllowlist(ctx context.Context, t *testing.T, db database.Store, app appWithSecret, allowlist sql.NullString) {
Expand Down Expand Up @@ -864,7 +1036,8 @@ func seedRefreshToken(ctx context.Context, t *testing.T, db database.Store, app
HashPrefix: []byte(secret.Prefix),
RefreshHash: secret.Hashed,
AppID: app.ID,
AppSecretID: uuid.NullUUID{UUID: app.SecretID, Valid: true},
// A public client's token references no secret.
AppSecretID: uuid.NullUUID{UUID: app.SecretID, Valid: app.SecretID != uuid.Nil},
APIKeyID: key.ID,
UserID: userID,
Scope: scope,
Expand Down Expand Up @@ -969,35 +1142,39 @@ func exchangeCode(ctx context.Context, t *testing.T, client *codersdk.Client, ap
func postTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values) (int, string) {
t.Helper()

status, body, err := tryTokenRequest(ctx, t, client, form)
status, _, body, err := tryTokenRequest(ctx, t, client, form)
require.NoError(t, err)
return status, body
}

// tryTokenRequest returns the request error instead of asserting on it, so a
// caller on a spawned goroutine can carry it back to the test goroutine.
// require there runs runtime.Goexit, which skips whatever the goroutine still
// owed its parent.
func tryTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values) (int, string, error) {
// owed its parent. The opts run on the built request, for callers that need
// to set HTTP Basic credentials or other headers.
func tryTokenRequest(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values, opts ...func(*http.Request)) (int, http.Header, string, error) {
t.Helper()

req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+"/oauth2/tokens", strings.NewReader(form.Encode()))
if err != nil {
return 0, "", xerrors.Errorf("build token request: %w", err)
return 0, nil, "", xerrors.Errorf("build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, opt := range opts {
opt(req)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, "", xerrors.Errorf("post token request: %w", err)
return 0, nil, "", xerrors.Errorf("post token request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, "", xerrors.Errorf("read token response: %w", err)
return 0, nil, "", xerrors.Errorf("read token response: %w", err)
}
return resp.StatusCode, string(body), nil
return resp.StatusCode, resp.Header, string(body), nil
}

func requireTokenResponse(t *testing.T, status int, body string) codersdk.OAuth2TokenResponse {
Expand Down Expand Up @@ -1028,6 +1205,17 @@ func requireTokenGrantError(t *testing.T, status int, body string) string {
return requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidGrant)
}

// requireTokenClientError asserts the RFC 6749 §5.2 invalid_client response:
// 401 rather than the 400 every other token error carries.
func requireTokenClientError(t *testing.T, status int, body string) {
t.Helper()

require.Equal(t, http.StatusUnauthorized, status, body)
var oauthErr codersdk.OAuth2Error
require.NoError(t, json.Unmarshal([]byte(body), &oauthErr))
require.Equal(t, codersdk.OAuth2ErrorCodeInvalidClient, oauthErr.Error)
}

func requireTokenScopeError(t *testing.T, status int, body string) string {
t.Helper()
return requireTokenError(t, status, body, codersdk.OAuth2ErrorCodeInvalidScope)
Expand Down
Loading
Loading