git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge remote-tracking branch 'upstream/master' into verified
commit95ee4358b266185183e28885b5089d989b852e9f authorCarlos Alexandro Becker <caarlos0@gmail.com> dateWed 20 Mar 2019, 13:46 (8 years ago) parent58b8bbeca89bb8
15 files changed+1087−144
.github/CODEOWNERS+9 CHANGELOG.md+7 Gopkg.lock+9 README.md+49 logging_handler_test.go+4−3 main.go+6 oauthproxy.go+14−5 oauthproxy_test.go+44 options.go+69−51 providers/logingov.go+275 providers/logingov_test.go+290 providers/providers.go+2 providers/session_state.go+122−65 providers/session_state_test.go+182−20 string_array.go+5
.github/CODEOWNERS+9
@@ -1,3 +1,12 @@
11# Default owner should be a Pusher cloud-team member unless overridden by later
22# rules in this file
33* @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 @@
33## Changes since v3.1.0
44
55- [#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)
613- [#92](https://github.com/pusher/oauth2_proxy/pull/92) Merge websocket proxy feature from openshift/oauth-proxy (@butzist)
714- [#57](https://github.com/pusher/oauth2_proxy/pull/57) Fall back to using OIDC Subject instead of Email (@aigarius)
815- [#85](https://github.com/pusher/oauth2_proxy/pull/85) Use non-root user in docker images (@kskewes)
Gopkg.lock+9
@@ -41,6 +41,14 @@
4141 revision = "346938d642f2ec3594ed81d874461961cd0faa76"
4242 version = "v1.1.0"
4343
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+
4452[[projects]]
4553 branch = "master"
4654 digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4"
@@ -201,6 +209,7 @@
201209 "github.com/BurntSushi/toml",
202210 "github.com/bitly/go-simplejson",
203211 "github.com/coreos/go-oidc",
212+ "github.com/dgrijalva/jwt-go",
204213 "github.com/mbland/hmacauth",
205214 "github.com/mreiferson/go-options",
206215 "github.com/stretchr/testify/assert",
README.md+49
@@ -48,6 +48,7 @@ Valid providers are :
4848- [GitHub](#github-auth-provider)
4949- [GitLab](#gitlab-auth-provider)
5050- [LinkedIn](#linkedin-auth-provider)
51+- [login.gov](#login.gov-provider)
5152
5253The provider can be selected using the `provider` configuration value.
5354
@@ -166,6 +167,54 @@ OpenID Connect is a spec for OAUTH 2.0 + identity that is implemented by many ma
166167 -cookie-secure=false
167168 -email-domain example.com
168169
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+
169218#### Skip OIDC discovery
170219
171220Some 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 (
55 "fmt"
66 "net/http"
77 "net/http/httptest"
8+ "strings"
89 "testing"
910 "time"
1011)
@@ -16,7 +17,7 @@ func TestLoggingHandler_ServeHTTP(t *testing.T) {
1617 Format,
1718 ExpectedLogMessage string
1819 }{
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"))},
2021 {"{{.RequestMethod}}", "GET\n"},
2122 }
2223
@@ -35,8 +36,8 @@ func TestLoggingHandler_ServeHTTP(t *testing.T) {
3536 h.ServeHTTP(httptest.NewRecorder(), r)
3637
3738 actual := buf.String()
38− if actual != test.ExpectedLogMessage {
39− t.Errorf("Log message was\n%s\ninstead of expected \n%s", actual, test.ExpectedLogMessage)
39+ if !strings.Contains(actual, test.ExpectedLogMessage) {
40+ t.Errorf("Log message was\n%s\ninstead of matching \n%s", actual, test.ExpectedLogMessage)
4041 }
4142 }
4243}
main.go+6
@@ -4,6 +4,7 @@ import (
44 "flag"
55 "fmt"
66 "log"
7+ "math/rand"
78 "os"
89 "runtime"
910 "strings"
@@ -88,6 +89,9 @@ func main() {
8889 flagSet.String("approval-prompt", "force", "OAuth approval_prompt")
8990
9091 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")
9195
9296 flagSet.Parse(os.Args[1:])
9397
@@ -133,6 +137,8 @@ func main() {
133137 }
134138 }
135139
140+ rand.Seed(time.Now().UnixNano())
141+
136142 s := &Server{
137143 Handler: LoggingHandler(os.Stdout, oauthproxy, opts.RequestLogging, opts.RequestLoggingFormat),
138144 Opts: opts,
oauthproxy.go+14−5
@@ -204,7 +204,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
204204 }
205205
206206 redirectURL := opts.redirectURL
207− if redirectURL.String() == "" {
207+ if redirectURL.Path == "" {
208208 redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix)
209209 }
210210
@@ -241,7 +241,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
241241 SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
242242 SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
243243 OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
244− OAuthCallbackPath: redirectURL.Path,
244+ OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix),
245245 AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
246246
247247 ProxyPrefix: opts.ProxyPrefix,
@@ -452,9 +452,18 @@ func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, va
452452// ClearSessionCookie creates a cookie to unset the user's authentication cookie
453453// stored in the user's session
454454func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) {
455− cookies := p.MakeSessionCookie(req, "", time.Hour*-1, time.Now())
456− for _, clr := range cookies {
457− http.SetCookie(rw, clr)
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) {
462+ clearCookie := p.makeCookie(req, c.Name, "", time.Hour*-1, time.Now())
463+
464+ http.SetCookie(rw, clearCookie)
465+ cookies = append(cookies, clearCookie)
466+ }
458467 }
459468
460469 // ugly hack because default domain changed
oauthproxy_test.go+44
@@ -1064,3 +1064,47 @@ func TestAjaxForbiddendRequest(t *testing.T) {
10641064 mime := rh.Get("Content-Type")
10651065 assert.NotEqual(t, applicationJSON, mime)
10661066}
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 (
1414 "time"
1515
1616 oidc "github.com/coreos/go-oidc"
17+ "github.com/dgrijalva/jwt-go"
1718 "github.com/mbland/hmacauth"
1819 "github.com/pusher/oauth2_proxy/providers"
1920)
@@ -21,71 +22,74 @@ import (
2122// Options holds Configuration Options that can be set by Command Line Flag,
2223// or Config File
2324type Options struct {
24− ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy-prefix"`
25− ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets"`
26− HTTPAddress string `flag:"http-address" cfg:"http_address"`
27− HTTPSAddress string `flag:"https-address" cfg:"https_address"`
28− RedirectURL string `flag:"redirect-url" cfg:"redirect_url"`
25+ ProxyPrefix string `flag:"proxy-prefix" cfg:"proxy-prefix" env:"OAUTH2_PROXY_PROXY_PREFIX"`
26+ ProxyWebSockets bool `flag:"proxy-websockets" cfg:"proxy_websockets" env:"OAUTH2_PROXY_PROXY_WEBSOCKETS"`
27+ HTTPAddress string `flag:"http-address" cfg:"http_address" env:"OAUTH2_PROXY_HTTP_ADDRESS"`
28+ HTTPSAddress string `flag:"https-address" cfg:"https_address" env:"OAUTH2_PROXY_HTTPS_ADDRESS"`
29+ RedirectURL string `flag:"redirect-url" cfg:"redirect_url" env:"OAUTH2_PROXY_REDIRECT_URL"`
2930 ClientID string `flag:"client-id" cfg:"client_id" env:"OAUTH2_PROXY_CLIENT_ID"`
3031 ClientSecret string `flag:"client-secret" cfg:"client_secret" env:"OAUTH2_PROXY_CLIENT_SECRET"`
31− TLSCertFile string `flag:"tls-cert" cfg:"tls_cert_file"`
32− TLSKeyFile string `flag:"tls-key" cfg:"tls_key_file"`
32+ TLSCertFile string `flag:"tls-cert" cfg:"tls_cert_file" env:"OAUTH2_PROXY_TLS_CERT_FILE"`
33+ TLSKeyFile string `flag:"tls-key" cfg:"tls_key_file" env:"OAUTH2_PROXY_TLS_KEY_FILE"`
3334
34− AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
35− AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"`
36− EmailDomains []string `flag:"email-domain" cfg:"email_domains"`
35+ AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file" env:"OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE"`
36+ AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant" env:"OAUTH2_PROXY_AZURE_TENANT"`
37+ EmailDomains []string `flag:"email-domain" cfg:"email_domains" env:"OAUTH2_PROXY_EMAIL_DOMAINS"`
3738 WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
38− GitHubOrg string `flag:"github-org" cfg:"github_org"`
39− GitHubTeam string `flag:"github-team" cfg:"github_team"`
40− GoogleGroups []string `flag:"google-group" cfg:"google_group"`
41− GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"`
42− GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"`
43− HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"`
44− DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"`
45− CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir"`
46− Footer string `flag:"footer" cfg:"footer"`
39+ GitHubOrg string `flag:"github-org" cfg:"github_org" env:"OAUTH2_PROXY_GITHUB_ORG"`
40+ GitHubTeam string `flag:"github-team" cfg:"github_team" env:"OAUTH2_PROXY_GITHUB_TEAM"`
41+ GoogleGroups []string `flag:"google-group" cfg:"google_group" env:"OAUTH2_PROXY_GOOGLE_GROUPS"`
42+ GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email" env:"OAUTH2_PROXY_GOOGLE_ADMIN_EMAIL"`
43+ GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json" env:"OAUTH2_PROXY_GOOGLE_SERVICE_ACCOUNT_JSON"`
44+ HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file" env:"OAUTH2_PROXY_HTPASSWD_FILE"`
45+ DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form" env:"OAUTH2_PROXY_DISPLAY_HTPASSWD_FORM"`
46+ CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir" env:"OAUTH2_PROXY_CUSTOM_TEMPLATES_DIR"`
47+ Footer string `flag:"footer" cfg:"footer" env:"OAUTH2_PROXY_FOOTER"`
4748
4849 CookieName string `flag:"cookie-name" cfg:"cookie_name" env:"OAUTH2_PROXY_COOKIE_NAME"`
4950 CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret" env:"OAUTH2_PROXY_COOKIE_SECRET"`
5051 CookieDomain string `flag:"cookie-domain" cfg:"cookie_domain" env:"OAUTH2_PROXY_COOKIE_DOMAIN"`
5152 CookieExpire time.Duration `flag:"cookie-expire" cfg:"cookie_expire" env:"OAUTH2_PROXY_COOKIE_EXPIRE"`
5253 CookieRefresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh" env:"OAUTH2_PROXY_COOKIE_REFRESH"`
53− CookieSecure bool `flag:"cookie-secure" cfg:"cookie_secure"`
54− CookieHTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
55−
56− Upstreams []string `flag:"upstream" cfg:"upstreams"`
57− SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
58− PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
59− BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"`
60− PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"`
61− PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"`
62− SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button"`
63− PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"`
64− SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify"`
65− SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest"`
66− SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header"`
67− PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"`
68− SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight"`
69− FlushInterval time.Duration `flag:"flush-interval" cfg:"flush_interval"`
54+ CookieSecure bool `flag:"cookie-secure" cfg:"cookie_secure" env:"OAUTH2_PROXY_COOKIE_SECURE"`
55+ CookieHTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly" env:"OAUTH2_PROXY_COOKIE_HTTPONLY"`
56+
57+ Upstreams []string `flag:"upstream" cfg:"upstreams" env:"OAUTH2_PROXY_UPSTREAMS"`
58+ SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex" env:"OAUTH2_PROXY_SKIP_AUTH_REGEX"`
59+ PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth" env:"OAUTH2_PROXY_PASS_BASIC_AUTH"`
60+ BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password" env:"OAUTH2_PROXY_BASIC_AUTH_PASSWORD"`
61+ PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token" env:"OAUTH2_PROXY_PASS_ACCESS_TOKEN"`
62+ PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header" env:"OAUTH2_PROXY_PASS_HOST_HEADER"`
63+ SkipProviderButton bool `flag:"skip-provider-button" cfg:"skip_provider_button" env:"OAUTH2_PROXY_SKIP_PROVIDER_BUTTON"`
64+ PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers" env:"OAUTH2_PROXY_PASS_USER_HEADERS"`
65+ SSLInsecureSkipVerify bool `flag:"ssl-insecure-skip-verify" cfg:"ssl_insecure_skip_verify" env:"OAUTH2_PROXY_SSL_INSECURE_SKIP_VERIFY"`
66+ SetXAuthRequest bool `flag:"set-xauthrequest" cfg:"set_xauthrequest" env:"OAUTH2_PROXY_SET_XAUTHREQUEST"`
67+ SetAuthorization bool `flag:"set-authorization-header" cfg:"set_authorization_header" env:"OAUTH2_PROXY_SET_AUTHORIZATION_HEADER"`
68+ PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header" env:"OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER"`
69+ SkipAuthPreflight bool `flag:"skip-auth-preflight" cfg:"skip_auth_preflight" env:"OAUTH2_PROXY_SKIP_AUTH_PREFLIGHT"`
70+ FlushInterval time.Duration `flag:"flush-interval" cfg:"flush_interval" env:"OAUTH2_PROXY_FLUSH_INTERVAL"`
7071
7172 // These options allow for other providers besides Google, with
7273 // potential overrides.
73− Provider string `flag:"provider" cfg:"provider"`
74− OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"`
75− SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"`
76− OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"`
77− LoginURL string `flag:"login-url" cfg:"login_url"`
78− RedeemURL string `flag:"redeem-url" cfg:"redeem_url"`
79− ProfileURL string `flag:"profile-url" cfg:"profile_url"`
80− ProtectedResource string `flag:"resource" cfg:"resource"`
81− ValidateURL string `flag:"validate-url" cfg:"validate_url"`
82− Scope string `flag:"scope" cfg:"scope"`
83− ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"`
84−
85− RequestLogging bool `flag:"request-logging" cfg:"request_logging"`
86− RequestLoggingFormat string `flag:"request-logging-format" cfg:"request_logging_format"`
74+ Provider string `flag:"provider" cfg:"provider" env:"OAUTH2_PROXY_PROVIDER"`
75+ OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url" env:"OAUTH2_PROXY_OIDC_ISSUER_URL"`
76+ SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery" env:"OAUTH2_SKIP_OIDC_DISCOVERY"`
77+ OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url" env:"OAUTH2_OIDC_JWKS_URL"`
78+ LoginURL string `flag:"login-url" cfg:"login_url" env:"OAUTH2_PROXY_LOGIN_URL"`
79+ RedeemURL string `flag:"redeem-url" cfg:"redeem_url" env:"OAUTH2_PROXY_REDEEM_URL"`
80+ ProfileURL string `flag:"profile-url" cfg:"profile_url" env:"OAUTH2_PROXY_PROFILE_URL"`
81+ ProtectedResource string `flag:"resource" cfg:"resource" env:"OAUTH2_PROXY_RESOURCE"`
82+ ValidateURL string `flag:"validate-url" cfg:"validate_url" env:"OAUTH2_PROXY_VALIDATE_URL"`
83+ Scope string `flag:"scope" cfg:"scope" env:"OAUTH2_PROXY_SCOPE"`
84+ ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt" env:"OAUTH2_PROXY_APPROVAL_PROMPT"`
85+
86+ RequestLogging bool `flag:"request-logging" cfg:"request_logging" env:"OAUTH2_PROXY_REQUEST_LOGGING"`
87+ RequestLoggingFormat string `flag:"request-logging-format" cfg:"request_logging_format" env:"OAUTH2_PROXY_REQUEST_LOGGING_FORMAT"`
8788
8889 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"`
8993
9094 // internal values that are set after config validation
9195 redirectURL *url.URL
@@ -157,7 +161,8 @@ func (o *Options) Validate() error {
157161 if o.ClientID == "" {
158162 msgs = append(msgs, "missing setting: client-id")
159163 }
160− if o.ClientSecret == "" {
164+ // login.gov uses a signed JWT to authenticate, not a client-secret
165+ if o.ClientSecret == "" && o.Provider != "login.gov" {
161166 msgs = append(msgs, "missing setting: client-secret")
162167 }
163168 if o.AuthenticatedEmailsFile == "" && len(o.EmailDomains) == 0 && o.HtpasswdFile == "" {
@@ -318,6 +323,19 @@ func parseProviderInfo(o *Options, msgs []string) []string {
318323 } else {
319324 p.Verifier = o.oidcVerifier
320325 }
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+ }
321339 }
322340 return msgs
323341}
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 {
3333 return NewGitLabProvider(p)
3434 case "oidc":
3535 return NewOIDCProvider(p)
36+ case "login.gov":
37+ return NewLoginGovProvider(p)
3638 default:
3739 return NewGoogleProvider(p)
3840 }
providers/session_state.go+122−65
@@ -1,6 +1,7 @@
11package providers
22
33import (
4+ "encoding/json"
45 "fmt"
56 "strconv"
67 "strings"
@@ -11,12 +12,18 @@ import (
1112
1213// SessionState is used to store information about the currently authenticated user session
1314type SessionState struct {
14− AccessToken string
15− IDToken string
16− ExpiresOn time.Time
17− RefreshToken string
18− Email string
19− User string
15+ AccessToken string `json:",omitempty"`
16+ IDToken string `json:",omitempty"`
17+ ExpiresOn time.Time `json:"-"`
18+ RefreshToken string `json:",omitempty"`
19+ Email string `json:",omitempty"`
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"`
2027}
2128
2229// IsExpired checks whether the session has expired
@@ -29,7 +36,7 @@ func (s *SessionState) IsExpired() bool {
2936
3037// String constructs a summary of the session state
3138func (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)
3340 if s.AccessToken != "" {
3441 o += " token:true"
3542 }
@@ -47,95 +54,145 @@ func (s *SessionState) String() string {
4754
4855// EncodeSessionState returns string representation of the current session
4956func (s *SessionState) EncodeSessionState(c *cookie.Cipher) (string, error) {
50− if c == nil || s.AccessToken == "" {
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
57+ var ss SessionState
6358 if c == nil {
64− panic("error. missing cipher")
65− }
66− a := s.AccessToken
67− if a != "" {
68− if a, err = c.Encrypt(a); err != nil {
69− return "", err
59+ // Store only Email and User when cipher is unavailable
60+ ss.Email = s.Email
61+ ss.User = s.User
62+ } else {
63+ ss = *s
64+ var err error
65+ if ss.AccessToken != "" {
66+ ss.AccessToken, err = c.Encrypt(ss.AccessToken)
67+ if err != nil {
68+ return "", err
69+ }
7070 }
71− }
72− i := s.IDToken
73− if i != "" {
74− if i, err = c.Encrypt(i); err != nil {
75− return "", err
71+ if ss.IDToken != "" {
72+ ss.IDToken, err = c.Encrypt(ss.IDToken)
73+ if err != nil {
74+ return "", err
75+ }
7676 }
77− }
78− r := s.RefreshToken
79− if r != "" {
80− if r, err = c.Encrypt(r); err != nil {
81− return "", err
77+ if ss.RefreshToken != "" {
78+ ss.RefreshToken, err = c.Encrypt(ss.RefreshToken)
79+ if err != nil {
80+ return "", err
81+ }
8282 }
8383 }
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
8591}
8692
87−func decodeSessionStatePlain(v string) (s *SessionState, err error) {
93+// legacyDecodeSessionStatePlain decodes older plain session state string
94+func legacyDecodeSessionStatePlain(v string) (*SessionState, error) {
8895 chunks := strings.Split(v, " ")
8996 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))
9198 }
9299
93− email := strings.TrimPrefix(chunks[0], "email:")
94100 user := strings.TrimPrefix(chunks[1], "user:")
95− if user == "" {
96− user = strings.Split(email, "@")[0]
97− }
101+ email := strings.TrimPrefix(chunks[0], "email:")
98102
99103 return &SessionState{User: user, Email: email}, nil
100104}
101105
102−// DecodeSessionState decodes the session cookie string into a SessionState
103−func DecodeSessionState(v string, c *cookie.Cipher) (s *SessionState, err error) {
106+// legacyDecodeSessionState attempts to decode the session state string
107+// generated by v3.1.0 or older
108+func legacyDecodeSessionState(v string, c *cookie.Cipher) (*SessionState, error) {
109+ chunks := strings.Split(v, "|")
110+
104111 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])
106116 }
107117
108− chunks := strings.Split(v, "|")
109− if len(chunks) != 5 {
110− err = fmt.Errorf("invalid number of fields (got %d expected 5)", len(chunks))
111− return
118+ if len(chunks) != 4 && len(chunks) != 5 {
119+ return nil, fmt.Errorf("invalid session state (legacy: expected 4 or 5 chunks for full got %d)", len(chunks))
112120 }
113121
114− sessionState, err := decodeSessionStatePlain(chunks[0])
122+ i := 0
123+ ss, err := legacyDecodeSessionStatePlain(chunks[i])
115124 if err != nil {
116125 return nil, err
117126 }
118127
119− if chunks[1] != "" {
120− if sessionState.AccessToken, err = c.Decrypt(chunks[1]); err != nil {
121− return nil, err
122− }
128+ i++
129+ ss.AccessToken = chunks[i]
130+
131+ if len(chunks) == 5 {
132+ // SessionState with IDToken in v3.1.0
133+ i++
134+ ss.IDToken = chunks[i]
123135 }
124136
125− if chunks[2] != "" {
126− if sessionState.IDToken, err = c.Decrypt(chunks[2]); err != nil {
127− return nil, err
128− }
137+ i++
138+ ts, err := strconv.Atoi(chunks[i])
139+ if err != nil {
140+ return nil, fmt.Errorf("invalid session state (legacy: wrong expiration time: %s)", err)
129141 }
142+ ss.ExpiresOn = time.Unix(int64(ts), 0)
143+
144+ i++
145+ ss.RefreshToken = chunks[i]
130146
131− ts, _ := strconv.Atoi(chunks[3])
132− sessionState.ExpiresOn = time.Unix(int64(ts), 0)
147+ return ss, nil
148+}
133149
134− if chunks[4] != "" {
135− if sessionState.RefreshToken, err = c.Decrypt(chunks[4]); err != nil {
150+// DecodeSessionState decodes the session cookie string into a SessionState
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 {
136165 return nil, err
137166 }
138167 }
139−
140− return sessionState, nil
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+ }
197+ return ss, nil
141198}
providers/session_state_test.go+182−20
@@ -2,7 +2,6 @@ package providers
22
33import (
44 "fmt"
5− "strings"
65 "testing"
76 "time"
87
@@ -27,7 +26,6 @@ func TestSessionStateSerialization(t *testing.T) {
2726 }
2827 encoded, err := s.EncodeSessionState(c)
2928 assert.Equal(t, nil, err)
30− assert.Equal(t, 4, strings.Count(encoded, "|"))
3129
3230 ss, err := DecodeSessionState(encoded, c)
3331 t.Logf("%#v", ss)
@@ -65,7 +63,6 @@ func TestSessionStateSerializationWithUser(t *testing.T) {
6563 }
6664 encoded, err := s.EncodeSessionState(c)
6765 assert.Equal(t, nil, err)
68− assert.Equal(t, 4, strings.Count(encoded, "|"))
6966
7067 ss, err := DecodeSessionState(encoded, c)
7168 t.Logf("%#v", ss)
@@ -96,8 +93,6 @@ func TestSessionStateSerializationNoCipher(t *testing.T) {
9693 }
9794 encoded, err := s.EncodeSessionState(nil)
9895 assert.Equal(t, nil, err)
99− expected := fmt.Sprintf("email:%s user:", s.Email)
100− assert.Equal(t, expected, encoded)
10196
10297 // only email should have been serialized
10398 ss, err := DecodeSessionState(encoded, nil)
@@ -118,8 +113,6 @@ func TestSessionStateSerializationNoCipherWithUser(t *testing.T) {
118113 }
119114 encoded, err := s.EncodeSessionState(nil)
120115 assert.Equal(t, nil, err)
121− expected := fmt.Sprintf("email:%s user:%s", s.Email, s.User)
122− assert.Equal(t, expected, encoded)
123116
124117 // only email should have been serialized
125118 ss, err := DecodeSessionState(encoded, nil)
@@ -130,19 +123,6 @@ func TestSessionStateSerializationNoCipherWithUser(t *testing.T) {
130123 assert.Equal(t, "", ss.RefreshToken)
131124}
132125
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−
146126func TestExpired(t *testing.T) {
147127 s := &SessionState{ExpiresOn: time.Now().Add(time.Duration(-1) * time.Minute)}
148128 assert.Equal(t, true, s.IsExpired())
@@ -153,3 +133,185 @@ func TestExpired(t *testing.T) {
153133 s = &SessionState{}
154134 assert.Equal(t, false, s.IsExpired())
155135}
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 (
77// StringArray is a type alias for a slice of strings
88type StringArray []string
99
10+// Get returns the slice of strings
11+func (a *StringArray) Get() interface{} {
12+ return []string(*a)
13+}
14+
1015// Set appends a string to the StringArray
1116func (a *StringArray) Set(s string) error {
1217 *a = append(*a, s)