diff --git a/api/params/params.go b/api/params/params.go index 8c8d8bf62..d63bbb132 100644 --- a/api/params/params.go +++ b/api/params/params.go @@ -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. diff --git a/internal/auth/oauth2.go b/internal/auth/oauth2.go index 9a655689a..d7ce6a8ac 100644 --- a/internal/auth/oauth2.go +++ b/internal/auth/oauth2.go @@ -14,6 +14,8 @@ import ( "github.com/juju/zaputil/zapctx" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" + gosdk_oauth2 "github.com/openfga/go-sdk/oauth2" + "github.com/openfga/go-sdk/oauth2/clientcredentials" "go.uber.org/zap" "golang.org/x/oauth2" @@ -310,3 +312,20 @@ 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: gosdk_oauth2.AuthStyle(as.oauthConfig.Endpoint.AuthStyle), + } + + _, err := cfg.Token(ctx) + if err != nil { + return errors.E(errors.CodeUnauthorized, "invalid client credentials") + } + return nil +} diff --git a/internal/auth/oauth2_test.go b/internal/auth/oauth2_test.go index 5014c7347..b7b79373a 100644 --- a/internal/auth/oauth2_test.go +++ b/internal/auth/oauth2_test.go @@ -226,3 +226,21 @@ 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 ( + 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") +} diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index 1d07fa3ec..a0aa4ce59 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -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 { diff --git a/internal/jujuapi/admin.go b/internal/jujuapi/admin.go index a0d6e010c..3a2933f44 100644 --- a/internal/jujuapi/admin.go +++ b/internal/jujuapi/admin.go @@ -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 { diff --git a/internal/jujuapi/admin_test.go b/internal/jujuapi/admin_test.go index 9869ca567..add0e1f8f 100644 --- a/internal/jujuapi/admin_test.go +++ b/internal/jujuapi/admin_test.go @@ -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" ) @@ -216,3 +217,30 @@ 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 ( + 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\)`) +} diff --git a/internal/jujuapi/controllerroot.go b/internal/jujuapi/controllerroot.go index ef7a01385..e87fb8aa9 100644 --- a/internal/jujuapi/controllerroot.go +++ b/internal/jujuapi/controllerroot.go @@ -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 }