github.com/google/osv-scalibr@v0.4.1/veles/secrets/postmanapikey/validator.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package postmanapikey 16 17 import ( 18 "encoding/json" 19 "fmt" 20 "io" 21 "net/http" 22 "time" 23 24 "github.com/google/osv-scalibr/veles" 25 "github.com/google/osv-scalibr/veles/secrets/common/simplevalidate" 26 ) 27 28 const ( 29 // API endpoint that returns authenticated user info when the API key is valid. 30 // We call /me with X-Api-Key header. 31 apiEndpoint = "https://api.getpostman.com/me" 32 // A dummy collection ID used to produce predictable authentication vs 33 // authorization responses when validating collection access tokens. 34 // 35 // Postman's collection endpoint requires both authentication and 36 // authorization. Using a collection ID we don't own 37 // ("aaaaaaaa-aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa") means that a valid 38 // collection access token will authenticate successfully but will 39 // typically receive a 403 Forbidden because the token is not authorized 40 // for that collection. An invalid token will produce a 401 Authentication 41 // error. This predictable difference lets us distinguish a valid token 42 // (authenticated but not authorized) from an invalid one. 43 dummyCollectionID = "aaaaaaaa-aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa" 44 collectionEndpoint = "https://api.postman.com/collections/" + dummyCollectionID 45 validationTimeout = 10 * time.Second 46 // Exact values observed for a valid-but-not-authorized response. 47 forbiddenErrorName = "forbiddenError" 48 ) 49 50 // NewAPIValidator creates a new Validator that validates Postman API keys 51 // (PMAK-...) using /me endpoint. 52 // It calls GET /me with header "X-Api-Key: <key>". 53 // - 200 OK -> authenticated and valid. 54 // - 401 -> invalid API key (authentication failure). 55 // - other -> validation failed (unexpected response). 56 func NewAPIValidator() *simplevalidate.Validator[PostmanAPIKey] { 57 return &simplevalidate.Validator[PostmanAPIKey]{ 58 Endpoint: apiEndpoint, 59 HTTPMethod: http.MethodGet, 60 HTTPHeaders: func(k PostmanAPIKey) map[string]string { 61 return map[string]string{"X-Api-Key": k.Key} 62 }, 63 ValidResponseCodes: []int{http.StatusOK}, 64 InvalidResponseCodes: []int{http.StatusUnauthorized}, 65 HTTPC: &http.Client{ 66 Timeout: validationTimeout, 67 }, 68 } 69 } 70 71 // collectionErrorResponse models Postman's collection endpoint error JSON. 72 type collectionErrorResponse struct { 73 Error struct { 74 Name string `json:"name"` 75 Message string `json:"message"` 76 } `json:"error"` 77 } 78 79 func statusFromCollectionResponseBody(body io.Reader) (veles.ValidationStatus, error) { 80 var resp collectionErrorResponse 81 if err := json.NewDecoder(body).Decode(&resp); err != nil { 82 // Decoding failed -> ambiguous response, treat as failed to validate. 83 return veles.ValidationFailed, fmt.Errorf("unable to parse response: %w", err) 84 } 85 if resp.Error.Name == forbiddenErrorName { 86 // Exact match -> authenticated but not authorized for dummy 87 // collection, therefore token is valid. 88 return veles.ValidationValid, nil 89 } 90 // 403 with different payload -> treat as invalid (conservative). 91 return veles.ValidationInvalid, nil 92 } 93 94 // NewCollectionValidator creates a new Validator that validates Postman 95 // collection access tokens (PMAT-...). 96 // It calls GET {collectionEndpoint}?access_key=<token> using a dummy 97 // collection ID. The dummy collection ID is used to create a predictable 98 // authorization failure for valid tokens (403 Forbidden) while invalid 99 // tokens produce 401 Authentication errors. 100 // 101 // Interpretation of statuses: 102 // - 200 OK -> token is valid and authorized for the collection (rare here). 103 // - 403 -> authenticated but not authorized for this collection -> valid 104 // *only if* StatusFromResponseBody returns ValidationValid based on 105 // Postman's JSON response for that situation: 106 // {"error":{"name":"forbiddenError","message":"You are not authorized to perform this action."}} 107 // - 401 -> invalid token (authentication failure). 108 // - other -> validation failed. 109 func NewCollectionValidator() *simplevalidate.Validator[PostmanCollectionToken] { 110 return &simplevalidate.Validator[PostmanCollectionToken]{ 111 EndpointFunc: func(k PostmanCollectionToken) (string, error) { 112 return collectionEndpoint + "?access_key=" + k.Key, nil 113 }, 114 HTTPMethod: http.MethodGet, 115 ValidResponseCodes: []int{http.StatusOK}, 116 InvalidResponseCodes: []int{http.StatusUnauthorized}, 117 StatusFromResponseBody: statusFromCollectionResponseBody, 118 HTTPC: &http.Client{ 119 Timeout: validationTimeout, 120 }, 121 } 122 }