Skip to content

Commit 54a1588

Browse files
committed
[public-api] Generate & Parse Personal Access Tokens
1 parent f5a67af commit 54a1588

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-0
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package auth
6+
7+
import (
8+
"crypto/rand"
9+
"crypto/subtle"
10+
"encoding/base64"
11+
"errors"
12+
"fmt"
13+
"math/big"
14+
"strings"
15+
)
16+
17+
const PersonalAccessTokenPrefix = "gitpod_pat_"
18+
19+
// PersonalAccessToken token is an Access Token for individuals. Any action taken with this token will act on behalf of the token creator.
20+
// The PersonalAccessToken, in string form, takes the following shape: gitpod_pat_<signature>.<value>
21+
// E.g. gitpod_pat_tVuNSaTwuEQgPf4ZouRpUxOB0g3ZwwrH_WnV0FXkIuc=.zXZ3nEOsweP4Y28TNe6Huoy9-IzY5T27RUZ6xO4-UmtOOJO6pSSIUw==
22+
type PersonalAccessToken struct {
23+
// prefix is the human readable prefix for the token used to identify which type of token it is,
24+
// but also for code-scanning of leaked credentials.
25+
// e.g. `gitpod_pat`
26+
prefix string
27+
28+
// value is the secret value of the token
29+
value string
30+
31+
// signature is the generated signature of the value
32+
// signature is used to validate the personal access token before using it
33+
// signature is Base 64 URL Encoded, without padding
34+
signature string
35+
}
36+
37+
func (t *PersonalAccessToken) String() string {
38+
return fmt.Sprintf("%s%s.%s", t.prefix, t.signature, t.value)
39+
}
40+
41+
func (t *PersonalAccessToken) Value() string {
42+
return t.value
43+
}
44+
45+
func GeneratePersonalAccessToken(signer Signer) (PersonalAccessToken, error) {
46+
if signer == nil {
47+
return PersonalAccessToken{}, errors.New("no personal access token signer available")
48+
}
49+
50+
value, err := generateTokenValue(40)
51+
if err != nil {
52+
return PersonalAccessToken{}, fmt.Errorf("failed to generate personal access token value: %w", err)
53+
}
54+
55+
signature, err := signer.Sign([]byte(value))
56+
if err != nil {
57+
return PersonalAccessToken{}, fmt.Errorf("failed to sign personal access token value: %w", err)
58+
}
59+
60+
return PersonalAccessToken{
61+
prefix: PersonalAccessTokenPrefix,
62+
value: value,
63+
// We use base64.RawURLEncoding because we do not want padding in the token in the form of '=' signs
64+
signature: base64.RawURLEncoding.EncodeToString(signature),
65+
}, nil
66+
}
67+
68+
func ParsePersonalAccessToken(token string, signer Signer) (PersonalAccessToken, error) {
69+
if token == "" {
70+
return PersonalAccessToken{}, errors.New("empty personal access")
71+
}
72+
// Assume we start with the following token: gitpod_pat_fWOI59CuXuDFw1Eprl_yvqBV8LCV8J02w6RY2u51Lq4.mk6kzRrMEoBgldwO0emmTPdbVVpxTFUesriyLjg1
73+
// First, we identify if the token contains the required prefix
74+
if !strings.HasPrefix(token, PersonalAccessTokenPrefix) {
75+
return PersonalAccessToken{}, fmt.Errorf("personal access token does not have %s prefix", PersonalAccessTokenPrefix)
76+
}
77+
78+
// Remove the gitpod_pat_ prefix
79+
token = strings.TrimPrefix(token, PersonalAccessTokenPrefix)
80+
81+
// We now have the token in the following form:
82+
// fWOI59CuXuDFw1Eprl_yvqBV8LCV8J02w6RY2u51Lq4.mk6kzRrMEoBgldwO0emmTPdbVVpxTFUesriyLjg1
83+
// Break it into <signature>.<value>
84+
parts := strings.SplitN(token, ".", 2)
85+
if len(parts) != 2 {
86+
return PersonalAccessToken{}, errors.New("failed to break personal access token into signature and value")
87+
}
88+
89+
// Sanity check the extracted values
90+
signature, value := parts[0], parts[1]
91+
if signature == "" {
92+
return PersonalAccessToken{}, errors.New("personal access token has empty signature")
93+
}
94+
if value == "" {
95+
return PersonalAccessToken{}, errors.New("personal access token has empty value")
96+
}
97+
98+
// We must validate the signature before we proceed further.
99+
signatureForValue, err := signer.Sign([]byte(value))
100+
if err != nil {
101+
return PersonalAccessToken{}, fmt.Errorf("failed to compute signature of personal access token value: %w", err)
102+
}
103+
104+
// The signature we receive is Base64 encoded, we also encode the signature for value we've just generated.
105+
encodedSignatureForValue := base64.RawURLEncoding.EncodeToString(signatureForValue)
106+
107+
// Perform a cryptographically safe comparison between the signature, and the value we've just signed
108+
if subtle.ConstantTimeCompare([]byte(signature), []byte(encodedSignatureForValue)) != 1 {
109+
return PersonalAccessToken{}, errors.New("personal access token signature does not match token value")
110+
}
111+
112+
return PersonalAccessToken{
113+
prefix: PersonalAccessTokenPrefix,
114+
value: value,
115+
signature: signature,
116+
}, nil
117+
}
118+
119+
func generateTokenValue(size int) (string, error) {
120+
if size <= 0 {
121+
return "", errors.New("token size must be greater than 0")
122+
}
123+
124+
// letters represent the resulting character-set of the token
125+
// we use only upper/lower alphanumberic to ensure the token is
126+
// * easy to select by double-clicking it
127+
// * URL safe
128+
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
129+
ret := make([]byte, size)
130+
for i := 0; i < size; i++ {
131+
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
132+
if err != nil {
133+
return "", err
134+
}
135+
ret[i] = letters[num.Int64()]
136+
}
137+
138+
return string(ret), nil
139+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package auth
6+
7+
import (
8+
"encoding/base64"
9+
"fmt"
10+
"regexp"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestGeneratePersonalAccessToken(t *testing.T) {
17+
signer := NewHS256Signer([]byte("my-secret"))
18+
19+
pat, err := GeneratePersonalAccessToken(signer)
20+
require.NoError(t, err)
21+
22+
signature, err := signer.Sign([]byte(pat.value))
23+
require.NoError(t, err)
24+
25+
require.NotEmpty(t, pat.value)
26+
require.Len(t, pat.value, 40)
27+
require.Equal(t, PersonalAccessToken{
28+
prefix: PersonalAccessTokenPrefix,
29+
value: pat.value,
30+
signature: base64.RawURLEncoding.EncodeToString(signature),
31+
}, pat)
32+
require.Equal(t, fmt.Sprintf("%s%s.%s", pat.prefix, pat.signature, pat.value), pat.String())
33+
34+
// must also be able to parse the token back
35+
parsed, err := ParsePersonalAccessToken(pat.String(), signer)
36+
require.NoError(t, err)
37+
require.Equal(t, pat, parsed)
38+
}
39+
40+
func TestParsePersonalAccessToken_Errors(t *testing.T) {
41+
signer := NewHS256Signer([]byte("my-secret"))
42+
43+
scenarios := []struct {
44+
Name string
45+
Token string
46+
}{
47+
{
48+
Name: "empty token is rejected",
49+
Token: "",
50+
},
51+
{
52+
Name: "invalid prefix",
53+
Token: "gitpod_yolo_fooo",
54+
},
55+
{
56+
Name: "invalid token with correct prefix",
57+
Token: "gitpod_pat_foo",
58+
},
59+
{
60+
Name: "invalid token with correct prefix and empty value and signature",
61+
Token: "gitpod_pat_.",
62+
},
63+
{
64+
Name: "invalid token with correct prefix but missing signature",
65+
Token: "gitpod_pat_.value",
66+
},
67+
{
68+
Name: "invalid token with correct prefix but missing value",
69+
Token: "gitpod_pat_signature.",
70+
},
71+
{
72+
Name: "invalid signature",
73+
Token: "gitpod_pat_signature.value",
74+
},
75+
}
76+
77+
for _, s := range scenarios {
78+
t.Run(s.Name, func(t *testing.T) {
79+
_, err := ParsePersonalAccessToken(s.Token, signer)
80+
require.Error(t, err)
81+
})
82+
83+
}
84+
}
85+
86+
func TestGenerateTokenValue_OnlyAlphaNumberic(t *testing.T) {
87+
sizes := []int{10, 20, 30, 40, 50, 60, 70, 80}
88+
89+
var tokens []string
90+
for _, size := range sizes {
91+
for i := 0; i < 10; i++ {
92+
token, err := generateTokenValue(size)
93+
require.NoError(t, err)
94+
95+
tokens = append(tokens, token)
96+
}
97+
}
98+
99+
for _, token := range tokens {
100+
rxp := regexp.MustCompile(`([a-zA-Z]|\d)+`)
101+
require.Regexp(t, rxp, token, "must match alphanumeric")
102+
}
103+
}
104+
105+
func TestGenerateTokenValue_FailsWithSizeZeroOrSmaller(t *testing.T) {
106+
_, err := generateTokenValue(0)
107+
require.Error(t, err)
108+
109+
_, err = generateTokenValue(-1)
110+
require.Error(t, err)
111+
}
112+
113+
func TestGen(t *testing.T) {
114+
signer := NewHS256Signer([]byte("my-secret"))
115+
for i := 0; i < 100; i++ {
116+
token, err := GeneratePersonalAccessToken(signer)
117+
require.NoError(t, err)
118+
119+
fmt.Println(token.String())
120+
}
121+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package auth
6+
7+
import (
8+
"crypto/hmac"
9+
"crypto/sha256"
10+
"fmt"
11+
)
12+
13+
type Signer interface {
14+
Sign(message []byte) ([]byte, error)
15+
}
16+
17+
func NewHS256Signer(key []byte) *HS256Signer {
18+
return &HS256Signer{
19+
key: key,
20+
}
21+
}
22+
23+
type HS256Signer struct {
24+
key []byte
25+
}
26+
27+
// Signs message with HS256 (HMAC with SHA-256)
28+
func (s *HS256Signer) Sign(message []byte) ([]byte, error) {
29+
h := hmac.New(sha256.New, s.key)
30+
_, err := h.Write(message)
31+
if err != nil {
32+
return nil, fmt.Errorf("failed to sign secret: %w", err)
33+
}
34+
return h.Sum(nil), nil
35+
}

0 commit comments

Comments
 (0)