Skip to content

Commit 9d2ab49

Browse files
authored
Browser based OAuth authentication (#226)
* Add authenticator and implementation Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> * Add sourcetool auth subcommand Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> * Enable auth subcommand Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> * Regenerate fakes Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> * Address review comments Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> --------- Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]>
1 parent 2b510c8 commit 9d2ab49

File tree

6 files changed

+949
-0
lines changed

6 files changed

+949
-0
lines changed

sourcetool/internal/cmd/auth.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Carabiner Systems, Inc
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"time"
10+
11+
"github.com/fatih/color"
12+
"github.com/spf13/cobra"
13+
14+
"github.com/slsa-framework/slsa-source-poc/sourcetool/pkg/auth"
15+
)
16+
17+
var colorHiRed = color.New(color.FgHiRed).SprintFunc()
18+
19+
func addAuth(parentCmd *cobra.Command) {
20+
authCmd := &cobra.Command{
21+
Short: "Manage user authentication",
22+
Use: "auth",
23+
SilenceUsage: false,
24+
SilenceErrors: true,
25+
}
26+
addWhoAmI(authCmd)
27+
addLogin(authCmd)
28+
parentCmd.AddCommand(authCmd)
29+
}
30+
31+
func addLogin(parentCmd *cobra.Command) {
32+
authCmd := &cobra.Command{
33+
Short: "Log the SLSA sourcetool into GitHub",
34+
Use: "login",
35+
SilenceUsage: false,
36+
SilenceErrors: true,
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
fmt.Println()
39+
for _, l := range logo {
40+
fmt.Println(" " + colorHiRed(l))
41+
}
42+
fmt.Println()
43+
44+
authn := auth.New()
45+
46+
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
47+
defer cancel()
48+
49+
if err := authn.Authenticate(ctx); err != nil {
50+
return err
51+
}
52+
return nil
53+
},
54+
}
55+
parentCmd.AddCommand(authCmd)
56+
}
57+
58+
func addWhoAmI(parentCmd *cobra.Command) {
59+
authCmd := &cobra.Command{
60+
Short: "Shows the user currently logged in",
61+
Use: "whoami",
62+
SilenceUsage: false,
63+
SilenceErrors: true,
64+
RunE: func(cmd *cobra.Command, args []string) error {
65+
fmt.Println()
66+
67+
me, err := auth.New().WhoAmI()
68+
if err != nil {
69+
return err
70+
}
71+
72+
if me == nil {
73+
fmt.Println("🟡 sourcetool is not currently logged in")
74+
fmt.Println("")
75+
fmt.Println("To authorize the app run:")
76+
fmt.Println("> sourcetool auth login")
77+
return nil
78+
}
79+
80+
fmt.Printf(" 👤 logged in as %s\n\n", me.GetLogin())
81+
return nil
82+
},
83+
}
84+
parentCmd.AddCommand(authCmd)
85+
}

sourcetool/internal/cmd/logo.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cmd
2+
3+
var logo = []string{
4+
" ########################### ",
5+
" ############################ ### ",
6+
" #########################% #######",
7+
"# #################### ##########",
8+
"### ############## ##############",
9+
"##### ###### ################% ",
10+
"######## ##################### ",
11+
"########### #################### ##",
12+
"############## ############ ####",
13+
"################## #### ######",
14+
"######################## #########",
15+
"###################### #############",
16+
"################# #################",
17+
"######## ######################",
18+
" ###############################",
19+
" ################################### ",
20+
" ############################# ",
21+
}

sourcetool/internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ controls and much more.
5151
addProv(rootCmd)
5252
addCheckTag(rootCmd)
5353
addCreatePolicy(rootCmd)
54+
addAuth(rootCmd)
5455
return rootCmd
5556
}
5657

sourcetool/pkg/auth/authenticator.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
2+
3+
package auth
4+
5+
import (
6+
"context"
7+
"errors"
8+
"fmt"
9+
"time"
10+
11+
"github.com/fatih/color"
12+
"github.com/google/go-github/v69/github"
13+
)
14+
15+
const (
16+
// GitHub endpoints
17+
deviceCodeURL = "https://github.com/login/device/code"
18+
tokenURL = "https://github.com/login/oauth/access_token" //nolint:gosec // not a credential
19+
20+
// The SLSA sourcetool app OAuth client ID
21+
oauthClientID = "Ov23lidVQsiU5R5tod3z"
22+
23+
// App's config directory name
24+
configDirName = "slsa"
25+
26+
// Token filename
27+
githubTokenFileName = "sourcetool.github.token"
28+
)
29+
30+
var oauthScopes = []string{
31+
"repo", "user:email",
32+
}
33+
34+
type Authenticator struct {
35+
impl authenticatorImplementation
36+
}
37+
38+
// DeviceCodeResponse models the github device code response
39+
type DeviceCodeResponse struct {
40+
DeviceCode string `json:"device_code"`
41+
UserCode string `json:"user_code"`
42+
VerificationURI string `json:"verification_uri"`
43+
ExpiresIn int `json:"expires_in"`
44+
Interval int `json:"interval"`
45+
}
46+
47+
// TokenResponse is the data structure returned when exchanging tokens
48+
type TokenResponse struct {
49+
AccessToken string `json:"access_token"`
50+
TokenType string `json:"token_type"`
51+
Scope string `json:"scope"`
52+
Error string `json:"error"`
53+
ErrorDesc string `json:"error_description"`
54+
}
55+
56+
// NewGitHubApp creates a new GitHub OAuth application for device flow
57+
func New() *Authenticator {
58+
return &Authenticator{
59+
impl: &defaultImplementation{},
60+
}
61+
}
62+
63+
// Authenticate performs the complete device flow authentication in the
64+
// user's terminal and web browser
65+
func (a *Authenticator) Authenticate(ctx context.Context) error {
66+
fmt.Println("Starting authentication flow...")
67+
68+
// Get a device code
69+
deviceResp, err := a.impl.requestDeviceCode(ctx)
70+
if err != nil {
71+
return fmt.Errorf("failed to request device code: %w", err)
72+
}
73+
74+
// Send the user to the authentication page
75+
fmt.Printf("\nYour browser will be opened to: %s\n\n", deviceResp.VerificationURI)
76+
fmt.Printf(" 🔑 Enter this code: %s\n\n", color.New(color.FgHiWhite, color.BgBlack).Sprint(deviceResp.UserCode))
77+
78+
// Try to open browser automatically
79+
if err := a.impl.openBrowser(deviceResp.VerificationURI); err != nil {
80+
fmt.Println("Failed to open browser, please manually visit the URL above.")
81+
fmt.Println()
82+
} else {
83+
fmt.Println("Please complete the authentication flow by pasting the above code in")
84+
fmt.Println("the page displayed on your browser.")
85+
}
86+
fmt.Println()
87+
88+
fmt.Println("⏳ Waiting for authentication...")
89+
90+
pollInterval := 5 * time.Second
91+
if deviceResp.Interval > 0 {
92+
pollInterval = time.Duration(deviceResp.Interval) * time.Second
93+
}
94+
95+
// Wait for the user to complete the flow while polling the server to
96+
// get the newly issued token
97+
token, err := a.impl.pollForToken(ctx, deviceResp.DeviceCode, pollInterval)
98+
if err != nil {
99+
return fmt.Errorf("failed to get access token: %w", err)
100+
}
101+
102+
fmt.Println("✅ Authentication successful!")
103+
fmt.Println()
104+
fmt.Println("At any point, you can find out the logged-in identity by running:")
105+
fmt.Println("> sourcetool auth whoami")
106+
fmt.Println()
107+
108+
// Persist the token to disk
109+
return a.impl.persistToken(token)
110+
}
111+
112+
// ReadToken reads the persisted token and returns it
113+
func (a *Authenticator) ReadToken() (string, error) {
114+
return a.impl.readToken()
115+
}
116+
117+
// GetGitHubClient returns a GitHub client preconfigured with
118+
// the logged-in token.
119+
func (a *Authenticator) GetGitHubClient() (*github.Client, error) {
120+
token, err := a.impl.readToken()
121+
if err != nil {
122+
return nil, fmt.Errorf("reading token: %w", err)
123+
}
124+
if token == "" {
125+
return nil, errors.New("token is empty")
126+
}
127+
return github.NewClient(nil).WithAuthToken(token), nil
128+
}
129+
130+
// WhoAmI returns the user authenticated with the token
131+
func (a *Authenticator) WhoAmI() (*github.User, error) {
132+
token, err := a.impl.readToken()
133+
if err != nil {
134+
return nil, fmt.Errorf("reading token: %w", err)
135+
}
136+
137+
// If no token is set, then no error, only nil
138+
if token == "" {
139+
return nil, nil
140+
}
141+
142+
client, err := a.GetGitHubClient()
143+
if err != nil {
144+
return nil, err
145+
}
146+
147+
user, _, err := client.Users.Get(context.Background(), "")
148+
if err != nil {
149+
return nil, fmt.Errorf("fetching user data: %w", err)
150+
}
151+
152+
return user, nil
153+
}

0 commit comments

Comments
 (0)