Skip to content

Commit

Permalink
Browser cookie sessions (#1178)
Browse files Browse the repository at this point in the history
Implements browser sessions via cookies and persistent session storage.
  • Loading branch information
ale8k authored Mar 22, 2024
1 parent 697a8a8 commit efbf21c
Show file tree
Hide file tree
Showing 27 changed files with 1,075 additions and 258 deletions.
22 changes: 11 additions & 11 deletions cmd/jimmsrv/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,13 @@ func start(ctx context.Context, s *service.Service) error {
secureSessionCookies = true
}

sessionCookieExpiry := os.Getenv("JIMM_SESSION_COOKIE_EXPIRY")
sessionCookieExpiryInt, err := strconv.Atoi(sessionCookieExpiry)
sessionCookieMaxAge := os.Getenv("JIMM_SESSION_COOKIE_MAX_AGE")
sessionCookieMaxAgeInt, err := strconv.Atoi(sessionCookieMaxAge)
if err != nil {
return errors.E("unable to parse jimm session cookie expiry")
return errors.E("unable to parse jimm session cookie max age")
}
if sessionCookieExpiryInt < 0 {
return errors.E("jimm session cookie expiry cannot be less than 0")
if sessionCookieMaxAgeInt < 0 {
return errors.E("jimm session cookie max age cannot be less than 0")
}

jimmsvc, err := jimm.NewService(ctx, jimm.Params{
Expand Down Expand Up @@ -159,15 +159,15 @@ func start(ctx context.Context, s *service.Service) error {
JWTExpiryDuration: jwtExpiryDuration,
InsecureSecretStorage: insecureSecretStorage,
OAuthAuthenticatorParams: jimm.OAuthAuthenticatorParams{
IssuerURL: issuerURL,
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: scopesParsed,
SessionTokenExpiry: sessionTokenExpiryDuration,
IssuerURL: issuerURL,
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: scopesParsed,
SessionTokenExpiry: sessionTokenExpiryDuration,
SessionCookieMaxAge: sessionCookieMaxAgeInt,
},
DashboardFinalRedirectURL: os.Getenv("JIMM_DASHBOARD_FINAL_REDIRECT_URL"),
SecureSessionCookies: secureSessionCookies,
SessionCookieExpiry: sessionCookieExpiryInt,
})
if err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ services:
JIMM_DASHBOARD_FINAL_REDIRECT_URL: "https://my-dashboard.com/final-callback" # Example URL
JIMM_ACCESS_TOKEN_EXPIRY_DURATION: 1h
JIMM_SECURE_SESSION_COOKIES: false
JIMM_SESSION_COOKIE_EXPIRY: 86400
JIMM_SESSION_COOKIE_MAX_AGE: 86400
volumes:
- ./:/jimm/
- ./local/vault/approle.json:/vault/approle.json:rw
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ require (
github.com/godbus/dbus/v5 v5.0.4 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.0.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
Expand Down Expand Up @@ -251,6 +251,7 @@ require (
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.55.0 // indirect
github.com/packethost/packngo v0.28.1 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down Expand Up @@ -881,6 +883,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
Expand Down
199 changes: 197 additions & 2 deletions internal/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import (
"context"
"encoding/base64"
stderrors "errors"
"fmt"
"net/http"
"net/mail"
"strings"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
"github.com/juju/zaputil/zapctx"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
Expand All @@ -28,6 +31,31 @@ import (
"github.com/canonical/jimm/internal/errors"
)

const (
// SessionName is the name of the gorilla session and is used to retrieve
// the session object from the database.
SessionName = "jimm-browser-session"

// SessionIdentityKey is the key for the identity value stored within the
// session.
SessionIdentityKey = "identity-id"
)

type sessionIdentityContextKey struct{}

func contextWithSessionIdentity(ctx context.Context, sessionIdentityId any) context.Context {
return context.WithValue(ctx, sessionIdentityContextKey{}, sessionIdentityId)
}

// SessionIdentityFromContext returns the session identity key from the context.
func SessionIdentityFromContext(ctx context.Context) string {
v := ctx.Value(sessionIdentityContextKey{})
if v == nil {
return ""
}
return v.(string)
}

// AuthenticationService handles authentication within JIMM.
type AuthenticationService struct {
oauthConfig oauth2.Config
Expand All @@ -37,7 +65,12 @@ type AuthenticationService struct {
// sessionTokenExpiry holds the expiry time for JIMM minted session tokens (JWTs).
sessionTokenExpiry time.Duration

// sessionCookieMaxAge holds the max age for session cookies.
sessionCookieMaxAge int

db IdentityStore

sessionStore sessions.Store
}

// Identity store holds the necessary methods to get and update an identity
Expand All @@ -62,6 +95,8 @@ type AuthenticationServiceParams struct {
Scopes []string
// SessionTokenExpiry holds the expiry time of minted JIMM session tokens (JWTs).
SessionTokenExpiry time.Duration
// SessionCookieMaxAge holds the max age for session cookies.
SessionCookieMaxAge int
// RedirectURL is the URL for handling the exchange of authorisation
// codes into access tokens (and id tokens), for JIMM, this is expected
// to be the servers own callback endpoint registered under /auth/callback.
Expand All @@ -71,6 +106,9 @@ type AuthenticationServiceParams struct {
// to fetch and update identities. I.e., their access tokens, refresh tokens,
// display name, etc.
Store IdentityStore

// SessionStore holds the store for creating, getting and saving gorrila sessions.
SessionStore sessions.Store
}

// NewAuthenticationService returns a new authentication service for handling
Expand All @@ -93,8 +131,10 @@ func NewAuthenticationService(ctx context.Context, params AuthenticationServiceP
Scopes: params.Scopes,
RedirectURL: params.RedirectURL,
},
sessionTokenExpiry: params.SessionTokenExpiry,
db: params.Store,
sessionTokenExpiry: params.SessionTokenExpiry,
db: params.Store,
sessionStore: params.SessionStore,
sessionCookieMaxAge: params.SessionCookieMaxAge,
}, nil
}

Expand Down Expand Up @@ -277,6 +317,8 @@ func (as *AuthenticationService) UpdateIdentity(ctx context.Context, email strin

u.AccessToken = token.AccessToken
u.RefreshToken = token.RefreshToken
u.AccessTokenExpiry = token.Expiry
u.AccessTokenType = token.TokenType
if err := db.UpdateIdentity(ctx, u); err != nil {
return errors.E(op, err)
}
Expand Down Expand Up @@ -335,3 +377,156 @@ func (as *AuthenticationService) VerifyClientCredentials(ctx context.Context, cl
}
return nil
}

// CreateBrowserSession creates a session and updates the cookie for a browser
// login callback.
func (as *AuthenticationService) CreateBrowserSession(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
secureCookies bool,
email string,
) error {
const op = errors.Op("auth.AuthenticationService.CreateBrowserSession")

session, err := as.sessionStore.Get(r, SessionName)
if err != nil {
return errors.E(op, err)
}

session.IsNew = true // Sets cookie to a fresh new cookie
session.Options.MaxAge = as.sessionCookieMaxAge // Expiry in seconds
session.Options.Secure = secureCookies // Ensures only sent with HTTPS
session.Options.HttpOnly = false // Allow Javascript to read it

session.Values[SessionIdentityKey] = email
if err = session.Save(r, w); err != nil {
return errors.E(op, err)
}
return nil
}

// AuthenticateBrowserSession updates the session for a browser, additionally
// retrieving new access tokens upon expiry. If this cannot be done, the cookie
// is deleted and an error is returned.
func (as *AuthenticationService) AuthenticateBrowserSession(ctx context.Context, w http.ResponseWriter, req *http.Request) (context.Context, error) {
const op = errors.Op("auth.AuthenticationService.AuthenticateBrowserSession")

session, err := as.sessionStore.Get(req, SessionName)
if err != nil {
return ctx, errors.E(op, err, "failed to retrieve session")
}

identityId, ok := session.Values[SessionIdentityKey]
if !ok {
return ctx, errors.E(op, "session is missing identity key")
}

err = as.validateAndUpdateAccessToken(ctx, identityId)
if err != nil {
if err := as.deleteSession(session, w, req); err != nil {
return ctx, errors.E(op, err)
}
return ctx, errors.E(op, err)
}

ctx = contextWithSessionIdentity(ctx, identityId)

if err := as.extendSession(session, w, req); err != nil {
return ctx, errors.E(op, err)
}

return ctx, nil
}

// validateAndUpdateAccessToken validates the access tokens expiry, and if it cannot, then
// it attempts to refresh the access token.
func (as *AuthenticationService) validateAndUpdateAccessToken(ctx context.Context, email any) error {
const op = errors.Op("auth.AuthenticationService.validateAndUpdateAccessToken")

emailStr, ok := email.(string)
if !ok {
return errors.E(op, fmt.Sprintf("failed to cast email: got %T, expected %T", email, emailStr))
}

db := as.db
u := &dbmodel.Identity{
Name: emailStr,
}
if err := db.GetIdentity(ctx, u); err != nil {
return errors.E(op, err)
}

t := &oauth2.Token{
AccessToken: u.AccessToken,
RefreshToken: u.RefreshToken,
Expiry: u.AccessTokenExpiry,
TokenType: u.AccessTokenType,
}

// Valid simply checks the expiry, if the token isn't valid,
// we attempt to refresh the identities tokens and update them.
if t.Valid() {
return nil
}

if err := as.refreshIdentitiesToken(ctx, emailStr, t); err != nil {
return errors.E(op, err)
}

return nil
}

// refreshIdentitiesToken creates a token source based on the expired token and performs
// a manual token refresh, updating the identity afterwards.
//
// This is to be called only when a token is expired.
func (as *AuthenticationService) refreshIdentitiesToken(ctx context.Context, email string, t *oauth2.Token) error {
const op = errors.Op("auth.AuthenticationService.refreshIdentitiesToken")

tSrc := as.oauthConfig.TokenSource(ctx, t)

// Get a new access and refresh token (token source only has Token())
newToken, err := tSrc.Token()
if err != nil {
return errors.E(op, err, "failed to refresh token")
}

if err := as.UpdateIdentity(ctx, email, newToken); err != nil {
return errors.E(op, err, "failed to update identity")
}

return nil
}

func (as *AuthenticationService) deleteSession(session *sessions.Session, w http.ResponseWriter, req *http.Request) error {
const op = errors.Op("auth.AuthenticationService.deleteSession")

if err := as.modifySession(session, w, req, -1); err != nil {
return errors.E(op, err)
}

return nil
}

func (as *AuthenticationService) extendSession(session *sessions.Session, w http.ResponseWriter, req *http.Request) error {
const op = errors.Op("auth.AuthenticationService.extendSession")

if err := as.modifySession(session, w, req, as.sessionCookieMaxAge); err != nil {
return errors.E(op, err)
}

return nil
}

func (as *AuthenticationService) modifySession(session *sessions.Session, w http.ResponseWriter, req *http.Request, maxAge int) error {
const op = errors.Op("auth.AuthenticationService.modifySession")

session.Options.MaxAge = maxAge

if err := session.Save(req, w); err != nil {
return errors.E(op, err)
}

return nil
}
Loading

0 comments on commit efbf21c

Please sign in to comment.