git.iko.soy/typeable/oauth2-proxy
on master help sign in
add login.gov provider (#55)

* first stab at login.gov provider * fixing bugs now that I think I understand things better * fixing up dependencies * remove some debug stuff * Fixing all dependencies to point at my fork * forgot to hit save on the github rehome here * adding options for setting keys and so on, use JWT workflow instead of PKCE * forgot comma * was too aggressive with search/replace * need JWTKey to be byte array * removed custom refresh stuff * do our own custom jwt claim and store it in the normal session store * golang json types are strange * I have much to learn about golang * fix time and signing key * add http lib * fixed claims up since we don't need custom claims * add libs * forgot ioutil * forgot ioutil * moved back to pusher location * changed proxy github location back so that it builds externally, fixed up []byte stuff, removed client_secret if we are using login.gov * update dependencies * do JWTs properly * finished oidc flow, fixed up tests to work better * updated comments, added test that we set expiresOn properly * got confused with header and post vs get * clean up debug and test dir * add login.gov to README, remove references to my repo * forgot to remove un-needed code * can use sample_key* instead of generating your own * updated changelog * apparently golint wants comments like this * linter wants non-standard libs in a separate grouping * Update options.go Co-Authored-By: timothy-spencer <timothy.spencer@gsa.gov> * Update options.go Co-Authored-By: timothy-spencer <timothy.spencer@gsa.gov> * remove sample_key, improve comments related to client-secret, fix changelog related to PR feedback * github doesn't seem to do gofmt when merging. :-) * update CODEOWNERS * check the nonce * validate the JWT fully * forgot to add pubjwk-url to README * unexport the struct * fix up the err masking that travis found * update nonce comment by request of @JoelSpeed * argh. Thought I'd formatted the merge properly, but apparently not. * fixed test to not fail if the query time was greater than zero

commit8cc5fbf8593054422e3e3bb7033d4409ac9caaf1 authorTim Spencer <timothy.spencer@gsa.gov> dateWed 20 Mar 2019, 06:44 (8 years ago) parentf715c93
10 files changed+715−54
.github/CODEOWNERS+9 CHANGELOG.md+2 Gopkg.lock+9 README.md+49 logging_handler_test.go+4−3 main.go+6 options.go+69−51 providers/logingov.go+275 providers/logingov_test.go+290 providers/providers.go+2
.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+2
@@ -2,6 +2,8 @@
22
33## Changes since v3.1.0
44
5+- [#55](https://github.com/pusher/oauth2_proxy/pull/55) Added login.gov provider (@timothy-spencer)
6+- [#55](https://github.com/pusher/oauth2_proxy/pull/55) Added environment variables for all config options (@timothy-spencer)
57- [#70](https://github.com/pusher/oauth2_proxy/pull/70) Fix handling of splitted cookies (@einfachchr)
68- [#92](https://github.com/pusher/oauth2_proxy/pull/92) Merge websocket proxy feature from openshift/oauth-proxy (@butzist)
79- [#57](https://github.com/pusher/oauth2_proxy/pull/57) Fall back to using OIDC Subject instead of Email (@aigarius)
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,
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 }