Skip to content

Commit 7bb34e3

Browse files
authored
feat: add GitHub Social login. (#1609)
## What this PR does / why we need it: Add support for `github` login with `terramate cloud login --github`. ## Which issue(s) this PR fixes: none ## Special notes for your reviewer: - [x] still requires TMC backend support. ## Does this PR introduce a user-facing change? ``` yes ```
2 parents a7bf461 + a5e787b commit 7bb34e3

9 files changed

Lines changed: 619 additions & 282 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Given a version number `MAJOR.MINOR.PATCH`, we increment the:
2424

2525
### Added
2626

27+
- Add `terramate cloud login --github` for authenticating with the Github account.
2728
- Add experimental support for `tmgen` file extension for easy code generation/templating
2829
of existing infrastructure. You can enable it with `terramate.config.experimental = ["tmgen"]`.
2930
- Make cloud-related options more concise by dropping the `cloud` prefix.

cmd/terramate/cli/cli.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,10 @@ type cliSpec struct {
216216
} `cmd:"" help:"Debug Terramate configuration."`
217217

218218
Cloud struct {
219-
Login struct{} `cmd:"" help:"Sign in to Terramate Cloud."`
219+
Login struct {
220+
Google bool `optional:"true" help:"authenticate with google credentials"`
221+
Github bool `optional:"true" help:"authenticate with github credentials"`
222+
} `cmd:"" help:"Sign in to Terramate Cloud."`
220223
Info struct{} `cmd:"" help:"Show your current Terramate Cloud login status."`
221224
Drift struct {
222225
Show struct {
@@ -513,8 +516,54 @@ func newCLI(version string, args []string, stdin io.Reader, stdout, stderr io.Wr
513516
case "experimental cloud login": // Deprecated: use cloud login
514517
fallthrough
515518
case "cloud login":
516-
err := googleLogin(output, idpkey(), clicfg)
519+
var err error
520+
var email string
521+
var alreadyUsedProviders []string
522+
var provider string
523+
if parsedArgs.Cloud.Login.Github {
524+
provider = "GitHub"
525+
email, alreadyUsedProviders, err = githubLogin(output, cloudBaseURL(), idpkey(), clicfg)
526+
} else {
527+
provider = "Google"
528+
email, alreadyUsedProviders, err = googleLogin(output, idpkey(), clicfg)
529+
}
517530
if err != nil {
531+
if errors.IsKind(err, ErrEmailNotVerified) {
532+
printer.Stderr.Error(stdfmt.Sprintf("email %s is not verified", email))
533+
printer.Stderr.Println(
534+
"Please login to https://cloud.terramate.io first to verify your email and continue the sign up process.",
535+
)
536+
os.Exit(1)
537+
}
538+
if errors.IsKind(err, ErrIDPNeedConfirmation) {
539+
printer.Stderr.Error(err.Error())
540+
541+
bufs := []strings.Builder{}
542+
for _, providerDomain := range alreadyUsedProviders {
543+
switch providerDomain {
544+
case "google.com":
545+
b := strings.Builder{}
546+
b.WriteString("- terramate cloud login --google (For login with your Google account)")
547+
bufs = append(bufs, b)
548+
case "github.com":
549+
b := strings.Builder{}
550+
b.WriteString("- terramate cloud login --github (For login with your GitHub account)")
551+
bufs = append(bufs, b)
552+
}
553+
}
554+
555+
var buf strings.Builder
556+
if len(bufs) > 0 {
557+
buf.WriteString("Please login using one of the methods below:\n")
558+
for _, b := range bufs {
559+
buf.WriteString(b.String() + "\n")
560+
}
561+
buf.WriteString("or alternatively:\n")
562+
}
563+
buf.WriteString(stdfmt.Sprintf("- Go to https://cloud.terramate.io and authenticate with the %s Social login to link the accounts.", provider))
564+
printer.Stderr.Println(buf.String())
565+
os.Exit(1)
566+
}
518567
fatal("authentication failed", err)
519568
}
520569
output.MsgStdOut("authenticated successfully")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright 2024 Terramate GmbH
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package cli
5+
6+
type providerID string
7+
8+
func (p providerID) String() string {
9+
switch p {
10+
case "google.com":
11+
return "Google"
12+
case "github.com":
13+
return "GitHub"
14+
default:
15+
return string(p)
16+
}
17+
}
Lines changed: 51 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -1,222 +1,93 @@
1-
// Copyright 2023 Terramate GmbH
1+
// Copyright 2024 Terramate GmbH
22
// SPDX-License-Identifier: MPL-2.0
33

44
package cli
55

66
import (
7-
"context"
87
"fmt"
9-
"math"
108
"net/url"
119
"os"
12-
"sync"
10+
"strconv"
1311
"time"
1412

15-
"github.com/golang-jwt/jwt"
16-
"github.com/terramate-io/terramate/cloud"
13+
"github.com/terramate-io/terramate/cmd/terramate/cli/cliconfig"
1714
"github.com/terramate-io/terramate/cmd/terramate/cli/github"
1815
"github.com/terramate-io/terramate/cmd/terramate/cli/out"
1916
"github.com/terramate-io/terramate/errors"
2017
"github.com/terramate-io/terramate/printer"
2118
)
2219

23-
const githubOIDCProviderName = "GitHub Actions OIDC"
20+
const defaultGitHubClientID = "08e1f8d6f599c7ec48c5"
2421

25-
type githubOIDC struct {
26-
mu sync.RWMutex
27-
token string
28-
jwtClaims jwt.MapClaims
29-
30-
expireAt time.Time
31-
repoOwner string
32-
repoName string
33-
34-
reqURL string
35-
reqToken string
36-
orgs cloud.MemberOrganizations
37-
38-
output out.O
39-
client *cloud.Client
40-
}
41-
42-
func newGithubOIDC(output out.O, client *cloud.Client) *githubOIDC {
43-
return &githubOIDC{
44-
output: output,
45-
client: client,
22+
func githubLogin(output out.O, tmcBaseURL string, idpKey string, clicfg cliconfig.Config) (string, []string, error) {
23+
token, err := githubAuth()
24+
if err != nil {
25+
return "", nil, err
4626
}
47-
}
4827

49-
func (g *githubOIDC) Load() (bool, error) {
50-
const envReqURL = "ACTIONS_ID_TOKEN_REQUEST_URL"
51-
const envReqTok = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"
52-
53-
g.reqURL = os.Getenv(envReqURL)
54-
if g.reqURL == "" {
55-
return false, nil
28+
postBody := url.Values{
29+
"access_token": []string{token},
30+
"providerId": []string{"github.com"},
5631
}
5732

58-
g.reqToken = os.Getenv(envReqTok)
59-
60-
audience := oidcAudience()
61-
if audience != "" {
62-
u, err := url.Parse(g.reqURL)
63-
if err != nil {
64-
return false, errors.E(err, "invalid ACTIONS_ID_TOKEN_REQUEST_URL env var")
65-
}
66-
67-
qr := u.Query()
68-
qr.Set("audience", audience)
69-
u.RawQuery = qr.Encode()
70-
g.reqURL = u.String()
33+
reqPayload := googleSignInPayload{
34+
PostBody: postBody.Encode(),
35+
RequestURI: tmcBaseURL + "/__/auth/handler",
36+
ReturnIdpCredential: true,
37+
ReturnSecureToken: true,
7138
}
7239

73-
err := g.Refresh()
40+
cred, email, alreadyUsedProviders, err := signInWithIDP(reqPayload, idpKey)
7441
if err != nil {
75-
return false, err
42+
return email, alreadyUsedProviders, err
7643
}
77-
g.client.Credential = g
78-
return true, g.fetchDetails()
79-
}
8044

81-
func (g *githubOIDC) Name() string {
82-
return githubOIDCProviderName
45+
output.MsgStdOut("Logged in as %s", cred.UserDisplayName())
46+
output.MsgStdOutV("Token: %s", cred.IDToken)
47+
expire, _ := strconv.Atoi(cred.ExpiresIn)
48+
output.MsgStdOutV("Expire at: %s", time.Now().Add(time.Second*time.Duration(expire)).Format(time.RFC822Z))
49+
return email, nil, saveCredential(output, cred, clicfg)
8350
}
8451

85-
func (g *githubOIDC) IsExpired() bool {
86-
g.mu.RLock()
87-
defer g.mu.RUnlock()
88-
return time.Now().After(g.expireAt)
89-
}
90-
91-
func (g *githubOIDC) ExpireAt() time.Time {
92-
g.mu.RLock()
93-
defer g.mu.RUnlock()
94-
return g.expireAt
95-
}
96-
97-
func (g *githubOIDC) Refresh() (err error) {
98-
if g.token != "" {
99-
g.output.MsgStdOutV("refreshing token...")
100-
101-
defer func() {
102-
if err == nil {
103-
g.output.MsgStdOutV("token successfully refreshed.")
104-
g.output.MsgStdOutV("next token refresh in: %s", time.Until(g.ExpireAt()))
105-
}
106-
}()
107-
}
108-
109-
ctx, cancel := context.WithTimeout(context.Background(), defaultGithubTimeout)
110-
defer cancel()
111-
112-
token, err := github.OIDCToken(ctx, github.OIDCVars{
113-
ReqURL: g.reqURL,
114-
ReqToken: g.reqToken,
115-
})
116-
52+
func githubAuth() (string, error) {
53+
oauthCtx, err := github.OAuthDeviceFlowAuthStart(ghClientID())
11754
if err != nil {
118-
return errors.E(err, "requesting new Github OIDC token")
55+
return "", err
11956
}
12057

121-
g.mu.Lock()
122-
defer g.mu.Unlock()
123-
124-
g.token = token
125-
g.jwtClaims, err = tokenClaims(g.token)
126-
if err != nil {
127-
return err
128-
}
129-
exp, ok := g.jwtClaims["exp"].(float64)
130-
if !ok {
131-
return errors.E(`cached JWT token has no "exp" field`)
132-
}
133-
sec, dec := math.Modf(exp)
134-
g.expireAt = time.Unix(int64(sec), int64(dec*(1e9)))
58+
printer.Stdout.Println(fmt.Sprintf("Please visit: %s", oauthCtx.VerificationURI))
59+
printer.Stdout.Println(fmt.Sprintf("and enter code: %s", oauthCtx.UserCode))
13560

136-
repoOwner, ok := g.jwtClaims["repository_owner"].(string)
137-
if !ok {
138-
return errors.E(`GitHub OIDC JWT with no "repository_owner" payload field.`)
139-
}
140-
repoName, ok := g.jwtClaims["repository"].(string)
141-
if !ok {
142-
return errors.E(`GitHub OIDC JWT with no "repository" payload field.`)
143-
}
144-
g.repoOwner = repoOwner
145-
g.repoName = repoName
146-
return nil
147-
}
61+
for {
62+
var token string
63+
token, err = oauthCtx.ProbeAuthState()
64+
if err == nil {
65+
return token, nil
66+
}
14867

149-
func (g *githubOIDC) Claims() jwt.MapClaims {
150-
g.mu.RLock()
151-
defer g.mu.RUnlock()
152-
return g.jwtClaims
153-
}
68+
var errInfo *errors.Error
69+
if !errors.As(err, &errInfo) {
70+
return "", err // unexpected err
71+
}
15472

155-
func (g *githubOIDC) DisplayClaims() []keyValue {
156-
return []keyValue{
157-
{
158-
key: "owner",
159-
value: g.repoOwner,
160-
},
161-
{
162-
key: "repository",
163-
value: g.repoName,
164-
},
165-
}
166-
}
73+
interval := time.Duration(oauthCtx.Interval) * time.Second
16774

168-
func (g *githubOIDC) Token() (string, error) {
169-
if g.IsExpired() {
170-
err := g.Refresh()
171-
if err != nil {
75+
switch errInfo.Kind {
76+
case github.ErrDeviceFlowSlowDown:
77+
interval += 5 * time.Second
78+
fallthrough
79+
case github.ErrDeviceFlowAuthPending:
80+
time.Sleep(interval)
81+
default:
17282
return "", err
17383
}
17484
}
175-
g.mu.RLock()
176-
defer g.mu.RUnlock()
177-
return g.token, nil
178-
}
179-
180-
// Validate if the credential is ready to be used.
181-
func (g *githubOIDC) fetchDetails() error {
182-
const apiTimeout = 5 * time.Second
183-
184-
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
185-
defer cancel()
186-
orgs, err := g.client.MemberOrganizations(ctx)
187-
if err != nil {
188-
return err
189-
}
190-
g.orgs = orgs
191-
return nil
19285
}
19386

194-
func (g *githubOIDC) info(selectedOrgName string) {
195-
if len(g.orgs) > 0 && g.orgs[0].Status == "trusted" {
196-
printer.Stdout.Println("status: signed in")
197-
} else {
198-
printer.Stdout.Println("status: untrusted")
199-
}
200-
201-
printer.Stdout.Println(fmt.Sprintf("provider: %s", g.Name()))
202-
203-
for _, kv := range g.DisplayClaims() {
204-
printer.Stdout.Println(fmt.Sprintf("%s: %s", kv.key, kv.value))
205-
}
206-
207-
if len(g.orgs) > 0 {
208-
printer.Stdout.Println(fmt.Sprintf("organizations: %s", g.orgs))
87+
func ghClientID() string {
88+
idpKey := os.Getenv("TMC_API_GITHUB_CLIENT_ID")
89+
if idpKey == "" {
90+
idpKey = defaultGitHubClientID
20991
}
210-
211-
if selectedOrgName == "" && len(g.orgs) > 1 {
212-
printer.Stderr.Warn("User is member of multiple organizations but none was selected")
213-
}
214-
215-
if len(g.orgs) == 0 {
216-
printer.Stderr.Warn("You are not part of an organization. Please visit cloud.terramate.io to create an organization.")
217-
}
218-
}
219-
220-
func (g *githubOIDC) organizations() cloud.MemberOrganizations {
221-
return g.orgs
92+
return idpKey
22293
}

0 commit comments

Comments
 (0)