Client Login via device authorization flow (#309)

UI and CLI Clients are now able to use SSO login by default

we will check if the management has configured or supports SSO providers

daemon will handle fetching and waiting for an access token

Oauth package was moved to internal to avoid one extra package at this stage

Secrets were removed from OAuth

CLI clients have less and better output

2 new status were introduced, NeedsLogin and FailedLogin for better messaging

With NeedsLogin we no longer have endless login attempts
This commit is contained in:
Maycon Santos
2022-05-12 11:17:24 +02:00
committed by GitHub
parent 49cca57565
commit e5c52efb4c
26 changed files with 925 additions and 427 deletions
+82
View File
@@ -1,7 +1,11 @@
package internal
import (
"context"
"fmt"
mgm "github.com/netbirdio/netbird/management/client"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"net/url"
"os"
@@ -83,6 +87,10 @@ func parseURL(serviceName, managementURL string) (*url.URL, error) {
// ReadConfig reads existing config. In case provided managementURL is not empty overrides the read property
func ReadConfig(managementURL, adminURL, configPath string, preSharedKey *string) (*Config, error) {
config := &Config{}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, status.Errorf(codes.NotFound, "config file doesn't exist")
}
if _, err := util.ReadJson(configPath, config); err != nil {
return nil, err
}
@@ -151,3 +159,77 @@ func generateKey() string {
}
return key.String()
}
// DeviceAuthorizationFlow represents Device Authorization Flow information
type DeviceAuthorizationFlow struct {
Provider string
ProviderConfig ProviderConfig
}
// ProviderConfig has all attributes needed to initiate a device authorization flow
type ProviderConfig struct {
// ClientID An IDP application client id
ClientID string
// ClientSecret An IDP application client secret
ClientSecret string
// Domain An IDP API domain
Domain string
// Audience An Audience for to authorization validation
Audience string
}
func GetDeviceAuthorizationFlowInfo(ctx context.Context, config *Config) (DeviceAuthorizationFlow, error) {
// validate our peer's Wireguard PRIVATE key
myPrivateKey, err := wgtypes.ParseKey(config.PrivateKey)
if err != nil {
log.Errorf("failed parsing Wireguard key %s: [%s]", config.PrivateKey, err.Error())
return DeviceAuthorizationFlow{}, err
}
var mgmTlsEnabled bool
if config.ManagementURL.Scheme == "https" {
mgmTlsEnabled = true
}
log.Debugf("connecting to Management Service %s", config.ManagementURL.String())
mgmClient, err := mgm.NewClient(ctx, config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
if err != nil {
log.Errorf("failed connecting to Management Service %s %v", config.ManagementURL.String(), err)
return DeviceAuthorizationFlow{}, err
}
log.Debugf("connected to management Service %s", config.ManagementURL.String())
serverKey, err := mgmClient.GetServerPublicKey()
if err != nil {
log.Errorf("failed while getting Management Service public key: %v", err)
return DeviceAuthorizationFlow{}, err
}
protoDeviceAuthorizationFlow, err := mgmClient.GetDeviceAuthorizationFlow(*serverKey)
if err != nil {
if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound {
log.Warnf("server couldn't find device flow, contact admin: %v", err)
return DeviceAuthorizationFlow{}, err
} else {
log.Errorf("failed to retrieve device flow: %v", err)
return DeviceAuthorizationFlow{}, err
}
}
err = mgmClient.Close()
if err != nil {
log.Errorf("failed closing Management Service client: %v", err)
return DeviceAuthorizationFlow{}, err
}
return DeviceAuthorizationFlow{
Provider: protoDeviceAuthorizationFlow.Provider.String(),
ProviderConfig: ProviderConfig{
Audience: protoDeviceAuthorizationFlow.ProviderConfig.Audience,
ClientID: protoDeviceAuthorizationFlow.ProviderConfig.ClientID,
ClientSecret: protoDeviceAuthorizationFlow.ProviderConfig.ClientSecret,
Domain: protoDeviceAuthorizationFlow.ProviderConfig.Domain,
},
}, nil
}
+10 -1
View File
@@ -29,7 +29,12 @@ func RunClient(ctx context.Context, config *Config) error {
}
state := CtxGetState(ctx)
defer state.Set(StatusIdle)
defer func() {
s, err := state.Status()
if err != nil || s != StatusNeedsLogin {
state.Set(StatusIdle)
}
}()
wrapErr := state.Wrap
operation := func() error {
@@ -57,6 +62,10 @@ func RunClient(ctx context.Context, config *Config) error {
mgmClient, loginResp, err := connectToManagement(ctx, config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
if err != nil {
log.Warn(err)
if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied {
state.Set(StatusNeedsLogin)
return nil
}
return wrapErr(err)
}
+2 -3
View File
@@ -2,7 +2,6 @@ package internal
import (
"context"
"github.com/google/uuid"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/management/client"
@@ -77,14 +76,14 @@ func loginPeer(serverPublicKey wgtypes.Key, client *mgm.GrpcClient, setupKey str
func registerPeer(serverPublicKey wgtypes.Key, client *mgm.GrpcClient, setupKey string, jwtToken string) (*mgmProto.LoginResponse, error) {
validSetupKey, err := uuid.Parse(setupKey)
if err != nil && jwtToken == "" {
return nil, err
return nil, status.Errorf(codes.InvalidArgument, "invalid setup-key or no sso information provided, err: %v", err)
}
log.Debugf("sending peer registration request to Management Service")
info := system.GetInfo()
loginResp, err := client.Register(serverPublicKey, validSetupKey.String(), jwtToken, info)
if err != nil {
log.Errorf("failed registering peer %v", err)
log.Errorf("failed registering peer %v,%s", err, validSetupKey.String())
return nil, err
}
@@ -1,4 +1,4 @@
package oauth
package internal
import (
"context"
@@ -11,19 +11,50 @@ import (
"time"
)
// auth0GrantType grant type for device flow on Auth0
// OAuthClient is a OAuth client interface for various idp providers
type OAuthClient interface {
RequestDeviceCode(ctx context.Context) (DeviceAuthInfo, error)
RotateAccessToken(ctx context.Context, refreshToken string) (TokenInfo, error)
WaitToken(ctx context.Context, info DeviceAuthInfo) (TokenInfo, error)
}
// HTTPClient http client interface for API calls
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// DeviceAuthInfo holds information for the OAuth device login flow
type DeviceAuthInfo struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// TokenInfo holds information of issued access token
type TokenInfo struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// HostedGrantType grant type for device flow on Hosted
const (
auth0GrantType = "urn:ietf:params:oauth:grant-type:device_code"
auth0RefreshGrant = "refresh_token"
HostedGrantType = "urn:ietf:params:oauth:grant-type:device_code"
HostedRefreshGrant = "refresh_token"
)
// Auth0 client
type Auth0 struct {
// Auth0 API Audience for validation
// Hosted client
type Hosted struct {
// Hosted API Audience for validation
Audience string
// Auth0 Native application client id
// Hosted Native application client id
ClientID string
// Auth0 domain
// Hosted domain
Domain string
HTTPClient HTTPClient
@@ -43,7 +74,7 @@ type TokenRequestPayload struct {
RefreshToken string `json:"refresh_token,omitempty"`
}
// TokenRequestResponse used for parsing Auth0 token's response
// TokenRequestResponse used for parsing Hosted token's response
type TokenRequestResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
@@ -55,8 +86,8 @@ type Claims struct {
Audience string `json:"aud"`
}
// NewAuth0DeviceFlow returns an Auth0 OAuth client
func NewAuth0DeviceFlow(audience string, clientID string, domain string) *Auth0 {
// NewHostedDeviceFlow returns an Hosted OAuth client
func NewHostedDeviceFlow(audience string, clientID string, domain string) *Hosted {
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.MaxIdleConns = 5
@@ -65,7 +96,7 @@ func NewAuth0DeviceFlow(audience string, clientID string, domain string) *Auth0
Transport: httpTransport,
}
return &Auth0{
return &Hosted{
Audience: audience,
ClientID: clientID,
Domain: domain,
@@ -73,12 +104,12 @@ func NewAuth0DeviceFlow(audience string, clientID string, domain string) *Auth0
}
}
// RequestDeviceCode requests a device code login flow information from Auth0
func (a *Auth0) RequestDeviceCode(ctx context.Context) (DeviceAuthInfo, error) {
url := "https://" + a.Domain + "/oauth/device/code"
// RequestDeviceCode requests a device code login flow information from Hosted
func (h *Hosted) RequestDeviceCode(ctx context.Context) (DeviceAuthInfo, error) {
url := "https://" + h.Domain + "/oauth/device/code"
codePayload := RequestDeviceCodePayload{
Audience: a.Audience,
ClientID: a.ClientID,
Audience: h.Audience,
ClientID: h.ClientID,
}
p, err := json.Marshal(codePayload)
if err != nil {
@@ -92,7 +123,7 @@ func (a *Auth0) RequestDeviceCode(ctx context.Context) (DeviceAuthInfo, error) {
req.Header.Add("content-type", "application/json")
res, err := a.HTTPClient.Do(req)
res, err := h.HTTPClient.Do(req)
if err != nil {
return DeviceAuthInfo{}, fmt.Errorf("doing request failed with error: %v", err)
}
@@ -117,22 +148,22 @@ func (a *Auth0) RequestDeviceCode(ctx context.Context) (DeviceAuthInfo, error) {
}
// WaitToken waits user's login and authorize the app. Once the user's authorize
// it retrieves the access token from Auth0's endpoint and validates it before returning
func (a *Auth0) WaitToken(ctx context.Context, info DeviceAuthInfo) (TokenInfo, error) {
// it retrieves the access token from Hosted's endpoint and validates it before returning
func (h *Hosted) WaitToken(ctx context.Context, info DeviceAuthInfo) (TokenInfo, error) {
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
for {
select {
case <-ctx.Done():
return TokenInfo{}, ctx.Err()
case <-ticker.C:
url := "https://" + a.Domain + "/oauth/token"
url := "https://" + h.Domain + "/oauth/token"
tokenReqPayload := TokenRequestPayload{
GrantType: auth0GrantType,
GrantType: HostedGrantType,
DeviceCode: info.DeviceCode,
ClientID: a.ClientID,
ClientID: h.ClientID,
}
body, statusCode, err := requestToken(a.HTTPClient, url, tokenReqPayload)
body, statusCode, err := requestToken(h.HTTPClient, url, tokenReqPayload)
if err != nil {
return TokenInfo{}, fmt.Errorf("wait for token: %v", err)
}
@@ -154,7 +185,7 @@ func (a *Auth0) WaitToken(ctx context.Context, info DeviceAuthInfo) (TokenInfo,
return TokenInfo{}, fmt.Errorf(tokenResponse.ErrorDescription)
}
err = isValidAccessToken(tokenResponse.AccessToken, a.Audience)
err = isValidAccessToken(tokenResponse.AccessToken, h.Audience)
if err != nil {
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
@@ -172,15 +203,15 @@ func (a *Auth0) WaitToken(ctx context.Context, info DeviceAuthInfo) (TokenInfo,
}
// RotateAccessToken requests a new token using an existing refresh token
func (a *Auth0) RotateAccessToken(ctx context.Context, refreshToken string) (TokenInfo, error) {
url := "https://" + a.Domain + "/oauth/token"
func (h *Hosted) RotateAccessToken(ctx context.Context, refreshToken string) (TokenInfo, error) {
url := "https://" + h.Domain + "/oauth/token"
tokenReqPayload := TokenRequestPayload{
GrantType: auth0RefreshGrant,
ClientID: a.ClientID,
GrantType: HostedRefreshGrant,
ClientID: h.ClientID,
RefreshToken: refreshToken,
}
body, statusCode, err := requestToken(a.HTTPClient, url, tokenReqPayload)
body, statusCode, err := requestToken(h.HTTPClient, url, tokenReqPayload)
if err != nil {
return TokenInfo{}, fmt.Errorf("rotate access token: %v", err)
}
@@ -195,7 +226,7 @@ func (a *Auth0) RotateAccessToken(ctx context.Context, refreshToken string) (Tok
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
}
err = isValidAccessToken(tokenResponse.AccessToken, a.Audience)
err = isValidAccessToken(tokenResponse.AccessToken, h.Audience)
if err != nil {
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
-37
View File
@@ -1,37 +0,0 @@
package oauth
import (
"context"
"net/http"
)
// HTTPClient http client interface for API calls
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// DeviceAuthInfo holds information for the OAuth device login flow
type DeviceAuthInfo struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// TokenInfo holds information of issued access token
type TokenInfo struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// Client is a OAuth client interface for various idp providers
type Client interface {
RequestDeviceCode(ctx context.Context) (DeviceAuthInfo, error)
RotateAccessToken(ctx context.Context, refreshToken string) (TokenInfo, error)
WaitToken(ctx context.Context, info DeviceAuthInfo) (TokenInfo, error)
}
-66
View File
@@ -1,66 +0,0 @@
package oauth
import (
"fmt"
"github.com/99designs/keyring"
)
// ServiceName default service name for saving the secret
const ServiceName = "Wiretrustee"
func newSecretAPI() (keyring.Keyring, error) {
return keyring.Open(keyring.Config{
ServiceName: ServiceName,
//KeychainName: ServiceName,
})
}
// SetSecret stores the secret in the system's available backend
func SetSecret(key string, value string) error {
storeAPI, err := newSecretAPI()
if err != nil {
return fmt.Errorf("failed to create secret API for setting a secret, error: %v", err)
}
item := keyring.Item{
Key: key,
Data: []byte(value),
}
err = storeAPI.Set(item)
if err != nil {
return fmt.Errorf("failed to set the secret, error: %v", err)
}
return nil
}
// GetSecret retrieves a secret from the system's available backend
func GetSecret(key string) (string, error) {
storeAPI, err := newSecretAPI()
if err != nil {
return "", fmt.Errorf("failed to create secret API for getting a secret, error: %v", err)
}
item, err := storeAPI.Get(key)
if err != nil {
return "", fmt.Errorf("failed to get secret, error: %v", err)
}
return string(item.Data), nil
}
// DeleteSecret deletes a secret from the system's available backend
func DeleteSecret(key string) error {
storeAPI, err := newSecretAPI()
if err != nil {
return fmt.Errorf("failed to create secret API for deleting a secret, error: %v", err)
}
err = storeAPI.Remove(key)
if err != nil {
return fmt.Errorf("failed to delete secret, error: %v", err)
}
return nil
}
-27
View File
@@ -1,27 +0,0 @@
package oauth
import (
"github.com/stretchr/testify/require"
"os"
"testing"
)
func TestSecret(t *testing.T) {
// this test is not ready to run as part of our ci/cd
// todo fix testing
if os.Getenv("CI") == "true" {
t.Skip("skipping testing in github actions")
}
key := "testing"
value := "1234"
err := SetSecret(key, value)
require.NoError(t, err, "should set secret")
v, err := GetSecret(key)
require.NoError(t, err, "should retrieve secret")
require.Equal(t, value, v, "values should match")
err = DeleteSecret(key)
require.NoError(t, err, "should delete secret")
}
@@ -1,4 +1,4 @@
package oauth
package internal
import (
"context"
@@ -43,7 +43,7 @@ func (c *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
}, c.err
}
func TestAuth0_RequestDeviceCode(t *testing.T) {
func TestHosted_RequestDeviceCode(t *testing.T) {
type test struct {
name string
inputResBody string
@@ -107,14 +107,14 @@ func TestAuth0_RequestDeviceCode(t *testing.T) {
err: testCase.inputReqError,
}
auth0 := Auth0{
hosted := Hosted{
Audience: testCase.expectPayload.Audience,
ClientID: testCase.expectPayload.ClientID,
Domain: "test.auth0.com",
Domain: "test.hosted.com",
HTTPClient: &httpClient,
}
authInfo, err := auth0.RequestDeviceCode(context.TODO())
authInfo, err := hosted.RequestDeviceCode(context.TODO())
testCase.testingErrFunc(t, err, testCase.expectedErrorMSG)
payload, _ := json.Marshal(testCase.expectPayload)
@@ -127,7 +127,7 @@ func TestAuth0_RequestDeviceCode(t *testing.T) {
}
}
func TestAuth0_WaitToken(t *testing.T) {
func TestHosted_WaitToken(t *testing.T) {
type test struct {
name string
inputResBody string
@@ -153,7 +153,7 @@ func TestAuth0_WaitToken(t *testing.T) {
}
tokenReqPayload := TokenRequestPayload{
GrantType: auth0GrantType,
GrantType: HostedGrantType,
DeviceCode: defaultInfo.DeviceCode,
ClientID: "test",
}
@@ -267,16 +267,16 @@ func TestAuth0_WaitToken(t *testing.T) {
countResBody: testCase.inputCountResBody,
}
auth0 := Auth0{
hosted := Hosted{
Audience: testCase.inputAudience,
ClientID: testCase.expectPayload.ClientID,
Domain: "test.auth0.com",
Domain: "test.hosted.com",
HTTPClient: &httpClient,
}
ctx, cancel := context.WithTimeout(context.TODO(), testCase.inputTimeout)
defer cancel()
tokenInfo, err := auth0.WaitToken(ctx, testCase.inputInfo)
tokenInfo, err := hosted.WaitToken(ctx, testCase.inputInfo)
testCase.testingErrFunc(t, err, testCase.expectedErrorMSG)
var payload []byte
@@ -294,7 +294,7 @@ func TestAuth0_WaitToken(t *testing.T) {
}
}
func TestAuth0_RotateAccessToken(t *testing.T) {
func TestHosted_RotateAccessToken(t *testing.T) {
type test struct {
name string
inputResBody string
@@ -318,7 +318,7 @@ func TestAuth0_RotateAccessToken(t *testing.T) {
}
tokenReqPayload := TokenRequestPayload{
GrantType: auth0RefreshGrant,
GrantType: HostedRefreshGrant,
ClientID: "test",
RefreshToken: "refresh_test",
}
@@ -391,14 +391,14 @@ func TestAuth0_RotateAccessToken(t *testing.T) {
MaxReqs: testCase.inputMaxReqs,
}
auth0 := Auth0{
hosted := Hosted{
Audience: testCase.inputAudience,
ClientID: testCase.expectPayload.ClientID,
Domain: "test.auth0.com",
Domain: "test.hosted.com",
HTTPClient: &httpClient,
}
tokenInfo, err := auth0.RotateAccessToken(context.TODO(), testCase.expectPayload.RefreshToken)
tokenInfo, err := hosted.RotateAccessToken(context.TODO(), testCase.expectPayload.RefreshToken)
testCase.testingErrFunc(t, err, testCase.expectedErrorMSG)
var payload []byte
+4 -2
View File
@@ -10,8 +10,10 @@ type StatusType string
const (
StatusIdle StatusType = "Idle"
StatusConnecting StatusType = "Connecting"
StatusConnected StatusType = "Connected"
StatusConnecting StatusType = "Connecting"
StatusConnected StatusType = "Connected"
StatusNeedsLogin StatusType = "NeedsLogin"
StatusLoginFailed StatusType = "LoginFailed"
)
// CtxInitState setup context state into the context tree.