Skip to content

Commit a57dfd9

Browse files
committed
[public-api] Create Personal Access Token implementation
1 parent a0a9ddd commit a57dfd9

File tree

7 files changed

+157
-28
lines changed

7 files changed

+157
-28
lines changed

components/gitpod-db/go/dbtest/personal_access_token.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func CreatePersonalAccessTokenRecords(t *testing.T, conn *gorm.DB, entries ...db
8282
records = append(records, record)
8383
ids = append(ids, record.ID.String())
8484

85-
_, err := db.CreateToken(context.Background(), conn, tokenEntry)
85+
_, err := db.CreatePersonalAccessToken(context.Background(), conn, tokenEntry)
8686
require.NoError(t, err)
8787
}
8888

components/gitpod-db/go/personal_access_token.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func GetToken(ctx context.Context, conn *gorm.DB, id uuid.UUID) (PersonalAccessT
4949
return token, nil
5050
}
5151

52-
func CreateToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (PersonalAccessToken, error) {
52+
func CreatePersonalAccessToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (PersonalAccessToken, error) {
5353
if req.UserID == uuid.Nil {
5454
return PersonalAccessToken{}, fmt.Errorf("Invalid or empty userID")
5555
}
@@ -63,6 +63,7 @@ func CreateToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (P
6363
return PersonalAccessToken{}, fmt.Errorf("Expiration time required")
6464
}
6565

66+
now := time.Now().UTC()
6667
token := PersonalAccessToken{
6768
ID: req.ID,
6869
UserID: req.UserID,
@@ -71,13 +72,13 @@ func CreateToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (P
7172
Description: req.Description,
7273
Scopes: req.Scopes,
7374
ExpirationTime: req.ExpirationTime,
74-
CreatedAt: time.Now().UTC(),
75-
LastModified: time.Now().UTC(),
75+
CreatedAt: now,
76+
LastModified: now,
7677
}
7778

7879
tx := conn.WithContext(ctx).Create(req)
7980
if tx.Error != nil {
80-
return PersonalAccessToken{}, fmt.Errorf("Failed to create token for user %s", req.UserID)
81+
return PersonalAccessToken{}, fmt.Errorf("Failed to create personal access token for user %s", req.UserID)
8182
}
8283

8384
return token, nil

components/gitpod-db/go/personal_access_token_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func TestPersonalAccessToken_Create(t *testing.T) {
5454
LastModified: time.Now(),
5555
}
5656

57-
result, err := db.CreateToken(context.Background(), conn, request)
57+
result, err := db.CreatePersonalAccessToken(context.Background(), conn, request)
5858
require.NoError(t, err)
5959

6060
require.Equal(t, request.ID, result.ID)

components/public-api-server/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/spf13/cobra v1.4.0
2424
github.com/stretchr/testify v1.8.1
2525
github.com/stripe/stripe-go/v72 v72.122.0
26+
golang.org/x/crypto v0.0.0-20220214200702-86341886e292
2627
google.golang.org/grpc v1.50.1
2728
google.golang.org/protobuf v1.28.1
2829
gorm.io/gorm v1.24.1

components/public-api-server/pkg/apiv1/tokens.go

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,63 @@ type TokensService struct {
4343
}
4444

4545
func (s *TokensService) CreatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.CreatePersonalAccessTokenRequest]) (*connect.Response[v1.CreatePersonalAccessTokenResponse], error) {
46+
tokenReq := req.Msg.GetToken()
47+
48+
name := strings.TrimSpace(tokenReq.GetName())
49+
if name == "" {
50+
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Token Name is a required parameter."))
51+
}
52+
53+
description := strings.TrimSpace(tokenReq.GetDescription())
54+
55+
expiry := tokenReq.GetExpirationTime()
56+
if !expiry.IsValid() {
57+
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))
58+
}
59+
60+
// TODO: Parse and validate scopes before storing
61+
// Until we do that, we store empty scopes.
62+
var scopes []string
63+
4664
conn, err := getConnection(ctx, s.connectionPool)
4765
if err != nil {
4866
return nil, err
4967
}
5068

51-
_, err = s.getUser(ctx, conn)
69+
_, userID, err := s.getUser(ctx, conn)
5270
if err != nil {
5371
return nil, err
5472
}
5573

56-
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitpod.experimental.v1.TokensService.CreatePersonalAccessToken is not implemented"))
74+
pat, err := auth.GeneratePersonalAccessToken(s.signer)
75+
if err != nil {
76+
log.WithError(err).Errorf("Failed to generate personal access token for user %s", userID.String())
77+
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to generate personal access token."))
78+
}
79+
80+
hash, err := pat.ValueHash()
81+
if err != nil {
82+
log.WithError(err).Errorf("Failed to generate personal access token value hash for user %s", userID.String())
83+
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to compute personal access token hash."))
84+
}
85+
86+
token, err := db.CreatePersonalAccessToken(ctx, s.dbConn, db.PersonalAccessToken{
87+
ID: uuid.New(),
88+
UserID: userID,
89+
Hash: hash,
90+
Name: name,
91+
Description: description,
92+
Scopes: scopes,
93+
ExpirationTime: expiry.AsTime().UTC(),
94+
})
95+
if err != nil {
96+
log.WithError(err).Errorf("Failed to store personal access token for user %s", userID.String())
97+
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to store personal access token."))
98+
}
99+
100+
return connect.NewResponse(&v1.CreatePersonalAccessTokenResponse{
101+
Token: personalAccessTokenToAPI(token, pat.String()),
102+
}), nil
57103
}
58104

59105
func (s *TokensService) GetPersonalAccessToken(ctx context.Context, req *connect.Request[v1.GetPersonalAccessTokenRequest]) (*connect.Response[v1.GetPersonalAccessTokenResponse], error) {
@@ -67,7 +113,7 @@ func (s *TokensService) GetPersonalAccessToken(ctx context.Context, req *connect
67113
return nil, err
68114
}
69115

70-
_, err = s.getUser(ctx, conn)
116+
_, _, err = s.getUser(ctx, conn)
71117
if err != nil {
72118
return nil, err
73119
}
@@ -82,16 +128,11 @@ func (s *TokensService) ListPersonalAccessTokens(ctx context.Context, req *conne
82128
return nil, err
83129
}
84130

85-
user, err := s.getUser(ctx, conn)
131+
_, userID, err := s.getUser(ctx, conn)
86132
if err != nil {
87133
return nil, err
88134
}
89135

90-
userID, err := uuid.Parse(user.ID)
91-
if err != nil {
92-
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to parse user ID as UUID"))
93-
}
94-
95136
result, err := db.ListPersonalAccessTokensForUser(ctx, s.dbConn, userID, paginationToDB(req.Msg.GetPagination()))
96137
if err != nil {
97138
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to list Personal Access Tokens for User %s", userID.String()))
@@ -114,7 +155,7 @@ func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req *
114155
return nil, err
115156
}
116157

117-
_, err = s.getUser(ctx, conn)
158+
_, _, err = s.getUser(ctx, conn)
118159
if err != nil {
119160
return nil, err
120161
}
@@ -134,7 +175,7 @@ func (s *TokensService) UpdatePersonalAccessToken(ctx context.Context, req *conn
134175
return nil, err
135176
}
136177

137-
_, err = s.getUser(ctx, conn)
178+
_, _, err = s.getUser(ctx, conn)
138179
if err != nil {
139180
return nil, err
140181
}
@@ -154,7 +195,7 @@ func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *conn
154195
return nil, err
155196
}
156197

157-
_, err = s.getUser(ctx, conn)
198+
_, _, err = s.getUser(ctx, conn)
158199
if err != nil {
159200
return nil, err
160201
}
@@ -163,17 +204,22 @@ func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *conn
163204
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitpod.experimental.v1.TokensService.DeletePersonalAccessToken is not implemented"))
164205
}
165206

166-
func (s *TokensService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, error) {
207+
func (s *TokensService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, uuid.UUID, error) {
167208
user, err := conn.GetLoggedInUser(ctx)
168209
if err != nil {
169-
return nil, proxy.ConvertError(err)
210+
return nil, uuid.Nil, proxy.ConvertError(err)
170211
}
171212

172213
if !experiments.IsPersonalAccessTokensEnabled(ctx, s.expClient, experiments.Attributes{UserID: user.ID}) {
173-
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("This feature is currently in beta. If you would like to be part of the beta, please contact us."))
214+
return nil, uuid.Nil, connect.NewError(connect.CodePermissionDenied, errors.New("This feature is currently in beta. If you would like to be part of the beta, please contact us."))
215+
}
216+
217+
userID, err := uuid.Parse(user.ID)
218+
if err != nil {
219+
return nil, uuid.Nil, connect.NewError(connect.CodeInternal, errors.New("Failed to parse user ID as UUID. Please contact support."))
174220
}
175221

176-
return user, nil
222+
return user, userID, nil
177223
}
178224

179225
func getConnection(ctx context.Context, pool proxy.ServerConnectionPool) (protocol.APIInterface, error) {

components/public-api-server/pkg/apiv1/tokens_test.go

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/golang/mock/gomock"
2424
"github.com/google/uuid"
2525
"github.com/stretchr/testify/require"
26+
"google.golang.org/protobuf/types/known/timestamppb"
2627
"gorm.io/gorm"
2728
)
2829

@@ -37,6 +38,8 @@ var (
3738
return experiment == experiments.PersonalAccessTokensEnabledFlag
3839
},
3940
}
41+
42+
signer = auth.NewHS256Signer([]byte("my-secret"))
4043
)
4144

4245
func TestTokensService_CreatePersonalAccessTokenWithoutFeatureFlag(t *testing.T) {
@@ -47,19 +50,86 @@ func TestTokensService_CreatePersonalAccessTokenWithoutFeatureFlag(t *testing.T)
4750

4851
serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil)
4952

50-
_, err := client.CreatePersonalAccessToken(context.Background(), &connect.Request[v1.CreatePersonalAccessTokenRequest]{})
53+
_, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{
54+
Token: &v1.PersonalAccessToken{
55+
Name: "my-token",
56+
ExpirationTime: timestamppb.Now(),
57+
},
58+
}))
5159

5260
require.Error(t, err, "This feature is currently in beta. If you would like to be part of the beta, please contact us.")
5361
require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
5462
})
5563

56-
t.Run("unimplemented when feature flag enabled", func(t *testing.T) {
57-
serverMock, _, client := setupTokensService(t, withTokenFeatureEnabled)
64+
t.Run("invalid argument when name is not specified", func(t *testing.T) {
65+
_, _, client := setupTokensService(t, withTokenFeatureDisabled)
66+
67+
_, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{
68+
Token: &v1.PersonalAccessToken{},
69+
}))
70+
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
71+
})
72+
73+
t.Run("invalid argument when expiration time is unspecified", func(t *testing.T) {
74+
_, _, client := setupTokensService(t, withTokenFeatureDisabled)
75+
76+
_, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{
77+
Token: &v1.PersonalAccessToken{
78+
Name: "my-token",
79+
},
80+
}))
81+
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
82+
})
83+
84+
t.Run("invalid argument when expiration time is invalid", func(t *testing.T) {
85+
_, _, client := setupTokensService(t, withTokenFeatureDisabled)
86+
87+
_, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{
88+
Token: &v1.PersonalAccessToken{
89+
Name: "my-token",
90+
ExpirationTime: &timestamppb.Timestamp{
91+
Seconds: 253402300799 + 1,
92+
},
93+
},
94+
}))
95+
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
96+
})
97+
98+
t.Run("crates personal access token", func(t *testing.T) {
99+
serverMock, dbConn, client := setupTokensService(t, withTokenFeatureEnabled)
58100

59101
serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil)
60102

61-
_, err := client.CreatePersonalAccessToken(context.Background(), &connect.Request[v1.CreatePersonalAccessTokenRequest]{})
62-
require.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err))
103+
token := &v1.PersonalAccessToken{
104+
Name: "my-token",
105+
Description: "my description",
106+
ExpirationTime: timestamppb.Now(),
107+
}
108+
109+
response, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{
110+
Token: token,
111+
}))
112+
require.NoError(t, err)
113+
114+
created := response.Msg.GetToken()
115+
t.Cleanup(func() {
116+
require.NoError(t, dbConn.Where("id = ?", created.GetId()).Delete(&db.PersonalAccessToken{}).Error)
117+
})
118+
119+
require.NotEmpty(t, created.GetId())
120+
require.Equal(t, token.Name, created.GetName())
121+
require.Equal(t, token.Description, created.GetDescription())
122+
require.Equal(t, token.Scopes, created.GetScopes())
123+
requireEqualProto(t, token.GetExpirationTime(), created.GetExpirationTime())
124+
125+
// Returned token must be parseable
126+
_, err = auth.ParsePersonalAccessToken(created.GetValue(), signer)
127+
require.NoError(t, err)
128+
129+
// token must exist in the DB, with the User ID of the requestor
130+
storedInDB, err := db.GetToken(context.Background(), dbConn, uuid.MustParse(created.GetId()))
131+
require.NoError(t, err)
132+
require.Equal(t, user.ID, storedInDB.UserID.String())
63133
})
64134
}
65135

@@ -370,7 +440,6 @@ func setupTokensService(t *testing.T, expClient experiments.Client) (*protocol.M
370440
t.Helper()
371441

372442
dbConn := dbtest.ConnectForTests(t)
373-
signer := auth.NewHS256Signer([]byte("my-secret"))
374443

375444
ctrl := gomock.NewController(t)
376445
t.Cleanup(ctrl.Finish)

components/public-api-server/pkg/auth/personal_access_token.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"fmt"
1313
"math/big"
1414
"strings"
15+
16+
"golang.org/x/crypto/bcrypt"
1517
)
1618

1719
const PersonalAccessTokenPrefix = "gitpod_pat_"
@@ -42,6 +44,16 @@ func (t *PersonalAccessToken) Value() string {
4244
return t.value
4345
}
4446

47+
func (t *PersonalAccessToken) ValueHash() (string, error) {
48+
const bcryptCost = 16
49+
hash, err := bcrypt.GenerateFromPassword([]byte(t.value), bcryptCost)
50+
if err != nil {
51+
return "", fmt.Errorf("failed to generate personal access token value hash: %w", err)
52+
}
53+
54+
return string(hash), nil
55+
}
56+
4557
func GeneratePersonalAccessToken(signer Signer) (PersonalAccessToken, error) {
4658
if signer == nil {
4759
return PersonalAccessToken{}, errors.New("no personal access token signer available")

0 commit comments

Comments
 (0)