|
1 | 1 | package oauth2provider |
2 | 2 |
|
3 | 3 | import ( |
| 4 | +"context" |
| 5 | +"crypto/sha256" |
| 6 | +"crypto/subtle" |
4 | 7 | "database/sql" |
5 | 8 | "errors" |
6 | 9 | "net/http" |
| 10 | +"strings" |
7 | 11 |
|
| 12 | +"golang.org/x/xerrors" |
| 13 | + |
| 14 | +"github.com/google/uuid" |
| 15 | + |
| 16 | +"cdr.dev/slog" |
8 | 17 | "github.com/coder/coder/v2/coderd/database" |
| 18 | +"github.com/coder/coder/v2/coderd/database/dbauthz" |
9 | 19 | "github.com/coder/coder/v2/coderd/httpapi" |
10 | 20 | "github.com/coder/coder/v2/coderd/httpmw" |
11 | 21 | ) |
12 | 22 |
|
| 23 | +var ( |
| 24 | +// ErrTokenNotBelongsToClient is returned when a token does not belong to the requesting client |
| 25 | +ErrTokenNotBelongsToClient=xerrors.New("token does not belong to requesting client") |
| 26 | +// ErrInvalidTokenFormat is returned when a token has an invalid format |
| 27 | +ErrInvalidTokenFormat=xerrors.New("invalid token format") |
| 28 | +) |
| 29 | + |
| 30 | +// RevokeToken implements RFC 7009 OAuth2 Token Revocation |
| 31 | +funcRevokeToken(db database.Store,logger slog.Logger) http.HandlerFunc { |
| 32 | +returnfunc(rw http.ResponseWriter,r*http.Request) { |
| 33 | +ctx:=r.Context() |
| 34 | +app:=httpmw.OAuth2ProviderApp(r) |
| 35 | + |
| 36 | +// RFC 7009 requires POST method with application/x-www-form-urlencoded |
| 37 | +ifr.Method!=http.MethodPost { |
| 38 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusMethodNotAllowed,"invalid_request","Method not allowed") |
| 39 | +return |
| 40 | +} |
| 41 | + |
| 42 | +iferr:=r.ParseForm();err!=nil { |
| 43 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Invalid form data") |
| 44 | +return |
| 45 | +} |
| 46 | + |
| 47 | +// RFC 7009 requires 'token' parameter |
| 48 | +token:=r.Form.Get("token") |
| 49 | +iftoken=="" { |
| 50 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Missing token parameter") |
| 51 | +return |
| 52 | +} |
| 53 | + |
| 54 | +// Extract client_id parameter - required for ownership verification |
| 55 | +clientID:=r.Form.Get("client_id") |
| 56 | +ifclientID=="" { |
| 57 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Missing client_id parameter") |
| 58 | +return |
| 59 | +} |
| 60 | + |
| 61 | +// Verify the extracted app matches the client_id parameter |
| 62 | +ifapp.ID.String()!=clientID { |
| 63 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_client","Invalid client_id") |
| 64 | +return |
| 65 | +} |
| 66 | + |
| 67 | +// Determine if this is a refresh token (starts with "coder_") or API key |
| 68 | +// APIKeys do not have the SecretIdentifier prefix. |
| 69 | +constcoderPrefix=SecretIdentifier+"_" |
| 70 | +isRefreshToken:=strings.HasPrefix(token,coderPrefix) |
| 71 | + |
| 72 | +// Revoke the token with ownership verification |
| 73 | +err:=db.InTx(func(tx database.Store)error { |
| 74 | +ifisRefreshToken { |
| 75 | +// Handle refresh token revocation |
| 76 | +returnrevokeRefreshTokenInTx(ctx,tx,token,app.ID) |
| 77 | +} |
| 78 | +// Handle API key revocation |
| 79 | +returnrevokeAPIKeyInTx(ctx,tx,token,app.ID) |
| 80 | +},nil) |
| 81 | +iferr!=nil { |
| 82 | +iferrors.Is(err,ErrTokenNotBelongsToClient) { |
| 83 | +// RFC 7009: Return success even if token doesn't belong to client (don't reveal token existence) |
| 84 | +logger.Debug(ctx,"token revocation failed: token does not belong to requesting client", |
| 85 | +slog.F("client_id",app.ID.String()), |
| 86 | +slog.F("app_name",app.Name)) |
| 87 | +rw.WriteHeader(http.StatusOK) |
| 88 | +return |
| 89 | +} |
| 90 | +iferrors.Is(err,ErrInvalidTokenFormat) { |
| 91 | +// Invalid token format should return 400 bad request |
| 92 | +logger.Debug(ctx,"token revocation failed: invalid token format", |
| 93 | +slog.F("client_id",app.ID.String()), |
| 94 | +slog.F("app_name",app.Name)) |
| 95 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusBadRequest,"invalid_request","Invalid token format") |
| 96 | +return |
| 97 | +} |
| 98 | +logger.Error(ctx,"token revocation failed with internal server error", |
| 99 | +slog.Error(err), |
| 100 | +slog.F("client_id",app.ID.String()), |
| 101 | +slog.F("app_name",app.Name)) |
| 102 | +httpapi.WriteOAuth2Error(ctx,rw,http.StatusInternalServerError,"server_error","Internal server error") |
| 103 | +return |
| 104 | +} |
| 105 | + |
| 106 | +// RFC 7009: successful revocation returns HTTP 200 |
| 107 | +rw.WriteHeader(http.StatusOK) |
| 108 | +} |
| 109 | +} |
| 110 | + |
| 111 | +funcrevokeRefreshTokenInTx(ctx context.Context,db database.Store,tokenstring,appID uuid.UUID)error { |
| 112 | +// Parse the refresh token using the existing function |
| 113 | +parsedToken,err:=ParseFormattedSecret(token) |
| 114 | +iferr!=nil { |
| 115 | +returnErrInvalidTokenFormat |
| 116 | +} |
| 117 | + |
| 118 | +// Try to find refresh token by prefix |
| 119 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 120 | +dbToken,err:=db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(parsedToken.Prefix)) |
| 121 | +iferr!=nil { |
| 122 | +iferrors.Is(err,sql.ErrNoRows) { |
| 123 | +// Token not found - return success per RFC 7009 (don't reveal token existence) |
| 124 | +returnnil |
| 125 | +} |
| 126 | +returnxerrors.Errorf("get oauth2 provider app token by prefix: %w",err) |
| 127 | +} |
| 128 | + |
| 129 | +// Verify ownership |
| 130 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 131 | +appSecret,err:=db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx),dbToken.AppSecretID) |
| 132 | +iferr!=nil { |
| 133 | +returnxerrors.Errorf("get oauth2 provider app secret: %w",err) |
| 134 | +} |
| 135 | +ifappSecret.AppID!=appID { |
| 136 | +returnErrTokenNotBelongsToClient |
| 137 | +} |
| 138 | + |
| 139 | +// Delete the associated API key, which should cascade to remove the refresh token |
| 140 | +// According to RFC 7009, when a refresh token is revoked, associated access tokens should be invalidated |
| 141 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 142 | +err=db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx),dbToken.APIKeyID) |
| 143 | +iferr!=nil&&!errors.Is(err,sql.ErrNoRows) { |
| 144 | +returnxerrors.Errorf("delete api key: %w",err) |
| 145 | +} |
| 146 | + |
| 147 | +returnnil |
| 148 | +} |
| 149 | + |
| 150 | +funcrevokeAPIKeyInTx(ctx context.Context,db database.Store,tokenstring,appID uuid.UUID)error { |
| 151 | +keyID,secret,err:=httpmw.SplitAPIToken(token) |
| 152 | +iferr!=nil { |
| 153 | +returnErrInvalidTokenFormat |
| 154 | +} |
| 155 | + |
| 156 | +// Get the API key |
| 157 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 158 | +apiKey,err:=db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx),keyID) |
| 159 | +iferr!=nil { |
| 160 | +iferrors.Is(err,sql.ErrNoRows) { |
| 161 | +// API key not found - return success per RFC 7009 (don't reveal token existence) |
| 162 | +// Note: This covers both non-existent keys and invalid key ID formats |
| 163 | +returnnil |
| 164 | +} |
| 165 | +returnxerrors.Errorf("get api key by id: %w",err) |
| 166 | +} |
| 167 | + |
| 168 | +// Checking to see if the provided secret matches the stored hashed secret |
| 169 | +hashedSecret:=sha256.Sum256([]byte(secret)) |
| 170 | +ifsubtle.ConstantTimeCompare(apiKey.HashedSecret,hashedSecret[:])!=1 { |
| 171 | +returnxerrors.Errorf("invalid api key") |
| 172 | +} |
| 173 | + |
| 174 | +// Verify the API key was created by OAuth2 |
| 175 | +ifapiKey.LoginType!=database.LoginTypeOAuth2ProviderApp { |
| 176 | +returnxerrors.New("API key is not an OAuth2 token") |
| 177 | +} |
| 178 | + |
| 179 | +// Find the associated OAuth2 token to verify ownership |
| 180 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 181 | +dbToken,err:=db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx),apiKey.ID) |
| 182 | +iferr!=nil { |
| 183 | +iferrors.Is(err,sql.ErrNoRows) { |
| 184 | +// No associated OAuth2 token - return success per RFC 7009 |
| 185 | +returnnil |
| 186 | +} |
| 187 | +returnxerrors.Errorf("get oauth2 provider app token by api key id: %w",err) |
| 188 | +} |
| 189 | + |
| 190 | +// Verify the token belongs to the requesting app |
| 191 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 192 | +appSecret,err:=db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx),dbToken.AppSecretID) |
| 193 | +iferr!=nil { |
| 194 | +returnxerrors.Errorf("get oauth2 provider app secret for api key verification: %w",err) |
| 195 | +} |
| 196 | + |
| 197 | +ifappSecret.AppID!=appID { |
| 198 | +returnErrTokenNotBelongsToClient |
| 199 | +} |
| 200 | + |
| 201 | +// Delete the API key |
| 202 | +//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint |
| 203 | +err=db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx),apiKey.ID) |
| 204 | +iferr!=nil&&!errors.Is(err,sql.ErrNoRows) { |
| 205 | +returnxerrors.Errorf("delete api key for revocation: %w",err) |
| 206 | +} |
| 207 | + |
| 208 | +returnnil |
| 209 | +} |
| 210 | + |
13 | 211 | funcRevokeApp(db database.Store) http.HandlerFunc { |
14 | 212 | returnfunc(rw http.ResponseWriter,r*http.Request) { |
15 | 213 | ctx:=r.Context() |
|