With our growing fleet of Managed Services infrastructure , several of which integrate with each other and Sourcegraph.com, we have a growing need to securely manage how these services talk to each other. The OAuth2 standard offers a mechanism for this specific use case, called "client credentials grant", which is implemented by Sourcegraph Accounts Management System (SAMS) as an OAuth2-compliant service. We leverage this mechanism to offer client credential grants and act as the authorization server for all managed services machine-to-machine (M2M) traffic .

Adoption of this mechanism was originally proposed in https://docs.google.com/document/d/1FEtaQ9h06zy2J8oabWoV_DTQouV_1CobbVGf2nvuMGI/edit



Overview

The OAuth2 standard offers a mechanism for M2M auth, called "client credentials grant", that is widely used - a generic flow is described here. Managed services (including Sourcegraph.com, when managing communication with managed services) provision client credentials using SAMS, and use that to request an access token from SAMS (as the authorization server). The access token that SAMS provides can be provided when communicating with other services, and the recipient validates the token against SAMS via an introspection request (also an OAuth2 standard).

The following diagram presents a high-level overview of how this works.

sequenceDiagram
    participant S1 as Service 1
    participant S2 as Service 2
    participant SAMS as Sourcegraph Accounts
    autonumber
    
    rect rgba(0, 0, 255, .1)
        note right of S1: One-time manual setup
        create participant C as OAuth2 Client
        SAMS ->> C: Generate with<br>allowed scopes
        Note right of C: Stored in<br>SAMS database
        C ->> S1: Client credentials (client ID and client secret) to configure and use
    end

    S1 ->> SAMS: Client ID, client secret, and<br>requested scope "client.service2"
    Note right of S1: Requested scope to be<br>"a client of service 2",<br>or a more granular scope
    SAMS ->> C: Is "client.service2" an allowed scope<br>for these credentials?
    Note right of C: Client created by<br>teammate with allowed<br>scope "client.service2"<br>in this example
    C ->> SAMS: OK
    activate SAMS
    Note right of SAMS: Session created<br>and tracked in SAMS 
    SAMS ->> S1: Issued access token
    S1 ->> S2: Request resource<br>with access token
    S2 ->> SAMS: Introspect token
    SAMS ->> S2: Is valid, has scopes
    S2 ->> S2: Are these scopes sufficient<br>for this resource?
    S2 ->> S1: Resource
     

    deactivate SAMS

Authentication: token introspection

Servers should check the validity of an access token and the scopes associated with it using token introspection - this is provided in the SAMS SDK (‣) via (TokenServiceV1).IntrospectToken. Introspection allows a server to check if the provided access token and its associated SAMS session is valid and active.

Clients should emit their credentials when using the SAMS SDK to pass a server’s introspection check in ClientV1ConnConfig, for example:

import (
	"context"
	"fmt"
	"log"
	"os"

	sams "github.com/sourcegraph/sourcegraph-accounts-sdk-go"
	"github.com/sourcegraph/sourcegraph-accounts-sdk-go/scopes"
)

func main() {
	connConfig := sams.NewConnConfigFromEnv(/* ... */)
	samsClient, err := sams.NewClientV1(sams.ClientV1Config{
		ConnConfig:  connConfig,
		TokenSource: sams.ClientCredentialsTokenSource(
			connConfig,
			os.Getenv("SAMS_CLIENT_ID"),
			os.Getenv("SAMS_CLIENT_SECRET"),
			[]scopes.Scope{
				scopes.OpenID,
				scopes.Profile,
				scopes.Email,
				"sams::user.roles::read",
				"sams::session::read",
				// ...
			},
		),
	})
	// ...
}

Enforcing required scopes server-side

A generalized middleware is provided for ConnectRPC services (see: API protocols and design) in the auth/clientcredentials subpackage. To get started, add the following to your service schema:

import "google/protobuf/descriptor.proto";

extend google.protobuf.MethodOptions {
  // The SAMS scopes required to use this RPC.
  //
  // The range 50000-99999 is reserved for internal use within individual organizations
  // so you can use numbers in this range freely for in-house applications.
  // Choose a 5x... range for your service to use.
  repeated string sams_required_scopes = 53010;
}

Then, in your RPC definitions, add your new sams_required_scopes extension option to declare the scopes (Authorization: allowed scopes and requested scopes) that are required to access that particular RPC:

service SubscriptionsService {
  rpc UpdateEnterpriseSubscription(UpdateEnterpriseSubscriptionRequest) returns (UpdateEnterpriseSubscriptionResponse) {
    option idempotency_level = IDEMPOTENT;
    option (sams_required_scopes) = "enterprise_portal::subscription::write";
    option (sams_required_scopes) = "enterprise_portal::subscription::read";
  }
}

Then, include clientcredentials.NewInterceptor as an interceptor when registering your ConnectRPC service handler, providing it the generated Go binding for your sams_required_scopes extension (this will be called E_SamsRequiredScopes):

import (
	"connectrpc.com/connect"
	"github.com/sourcegraph/log"

	"github.com/sourcegraph/sourcegraph-accounts-sdk-go/auth/clientcredentials"

	subscriptionsv1 "github.com/sourcegraph/sourcegraph/lib/enterpriseportal/subscriptions/v1"
	subscriptionsv1connect "github.com/sourcegraph/sourcegraph/lib/enterpriseportal/subscriptions/v1/v1connect"
)

func RegisterV1(/* ... */) {
	mux.Handle(
		subscriptionsv1connect.NewSubscriptionsServiceHandler(
			&handlerV1{/* ... */},
			append(opts, connect.WithInterceptors(
				// 🚨 SECURITY: Require appropriate M2M scopes
				clientcredentials.NewInterceptor(
					logger,
					tokens,
					// Generated Go binding for your extension
					subscriptionsv1.E_SamsRequiredScopes,
				),
			))...,
		),
	)
}