Skip to content

Commit 3f2604b

Browse files
committed
Initial commit to support JWT authentication
1 parent 3c2ba31 commit 3f2604b

File tree

3 files changed

+274
-11
lines changed

3 files changed

+274
-11
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/hashicorp/hc-install v0.9.2
2121
github.com/hashicorp/terraform-exec v0.23.0
2222
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
23+
github.com/lestrrat-go/jwx/v2 v2.1.6
2324
github.com/lestrrat-go/jwx/v3 v3.0.1
2425
github.com/logrusorgru/aurora v2.0.3+incompatible
2526
github.com/manifoldco/promptui v0.9.0
@@ -76,7 +77,6 @@ require (
7677
github.com/lestrrat-go/httprc v1.0.6 // indirect
7778
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
7879
github.com/lestrrat-go/iter v1.0.2 // indirect
79-
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
8080
github.com/lestrrat-go/option v1.0.1 // indirect
8181
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
8282
github.com/mattn/go-colorable v0.1.13 // indirect

internal/auth/auth.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import (
1313
"strings"
1414
"time"
1515

16+
"github.com/google/uuid"
17+
"github.com/lestrrat-go/jwx/v2/jwa"
18+
"github.com/lestrrat-go/jwx/v2/jwk"
19+
"github.com/lestrrat-go/jwx/v2/jwt"
20+
"golang.org/x/oauth2"
1621
"golang.org/x/oauth2/clientcredentials"
1722
)
1823

@@ -257,3 +262,151 @@ func GetAccessTokenFromClientCreds(ctx context.Context, args ClientCredentials)
257262
ExpiresAt: resp.Expiry,
258263
}, nil
259264
}
265+
266+
// GetAccessTokenFromClientPrivateJWT generates an access token from client prviateJWT.
267+
func GetAccessTokenFromClientPrivateJWT(args PrivateKeyJwtTokenSource) (Result, error) {
268+
resp, err := args.Token()
269+
if err != nil {
270+
return Result{}, err
271+
}
272+
273+
fmt.Println(resp.AccessToken)
274+
275+
return Result{
276+
AccessToken: resp.AccessToken,
277+
ExpiresAt: resp.Expiry,
278+
}, nil
279+
}
280+
281+
// PrivateKeyJwtTokenSource implements oauth2.TokenSource for Private Key JWT client authentication.
282+
type PrivateKeyJwtTokenSource struct {
283+
Ctx context.Context
284+
Uri string
285+
ClientID string
286+
ClientAssertionSigningAlg string
287+
ClientAssertionSigningKey string
288+
Audience string
289+
}
290+
291+
// Token generates a new token using Private Key JWT client authentication.
292+
func (p PrivateKeyJwtTokenSource) Token() (*oauth2.Token, error) {
293+
alg, err := DetermineSigningAlgorithm(p.ClientAssertionSigningAlg)
294+
if err != nil {
295+
return nil, fmt.Errorf("invalid algorithm: %w", err)
296+
}
297+
298+
baseURL, err := url.Parse(p.Uri)
299+
if err != nil {
300+
return nil, fmt.Errorf("invalid URI: %w", err)
301+
}
302+
303+
assertion, err := CreateClientAssertion(
304+
alg,
305+
p.ClientAssertionSigningKey,
306+
p.ClientID,
307+
baseURL.JoinPath("/").String(),
308+
)
309+
if err != nil {
310+
return nil, fmt.Errorf("failed to create client assertion: %w", err)
311+
}
312+
313+
cfg := &clientcredentials.Config{
314+
TokenURL: p.Uri + "/oauth/token",
315+
AuthStyle: oauth2.AuthStyleInParams,
316+
EndpointParams: url.Values{
317+
"audience": []string{p.Audience},
318+
"client_assertion_type": []string{"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
319+
"client_assertion": []string{assertion},
320+
"grant_type": []string{"client_credentials"},
321+
},
322+
}
323+
324+
token, err := cfg.Token(p.Ctx)
325+
if err != nil {
326+
return nil, fmt.Errorf("token request failed: %w", err)
327+
}
328+
329+
return token, nil
330+
}
331+
332+
// DetermineSigningAlgorithm returns the appropriate JWA signature algorithm based on the string representation.
333+
func DetermineSigningAlgorithm(alg string) (jwa.SignatureAlgorithm, error) {
334+
switch alg {
335+
case "RS256":
336+
return jwa.RS256, nil
337+
case "RS384":
338+
return jwa.RS384, nil
339+
case "RS512":
340+
return jwa.RS512, nil
341+
case "PS256":
342+
return jwa.PS256, nil
343+
case "PS384":
344+
return jwa.PS384, nil
345+
case "PS512":
346+
return jwa.PS512, nil
347+
case "ES256":
348+
return jwa.ES256, nil
349+
case "ES384":
350+
return jwa.ES384, nil
351+
case "ES512":
352+
return jwa.ES512, nil
353+
default:
354+
return "", fmt.Errorf("unsupported client assertion algorithm %q", alg)
355+
}
356+
}
357+
358+
// CreateClientAssertion creates a JWT token for client authentication with the specified lifetime.
359+
func CreateClientAssertion(alg jwa.SignatureAlgorithm, signingKey, clientID, audience string) (string, error) {
360+
key, err := jwk.ParseKey([]byte(signingKey), jwk.WithPEM(true))
361+
if err != nil {
362+
return "", fmt.Errorf("failed to parse signing key: %w", err)
363+
}
364+
365+
// Verify that the key type is compatible with the algorithm
366+
if err := verifyKeyCompatibility(alg, key); err != nil {
367+
return "", err
368+
}
369+
370+
now := time.Now()
371+
372+
token, err := jwt.NewBuilder().
373+
IssuedAt(now).
374+
NotBefore(now).
375+
Subject(clientID).
376+
JwtID(uuid.NewString()).
377+
Issuer(clientID).
378+
Audience([]string{audience}).
379+
Expiration(now.Add(2 * time.Minute)).
380+
Build()
381+
if err != nil {
382+
return "", fmt.Errorf("failed to build JWT: %w", err)
383+
}
384+
385+
signedToken, err := jwt.Sign(token, jwt.WithKey(alg, key))
386+
if err != nil {
387+
return "", fmt.Errorf("failed to sign JWT: %w", err)
388+
}
389+
390+
return string(signedToken), nil
391+
}
392+
393+
// verifyKeyCompatibility checks if the provided key is compatible with the specified algorithm.
394+
func verifyKeyCompatibility(alg jwa.SignatureAlgorithm, key jwk.Key) error {
395+
keyType := key.KeyType()
396+
397+
// Check key compatibility with algorithm
398+
switch alg {
399+
case jwa.RS256, jwa.RS384, jwa.RS512, jwa.PS256, jwa.PS384, jwa.PS512:
400+
if keyType != "RSA" {
401+
return fmt.Errorf("%s algorithm requires an RSA key, but got %s", alg, keyType)
402+
}
403+
case jwa.ES256, jwa.ES384, jwa.ES512:
404+
if keyType != "EC" {
405+
return fmt.Errorf("%s algorithm requires an EC key, but got %s", alg, keyType)
406+
}
407+
default:
408+
return fmt.Errorf("unsupported algorithm: %s", alg)
409+
}
410+
411+
return nil
412+
}

internal/cli/login.go

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ var (
4141
AlwaysPrompt: false,
4242
}
4343

44+
loginClientAssertionSigningKey = Flag{
45+
Name: "Client Assertion Signing Key",
46+
LongForm: "client-assertion-signing-key",
47+
Help: "Client Assertion .",
48+
IsRequired: false,
49+
AlwaysPrompt: false,
50+
}
51+
52+
loginClientAssertionSigningAlg = Flag{
53+
Name: "Client Assertion Signing Algorithm",
54+
LongForm: "client-assertion-signing-alg",
55+
Help: "Client Assertion Signing Algorithm.",
56+
IsRequired: false,
57+
AlwaysPrompt: false,
58+
}
59+
4460
loginAdditionalScopes = Flag{
4561
Name: "Additional Scopes",
4662
LongForm: "scopes",
@@ -51,10 +67,12 @@ var (
5167
)
5268

5369
type LoginInputs struct {
54-
Domain string
55-
ClientID string
56-
ClientSecret string
57-
AdditionalScopes []string
70+
Domain string
71+
ClientID string
72+
ClientSecret string
73+
ClientAssertionSigningKey string
74+
ClientAssertionSigningAlg string
75+
AdditionalScopes []string
5876
}
5977

6078
func (i *LoginInputs) isLoggingInWithAdditionalScopes() bool {
@@ -78,7 +96,7 @@ func loginCmd(cli *cli) *cobra.Command {
7896
RunE: func(cmd *cobra.Command, args []string) error {
7997
var selectedLoginType string
8098
const loginAsUser, loginAsMachine = "As a user", "As a machine"
81-
shouldLoginAsUser, shouldLoginAsMachine := false, false
99+
shouldLoginAsUser, shouldLoginAsMachine, shouldLoginAsMachineWithJWT := false, false, false
82100

83101
/*
84102
Based on the initial inputs we'd like to determine if
@@ -95,35 +113,59 @@ func loginCmd(cli *cli) *cobra.Command {
95113
case inputs.Domain != "" && inputs.ClientSecret != "" && inputs.ClientID != "":
96114
// If all three fields are passed, machine login flag is set to true.
97115
shouldLoginAsMachine = true
98-
case inputs.Domain != "" && inputs.ClientSecret == "" && inputs.ClientID == "":
116+
case inputs.Domain != "" && inputs.ClientID != "" && inputs.ClientAssertionSigningAlg != "" && inputs.ClientAssertionSigningKey != "":
117+
// If all four fields are passed related to client Assertion, machine login with jwt flag is set to true.
118+
shouldLoginAsMachineWithJWT = true
119+
case inputs.Domain != "" && inputs.ClientSecret == "" && inputs.ClientID == "" && inputs.ClientAssertionSigningAlg == "" && inputs.ClientAssertionSigningKey == "":
99120
/*
100121
The domain flag is common between Machine and User Login.
101122
If domain is passed without client-id and client-secret,
102123
it can be evaluated that it is a user login flow.
103124
*/
104125
shouldLoginAsUser = true
105-
case inputs.Domain != "" || inputs.ClientSecret != "" || inputs.ClientID != "":
126+
case inputs.Domain != "" || inputs.ClientSecret != "" || inputs.ClientID != "" || inputs.ClientAssertionSigningAlg != "" || inputs.ClientAssertionSigningKey != "":
106127
/*
107128
At this point, if AT LEAST one of the three flags are passed but not ALL three,
108129
we return an error since it's a no-input flow and it will need all three params
109130
for successful machine flow.
110131
Note that we already determined it's not a user login flow in the condition above.
111132
*/
112-
return fmt.Errorf("flags client-id, client-secret and domain are required together")
133+
return fmt.Errorf("flags client-id, client-secret and domain are required together or client-id, client-assertion-signing-alg, client-assertion-signing-key and domain are required together")
113134
default:
114135
/*
115136
If no flags are passed along with --no-input, it is defaulted to user login flow.
116137
*/
117138
shouldLoginAsUser = true
118139
}
119140
default:
120-
if inputs.ClientSecret != "" || inputs.ClientID != "" {
141+
if inputs.ClientID != "" {
142+
const clientSecret, clientAssertion = "Client Secret", "Client Assertion"
143+
input := prompt.SelectInput("", "How would you like to authenticate?", "", []string{clientSecret, clientAssertion}, clientSecret, true)
144+
if err := prompt.AskOne(input, &selectedLoginType); err != nil {
145+
return handleInputError(err)
146+
}
147+
if selectedLoginType == clientAssertion {
148+
shouldLoginAsMachineWithJWT = true
149+
} else {
150+
shouldLoginAsMachine = true
151+
}
152+
}
153+
154+
if inputs.ClientSecret != "" {
121155
/*
122156
If all three params are passed, we evaluate it as a Machine Login Flow.
123157
Else required params are prompted for.
124158
*/
125159
shouldLoginAsMachine = true
126160
}
161+
162+
if inputs.ClientAssertionSigningAlg != "" || inputs.ClientAssertionSigningKey != "" {
163+
/*
164+
If all four params are passed, we evaluate it as a Machine Login Flow.
165+
Else required params are prompted for.
166+
*/
167+
shouldLoginAsMachineWithJWT = true
168+
}
127169
}
128170

129171
// If additional scopes are passed we mark shouldLoginAsUser flag to be true.
@@ -136,7 +178,7 @@ func loginCmd(cli *cli) *cobra.Command {
136178
based on all the evaluation above, we go on to prompt the user and
137179
determine if it's LoginAsUser or LoginAsMachine
138180
*/
139-
if !shouldLoginAsUser && !shouldLoginAsMachine {
181+
if !shouldLoginAsUser && !shouldLoginAsMachine && !shouldLoginAsMachineWithJWT {
140182
cli.renderer.Output(
141183
fmt.Sprintf(
142184
"%s\n\n%s\n%s\n\n%s\n%s\n%s\n%s\n\n",
@@ -317,6 +359,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string, do
317359

318360
// RunLoginAsMachine facilitates the authentication process using client credentials (client ID, client secret).
319361
func RunLoginAsMachine(ctx context.Context, inputs LoginInputs, cli *cli, cmd *cobra.Command) error {
362+
// Yet to handle the case with clientJWT Assertions
320363
if err := loginTenantDomain.Ask(cmd, &inputs.Domain, nil); err != nil {
321364
return err
322365
}
@@ -371,3 +414,70 @@ func RunLoginAsMachine(ctx context.Context, inputs LoginInputs, cli *cli, cmd *c
371414

372415
return nil
373416
}
417+
418+
// RunLoginAsMachineJWT facilitates the authentication process using the client credentials
419+
// with Private Key JWT authentication flow. (client ID, client Assertion Signing key).
420+
func RunLoginAsMachineJWT(ctx context.Context, inputs LoginInputs, cli *cli, cmd *cobra.Command) error {
421+
if err := loginTenantDomain.Ask(cmd, &inputs.Domain, nil); err != nil {
422+
return err
423+
}
424+
425+
if err := loginClientID.Ask(cmd, &inputs.ClientID, nil); err != nil {
426+
return err
427+
}
428+
429+
if err := loginClientAssertionSigningAlg.Ask(cmd, &inputs.ClientAssertionSigningAlg, nil); err != nil {
430+
return err
431+
}
432+
433+
if err := loginClientAssertionSigningKey.AskPassword(cmd, &inputs.ClientAssertionSigningKey); err != nil {
434+
return err
435+
}
436+
437+
domain := "https://" + inputs.Domain
438+
439+
token, err := auth.GetAccessTokenFromClientPrivateJWT(
440+
auth.PrivateKeyJwtTokenSource{
441+
Ctx: ctx,
442+
ClientID: inputs.ClientID,
443+
ClientAssertionSigningAlg: inputs.ClientAssertionSigningAlg,
444+
Uri: domain,
445+
Audience: domain + "/api/v2/",
446+
ClientAssertionSigningKey: inputs.ClientAssertionSigningKey,
447+
},
448+
)
449+
450+
if err != nil {
451+
return fmt.Errorf("failed to fetch access token using client credentials with Private Key. \n\nEnsure that the provided client-id, client-assertion-signing-key, client-assertion-signing-alg and domain are correct. \n\nerror: %w", err)
452+
}
453+
454+
tenant := config.Tenant{
455+
Name: strings.Split(inputs.Domain, ".")[0],
456+
Domain: inputs.Domain,
457+
ExpiresAt: token.ExpiresAt,
458+
ClientID: inputs.ClientID,
459+
}
460+
461+
if err = keyring.StoreClientSecret(inputs.Domain, inputs.ClientSecret); err != nil {
462+
cli.renderer.Warnf("Could not store the client secret and the access token to the keyring: %s", err)
463+
cli.renderer.Warnf("Expect to login again when your access token expires.")
464+
}
465+
466+
if err := keyring.StoreAccessToken(inputs.Domain, token.AccessToken); err != nil {
467+
// In case we don't have a keyring, we want the
468+
// access token to be saved in the config file.
469+
tenant.AccessToken = token.AccessToken
470+
}
471+
472+
if err = cli.Config.AddTenant(tenant); err != nil {
473+
return fmt.Errorf("failed to save tenant data: %w", err)
474+
}
475+
476+
cli.renderer.Newline()
477+
cli.renderer.Infof("Successfully logged in.")
478+
cli.renderer.Infof("Tenant: %s", inputs.Domain)
479+
480+
cli.tracker.TrackFirstLogin(cli.Config.InstallID)
481+
482+
return nil
483+
}

0 commit comments

Comments
 (0)