Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Css 6646/callback endpoint #1170

Merged
merged 11 commits into from
Mar 6, 2024
1 change: 1 addition & 0 deletions cmd/jimmsrv/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func start(ctx context.Context, s *service.Service) error {
Scopes: scopesParsed,
SessionTokenExpiry: sessionTokenExpiryDuration,
},
DashboardFinalRedirectURL: os.Getenv("JIMM_DASHBOARD_FINAL_REDIRECT_URL"),
})
if err != nil {
return err
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ services:
JIMM_OAUTH_CLIENT_ID: "jimm-device"
JIMM_OAUTH_CLIENT_SECRET: "SwjDofnbDzJDm9iyfUhEp67FfUFMY8L4"
JIMM_OAUTH_SCOPES: "openid profile email" # Space separated list of scopes
JIMM_DASHBOARD_FINAL_REDIRECT_URL: "https://my-dashboard.com/final-callback" # Example URL
JIMM_ACCESS_TOKEN_EXPIRY_DURATION: 1h
volumes:
- ./:/jimm/
Expand Down
62 changes: 62 additions & 0 deletions internal/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/base64"
stderrors "errors"
"net/mail"
"strings"
"time"

"github.com/coreos/go-oidc/v3/oidc"
Expand All @@ -16,6 +17,7 @@ import (
"go.uber.org/zap"
"golang.org/x/oauth2"

"github.com/canonical/jimm/internal/dbmodel"
"github.com/canonical/jimm/internal/errors"
)

Expand All @@ -27,6 +29,13 @@ type AuthenticationService struct {
provider *oidc.Provider
// sessionTokenExpiry holds the expiry time for JIMM minted session tokens (JWTs).
sessionTokenExpiry time.Duration

db IdentityStore
}

type IdentityStore interface {
ale8k marked this conversation as resolved.
Show resolved Hide resolved
GetIdentity(ctx context.Context, u *dbmodel.Identity) error
UpdateIdentity(ctx context.Context, u *dbmodel.Identity) error
}

// AuthenticationServiceParams holds the parameters to initialise
Expand All @@ -48,6 +57,11 @@ type AuthenticationServiceParams struct {
// codes into access tokens (and id tokens), for JIMM, this is expected
// to be the servers own callback endpoint registered under /auth/callback.
RedirectURL string

// Store holds the identity store used by the authentication service
// to fetch and update identities. I.e., their access tokens, refresh tokens,
// display name, etc.
Store IdentityStore
}

// NewAuthenticationService returns a new authentication service for handling
Expand All @@ -71,6 +85,7 @@ func NewAuthenticationService(ctx context.Context, params AuthenticationServiceP
RedirectURL: params.RedirectURL,
},
sessionTokenExpiry: params.SessionTokenExpiry,
db: params.Store,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some validation of input parameters should be done here, no? like.. store should not be nil, sessionTokenExpiry should not be 0.. i'm sure there's other validation we could do

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like validation should be done at the service.go level, but happy to add more in?

}, nil
}

Expand All @@ -86,6 +101,27 @@ func (as *AuthenticationService) AuthCodeURL() string {
return as.oauthConfig.AuthCodeURL("")
}

// Exchange exchanges an authorisation code for an access token.
//
// TODO(ale8k): How to test this? A callback has to be made and it needs to be valid,
// this may need some thought as to whether its actually worth testing or are we
// just testing the library. The handler test essentially covers this so perhaps
// its ok to leave it as is?
func (as *AuthenticationService) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
const op = errors.Op("auth.AuthenticationService.Exchange")

t, err := as.oauthConfig.Exchange(
ctx,
code,
oauth2.SetAuthURLParam("client_secret", as.oauthConfig.ClientSecret),
)
if err != nil {
return nil, errors.E(op, err, "device access token call failed")
}

return t, nil
}

// Device initiates a device flow login and is step ONE of TWO.
//
// This is done via retrieving a:
Expand Down Expand Up @@ -203,6 +239,32 @@ func (as *AuthenticationService) VerifySessionToken(token string, secretKey stri
return VerifySessionToken(token, secretKey)
}

// UpdateIdentity updates the database with the display name and access token set for the user.
// And, if present, a refresh token.
func (as *AuthenticationService) UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this unit tested?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also.. it doesn't make sense to pass in email as a separate parameter.. you could just call

	idToken, err := as.ExtractAndVerifyIDToken(ctx, token)
	if err != nil {
		writeError(ctx, w, http.StatusBadRequest, err, "failed to extract and verify id token")
		return
	}

	email, err := email(idToken)

here to get the email from the token

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't tested individually it's part of the TestDevice, I need to come back and mock all of this out afterwards. That's the plan anyways, so I guess view the tests here as temporary, I just written them to help develop. And it's separate as I think the extracting email from a field that can error out is better done outside of the function? Happy to call .Email() within updateidentity but I feel this is clearer / more obvious?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also in next pr we actually use the email in another function within the handler, so i'll leave this as is

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We pass it over to the session creation

	// If the session is empty, it'll just be an empty session, we only check
	// errors for bad decoding etc.
	session, err := oah.SessionStore.Get(r, "jimm-browser-session")
	if err != nil {
		writeError(ctx, w, http.StatusBadRequest, err, "failed to get session")
	}

	session.Values["jimm-session"] = email

db := as.db
u := &dbmodel.Identity{
Name: email,
}
// TODO(babakks): If user does not exist, we will create one with an empty
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we create a ticket for this and link it here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one might exist... i am a bit confused by it though. trhis was more babaks suggestion

// display name (which we shouldn't). So it would be better to fetch
// and then create. At the moment, GetUser is used for both create and fetch,
// this should be changed and split apart so it is intentional what entities
// we are creating or fetching.
if err := db.GetIdentity(ctx, u); err != nil {
return err
}
// Check if user has a display name, if not, set one
if u.DisplayName == "" {
u.DisplayName = strings.Split(email, "@")[0]
ale8k marked this conversation as resolved.
Show resolved Hide resolved
}
u.AccessToken = token.AccessToken
if err := db.UpdateIdentity(ctx, u); err != nil {
return err
ale8k marked this conversation as resolved.
Show resolved Hide resolved
}
return nil
}

// VerifySessionToken symmetrically verifies the validty of the signature on the
// access token JWT, returning the parsed token.
//
Expand Down
4 changes: 4 additions & 0 deletions internal/auth/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/canonical/jimm/internal/auth"
"github.com/canonical/jimm/internal/db"
"github.com/canonical/jimm/internal/jimmtest"
"github.com/coreos/go-oidc/v3/oidc"
qt "github.com/frankban/quicktest"
Expand All @@ -27,6 +28,9 @@ func setupTestAuthSvc(ctx context.Context, c *qt.C, expiry time.Duration) *auth.
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
SessionTokenExpiry: expiry,
RedirectURL: "http://localhost:8080/auth/callback",
Store: &db.Database{
DB: jimmtest.PostgresDB(c, func() time.Time { return time.Now() }),
},
})
c.Assert(err, qt.IsNil)

Expand Down
1 change: 1 addition & 0 deletions internal/cmdtest/jimmsuite.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func (s *JimmCmdSuite) SetUpTest(c *gc.C) {
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
SessionTokenExpiry: time.Duration(time.Hour),
},
DashboardFinalRedirectURL: "",
}

srv, err := service.NewService(ctx, s.Params)
Expand Down
4 changes: 4 additions & 0 deletions internal/jimm/jimm.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ type OAuthAuthenticator interface {
// The subject of the token contains the user's email and can be used
// for user object creation.
VerifySessionToken(token string, secretKey string) (jwt.Token, error)

// 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
}

type permission struct {
Expand Down
3 changes: 3 additions & 0 deletions internal/jimm/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ func TestGetOpenFGAUser(t *testing.T) {
ClientID: "jimm-device",
Scopes: []string{"openid", "profile", "email"},
SessionTokenExpiry: time.Hour,
Store: &db.Database{
DB: jimmtest.PostgresDB(c, func() time.Time { return time.Now() }),
},
})
c.Assert(err, qt.IsNil)

Expand Down
74 changes: 68 additions & 6 deletions internal/jimmhttp/auth_handler.go
Original file line number Diff line number Diff line change
@@ -1,43 +1,105 @@
package jimmhttp

import (
"context"
"net/http"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"github.com/juju/zaputil/zapctx"
"go.uber.org/zap"
"golang.org/x/oauth2"

"github.com/canonical/jimm/internal/errors"
)

// OAuthHandler handles the oauth2.0 browser flow for JIMM.
// Implements jimmhttp.JIMMHttpHandler.
type OAuthHandler struct {
Router *chi.Mux
Authenticator BrowserOAuthAuthenticator
Router *chi.Mux
Authenticator BrowserOAuthAuthenticator
DashboardFinalRedirectURL string
}

// BrowserOAuthAuthenticator handles authorisation code authentication within JIMM
// via OIDC.
type BrowserOAuthAuthenticator interface {
// AuthCodeURL returns a URL that will be used to redirect a browser to the identity provider.
AuthCodeURL() string
Exchange(ctx context.Context, code string) (*oauth2.Token, error)
ExtractAndVerifyIDToken(ctx context.Context, oauth2Token *oauth2.Token) (*oidc.IDToken, error)
Email(idToken *oidc.IDToken) (string, error)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Email does not need to be a method of the authenticator.. it can be an independent function because it just extracts an email from the id token that's passed in

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered in the other comment why I did this

UpdateIdentity(ctx context.Context, email string, token *oauth2.Token) error
}

// NewOAuthHandler returns a new OAuth handler.
func NewOAuthHandler(authenticator BrowserOAuthAuthenticator) *OAuthHandler {
return &OAuthHandler{Router: chi.NewRouter(), Authenticator: authenticator}
func NewOAuthHandler(authenticator BrowserOAuthAuthenticator, dashboardFinalRedirectURL string) (*OAuthHandler, error) {
if authenticator == nil {
return nil, errors.E("nil authenticator")
}
if dashboardFinalRedirectURL == "" {
return nil, errors.E("final redirect url not specified")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good enough, but I think we should give it another thought. All the tests are created with "no dashboard required for this test", which could have been avoided if this was not required here as we return an error.

Moreover, checking on empty strings shouldn't be enough, we need to actually verify the url is parseable, valid, and reachable. If not, return an error, too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because the auth handler has validation, which I agree with Ales it was necessary, just our other handlers don't have the same validation... Because we embed into jimm a lot we end up with this polluted mess of creating stuff for the sake of creating it. And ah yeah definitely, I'll add a url parse to next PR totally agree.

}
return &OAuthHandler{
Router: chi.NewRouter(),
Authenticator: authenticator,
DashboardFinalRedirectURL: dashboardFinalRedirectURL,
}, nil
}

// Routes returns the grouped routers routes with group specific middlewares.
func (oah *OAuthHandler) Routes() chi.Router {
oah.SetupMiddleware()
oah.Router.Get("/login", oah.Login)
oah.Router.Get("/callback", oah.Callback)
return oah.Router
}

// SetupMiddleware applies middlewares.
func (oah *OAuthHandler) SetupMiddleware() {
}

// Login handles /auth/login,
// Login handles /auth/login.
func (oah *OAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
redirectURL := oah.Authenticator.AuthCodeURL()
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}

func write500(ctx context.Context, err error, w http.ResponseWriter, logMsg string) {
zapctx.Error(ctx, logMsg, zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error."))
ale8k marked this conversation as resolved.
Show resolved Hide resolved
}

// Callback handles /auth/callback.
func (oah *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()

code := r.URL.Query().Get("code")

authSvc := oah.Authenticator
alesstimec marked this conversation as resolved.
Show resolved Hide resolved

token, err := authSvc.Exchange(ctx, code)
if err != nil {
write500(ctx, err, w, "failed to exchange authcode")
return
}

idToken, err := authSvc.ExtractAndVerifyIDToken(ctx, token)
if err != nil {
write500(ctx, err, w, "failed to extract and verify id token")
return
}

email, err := authSvc.Email(idToken)
if err != nil {
write500(ctx, err, w, "failed to extract email from id token")
return
}

if err := authSvc.UpdateIdentity(ctx, email, token); err != nil {
write500(ctx, err, w, "failed to update identity")
return
}

http.Redirect(w, r, oah.DashboardFinalRedirectURL, http.StatusPermanentRedirect)
alesstimec marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading