feat: rudimentary support for refresh tokens

This commit is contained in:
Trong Huu Nguyen
2022-08-26 14:32:39 +02:00
parent dc0741f79f
commit d5bbca9897
30 changed files with 1048 additions and 335 deletions
+5 -2
View File
@@ -81,7 +81,7 @@ The following flags are available:
--bind-address string Listen address for public connections. (default "127.0.0.1:3000")
--encryption-key string Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store.
--error-redirect-uri string URI to redirect user to on errors for custom error handling.
--ingress string Comma separated list of ingresses used to access the main application.
--ingress strings Comma separated list of ingresses used to access the main application.
--log-format string Log format, either 'json' or 'text'. (default "json")
--log-level string Logging verbosity level. (default "info")
--loginstatus.cookie-domain string The domain that the cookie should be set for.
@@ -102,10 +102,13 @@ The following flags are available:
--redis.password string Password for Redis.
--redis.tls Whether or not to use TLS for connecting to Redis. (default true)
--redis.username string Username for Redis.
--session-max-lifetime duration Max lifetime for user sessions. (default 1h0m0s)
--session.max-lifetime duration Max lifetime for user sessions. (default 1h0m0s)
--session.refresh Automatically refresh the tokens for user sessions if they are expired, as long as the session exists (indicated by the session max lifetime).
--upstream-host string Address of upstream host. (default "127.0.0.1:8080")
```
Boolean flags/options are by default set to `false` unless noted otherwise.
At minimum, the following configuration must be provided:
- `openid.client-id`
+1 -7
View File
@@ -13,7 +13,6 @@ import (
openidconfig "github.com/nais/wonderwall/pkg/openid/config"
"github.com/nais/wonderwall/pkg/router"
"github.com/nais/wonderwall/pkg/server"
"github.com/nais/wonderwall/pkg/session"
)
func run() error {
@@ -36,12 +35,7 @@ func run() error {
defer cancel()
crypt := crypto.NewCrypter(key)
sessionHandler, err := session.NewHandler(cfg, openidConfig, crypt)
if err != nil {
return err
}
h, err := handler.NewHandler(ctx, cfg, openidConfig, crypt, sessionHandler)
h, err := handler.NewHandler(ctx, cfg, openidConfig, crypt)
if err != nil {
return fmt.Errorf("initializing routing handler: %w", err)
}
+16 -8
View File
@@ -17,13 +17,13 @@ type Config struct {
LogLevel string `json:"log-level"`
MetricsBindAddress string `json:"metrics-bind-address"`
AutoLogin bool `json:"auto-login"`
AutoLoginIgnorePaths []string `json:"auto-login-ignore-paths"`
EncryptionKey string `json:"encryption-key"`
ErrorRedirectURI string `json:"error-redirect-uri"`
Ingresses []string `json:"ingress"`
SessionMaxLifetime time.Duration `json:"session-max-lifetime"`
UpstreamHost string `json:"upstream-host"`
AutoLogin bool `json:"auto-login"`
AutoLoginIgnorePaths []string `json:"auto-login-ignore-paths"`
EncryptionKey string `json:"encryption-key"`
ErrorRedirectURI string `json:"error-redirect-uri"`
Ingresses []string `json:"ingress"`
Session Session `json:"session"`
UpstreamHost string `json:"upstream-host"`
OpenID OpenID `json:"openid"`
Redis Redis `json:"redis"`
@@ -39,6 +39,11 @@ type Loginstatus struct {
TokenURL string `json:"token-url"`
}
type Session struct {
MaxLifetime time.Duration `json:"max-lifetime"`
Refresh bool `json:"refresh"`
}
const (
BindAddress = "bind-address"
LogFormat = "log-format"
@@ -50,9 +55,11 @@ const (
EncryptionKey = "encryption-key"
ErrorRedirectURI = "error-redirect-uri"
Ingress = "ingress"
SessionMaxLifetime = "session-max-lifetime"
UpstreamHost = "upstream-host"
SessionMaxLifetime = "session.max-lifetime"
SessionRefresh = "session.refresh"
LoginstatusEnabled = "loginstatus.enabled"
LoginstatusCookieDomain = "loginstatus.cookie-domain"
LoginstatusCookieName = "loginstatus.cookie-name"
@@ -74,6 +81,7 @@ func Initialize() (*Config, error) {
flag.String(ErrorRedirectURI, "", "URI to redirect user to on errors for custom error handling.")
flag.StringSlice(Ingress, []string{}, "Comma separated list of ingresses used to access the main application.")
flag.Duration(SessionMaxLifetime, time.Hour, "Max lifetime for user sessions.")
flag.Bool(SessionRefresh, false, "Automatically refresh the tokens for user sessions if they are expired, as long as the session exists (indicated by the session max lifetime).")
flag.String(UpstreamHost, "127.0.0.1:8080", "Address of upstream host.")
flag.Bool(LoginstatusEnabled, false, "Feature toggle for Loginstatus, a separate service that should provide an opaque token to indicate that a user has been authenticated previously, e.g. by another application in another subdomain.")
+15 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httputil"
"time"
"github.com/nais/wonderwall/pkg/autologin"
"github.com/nais/wonderwall/pkg/config"
@@ -35,7 +36,6 @@ func NewHandler(
cfg *config.Config,
openidConfig openidconfig.Config,
crypter crypto.Crypter,
sessionHandler *session.Handler,
) (*Handler, error) {
openidProvider, err := provider.NewProvider(ctx, openidConfig)
if err != nil {
@@ -47,13 +47,25 @@ func NewHandler(
return nil, err
}
httpClient := &http.Client{
Timeout: time.Second * 10,
}
openidClient := client.NewClient(openidConfig)
openidClient.SetHttpClient(httpClient)
sessionHandler, err := session.NewHandler(cfg, openidConfig, crypter, openidClient)
if err != nil {
return nil, err
}
return &Handler{
AutoLogin: autoLogin,
Client: client.NewClient(openidConfig),
Client: openidClient,
Config: cfg,
CookieOptions: cookie.DefaultOptions(),
Crypter: crypter,
Loginstatus: loginstatus.NewClient(cfg.Loginstatus, http.DefaultClient),
Loginstatus: loginstatus.NewClient(cfg.Loginstatus, httpClient),
OpenIDConfig: openidConfig,
Provider: openidProvider,
ReverseProxy: newReverseProxy(cfg.UpstreamHost),
+6 -26
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/sethvargo/go-retry"
log "github.com/sirupsen/logrus"
@@ -56,15 +55,16 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
return
}
expiresIn := h.getSessionLifetime(tokens.Expiry)
key, err := h.Sessions.Create(r, tokens, expiresIn)
sessionLifetime := h.Config.Session.MaxLifetime
key, err := h.Sessions.Create(r, tokens, sessionLifetime)
if err != nil {
h.InternalError(w, r, fmt.Errorf("callback: creating session: %w", err))
return
}
opts := h.CookieOptsPathAware(r).
WithExpiresIn(expiresIn)
WithExpiresIn(sessionLifetime)
err = cookie.EncryptAndSet(w, cookie.Session, key, opts, h.Crypter)
if err != nil {
h.InternalError(w, r, fmt.Errorf("callback: setting session cookie: %w", err))
@@ -92,11 +92,7 @@ func (h *Handler) redeemValidTokens(r *http.Request, loginCallback client.LoginC
retryable := func(ctx context.Context) error {
tokens, err = loginCallback.RedeemTokens(ctx)
if err != nil {
return retry.RetryableError(err)
}
return nil
return retry.RetryableError(err)
}
if err := retry.Do(r.Context(), retrypkg.DefaultBackoff, retryable); err != nil {
@@ -106,18 +102,6 @@ func (h *Handler) redeemValidTokens(r *http.Request, loginCallback client.LoginC
return tokens, nil
}
func (h *Handler) getSessionLifetime(tokenExpiry time.Time) time.Duration {
defaultSessionLifetime := h.Config.SessionMaxLifetime
tokenDuration := tokenExpiry.Sub(time.Now())
if tokenDuration <= defaultSessionLifetime {
return tokenDuration
}
return defaultSessionLifetime
}
func (h *Handler) getLoginstatusToken(r *http.Request, tokens *openid.Tokens) (*loginstatus.TokenResponse, error) {
var tokenResponse *loginstatus.TokenResponse
@@ -125,11 +109,7 @@ func (h *Handler) getLoginstatusToken(r *http.Request, tokens *openid.Tokens) (*
var err error
tokenResponse, err = h.Loginstatus.ExchangeToken(ctx, tokens.AccessToken)
if err != nil {
return retry.RetryableError(err)
}
return nil
return retry.RetryableError(err)
}
if err := retry.Do(r.Context(), retrypkg.DefaultBackoff, retryable); err != nil {
return nil, err
+2 -2
View File
@@ -49,8 +49,8 @@ func (h *Handler) Default(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) accessToken(r *http.Request, logger *log.Entry) (string, bool) {
sessionData, err := h.Sessions.Get(r)
if err == nil && sessionData != nil && len(sessionData.AccessToken) > 0 {
sessionData, err := h.Sessions.GetOrRefresh(r)
if err == nil && sessionData != nil && sessionData.HasAccessToken() {
return sessionData.AccessToken, true
}
+36
View File
@@ -0,0 +1,36 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
mw "github.com/nais/wonderwall/pkg/middleware"
"github.com/nais/wonderwall/pkg/session"
)
// SessionInfo returns metadata for the current user's session.
func (h *Handler) SessionInfo(w http.ResponseWriter, r *http.Request) {
logger := mw.LogEntryFrom(r)
data, err := h.Sessions.Get(r)
if err != nil {
if errors.Is(err, session.CookieNotFoundError) || errors.Is(err, session.KeyNotFoundError) {
logger.Infof("session/info: getting session: %+v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
logger.Warnf("session/info: getting session: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(data.Metadata.Verbose())
if err != nil {
logger.Warnf("session/info: marshalling metadata: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
mw "github.com/nais/wonderwall/pkg/middleware"
"github.com/nais/wonderwall/pkg/session"
)
// SessionRefresh refreshes current user's session and returns the associated updated metadata.
func (h *Handler) SessionRefresh(w http.ResponseWriter, r *http.Request) {
logger := mw.LogEntryFrom(r)
key, err := h.Sessions.GetKey(r)
if err != nil {
logger.Infof("session/refresh: getting key: %+v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
data, err := h.Sessions.Get(r)
if err != nil {
if errors.Is(err, session.KeyNotFoundError) {
logger.Infof("session/refresh: getting session: %+v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
logger.Warnf("session/refresh: getting session: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
data, err = h.Sessions.Refresh(r, key, data)
if err != nil {
logger.Warnf("session/refresh: refreshing: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(data.Metadata.Verbose())
if err != nil {
logger.Warnf("session/refresh: marshalling metadata: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
+173 -3
View File
@@ -2,6 +2,7 @@ package handler_test
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -10,12 +11,14 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/cookie"
urlpkg "github.com/nais/wonderwall/pkg/handler/url"
"github.com/nais/wonderwall/pkg/mock"
"github.com/nais/wonderwall/pkg/session"
)
func TestHandler_Login(t *testing.T) {
@@ -122,11 +125,9 @@ func TestHandler_FrontChannelLogout(t *testing.T) {
return data.ExternalSessionID
}
frontchannelLogoutURL, err := url.Parse(idp.RelyingPartyServer.URL)
frontchannelLogoutURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/logout/frontchannel")
assert.NoError(t, err)
frontchannelLogoutURL.Path = "/oauth2/logout/frontchannel"
req := idp.GetRequest(frontchannelLogoutURL.String())
values := url.Values{}
@@ -154,6 +155,161 @@ func TestHandler_SessionStateRequired(t *testing.T) {
assert.NotEmpty(t, sessionState)
}
func TestHandler_SessionInfo(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Minute
defer idp.Close()
rpClient := idp.RelyingPartyClient()
login(t, rpClient, idp)
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data session.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &data)
assert.NoError(t, err)
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now(), data.SessionCreatedAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(cfg.Session.MaxLifetime), data.SessionEndsAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), data.TokensExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), data.TokensRefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(data.SessionEndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(data.TokensExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
// 1 second < next token refresh <= seconds until token expires
assert.LessOrEqual(t, data.TokensNextRefreshInSeconds, data.TokensExpireInSeconds)
assert.Greater(t, data.TokensNextRefreshInSeconds, int64(1))
assert.True(t, data.TokensRefreshCooldown)
// 1 second < refresh cooldown <= minimum refresh interval
assert.LessOrEqual(t, data.TokensRefreshCooldownSeconds, session.RefreshMinInterval)
assert.Greater(t, data.TokensRefreshCooldownSeconds, int64(1))
}
func TestHandler_SessionInfo_Disabled(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = false
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Second
defer idp.Close()
rpClient := idp.RelyingPartyClient()
login(t, rpClient, idp)
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestHandler_SessionRefresh(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Second
defer idp.Close()
rpClient := idp.RelyingPartyClient()
login(t, rpClient, idp)
// get initial session info
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data session.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &data)
assert.NoError(t, err)
// wait until refresh cooldown has reached zero before refresh
func() {
timeout := time.After(5 * time.Second)
ticker := time.Tick(500 * time.Millisecond)
for {
select {
case <-timeout:
assert.Fail(t, "refresh cooldown timer exceeded timeout")
case <-ticker:
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var temp session.MetadataVerbose
err = json.Unmarshal([]byte(resp.Body), &temp)
assert.NoError(t, err)
if !temp.TokensRefreshCooldown {
return
}
}
}
}()
resp = sessionRefresh(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var refreshedData session.MetadataVerbose
err = json.Unmarshal([]byte(resp.Body), &refreshedData)
assert.NoError(t, err)
// session create and end times should be unchanged
assert.WithinDuration(t, data.SessionCreatedAt, refreshedData.SessionCreatedAt, 0)
assert.WithinDuration(t, data.SessionEndsAt, refreshedData.SessionEndsAt, 0)
// token expiration and refresh times should be later than before
assert.True(t, refreshedData.TokensExpireAt.After(data.TokensExpireAt))
assert.True(t, refreshedData.TokensRefreshedAt.After(data.TokensRefreshedAt))
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), refreshedData.TokensExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), refreshedData.TokensRefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(refreshedData.SessionEndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(refreshedData.TokensExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
// 1 second < next token refresh <= seconds until token expires
assert.LessOrEqual(t, refreshedData.TokensNextRefreshInSeconds, refreshedData.TokensExpireInSeconds)
assert.Greater(t, refreshedData.TokensNextRefreshInSeconds, int64(1))
assert.True(t, refreshedData.TokensRefreshCooldown)
// 1 second < refresh cooldown <= minimum refresh interval
assert.LessOrEqual(t, refreshedData.TokensRefreshCooldownSeconds, session.RefreshMinInterval)
assert.Greater(t, refreshedData.TokensRefreshCooldownSeconds, int64(1))
}
func TestHandler_SessionRefresh_Disabled(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = false
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Second
defer idp.Close()
rpClient := idp.RelyingPartyClient()
login(t, rpClient, idp)
resp := sessionRefresh(t, idp, rpClient)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestHandler_Default(t *testing.T) {
up := newUpstream(t)
defer up.Server.Close()
@@ -413,6 +569,20 @@ func logout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) {
assert.Nil(t, sessionCookie)
}
func sessionInfo(t *testing.T, idp *mock.IdentityProvider, rpClient *http.Client) response {
sessionInfoURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/session")
assert.NoError(t, err)
return get(t, rpClient, sessionInfoURL.String())
}
func sessionRefresh(t *testing.T, idp *mock.IdentityProvider, rpClient *http.Client) response {
sessionRefreshURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/session/refresh")
assert.NoError(t, err)
return get(t, rpClient, sessionRefreshURL.String())
}
type response struct {
Body string
Location *url.URL
+3 -1
View File
@@ -23,7 +23,9 @@ func Config() *config.Config {
Scopes: []string{"some-scope"},
UILocales: "nb",
},
SessionMaxLifetime: time.Hour,
Session: config.Session{
MaxLifetime: time.Hour,
},
}
}
+162 -65
View File
@@ -20,11 +20,11 @@ import (
"github.com/nais/wonderwall/pkg/config"
"github.com/nais/wonderwall/pkg/crypto"
handlerpkg "github.com/nais/wonderwall/pkg/handler"
"github.com/nais/wonderwall/pkg/openid"
openidclient "github.com/nais/wonderwall/pkg/openid/client"
openidconfig "github.com/nais/wonderwall/pkg/openid/config"
scopespkg "github.com/nais/wonderwall/pkg/openid/scopes"
"github.com/nais/wonderwall/pkg/router"
"github.com/nais/wonderwall/pkg/session"
)
type IdentityProvider struct {
@@ -82,13 +82,9 @@ func NewIdentityProvider(cfg *config.Config) *IdentityProvider {
openidConfig.TestProvider.SetTokenEndpoint(server.URL + "/token")
crypter := crypto.NewCrypter([]byte(cfg.EncryptionKey))
sessionHandler, err := session.NewHandler(cfg, openidConfig, crypter)
if err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
rpHandler, err := handlerpkg.NewHandler(ctx, cfg, openidConfig, crypter, sessionHandler)
rpHandler, err := handlerpkg.NewHandler(ctx, cfg, openidConfig, crypter)
if err != nil {
panic(err)
}
@@ -98,7 +94,6 @@ func NewIdentityProvider(cfg *config.Config) *IdentityProvider {
// reconfigure client after Relying Party server is started
openidConfig.TestClient.SetIngresses(rpServer.URL)
rpHandler.Client = openidclient.NewClient(openidConfig)
return &IdentityProvider{
cancelFunc: cancel,
@@ -122,18 +117,22 @@ func identityProviderRouter(ip *IdentityProviderHandler) chi.Router {
}
type IdentityProviderHandler struct {
Codes map[string]*AuthorizeRequest
Config openidconfig.Config
Provider *TestProvider
Sessions map[string]string
Codes map[string]*AuthorizeRequest
Config openidconfig.Config
Provider *TestProvider
Sessions map[string]string
RefreshTokens map[string]*RefreshTokenData
TokenDuration time.Duration
}
func newIdentityProviderHandler(provider *TestProvider, cfg openidconfig.Config) *IdentityProviderHandler {
return &IdentityProviderHandler{
Codes: make(map[string]*AuthorizeRequest),
Config: cfg,
Provider: provider,
Sessions: make(map[string]string),
Codes: make(map[string]*AuthorizeRequest),
Config: cfg,
Provider: provider,
Sessions: make(map[string]string),
RefreshTokens: make(map[string]*RefreshTokenData),
TokenDuration: time.Minute,
}
}
@@ -147,6 +146,13 @@ type AuthorizeRequest struct {
SessionID string
}
type RefreshTokenData struct {
ClientID string
RefreshToken string
OriginalIDToken jwt.Token
SessionID string
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
@@ -299,8 +305,23 @@ func (ip *IdentityProviderHandler) Token(w http.ResponseWriter, r *http.Request)
return
}
code := r.PostForm.Get("code")
grantType := r.PostForm.Get(openid.GrantType)
switch grantType {
case "authorization_code":
ip.TokenCodeGrant(w, r)
return
case "refresh_token":
ip.RefreshTokenGrant(w, r)
return
default:
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("unsupported grant_type: " + grantType))
return
}
}
func (ip *IdentityProviderHandler) TokenCodeGrant(w http.ResponseWriter, r *http.Request) {
code := r.PostForm.Get("code")
if len(code) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("missing code"))
@@ -314,23 +335,9 @@ func (ip *IdentityProviderHandler) Token(w http.ResponseWriter, r *http.Request)
return
}
clientID := r.PostForm.Get("client_id")
if len(clientID) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("missing client_id"))
return
}
if auth.ClientID != clientID {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("client_id does not match client_id used to acquire code"))
return
}
clientAssertion := r.PostForm.Get("client_assertion")
if len(clientID) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("missing client_assertion"))
err := ip.validateClientAuthentication(w, r, auth.ClientID)
if err != nil {
w.Write([]byte(err.Error()))
return
}
@@ -353,34 +360,6 @@ func (ip *IdentityProviderHandler) Token(w http.ResponseWriter, r *http.Request)
return
}
clientJwk := ip.Config.Client().ClientJWK()
clientJwkSet := jwk.NewSet()
clientJwkSet.AddKey(clientJwk)
publicClientJwkSet, err := jwk.PublicSetOf(clientJwkSet)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("failed to create public client jwk set"))
return
}
opts := []jwt.ParseOption{
jwt.WithValidate(true),
jwt.WithKeySet(publicClientJwkSet),
jwt.WithIssuer(ip.Config.Client().ClientID()),
jwt.WithSubject(ip.Config.Client().ClientID()),
jwt.WithAudience(ip.Config.Provider().Issuer()),
}
_, err = jwt.Parse([]byte(clientAssertion), opts...)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
v := url.Values{}
v.Set("error", "Unauthenticated")
v.Set("error_description", "invalid client assertion")
v.Encode()
w.Write([]byte(fmt.Sprintf(v.Encode()+"%+v", err)))
return
}
codeVerifier := r.PostForm.Get("code_verifier")
if len(codeVerifier) == 0 {
w.WriteHeader(http.StatusBadRequest)
@@ -396,9 +375,8 @@ func (ip *IdentityProviderHandler) Token(w http.ResponseWriter, r *http.Request)
return
}
expires := int64(1200)
iat := time.Now().Truncate(time.Second)
exp := iat.Add(time.Duration(expires) * time.Second)
exp := iat.Add(ip.TokenDuration)
sub := uuid.New().String()
accessToken := jwt.New()
@@ -438,12 +416,21 @@ func (ip *IdentityProviderHandler) Token(w http.ResponseWriter, r *http.Request)
return
}
refreshToken := code + "some-refresh-token"
token := &tokenResponse{
AccessToken: signedAccessToken,
TokenType: "Bearer",
IDToken: signedIdToken,
RefreshToken: code + "some-refresh-token",
ExpiresIn: expires,
RefreshToken: refreshToken,
ExpiresIn: int64(ip.TokenDuration.Seconds()),
}
ip.RefreshTokens[refreshToken] = &RefreshTokenData{
ClientID: auth.ClientID,
RefreshToken: refreshToken,
OriginalIDToken: idToken,
SessionID: auth.SessionID,
}
w.Header().Set("content-type", "application/json")
@@ -451,6 +438,116 @@ func (ip *IdentityProviderHandler) Token(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(token)
}
func (ip *IdentityProviderHandler) RefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
refreshToken := r.PostForm.Get("refresh_token")
if len(refreshToken) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("missing refresh_token"))
return
}
data, ok := ip.RefreshTokens[refreshToken]
if !ok {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("no matching refresh_token"))
return
}
err := ip.validateClientAuthentication(w, r, data.ClientID)
if err != nil {
w.Write([]byte(err.Error()))
return
}
iat := time.Now().Truncate(time.Second)
exp := iat.Add(ip.TokenDuration)
sub := data.OriginalIDToken.Subject()
accessToken := jwt.New()
accessToken.Set("sub", sub)
accessToken.Set("iss", ip.Config.Provider().Issuer())
accessToken.Set("iat", iat.Unix())
accessToken.Set("exp", exp.Unix())
accessToken.Set("jti", uuid.NewString())
signedAccessToken, err := ip.signToken(accessToken)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("could not sign access token: " + err.Error()))
return
}
// remove provided refresh_token as it is now used
delete(ip.RefreshTokens, refreshToken)
// generate and store a new refresh_token
refreshToken = uuid.NewString() + "some-new-refresh-token"
token := &tokenResponse{
AccessToken: signedAccessToken,
TokenType: "Bearer",
RefreshToken: refreshToken,
ExpiresIn: int64(ip.TokenDuration.Seconds()),
}
ip.RefreshTokens[refreshToken] = &RefreshTokenData{
ClientID: data.ClientID,
RefreshToken: refreshToken,
OriginalIDToken: data.OriginalIDToken,
SessionID: data.SessionID,
}
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(token)
}
func (ip *IdentityProviderHandler) validateClientAuthentication(w http.ResponseWriter, r *http.Request, expectedClientID string) error {
clientID := r.PostForm.Get("client_id")
if len(clientID) == 0 {
w.WriteHeader(http.StatusBadRequest)
return fmt.Errorf("missing client_id")
}
if expectedClientID != clientID {
w.WriteHeader(http.StatusBadRequest)
return fmt.Errorf("client_id does not match client_id for original authorization")
}
clientAssertion := r.PostForm.Get("client_assertion")
if len(clientAssertion) == 0 {
w.WriteHeader(http.StatusBadRequest)
return fmt.Errorf("missing client_assertion")
}
clientJwk := ip.Config.Client().ClientJWK()
clientJwkSet := jwk.NewSet()
clientJwkSet.AddKey(clientJwk)
publicClientJwkSet, err := jwk.PublicSetOf(clientJwkSet)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return fmt.Errorf("failed to create public client jwk set")
}
opts := []jwt.ParseOption{
jwt.WithValidate(true),
jwt.WithKeySet(publicClientJwkSet),
jwt.WithIssuer(ip.Config.Client().ClientID()),
jwt.WithSubject(ip.Config.Client().ClientID()),
jwt.WithAudience(ip.Config.Provider().Issuer()),
}
_, err = jwt.Parse([]byte(clientAssertion), opts...)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
v := url.Values{}
v.Set("error", "Unauthenticated")
v.Set("error_description", "invalid client assertion")
v.Encode()
return fmt.Errorf("%s: %+v", v.Encode(), err)
}
return nil
}
func (ip *IdentityProviderHandler) EndSession(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
postLogoutRedirectURI := query.Get("post_logout_redirect_uri")
+59 -4
View File
@@ -2,8 +2,12 @@ package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
@@ -20,6 +24,8 @@ type Client interface {
config() openidconfig.Config
oAuth2Config() *oauth2.Config
SetHttpClient(c *http.Client)
Login(r *http.Request, loginstatus loginstatus.Loginstatus) (Login, error)
LoginCallback(r *http.Request, p provider.Provider, cookie *openid.LoginCookie) (LoginCallback, error)
Logout(r *http.Request) (Logout, error)
@@ -28,11 +34,12 @@ type Client interface {
AuthCodeGrant(ctx context.Context, code string, opts []oauth2.AuthCodeOption) (*oauth2.Token, error)
MakeAssertion(expiration time.Duration) (string, error)
RefreshGrant(r *http.Request) error
RefreshGrant(ctx context.Context, refreshToken string) (*openid.TokenResponse, error)
}
type client struct {
cfg openidconfig.Config
httpClient *http.Client
oauth2Config *oauth2.Config
}
@@ -49,6 +56,7 @@ func NewClient(cfg openidconfig.Config) Client {
return &client{
cfg: cfg,
httpClient: http.DefaultClient,
oauth2Config: oauth2Config,
}
}
@@ -61,6 +69,10 @@ func (c *client) oAuth2Config() *oauth2.Config {
return c.oauth2Config
}
func (c *client) SetHttpClient(httpClient *http.Client) {
c.httpClient = httpClient
}
func (c *client) Login(r *http.Request, loginstatus loginstatus.Loginstatus) (Login, error) {
login, err := NewLogin(c, r, loginstatus)
if err != nil {
@@ -132,7 +144,50 @@ func (c *client) MakeAssertion(expiration time.Duration) (string, error) {
return string(encoded), nil
}
func (c *client) RefreshGrant(r *http.Request) error {
//TODO implement me
panic("implement me")
func (c *client) RefreshGrant(ctx context.Context, refreshToken string) (*openid.TokenResponse, error) {
assertion, err := c.MakeAssertion(30 * time.Second)
if err != nil {
return nil, fmt.Errorf("creating client assertion: %w", err)
}
v := url.Values{}
v.Set(openid.GrantType, openid.RefreshTokenValue)
v.Set(openid.RefreshToken, refreshToken)
v.Set(openid.ClientID, c.config().Client().ClientID())
v.Set(openid.ClientAssertion, assertion)
v.Set(openid.ClientAssertionType, openid.ClientAssertionTypeJwtBearer)
r, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config().Provider().TokenEndpoint(), strings.NewReader(v.Encode()))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(r)
if err != nil {
return nil, fmt.Errorf("performing request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading server response: %w", err)
}
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
var errorResponse openid.TokenErrorResponse
if err := json.Unmarshal(body, &errorResponse); err != nil {
return nil, fmt.Errorf("client error: HTTP %d: unmarshalling error response: %w", resp.StatusCode, err)
}
return nil, fmt.Errorf("client error: HTTP %d: %s: %s", resp.StatusCode, errorResponse.Error, errorResponse.ErrorDescription)
} else if resp.StatusCode >= 500 {
return nil, fmt.Errorf("server error: HTTP %d: %s", resp.StatusCode, body)
}
var tokenResponse openid.TokenResponse
if err := json.Unmarshal(body, &tokenResponse); err != nil {
return nil, fmt.Errorf("unmarshalling token response: %w", err)
}
return &tokenResponse, nil
}
+1 -5
View File
@@ -13,10 +13,6 @@ import (
"github.com/nais/wonderwall/pkg/openid/provider"
)
const (
ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
)
type LoginCallback interface {
IdentityProviderError() error
StateMismatchError() error
@@ -79,7 +75,7 @@ func (in *loginCallback) RedeemTokens(ctx context.Context) (*openid.Tokens, erro
opts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam(openid.CodeVerifier, in.cookie.CodeVerifier),
oauth2.SetAuthURLParam(openid.ClientAssertion, clientAssertion),
oauth2.SetAuthURLParam(openid.ClientAssertionType, ClientAssertionTypeJwtBearer),
oauth2.SetAuthURLParam(openid.ClientAssertionType, openid.ClientAssertionTypeJwtBearer),
oauth2.SetAuthURLParam(openid.RedirectURI, in.cookie.RedirectURI),
}
+1
View File
@@ -132,6 +132,7 @@ func (in *client) Azure() Client {
func (in *azure) Scopes() scopes.Scopes {
return scopes.DefaultScopes().
WithAzureScope(in.OpenID.ClientID).
WithOfflineAccess().
WithAdditional(in.OpenID.Scopes...)
}
+3
View File
@@ -4,12 +4,14 @@ const (
ACRValues = "acr_values"
ClientAssertion = "client_assertion"
ClientAssertionType = "client_assertion_type"
ClientID = "client_id"
CodeChallenge = "code_challenge"
CodeChallengeMethod = "code_challenge_method"
Code = "code"
CodeVerifier = "code_verifier"
Error = "error"
ErrorDescription = "error_description"
GrantType = "grant_type"
IDTokenHint = "id_token_hint"
Nonce = "nonce"
PostLogoutRedirectURI = "post_logout_redirect_uri"
@@ -17,6 +19,7 @@ const (
Sid = "sid"
State = "state"
RedirectURI = "redirect_uri"
RefreshToken = "refresh_token"
Resource = "resource"
ResponseMode = "response_mode"
UILocales = "ui_locales"
+6
View File
@@ -0,0 +1,6 @@
package openid
const (
ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
RefreshTokenValue = "refresh_token"
)
+17
View File
@@ -0,0 +1,17 @@
package openid
// TokenResponse is the struct representing the HTTP response from OpenID Connect providers returning a token in
// JSON form.
type TokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
}
// TokenErrorResponse is the struct representing the HTTP error response returned from OpenID Connect providers
// in JSON form.
type TokenErrorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
+5
View File
@@ -7,6 +7,7 @@ import (
const (
OpenID = "openid"
OfflineAccess = "offline_access"
AzureAPITemplate = "api://%s/.default"
)
@@ -24,6 +25,10 @@ func (s Scopes) WithAzureScope(clientID string) Scopes {
return append(s, fmt.Sprintf(AzureAPITemplate, clientID))
}
func (s Scopes) WithOfflineAccess() Scopes {
return append(s, OfflineAccess)
}
func DefaultScopes() Scopes {
return []string{OpenID}
}
+2
View File
@@ -7,4 +7,6 @@ const (
Logout = "/logout"
LogoutCallback = "/logout/callback"
FrontChannelLogout = "/logout/frontchannel"
Session = "/session"
SessionRefresh = "/session/refresh"
)
+5
View File
@@ -36,6 +36,11 @@ func New(handler *handler.Handler) chi.Router {
r.Get(paths.Logout, handler.Logout)
r.Get(paths.FrontChannelLogout, handler.FrontChannelLogout)
r.Get(paths.LogoutCallback, handler.LogoutCallback)
if handler.Config.Session.Refresh {
r.Get(paths.Session, handler.SessionInfo)
r.Get(paths.SessionRefresh, handler.SessionRefresh)
}
})
}
})
+202
View File
@@ -0,0 +1,202 @@
package session
import (
"encoding"
"encoding/base64"
"encoding/json"
"time"
"github.com/nais/wonderwall/pkg/crypto"
"github.com/nais/wonderwall/pkg/openid"
)
const (
RefreshMinInterval = 1 * time.Minute
RefreshLeeway = 5 * time.Minute
)
type EncryptedData struct {
Data string `json:"data"`
}
var _ encoding.BinaryMarshaler = &EncryptedData{}
var _ encoding.BinaryUnmarshaler = &EncryptedData{}
func (in *EncryptedData) MarshalBinary() ([]byte, error) {
return json.Marshal(in)
}
func (in *EncryptedData) UnmarshalBinary(bytes []byte) error {
return json.Unmarshal(bytes, in)
}
func (in *EncryptedData) Decrypt(crypter crypto.Crypter) (*Data, error) {
ciphertext, err := base64.StdEncoding.DecodeString(in.Data)
if err != nil {
return nil, err
}
rawData, err := crypter.Decrypt(ciphertext)
if err != nil {
return nil, err
}
var data Data
err = json.Unmarshal(rawData, &data)
if err != nil {
return nil, err
}
return &data, nil
}
type Data struct {
ExternalSessionID string `json:"external_session_id"`
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
IDTokenJwtID string `json:"id_token_jwt_id"`
Metadata Metadata `json:"metadata"`
}
func NewData(externalSessionID string, tokens *openid.Tokens, metadata *Metadata) *Data {
data := &Data{
ExternalSessionID: externalSessionID,
AccessToken: tokens.AccessToken,
IDToken: tokens.IDToken.GetSerialized(),
IDTokenJwtID: tokens.IDToken.GetJwtID(),
RefreshToken: tokens.RefreshToken,
}
if metadata != nil {
data.Metadata = *metadata
}
return data
}
func (in *Data) Encrypt(crypter crypto.Crypter) (*EncryptedData, error) {
bytes, err := json.Marshal(in)
if err != nil {
return nil, err
}
ciphertext, err := crypter.Encrypt(bytes)
if err != nil {
return nil, err
}
return &EncryptedData{
Data: base64.StdEncoding.EncodeToString(ciphertext),
}, nil
}
func (in *Data) HasAccessToken() bool {
return len(in.AccessToken) > 0
}
func (in *Data) HasRefreshToken() bool {
return len(in.RefreshToken) > 0
}
type Metadata struct {
// SessionCreatedAt is the time when the session was created.
SessionCreatedAt time.Time `json:"session_created_at"`
// SessionEndsAt is the time when the session will end, i.e. the absolute lifetime/time-to-live for the session.
SessionEndsAt time.Time `json:"session_ends_at"`
// TokensExpireAt is the time when the tokens within the session expires.
TokensExpireAt time.Time `json:"tokens_expire_at"`
// TokensRefreshedAt is the time when the tokens within the session was refreshed.
TokensRefreshedAt time.Time `json:"tokens_refreshed_at"`
}
func NewMetadata(expiresIn time.Duration, endsIn time.Duration) *Metadata {
now := time.Now()
return &Metadata{
SessionCreatedAt: now,
SessionEndsAt: now.Add(endsIn),
TokensRefreshedAt: now,
TokensExpireAt: now.Add(expiresIn),
}
}
func (in *Metadata) NextRefresh() time.Time {
// subtract the leeway to ensure that we refresh before expiry
next := in.TokensExpireAt.Add(-RefreshLeeway)
// try to refresh at the first opportunity if the next refresh is in the past
if next.Before(time.Now()) {
return in.RefreshCooldown()
}
return next
}
func (in *Metadata) Refresh(nextExpirySeconds int64) {
now := time.Now()
in.TokensRefreshedAt = now
in.TokensExpireAt = now.Add(time.Duration(nextExpirySeconds) * time.Second)
}
func (in *Metadata) RefreshCooldown() time.Time {
refreshed := in.TokensRefreshedAt
tokenLifetime := in.TokenLifetime()
// if token lifetime is less than the minimum refresh interval * 2, we'll allow refreshes at the token half-life
if tokenLifetime <= RefreshMinInterval*2 {
return refreshed.Add(tokenLifetime / 2)
}
return refreshed.Add(RefreshMinInterval)
}
func (in *Metadata) RefreshOnCooldown() bool {
return time.Now().Before(in.RefreshCooldown())
}
func (in *Metadata) ShouldRefresh() bool {
if in.RefreshOnCooldown() {
return false
}
return time.Now().After(in.NextRefresh())
}
func (in *Metadata) TokenLifetime() time.Duration {
return in.TokensExpireAt.Sub(in.TokensRefreshedAt)
}
func (in *Metadata) Verbose() MetadataVerbose {
now := time.Now()
expireTime := in.TokensExpireAt
endTime := in.SessionEndsAt
nextRefreshTime := in.NextRefresh()
return MetadataVerbose{
Metadata: *in,
SessionEndsInSeconds: toSeconds(endTime.Sub(now)),
TokensExpireInSeconds: toSeconds(expireTime.Sub(now)),
TokensNextRefreshInSeconds: toSeconds(nextRefreshTime.Sub(now)),
TokensRefreshCooldown: in.RefreshOnCooldown(),
TokensRefreshCooldownSeconds: toSeconds(in.RefreshCooldown().Sub(now)),
}
}
type MetadataVerbose struct {
Metadata
SessionEndsInSeconds int64 `json:"session_ends_in_seconds"`
TokensExpireInSeconds int64 `json:"tokens_expire_in_seconds"`
TokensNextRefreshInSeconds int64 `json:"tokens_next_refresh_in_seconds"`
TokensRefreshCooldown bool `json:"tokens_refresh_cooldown"`
TokensRefreshCooldownSeconds int64 `json:"tokens_refresh_cooldown_seconds"`
}
func toSeconds(d time.Duration) int64 {
i := int64(d.Seconds())
if i <= 0 {
return 0
}
return i
}
+41
View File
@@ -0,0 +1,41 @@
package session_test
import (
"testing"
)
func TestData_HasAccessToken(t *testing.T) {
// TODO
}
func TestData_HasRefreshToken(t *testing.T) {
// TODO
}
func TestMetadata_NextRefresh(t *testing.T) {
// TODO
}
func TestMetadata_Refresh(t *testing.T) {
// TODO
}
func TestMetadata_RefreshCooldown(t *testing.T) {
// TODO
}
func TestMetadata_RefreshOnCooldown(t *testing.T) {
// TODO
}
func TestMetadata_ShouldRefresh(t *testing.T) {
// TODO
}
func TestMetadata_TokenAge(t *testing.T) {
// TODO
}
func TestMetadata_Verbose(t *testing.T) {
// TODO
}
+102 -28
View File
@@ -13,54 +13,59 @@ import (
"github.com/nais/wonderwall/pkg/config"
"github.com/nais/wonderwall/pkg/cookie"
"github.com/nais/wonderwall/pkg/crypto"
mw "github.com/nais/wonderwall/pkg/middleware"
"github.com/nais/wonderwall/pkg/openid"
openidclient "github.com/nais/wonderwall/pkg/openid/client"
openidconfig "github.com/nais/wonderwall/pkg/openid/config"
retrypkg "github.com/nais/wonderwall/pkg/retry"
"github.com/nais/wonderwall/pkg/strings"
)
var (
CookieNotFoundError = errors.New("cookie not found")
)
type Handler struct {
cfg *config.Config
openidCfg openidconfig.Config
crypter crypto.Crypter
store Store
client openidclient.Client
crypter crypto.Crypter
openidCfg openidconfig.Config
refreshEnabled bool
store Store
}
func NewHandler(cfg *config.Config, openidCfg openidconfig.Config, crypter crypto.Crypter) (*Handler, error) {
func NewHandler(cfg *config.Config, openidCfg openidconfig.Config, crypter crypto.Crypter, openidClient openidclient.Client) (*Handler, error) {
store, err := NewStore(cfg)
if err != nil {
return nil, err
}
return &Handler{
cfg: cfg,
crypter: crypter,
openidCfg: openidCfg,
store: store,
crypter: crypter,
client: openidClient,
openidCfg: openidCfg,
store: store,
refreshEnabled: cfg.Session.Refresh,
}, nil
}
// Create creates and stores a session in the Store, and returns the session's key.
func (h *Handler) Create(r *http.Request, tokens *openid.Tokens, expiresIn time.Duration) (string, error) {
func (h *Handler) Create(r *http.Request, tokens *openid.Tokens, sessionLifetime time.Duration) (string, error) {
externalSessionID, err := h.IDOrGenerate(r, tokens)
if err != nil {
return "", fmt.Errorf("generating session ID: %w", err)
}
key := h.Key(externalSessionID)
metadata := NewMetadata(time.Now().Add(expiresIn))
tokenExpiresIn := tokens.Expiry.Sub(time.Now())
metadata := NewMetadata(tokenExpiresIn, sessionLifetime)
encrypted, err := NewData(externalSessionID, tokens, metadata).Encrypt(h.crypter)
if err != nil {
return "", fmt.Errorf("encrypting session data: %w", err)
}
retryable := func(ctx context.Context) error {
err = h.store.Write(r.Context(), key, encrypted, expiresIn)
if err != nil {
return retry.RetryableError(err)
}
return nil
err = h.store.Write(r.Context(), key, encrypted, sessionLifetime)
return retry.RetryableError(err)
}
if err := retry.Do(r.Context(), retrypkg.DefaultBackoff, retryable); err != nil {
@@ -99,21 +104,12 @@ func (h *Handler) destroyForKey(r *http.Request, key string) error {
// Get returns the session data for a given http.Request, matching by the session cookie.
func (h *Handler) Get(r *http.Request) (*Data, error) {
key, err := cookie.GetDecrypted(r, cookie.Session, h.crypter)
key, err := h.GetKey(r)
if err != nil {
return nil, fmt.Errorf("no session cookie: %w", err)
}
sessionData, err := h.GetForKey(r, key)
if err == nil {
return sessionData, nil
}
if errors.Is(err, KeyNotFoundError) {
return nil, fmt.Errorf("session not found: %w", err)
}
return nil, err
return h.GetForKey(r, key)
}
// GetForID returns the session data for a given session ID.
@@ -152,6 +148,42 @@ func (h *Handler) GetForKey(r *http.Request, key string) (*Data, error) {
return sessionData, nil
}
// GetKey extracts the session Key from the session cookie found in the request, if any.
func (h *Handler) GetKey(r *http.Request) (string, error) {
key, err := cookie.GetDecrypted(r, cookie.Session, h.crypter)
if err != nil {
return "", fmt.Errorf("%w: %+v", CookieNotFoundError, err)
}
return key, nil
}
// GetOrRefresh returns the session data, performing refreshes if enabled and necessary.
func (h *Handler) GetOrRefresh(r *http.Request) (*Data, error) {
key, err := h.GetKey(r)
if err != nil {
return nil, err
}
sessionData, err := h.GetForKey(r, key)
if err != nil {
return nil, err
}
if !h.refreshEnabled || !sessionData.HasRefreshToken() || !sessionData.Metadata.ShouldRefresh() {
return sessionData, nil
}
refreshed, err := h.Refresh(r, key, sessionData)
if err != nil {
mw.LogEntryFrom(r).Warnf("session: could not refresh tokens, falling back to existing token: %+v", err)
} else {
sessionData = refreshed
}
return sessionData, nil
}
// IDOrGenerate returns the session ID, derived from the given request or id_token; e.g. `sid` or `session_state`.
// If none are present, a generated ID is returned.
func (h *Handler) IDOrGenerate(r *http.Request, tokens *openid.Tokens) (string, error) {
@@ -172,6 +204,48 @@ func (h *Handler) Key(sessionID string) string {
return fmt.Sprintf("%s:%s:%s", provider.Name(), client.ClientID(), sessionID)
}
// Refresh refreshes the user's session and returns the updated session data.
func (h *Handler) Refresh(r *http.Request, key string, data *Data) (*Data, error) {
if !h.refreshEnabled || !data.HasRefreshToken() || data.Metadata.RefreshOnCooldown() {
return data, nil
}
logger := mw.LogEntryFrom(r)
logger.Info("session: refreshing token...")
var resp *openid.TokenResponse
var err error
refresh := func(ctx context.Context) error {
resp, err = h.client.RefreshGrant(r.Context(), data.RefreshToken)
return retry.RetryableError(err)
}
if err := retry.Do(r.Context(), retrypkg.DefaultBackoff, refresh); err != nil {
return nil, fmt.Errorf("performing refresh: %w", err)
}
data.AccessToken = resp.AccessToken
data.RefreshToken = resp.RefreshToken
data.Metadata.Refresh(resp.ExpiresIn)
encrypted, err := data.Encrypt(h.crypter)
if err != nil {
return nil, fmt.Errorf("encrypting session data: %w", err)
}
update := func(ctx context.Context) error {
err = h.store.Update(r.Context(), key, encrypted)
return retry.RetryableError(err)
}
if err := retry.Do(r.Context(), retrypkg.DefaultBackoff, update); err != nil {
return nil, fmt.Errorf("updating in store: %w", err)
}
logger.Info("session: successfully refreshed")
return data, nil
}
func NewSessionID(cfg openidconfig.Provider, idToken *openid.IDToken, params url.Values) (string, error) {
// 1. check for 'sid' claim in id_token
sessionID, err := idToken.GetSidClaim()
-105
View File
@@ -1,105 +0,0 @@
package session
import (
"encoding"
"encoding/base64"
"encoding/json"
"time"
"github.com/nais/wonderwall/pkg/crypto"
"github.com/nais/wonderwall/pkg/openid"
)
type EncryptedData struct {
Data string `json:"data"`
}
var _ encoding.BinaryMarshaler = &EncryptedData{}
var _ encoding.BinaryUnmarshaler = &EncryptedData{}
func (in *EncryptedData) MarshalBinary() ([]byte, error) {
return json.Marshal(in)
}
func (in *EncryptedData) UnmarshalBinary(bytes []byte) error {
return json.Unmarshal(bytes, in)
}
func (in *EncryptedData) Decrypt(crypter crypto.Crypter) (*Data, error) {
ciphertext, err := base64.StdEncoding.DecodeString(in.Data)
if err != nil {
return nil, err
}
rawData, err := crypter.Decrypt(ciphertext)
if err != nil {
return nil, err
}
var data Data
err = json.Unmarshal(rawData, &data)
if err != nil {
return nil, err
}
return &data, nil
}
type Data struct {
ExternalSessionID string `json:"external_session_id"`
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
IDTokenJwtID string `json:"id_token_jwt_id"`
Metadata Metadata `json:"metadata"`
}
func NewData(externalSessionID string, tokens *openid.Tokens, metadata *Metadata) *Data {
data := &Data{
ExternalSessionID: externalSessionID,
AccessToken: tokens.AccessToken,
IDToken: tokens.IDToken.GetSerialized(),
IDTokenJwtID: tokens.IDToken.GetJwtID(),
RefreshToken: tokens.RefreshToken,
}
if metadata != nil {
data.Metadata = *metadata
}
return data
}
func (in *Data) Encrypt(crypter crypto.Crypter) (*EncryptedData, error) {
bytes, err := json.Marshal(in)
if err != nil {
return nil, err
}
ciphertext, err := crypter.Encrypt(bytes)
if err != nil {
return nil, err
}
return &EncryptedData{
Data: base64.StdEncoding.EncodeToString(ciphertext),
}, nil
}
type Metadata struct {
CreatedAt int64 `json:"created_at"`
RefreshedAt int64 `json:"refreshed_at"`
ExpiresAt int64 `json:"expires_at"`
}
func NewMetadata(expiresAt time.Time) *Metadata {
return &Metadata{
CreatedAt: time.Now().Unix(),
RefreshedAt: time.Now().Unix(),
ExpiresAt: expiresAt.Unix(),
}
}
func (in *Metadata) UpdateRefreshedAt() {
in.RefreshedAt = time.Now().Unix()
}
+1
View File
@@ -20,6 +20,7 @@ type Store interface {
Write(ctx context.Context, key string, value *EncryptedData, expiration time.Duration) error
Read(ctx context.Context, key string) (*EncryptedData, error)
Delete(ctx context.Context, keys ...string) error
Update(ctx context.Context, key string, value *EncryptedData) error
}
func NewStore(cfg *config.Config) (Store, error) {
+8
View File
@@ -50,3 +50,11 @@ func (s *memorySessionStore) Delete(_ context.Context, keys ...string) error {
return nil
}
func (s *memorySessionStore) Update(_ context.Context, key string, value *EncryptedData) error {
s.lock.Lock()
defer s.lock.Unlock()
s.sessions[key] = value
return nil
}
+12 -38
View File
@@ -1,57 +1,31 @@
package session_test
import (
"context"
"testing"
"time"
jwtlib "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nais/liberator/pkg/keygen"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/crypto"
"github.com/nais/wonderwall/pkg/openid"
"github.com/nais/wonderwall/pkg/session"
)
func TestMemory(t *testing.T) {
key, err := keygen.Keygen(32)
assert.NoError(t, err)
crypter := crypto.NewCrypter(key)
idToken := jwtlib.New()
idToken.Set("jti", "id-token-jti")
accessToken := "some-access-token"
refreshToken := "some-refresh-token"
tokens := &openid.Tokens{
AccessToken: accessToken,
IDToken: openid.NewIDToken("id_token", idToken),
RefreshToken: refreshToken,
}
metadata := session.NewMetadata(time.Now().Add(time.Hour))
data := session.NewData("myid", tokens, metadata)
crypter := makeCrypter(t)
data := makeData()
encryptedData, err := data.Encrypt(crypter)
assert.NoError(t, err)
sess := session.NewMemory()
err = sess.Write(context.Background(), "key", encryptedData, time.Minute)
assert.NoError(t, err)
store := session.NewMemory()
key := "key"
result, err := sess.Read(context.Background(), "key")
assert.NoError(t, err)
assert.Equal(t, encryptedData, result)
write(t, store, key, encryptedData)
decrypted, err := result.Decrypt(crypter)
assert.NoError(t, err)
assert.Equal(t, data, decrypted)
decrypted := read(t, store, key, encryptedData, crypter)
decryptedEqual(t, data, decrypted)
err = sess.Delete(context.Background(), "key")
data, encryptedData = update(t, store, key, data, crypter)
result, err = sess.Read(context.Background(), "key")
assert.Error(t, err)
assert.ErrorIs(t, err, session.KeyNotFoundError)
assert.Nil(t, result)
decrypted = read(t, store, key, encryptedData, crypter)
decryptedEqual(t, data, decrypted)
del(t, store, key)
}
+11
View File
@@ -64,3 +64,14 @@ func (s *redisSessionStore) Delete(ctx context.Context, keys ...string) error {
return fmt.Errorf("%w: %s", UnexpectedError, err.Error())
}
func (s *redisSessionStore) Update(ctx context.Context, key string, value *EncryptedData) error {
err := metrics.ObserveRedisLatency(metrics.RedisOperationWrite, func() error {
return s.client.Set(ctx, key, value, redis.KeepTTL).Err()
})
if err != nil {
return fmt.Errorf("%w: %s", UnexpectedError, err.Error())
}
return nil
}
+12 -38
View File
@@ -1,40 +1,18 @@
package session_test
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
jwtlib "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nais/liberator/pkg/keygen"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/crypto"
"github.com/nais/wonderwall/pkg/openid"
"github.com/nais/wonderwall/pkg/session"
)
func TestRedis(t *testing.T) {
key, err := keygen.Keygen(32)
assert.NoError(t, err)
crypter := crypto.NewCrypter(key)
idToken := jwtlib.New()
idToken.Set("jti", "id-token-jti")
accessToken := "some-access-token"
refreshToken := "some-refresh-token"
tokens := &openid.Tokens{
AccessToken: accessToken,
IDToken: openid.NewIDToken("id_token", idToken),
RefreshToken: refreshToken,
}
metadata := session.NewMetadata(time.Now().Add(time.Hour))
data := session.NewData("myid", tokens, metadata)
crypter := makeCrypter(t)
data := makeData()
encryptedData, err := data.Encrypt(crypter)
assert.NoError(t, err)
@@ -49,22 +27,18 @@ func TestRedis(t *testing.T) {
Addr: s.Addr(),
})
sess := session.NewRedis(client)
err = sess.Write(context.Background(), "key", encryptedData, time.Minute)
assert.NoError(t, err)
store := session.NewRedis(client)
key := "key"
result, err := sess.Read(context.Background(), "key")
assert.NoError(t, err)
assert.Equal(t, encryptedData, result)
write(t, store, key, encryptedData)
decrypted, err := result.Decrypt(crypter)
assert.NoError(t, err)
assert.Equal(t, data, decrypted)
decrypted := read(t, store, key, encryptedData, crypter)
decryptedEqual(t, data, decrypted)
err = sess.Delete(context.Background(), "key")
data, encryptedData = update(t, store, key, data, crypter)
result, err = sess.Read(context.Background(), "key")
assert.Error(t, err)
assert.ErrorIs(t, err, session.KeyNotFoundError)
assert.Nil(t, result)
decrypted = read(t, store, key, encryptedData, crypter)
decryptedEqual(t, data, decrypted)
del(t, store, key)
}
+91
View File
@@ -0,0 +1,91 @@
package session_test
import (
"context"
"testing"
"time"
jwtlib "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nais/liberator/pkg/keygen"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/crypto"
"github.com/nais/wonderwall/pkg/openid"
"github.com/nais/wonderwall/pkg/session"
)
func decryptedEqual(t *testing.T, expected, actual *session.Data) {
assert.Equal(t, expected.AccessToken, actual.AccessToken)
assert.Equal(t, expected.RefreshToken, actual.RefreshToken)
assert.Equal(t, expected.IDToken, actual.IDToken)
assert.Equal(t, expected.IDTokenJwtID, actual.IDTokenJwtID)
assert.Equal(t, expected.ExternalSessionID, actual.ExternalSessionID)
assert.WithinDuration(t, expected.Metadata.SessionCreatedAt, actual.Metadata.SessionCreatedAt, 0)
assert.WithinDuration(t, expected.Metadata.SessionEndsAt, actual.Metadata.SessionEndsAt, 0)
assert.WithinDuration(t, expected.Metadata.TokensExpireAt, actual.Metadata.TokensExpireAt, 0)
assert.WithinDuration(t, expected.Metadata.TokensRefreshedAt, actual.Metadata.TokensRefreshedAt, 0)
}
func makeCrypter(t *testing.T) crypto.Crypter {
key, err := keygen.Keygen(32)
assert.NoError(t, err)
return crypto.NewCrypter(key)
}
func makeData() *session.Data {
idToken := jwtlib.New()
idToken.Set("jti", "id-token-jti")
accessToken := "some-access-token"
refreshToken := "some-refresh-token"
tokens := &openid.Tokens{
AccessToken: accessToken,
IDToken: openid.NewIDToken("id_token", idToken),
RefreshToken: refreshToken,
}
expiresIn := time.Hour
endsIn := time.Hour
metadata := session.NewMetadata(expiresIn, endsIn)
return session.NewData("myid", tokens, metadata)
}
func write(t *testing.T, store session.Store, key string, value *session.EncryptedData) {
err := store.Write(context.Background(), key, value, time.Minute)
assert.NoError(t, err)
}
func read(t *testing.T, store session.Store, key string, encrypted *session.EncryptedData, crypter crypto.Crypter) *session.Data {
result, err := store.Read(context.Background(), key)
assert.NoError(t, err)
assert.Equal(t, encrypted, result)
decrypted, err := result.Decrypt(crypter)
assert.NoError(t, err)
return decrypted
}
func update(t *testing.T, store session.Store, key string, data *session.Data, crypter crypto.Crypter) (*session.Data, *session.EncryptedData) {
data.AccessToken = "new-access-token"
data.RefreshToken = "new-refresh-token"
encryptedData, err := data.Encrypt(crypter)
assert.NoError(t, err)
err = store.Update(context.Background(), key, encryptedData)
assert.NoError(t, err)
return data, encryptedData
}
func del(t *testing.T, store session.Store, key string) {
err := store.Delete(context.Background(), key)
result, err := store.Read(context.Background(), key)
assert.Error(t, err)
assert.ErrorIs(t, err, session.KeyNotFoundError)
assert.Nil(t, result)
}