Files
coder/coderd/oauth2provider/revoke.go
T
Spike Curtis bddb808b25 chore: arrange imports in a standard way (#21452)
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example:

```
import (
	"context"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"golang.org/x/xerrors"
	"gopkg.in/natefinch/lumberjack.v2"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/codersdk/agentsdk"
	"github.com/coder/serpent"
)
```

3 groups: standard library, 3rd partly libs, Coder libs.

This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
2026-01-08 15:24:11 +04:00

240 lines
8.3 KiB
Go

package oauth2provider
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"errors"
"net/http"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
)
var (
// ErrTokenNotBelongsToClient is returned when a token does not belong to the requesting client
ErrTokenNotBelongsToClient = xerrors.New("token does not belong to requesting client")
// ErrInvalidTokenFormat is returned when a token has an invalid format
ErrInvalidTokenFormat = xerrors.New("invalid token format")
)
// RevokeToken implements RFC 7009 OAuth2 Token Revocation
// Authentication is unique for this endpoint in that it does not use the
// standard token authentication middleware. Instead, it expects the token that
// is being revoked to be valid.
// TODO: Currently the token validation occurs in the revocation logic itself.
// This code should be refactored to share token validation logic with other parts
// of the OAuth2 provider/http middleware.
func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
// RFC 7009 requires POST method with application/x-www-form-urlencoded
if r.Method != http.MethodPost {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusMethodNotAllowed, "invalid_request", "Method not allowed")
return
}
if err := r.ParseForm(); err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid form data")
return
}
// RFC 7009 requires 'token' parameter
token := r.Form.Get("token")
if token == "" {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing token parameter")
return
}
// Determine if this is a refresh token (starts with "coder_") or API key
// APIKeys do not have the SecretIdentifier prefix.
const coderPrefix = SecretIdentifier + "_"
isRefreshToken := strings.HasPrefix(token, coderPrefix)
// Revoke the token with ownership verification
err := db.InTx(func(tx database.Store) error {
if isRefreshToken {
// Handle refresh token revocation
return revokeRefreshTokenInTx(ctx, tx, token, app.ID)
}
// Handle API key revocation
return revokeAPIKeyInTx(ctx, tx, token, app.ID)
}, nil)
if err != nil {
if errors.Is(err, ErrTokenNotBelongsToClient) {
// RFC 7009: Return success even if token doesn't belong to client (don't reveal token existence)
logger.Debug(ctx, "token revocation failed: token does not belong to requesting client",
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
rw.WriteHeader(http.StatusOK)
return
}
if errors.Is(err, ErrInvalidTokenFormat) {
// Invalid token format should return 400 bad request
logger.Debug(ctx, "token revocation failed: invalid token format",
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid token format")
return
}
logger.Error(ctx, "token revocation failed with internal server error",
slog.Error(err),
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error")
return
}
// RFC 7009: successful revocation returns HTTP 200
rw.WriteHeader(http.StatusOK)
}
}
func revokeRefreshTokenInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error {
// Parse the refresh token using the existing function
parsedToken, err := ParseFormattedSecret(token)
if err != nil {
return ErrInvalidTokenFormat
}
// Try to find refresh token by prefix
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(parsedToken.Prefix))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Token not found - return success per RFC 7009 (don't reveal token existence)
return nil
}
return xerrors.Errorf("get oauth2 provider app token by prefix: %w", err)
}
equal := apikey.ValidateHash(dbToken.RefreshHash, parsedToken.Secret)
if !equal {
return xerrors.Errorf("invalid refresh token")
}
// Verify ownership
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID)
if err != nil {
return xerrors.Errorf("get oauth2 provider app secret: %w", err)
}
if appSecret.AppID != appID {
return ErrTokenNotBelongsToClient
}
// Delete the associated API key, which should cascade to remove the refresh token
// According to RFC 7009, when a refresh token is revoked, associated access tokens should be invalidated
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("delete api key: %w", err)
}
return nil
}
func revokeAPIKeyInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error {
keyID, secret, err := httpmw.SplitAPIToken(token)
if err != nil {
return ErrInvalidTokenFormat
}
// Get the API key
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), keyID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// API key not found - return success per RFC 7009 (don't reveal token existence)
return nil
}
return xerrors.Errorf("get api key by id: %w", err)
}
// Checking to see if the provided secret matches the stored hashed secret
hashedSecret := sha256.Sum256([]byte(secret))
if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 {
return xerrors.Errorf("invalid api key")
}
// Verify the API key was created by OAuth2
if apiKey.LoginType != database.LoginTypeOAuth2ProviderApp {
return xerrors.New("api key is not an oauth2 token")
}
// Find the associated OAuth2 token to verify ownership
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
dbToken, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// No associated OAuth2 token - return success per RFC 7009
return nil
}
return xerrors.Errorf("get oauth2 provider app token by api key id: %w", err)
}
// Verify the token belongs to the requesting app
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID)
if err != nil {
return xerrors.Errorf("get oauth2 provider app secret for api key verification: %w", err)
}
if appSecret.AppID != appID {
return ErrTokenNotBelongsToClient
}
// Delete the API key
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint
err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("delete api key for revocation: %w", err)
}
return nil
}
func RevokeApp(db database.Store) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
app := httpmw.OAuth2ProviderApp(r)
err := db.InTx(func(tx database.Store) error {
err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
AppID: app.ID,
UserID: apiKey.UserID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
AppID: app.ID,
UserID: apiKey.UserID,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return nil
}, nil)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
}
}