Skip to content

Commit ccc14fc

Browse files
Simplify key flow: extract private key from service account key if present (#203)
* Draft implementation for extracting private key from service account key * Add comment and update test * Update unit tests, add separate struct for sa key credentials * Env var and configured private key take precendence * Fix lint * Update documentation * Update changelog * Update example and fix empty private key path case * Changes after review
1 parent 1e0d473 commit ccc14fc

File tree

7 files changed

+223
-120
lines changed

7 files changed

+223
-120
lines changed

README.md

+12-11
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,7 @@ When setting up authentication, the SDK will always try to use the key flow firs
108108
```json
109109
{
110110
"STACKIT_SERVICE_ACCOUNT_TOKEN": "foo_token",
111-
"STACKIT_SERVICE_ACCOUNT_KEY_PATH": "path/to/sa_key.json",
112-
"STACKIT_PRIVATE_KEY_PATH": "path/to/private_key.pem"
111+
"STACKIT_SERVICE_ACCOUNT_KEY_PATH": "path/to/sa_key.json"
113112
}
114113
```
115114

@@ -118,17 +117,15 @@ Check the [authentication example](examples/authentication/authentication.go) fo
118117
### Key flow
119118

120119
To use the key flow, you need to have a service account key and an RSA key-pair.
121-
To configure it, follow this steps:
120+
To configure it, follow these steps:
122121

123122
The following instructions assume that you have created a service account and assigned it the necessary permissions, e.g. project.owner.
124123

125124
1. In the Portal, go to the `Service Accounts` tab, choose a `Service Account` and go to `Service Account Keys` to create a key.
126125
- You can create your own RSA key-pair or have the Portal generate one for you.
127-
2. Save the content of the service account key and the corresponding private key by copying them or saving them in a file.
126+
2. Save the content of the service account key by copying it and saving it in a JSON file.
128127

129-
**Hint:** If you have generated the RSA key-pair using the Portal, you can save the private key in a PEM encoded file by downloading the service account key as a PEM file and using `openssl storeutl -keys <path/to/sa_key_pem_file> > private.key` to extract the private key from the service account key.
130-
131-
The expected format of the service account key is a **json** with the following structure:
128+
The expected format of the service account key is a **json** with the following structure:
132129

133130
```json
134131
{
@@ -150,11 +147,15 @@ The expected format of the service account key is a **json** with the following
150147
}
151148
```
152149

153-
3. Configure the service account key and private key for authentication in the SDK by following one of the alternatives below:
150+
3. Configure the service account key for authentication in the SDK by following one of the alternatives below:
154151
- using the configuration options: `config.WithServiceAccountKey` or `config.WithServiceAccountKeyPath`, `config.WithPrivateKey` or `config.WithPrivateKeyPath`
155-
- setting environment variables: `STACKIT_SERVICE_ACCOUNT_KEY_PATH` and `STACKIT_PRIVATE_KEY_PATH`
156-
- setting `STACKIT_SERVICE_ACCOUNT_KEY_PATH` and `STACKIT_PRIVATE_KEY_PATH` in the credentials file (see above)
157-
4. The SDK will search for the keys and, if valid, will use them to get access and refresh tokens which will be used to authenticate all the requests.
152+
- setting environment variables: `STACKIT_SERVICE_ACCOUNT_KEY_PATH`
153+
- setting `STACKIT_SERVICE_ACCOUNT_KEY_PATH` in the credentials file (see above)
154+
4. **If you have provided your own RSA key-pair**, you can set it the same way (it will take precedence over the private key included in the service account key, if present):
155+
- using the configuration options: `config.WithPrivateKey` or `config.WithPrivateKeyPath`
156+
- setting environment variables: `STACKIT_PRIVATE_KEY_PATH`
157+
- setting `STACKIT_PRIVATE_KEY_PATH` in the credentials file (see above)
158+
5. The SDK will search for the keys and, if valid, will use them to get access and refresh tokens which will be used to authenticate all the requests.
158159

159160
### Token flow
160161

core/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## v0.7.5 (2024-01-09)
2+
3+
- **Improvement:** When using the key flow, the SDK will extract the private key from the service account key and use it, if no private key is provided in the configuration, through environment variable or in the credentials file. This makes it simpler to use the key flow: if you create a service account key including the private key, you don't need to provide the private key separately anymore
4+
15
## v0.7.4 (2023-12-22)
26

37
- Replace k8s.io/apimachinery with cenkalti/backoff

core/auth/auth.go

+28-3
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,37 @@ func TokenAuth(cfg *config.Configuration) (http.RoundTripper, error) {
139139

140140
// KeyAuth configures the key flow and returns an http.RoundTripper
141141
// that can be used to make authenticated requests using an access token
142+
// The KeyFlow requires a service account key and a private key.
143+
//
144+
// If the private key is not provided explicitly, KeyAuth will check if there is one included
145+
// in the service account key and use that one. An explicitly provided private key takes
146+
// precedence over the one on the service account key.
142147
func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) {
143148
err := getServiceAccountKey(cfg)
144149
if err != nil {
145150
return nil, fmt.Errorf("configuring key authentication: service account key could not be found: %w", err)
146151
}
147152

153+
// Unmarshal service account key to check if private key is present
154+
var serviceAccountKey = &clients.ServiceAccountKeyResponse{}
155+
err = json.Unmarshal([]byte(cfg.ServiceAccountKey), serviceAccountKey)
156+
if err != nil {
157+
return nil, fmt.Errorf("unmarshalling service account key: %w", err)
158+
}
159+
160+
// Try to get private key from configuration, environment or credentials file
148161
err = getPrivateKey(cfg)
149162
if err != nil {
150-
return nil, fmt.Errorf("configuring key authentication: private key could not be found: %w", err)
163+
// If the private key is not provided explicitly, try to extract private key from the service account key
164+
// and use it if present
165+
var extractedPrivateKey string
166+
if serviceAccountKey.Credentials != nil && serviceAccountKey.Credentials.PrivateKey != nil {
167+
extractedPrivateKey = *serviceAccountKey.Credentials.PrivateKey
168+
}
169+
if extractedPrivateKey == "" {
170+
return nil, fmt.Errorf("configuring key authentication: private key is not part of the service account key and could not be found: %w", err)
171+
}
172+
cfg.PrivateKey = extractedPrivateKey
151173
}
152174

153175
if cfg.TokenCustomUrl == "" {
@@ -164,7 +186,7 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) {
164186
}
165187

166188
keyCfg := clients.KeyFlowConfig{
167-
ServiceAccountKey: cfg.ServiceAccountKey,
189+
ServiceAccountKey: serviceAccountKey,
168190
PrivateKey: cfg.PrivateKey,
169191
ClientRetry: cfg.RetryOptions,
170192
TokenUrl: cfg.TokenCustomUrl,
@@ -203,7 +225,7 @@ func readCredentialsFile(path string) (*Credentials, error) {
203225
var credentials Credentials
204226
err = json.Unmarshal(credentialsRaw, &credentials)
205227
if err != nil {
206-
return nil, fmt.Errorf("unmarshalling credentials: %w", err)
228+
return nil, fmt.Errorf("unmaPrivateKeyrshalling credentials: %w", err)
207229
}
208230
return &credentials, nil
209231
}
@@ -279,6 +301,9 @@ func getPrivateKey(cfg *config.Configuration) (err error) {
279301
if err != nil {
280302
return err
281303
}
304+
if len(privateKeyBytes) == 0 {
305+
return fmt.Errorf("key path points to an empty file")
306+
}
282307
cfg.PrivateKey = string(privateKeyBytes)
283308
}
284309
return nil

core/auth/auth_test.go

+118-51
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,43 @@ import (
44
"crypto/rand"
55
"crypto/rsa"
66
"crypto/x509"
7+
"encoding/json"
78
"encoding/pem"
8-
"fmt"
99
"os"
1010
"reflect"
1111
"testing"
12+
"time"
1213

1314
"github.com/google/uuid"
1415
"github.com/stackitcloud/stackit-sdk-go/core/clients"
1516
"github.com/stackitcloud/stackit-sdk-go/core/config"
1617
)
1718

18-
const saKeyStrPattern = `{
19-
"active": true,
20-
"createdAt": "2023-03-23T18:26:20.335Z",
21-
"credentials": {
22-
"aud": "https://test-url.com",
23-
24-
"kid": "%s",
25-
"sub": "%s"
26-
},
27-
"id": "%s",
28-
"keyAlgorithm": "RSA_2048",
29-
"keyOrigin": "USER_PROVIDED",
30-
"keyType": "USER_MANAGED",
31-
"publicKey": "...",
32-
"validUntil": "2024-03-22T18:05:41Z"
33-
}`
34-
35-
var saKey = fmt.Sprintf(saKeyStrPattern, uuid.New().String(), uuid.New().String(), uuid.New().String())
36-
37-
// Error cases are tested in the noAuth, TokenAuth and DefaultAuth functions
19+
func fixtureServiceAccountKey(mods ...func(*clients.ServiceAccountKeyResponse)) *clients.ServiceAccountKeyResponse {
20+
validUntil := time.Now().Add(time.Hour)
21+
serviceAccountKeyResponse := &clients.ServiceAccountKeyResponse{
22+
Active: true,
23+
CreatedAt: time.Now(),
24+
Credentials: &clients.ServiceAccountKeyCredentials{
25+
Aud: "https://stackit-service-account-prod.apps.01.cf.eu01.stackit.cloud",
26+
27+
Kid: uuid.New().String(),
28+
Sub: uuid.New(),
29+
},
30+
ID: uuid.New(),
31+
KeyAlgorithm: "RSA_2048",
32+
KeyOrigin: "USER_PROVIDED",
33+
KeyType: "USER_MANAGED",
34+
PublicKey: "...",
35+
ValidUntil: &validUntil,
36+
}
37+
for _, mod := range mods {
38+
mod(serviceAccountKeyResponse)
39+
}
40+
return serviceAccountKeyResponse
41+
}
42+
43+
// Error cases are tested in the NoAuth, KeyAuth, TokenAuth and DefaultAuth functions
3844
func TestSetupAuth(t *testing.T) {
3945
privateKey, err := generatePrivateKey()
4046
if err != nil {
@@ -78,7 +84,11 @@ func TestSetupAuth(t *testing.T) {
7884
}
7985
}()
8086

81-
// Write some text to the file
87+
// Write the service account key to the file
88+
saKey, err := json.Marshal(fixtureServiceAccountKey())
89+
if err != nil {
90+
t.Fatalf("unmarshalling service account key: %s", err)
91+
}
8292
_, errs = saKeyFile.WriteString(string(saKey))
8393
if errs != nil {
8494
t.Fatalf("Writing private key to temporary file: %s", err)
@@ -288,7 +298,11 @@ func TestDefaultAuth(t *testing.T) {
288298
}
289299
}()
290300

291-
// Write some text to the file
301+
// Write the service account key to the file
302+
saKey, err := json.Marshal(fixtureServiceAccountKey())
303+
if err != nil {
304+
t.Fatalf("unmarshalling service account key: %s", err)
305+
}
292306
_, errs = saKeyFile.WriteString(string(saKey))
293307
if errs != nil {
294308
t.Fatalf("Writing private key to temporary file: %s", err)
@@ -417,40 +431,71 @@ func TestTokenAuth(t *testing.T) {
417431
}
418432

419433
func TestKeyAuth(t *testing.T) {
420-
privateKey, err := generatePrivateKey()
434+
includedPrivateKey, err := generatePrivateKey()
435+
if err != nil {
436+
t.Fatalf("Failed to generate private key for testing")
437+
}
438+
configuredPrivateKey, err := generatePrivateKey()
421439
if err != nil {
422440
t.Fatalf("Failed to generate private key for testing")
423441
}
424442

425443
for _, test := range []struct {
426-
desc string
427-
serviceAccountKey string
428-
privateKey string
429-
isValid bool
444+
desc string
445+
serviceAccountKey *clients.ServiceAccountKeyResponse
446+
includedPrivateKey *string
447+
configuredPrivateKey string
448+
envVarPrivateKey string
449+
expectedPrivateKey string
450+
isValid bool
430451
}{
431452
{
432-
desc: "valid_case",
433-
serviceAccountKey: saKey,
434-
privateKey: string(privateKey),
435-
isValid: true,
453+
desc: "configured_private_key",
454+
serviceAccountKey: fixtureServiceAccountKey(),
455+
configuredPrivateKey: string(configuredPrivateKey),
456+
expectedPrivateKey: string(configuredPrivateKey),
457+
isValid: true,
436458
},
437459
{
438-
desc: "no_sa_key",
439-
serviceAccountKey: "",
440-
privateKey: string(privateKey),
441-
isValid: false,
460+
desc: "included_private_key",
461+
serviceAccountKey: fixtureServiceAccountKey(),
462+
includedPrivateKey: &includedPrivateKey,
463+
expectedPrivateKey: includedPrivateKey,
464+
isValid: true,
465+
},
466+
{
467+
desc: "empty_configured_use_included_private_key",
468+
serviceAccountKey: fixtureServiceAccountKey(),
469+
includedPrivateKey: &includedPrivateKey,
470+
configuredPrivateKey: "",
471+
expectedPrivateKey: includedPrivateKey,
472+
isValid: true,
442473
},
443474
{
444-
desc: "no_private_key",
445-
serviceAccountKey: "no_sa_key",
446-
privateKey: "",
447-
isValid: false,
475+
desc: "configured_over_included_private_key",
476+
serviceAccountKey: fixtureServiceAccountKey(),
477+
includedPrivateKey: &includedPrivateKey,
478+
configuredPrivateKey: configuredPrivateKey,
479+
expectedPrivateKey: configuredPrivateKey,
480+
isValid: true,
448481
},
449482
{
450-
desc: "no_keys",
451-
serviceAccountKey: "",
452-
privateKey: "",
453-
isValid: false,
483+
desc: "no_sa_key",
484+
serviceAccountKey: nil,
485+
configuredPrivateKey: configuredPrivateKey,
486+
isValid: false,
487+
},
488+
{
489+
desc: "missing_private_key",
490+
serviceAccountKey: fixtureServiceAccountKey(),
491+
configuredPrivateKey: "",
492+
isValid: false,
493+
},
494+
{
495+
desc: "no_keys",
496+
serviceAccountKey: nil,
497+
configuredPrivateKey: "",
498+
isValid: false,
454499
},
455500
} {
456501
t.Run(test.desc, func(t *testing.T) {
@@ -459,9 +504,21 @@ func TestKeyAuth(t *testing.T) {
459504
t.Setenv("STACKIT_PRIVATE_KEY", "")
460505
t.Setenv("STACKIT_PRIVATE_KEY_PATH", "")
461506

507+
var saKey string
508+
if test.serviceAccountKey != nil {
509+
test.serviceAccountKey.Credentials.PrivateKey = test.includedPrivateKey
510+
511+
saKeyBytes, err := json.Marshal(test.serviceAccountKey)
512+
if err != nil {
513+
t.Fatalf("unmarshalling service account key: %s", err)
514+
}
515+
516+
saKey = string(saKeyBytes)
517+
}
518+
462519
cfg := &config.Configuration{
463-
ServiceAccountKey: test.serviceAccountKey,
464-
PrivateKey: test.privateKey,
520+
ServiceAccountKey: saKey,
521+
PrivateKey: test.configuredPrivateKey,
465522
}
466523
// Get the key authentication client and ensure that it's not nil
467524
authClient, err := KeyAuth(cfg)
@@ -474,8 +531,18 @@ func TestKeyAuth(t *testing.T) {
474531
t.Fatalf("Test didn't return error on invalid test case")
475532
}
476533

477-
if test.isValid && authClient == nil {
478-
t.Fatalf("Client returned is nil for valid test case")
534+
if test.isValid {
535+
if authClient == nil {
536+
t.Fatalf("Client returned is nil for valid test case")
537+
}
538+
539+
keyFlow, ok := authClient.(*clients.KeyFlow)
540+
if !ok {
541+
t.Fatalf("Could not convert authClient to KeyFlow")
542+
}
543+
if keyFlow.GetConfig().PrivateKey != test.expectedPrivateKey {
544+
t.Fatalf("The private key is wrong: expected %s, got %s", test.expectedPrivateKey, keyFlow.GetConfig().PrivateKey)
545+
}
479546
}
480547
})
481548
}
@@ -575,11 +642,11 @@ func TestGetServiceAccountEmail(t *testing.T) {
575642
}
576643
}
577644

578-
func generatePrivateKey() ([]byte, error) {
645+
func generatePrivateKey() (string, error) {
579646
// Generate a new RSA key pair with a size of 2048 bits
580647
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
581648
if err != nil {
582-
return nil, err
649+
return "", err
583650
}
584651

585652
// Encode the private key in PEM format
@@ -589,7 +656,7 @@ func generatePrivateKey() ([]byte, error) {
589656
}
590657

591658
// Print the private and public keys
592-
return pem.EncodeToMemory(privKeyPEM), nil
659+
return string(pem.EncodeToMemory(privKeyPEM)), nil
593660
}
594661

595662
func TestGetPrivateKey(t *testing.T) {

0 commit comments

Comments
 (0)