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

Skip to content
Draft
198 changes: 192 additions & 6 deletions coderd/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,9 @@ func TestOAuth2ProviderRevokeCrossApp(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 // OAauth2 app management requires owner permission.
noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
require.NoError(t, err)

tests := []struct {
name string
Expand Down Expand Up @@ -1154,19 +1157,201 @@ func TestOAuth2ProviderRevokeCrossApp(t *testing.T) {

// RFC 7009: revoking under a different app than the one that
// issued the token must not reveal whether it exists (no
// error), and must not actually end the session.
err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, tokenUnderTest)
// error), and must not actually end the session. The other app
// authenticates as itself, so this reaches the ownership check.
err = userClient.RevokeOAuth2Token(ctx, apps.NoPort.ID, noPortSecret.ClientSecretFull, tokenUnderTest)
require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009")
require.True(t, sessionWorks(), "cross-app revoke must not actually end the session")

// Revoking under the correct, issuing app must actually work.
err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, tokenUnderTest)
err = userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, tokenUnderTest)
require.NoError(t, err)
require.False(t, sessionWorks(), "same-app revoke must end the session")
})
}
}

// A confidential client authenticates at revocation as it does at the token
// endpoint (RFC 7009 §2.1). Each case gets its own session because a
// successful revocation ends it.
func TestOAuth2ProviderRevokeClientAuthentication(t *testing.T) {
t.Parallel()

ownerClient := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
apps := generateApps(ctx, t, ownerClient, "revoke-client-auth")

//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
require.NoError(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
noPortSecret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
require.NoError(t, err)

// Mints a session for apps.Default and returns the refresh token with a
// probe reporting whether the session's access token still authenticates.
newSession := func(ctx context.Context, t *testing.T) (*codersdk.Client, string, func() bool) {
t.Helper()

userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
cfg := &oauth2.Config{
ClientID: apps.Default.ID.String(),
ClientSecret: secret.ClientSecretFull,
Endpoint: oauth2.Endpoint{
AuthURL: apps.Default.Endpoints.Authorization,
TokenURL: apps.Default.Endpoints.Token,
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: apps.Default.CallbackURL,
Scopes: []string{},
}
code, verifier, err := authorizationFlow(ctx, userClient, cfg)
require.NoError(t, err)
token, err := cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifier))
require.NoError(t, err)

works := func() bool {
checkClient := codersdk.New(userClient.URL)
checkClient.SetSessionToken(token.AccessToken)
_, err := checkClient.User(ctx, codersdk.Me)
return err == nil
}
require.True(t, works(), "session should be valid before any revoke attempt")
return userClient, token.RefreshToken, works
}

// Posts the revocation form by hand so the request can carry HTTP Basic
// credentials, which the SDK method does not send. A 200 has no body
// (RFC 7009), so the decoded error is zero on success.
postRevoke := func(ctx context.Context, t *testing.T, client *codersdk.Client, form url.Values, opts ...codersdk.RequestOption) (status int, header http.Header, oauthErr codersdk.OAuth2Error) {
t.Helper()

opts = append(opts, func(r *http.Request) {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
})
resp, err := client.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), opts...)
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&oauthErr))
}
return resp.StatusCode, resp.Header, oauthErr
}

requireInvalidClient := func(t *testing.T, err error, works func() bool) {
t.Helper()

var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), string(codersdk.OAuth2ErrorCodeInvalidClient))
require.True(t, works(), "a refused revocation must not end the session")
}

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

err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", refreshToken)
requireInvalidClient(t, err, works)
})

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

err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull+"x", refreshToken)
Comment thread
BobbyHo marked this conversation as resolved.
requireInvalidClient(t, err, works)
})

// Right hash, wrong app: the secret is valid, but not for the client_id
// it is presented under.
t.Run("SecretOfAnotherApp", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
userClient, refreshToken, works := newSession(ctx, t)

err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, noPortSecret.ClientSecretFull, refreshToken)
requireInvalidClient(t, err, works)
})

// Authentication runs before the token is classified, so a caller without
// the secret learns nothing from the token it presents: 401, not the 200
// an unknown token would otherwise receive under RFC 7009 §2.2.
// The fake token is not tied to the session, so only the status check
// carries this case; the session probe is the shared helper's
// post-condition.
t.Run("MissingSecretUnknownToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
userClient, _, works := newSession(ctx, t)

err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, "", "coder_notreal_notreal")
requireInvalidClient(t, err, works)
})

t.Run("CorrectSecretOwnToken", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
userClient, refreshToken, works := newSession(ctx, t)

err := userClient.RevokeOAuth2Token(ctx, apps.Default.ID, secret.ClientSecretFull, refreshToken)
require.NoError(t, err)
require.False(t, works(), "an authenticated revocation must end the session")
})

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

form := url.Values{}
form.Set("token", refreshToken)
status, _, _ := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull)
})
require.Equal(t, http.StatusOK, status)
require.False(t, works(), "a Basic-authenticated revocation must end the session")
})

t.Run("BasicAuthWrongSecret", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
userClient, refreshToken, works := newSession(ctx, t)

form := url.Values{}
form.Set("token", refreshToken)
status, header, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull+"x")
})
require.Equal(t, http.StatusUnauthorized, status)
require.Equal(t, `Basic realm="coder"`, header.Get("WWW-Authenticate"))
require.Equal(t, codersdk.OAuth2ErrorCodeInvalidClient, oauthErr.Error)
require.True(t, works(), "a refused revocation must not end the session")
})

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

form := url.Values{}
form.Set("token", refreshToken)
form.Set("client_id", apps.Default.ID.String())
form.Set("client_secret", secret.ClientSecretFull+"x")
status, _, oauthErr := postRevoke(ctx, t, userClient, form, func(r *http.Request) {
r.SetBasicAuth(apps.Default.ID.String(), secret.ClientSecretFull)
})
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t, codersdk.OAuth2ErrorCodeInvalidRequest, oauthErr.Error)
require.Contains(t, oauthErr.ErrorDescription, "Conflicting client credentials")
require.True(t, works(), "a refused revocation must not end the session")
})
}

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

Expand All @@ -1193,11 +1378,12 @@ func TestOAuth2ProviderPublicClientTokenLifecycle(t *testing.T) {
session := refreshedPublicClientSession(ctx, t)
tokenUnderTest := test.tokenFor(session.token)

err := session.userClient.RevokeOAuth2Token(ctx, session.otherAppID, tokenUnderTest)
// Public clients have no secret to present.
err := session.userClient.RevokeOAuth2Token(ctx, session.otherAppID, "", tokenUnderTest)
require.NoError(t, err, "cross-app revoke must appear to succeed per RFC 7009")
require.True(t, session.works(), "cross-app revoke must not end the session")

err = session.userClient.RevokeOAuth2Token(ctx, session.appID, tokenUnderTest)
err = session.userClient.RevokeOAuth2Token(ctx, session.appID, "", tokenUnderTest)
require.NoError(t, err)
require.False(t, session.works(), "public client must be able to revoke its own token")
})
Expand Down Expand Up @@ -2253,7 +2439,7 @@ func TestOAuth2CoderClient(t *testing.T) {

// Revoking the refresh token should prevent further access
// Revoking the refresh also invalidates the associated access token.
err = usingOauth.RevokeOAuth2Token(ctx, app.ID, token.RefreshToken)
err = usingOauth.RevokeOAuth2Token(ctx, app.ID, appsecret.ClientSecretFull, token.RefreshToken)
require.NoError(t, err)

_, err = usingOauth.User(ctx, codersdk.Me)
Expand Down
12 changes: 11 additions & 1 deletion coderd/oauth2provider/nostore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,19 @@ func TestOAuth2NoStoreHeaders(t *testing.T) {
form.Set("token", token.RefreshToken)
form.Set("client_id", app.ID.String())

// RFC 7009 success is a bare WriteHeader(200), never httpapi.Write.
// A confidential client authenticates at revocation (RFC 7009 §2.1),
// so the 401 refusal must be no-store as well.
resp := doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType)
defer resp.Body.Close()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
requireNoStore(t, resp)
require.Equal(t, `Basic realm="coder"`, resp.Header.Get("WWW-Authenticate"))

form.Set("client_secret", secret)

// RFC 7009 success is a bare WriteHeader(200), never httpapi.Write.
resp = doRequest(ctx, t, http.MethodPost, baseURL+"/oauth2/revoke", strings.NewReader(form.Encode()), formContentType)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
requireNoStore(t, resp)
})
Expand Down
38 changes: 38 additions & 0 deletions coderd/oauth2provider/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ func extractRevocationRequest(r *http.Request) (codersdk.OAuth2TokenRevocationRe
ClientSecret: r.Form.Get("client_secret"),
}

// RFC 7009 §2.1 defers to RFC 6749 §2.3 for client authentication, so a
// confidential client may present its credentials as HTTP Basic here as
// it does at the token endpoint.
var err error
req.ClientID, req.ClientSecret, err = mergeBasicClientAuth(r, req.ClientID, req.ClientSecret)
if err != nil {
return codersdk.OAuth2TokenRevocationRequest{}, err
}

// RFC 7009 requires 'token' parameter.
if req.Token == "" {
return codersdk.OAuth2TokenRevocationRequest{}, xerrors.New("missing token parameter")
Expand Down Expand Up @@ -67,11 +76,40 @@ func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc {
}

req, err := extractRevocationRequest(r)
if errors.Is(err, errConflictingClientAuth) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Conflicting client credentials between Authorization header and request body")
return
}
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
return
}

// RFC 7009 §2.1: "The authorization server first validates the client
// credentials (in case of a confidential client) and then verifies
// whether the token was issued to the client making the revocation
// request." A public client has no credentials to validate; the AppID
// checks in both revoke branches are its binding. Authenticating before
// the token is examined also keeps an unauthenticated caller from
// learning anything about the token it presents.
if !app.IsPublic() {
if _, err := authenticateClient(ctx, db, app, req.ClientSecret); err != nil {
if errors.Is(err, errBadSecret) {
logger.Warn(ctx, "oauth2 revocation refused: client authentication failed",
Comment thread
BobbyHo marked this conversation as resolved.
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
return
}
logger.Error(ctx, "token revocation failed with internal server error",
Comment thread
BobbyHo marked this conversation as resolved.
slog.Error(err),
Comment thread
BobbyHo marked this conversation as resolved.
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Internal server error")
return
}
}

// Determine if this is a refresh token (starts with "coder_") or API key
// APIKeys do not have the SecretIdentifier prefix.
const coderPrefix = SecretIdentifier + "_"
Expand Down
10 changes: 7 additions & 3 deletions codersdk/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,16 @@ type OAuth2TokenRevocationRequest struct {
ClientSecret string `json:"client_secret,omitempty"`
}

// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation.
func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, token string) error {
// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token
// revocation. A confidential client must present its clientSecret; a public
// client passes an empty string and is bound to the token by client_id alone.
func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, clientSecret, token string) error {
Comment thread
BobbyHo marked this conversation as resolved.
form := url.Values{}
form.Set("token", token)
// Client authentication is handled via the client_id in the app middleware
form.Set("client_id", clientID.String())
if clientSecret != "" {
form.Set("client_secret", clientSecret)
}

res, err := c.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), func(r *http.Request) {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
Expand Down
Loading
Loading