package middleware import ( "bytes" "context" "errors" "fmt" "io" "time" "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/domains/authentication" "ruben/inventory2/internal/logging" "ruben/inventory2/internal/server/cookies" "ruben/inventory2/internal/server/response" "github.com/gin-gonic/gin" ) type ( Auth struct { log *logging.Logger auth *authentication.Authenticator newLoginURL LoginURLProviderFunc accts *accounts.Store } Identity struct { AccessToken string Claims authentication.AccessTokenClaims User accounts.OAuthUser Account *accounts.Account } LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) AuthorizationAssertions = response.HandlerFunc ) func NewAuth( logger *logging.Logger, auth *authentication.Authenticator, newLoginURL LoginURLProviderFunc, accts *accounts.Store, ) *Auth { return &Auth{ log: logger, auth: auth, newLoginURL: newLoginURL, accts: accts, } } func (a *Auth) AddIdentity(fn response.HandlerFunc) response.HandlerFunc { return func(c *gin.Context) (response.Response, error) { c, err := a.AddIdentityToRequest(c) if err != nil { return nil, err } return fn(c) } } func (a *Auth) AddIdentityToRequest(c *gin.Context) (*gin.Context, error) { r := c.Request ck, err := r.Cookie("access_token") if err != nil { return c, nil } ctx := r.Context() accessToken := ck.Value claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { return c, nil } return c, response.Errorf("failed to load authentication details: %w", err) } if expiration.Before(time.Now()) { return c, nil } user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { return c, response.Errorf("failed to load user and account defails: %w", err) } c.Request = r.WithContext(SetIdentity(ctx, Identity{ AccessToken: accessToken, Claims: claims, User: user, Account: acct, })) return c, nil } // TODO: after getting auth, consider making http handler functions take 'claims', etc, as function arguments // - then consider doing the same with authorizationAssertions // auth middleware to verify access_token cookie and set custom claims in the request context func (a *Auth) AuthenticateAndAddIdentity(f response.HandlerFunc, assertions ...AuthorizationAssertions) response.HandlerFunc { return func(c *gin.Context) (response.Response, error) { c, res, err := a.AuthenticateAndAddIdentityToRequest(c, assertions...) if res != nil || err != nil { return res, err } return f(c) } } func (a *Auth) AuthenticateAndAddIdentityGin(assertions ...AuthorizationAssertions) gin.HandlerFunc { return a.AuthenticateAndAddIdentityToRequestGin(assertions...) } func (a *Auth) AuthenticateAndAddIdentityToRequest(c *gin.Context, assertions ...AuthorizationAssertions) (*gin.Context, response.Response, error) { r := c.Request ck, err := r.Cookie("access_token") if err != nil { return c, response.TemporaryRedirect("/"). JSON("no access_token cookie provided"), nil } ctx := r.Context() accessToken := ck.Value claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { u, err := a.newLoginURL(ctx, a.auth, r.URL.String()) if err != nil { return c, nil, response.Errorf("failed to generate login url: %w", err) } return c, response.TemporaryRedirect(u), nil } return c, nil, response.Errorf("failed to authenticate: %w", err) } now := time.Now() // refresh tokens, when the access token is "old enough" // id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in. const idTokenLifetime = 48 * time.Hour if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) { accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken) if err != nil { a.log.Warn("failed to refresh access token", "error", err) return c, response.TemporaryRedirect("/"). Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))). Cookie(cookies.Expired("access_token")), nil } // 'redirect' to same url, to set the new access_token cookie return c, response.TemporaryRedirect(r.URL.String()). Cookie(cookies.AccessToken(accessToken, expiration)), nil } // add identity info to request context user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { return c, nil, response.Errorf("failed to authorize: %w", err) } for _, as := range assertions { if res, err := as(c); res != nil || err != nil { return c, res, err } } r = r.WithContext(SetIdentity(ctx, Identity{ AccessToken: accessToken, Claims: claims, User: user, Account: acct, })) c.Request = r return c, nil, nil } func (a *Auth) AuthenticateAndAddIdentityToRequestGin(assertions ...AuthorizationAssertions) gin.HandlerFunc { return func(c *gin.Context) { r := c.Request ck, err := r.Cookie("access_token") if err != nil { response.Write(c, response.TemporaryRedirect("/"). JSON("no access_token cookie provided")) c.Abort() return } ctx := r.Context() accessToken := ck.Value claims, expiration, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { u, err := a.newLoginURL(ctx, a.auth, r.URL.String()) if err != nil { c.Error(response.Errorf("failed to generate login url: %w", err)) c.Abort() return } response.Write(c, response.TemporaryRedirect(u)) c.Abort() return } c.Error(response.Errorf("failed to authenticate: %w", err)) c.Abort() return } now := time.Now() // refresh tokens, when the access token is "old enough" // id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in. const idTokenLifetime = 48 * time.Hour if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) { accessToken, expiration, err = a.auth.RefreshAccessToken(ctx, accessToken) if err != nil { a.log.Warn("failed to refresh access token", "error", err) response.Write(c, response.TemporaryRedirect("/"). Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))). Cookie(cookies.Expired("access_token"))) c.Abort() return } // 'redirect' to same url, to set the new access_token cookie response.Write(c, response.TemporaryRedirect(r.URL.String()). Cookie(cookies.AccessToken(accessToken, expiration))) c.Abort() return } // add identity info to request context user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken) if err != nil { c.Error(response.Errorf("failed to authorize: %w", err)) c.Abort() return } for _, as := range assertions { res, err := as(c) if err != nil { c.Error(err) c.Abort() return } if res != nil { response.Write(c, res) c.Abort() return } } id := Identity{ AccessToken: accessToken, Claims: claims, User: user, Account: acct, } c.Set(identityKeyString, id) c.Request = r.WithContext(SetIdentity(ctx, id)) c.Next() } } type identityKey struct{} const identityKeyString = "identity" // stores identity in context func SetIdentity(ctx context.Context, id Identity) context.Context { return context.WithValue(ctx, identityKey{}, id) } // get identity from context func GetIdentity(ctx context.Context) Identity { id, ok := ctx.Value(identityKey{}).(Identity) if ok { return id } c, ok := ctx.(*gin.Context) if !ok { return Identity{} } v, ok := c.Get(identityKeyString) if !ok { return Identity{} } id, _ = v.(Identity) return id }