mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: implement oauth2 RFC 7009 token revocation endpoint (#20362)
Adds RFC 7009 token revocation endpoint
This commit is contained in:
Generated
+42
@@ -3082,6 +3082,45 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/oauth2/revoke": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/x-www-form-urlencoded"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Revoke OAuth2 tokens (RFC 7009).",
|
||||
"operationId": "oauth2-token-revocation",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Client ID for authentication",
|
||||
"name": "client_id",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The token to revoke",
|
||||
"name": "token",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Hint about token type (access_token or refresh_token)",
|
||||
"name": "token_type_hint",
|
||||
"in": "formData"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Token successfully revoked"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/oauth2/tokens": {
|
||||
"post": {
|
||||
"produces": [
|
||||
@@ -15353,6 +15392,9 @@ const docTemplate = `{
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"token_revoke": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+38
@@ -2720,6 +2720,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/oauth2/revoke": {
|
||||
"post": {
|
||||
"consumes": ["application/x-www-form-urlencoded"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Revoke OAuth2 tokens (RFC 7009).",
|
||||
"operationId": "oauth2-token-revocation",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Client ID for authentication",
|
||||
"name": "client_id",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The token to revoke",
|
||||
"name": "token",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Hint about token type (access_token or refresh_token)",
|
||||
"name": "token_type_hint",
|
||||
"in": "formData"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Token successfully revoked"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/oauth2/tokens": {
|
||||
"post": {
|
||||
"produces": ["application/json"],
|
||||
@@ -13911,6 +13946,9 @@
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"token_revoke": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -985,6 +985,16 @@ func New(options *Options) *API {
|
||||
r.Post("/", api.postOAuth2ProviderAppToken())
|
||||
})
|
||||
|
||||
// RFC 7009 Token Revocation Endpoint
|
||||
r.Route("/revoke", func(r chi.Router) {
|
||||
r.Use(
|
||||
// RFC 7009 endpoint uses OAuth2 client authentication, not API key
|
||||
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
|
||||
)
|
||||
// POST /revoke is the standard OAuth2 token revocation endpoint per RFC 7009
|
||||
r.Post("/", api.revokeOAuth2Token())
|
||||
})
|
||||
|
||||
// RFC 7591 Dynamic Client Registration - Public endpoint
|
||||
r.Post("/register", api.postOAuth2ClientRegistration())
|
||||
|
||||
|
||||
@@ -383,6 +383,9 @@ func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) cod
|
||||
}).String(),
|
||||
// We do not currently support DeviceAuth.
|
||||
DeviceAuth: "",
|
||||
TokenRevoke: accessURL.ResolveReference(&url.URL{
|
||||
Path: "/oauth2/revoke",
|
||||
}).String(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,34 @@ var (
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
subjectSystemOAuth2 = rbac.Subject{
|
||||
Type: rbac.SubjectTypeSystemOAuth,
|
||||
FriendlyName: "System OAuth2",
|
||||
ID: uuid.Nil.String(),
|
||||
Roles: rbac.Roles([]rbac.Role{
|
||||
{
|
||||
Identifier: rbac.RoleIdentifier{Name: "system-oauth2"},
|
||||
DisplayName: "System OAuth2",
|
||||
Site: rbac.Permissions(map[string][]policy.Action{
|
||||
// OAuth2 resources - full CRUD permissions
|
||||
rbac.ResourceOauth2App.Type: rbac.ResourceOauth2App.AvailableActions(),
|
||||
rbac.ResourceOauth2AppSecret.Type: rbac.ResourceOauth2AppSecret.AvailableActions(),
|
||||
rbac.ResourceOauth2AppCodeToken.Type: rbac.ResourceOauth2AppCodeToken.AvailableActions(),
|
||||
|
||||
// API key permissions needed for OAuth2 token revocation
|
||||
rbac.ResourceApiKey.Type: {policy.ActionRead, policy.ActionDelete},
|
||||
|
||||
// Minimal read permissions that might be needed for OAuth2 operations
|
||||
rbac.ResourceUser.Type: {policy.ActionRead},
|
||||
rbac.ResourceOrganization.Type: {policy.ActionRead},
|
||||
}),
|
||||
User: []rbac.Permission{},
|
||||
ByOrgID: map[string]rbac.OrgPermissions{},
|
||||
},
|
||||
}),
|
||||
Scope: rbac.ScopeAll,
|
||||
}.WithCachedASTValue()
|
||||
|
||||
subjectSystemReadProvisionerDaemons = rbac.Subject{
|
||||
Type: rbac.SubjectTypeSystemReadProvisionerDaemons,
|
||||
FriendlyName: "Provisioner Daemons Reader",
|
||||
@@ -643,6 +671,12 @@ func AsSystemRestricted(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectSystemRestricted)
|
||||
}
|
||||
|
||||
// AsSystemOAuth2 returns a context with an actor that has permissions
|
||||
// required for OAuth2 provider operations (token revocation, device codes, registration).
|
||||
func AsSystemOAuth2(ctx context.Context) context.Context {
|
||||
return As(ctx, subjectSystemOAuth2)
|
||||
}
|
||||
|
||||
// AsSystemReadProvisionerDaemons returns a context with an actor that has permissions
|
||||
// to read provisioner daemons.
|
||||
func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context {
|
||||
|
||||
@@ -160,6 +160,19 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
|
||||
return oauth2provider.RevokeApp(api.Database)
|
||||
}
|
||||
|
||||
// @Summary Revoke OAuth2 tokens (RFC 7009).
|
||||
// @ID oauth2-token-revocation
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Tags Enterprise
|
||||
// @Param client_id formData string true "Client ID for authentication"
|
||||
// @Param token formData string true "The token to revoke"
|
||||
// @Param token_type_hint formData string false "Hint about token type (access_token or refresh_token)"
|
||||
// @Success 200 "Token successfully revoked"
|
||||
// @Router /oauth2/revoke [post]
|
||||
func (api *API) revokeOAuth2Token() http.HandlerFunc {
|
||||
return oauth2provider.RevokeToken(api.Database, api.Logger)
|
||||
}
|
||||
|
||||
// @Summary OAuth2 authorization server metadata.
|
||||
// @ID oauth2-authorization-server-metadata
|
||||
// @Produce json
|
||||
|
||||
+76
-1
@@ -720,7 +720,7 @@ func TestOAuth2ProviderRevoke(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeleteToken",
|
||||
name: "DeleteApp",
|
||||
fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
|
||||
err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -1603,5 +1603,80 @@ func TestOAuth2RegistrationAccessToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client.
|
||||
func TestOAuth2CoderClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, owner)
|
||||
|
||||
// Setup an oauth app
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
||||
Name: "new-app",
|
||||
CallbackURL: "http://localhost",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &oauth2.Config{
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: appsecret.ClientSecretFull,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: app.Endpoints.Authorization,
|
||||
DeviceAuthURL: app.Endpoints.DeviceAuth,
|
||||
TokenURL: app.Endpoints.Token,
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
RedirectURL: app.CallbackURL,
|
||||
Scopes: []string{},
|
||||
}
|
||||
|
||||
// Make a new user
|
||||
client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
|
||||
|
||||
// Do an OAuth2 token exchange and get a new client with an oauth token
|
||||
state := uuid.NewString()
|
||||
|
||||
// Get an OAuth2 code for a token exchange
|
||||
code, err := oidctest.OAuth2GetCode(
|
||||
cfg.AuthCodeURL(state),
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
// Change to POST to simulate the form submission
|
||||
req.Method = http.MethodPost
|
||||
|
||||
// Prevent automatic redirect following
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return client.Request(ctx, req.Method, req.URL.String(), nil)
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := cfg.Exchange(ctx, code)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use the oauth client's authentication
|
||||
// TODO: The SDK could probably support this with a better syntax/api.
|
||||
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
||||
usingOauth := codersdk.New(owner.URL)
|
||||
usingOauth.HTTPClient = oauthClient
|
||||
|
||||
me, err := usingOauth.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, me.ID)
|
||||
|
||||
// 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)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = usingOauth.User(ctx, codersdk.Me)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// NOTE: OAuth2 client registration validation tests have been migrated to
|
||||
// oauth2provider/validation_test.go for better separation of concerns
|
||||
|
||||
@@ -26,12 +26,6 @@ import (
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
// Constants for OAuth2 secret generation (RFC 7591)
|
||||
const (
|
||||
secretLength = 40 // Length of the actual secret part
|
||||
displaySecretLength = 6 // Length of visible part in UI (last 6 characters)
|
||||
)
|
||||
|
||||
// CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register
|
||||
func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -121,7 +115,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
|
||||
}
|
||||
|
||||
// Create client secret - parse the formatted secret to get components
|
||||
parsedSecret, err := parseFormattedSecret(clientSecret)
|
||||
parsedSecret, err := ParseFormattedSecret(clientSecret)
|
||||
if err != nil {
|
||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||
"server_error", "Failed to parse generated secret")
|
||||
@@ -132,7 +126,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
|
||||
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: now,
|
||||
SecretPrefix: []byte(parsedSecret.prefix),
|
||||
SecretPrefix: []byte(parsedSecret.Prefix),
|
||||
HashedSecret: []byte(hashedSecret),
|
||||
DisplaySecret: createDisplaySecret(clientSecret),
|
||||
AppID: clientID,
|
||||
@@ -551,27 +545,6 @@ func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, sta
|
||||
_ = json.NewEncoder(rw).Encode(errorResponse)
|
||||
}
|
||||
|
||||
// parsedSecret represents the components of a formatted OAuth2 secret
|
||||
type parsedSecret struct {
|
||||
prefix string
|
||||
secret string
|
||||
}
|
||||
|
||||
// parseFormattedSecret parses a formatted secret like "coder_prefix_secret"
|
||||
func parseFormattedSecret(secret string) (parsedSecret, error) {
|
||||
parts := strings.Split(secret, "_")
|
||||
if len(parts) != 3 {
|
||||
return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts))
|
||||
}
|
||||
if parts[0] != "coder" {
|
||||
return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0])
|
||||
}
|
||||
return parsedSecret{
|
||||
prefix: parts[1],
|
||||
secret: parts[2],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createDisplaySecret creates a display version of the secret showing only the last few characters
|
||||
func createDisplaySecret(secret string) string {
|
||||
if len(secret) <= displaySecretLength {
|
||||
|
||||
@@ -1,15 +1,214 @@
|
||||
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()
|
||||
|
||||
@@ -2,32 +2,68 @@ package oauth2provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type AppSecret struct {
|
||||
// Formatted contains the secret. This value is owned by the client, not the
|
||||
// server. It is formatted to include the prefix.
|
||||
Formatted string
|
||||
// Prefix is the ID of this secret owned by the server. When a client uses a
|
||||
// secret, this is the matching string to do a lookup on the hashed value. We
|
||||
// cannot use the hashed value directly because the server does not store the
|
||||
// salt.
|
||||
Prefix string
|
||||
const (
|
||||
// SecretIdentifier is the prefix added to all generated secrets.
|
||||
SecretIdentifier = "coder"
|
||||
)
|
||||
|
||||
// Constants for OAuth2 secret generation
|
||||
const (
|
||||
secretLength = 40 // Length of the actual secret part
|
||||
displaySecretLength = 6 // Length of visible part in UI (last 6 characters)
|
||||
)
|
||||
|
||||
type HashedAppSecret struct {
|
||||
AppSecret
|
||||
// Hashed is the server stored hash(secret,salt,...). Used for verifying a
|
||||
// secret.
|
||||
Hashed string
|
||||
}
|
||||
|
||||
type AppSecret struct {
|
||||
// Formatted contains the secret. This value is owned by the client, not the
|
||||
// server. It is formatted to include the prefix.
|
||||
Formatted string
|
||||
// Secret is the raw secret value. This value should only be known to the client.
|
||||
Secret string
|
||||
// Prefix is the ID of this secret owned by the server. When a client uses a
|
||||
// secret, this is the matching string to do a lookup on the hashed value. We
|
||||
// cannot use the hashed value directly because the server does not store the
|
||||
// salt.
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// ParseFormattedSecret parses a formatted secret like "coder_<prefix>_<secret"
|
||||
func ParseFormattedSecret(formatted string) (AppSecret, error) {
|
||||
parts := strings.Split(formatted, "_")
|
||||
if len(parts) != 3 {
|
||||
return AppSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts))
|
||||
}
|
||||
if parts[0] != SecretIdentifier {
|
||||
return AppSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0])
|
||||
}
|
||||
return AppSecret{
|
||||
Formatted: formatted,
|
||||
Prefix: parts[1],
|
||||
Secret: parts[2],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateSecret generates a secret to be used as a client secret, refresh
|
||||
// token, or authorization code.
|
||||
func GenerateSecret() (AppSecret, error) {
|
||||
func GenerateSecret() (HashedAppSecret, error) {
|
||||
// 40 characters matches the length of GitHub's client secrets.
|
||||
secret, err := cryptorand.String(40)
|
||||
secret, err := cryptorand.String(secretLength)
|
||||
if err != nil {
|
||||
return AppSecret{}, err
|
||||
return HashedAppSecret{}, err
|
||||
}
|
||||
|
||||
// This ID is prefixed to the secret so it can be used to look up the secret
|
||||
@@ -35,17 +71,20 @@ func GenerateSecret() (AppSecret, error) {
|
||||
// will not have the salt.
|
||||
prefix, err := cryptorand.String(10)
|
||||
if err != nil {
|
||||
return AppSecret{}, err
|
||||
return HashedAppSecret{}, err
|
||||
}
|
||||
|
||||
hashed, err := userpassword.Hash(secret)
|
||||
if err != nil {
|
||||
return AppSecret{}, err
|
||||
return HashedAppSecret{}, err
|
||||
}
|
||||
|
||||
return AppSecret{
|
||||
Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret),
|
||||
Prefix: prefix,
|
||||
Hashed: hashed,
|
||||
return HashedAppSecret{
|
||||
AppSecret: AppSecret{
|
||||
Formatted: fmt.Sprintf("%s_%s_%s", SecretIdentifier, prefix, secret),
|
||||
Secret: secret,
|
||||
Prefix: prefix,
|
||||
},
|
||||
Hashed: hashed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -185,19 +185,19 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
|
||||
|
||||
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
||||
// Validate the client secret.
|
||||
secret, err := parseFormattedSecret(params.clientSecret)
|
||||
secret, err := ParseFormattedSecret(params.clientSecret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
//nolint:gocritic // Users cannot read secrets so we must use the system.
|
||||
dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.prefix))
|
||||
dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.Prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err := userpassword.Compare(string(dbSecret.HashedSecret), secret.secret)
|
||||
equal, err := userpassword.Compare(string(dbSecret.HashedSecret), secret.Secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare secret: %w", err)
|
||||
}
|
||||
@@ -206,19 +206,19 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
|
||||
}
|
||||
|
||||
// Validate the authorization code.
|
||||
code, err := parseFormattedSecret(params.code)
|
||||
code, err := ParseFormattedSecret(params.code)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(code.prefix))
|
||||
dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(code.Prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err = userpassword.Compare(string(dbCode.HashedSecret), code.secret)
|
||||
equal, err = userpassword.Compare(string(dbCode.HashedSecret), code.Secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare code: %w", err)
|
||||
}
|
||||
@@ -344,19 +344,19 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
|
||||
|
||||
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
||||
// Validate the token.
|
||||
token, err := parseFormattedSecret(params.refreshToken)
|
||||
token, err := ParseFormattedSecret(params.refreshToken)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.prefix))
|
||||
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.Prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err := userpassword.Compare(string(dbToken.RefreshHash), token.secret)
|
||||
equal, err := userpassword.Compare(string(dbToken.RefreshHash), token.Secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare token: %w", err)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ const (
|
||||
SubjectTypePrebuildsOrchestrator SubjectType = "prebuilds_orchestrator"
|
||||
SubjectTypeSystemReadProvisionerDaemons SubjectType = "system_read_provisioner_daemons"
|
||||
SubjectTypeSystemRestricted SubjectType = "system_restricted"
|
||||
SubjectTypeSystemOAuth SubjectType = "system_oauth"
|
||||
SubjectTypeNotifier SubjectType = "notifier"
|
||||
SubjectTypeSubAgentAPI SubjectType = "sub_agent_api"
|
||||
SubjectTypeFileReader SubjectType = "file_reader"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ type OAuth2ProviderApp struct {
|
||||
type OAuth2AppEndpoints struct {
|
||||
Authorization string `json:"authorization"`
|
||||
Token string `json:"token"`
|
||||
TokenRevoke string `json:"token_revoke"`
|
||||
// DeviceAuth is optional.
|
||||
DeviceAuth string `json:"device_authorization"`
|
||||
}
|
||||
@@ -212,6 +214,26 @@ func (e OAuth2ProviderResponseType) Valid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation.
|
||||
func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, token string) error {
|
||||
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())
|
||||
|
||||
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")
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOAuth2ProviderApp completely revokes an app's access for the
|
||||
// authenticated user.
|
||||
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
|
||||
|
||||
Generated
+45
-4
@@ -809,7 +809,8 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \
|
||||
"endpoints": {
|
||||
"authorization": "string",
|
||||
"device_authorization": "string",
|
||||
"token": "string"
|
||||
"token": "string",
|
||||
"token_revoke": "string"
|
||||
},
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -836,6 +837,7 @@ Status Code **200**
|
||||
| `»» authorization` | string | false | | |
|
||||
| `»» device_authorization` | string | false | | Device authorization is optional. |
|
||||
| `»» token` | string | false | | |
|
||||
| `»» token_revoke` | string | false | | |
|
||||
| `» icon` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
@@ -882,7 +884,8 @@ curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \
|
||||
"endpoints": {
|
||||
"authorization": "string",
|
||||
"device_authorization": "string",
|
||||
"token": "string"
|
||||
"token": "string",
|
||||
"token_revoke": "string"
|
||||
},
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -927,7 +930,8 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \
|
||||
"endpoints": {
|
||||
"authorization": "string",
|
||||
"device_authorization": "string",
|
||||
"token": "string"
|
||||
"token": "string",
|
||||
"token_revoke": "string"
|
||||
},
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -984,7 +988,8 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \
|
||||
"endpoints": {
|
||||
"authorization": "string",
|
||||
"device_authorization": "string",
|
||||
"token": "string"
|
||||
"token": "string",
|
||||
"token_revoke": "string"
|
||||
},
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
@@ -1500,6 +1505,42 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/register \
|
||||
|--------|--------------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.OAuth2ClientRegistrationResponse](schemas.md#codersdkoauth2clientregistrationresponse) |
|
||||
|
||||
## Revoke OAuth2 tokens (RFC 7009)
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/oauth2/revoke \
|
||||
|
||||
```
|
||||
|
||||
`POST /oauth2/revoke`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```yaml
|
||||
client_id: string
|
||||
token: string
|
||||
token_type_hint: string
|
||||
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|---------------------|------|--------|----------|-------------------------------------------------------|
|
||||
| `body` | body | object | true | |
|
||||
| `» client_id` | body | string | true | Client ID for authentication |
|
||||
| `» token` | body | string | true | The token to revoke |
|
||||
| `» token_type_hint` | body | string | false | Hint about token type (access_token or refresh_token) |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|----------------------------|--------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Token successfully revoked | |
|
||||
|
||||
## OAuth2 token exchange
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+5
-2
@@ -5272,7 +5272,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
{
|
||||
"authorization": "string",
|
||||
"device_authorization": "string",
|
||||
"token": "string"
|
||||
"token": "string",
|
||||
"token_revoke": "string"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -5283,6 +5284,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
| `authorization` | string | false | | |
|
||||
| `device_authorization` | string | false | | Device authorization is optional. |
|
||||
| `token` | string | false | | |
|
||||
| `token_revoke` | string | false | | |
|
||||
|
||||
## codersdk.OAuth2AuthorizationServerMetadata
|
||||
|
||||
@@ -5594,7 +5596,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
"endpoints": {
|
||||
"authorization": "string",
|
||||
"device_authorization": "string",
|
||||
"token": "string"
|
||||
"token": "string",
|
||||
"token_revoke": "string"
|
||||
},
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
|
||||
Generated
+1
@@ -2889,6 +2889,7 @@ export interface NullHCLString {
|
||||
export interface OAuth2AppEndpoints {
|
||||
readonly authorization: string;
|
||||
readonly token: string;
|
||||
readonly token_revoke: string;
|
||||
/**
|
||||
* DeviceAuth is optional.
|
||||
*/
|
||||
|
||||
@@ -4528,6 +4528,7 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [
|
||||
authorization: "http://localhost:3001/oauth2/authorize",
|
||||
token: "http://localhost:3001/oauth2/token",
|
||||
device_authorization: "",
|
||||
token_revoke: "http://localhost:3001/oauth2/revoke",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user