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  }