Skip to content

[public-api] Generate & Parse Personal Access Tokens #14806

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 1 commit into from
Nov 21, 2022
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
139 changes: 139 additions & 0 deletions components/public-api-server/pkg/auth/personal_access_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package auth

import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"math/big"
"strings"
)

const PersonalAccessTokenPrefix = "gitpod_pat_"

// PersonalAccessToken token is an Access Token for individuals. Any action taken with this token will act on behalf of the token creator.
// The PersonalAccessToken, in string form, takes the following shape: gitpod_pat_<signature>.<value>
// E.g. gitpod_pat_ko8KC1tJ-GkqIwqNliwF4tBUk2Jd5nEe9qOWqYfobtY.6ZDQVanpaTKj9hQuji0thCe8KFCcmEDGpsaTkSSb
type PersonalAccessToken struct {
// prefix is the human readable prefix for the token used to identify which type of token it is,
// but also for code-scanning of leaked credentials.
// e.g. `gitpod_pat_`
prefix string

// value is the secret value of the token
value string

// signature is the generated signature of the value
// signature is used to validate the personal access token before using it
// signature is Base 64 URL Encoded, without padding
signature string
}

func (t *PersonalAccessToken) String() string {
return fmt.Sprintf("%s%s.%s", t.prefix, t.signature, t.value)
}

func (t *PersonalAccessToken) Value() string {
return t.value
}

func GeneratePersonalAccessToken(signer Signer) (PersonalAccessToken, error) {
if signer == nil {
return PersonalAccessToken{}, errors.New("no personal access token signer available")
}

value, err := generateTokenValue(40)
if err != nil {
return PersonalAccessToken{}, fmt.Errorf("failed to generate personal access token value: %w", err)
}

signature, err := signer.Sign([]byte(value))
if err != nil {
return PersonalAccessToken{}, fmt.Errorf("failed to sign personal access token value: %w", err)
}

return PersonalAccessToken{
prefix: PersonalAccessTokenPrefix,
value: value,
// We use base64.RawURLEncoding because we do not want padding in the token in the form of '=' signs
signature: base64.RawURLEncoding.EncodeToString(signature),
}, nil
}

func ParsePersonalAccessToken(token string, signer Signer) (PersonalAccessToken, error) {
if token == "" {
return PersonalAccessToken{}, errors.New("empty personal access")
}
// Assume we start with the following token: gitpod_pat_ko8KC1tJ-GkqIwqNliwF4tBUk2Jd5nEe9qOWqYfobtY.6ZDQVanpaTKj9hQuji0thCe8KFCcmEDGpsaTkSSb
// First, we identify if the token contains the required prefix
if !strings.HasPrefix(token, PersonalAccessTokenPrefix) {
return PersonalAccessToken{}, fmt.Errorf("personal access token does not have %s prefix", PersonalAccessTokenPrefix)
}

// Remove the gitpod_pat_ prefix
token = strings.TrimPrefix(token, PersonalAccessTokenPrefix)

// We now have the token in the following form:
// ko8KC1tJ-GkqIwqNliwF4tBUk2Jd5nEe9qOWqYfobtY.6ZDQVanpaTKj9hQuji0thCe8KFCcmEDGpsaTkSSb
// Break it into <signature>.<value>
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return PersonalAccessToken{}, errors.New("failed to break personal access token into signature and value")
}

// Sanity check the extracted values
signature, value := parts[0], parts[1]
if signature == "" {
return PersonalAccessToken{}, errors.New("personal access token has empty signature")
}
if value == "" {
return PersonalAccessToken{}, errors.New("personal access token has empty value")
}

// We must validate the signature before we proceed further.
signatureForValue, err := signer.Sign([]byte(value))
if err != nil {
return PersonalAccessToken{}, fmt.Errorf("failed to compute signature of personal access token value: %w", err)
}

// The signature we receive is Base64 encoded, we also encode the signature for value we've just generated.
encodedSignatureForValue := base64.RawURLEncoding.EncodeToString(signatureForValue)

// Perform a cryptographically safe comparison between the signature, and the value we've just signed
if subtle.ConstantTimeCompare([]byte(signature), []byte(encodedSignatureForValue)) != 1 {
return PersonalAccessToken{}, errors.New("personal access token signature does not match token value")
}

return PersonalAccessToken{
prefix: PersonalAccessTokenPrefix,
value: value,
signature: signature,
}, nil
}

func generateTokenValue(size int) (string, error) {
if size <= 0 {
return "", errors.New("token size must be greater than 0")
}

// letters represent the resulting character-set of the token
// we use only upper/lower alphanumberic to ensure the token is
// * easy to select by double-clicking it
// * URL safe
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ret := make([]byte, size)
for i := 0; i < size; i++ {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
return "", err
}
ret[i] = letters[num.Int64()]
}

return string(ret), nil
}
111 changes: 111 additions & 0 deletions components/public-api-server/pkg/auth/personal_access_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package auth

import (
"encoding/base64"
"fmt"
"regexp"
"testing"

"github.com/stretchr/testify/require"
)

func TestGeneratePersonalAccessToken(t *testing.T) {
signer := NewHS256Signer([]byte("my-secret"))

pat, err := GeneratePersonalAccessToken(signer)
require.NoError(t, err)

signature, err := signer.Sign([]byte(pat.value))
require.NoError(t, err)

require.NotEmpty(t, pat.value)
require.Len(t, pat.value, 40)
require.Equal(t, PersonalAccessToken{
prefix: PersonalAccessTokenPrefix,
value: pat.value,
signature: base64.RawURLEncoding.EncodeToString(signature),
}, pat)
require.Equal(t, fmt.Sprintf("%s%s.%s", pat.prefix, pat.signature, pat.value), pat.String())

// must also be able to parse the token back
parsed, err := ParsePersonalAccessToken(pat.String(), signer)
require.NoError(t, err)
require.Equal(t, pat, parsed)
}

func TestParsePersonalAccessToken_Errors(t *testing.T) {
signer := NewHS256Signer([]byte("my-secret"))

scenarios := []struct {
Name string
Token string
}{
{
Name: "empty token is rejected",
Token: "",
},
{
Name: "invalid prefix",
Token: "gitpod_yolo_fooo",
},
{
Name: "invalid token with correct prefix",
Token: "gitpod_pat_foo",
},
{
Name: "invalid token with correct prefix and empty value and signature",
Token: "gitpod_pat_.",
},
{
Name: "invalid token with correct prefix but missing signature",
Token: "gitpod_pat_.value",
},
{
Name: "invalid token with correct prefix but missing value",
Token: "gitpod_pat_signature.",
},
{
Name: "invalid signature",
Token: "gitpod_pat_signature.value",
},
}

for _, s := range scenarios {
t.Run(s.Name, func(t *testing.T) {
_, err := ParsePersonalAccessToken(s.Token, signer)
require.Error(t, err)
})

}
}

func TestGenerateTokenValue_OnlyAlphaNumberic(t *testing.T) {
sizes := []int{10, 20, 30, 40, 50, 60, 70, 80}

var tokens []string
for _, size := range sizes {
for i := 0; i < 10; i++ {
token, err := generateTokenValue(size)
require.NoError(t, err)

tokens = append(tokens, token)
}
}

for _, token := range tokens {
rxp := regexp.MustCompile(`([a-zA-Z]|\d)+`)
require.Regexp(t, rxp, token, "must match alphanumeric")
}
}

func TestGenerateTokenValue_FailsWithSizeZeroOrSmaller(t *testing.T) {
_, err := generateTokenValue(0)
require.Error(t, err)

_, err = generateTokenValue(-1)
require.Error(t, err)
}
35 changes: 35 additions & 0 deletions components/public-api-server/pkg/auth/signature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package auth

import (
"crypto/hmac"
"crypto/sha256"
"fmt"
)

type Signer interface {
Sign(message []byte) ([]byte, error)
}

func NewHS256Signer(key []byte) *HS256Signer {
return &HS256Signer{
key: key,
}
}

type HS256Signer struct {
key []byte
}

// Signs message with HS256 (HMAC with SHA-256)
func (s *HS256Signer) Sign(message []byte) ([]byte, error) {
h := hmac.New(sha256.New, s.key)
_, err := h.Write(message)
if err != nil {
return nil, fmt.Errorf("failed to sign secret: %w", err)
}
return h.Sum(nil), nil
}