Skip to content

clients/v1: add service access token methods #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 34 additions & 34 deletions clients/v1/clients.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions clients/v1/clients.proto
Original file line number Diff line number Diff line change
Expand Up @@ -332,23 +332,23 @@ service ServiceAccessTokensService {
// belong to the same service, e.g. "analytics::analytics::read" when the service is
// "analytics".
rpc CreateServiceAccessToken(CreateServiceAccessTokenRequest) returns (CreateServiceAccessTokenResponse) {
option (sams_required_scopes) = "sams::service_access_token::write";
option (sams_required_scopes) = "sams::service_access_tokens::write";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstrings are now all outdated 😁 assuming this is an intended change.

}
// ListServiceAccessTokens returns a list of service access tokens in reverse chronological
// order by creation time. A client can only list service access tokens for services granted
// via scopes, e.g. "sams::service_access_token.analytics::read" allows listing service
// access tokens for the Sourcegraph Analytics service.
rpc ListServiceAccessTokens(ListServiceAccessTokensRequest) returns (ListServiceAccessTokensResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
option (sams_required_scopes) = "sams::service_access_token::read";
option (sams_required_scopes) = "sams::service_access_tokens::read";
}
// RevokeServiceAccessToken revokes the specified service access token. A client can only revoke
// service access tokens for services granted via scopes, e.g.
// "sams::service_access_tokens.analytic::delete" allows revoking service access tokens for
// the Sourcegraph Analytics service.
rpc RevokeServiceAccessToken(RevokeServiceAccessTokenRequest) returns (RevokeServiceAccessTokenResponse) {
option idempotency_level = IDEMPOTENT;
option (sams_required_scopes) = "sams::service_access_token::delete";
option (sams_required_scopes) = "sams::service_access_tokens::delete";
}
}

Expand Down
5 changes: 5 additions & 0 deletions clientv1.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ func (c *ClientV1) Roles() *RolesServiceV1 {
return &RolesServiceV1{client: c}
}

// ServiceAccessTokens returns a client handler to interact with the ServiceAccessTokensServiceV1 API.
func (c *ClientV1) ServiceAccessTokens() *ServiceAccessTokensServiceV1 {
return &ServiceAccessTokensServiceV1{client: c}
}

var (
ErrNotFound = errors.New("not found")
ErrRecordMismatch = errors.New("record mismatch")
Expand Down
147 changes: 147 additions & 0 deletions clientv1_service_access_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package sams

import (
"context"
"time"

"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"

clientsv1 "github.com/sourcegraph/sourcegraph-accounts-sdk-go/clients/v1"
"github.com/sourcegraph/sourcegraph-accounts-sdk-go/clients/v1/clientsv1connect"
"github.com/sourcegraph/sourcegraph-accounts-sdk-go/scopes"
"github.com/sourcegraph/sourcegraph-accounts-sdk-go/services"
"github.com/sourcegraph/sourcegraph/lib/errors"
"golang.org/x/oauth2"
)

// ServiceAccessTokensServiceV1 provides client methods to interact with the
// ServiceAccessTokensService API v1.
type ServiceAccessTokensServiceV1 struct {
client *ClientV1
}

func (s *ServiceAccessTokensServiceV1) newClient(ctx context.Context) clientsv1connect.ServiceAccessTokensServiceClient {
return clientsv1connect.NewServiceAccessTokensServiceClient(
oauth2.NewClient(ctx, s.client.tokenSource),
s.client.gRPCURL(),
connect.WithInterceptors(s.client.defaultInterceptors...),
)
}

// CreateServiceAccessTokenOptions represents the optional parameters for creating a service access token.
type CreateServiceAccessTokenOptions struct {
// The human-friendly name of the token (optional).
DisplayName string
// The time the token will expire (optional, defaults to never expire).
ExpiresAt *time.Time
}

// CreateServiceAccessTokenResponse represents the response from creating a service access token.
type CreateServiceAccessTokenResponse struct {
Token *clientsv1.ServiceAccessToken
Secret string
}

// CreateServiceAccessToken creates a new service access token.
//
// Required scope: sams::service_access_tokens::write
func (s *ServiceAccessTokensServiceV1) CreateServiceAccessToken(ctx context.Context, service services.Service, tokenScopes []scopes.Scope, userID string, opts CreateServiceAccessTokenOptions) (*CreateServiceAccessTokenResponse, error) {
if service == "" {
return nil, errors.New("service cannot be empty")
}
if len(tokenScopes) == 0 {
return nil, errors.New("scopes cannot be empty")
}
if userID == "" {
return nil, errors.New("user ID cannot be empty")
}

token := &clientsv1.ServiceAccessToken{
Service: string(service),
Scopes: scopes.ToStrings(tokenScopes),
UserId: userID,
DisplayName: opts.DisplayName,
}

if opts.ExpiresAt != nil {
token.ExpireTime = timestamppb.New(*opts.ExpiresAt)
}

req := &clientsv1.CreateServiceAccessTokenRequest{Token: token}
client := s.newClient(ctx)
resp, err := parseResponseAndError(client.CreateServiceAccessToken(ctx, connect.NewRequest(req)))
if err != nil {
return nil, err
}

return &CreateServiceAccessTokenResponse{
Token: resp.Msg.Token,
Secret: resp.Msg.Secret,
}, nil
}

// ListServiceAccessTokensOptions represents the options for listing service access tokens.
type ListServiceAccessTokensOptions struct {
// Maximum number of results to return (optional).
PageSize int32
// Page token for pagination (optional).
PageToken string
// Service filter (optional).
Service string
// User ID filter (optional).
UserID string
// Whether to include expired tokens (optional).
ShowExpired bool
}

// ListServiceAccessTokens returns a list of service access tokens in reverse chronological
// order by creation time.
//
// Required scope: sams::service_access_tokens::read
func (s *ServiceAccessTokensServiceV1) ListServiceAccessTokens(ctx context.Context, opts ListServiceAccessTokensOptions) ([]*clientsv1.ServiceAccessToken, error) {
req := &clientsv1.ListServiceAccessTokensRequest{
PageSize: opts.PageSize,
PageToken: opts.PageToken,
}

// Build filters
var filters []*clientsv1.ListServiceAccessTokensFilter
if opts.Service != "" {
filters = append(filters, &clientsv1.ListServiceAccessTokensFilter{
Filter: &clientsv1.ListServiceAccessTokensFilter_Service{Service: opts.Service},
})
}
if opts.UserID != "" {
filters = append(filters, &clientsv1.ListServiceAccessTokensFilter{
Filter: &clientsv1.ListServiceAccessTokensFilter_UserId{UserId: opts.UserID},
})
}
if opts.ShowExpired {
filters = append(filters, &clientsv1.ListServiceAccessTokensFilter{
Filter: &clientsv1.ListServiceAccessTokensFilter_ShowExpired{ShowExpired: opts.ShowExpired},
})
}
req.Filters = filters

client := s.newClient(ctx)
resp, err := parseResponseAndError(client.ListServiceAccessTokens(ctx, connect.NewRequest(req)))
if err != nil {
return nil, err
}
return resp.Msg.GetTokens(), nil
}

// RevokeServiceAccessToken revokes the specified service access token.
//
// Required scope: sams::service_access_tokens::delete
func (s *ServiceAccessTokensServiceV1) RevokeServiceAccessToken(ctx context.Context, tokenID string) error {
if tokenID == "" {
return errors.New("token ID cannot be empty")
}

req := &clientsv1.RevokeServiceAccessTokenRequest{Id: tokenID}
client := s.newClient(ctx)
_, err := parseResponseAndError(client.RevokeServiceAccessToken(ctx, connect.NewRequest(req)))
return err
}
6 changes: 5 additions & 1 deletion clientv1_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ type IntrospectTokenResponse struct {
Scopes scopes.Scopes
// ClientID is the identifier of the SAMS client that the token was issued to.
ClientID string
// ExpiresAt indicates when the token expires.
// ExpiresAt indicates when the token expires. If the token has no expiry, this
// will be the zero value.
ExpiresAt time.Time
// UserID if set is the identifier of the user that the Service Access Token was issued to.
UserID string
}

// IntrospectToken takes a SAMS access token and returns relevant metadata.
Expand Down Expand Up @@ -74,6 +77,7 @@ func (s *TokensServiceV1) IntrospectToken(ctx context.Context, token string) (*I
Scopes: scopes.ToScopes(resp.Msg.Scopes),
ClientID: resp.Msg.ClientId,
ExpiresAt: resp.Msg.ExpiresAt.AsTime(),
UserID: resp.Msg.UserId,
}
if s.introspectTokenCache != nil && tokenResponse.ExpiresAt.After(time.Now()) {
_ = s.introspectTokenCache.Add(token, tokenResponse)
Expand Down
Loading