git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge remote-tracking branch 'upstream/master' into verified
95ee435Carlos Alexandro Becker
.github/CODEOWNERS+9
@@ -1,3 +1,12 @@
1# Default owner should be a Pusher cloud-team member unless overridden by later
1# Default owner should be a Pusher cloud-team member unless overridden by later
2# rules in this file
2# rules in this file
3* @pusher/cloud-team
3* @pusher/cloud-team
4+
5+# login.gov provider
6+# Note: If @timothy-spencer terms out of his appointment, your best bet
7+# for finding somebody who can test the oauth2_proxy would be to ask somebody
8+# in the login.gov team (https://login.gov/developers/), the cloud.gov team
9+# (https://cloud.gov/docs/help/), or the 18F org (https://18f.gsa.gov/contact/
10+# or the public devops channel at https://chat.18f.gov/).
11+providers/logingov.go @timothy-spencer
12+providers/logingov_test.go @timothy-spencer
CHANGELOG.md+7
@@ -3,6 +3,13 @@
3## Changes since v3.1.0
3## Changes since v3.1.0
4
4
5- [#96](https://github.com/bitly/oauth2_proxy/pull/96) Check if email is verified on GitHub (@caarlos0)
5- [#96](https://github.com/bitly/oauth2_proxy/pull/96) Check if email is verified on GitHub (@caarlos0)
6+- [#63](https://github.com/pusher/oauth2_proxy/pull/63) Use encoding/json for SessionState serialization (@yaegashi)
7+ - Use JSON to encode session state to be stored in browser cookies
8+ - Implement legacy decode function to support existing cookies generated by older versions
9+ - Add detailed table driven tests in session_state_test.go
10+- [#55](https://github.com/pusher/oauth2_proxy/pull/55) Added login.gov provider (@timothy-spencer)
11+- [#55](https://github.com/pusher/oauth2_proxy/pull/55) Added environment variables for all config options (@timothy-spencer)
12+- [#70](https://github.com/pusher/oauth2_proxy/pull/70) Fix handling of splitted cookies (@einfachchr)
6- [#92](https://github.com/pusher/oauth2_proxy/pull/92) Merge websocket proxy feature from openshift/oauth-proxy (@butzist)
13- [#92](https://github.com/pusher/oauth2_proxy/pull/92) Merge websocket proxy feature from openshift/oauth-proxy (@butzist)
7- [#57](https://github.com/pusher/oauth2_proxy/pull/57) Fall back to using OIDC Subject instead of Email (@aigarius)
14- [#57](https://github.com/pusher/oauth2_proxy/pull/57) Fall back to using OIDC Subject instead of Email (@aigarius)
8- [#85](https://github.com/pusher/oauth2_proxy/pull/85) Use non-root user in docker images (@kskewes)
15- [#85](https://github.com/pusher/oauth2_proxy/pull/85) Use non-root user in docker images (@kskewes)
Gopkg.lock+9
@@ -41,6 +41,14 @@
41 revision = "346938d642f2ec3594ed81d874461961cd0faa76"
41 revision = "346938d642f2ec3594ed81d874461961cd0faa76"
42 version = "v1.1.0"
42 version = "v1.1.0"
43
43
44+[[projects]]
45+ digest = "1:6098222470fe0172157ce9bbef5d2200df4edde17ee649c5d6e48330e4afa4c6"
46+ name = "github.com/dgrijalva/jwt-go"
47+ packages = ["."]
48+ pruneopts = ""
49+ revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e"
50+ version = "v3.2.0"
51+
44[[projects]]
52[[projects]]
45 branch = "master"
53 branch = "master"
46 digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4"
54 digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4"
@@ -201,6 +209,7 @@
201 "github.com/BurntSushi/toml",
209 "github.com/BurntSushi/toml",
202 "github.com/bitly/go-simplejson",
210 "github.com/bitly/go-simplejson",
203 "github.com/coreos/go-oidc",
211 "github.com/coreos/go-oidc",
212+ "github.com/dgrijalva/jwt-go",
204 "github.com/mbland/hmacauth",
213 "github.com/mbland/hmacauth",
205 "github.com/mreiferson/go-options",
214 "github.com/mreiferson/go-options",
206 "github.com/stretchr/testify/assert",
215 "github.com/stretchr/testify/assert",
README.md+49
@@ -48,6 +48,7 @@ Valid providers are :
48- [GitHub](#github-auth-provider)
48- [GitHub](#github-auth-provider)
49- [GitLab](#gitlab-auth-provider)
49- [GitLab](#gitlab-auth-provider)
50- [LinkedIn](#linkedin-auth-provider)
50- [LinkedIn](#linkedin-auth-provider)
51+- [login.gov](#login.gov-provider)
51
52
52The provider can be selected using the `provider` configuration value.
53The provider can be selected using the `provider` configuration value.
53
54
@@ -166,6 +167,54 @@ OpenID Connect is a spec for OAUTH 2.0 + identity that is implemented by many ma
166 -cookie-secure=false
167 -cookie-secure=false
167 -email-domain example.com
168 -email-domain example.com
168
169
170+### login.gov Provider
171+
172+login.gov is an OIDC provider for the US Government.
173+If you are a US Government agency, you can contact the login.gov team through the contact information
174+that you can find on https://login.gov/developers/ and work with them to understand how to get login.gov
175+accounts for integration/test and production access.
176+
177+A developer guide is available here: https://developers.login.gov/, though this proxy handles everything
178+but the data you need to create to register your application in the login.gov dashboard.
179+
180+As a demo, we will assume that you are running your application that you want to secure locally on
181+http://localhost:3000/, that you will be starting your proxy up on http://localhost:4180/, and that
182+you have an agency integration account for testing.
183+
184+First, register your application in the dashboard. The important bits are:
185+ * Identity protocol: make this `Openid connect`
186+ * Issuer: do what they say for OpenID Connect. We will refer to this string as `${LOGINGOV_ISSUER}`.
187+ * Public key: This is a self-signed certificate in .pem format generated from a 2048 bit RSA private key.
188+ A quick way to do this is `openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 -nodes -subj '/C=US/ST=Washington/L=DC/O=GSA/OU=18F/CN=localhost'`,
189+ The contents of the `key.pem` shall be referred to as `${OAUTH2_PROXY_JWT_KEY}`.
190+ * Return to App URL: Make this be `http://localhost:4180/`
191+ * Redirect URIs: Make this be `http://localhost:4180/oauth2/callback`.
192+ * Attribute Bundle: Make sure that email is selected.
193+
194+Now start the proxy up with the following options:
195+```
196+./oauth2_proxy -provider login.gov \
197+ -client-id=${LOGINGOV_ISSUER} \
198+ -redirect-url=http://localhost:4180/oauth2/callback \
199+ -oidc-issuer-url=https://idp.int.identitysandbox.gov/ \
200+ -cookie-secure=false \
201+ -email-domain=gsa.gov \
202+ -upstream=http://localhost:3000/ \
203+ -cookie-secret=somerandomstring12341234567890AB \
204+ -cookie-domain=localhost \
205+ -skip-provider-button=true \
206+ -pubjwk-url=https://idp.int.identitysandbox.gov/api/openid_connect/certs \
207+ -profile-url=https://idp.int.identitysandbox.gov/api/openid_connect/userinfo \
208+ -jwt-key="${OAUTH2_PROXY_JWT_KEY}"
209+```
210+You can also set all these options with environment variables, for use in cloud/docker environments.
211+
212+Once it is running, you should be able to go to `http://localhost:4180/` in your browser,
213+get authenticated by the login.gov integration server, and then get proxied on to your
214+application running on `http://localhost:3000/`. In a real deployment, you would secure
215+your application with a firewall or something so that it was only accessible from the
216+proxy, and you would use real hostnames everywhere.
217+
169#### Skip OIDC discovery
218#### Skip OIDC discovery
170
219
171Some providers do not support OIDC discovery via their issuer URL, so oauth2_proxy cannot simply grab the authorization, token and jwks URI endpoints from the provider's metadata.
220Some providers do not support OIDC discovery via their issuer URL, so oauth2_proxy cannot simply grab the authorization, token and jwks URI endpoints from the provider's metadata.
logging_handler_test.go+4−3
@@ -5,6 +5,7 @@ import (
5 "fmt"
5 "fmt"
6 "net/http"
6 "net/http"
7 "net/http/httptest"
7 "net/http/httptest"
8+ "strings"
8 "testing"
9 "testing"
9 "time"
10 "time"
10)
11)
@@ -16,7 +17,7 @@ func TestLoggingHandler_ServeHTTP(t *testing.T) {
16 Format,
17 Format,
17 ExpectedLogMessage string
18 ExpectedLogMessage string
18 }{
19 }{
19− {defaultRequestLoggingFormat, fmt.Sprintf("127.0.0.1 - - [%s] test-server GET - \"/foo/bar\" HTTP/1.1 \"\" 200 4 0.000\n", ts.Format("02/Jan/2006:15:04:05 -0700"))},
20+ {defaultRequestLoggingFormat, fmt.Sprintf("127.0.0.1 - - [%s] test-server GET - \"/foo/bar\" HTTP/1.1 \"\" 200 4 0", ts.Format("02/Jan/2006:15:04:05 -0700"))},
20 {"{{.RequestMethod}}", "GET\n"},
21 {"{{.RequestMethod}}", "GET\n"},
21 }
22 }
22
23
@@ -35,8 +36,8 @@ func TestLoggingHandler_ServeHTTP(t *testing.T) {
35 h.ServeHTTP(httptest.NewRecorder(), r)
36 h.ServeHTTP(httptest.NewRecorder(), r)
36
37
37 actual := buf.String()
38 actual := buf.String()
38− if actual != test.ExpectedLogMessage {
39+ if !strings.Contains(actual, test.ExpectedLogMessage) {
39− t.Errorf("Log message was\n%s\ninstead of expected \n%s", actual, test.ExpectedLogMessage)
40+ t.Errorf("Log message was\n%s\ninstead of matching \n%s", actual, test.ExpectedLogMessage)
40 }
41 }
41 }
42 }
42}
43}
main.go+6
@@ -4,6 +4,7 @@ import (
4 "flag"
4 "flag"
5 "fmt"
5 "fmt"
6 "log"
6 "log"
7+ "math/rand"
7 "os"
8 "os"
8 "runtime"
9 "runtime"
9 "strings"
10 "strings"
@@ -88,6 +89,9 @@ func main() {
88 flagSet.String("approval-prompt", "force", "OAuth approval_prompt")
89 flagSet.String("approval-prompt", "force", "OAuth approval_prompt")
89
90
90 flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)")
91 flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)")
92+ flagSet.String("acr-values", "http://idmanagement.gov/ns/assurance/loa/1", "acr values string: optional, used by login.gov")
93+ flagSet.String("jwt-key", "", "private key used to sign JWT: required by login.gov")
94+ flagSet.String("pubjwk-url", "", "JWK pubkey access endpoint: required by login.gov")
91
95
92 flagSet.Parse(os.Args[1:])
96 flagSet.Parse(os.Args[1:])
93
97
@@ -133,6 +137,8 @@ func main() {
133 }
137 }
134 }
138 }
135
139
140+ rand.Seed(time.Now().UnixNano())
141+
136 s := &Server{
142 s := &Server{
137 Handler: LoggingHandler(os.Stdout, oauthproxy, opts.RequestLogging, opts.RequestLoggingFormat),
143 Handler: LoggingHandler(os.Stdout, oauthproxy, opts.RequestLogging, opts.RequestLoggingFormat),
138 Opts: opts,
144 Opts: opts,
oauthproxy.go+14−5
@@ -204,7 +204,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
204 }
204 }
205
205
206 redirectURL := opts.redirectURL
206 redirectURL := opts.redirectURL
207− if redirectURL.String() == "" {
207+ if redirectURL.Path == "" {
208 redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix)
208 redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix)
209 }
209 }
210
210
@@ -241,7 +241,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
241 SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
241 SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
242 SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
242 SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
243 OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
243 OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
244− OAuthCallbackPath: redirectURL.Path,
244+ OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix),
245 AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
245 AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
246
246
247 ProxyPrefix: opts.ProxyPrefix,
247 ProxyPrefix: opts.ProxyPrefix,
@@ -452,9 +452,18 @@ func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, va
452// ClearSessionCookie creates a cookie to unset the user's authentication cookie
452// ClearSessionCookie creates a cookie to unset the user's authentication cookie
453// stored in the user's session
453// stored in the user's session
454func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) {
454func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) {
455+ var cookies []*http.Cookie
456+
457+ // matches CookieName, CookieName_<number>
458+ var cookieNameRegex = regexp.MustCompile(fmt.Sprintf("^%s(_\\d+)?$", p.CookieName))
459+
460+ for _, c := range req.Cookies() {
461+ if cookieNameRegex.MatchString(c.Name) {
455− cookies := p.MakeSessionCookie(req, "", time.Hour*-1, time.Now())
462+ clearCookie := p.makeCookie(req, c.Name, "", time.Hour*-1, time.Now())
456− for _, clr := range cookies {
463+
457− http.SetCookie(rw, clr)
464+ http.SetCookie(rw, clearCookie)
465+ cookies = append(cookies, clearCookie)
466+ }
458 }
467 }
459
468
460 // ugly hack because default domain changed
469 // ugly hack because default domain changed
oauthproxy_test.go+44
@@ -1064,3 +1064,47 @@ func TestAjaxForbiddendRequest(t *testing.T) {
1064 mime := rh.Get("Content-Type")
1064 mime := rh.Get("Content-Type")
1065 assert.NotEqual(t, applicationJSON, mime)
1065 assert.NotEqual(t, applicationJSON, mime)
1066}
1066}
1067+
1068+func TestClearSplitCookie(t *testing.T) {
1069+ p := OAuthProxy{CookieName: "oauth2", CookieDomain: "abc"}
1070+ var rw = httptest.NewRecorder()
1071+ req := httptest.NewRequest("get", "/", nil)
1072+
1073+ req.AddCookie(&http.Cookie{
1074+ Name: "test1",
1075+ Value: "test1",
1076+ })
1077+ req.AddCookie(&http.Cookie{
1078+ Name: "oauth2_0",
1079+ Value: "oauth2_0",
1080+ })
1081+ req.AddCookie(&http.Cookie{
1082+ Name: "oauth2_1",
1083+ Value: "oauth2_1",
1084+ })
1085+
1086+ p.ClearSessionCookie(rw, req)
1087+ header := rw.Header()
1088+
1089+ assert.Equal(t, 2, len(header["Set-Cookie"]), "should have 3 set-cookie header entries")
1090+}
1091+
1092+func TestClearSingleCookie(t *testing.T) {
1093+ p := OAuthProxy{CookieName: "oauth2", CookieDomain: "abc"}
1094+ var rw = httptest.NewRecorder()
1095+ req := httptest.NewRequest("get", "/", nil)
1096+
1097+ req.AddCookie(&http.Cookie{
1098+ Name: "test1",
1099+ Value: "test1",
1100+ })
1101+ req.AddCookie(&http.Cookie{
1102+ Name: "oauth2",
1103+ Value: "oauth2",
1104+ })
1105+
1106+ p.ClearSessionCookie(rw, req)
1107+ header := rw.Header()
1108+
1109+ assert.Equal(t, 1, len(header["Set-Cookie"]), "should have 1 set-cookie header entries")
1110+}
options.go+69−51
@@ -14,6 +14,7 @@ import (
14 "time"
14 "time"
15
15
16 oidc "github.com/coreos/go-oidc"
16 oidc "github.com/coreos/go-oidc"
17+ "github.com/dgrijalva/jwt-go"
17 "github.com/mbland/hmacauth"
18 "github.com/mbland/hmacauth"
18 "github.com/pusher/oauth2_proxy/providers"
19 "github.com/pusher/oauth2_proxy/providers"
19)
20)
@@ -21,71 +22,74 @@ import (
21// Options holds Configuration Options that can be set by Command Line Flag,
22// Options holds Configuration Options that can be set by Command Line Flag,
22// or Config File
23// or Config File
23type Options struct {
24type Options struct {
24− ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy-prefix"`
25+ ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy-prefix" env:"OAUTH2_PROXY_PROXY_PREFIX"`
25− ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"`
26+ ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets" env:"OAUTH2_PROXY_PROXY_WEBSOCKETS"`
26− HTTPAddress string `flag:"http-address" cfg:"http_address"`
27+ HTTPAddress string `flag:"http-address" cfg:"http_address" env:"OAUTH2_PROXY_HTTP_ADDRESS"`
27− HTTPSAddress string `flag:"https-address" cfg:"https_address"`
28+ HTTPSAddress string `flag:"https-address" cfg:"https_address" env:"OAUTH2_PROXY_HTTPS_ADDRESS"`
28− RedirectURL string `flag:"redirect-url" cfg:"redirect_url"`
29+ RedirectURL string `flag:"redirect-url" cfg:"redirect_url" env:"OAUTH2_PROXY_REDIRECT_URL"`
29 ClientID string `flag:"client-id" cfg:"client_id" env:"OAUTH2_PROXY_CLIENT_ID"`
30 ClientID string `flag:"client-id" cfg:"client_id" env:"OAUTH2_PROXY_CLIENT_ID"`
30 ClientSecret string `flag:"client-secret" cfg:"client_secret" env:"OAUTH2_PROXY_CLIENT_SECRET"`
31 ClientSecret string `flag:"client-secret" cfg:"client_secret" env:"OAUTH2_PROXY_CLIENT_SECRET"`
31− TLSCertFile string `flag:"tls-cert" cfg:"tls_cert_file"`
32+ TLSCertFile string `flag:"tls-cert" cfg:"tls_cert_file" env:"OAUTH2_PROXY_TLS_CERT_FILE"`
32− TLSKeyFile string `flag:"tls-key" cfg:"tls_key_file"`
33+ TLSKeyFile string `flag:"tls-key" cfg:"tls_key_file" env:"OAUTH2_PROXY_TLS_KEY_FILE"`
33
34
34− AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
35+ AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file" env:"OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE"`
35− AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
36+ AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant" env:"OAUTH2_PROXY_AZURE_TENANT"`
36− EmailDomains []string `flag:"email-domain" cfg:"email_domains"`
37+ EmailDomains []string `flag:"email-domain" cfg:"email_domains" env:"OAUTH2_PROXY_EMAIL_DOMAINS"`
37 WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
38 WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
38− GitHubOrg string `flag:"github-org" cfg:"github_org"`
39+ GitHubOrg string `flag:"github-org" cfg:"github_org" env:"OAUTH2_PROXY_GITHUB_ORG"`
39− GitHubTeam string `flag:"github-team" cfg:"github_team"`
40+ GitHubTeam string `flag:"github-team" cfg:"github_team" env:"OAUTH2_PROXY_GITHUB_TEAM"`
40− GoogleGroups []string `flag:"google-group" cfg:"google_group"`
41+ GoogleGroups []string `flag:"google-group" cfg:"google_group" env:"OAUTH2_PROXY_GOOGLE_GROUPS"`
41− GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"`
42+ GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email" env:"OAUTH2_PROXY_GOOGLE_ADMIN_EMAIL"`
42− GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"`
43+ GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json" env:"OAUTH2_PROXY_GOOGLE_SERVICE_ACCOUNT_JSON"`
43− HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"`
44+ HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file" env:"OAUTH2_PROXY_HTPASSWD_FILE"`
44− DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"`
45+ DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form" env:"OAUTH2_PROXY_DISPLAY_HTPASSWD_FORM"`
45− CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir"`
46+ CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir" env:"OAUTH2_PROXY_CUSTOM_TEMPLATES_DIR"`
46− Footer string `flag:"footer" cfg:"footer"`
47+ Footer string `flag:"footer" cfg:"footer" env:"OAUTH2_PROXY_FOOTER"`
47
48
48 CookieName string `flag:"cookie-name" cfg:"cookie_name" env:"OAUTH2_PROXY_COOKIE_NAME"`
49 CookieName string `flag:"cookie-name" cfg:"cookie_name" env:"OAUTH2_PROXY_COOKIE_NAME"`
49 CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret" env:"OAUTH2_PROXY_COOKIE_SECRET"`
50 CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret" env:"OAUTH2_PROXY_COOKIE_SECRET"`
50 CookieDomain string `flag:"cookie-domain" cfg:"cookie_domain" env:"OAUTH2_PROXY_COOKIE_DOMAIN"`
51 CookieDomain string `flag:"cookie-domain" cfg:"cookie_domain" env:"OAUTH2_PROXY_COOKIE_DOMAIN"`
51 CookieExpire time.Duration `flag:"cookie-expire" cfg:"cookie_expire" env:"OAUTH2_PROXY_COOKIE_EXPIRE"`
52 CookieExpire time.Duration `flag:"cookie-expire" cfg:"cookie_expire" env:"OAUTH2_PROXY_COOKIE_EXPIRE"`
52 CookieRefresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh" env:"OAUTH2_PROXY_COOKIE_REFRESH"`
53 CookieRefresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh" env:"OAUTH2_PROXY_COOKIE_REFRESH"`
53− CookieSecure bool `flag:"cookie-secure" cfg:"cookie_secure"`
54+ CookieSecure bool `flag:"cookie-secure" cfg:"cookie_secure" env:"OAUTH2_PROXY_COOKIE_SECURE"`
54− CookieHTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
55+ CookieHTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly" env:"OAUTH2_PROXY_COOKIE_HTTPONLY"`
55−
56+
56− Upstreams []string `flag:"upstream" cfg:"upstreams"`
57+ Upstreams []string `flag:"upstream" cfg:"upstreams" env:"OAUTH2_PROXY_UPSTREAMS"`
57− SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
58+ SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex" env:"OAUTH2_PROXY_SKIP_AUTH_REGEX"`
58− PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
59+ PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth" env:"OAUTH2_PROXY_PASS_BASIC_AUTH"`
59− BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"`
60+ BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password" env:"OAUTH2_PROXY_BASIC_AUTH_PASSWORD"`
60− PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"`
61+ PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token" env:"OAUTH2_PROXY_PASS_ACCESS_TOKEN"`
61− PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"`
62+ PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header" env:"OAUTH2_PROXY_PASS_HOST_HEADER"`
62− SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
63+ SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button" env:"OAUTH2_PROXY_SKIP_PROVIDER_BUTTON"`
63− PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"`
64+ PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers" env:"OAUTH2_PROXY_PASS_USER_HEADERS"`
64− SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
65+ SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify" env:"OAUTH2_PROXY_SSL_INSECURE_SKIP_VERIFY"`
65− SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest"`
66+ SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest" env:"OAUTH2_PROXY_SET_XAUTHREQUEST"`
66− SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header"`
67+ SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header" env:"OAUTH2_PROXY_SET_AUTHORIZATION_HEADER"`
67− PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"`
68+ PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header" env:"OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER"`
68− SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
69+ SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight" env:"OAUTH2_PROXY_SKIP_AUTH_PREFLIGHT"`
69− FlushInterval time.Duration `flag:"flush-interval" cfg:"flush_interval"`
70+ FlushInterval time.Duration `flag:"flush-interval" cfg:"flush_interval" env:"OAUTH2_PROXY_FLUSH_INTERVAL"`
70
71
71 // These options allow for other providers besides Google, with
72 // These options allow for other providers besides Google, with
72 // potential overrides.
73 // potential overrides.
73− Provider string `flag:"provider" cfg:"provider"`
74+ Provider string `flag:"provider" cfg:"provider" env:"OAUTH2_PROXY_PROVIDER"`
74− OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"`
75+ OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url" env:"OAUTH2_PROXY_OIDC_ISSUER_URL"`
75− SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
76+ SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery" env:"OAUTH2_SKIP_OIDC_DISCOVERY"`
76− OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
77+ OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url" env:"OAUTH2_OIDC_JWKS_URL"`
77− LoginURL string `flag:"login-url" cfg:"login_url"`
78+ LoginURL string `flag:"login-url" cfg:"login_url" env:"OAUTH2_PROXY_LOGIN_URL"`
78− RedeemURL string `flag:"redeem-url" cfg:"redeem_url"`
79+ RedeemURL string `flag:"redeem-url" cfg:"redeem_url" env:"OAUTH2_PROXY_REDEEM_URL"`
79− ProfileURL string `flag:"profile-url" cfg:"profile_url"`
80+ ProfileURL string `flag:"profile-url" cfg:"profile_url" env:"OAUTH2_PROXY_PROFILE_URL"`
80− ProtectedResource string `flag:"resource" cfg:"resource"`
81+ ProtectedResource string `flag:"resource" cfg:"resource" env:"OAUTH2_PROXY_RESOURCE"`
81− ValidateURL string `flag:"validate-url" cfg:"validate_url"`
82+ ValidateURL string `flag:"validate-url" cfg:"validate_url" env:"OAUTH2_PROXY_VALIDATE_URL"`
82− Scope string `flag:"scope" cfg:"scope"`
83+ Scope string `flag:"scope" cfg:"scope" env:"OAUTH2_PROXY_SCOPE"`
83− ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"`
84+ ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt" env:"OAUTH2_PROXY_APPROVAL_PROMPT"`
84−
85+
85− RequestLogging bool `flag:"request-logging" cfg:"request_logging"`
86+ RequestLogging bool `flag:"request-logging" cfg:"request_logging" env:"OAUTH2_PROXY_REQUEST_LOGGING"`
86− RequestLoggingFormat string `flag:"request-logging-format" cfg:"request_logging_format"`
87+ RequestLoggingFormat string `flag:"request-logging-format" cfg:"request_logging_format" env:"OAUTH2_PROXY_REQUEST_LOGGING_FORMAT"`
87
88
88 SignatureKey string `flag:"signature-key" cfg:"signature_key" env:"OAUTH2_PROXY_SIGNATURE_KEY"`
89 SignatureKey string `flag:"signature-key" cfg:"signature_key" env:"OAUTH2_PROXY_SIGNATURE_KEY"`
90+ AcrValues string `flag:"acr-values" cfg:"acr_values" env:"OAUTH2_PROXY_ACR_VALUES"`
91+ JWTKey string `flag:"jwt-key" cfg:"jwt_key" env:"OAUTH2_PROXY_JWT_KEY"`
92+ PubJWKURL string `flag:"pubjwk-url" cfg:"pubjwk_url" env:"OAUTH2_PROXY_PUBJWK_URL"`
89
93
90 // internal values that are set after config validation
94 // internal values that are set after config validation
91 redirectURL *url.URL
95 redirectURL *url.URL
@@ -157,7 +161,8 @@ func (o *Options) Validate() error {
157 if o.ClientID == "" {
161 if o.ClientID == "" {
158 msgs = append(msgs, "missing setting: client-id")
162 msgs = append(msgs, "missing setting: client-id")
159 }
163 }
164+ // login.gov uses a signed JWT to authenticate, not a client-secret
160− if o.ClientSecret == "" {
165+ if o.ClientSecret == "" && o.Provider != "login.gov" {
161 msgs = append(msgs, "missing setting: client-secret")
166 msgs = append(msgs, "missing setting: client-secret")
162 }
167 }
163 if o.AuthenticatedEmailsFile == "" && len(o.EmailDomains) == 0 && o.HtpasswdFile == "" {
168 if o.AuthenticatedEmailsFile == "" && len(o.EmailDomains) == 0 && o.HtpasswdFile == "" {
@@ -318,6 +323,19 @@ func parseProviderInfo(o *Options, msgs []string) []string {
318 } else {
323 } else {
319 p.Verifier = o.oidcVerifier
324 p.Verifier = o.oidcVerifier
320 }
325 }
326+ case *providers.LoginGovProvider:
327+ p.AcrValues = o.AcrValues
328+ p.PubJWKURL, msgs = parseURL(o.PubJWKURL, "pubjwk", msgs)
329+ if o.JWTKey == "" {
330+ msgs = append(msgs, "login.gov provider requires a private key for signing JWTs")
331+ } else {
332+ signKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(o.JWTKey))
333+ if err != nil {
334+ msgs = append(msgs, "could not parse RSA Private Key PEM")
335+ } else {
336+ p.JWTKey = signKey
337+ }
338+ }
321 }
339 }
322 return msgs
340 return msgs
323}
341}
providers/logingov.gonew file+275
@@ -0,0 +1,275 @@
1+package providers
2+
3+import (
4+ "bytes"
5+ "crypto/rsa"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io/ioutil"
10+ "math/rand"
11+ "net/http"
12+ "net/url"
13+ "time"
14+
15+ "github.com/dgrijalva/jwt-go"
16+ "gopkg.in/square/go-jose.v2"
17+)
18+
19+// LoginGovProvider represents an OIDC based Identity Provider
20+type LoginGovProvider struct {
21+ *ProviderData
22+
23+ // TODO (@timothy-spencer): Ideally, the nonce would be in the session state, but the session state
24+ // is created only upon code redemption, not during the auth, when this must be supplied.
25+ Nonce string
26+ AcrValues string
27+ JWTKey *rsa.PrivateKey
28+ PubJWKURL *url.URL
29+}
30+
31+// For generating a nonce
32+var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
33+
34+func randSeq(n int) string {
35+ b := make([]rune, n)
36+ for i := range b {
37+ b[i] = letters[rand.Intn(len(letters))]
38+ }
39+ return string(b)
40+}
41+
42+// NewLoginGovProvider initiates a new LoginGovProvider
43+func NewLoginGovProvider(p *ProviderData) *LoginGovProvider {
44+ p.ProviderName = "login.gov"
45+
46+ if p.LoginURL == nil || p.LoginURL.String() == "" {
47+ p.LoginURL = &url.URL{
48+ Scheme: "https",
49+ Host: "secure.login.gov",
50+ Path: "/openid_connect/authorize",
51+ }
52+ }
53+ if p.RedeemURL == nil || p.RedeemURL.String() == "" {
54+ p.RedeemURL = &url.URL{
55+ Scheme: "https",
56+ Host: "secure.login.gov",
57+ Path: "/api/openid_connect/token",
58+ }
59+ }
60+ if p.ProfileURL == nil || p.ProfileURL.String() == "" {
61+ p.ProfileURL = &url.URL{
62+ Scheme: "https",
63+ Host: "secure.login.gov",
64+ Path: "/api/openid_connect/userinfo",
65+ }
66+ }
67+ if p.Scope == "" {
68+ p.Scope = "email openid"
69+ }
70+
71+ return &LoginGovProvider{
72+ ProviderData: p,
73+ Nonce: randSeq(32),
74+ }
75+}
76+
77+type loginGovCustomClaims struct {
78+ Acr string `json:"acr"`
79+ Nonce string `json:"nonce"`
80+ Email string `json:"email"`
81+ EmailVerified bool `json:"email_verified"`
82+ GivenName string `json:"given_name"`
83+ FamilyName string `json:"family_name"`
84+ Birthdate string `json:"birthdate"`
85+ AtHash string `json:"at_hash"`
86+ CHash string `json:"c_hash"`
87+ jwt.StandardClaims
88+}
89+
90+// checkNonce checks the nonce in the id_token
91+func checkNonce(idToken string, p *LoginGovProvider) (err error) {
92+ token, err := jwt.ParseWithClaims(idToken, &loginGovCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
93+ resp, myerr := http.Get(p.PubJWKURL.String())
94+ if myerr != nil {
95+ return nil, myerr
96+ }
97+ if resp.StatusCode != 200 {
98+ myerr = fmt.Errorf("got %d from %q", resp.StatusCode, p.PubJWKURL.String())
99+ return nil, myerr
100+ }
101+ body, myerr := ioutil.ReadAll(resp.Body)
102+ resp.Body.Close()
103+ if myerr != nil {
104+ return nil, myerr
105+ }
106+
107+ var pubkeys jose.JSONWebKeySet
108+ myerr = json.Unmarshal(body, &pubkeys)
109+ if myerr != nil {
110+ return nil, myerr
111+ }
112+ pubkey := pubkeys.Keys[0]
113+
114+ return pubkey.Key, nil
115+ })
116+ if err != nil {
117+ return
118+ }
119+
120+ claims := token.Claims.(*loginGovCustomClaims)
121+ if claims.Nonce != p.Nonce {
122+ err = fmt.Errorf("nonce validation failed")
123+ return
124+ }
125+ return
126+}
127+
128+func emailFromUserInfo(accessToken string, userInfoEndpoint string) (email string, err error) {
129+ // query the user info endpoint for user attributes
130+ var req *http.Request
131+ req, err = http.NewRequest("GET", userInfoEndpoint, nil)
132+ if err != nil {
133+ return
134+ }
135+ req.Header.Set("Authorization", "Bearer "+accessToken)
136+
137+ resp, err := http.DefaultClient.Do(req)
138+ if err != nil {
139+ return
140+ }
141+ var body []byte
142+ body, err = ioutil.ReadAll(resp.Body)
143+ resp.Body.Close()
144+ if err != nil {
145+ return
146+ }
147+
148+ if resp.StatusCode != 200 {
149+ err = fmt.Errorf("got %d from %q %s", resp.StatusCode, userInfoEndpoint, body)
150+ return
151+ }
152+
153+ // parse the user attributes from the data we got and make sure that
154+ // the email address has been validated.
155+ var emailData struct {
156+ Email string `json:"email"`
157+ EmailVerified bool `json:"email_verified"`
158+ }
159+ err = json.Unmarshal(body, &emailData)
160+ if err != nil {
161+ return
162+ }
163+ if emailData.Email == "" {
164+ err = fmt.Errorf("missing email")
165+ return
166+ }
167+ email = emailData.Email
168+ if !emailData.EmailVerified {
169+ err = fmt.Errorf("email %s not listed as verified", email)
170+ return
171+ }
172+ return
173+}
174+
175+// Redeem exchanges the OAuth2 authentication token for an ID token
176+func (p *LoginGovProvider) Redeem(redirectURL, code string) (s *SessionState, err error) {
177+ if code == "" {
178+ err = errors.New("missing code")
179+ return
180+ }
181+
182+ claims := &jwt.StandardClaims{
183+ Issuer: p.ClientID,
184+ Subject: p.ClientID,
185+ Audience: p.RedeemURL.String(),
186+ ExpiresAt: int64(time.Now().Add(time.Duration(5 * time.Minute)).Unix()),
187+ Id: randSeq(32),
188+ }
189+ token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims)
190+ ss, err := token.SignedString(p.JWTKey)
191+ if err != nil {
192+ return
193+ }
194+
195+ params := url.Values{}
196+ params.Add("client_assertion", ss)
197+ params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
198+ params.Add("code", code)
199+ params.Add("grant_type", "authorization_code")
200+
201+ var req *http.Request
202+ req, err = http.NewRequest("POST", p.RedeemURL.String(), bytes.NewBufferString(params.Encode()))
203+ if err != nil {
204+ return
205+ }
206+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
207+
208+ var resp *http.Response
209+ resp, err = http.DefaultClient.Do(req)
210+ if err != nil {
211+ return nil, err
212+ }
213+ var body []byte
214+ body, err = ioutil.ReadAll(resp.Body)
215+ resp.Body.Close()
216+ if err != nil {
217+ return
218+ }
219+
220+ if resp.StatusCode != 200 {
221+ err = fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RedeemURL.String(), body)
222+ return
223+ }
224+
225+ // Get the token from the body that we got from the token endpoint.
226+ var jsonResponse struct {
227+ AccessToken string `json:"access_token"`
228+ IDToken string `json:"id_token"`
229+ TokenType string `json:"token_type"`
230+ ExpiresIn int64 `json:"expires_in"`
231+ }
232+ err = json.Unmarshal(body, &jsonResponse)
233+ if err != nil {
234+ return
235+ }
236+
237+ // check nonce here
238+ err = checkNonce(jsonResponse.IDToken, p)
239+ if err != nil {
240+ return
241+ }
242+
243+ // Get the email address
244+ var email string
245+ email, err = emailFromUserInfo(jsonResponse.AccessToken, p.ProfileURL.String())
246+ if err != nil {
247+ return
248+ }
249+
250+ // Store the data that we found in the session state
251+ s = &SessionState{
252+ AccessToken: jsonResponse.AccessToken,
253+ IDToken: jsonResponse.IDToken,
254+ ExpiresOn: time.Now().Add(time.Duration(jsonResponse.ExpiresIn) * time.Second).Truncate(time.Second),
255+ Email: email,
256+ }
257+ return
258+}
259+
260+// GetLoginURL overrides GetLoginURL to add login.gov parameters
261+func (p *LoginGovProvider) GetLoginURL(redirectURI, state string) string {
262+ var a url.URL
263+ a = *p.LoginURL
264+ params, _ := url.ParseQuery(a.RawQuery)
265+ params.Set("redirect_uri", redirectURI)
266+ params.Set("approval_prompt", p.ApprovalPrompt)
267+ params.Add("scope", p.Scope)
268+ params.Set("client_id", p.ClientID)
269+ params.Set("response_type", "code")
270+ params.Add("state", state)
271+ params.Add("acr_values", p.AcrValues)
272+ params.Add("nonce", p.Nonce)
273+ a.RawQuery = params.Encode()
274+ return a.String()
275+}
providers/logingov_test.gonew file+290
@@ -0,0 +1,290 @@
1+package providers
2+
3+import (
4+ "crypto"
5+ "crypto/rand"
6+ "crypto/rsa"
7+ "encoding/json"
8+ "net/http"
9+ "net/http/httptest"
10+ "net/url"
11+ "testing"
12+ "time"
13+
14+ "github.com/dgrijalva/jwt-go"
15+ "github.com/stretchr/testify/assert"
16+ "gopkg.in/square/go-jose.v2"
17+)
18+
19+type MyKeyData struct {
20+ PubKey crypto.PublicKey
21+ PrivKey *rsa.PrivateKey
22+ PubJWK jose.JSONWebKey
23+}
24+
25+func newLoginGovServer(body []byte) (*url.URL, *httptest.Server) {
26+ s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
27+ rw.Write(body)
28+ }))
29+ u, _ := url.Parse(s.URL)
30+ return u, s
31+}
32+
33+func newLoginGovProvider() (l *LoginGovProvider, serverKey *MyKeyData, err error) {
34+ key, err := rsa.GenerateKey(rand.Reader, 2048)
35+ if err != nil {
36+ return
37+ }
38+ serverKey = &MyKeyData{
39+ PubKey: key.Public(),
40+ PrivKey: key,
41+ PubJWK: jose.JSONWebKey{
42+ Key: key.Public(),
43+ KeyID: "testkey",
44+ Algorithm: string(jose.RS256),
45+ Use: "sig",
46+ },
47+ }
48+
49+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
50+ if err != nil {
51+ return
52+ }
53+
54+ l = NewLoginGovProvider(
55+ &ProviderData{
56+ ProviderName: "",
57+ LoginURL: &url.URL{},
58+ RedeemURL: &url.URL{},
59+ ProfileURL: &url.URL{},
60+ ValidateURL: &url.URL{},
61+ Scope: ""})
62+ l.JWTKey = privateKey
63+ l.Nonce = "fakenonce"
64+ return
65+}
66+
67+func TestLoginGovProviderDefaults(t *testing.T) {
68+ p, _, err := newLoginGovProvider()
69+ assert.NotEqual(t, nil, p)
70+ assert.NoError(t, err)
71+ assert.Equal(t, "login.gov", p.Data().ProviderName)
72+ assert.Equal(t, "https://secure.login.gov/openid_connect/authorize",
73+ p.Data().LoginURL.String())
74+ assert.Equal(t, "https://secure.login.gov/api/openid_connect/token",
75+ p.Data().RedeemURL.String())
76+ assert.Equal(t, "https://secure.login.gov/api/openid_connect/userinfo",
77+ p.Data().ProfileURL.String())
78+ assert.Equal(t, "email openid", p.Data().Scope)
79+}
80+
81+func TestLoginGovProviderOverrides(t *testing.T) {
82+ p := NewLoginGovProvider(
83+ &ProviderData{
84+ LoginURL: &url.URL{
85+ Scheme: "https",
86+ Host: "example.com",
87+ Path: "/oauth/auth"},
88+ RedeemURL: &url.URL{
89+ Scheme: "https",
90+ Host: "example.com",
91+ Path: "/oauth/token"},
92+ ProfileURL: &url.URL{
93+ Scheme: "https",
94+ Host: "example.com",
95+ Path: "/oauth/profile"},
96+ Scope: "profile"})
97+ assert.NotEqual(t, nil, p)
98+ assert.Equal(t, "login.gov", p.Data().ProviderName)
99+ assert.Equal(t, "https://example.com/oauth/auth",
100+ p.Data().LoginURL.String())
101+ assert.Equal(t, "https://example.com/oauth/token",
102+ p.Data().RedeemURL.String())
103+ assert.Equal(t, "https://example.com/oauth/profile",
104+ p.Data().ProfileURL.String())
105+ assert.Equal(t, "profile", p.Data().Scope)
106+}
107+
108+func TestLoginGovProviderSessionData(t *testing.T) {
109+ p, serverkey, err := newLoginGovProvider()
110+ assert.NotEqual(t, nil, p)
111+ assert.NoError(t, err)
112+
113+ // Set up the redeem endpoint here
114+ type loginGovRedeemResponse struct {
115+ AccessToken string `json:"access_token"`
116+ TokenType string `json:"token_type"`
117+ ExpiresIn int64 `json:"expires_in"`
118+ IDToken string `json:"id_token"`
119+ }
120+ expiresIn := int64(60)
121+ type MyCustomClaims struct {
122+ Acr string `json:"acr"`
123+ Nonce string `json:"nonce"`
124+ Email string `json:"email"`
125+ EmailVerified bool `json:"email_verified"`
126+ GivenName string `json:"given_name"`
127+ FamilyName string `json:"family_name"`
128+ Birthdate string `json:"birthdate"`
129+ AtHash string `json:"at_hash"`
130+ CHash string `json:"c_hash"`
131+ jwt.StandardClaims
132+ }
133+ claims := MyCustomClaims{
134+ "http://idmanagement.gov/ns/assurance/loa/1",
135+ "fakenonce",
136+ "timothy.spencer@gsa.gov",
137+ true,
138+ "",
139+ "",
140+ "",
141+ "",
142+ "",
143+ jwt.StandardClaims{
144+ Audience: "Audience",
145+ ExpiresAt: time.Now().Unix() + expiresIn,
146+ Id: "foo",
147+ IssuedAt: time.Now().Unix(),
148+ Issuer: "https://idp.int.login.gov",
149+ NotBefore: time.Now().Unix() - 1,
150+ Subject: "b2d2d115-1d7e-4579-b9d6-f8e84f4f56ca",
151+ },
152+ }
153+ idtoken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
154+ signedidtoken, err := idtoken.SignedString(serverkey.PrivKey)
155+ assert.NoError(t, err)
156+ body, err := json.Marshal(loginGovRedeemResponse{
157+ AccessToken: "a1234",
158+ TokenType: "Bearer",
159+ ExpiresIn: expiresIn,
160+ IDToken: signedidtoken,
161+ })
162+ assert.NoError(t, err)
163+ var server *httptest.Server
164+ p.RedeemURL, server = newLoginGovServer(body)
165+ defer server.Close()
166+
167+ // Set up the user endpoint here
168+ type loginGovUserResponse struct {
169+ Email string `json:"email"`
170+ EmailVerified bool `json:"email_verified"`
171+ Subject string `json:"sub"`
172+ }
173+ userbody, err := json.Marshal(loginGovUserResponse{
174+ Email: "timothy.spencer@gsa.gov",
175+ EmailVerified: true,
176+ Subject: "b2d2d115-1d7e-4579-b9d6-f8e84f4f56ca",
177+ })
178+ assert.NoError(t, err)
179+ var userserver *httptest.Server
180+ p.ProfileURL, userserver = newLoginGovServer(userbody)
181+ defer userserver.Close()
182+
183+ // Set up the PubJWKURL endpoint here used to verify the JWT
184+ var pubkeys jose.JSONWebKeySet
185+ pubkeys.Keys = append(pubkeys.Keys, serverkey.PubJWK)
186+ pubjwkbody, err := json.Marshal(pubkeys)
187+ assert.NoError(t, err)
188+ var pubjwkserver *httptest.Server
189+ p.PubJWKURL, pubjwkserver = newLoginGovServer(pubjwkbody)
190+ defer pubjwkserver.Close()
191+
192+ session, err := p.Redeem("http://redirect/", "code1234")
193+ assert.NoError(t, err)
194+ assert.NotEqual(t, session, nil)
195+ assert.Equal(t, "timothy.spencer@gsa.gov", session.Email)
196+ assert.Equal(t, "a1234", session.AccessToken)
197+
198+ // The test ought to run in under 2 seconds. If not, you may need to bump this up.
199+ assert.InDelta(t, session.ExpiresOn.Unix(), time.Now().Unix()+expiresIn, 2)
200+}
201+
202+func TestLoginGovProviderBadNonce(t *testing.T) {
203+ p, serverkey, err := newLoginGovProvider()
204+ assert.NotEqual(t, nil, p)
205+ assert.NoError(t, err)
206+
207+ // Set up the redeem endpoint here
208+ type loginGovRedeemResponse struct {
209+ AccessToken string `json:"access_token"`
210+ TokenType string `json:"token_type"`
211+ ExpiresIn int64 `json:"expires_in"`
212+ IDToken string `json:"id_token"`
213+ }
214+ expiresIn := int64(60)
215+ type MyCustomClaims struct {
216+ Acr string `json:"acr"`
217+ Nonce string `json:"nonce"`
218+ Email string `json:"email"`
219+ EmailVerified bool `json:"email_verified"`
220+ GivenName string `json:"given_name"`
221+ FamilyName string `json:"family_name"`
222+ Birthdate string `json:"birthdate"`
223+ AtHash string `json:"at_hash"`
224+ CHash string `json:"c_hash"`
225+ jwt.StandardClaims
226+ }
227+ claims := MyCustomClaims{
228+ "http://idmanagement.gov/ns/assurance/loa/1",
229+ "badfakenonce",
230+ "timothy.spencer@gsa.gov",
231+ true,
232+ "",
233+ "",
234+ "",
235+ "",
236+ "",
237+ jwt.StandardClaims{
238+ Audience: "Audience",
239+ ExpiresAt: time.Now().Unix() + expiresIn,
240+ Id: "foo",
241+ IssuedAt: time.Now().Unix(),
242+ Issuer: "https://idp.int.login.gov",
243+ NotBefore: time.Now().Unix() - 1,
244+ Subject: "b2d2d115-1d7e-4579-b9d6-f8e84f4f56ca",
245+ },
246+ }
247+ idtoken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
248+ signedidtoken, err := idtoken.SignedString(serverkey.PrivKey)
249+ assert.NoError(t, err)
250+ body, err := json.Marshal(loginGovRedeemResponse{
251+ AccessToken: "a1234",
252+ TokenType: "Bearer",
253+ ExpiresIn: expiresIn,
254+ IDToken: signedidtoken,
255+ })
256+ assert.NoError(t, err)
257+ var server *httptest.Server
258+ p.RedeemURL, server = newLoginGovServer(body)
259+ defer server.Close()
260+
261+ // Set up the user endpoint here
262+ type loginGovUserResponse struct {
263+ Email string `json:"email"`
264+ EmailVerified bool `json:"email_verified"`
265+ Subject string `json:"sub"`
266+ }
267+ userbody, err := json.Marshal(loginGovUserResponse{
268+ Email: "timothy.spencer@gsa.gov",
269+ EmailVerified: true,
270+ Subject: "b2d2d115-1d7e-4579-b9d6-f8e84f4f56ca",
271+ })
272+ assert.NoError(t, err)
273+ var userserver *httptest.Server
274+ p.ProfileURL, userserver = newLoginGovServer(userbody)
275+ defer userserver.Close()
276+
277+ // Set up the PubJWKURL endpoint here used to verify the JWT
278+ var pubkeys jose.JSONWebKeySet
279+ pubkeys.Keys = append(pubkeys.Keys, serverkey.PubJWK)
280+ pubjwkbody, err := json.Marshal(pubkeys)
281+ assert.NoError(t, err)
282+ var pubjwkserver *httptest.Server
283+ p.PubJWKURL, pubjwkserver = newLoginGovServer(pubjwkbody)
284+ defer pubjwkserver.Close()
285+
286+ _, err = p.Redeem("http://redirect/", "code1234")
287+
288+ // The "badfakenonce" in the idtoken above should cause this to error out
289+ assert.Error(t, err)
290+}
providers/providers.go+2
@@ -33,6 +33,8 @@ func New(provider string, p *ProviderData) Provider {
33 return NewGitLabProvider(p)
33 return NewGitLabProvider(p)
34 case "oidc":
34 case "oidc":
35 return NewOIDCProvider(p)
35 return NewOIDCProvider(p)
36+ case "login.gov":
37+ return NewLoginGovProvider(p)
36 default:
38 default:
37 return NewGoogleProvider(p)
39 return NewGoogleProvider(p)
38 }
40 }
providers/session_state.go+122−65
@@ -1,6 +1,7 @@
1package providers
1package providers
2
2
3import (
3import (
4+ "encoding/json"
4 "fmt"
5 "fmt"
5 "strconv"
6 "strconv"
6 "strings"
7 "strings"
@@ -11,12 +12,18 @@ import (
11
12
12// SessionState is used to store information about the currently authenticated user session
13// SessionState is used to store information about the currently authenticated user session
13type SessionState struct {
14type SessionState struct {
14− AccessToken string
15+ AccessToken string `json:",omitempty"`
15− IDToken string
16+ IDToken string `json:",omitempty"`
16− ExpiresOn time.Time
17+ ExpiresOn time.Time `json:"-"`
17− RefreshToken string
18+ RefreshToken string `json:",omitempty"`
18− Email string
19+ Email string `json:",omitempty"`
19− User string
20+ User string `json:",omitempty"`
21+}
22+
23+// SessionStateJSON is used to encode SessionState into JSON without exposing time.Time zero value
24+type SessionStateJSON struct {
25+ *SessionState
26+ ExpiresOn *time.Time `json:",omitempty"`
20}
27}
21
28
22// IsExpired checks whether the session has expired
29// IsExpired checks whether the session has expired
@@ -29,7 +36,7 @@ func (s *SessionState) IsExpired() bool {
29
36
30// String constructs a summary of the session state
37// String constructs a summary of the session state
31func (s *SessionState) String() string {
38func (s *SessionState) String() string {
32− o := fmt.Sprintf("Session{%s", s.accountInfo())
39+ o := fmt.Sprintf("Session{email:%s user:%s", s.Email, s.User)
33 if s.AccessToken != "" {
40 if s.AccessToken != "" {
34 o += " token:true"
41 o += " token:true"
35 }
42 }
@@ -47,95 +54,145 @@ func (s *SessionState) String() string {
47
54
48// EncodeSessionState returns string representation of the current session
55// EncodeSessionState returns string representation of the current session
49func (s *SessionState) EncodeSessionState(c *cookie.Cipher) (string, error) {
56func (s *SessionState) EncodeSessionState(c *cookie.Cipher) (string, error) {
50− if c == nil || s.AccessToken == "" {
57+ var ss SessionState
51− return s.accountInfo(), nil
52− }
53− return s.EncryptedString(c)
54−}
55−
56−func (s *SessionState) accountInfo() string {
57− return fmt.Sprintf("email:%s user:%s", s.Email, s.User)
58−}
59−
60−// EncryptedString encrypts the session state into a cookie string
61−func (s *SessionState) EncryptedString(c *cookie.Cipher) (string, error) {
62− var err error
63 if c == nil {
58 if c == nil {
64− panic("error. missing cipher")
59+ // Store only Email and User when cipher is unavailable
65− }
60+ ss.Email = s.Email
66− a := s.AccessToken
61+ ss.User = s.User
67− if a != "" {
62+ } else {
63+ ss = *s
64+ var err error
65+ if ss.AccessToken != "" {
66+ ss.AccessToken, err = c.Encrypt(ss.AccessToken)
68− if a, err = c.Encrypt(a); err != nil {
67+ if err != nil {
69− return "", err
68+ return "", err
69+ }
70 }
70 }
71− }
71+ if ss.IDToken != "" {
72− i := s.IDToken
73− if i != "" {
74− if i, err = c.Encrypt(i); err != nil {
72+ ss.IDToken, err = c.Encrypt(ss.IDToken)
73+ if err != nil {
75− return "", err
74+ return "", err
75+ }
76 }
76 }
77− }
77+ if ss.RefreshToken != "" {
78− r := s.RefreshToken
78+ ss.RefreshToken, err = c.Encrypt(ss.RefreshToken)
79− if r != "" {
80− if r, err = c.Encrypt(r); err != nil {
79+ if err != nil {
81− return "", err
80+ return "", err
81+ }
82 }
82 }
83 }
83 }
84− return fmt.Sprintf("%s|%s|%s|%d|%s", s.accountInfo(), a, i, s.ExpiresOn.Unix(), r), nil
84+ // Embed SessionState and ExpiresOn pointer into SessionStateJSON
85+ ssj := &SessionStateJSON{SessionState: &ss}
86+ if !ss.ExpiresOn.IsZero() {
87+ ssj.ExpiresOn = &ss.ExpiresOn
88+ }
89+ b, err := json.Marshal(ssj)
90+ return string(b), err
85}
91}
86
92
93+// legacyDecodeSessionStatePlain decodes older plain session state string
87−func decodeSessionStatePlain(v string) (s *SessionState, err error) {
94+func legacyDecodeSessionStatePlain(v string) (*SessionState, error) {
88 chunks := strings.Split(v, " ")
95 chunks := strings.Split(v, " ")
89 if len(chunks) != 2 {
96 if len(chunks) != 2 {
90− return nil, fmt.Errorf("could not decode session state: expected 2 chunks got %d", len(chunks))
97+ return nil, fmt.Errorf("invalid session state (legacy: expected 2 chunks for user/email got %d)", len(chunks))
91 }
98 }
92
99
93− email := strings.TrimPrefix(chunks[0], "email:")
94 user := strings.TrimPrefix(chunks[1], "user:")
100 user := strings.TrimPrefix(chunks[1], "user:")
95− if user == "" {
101+ email := strings.TrimPrefix(chunks[0], "email:")
96− user = strings.Split(email, "@")[0]
97− }
98
102
99 return &SessionState{User: user, Email: email}, nil
103 return &SessionState{User: user, Email: email}, nil
100}
104}
101
105
102−// DecodeSessionState decodes the session cookie string into a SessionState
106+// legacyDecodeSessionState attempts to decode the session state string
107+// generated by v3.1.0 or older
103−func DecodeSessionState(v string, c *cookie.Cipher) (s *SessionState, err error) {
108+func legacyDecodeSessionState(v string, c *cookie.Cipher) (*SessionState, error) {
109+ chunks := strings.Split(v, "|")
110+
104 if c == nil {
111 if c == nil {
105− return decodeSessionStatePlain(v)
112+ if len(chunks) != 1 {
113+ return nil, fmt.Errorf("invalid session state (legacy: expected 1 chunk for plain got %d)", len(chunks))
114+ }
115+ return legacyDecodeSessionStatePlain(chunks[0])
106 }
116 }
107
117
108− chunks := strings.Split(v, "|")
109− if len(chunks) != 5 {
118+ if len(chunks) != 4 && len(chunks) != 5 {
110− err = fmt.Errorf("invalid number of fields (got %d expected 5)", len(chunks))
119+ return nil, fmt.Errorf("invalid session state (legacy: expected 4 or 5 chunks for full got %d)", len(chunks))
111− return
112 }
120 }
113
121
114− sessionState, err := decodeSessionStatePlain(chunks[0])
122+ i := 0
123+ ss, err := legacyDecodeSessionStatePlain(chunks[i])
115 if err != nil {
124 if err != nil {
116 return nil, err
125 return nil, err
117 }
126 }
118
127
119− if chunks[1] != "" {
128+ i++
120− if sessionState.AccessToken, err = c.Decrypt(chunks[1]); err != nil {
129+ ss.AccessToken = chunks[i]
121− return nil, err
130+
122− }
131+ if len(chunks) == 5 {
132+ // SessionState with IDToken in v3.1.0
133+ i++
134+ ss.IDToken = chunks[i]
123 }
135 }
124
136
125− if chunks[2] != "" {
137+ i++
126− if sessionState.IDToken, err = c.Decrypt(chunks[2]); err != nil {
138+ ts, err := strconv.Atoi(chunks[i])
127− return nil, err
139+ if err != nil {
128− }
140+ return nil, fmt.Errorf("invalid session state (legacy: wrong expiration time: %s)", err)
129 }
141 }
142+ ss.ExpiresOn = time.Unix(int64(ts), 0)
143+
144+ i++
145+ ss.RefreshToken = chunks[i]
130
146
131− ts, _ := strconv.Atoi(chunks[3])
147+ return ss, nil
132− sessionState.ExpiresOn = time.Unix(int64(ts), 0)
148+}
133
149
134− if chunks[4] != "" {
150+// DecodeSessionState decodes the session cookie string into a SessionState
135− if sessionState.RefreshToken, err = c.Decrypt(chunks[4]); err != nil {
151+func DecodeSessionState(v string, c *cookie.Cipher) (*SessionState, error) {
152+ var ssj SessionStateJSON
153+ var ss *SessionState
154+ err := json.Unmarshal([]byte(v), &ssj)
155+ if err == nil && ssj.SessionState != nil {
156+ // Extract SessionState and ExpiresOn value from SessionStateJSON
157+ ss = ssj.SessionState
158+ if ssj.ExpiresOn != nil {
159+ ss.ExpiresOn = *ssj.ExpiresOn
160+ }
161+ } else {
162+ // Try to decode a legacy string when json.Unmarshal failed
163+ ss, err = legacyDecodeSessionState(v, c)
164+ if err != nil {
136 return nil, err
165 return nil, err
137 }
166 }
138 }
167 }
139−
168+ if c == nil {
169+ // Load only Email and User when cipher is unavailable
170+ ss = &SessionState{
171+ Email: ss.Email,
172+ User: ss.User,
173+ }
174+ } else {
175+ if ss.AccessToken != "" {
176+ ss.AccessToken, err = c.Decrypt(ss.AccessToken)
177+ if err != nil {
178+ return nil, err
179+ }
180+ }
181+ if ss.IDToken != "" {
182+ ss.IDToken, err = c.Decrypt(ss.IDToken)
183+ if err != nil {
184+ return nil, err
185+ }
186+ }
187+ if ss.RefreshToken != "" {
188+ ss.RefreshToken, err = c.Decrypt(ss.RefreshToken)
189+ if err != nil {
190+ return nil, err
191+ }
192+ }
193+ }
194+ if ss.User == "" {
195+ ss.User = strings.Split(ss.Email, "@")[0]
196+ }
140− return sessionState, nil
197+ return ss, nil
141}
198}
providers/session_state_test.go+182−20
@@ -2,7 +2,6 @@ package providers
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "strings"
6 "testing"
5 "testing"
7 "time"
6 "time"
8
7
@@ -27,7 +26,6 @@ func TestSessionStateSerialization(t *testing.T) {
27 }
26 }
28 encoded, err := s.EncodeSessionState(c)
27 encoded, err := s.EncodeSessionState(c)
29 assert.Equal(t, nil, err)
28 assert.Equal(t, nil, err)
30− assert.Equal(t, 4, strings.Count(encoded, "|"))
31
29
32 ss, err := DecodeSessionState(encoded, c)
30 ss, err := DecodeSessionState(encoded, c)
33 t.Logf("%#v", ss)
31 t.Logf("%#v", ss)
@@ -65,7 +63,6 @@ func TestSessionStateSerializationWithUser(t *testing.T) {
65 }
63 }
66 encoded, err := s.EncodeSessionState(c)
64 encoded, err := s.EncodeSessionState(c)
67 assert.Equal(t, nil, err)
65 assert.Equal(t, nil, err)
68− assert.Equal(t, 4, strings.Count(encoded, "|"))
69
66
70 ss, err := DecodeSessionState(encoded, c)
67 ss, err := DecodeSessionState(encoded, c)
71 t.Logf("%#v", ss)
68 t.Logf("%#v", ss)
@@ -96,8 +93,6 @@ func TestSessionStateSerializationNoCipher(t *testing.T) {
96 }
93 }
97 encoded, err := s.EncodeSessionState(nil)
94 encoded, err := s.EncodeSessionState(nil)
98 assert.Equal(t, nil, err)
95 assert.Equal(t, nil, err)
99− expected := fmt.Sprintf("email:%s user:", s.Email)
100− assert.Equal(t, expected, encoded)
101
96
102 // only email should have been serialized
97 // only email should have been serialized
103 ss, err := DecodeSessionState(encoded, nil)
98 ss, err := DecodeSessionState(encoded, nil)
@@ -118,8 +113,6 @@ func TestSessionStateSerializationNoCipherWithUser(t *testing.T) {
118 }
113 }
119 encoded, err := s.EncodeSessionState(nil)
114 encoded, err := s.EncodeSessionState(nil)
120 assert.Equal(t, nil, err)
115 assert.Equal(t, nil, err)
121− expected := fmt.Sprintf("email:%s user:%s", s.Email, s.User)
122− assert.Equal(t, expected, encoded)
123
116
124 // only email should have been serialized
117 // only email should have been serialized
125 ss, err := DecodeSessionState(encoded, nil)
118 ss, err := DecodeSessionState(encoded, nil)
@@ -130,19 +123,6 @@ func TestSessionStateSerializationNoCipherWithUser(t *testing.T) {
130 assert.Equal(t, "", ss.RefreshToken)
123 assert.Equal(t, "", ss.RefreshToken)
131}
124}
132
125
133−func TestSessionStateAccountInfo(t *testing.T) {
134− s := &SessionState{
135− Email: "user@domain.com",
136− User: "just-user",
137− }
138− expected := fmt.Sprintf("email:%v user:%v", s.Email, s.User)
139− assert.Equal(t, expected, s.accountInfo())
140−
141− s.Email = ""
142− expected = fmt.Sprintf("email:%v user:%v", s.Email, s.User)
143− assert.Equal(t, expected, s.accountInfo())
144−}
145−
146func TestExpired(t *testing.T) {
126func TestExpired(t *testing.T) {
147 s := &SessionState{ExpiresOn: time.Now().Add(time.Duration(-1) * time.Minute)}
127 s := &SessionState{ExpiresOn: time.Now().Add(time.Duration(-1) * time.Minute)}
148 assert.Equal(t, true, s.IsExpired())
128 assert.Equal(t, true, s.IsExpired())
@@ -153,3 +133,185 @@ func TestExpired(t *testing.T) {
153 s = &SessionState{}
133 s = &SessionState{}
154 assert.Equal(t, false, s.IsExpired())
134 assert.Equal(t, false, s.IsExpired())
155}
135}
136+
137+type testCase struct {
138+ SessionState
139+ Encoded string
140+ Cipher *cookie.Cipher
141+ Error bool
142+}
143+
144+// TestEncodeSessionState tests EncodeSessionState with the test vector
145+//
146+// Currently only tests without cipher here because we have no way to mock
147+// the random generator used in EncodeSessionState.
148+func TestEncodeSessionState(t *testing.T) {
149+ e := time.Now().Add(time.Duration(1) * time.Hour)
150+
151+ testCases := []testCase{
152+ {
153+ SessionState: SessionState{
154+ Email: "user@domain.com",
155+ User: "just-user",
156+ },
157+ Encoded: `{"Email":"user@domain.com","User":"just-user"}`,
158+ },
159+ {
160+ SessionState: SessionState{
161+ Email: "user@domain.com",
162+ User: "just-user",
163+ AccessToken: "token1234",
164+ IDToken: "rawtoken1234",
165+ ExpiresOn: e,
166+ RefreshToken: "refresh4321",
167+ },
168+ Encoded: `{"Email":"user@domain.com","User":"just-user"}`,
169+ },
170+ }
171+
172+ for i, tc := range testCases {
173+ encoded, err := tc.EncodeSessionState(tc.Cipher)
174+ t.Logf("i:%d Encoded:%#v SessionState:%#v Error:%#v", i, encoded, tc.SessionState, err)
175+ if tc.Error {
176+ assert.Error(t, err)
177+ assert.Empty(t, encoded)
178+ continue
179+ }
180+ assert.NoError(t, err)
181+ assert.JSONEq(t, tc.Encoded, encoded)
182+ }
183+}
184+
185+// TestDecodeSessionState tests DecodeSessionState with the test vector
186+func TestDecodeSessionState(t *testing.T) {
187+ e := time.Now().Add(time.Duration(1) * time.Hour)
188+ eJSON, _ := e.MarshalJSON()
189+ eString := string(eJSON)
190+ eUnix := e.Unix()
191+
192+ c, err := cookie.NewCipher([]byte(secret))
193+ assert.NoError(t, err)
194+
195+ testCases := []testCase{
196+ {
197+ SessionState: SessionState{
198+ Email: "user@domain.com",
199+ User: "just-user",
200+ },
201+ Encoded: `{"Email":"user@domain.com","User":"just-user"}`,
202+ },
203+ {
204+ SessionState: SessionState{
205+ Email: "user@domain.com",
206+ User: "user",
207+ },
208+ Encoded: `{"Email":"user@domain.com"}`,
209+ },
210+ {
211+ SessionState: SessionState{
212+ User: "just-user",
213+ },
214+ Encoded: `{"User":"just-user"}`,
215+ },
216+ {
217+ SessionState: SessionState{
218+ Email: "user@domain.com",
219+ User: "just-user",
220+ },
221+ Encoded: fmt.Sprintf(`{"Email":"user@domain.com","User":"just-user","AccessToken":"I6s+ml+/MldBMgHIiC35BTKTh57skGX24w==","IDToken":"xojNdyyjB1HgYWh6XMtXY/Ph5eCVxa1cNsklJw==","RefreshToken":"qEX0x6RmASxo4dhlBG6YuRs9Syn/e9sHu/+K","ExpiresOn":%s}`, eString),
222+ },
223+ {
224+ SessionState: SessionState{
225+ Email: "user@domain.com",
226+ User: "just-user",
227+ AccessToken: "token1234",
228+ IDToken: "rawtoken1234",
229+ ExpiresOn: e,
230+ RefreshToken: "refresh4321",
231+ },
232+ Encoded: fmt.Sprintf(`{"Email":"user@domain.com","User":"just-user","AccessToken":"I6s+ml+/MldBMgHIiC35BTKTh57skGX24w==","IDToken":"xojNdyyjB1HgYWh6XMtXY/Ph5eCVxa1cNsklJw==","RefreshToken":"qEX0x6RmASxo4dhlBG6YuRs9Syn/e9sHu/+K","ExpiresOn":%s}`, eString),
233+ Cipher: c,
234+ },
235+ {
236+ SessionState: SessionState{
237+ Email: "user@domain.com",
238+ User: "just-user",
239+ },
240+ Encoded: `{"Email":"user@domain.com","User":"just-user"}`,
241+ Cipher: c,
242+ },
243+ {
244+ Encoded: `{"Email":"user@domain.com","User":"just-user","AccessToken":"X"}`,
245+ Cipher: c,
246+ Error: true,
247+ },
248+ {
249+ Encoded: `{"Email":"user@domain.com","User":"just-user","IDToken":"XXXX"}`,
250+ Cipher: c,
251+ Error: true,
252+ },
253+ {
254+ SessionState: SessionState{
255+ User: "just-user",
256+ Email: "user@domain.com",
257+ },
258+ Encoded: "email:user@domain.com user:just-user",
259+ },
260+ {
261+ Encoded: "email:user@domain.com user:just-user||||",
262+ Error: true,
263+ },
264+ {
265+ Encoded: "email:user@domain.com user:just-user",
266+ Cipher: c,
267+ Error: true,
268+ },
269+ {
270+ Encoded: "email:user@domain.com user:just-user|||99999999999999999999|",
271+ Cipher: c,
272+ Error: true,
273+ },
274+ {
275+ SessionState: SessionState{
276+ Email: "user@domain.com",
277+ User: "just-user",
278+ AccessToken: "token1234",
279+ ExpiresOn: e,
280+ RefreshToken: "refresh4321",
281+ },
282+ Encoded: fmt.Sprintf("email:user@domain.com user:just-user|I6s+ml+/MldBMgHIiC35BTKTh57skGX24w==|%d|qEX0x6RmASxo4dhlBG6YuRs9Syn/e9sHu/+K", eUnix),
283+ Cipher: c,
284+ },
285+ {
286+ SessionState: SessionState{
287+ Email: "user@domain.com",
288+ User: "just-user",
289+ AccessToken: "token1234",
290+ IDToken: "rawtoken1234",
291+ ExpiresOn: e,
292+ RefreshToken: "refresh4321",
293+ },
294+ Encoded: fmt.Sprintf("email:user@domain.com user:just-user|I6s+ml+/MldBMgHIiC35BTKTh57skGX24w==|xojNdyyjB1HgYWh6XMtXY/Ph5eCVxa1cNsklJw==|%d|qEX0x6RmASxo4dhlBG6YuRs9Syn/e9sHu/+K", eUnix),
295+ Cipher: c,
296+ },
297+ }
298+
299+ for i, tc := range testCases {
300+ ss, err := DecodeSessionState(tc.Encoded, tc.Cipher)
301+ t.Logf("i:%d Encoded:%#v SessionState:%#v Error:%#v", i, tc.Encoded, ss, err)
302+ if tc.Error {
303+ assert.Error(t, err)
304+ assert.Nil(t, ss)
305+ continue
306+ }
307+ assert.NoError(t, err)
308+ if assert.NotNil(t, ss) {
309+ assert.Equal(t, tc.User, ss.User)
310+ assert.Equal(t, tc.Email, ss.Email)
311+ assert.Equal(t, tc.AccessToken, ss.AccessToken)
312+ assert.Equal(t, tc.RefreshToken, ss.RefreshToken)
313+ assert.Equal(t, tc.IDToken, ss.IDToken)
314+ assert.Equal(t, tc.ExpiresOn.Unix(), ss.ExpiresOn.Unix())
315+ }
316+ }
317+}
string_array.go+5
@@ -7,6 +7,11 @@ import (
7// StringArray is a type alias for a slice of strings
7// StringArray is a type alias for a slice of strings
8type StringArray []string
8type StringArray []string
9
9
10+// Get returns the slice of strings
11+func (a *StringArray) Get() interface{} {
12+ return []string(*a)
13+}
14+
10// Set appends a string to the StringArray
15// Set appends a string to the StringArray
11func (a *StringArray) Set(s string) error {
16func (a *StringArray) Set(s string) error {
12 *a = append(*a, s)
17 *a = append(*a, s)