git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge branch 'master' into bumpoidc
commit78feaec6faab346ab143bf350f0cfd0e8166b992 authorJoel Speed <Joel.speed@hotmail.co.uk> dateThu 6 Jun 2019, 17:38 (7 years ago) parentb6c60f555a853c
11 files changed+664−52
CHANGELOG.md+10 Gopkg.lock+61 Gopkg.toml+8 docs/configuration/configuration.md+4 docs/configuration/sessions.md+33 main.go+5 pkg/apis/options/sessions.go+13 pkg/apis/sessions/session_state.go+2−2 pkg/sessions/redis/redis_store.go+305 pkg/sessions/session_store.go+3 pkg/sessions/session_store_test.go+220−50
CHANGELOG.md+10
@@ -16,6 +16,16 @@
1616
1717- [#175](https://github.com/pusher/outh2_proxy/pull/175) Bump go-oidc to v2.0.0 (@aeijdenberg).
1818 - Includes fix for potential signature checking issue when OIDC discovery is skipped.
19+- [#155](https://github.com/pusher/outh2_proxy/pull/155) Add RedisSessionStore implementation (@brianv0, @JoelSpeed)
20+ - Implement flags to configure the redis session store
21+ - `-session-store-type=redis` Sets the store type to redis
22+ - `-redis-connection-url` Sets the Redis connection URL
23+ - `-redis-use-sentinel=true` Enables Redis Sentinel support
24+ - `-redis-sentinel-master-name` Sets the Sentinel master name, if sentinel is enabled
25+ - `-redis-sentinel-connection-urls` Defines the Redis Sentinel Connection URLs, if sentinel is enabled
26+ - Introduces the concept of a session ticket. Tickets are composed of the cookie name, a session ID, and a secret.
27+ - Redis Sessions are stored encrypted with a per-session secret
28+ - Added tests for server based session stores
1929- [#168](https://github.com/pusher/outh2_proxy/pull/168) Drop Go 1.11 support in Travis (@JoelSpeed)
2030- [#169](https://github.com/pusher/outh2_proxy/pull/169) Update Alpine to 3.9 (@kskewes)
2131- [#148](https://github.com/pusher/outh2_proxy/pull/148) Implement SessionStore interface within proxy (@JoelSpeed)
Gopkg.lock+61
@@ -17,6 +17,25 @@
1717 revision = "b26d9c308763d68093482582cea63d69be07a0f0"
1818 version = "v0.3.0"
1919
20+[[projects]]
21+ branch = "master"
22+ digest = "1:3cce78d5d0090e3f1162945fba60ba74e72e8422e8e41bb9c701afb67237bb65"
23+ name = "github.com/alicebob/gopher-json"
24+ packages = ["."]
25+ pruneopts = ""
26+ revision = "5a6b3ba71ee69b77cf64febf8b5a7526ca5eaef0"
27+
28+[[projects]]
29+ digest = "1:18a07506ddaa87b1612bfd69eef03f510faf122398df3da774d46dcfe751a060"
30+ name = "github.com/alicebob/miniredis"
31+ packages = [
32+ ".",
33+ "server",
34+ ]
35+ pruneopts = ""
36+ revision = "3d7aa1333af56ab862d446678d93aaa6803e0938"
37+ version = "v2.7.0"
38+
2039[[projects]]
2140 digest = "1:512883404c2a99156e410e9880e3bb35ecccc0c07c1159eb204b5f3ef3c431b3"
2241 name = "github.com/bitly/go-simplejson"
@@ -49,6 +68,22 @@
4968 revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e"
5069 version = "v3.2.0"
5170
71+[[projects]]
72+ digest = "1:8c7410dae63c74bd92db09bf33af7e0698b635ab6a397fd8e9e10dfcce3138ac"
73+ name = "github.com/go-redis/redis"
74+ packages = [
75+ ".",
76+ "internal",
77+ "internal/consistenthash",
78+ "internal/hashtag",
79+ "internal/pool",
80+ "internal/proto",
81+ "internal/util",
82+ ]
83+ pruneopts = ""
84+ revision = "d22fde8721cc915a55aeb6b00944a76a92bfeb6e"
85+ version = "v6.15.2"
86+
5287[[projects]]
5388 branch = "master"
5489 digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4"
@@ -57,6 +92,17 @@
5792 pruneopts = ""
5893 revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845"
5994
95+[[projects]]
96+ digest = "1:dcf8316121302735c0ac84e05f4686e3b34e284444435e9a206da48d8be18cb1"
97+ name = "github.com/gomodule/redigo"
98+ packages = [
99+ "internal",
100+ "redis",
101+ ]
102+ pruneopts = ""
103+ revision = "9c11da706d9b7902c6da69c592f75637793fe121"
104+ version = "v2.0.0"
105+
60106[[projects]]
61107 digest = "1:b3c5b95e56c06f5aa72cb2500e6ee5f44fcd122872d4fec2023a488e561218bc"
62108 name = "github.com/hpcloud/tail"
@@ -173,6 +219,19 @@
173219 pruneopts = ""
174220 revision = "1d66fa95c997864ba4d8479f56609620fe542928"
175221
222+[[projects]]
223+ branch = "master"
224+ digest = "1:378d29a839ff770e9d9150580b4c01ff0a513a296b0487558a7af7c18adab98e"
225+ name = "github.com/yuin/gopher-lua"
226+ packages = [
227+ ".",
228+ "ast",
229+ "parse",
230+ "pm",
231+ ]
232+ pruneopts = ""
233+ revision = "8bfc7677f583b35a5663a9dd934c08f3b5774bbb"
234+
176235[[projects]]
177236 branch = "master"
178237 digest = "1:f6a006d27619a4d93bf9b66fe1999b8c8d1fa62bdc63af14f10fbe6fcaa2aa1a"
@@ -341,9 +400,11 @@
341400 analyzer-version = 1
342401 input-imports = [
343402 "github.com/BurntSushi/toml",
403+ "github.com/alicebob/miniredis",
344404 "github.com/bitly/go-simplejson",
345405 "github.com/coreos/go-oidc",
346406 "github.com/dgrijalva/jwt-go",
407+ "github.com/go-redis/redis",
347408 "github.com/mbland/hmacauth",
348409 "github.com/mreiferson/go-options",
349410 "github.com/onsi/ginkgo",
Gopkg.toml+8
@@ -46,3 +46,11 @@
4646[[constraint]]
4747 name = "gopkg.in/natefinch/lumberjack.v2"
4848 version = "2.1.0"
49+
50+[[constraint]]
51+ name = "github.com/go-redis/redis"
52+ version = "v6.15.2"
53+
54+[[constraint]]
55+ name = "github.com/alicebob/miniredis"
56+ version = "2.7.0"
docs/configuration/configuration.md+4
@@ -75,6 +75,10 @@ Usage of oauth2_proxy:
7575 -pubjwk-url string: JWK pubkey access endpoint: required by login.gov
7676 -redeem-url string: Token redemption endpoint
7777 -redirect-url string: the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback"
78+ -redis-connection-url string: URL of redis server for redis session storage (eg: redis://HOST[:PORT])
79+ -redis-sentinel-master-name string: Redis sentinel master name. Used in conjuction with --redis-use-sentinel
80+ -redis-sentinel-connection-urls: List of Redis sentinel conneciton URLs (eg redis://HOST[:PORT]). Used in conjuction with --redis-use-sentinel
81+ -redis-use-sentinel: Connect to redis via sentinels. Must set --redis-sentinel-master-name and --redis-sentinel-connection-urls to use this feature (default: false)
7882 -request-logging: Log requests to stdout (default true)
7983 -request-logging-format: Template for request log lines (see "Logging Configuration" paragraph below)
8084 -resource string: The resource that is protected (Azure AD only)
docs/configuration/sessions.md+33
@@ -16,6 +16,7 @@ data in one of the available session storage backends.
1616
1717At present the available backends are (as passed to `--session-store-type`):
1818- [cookie](cookie-storage) (default)
19+- [redis](redis-storage)
1920
2021### Cookie Storage
2122
@@ -32,3 +33,35 @@ The following should be known when using this implementation:
3233- Since multiple requests can be made concurrently to the OAuth2 Proxy, this session implementation
3334cannot lock sessions and while updating and refreshing sessions, there can be conflicts which force
3435users to re-authenticate
36+
37+
38+### Redis Storage
39+
40+The Redis Storage backend stores sessions, encrypted, in redis. Instead sending all the information
41+back the the client for storage, as in the [Cookie storage](cookie-storage), a ticket is sent back
42+to the user as the cookie value instead.
43+
44+A ticket is composed as the following:
45+
46+`{CookieName}-{ticketID}.{secret}`
47+
48+Where:
49+
50+- The `CookieName` is the OAuth2 cookie name (_oauth2_proxy by default)
51+- The `ticketID` is a 128 bit random number, hex-encoded
52+- The `secret` is a 128 bit random number, base64url encoded (no padding). The secret is unique for every session.
53+- The pair of `{CookieName}-{ticketID}` comprises a ticket handle, and thus, the redis key
54+to which the session is stored. The encoded session is encrypted with the secret and stored
55+in redis via the `SETEX` command.
56+
57+Encrypting every session uniquely protects the refresh/access/id tokens stored in the session from
58+disclosure.
59+
60+#### Usage
61+
62+When using the redis store, specify `--session-store-type=redis` as well as the Redis connection URL, via
63+`--redis-connection-url=redis://host[:port][/db-number]`.
64+
65+You may also configure the store for Redis Sentinel. In this case, you will want to use the
66+`--redis-use-sentinel=true` flag, as well as configure the flags `--redis-sentinel-master-name`
67+and `--redis-sentinel-connection-urls` appropriately.
main.go+5
@@ -24,6 +24,7 @@ func main() {
2424 upstreams := StringArray{}
2525 skipAuthRegex := StringArray{}
2626 googleGroups := StringArray{}
27+ redisSentinelConnectionURLs := StringArray{}
2728
2829 config := flagSet.String("config", "", "path to config file")
2930 showVersion := flagSet.Bool("version", false, "print version string")
@@ -76,6 +77,10 @@ func main() {
7677 flagSet.Bool("cookie-httponly", true, "set HttpOnly cookie flag")
7778
7879 flagSet.String("session-store-type", "cookie", "the session storage provider to use")
80+ flagSet.String("redis-connection-url", "", "URL of redis server for redis session storage (eg: redis://HOST[:PORT])")
81+ flagSet.Bool("redis-use-sentinel", false, "Connect to redis via sentinels. Must set --redis-sentinel-master-name and --redis-sentinel-connection-urls to use this feature")
82+ flagSet.String("redis-sentinel-master-name", "", "Redis sentinel master name. Used in conjuction with --redis-use-sentinel")
83+ flagSet.Var(&redisSentinelConnectionURLs, "redis-sentinel-connection-urls", "List of Redis sentinel connection URLs (eg redis://HOST[:PORT]). Used in conjuction with --redis-use-sentinel")
7984
8085 flagSet.String("logging-filename", "", "File to log requests to, empty for stdout")
8186 flagSet.Int("logging-max-size", 100, "Maximum size in megabytes of the log file before rotation")
pkg/apis/options/sessions.go+13
@@ -9,6 +9,7 @@ type SessionOptions struct {
99 Type string `flag:"session-store-type" cfg:"session_store_type" env:"OAUTH2_PROXY_SESSION_STORE_TYPE"`
1010 Cipher *cookie.Cipher
1111 CookieStoreOptions
12+ RedisStoreOptions
1213}
1314
1415// CookieSessionStoreType is used to indicate the CookieSessionStore should be
@@ -17,3 +18,15 @@ var CookieSessionStoreType = "cookie"
1718
1819// CookieStoreOptions contains configuration options for the CookieSessionStore.
1920type CookieStoreOptions struct{}
21+
22+// RedisSessionStoreType is used to indicate the RedisSessionStore should be
23+// used for storing sessions.
24+var RedisSessionStoreType = "redis"
25+
26+// RedisStoreOptions contains configuration options for the RedisSessionStore.
27+type RedisStoreOptions struct {
28+ RedisConnectionURL string `flag:"redis-connection-url" cfg:"redis_connection_url" env:"OAUTH2_PROXY_REDIS_CONNECTION_URL"`
29+ UseSentinel bool `flag:"redis-use-sentinel" cfg:"redis_use_sentinel" env:"OAUTH2_PROXY_REDIS_USE_SENTINEL"`
30+ SentinelMasterName string `flag:"redis-sentinel-master-name" cfg:"redis_sentinel_master_name" env:"OAUTH2_PROXY_REDIS_SENTINEL_MASTER_NAME"`
31+ SentinelConnectionURLs []string `flag:"redis-sentinel-connection-urls" cfg:"redis_sentinel_connection_urls" env:"OAUTH2_PROXY_REDIS_SENTINEL_CONNECTION_URLS"`
32+}
pkg/apis/sessions/session_state.go+2−2
@@ -203,14 +203,14 @@ func DecodeSessionState(v string, c *cookie.Cipher) (*SessionState, error) {
203203 User: ss.User,
204204 }
205205 } else {
206− // Backward compatibility with using unecrypted Email
206+ // Backward compatibility with using unencrypted Email
207207 if ss.Email != "" {
208208 decryptedEmail, errEmail := c.Decrypt(ss.Email)
209209 if errEmail == nil {
210210 ss.Email = decryptedEmail
211211 }
212212 }
213− // Backward compatibility with using unecrypted User
213+ // Backward compatibility with using unencrypted User
214214 if ss.User != "" {
215215 decryptedUser, errUser := c.Decrypt(ss.User)
216216 if errUser == nil {
pkg/sessions/redis/redis_store.gonew file+305
@@ -0,0 +1,305 @@
1+package redis
2+
3+import (
4+ "crypto/aes"
5+ "crypto/cipher"
6+ "crypto/rand"
7+ "encoding/base64"
8+ "encoding/hex"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "strings"
13+ "time"
14+
15+ "github.com/go-redis/redis"
16+ "github.com/pusher/oauth2_proxy/cookie"
17+ "github.com/pusher/oauth2_proxy/pkg/apis/options"
18+ "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
19+ "github.com/pusher/oauth2_proxy/pkg/cookies"
20+)
21+
22+// TicketData is a structure representing the ticket used in server session storage
23+type TicketData struct {
24+ TicketID string
25+ Secret []byte
26+}
27+
28+// SessionStore is an implementation of the sessions.SessionStore
29+// interface that stores sessions in redis
30+type SessionStore struct {
31+ CookieCipher *cookie.Cipher
32+ CookieOptions *options.CookieOptions
33+ Client *redis.Client
34+}
35+
36+// NewRedisSessionStore initialises a new instance of the SessionStore from
37+// the configuration given
38+func NewRedisSessionStore(opts *options.SessionOptions, cookieOpts *options.CookieOptions) (sessions.SessionStore, error) {
39+ client, err := newRedisClient(opts.RedisStoreOptions)
40+ if err != nil {
41+ return nil, fmt.Errorf("error constructing redis client: %v", err)
42+ }
43+
44+ rs := &SessionStore{
45+ Client: client,
46+ CookieCipher: opts.Cipher,
47+ CookieOptions: cookieOpts,
48+ }
49+ return rs, nil
50+
51+}
52+
53+func newRedisClient(opts options.RedisStoreOptions) (*redis.Client, error) {
54+ if opts.UseSentinel {
55+ client := redis.NewFailoverClient(&redis.FailoverOptions{
56+ MasterName: opts.SentinelMasterName,
57+ SentinelAddrs: opts.SentinelConnectionURLs,
58+ })
59+ return client, nil
60+ }
61+
62+ opt, err := redis.ParseURL(opts.RedisConnectionURL)
63+ if err != nil {
64+ return nil, fmt.Errorf("unable to parse redis url: %s", err)
65+ }
66+
67+ client := redis.NewClient(opt)
68+ return client, nil
69+}
70+
71+// Save takes a sessions.SessionState and stores the information from it
72+// to redies, and adds a new ticket cookie on the HTTP response writer
73+func (store *SessionStore) Save(rw http.ResponseWriter, req *http.Request, s *sessions.SessionState) error {
74+ if s.CreatedAt.IsZero() {
75+ s.CreatedAt = time.Now()
76+ }
77+
78+ // Old sessions that we are refreshing would have a request cookie
79+ // New sessions don't, so we ignore the error. storeValue will check requestCookie
80+ requestCookie, _ := req.Cookie(store.CookieOptions.CookieName)
81+ value, err := s.EncodeSessionState(store.CookieCipher)
82+ if err != nil {
83+ return err
84+ }
85+ ticketString, err := store.storeValue(value, store.CookieOptions.CookieExpire, requestCookie)
86+ if err != nil {
87+ return err
88+ }
89+
90+ ticketCookie := store.makeCookie(
91+ req,
92+ ticketString,
93+ store.CookieOptions.CookieExpire,
94+ s.CreatedAt,
95+ )
96+
97+ http.SetCookie(rw, ticketCookie)
98+ return nil
99+}
100+
101+// Load reads sessions.SessionState information from a ticket
102+// cookie within the HTTP request object
103+func (store *SessionStore) Load(req *http.Request) (*sessions.SessionState, error) {
104+ requestCookie, err := req.Cookie(store.CookieOptions.CookieName)
105+ if err != nil {
106+ return nil, fmt.Errorf("error loading session: %s", err)
107+ }
108+
109+ val, _, ok := cookie.Validate(requestCookie, store.CookieOptions.CookieSecret, store.CookieOptions.CookieExpire)
110+ if !ok {
111+ return nil, fmt.Errorf("Cookie Signature not valid")
112+ }
113+ session, err := store.loadSessionFromString(val)
114+ if err != nil {
115+ return nil, fmt.Errorf("error loading session: %s", err)
116+ }
117+ return session, nil
118+}
119+
120+// loadSessionFromString loads the session based on the ticket value
121+func (store *SessionStore) loadSessionFromString(value string) (*sessions.SessionState, error) {
122+ ticket, err := decodeTicket(store.CookieOptions.CookieName, value)
123+ if err != nil {
124+ return nil, err
125+ }
126+
127+ result, err := store.Client.Get(ticket.asHandle(store.CookieOptions.CookieName)).Result()
128+ if err != nil {
129+ return nil, err
130+ }
131+
132+ resultBytes := []byte(result)
133+ block, err := aes.NewCipher(ticket.Secret)
134+ if err != nil {
135+ return nil, err
136+ }
137+ // Use secret as the IV too, because each entry has it's own key
138+ stream := cipher.NewCFBDecrypter(block, ticket.Secret)
139+ stream.XORKeyStream(resultBytes, resultBytes)
140+
141+ session, err := sessions.DecodeSessionState(string(resultBytes), store.CookieCipher)
142+ if err != nil {
143+ return nil, err
144+ }
145+ return session, nil
146+}
147+
148+// Clear clears any saved session information for a given ticket cookie
149+// from redis, and then clears the session
150+func (store *SessionStore) Clear(rw http.ResponseWriter, req *http.Request) error {
151+ // We go ahead and clear the cookie first, always.
152+ clearCookie := store.makeCookie(
153+ req,
154+ "",
155+ time.Hour*-1,
156+ time.Now(),
157+ )
158+ http.SetCookie(rw, clearCookie)
159+
160+ // If there was an existing cookie we should clear the session in redis
161+ requestCookie, err := req.Cookie(store.CookieOptions.CookieName)
162+ if err != nil && err == http.ErrNoCookie {
163+ // No existing cookie so can't clear redis
164+ return nil
165+ } else if err != nil {
166+ return fmt.Errorf("error retrieving cookie: %v", err)
167+ }
168+
169+ val, _, ok := cookie.Validate(requestCookie, store.CookieOptions.CookieSecret, store.CookieOptions.CookieExpire)
170+ if !ok {
171+ return fmt.Errorf("Cookie Signature not valid")
172+ }
173+
174+ // We only return an error if we had an issue with redis
175+ // If there's an issue decoding the ticket, ignore it
176+ ticket, _ := decodeTicket(store.CookieOptions.CookieName, val)
177+ if ticket != nil {
178+ _, err := store.Client.Del(ticket.asHandle(store.CookieOptions.CookieName)).Result()
179+ if err != nil {
180+ return fmt.Errorf("error clearing cookie from redis: %s", err)
181+ }
182+ }
183+ return nil
184+}
185+
186+// makeCookie makes a cookie, signing the value if present
187+func (store *SessionStore) makeCookie(req *http.Request, value string, expires time.Duration, now time.Time) *http.Cookie {
188+ if value != "" {
189+ value = cookie.SignedValue(store.CookieOptions.CookieSecret, store.CookieOptions.CookieName, value, now)
190+ }
191+ return cookies.MakeCookieFromOptions(
192+ req,
193+ store.CookieOptions.CookieName,
194+ value,
195+ store.CookieOptions,
196+ expires,
197+ now,
198+ )
199+}
200+
201+func (store *SessionStore) storeValue(value string, expiration time.Duration, requestCookie *http.Cookie) (string, error) {
202+ ticket, err := store.getTicket(requestCookie)
203+ if err != nil {
204+ return "", fmt.Errorf("error getting ticket: %v", err)
205+ }
206+
207+ ciphertext := make([]byte, len(value))
208+ block, err := aes.NewCipher(ticket.Secret)
209+ if err != nil {
210+ return "", fmt.Errorf("error initiating cipher block %s", err)
211+ }
212+
213+ // Use secret as the Initialization Vector too, because each entry has it's own key
214+ stream := cipher.NewCFBEncrypter(block, ticket.Secret)
215+ stream.XORKeyStream(ciphertext, []byte(value))
216+
217+ handle := ticket.asHandle(store.CookieOptions.CookieName)
218+ err = store.Client.Set(handle, ciphertext, expiration).Err()
219+ if err != nil {
220+ return "", err
221+ }
222+ return ticket.encodeTicket(store.CookieOptions.CookieName), nil
223+}
224+
225+// getTicket retrieves an existing ticket from the cookie if present,
226+// or creates a new ticket
227+func (store *SessionStore) getTicket(requestCookie *http.Cookie) (*TicketData, error) {
228+ if requestCookie == nil {
229+ return newTicket()
230+ }
231+
232+ // An existing cookie exists, try to retrieve the ticket
233+ val, _, ok := cookie.Validate(requestCookie, store.CookieOptions.CookieSecret, store.CookieOptions.CookieExpire)
234+ if !ok {
235+ // Cookie is invalid, create a new ticket
236+ return newTicket()
237+ }
238+
239+ // Valid cookie, decode the ticket
240+ ticket, err := decodeTicket(store.CookieOptions.CookieName, val)
241+ if err != nil {
242+ // If we can't decode the ticket we have to create a new one
243+ return newTicket()
244+ }
245+ return ticket, nil
246+}
247+
248+func newTicket() (*TicketData, error) {
249+ rawID := make([]byte, 16)
250+ if _, err := io.ReadFull(rand.Reader, rawID); err != nil {
251+ return nil, fmt.Errorf("failed to create new ticket ID %s", err)
252+ }
253+ // ticketID is hex encoded
254+ ticketID := fmt.Sprintf("%x", rawID)
255+
256+ secret := make([]byte, aes.BlockSize)
257+ if _, err := io.ReadFull(rand.Reader, secret); err != nil {
258+ return nil, fmt.Errorf("failed to create initialization vector %s", err)
259+ }
260+ ticket := &TicketData{
261+ TicketID: ticketID,
262+ Secret: secret,
263+ }
264+ return ticket, nil
265+}
266+
267+func (ticket *TicketData) asHandle(prefix string) string {
268+ return fmt.Sprintf("%s-%s", prefix, ticket.TicketID)
269+}
270+
271+func decodeTicket(cookieName string, ticketString string) (*TicketData, error) {
272+ prefix := cookieName + "-"
273+ if !strings.HasPrefix(ticketString, prefix) {
274+ return nil, fmt.Errorf("failed to decode ticket handle")
275+ }
276+ trimmedTicket := strings.TrimPrefix(ticketString, prefix)
277+
278+ ticketParts := strings.Split(trimmedTicket, ".")
279+ if len(ticketParts) != 2 {
280+ return nil, fmt.Errorf("failed to decode ticket")
281+ }
282+ ticketID, secretBase64 := ticketParts[0], ticketParts[1]
283+
284+ // ticketID must be a hexadecimal string
285+ _, err := hex.DecodeString(ticketID)
286+ if err != nil {
287+ return nil, fmt.Errorf("server ticket failed sanity checks")
288+ }
289+
290+ secret, err := base64.RawURLEncoding.DecodeString(secretBase64)
291+ if err != nil {
292+ return nil, fmt.Errorf("failed to decode initialization vector %s", err)
293+ }
294+ ticketData := &TicketData{
295+ TicketID: ticketID,
296+ Secret: secret,
297+ }
298+ return ticketData, nil
299+}
300+
301+func (ticket *TicketData) encodeTicket(prefix string) string {
302+ handle := ticket.asHandle(prefix)
303+ ticketString := handle + "." + base64.RawURLEncoding.EncodeToString(ticket.Secret)
304+ return ticketString
305+}
pkg/sessions/session_store.go+3
@@ -6,6 +6,7 @@ import (
66 "github.com/pusher/oauth2_proxy/pkg/apis/options"
77 "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
88 "github.com/pusher/oauth2_proxy/pkg/sessions/cookie"
9+ "github.com/pusher/oauth2_proxy/pkg/sessions/redis"
910)
1011
1112// NewSessionStore creates a SessionStore from the provided configuration
@@ -13,6 +14,8 @@ func NewSessionStore(opts *options.SessionOptions, cookieOpts *options.CookieOpt
1314 switch opts.Type {
1415 case options.CookieSessionStoreType:
1516 return cookie.NewCookieSessionStore(opts, cookieOpts)
17+ case options.RedisSessionStoreType:
18+ return redis.NewRedisSessionStore(opts, cookieOpts)
1619 default:
1720 return nil, fmt.Errorf("unknown session store type '%s'", opts.Type)
1821 }
pkg/sessions/session_store_test.go+220−50
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/alicebob/miniredis"
1314 . "github.com/onsi/ginkgo"
1415 . "github.com/onsi/gomega"
1516 "github.com/pusher/oauth2_proxy/cookie"
@@ -18,6 +19,7 @@ import (
1819 "github.com/pusher/oauth2_proxy/pkg/cookies"
1920 "github.com/pusher/oauth2_proxy/pkg/sessions"
2021 sessionscookie "github.com/pusher/oauth2_proxy/pkg/sessions/cookie"
22+ "github.com/pusher/oauth2_proxy/pkg/sessions/redis"
2123 "github.com/pusher/oauth2_proxy/pkg/sessions/utils"
2224)
2325
@@ -34,6 +36,7 @@ var _ = Describe("NewSessionStore", func() {
3436 var response *httptest.ResponseRecorder
3537 var session *sessionsapi.SessionState
3638 var ss sessionsapi.SessionStore
39+ var mr *miniredis.Miniredis
3740
3841 CheckCookieOptions := func() {
3942 Context("the cookies returned", func() {
@@ -89,19 +92,113 @@ var _ = Describe("NewSessionStore", func() {
8992 })
9093 }
9194
92− SessionStoreInterfaceTests := func() {
93− Context("when Save is called", func() {
95+ // The following should only be for server stores
96+ PersistentSessionStoreTests := func() {
97+ Context("when Clear is called on a persistent store", func() {
98+ var resultCookies []*http.Cookie
99+
94100 BeforeEach(func() {
95− err := ss.Save(response, request, session)
101+ req := httptest.NewRequest("GET", "http://example.com/", nil)
102+ saveResp := httptest.NewRecorder()
103+ err := ss.Save(saveResp, req, session)
104+ Expect(err).ToNot(HaveOccurred())
105+
106+ resultCookies = saveResp.Result().Cookies()
107+ for _, c := range resultCookies {
108+ request.AddCookie(c)
109+ }
110+ err = ss.Clear(response, request)
96111 Expect(err).ToNot(HaveOccurred())
97112 })
98113
99− It("sets a `set-cookie` header in the response", func() {
100− Expect(response.Header().Get("set-cookie")).ToNot(BeEmpty())
114+ Context("attempting to Load", func() {
115+ var loadedAfterClear *sessionsapi.SessionState
116+ var loadErr error
117+
118+ BeforeEach(func() {
119+ loadReq := httptest.NewRequest("GET", "http://example.com/", nil)
120+ for _, c := range resultCookies {
121+ loadReq.AddCookie(c)
122+ }
123+
124+ loadedAfterClear, loadErr = ss.Load(loadReq)
125+ })
126+
127+ It("returns an empty session", func() {
128+ Expect(loadedAfterClear).To(BeNil())
129+ })
130+
131+ It("returns an error", func() {
132+ Expect(loadErr).To(HaveOccurred())
133+ })
101134 })
102135
103− It("Ensures the session CreatedAt is not zero", func() {
104− Expect(session.CreatedAt.IsZero()).To(BeFalse())
136+ CheckCookieOptions()
137+ })
138+ }
139+
140+ SessionStoreInterfaceTests := func(persistent bool) {
141+ Context("when Save is called", func() {
142+ Context("with no existing session", func() {
143+ BeforeEach(func() {
144+ err := ss.Save(response, request, session)
145+ Expect(err).ToNot(HaveOccurred())
146+ })
147+
148+ It("sets a `set-cookie` header in the response", func() {
149+ Expect(response.Header().Get("set-cookie")).ToNot(BeEmpty())
150+ })
151+
152+ It("Ensures the session CreatedAt is not zero", func() {
153+ Expect(session.CreatedAt.IsZero()).To(BeFalse())
154+ })
155+ })
156+
157+ Context("with a broken session", func() {
158+ BeforeEach(func() {
159+ By("Using a valid cookie with a different providers session encoding")
160+ broken := "BrokenSessionFromADifferentSessionImplementation"
161+ value := cookie.SignedValue(cookieOpts.CookieSecret, cookieOpts.CookieName, broken, time.Now())
162+ cookie := cookies.MakeCookieFromOptions(request, cookieOpts.CookieName, value, cookieOpts, cookieOpts.CookieExpire, time.Now())
163+ request.AddCookie(cookie)
164+
165+ err := ss.Save(response, request, session)
166+ Expect(err).ToNot(HaveOccurred())
167+ })
168+
169+ It("sets a `set-cookie` header in the response", func() {
170+ Expect(response.Header().Get("set-cookie")).ToNot(BeEmpty())
171+ })
172+
173+ It("Ensures the session CreatedAt is not zero", func() {
174+ Expect(session.CreatedAt.IsZero()).To(BeFalse())
175+ })
176+ })
177+
178+ Context("with an expired saved session", func() {
179+ var err error
180+ BeforeEach(func() {
181+ By("saving a session")
182+ req := httptest.NewRequest("GET", "http://example.com/", nil)
183+ saveResp := httptest.NewRecorder()
184+ err = ss.Save(saveResp, req, session)
185+ Expect(err).ToNot(HaveOccurred())
186+
187+ By("and clearing the session")
188+ for _, c := range saveResp.Result().Cookies() {
189+ request.AddCookie(c)
190+ }
191+ clearResp := httptest.NewRecorder()
192+ err = ss.Clear(clearResp, request)
193+ Expect(err).ToNot(HaveOccurred())
194+
195+ By("then saving a request with the cleared session")
196+ err = ss.Save(response, request, session)
197+ })
198+
199+ It("no error should occur", func() {
200+ Expect(err).ToNot(HaveOccurred())
201+ })
105202 })
106203
107204 CheckCookieOptions()
@@ -109,18 +206,15 @@ var _ = Describe("NewSessionStore", func() {
109206
110207 Context("when Clear is called", func() {
111208 BeforeEach(func() {
112− cookie := cookies.MakeCookie(request,
113− cookieOpts.CookieName,
114− "foo",
115− cookieOpts.CookiePath,
116− cookieOpts.CookieDomain,
117− cookieOpts.CookieHTTPOnly,
118− cookieOpts.CookieSecure,
119− cookieOpts.CookieExpire,
120− time.Now(),
121− )
122− request.AddCookie(cookie)
123− err := ss.Clear(response, request)
209+ req := httptest.NewRequest("GET", "http://example.com/", nil)
210+ saveResp := httptest.NewRecorder()
211+ err := ss.Save(saveResp, req, session)
212+ Expect(err).ToNot(HaveOccurred())
213+
214+ for _, c := range saveResp.Result().Cookies() {
215+ request.AddCookie(c)
216+ }
217+ err = ss.Clear(response, request)
124218 Expect(err).ToNot(HaveOccurred())
125219 })
126220
@@ -132,7 +226,38 @@ var _ = Describe("NewSessionStore", func() {
132226 })
133227
134228 Context("when Load is called", func() {
135− var loadedSession *sessionsapi.SessionState
229+ LoadSessionTests := func() {
230+ var loadedSession *sessionsapi.SessionState
231+ BeforeEach(func() {
232+ var err error
233+ loadedSession, err = ss.Load(request)
234+ Expect(err).ToNot(HaveOccurred())
235+ })
236+
237+ It("loads a session equal to the original session", func() {
238+ if cookieOpts.CookieSecret == "" {
239+ // Only Email and User stored in session when encrypted
240+ Expect(loadedSession.Email).To(Equal(session.Email))
241+ Expect(loadedSession.User).To(Equal(session.User))
242+ } else {
243+ // All fields stored in session if encrypted
244+
245+ // Can't compare time.Time using Equal() so remove ExpiresOn from sessions
246+ l := *loadedSession
247+ l.CreatedAt = time.Time{}
248+ l.ExpiresOn = time.Time{}
249+ s := *session
250+ s.CreatedAt = time.Time{}
251+ s.ExpiresOn = time.Time{}
252+ Expect(l).To(Equal(s))
253+
254+ // Compare time.Time separately
255+ Expect(loadedSession.CreatedAt.Equal(session.CreatedAt)).To(BeTrue())
256+ Expect(loadedSession.ExpiresOn.Equal(session.ExpiresOn)).To(BeTrue())
257+ }
258+ })
259+ }
260+
136261 BeforeEach(func() {
137262 req := httptest.NewRequest("GET", "http://example.com/", nil)
138263 resp := httptest.NewRecorder()
@@ -142,36 +267,57 @@ var _ = Describe("NewSessionStore", func() {
142267 for _, cookie := range resp.Result().Cookies() {
143268 request.AddCookie(cookie)
144269 }
145− loadedSession, err = ss.Load(request)
146− Expect(err).ToNot(HaveOccurred())
147270 })
148271
149− It("loads a session equal to the original session", func() {
150− if cookieOpts.CookieSecret == "" {
151− // Only Email and User stored in session when encrypted
152− Expect(loadedSession.Email).To(Equal(session.Email))
153− Expect(loadedSession.User).To(Equal(session.User))
154− } else {
155− // All fields stored in session if encrypted
156−
157− // Can't compare time.Time using Equal() so remove ExpiresOn from sessions
158− l := *loadedSession
159− l.CreatedAt = time.Time{}
160− l.ExpiresOn = time.Time{}
161− s := *session
162− s.CreatedAt = time.Time{}
163− s.ExpiresOn = time.Time{}
164− Expect(l).To(Equal(s))
165−
166− // Compare time.Time separately
167− Expect(loadedSession.CreatedAt.Equal(session.CreatedAt)).To(BeTrue())
168− Expect(loadedSession.ExpiresOn.Equal(session.ExpiresOn)).To(BeTrue())
169− }
272+ Context("before the refresh period", func() {
273+ LoadSessionTests()
170274 })
275+
276+ // Test TTLs and cleanup of persistent session storage
277+ // For non-persistent we rely on the browser cookie lifecycle
278+ if persistent {
279+ Context("after the refresh period, but before the cookie expire period", func() {
280+ BeforeEach(func() {
281+ switch ss.(type) {
282+ case *redis.SessionStore:
283+ mr.FastForward(cookieOpts.CookieRefresh + time.Minute)
284+ }
285+ })
286+
287+ LoadSessionTests()
288+ })
289+
290+ Context("after the cookie expire period", func() {
291+ var loadedSession *sessionsapi.SessionState
292+ var err error
293+
294+ BeforeEach(func() {
295+ switch ss.(type) {
296+ case *redis.SessionStore:
297+ mr.FastForward(cookieOpts.CookieExpire + time.Minute)
298+ }
299+
300+ loadedSession, err = ss.Load(request)
301+ Expect(err).To(HaveOccurred())
302+ })
303+
304+ It("returns an error loading the session", func() {
305+ Expect(err).To(HaveOccurred())
306+ })
307+
308+ It("returns an empty session", func() {
309+ Expect(loadedSession).To(BeNil())
310+ })
311+ })
312+ }
171313 })
314+
315+ if persistent {
316+ PersistentSessionStoreTests()
317+ }
172318 }
173319
174− RunSessionTests := func() {
320+ RunSessionTests := func(persistent bool) {
175321 Context("with default options", func() {
176322 BeforeEach(func() {
177323 var err error
@@ -179,7 +325,7 @@ var _ = Describe("NewSessionStore", func() {
179325 Expect(err).ToNot(HaveOccurred())
180326 })
181327
182− SessionStoreInterfaceTests()
328+ SessionStoreInterfaceTests(persistent)
183329 })
184330
185331 Context("with non-default options", func() {
@@ -188,7 +334,7 @@ var _ = Describe("NewSessionStore", func() {
188334 CookieName: "_cookie_name",
189335 CookiePath: "/path",
190336 CookieExpire: time.Duration(72) * time.Hour,
191− CookieRefresh: time.Duration(3600),
337+ CookieRefresh: time.Duration(2) * time.Hour,
192338 CookieSecure: false,
193339 CookieHTTPOnly: false,
194340 CookieDomain: "example.com",
@@ -199,7 +345,7 @@ var _ = Describe("NewSessionStore", func() {
199345 Expect(err).ToNot(HaveOccurred())
200346 })
201347
202− SessionStoreInterfaceTests()
348+ SessionStoreInterfaceTests(persistent)
203349 })
204350
205351 Context("with a cipher", func() {
@@ -217,7 +363,7 @@ var _ = Describe("NewSessionStore", func() {
217363 Expect(err).ToNot(HaveOccurred())
218364 })
219365
220− SessionStoreInterfaceTests()
366+ SessionStoreInterfaceTests(persistent)
221367 })
222368 }
223369
@@ -230,7 +376,7 @@ var _ = Describe("NewSessionStore", func() {
230376 CookieName: "_oauth2_proxy",
231377 CookiePath: "/",
232378 CookieExpire: time.Duration(168) * time.Hour,
233− CookieRefresh: time.Duration(0),
379+ CookieRefresh: time.Duration(1) * time.Hour,
234380 CookieSecure: true,
235381 CookieHTTPOnly: true,
236382 }
@@ -260,7 +406,31 @@ var _ = Describe("NewSessionStore", func() {
260406 })
261407
262408 Context("the cookie.SessionStore", func() {
263− RunSessionTests()
409+ RunSessionTests(false)
410+ })
411+ })
412+
413+ Context("with type 'redis'", func() {
414+ BeforeEach(func() {
415+ var err error
416+ mr, err = miniredis.Run()
417+ Expect(err).ToNot(HaveOccurred())
418+ opts.Type = options.RedisSessionStoreType
419+ opts.RedisConnectionURL = "redis://" + mr.Addr()
420+ })
421+
422+ AfterEach(func() {
423+ mr.Close()
424+ })
425+
426+ It("creates a redis.SessionStore", func() {
427+ ss, err := sessions.NewSessionStore(opts, cookieOpts)
428+ Expect(err).NotTo(HaveOccurred())
429+ Expect(ss).To(BeAssignableToTypeOf(&redis.SessionStore{}))
430+ })
431+
432+ Context("the redis.SessionStore", func() {
433+ RunSessionTests(true)
264434 })
265435 })
266436