Skip to content

Commit

Permalink
Adds LoginWithClientCredentials to Admin facade version 4.
Browse files Browse the repository at this point in the history
  • Loading branch information
alesstimec committed Mar 8, 2024
1 parent 0d3ec34 commit e54dae8
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 1 deletion.
7 changes: 7 additions & 0 deletions api/params/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,13 @@ type LoginWithSessionTokenRequest struct {

// Service Account related request parameters

// LoginWithClientCredentialsRequest holds the client id and secret used
// to authenticate with JIMM.
type LoginWithClientCredentialsRequest struct {
ClientID string `json:"client-id"`
ClientSecret string `json:"client-secret"`
}

// AddServiceAccountRequest holds a request to add a service account.
type AddServiceAccountRequest struct {
// ClientID holds the client id of the service account.
Expand Down
21 changes: 20 additions & 1 deletion internal/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/lestrrat-go/jwx/v2/jwt"
"go.uber.org/zap"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"

"github.com/canonical/jimm/internal/dbmodel"
"github.com/canonical/jimm/internal/errors"
Expand Down Expand Up @@ -219,7 +220,7 @@ func (as *AuthenticationService) Email(idToken *oidc.IDToken) (string, error) {
// via an access token. The token only contains the user's email for authentication.
func (as *AuthenticationService) MintSessionToken(email string, secretKey string) (string, error) {
const op = errors.Op("auth.AuthenticationService.MintAccessToken")

token, err := jwt.NewBuilder().
Subject(email).
Expiration(time.Now().Add(as.sessionTokenExpiry)).
Expand Down Expand Up @@ -310,3 +311,21 @@ func VerifySessionToken(token string, secretKey string) (jwt.Token, error) {

return parsedToken, nil
}

// VerifyClientCredentials verifies the provided client ID and client secret.
func (as *AuthenticationService) VerifyClientCredentials(ctx context.Context, clientID string, clientSecret string) error {
cfg := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: as.oauthConfig.Endpoint.TokenURL,
Scopes: as.oauthConfig.Scopes,
AuthStyle: oauth2.AuthStyle(as.oauthConfig.Endpoint.AuthStyle),
}

_, err := cfg.Token(ctx)
if err != nil {
zapctx.Error(ctx, "client credential verification failed", zap.Error(err))
return errors.E(errors.CodeUnauthorized, "invalid client credentials")
}
return nil
}
19 changes: 19 additions & 0 deletions internal/auth/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,22 @@ func TestSessionTokenValidatesEmail(t *testing.T) {
_, err = authSvc.VerifySessionToken(token, secretKey)
c.Assert(err, qt.ErrorMatches, "failed to parse email")
}

func TestVerifyClientCredentials(t *testing.T) {
c := qt.New(t)
ctx := context.Background()

const (
// these are valid client credentials hardcoded into the jimm realm
validClientID = "test-client-id"
validClientSecret = "2M2blFbO4GX4zfggQpivQSxwWX1XGgNf"
)

authSvc, _ := setupTestAuthSvc(ctx, c, time.Hour)

err := authSvc.VerifyClientCredentials(ctx, validClientID, validClientSecret)
c.Assert(err, qt.IsNil)

err = authSvc.VerifyClientCredentials(ctx, "invalid-client-id", validClientSecret)
c.Assert(err, qt.ErrorMatches, "invalid client credentials")
}
3 changes: 3 additions & 0 deletions internal/jimm/jimm.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ type OAuthAuthenticator interface {
// UpdateIdentity updates the database with the display name and access token set for the user.
// And, if present, a refresh token.
UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error

// VerifyClientCredentials verifies the provided client ID and client secret.
VerifyClientCredentials(ctx context.Context, clientID string, clientSecret string) error
}

type permission struct {
Expand Down
38 changes: 38 additions & 0 deletions internal/jujuapi/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,44 @@ func (r *controllerRoot) LoginWithSessionToken(ctx context.Context, req params.L
}, nil
}

// LoginWithClientCredentials handles logging into the JIMM with the client ID
// and secret created by the IdP.
func (r *controllerRoot) LoginWithClientCredentials(ctx context.Context, req params.LoginWithClientCredentialsRequest) (jujuparams.LoginResult, error) {
const op = errors.Op("jujuapi.LoginWithClientCredentials")

authenticationSvc := r.jimm.OAuthAuthenticationService()
if authenticationSvc == nil {
return jujuparams.LoginResult{}, errors.E("authentication service not specified")
}
err := authenticationSvc.VerifyClientCredentials(ctx, req.ClientID, req.ClientSecret)
if err != nil {
return jujuparams.LoginResult{}, errors.E(err, errors.CodeUnauthorized)
}

user, err := r.jimm.GetOpenFGAUserAndAuthorise(ctx, req.ClientID)
if err != nil {
return jujuparams.LoginResult{}, errors.E(op, err)
}

r.mu.Lock()
r.user = user
r.mu.Unlock()

// Get server version for LoginResult
srvVersion, err := r.jimm.EarliestControllerVersion(ctx)
if err != nil {
return jujuparams.LoginResult{}, errors.E(op, err)
}

return jujuparams.LoginResult{
PublicDNSName: r.params.PublicDNSName,
UserInfo: setupAuthUserInfo(ctx, r, user),
ControllerTag: setupControllerTag(r),
Facades: setupFacades(r),
ServerVersion: srvVersion.String(),
}, nil
}

// setupControllerTag returns the String() of a controller tag based on the
// JIMM controller UUID.
func setupControllerTag(root *controllerRoot) string {
Expand Down
29 changes: 29 additions & 0 deletions internal/jujuapi/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"github.com/juju/juju/api"
jujuparams "github.com/juju/juju/rpc/params"
"github.com/juju/names/v4"
gc "gopkg.in/check.v1"
"gopkg.in/macaroon.v2"
)
Expand Down Expand Up @@ -216,3 +217,31 @@ func handleLoginForm(c *gc.C, loginForm string, client *http.Client, username, p
re = regexp.MustCompile(`Device Login Successful`)
c.Assert(re.MatchString(string(b)), gc.Equals, true)
}

func (s *adminSuite) TestLoginWithClientCredentials(c *gc.C) {
conn := s.open(c, &api.Info{
SkipLogin: true,
}, "test")
defer conn.Close()

const (
// these are valid client credentials hardcoded into the jimm realm
validClientID = "test-client-id"
validClientSecret = "2M2blFbO4GX4zfggQpivQSxwWX1XGgNf"
)

var loginResult jujuparams.LoginResult
err := conn.APICall("Admin", 4, "", "LoginWithClientCredentials", params.LoginWithClientCredentialsRequest{
ClientID: validClientID,
ClientSecret: validClientSecret,
}, &loginResult)
c.Assert(err, gc.IsNil)
c.Assert(loginResult.ControllerTag, gc.Equals, names.NewControllerTag(s.Params.ControllerUUID).String())
c.Assert(loginResult.UserInfo.Identity, gc.Equals, names.NewUserTag("test-client-id").String())

err = conn.APICall("Admin", 4, "", "LoginWithClientCredentials", params.LoginWithClientCredentialsRequest{
ClientID: "invalid-client-id",
ClientSecret: "invalid-secret",
}, &loginResult)
c.Assert(err, gc.ErrorMatches, `invalid client credentials \(unauthorized access\)`)
}
1 change: 1 addition & 0 deletions internal/jujuapi/controllerroot.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ func newControllerRoot(j JIMM, p Params) *controllerRoot {
r.AddMethod("Admin", 4, "LoginDevice", rpc.Method(r.LoginDevice))
r.AddMethod("Admin", 4, "GetDeviceSessionToken", rpc.Method(r.GetDeviceSessionToken))
r.AddMethod("Admin", 4, "LoginWithSessionToken", rpc.Method(r.LoginWithSessionToken))
r.AddMethod("Admin", 4, "LoginWithClientCredentials", rpc.Method(r.LoginWithClientCredentials))
r.AddMethod("Pinger", 1, "Ping", rpc.Method(r.Ping))
return r
}
Expand Down

0 comments on commit e54dae8

Please sign in to comment.