mirror of
https://github.com/coder/coder.git
synced 2026-11-06 08:12:45 +08:00
244 lines
8.4 KiB
Go
244 lines
8.4 KiB
Go
package oauth2provider
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog"
|
|
"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"
|
|
"github.com/coder/coder/v2/coderd/userpassword"
|
|
)
|
|
|
|
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, err := userpassword.Compare(string(dbToken.RefreshHash), parsedToken.Secret)
|
|
if err != nil {
|
|
return xerrors.Errorf("invalid refresh token: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|