diff options
author | Mike Crute <mike@crute.us> | 2017-08-31 04:28:14 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2017-08-31 04:28:14 +0000 |
commit | 34d0f2d7e323acdc48cf91b0dc8514b6753de5d5 (patch) | |
tree | 10d41d475d60f8fe88dc4e43e2352362a36c1f86 | |
parent | b602fac5decc5daab15af6abf2dc6dfeb649c1d5 (diff) | |
download | oidc_proxy-34d0f2d7e323acdc48cf91b0dc8514b6753de5d5.tar.bz2 oidc_proxy-34d0f2d7e323acdc48cf91b0dc8514b6753de5d5.tar.xz oidc_proxy-34d0f2d7e323acdc48cf91b0dc8514b6753de5d5.zip |
Implement JWKS fetching
-rw-r--r-- | cautious_http_client.go | 80 | ||||
-rw-r--r-- | main.go | 119 |
2 files changed, 192 insertions, 7 deletions
diff --git a/cautious_http_client.go b/cautious_http_client.go new file mode 100644 index 0000000..66179f2 --- /dev/null +++ b/cautious_http_client.go | |||
@@ -0,0 +1,80 @@ | |||
1 | package main | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "net" | ||
6 | "net/http" | ||
7 | "net/url" | ||
8 | "time" | ||
9 | ) | ||
10 | |||
11 | type CautiousHTTPClient interface { | ||
12 | Get(string) (*http.Response, error) | ||
13 | GetJSON(string, interface{}) error | ||
14 | } | ||
15 | |||
16 | type cautiousHttpClient struct { | ||
17 | client *http.Client | ||
18 | } | ||
19 | |||
20 | func NewCautiousHTTPClient() CautiousHTTPClient { | ||
21 | // May Need: TLSClientConfig *tls.Config | ||
22 | CautiousTransport := &http.Transport{ | ||
23 | Proxy: http.ProxyFromEnvironment, | ||
24 | DialContext: (&net.Dialer{ | ||
25 | Timeout: 1 * time.Second, | ||
26 | KeepAlive: 30 * time.Second, | ||
27 | DualStack: true, | ||
28 | }).DialContext, | ||
29 | MaxIdleConns: 100, | ||
30 | IdleConnTimeout: 90 * time.Second, | ||
31 | TLSHandshakeTimeout: 3 * time.Second, | ||
32 | ExpectContinueTimeout: 1 * time.Second, | ||
33 | ResponseHeaderTimeout: 5 * time.Second, | ||
34 | MaxResponseHeaderBytes: 500000, // .5 MB | ||
35 | } | ||
36 | |||
37 | return &cautiousHttpClient{ | ||
38 | client: &http.Client{ | ||
39 | Transport: CautiousTransport, | ||
40 | Timeout: 30 * time.Second, | ||
41 | }, | ||
42 | } | ||
43 | } | ||
44 | |||
45 | func (c *cautiousHttpClient) Get(gurl string) (*http.Response, error) { | ||
46 | u, err := url.Parse(gurl) | ||
47 | if err != nil { | ||
48 | return nil, err | ||
49 | } | ||
50 | |||
51 | // TODO | ||
52 | /* | ||
53 | if u.Scheme != "https" { | ||
54 | return nil, fmt.Errorf("URL for GET must be secure") | ||
55 | } | ||
56 | */ | ||
57 | |||
58 | r, err := c.client.Get(u.String()) | ||
59 | if err != nil { | ||
60 | return nil, err | ||
61 | } | ||
62 | r.Body = http.MaxBytesReader(nil, r.Body, 1000000) | ||
63 | return r, err | ||
64 | } | ||
65 | |||
66 | func (c *cautiousHttpClient) GetJSON(url string, rv interface{}) error { | ||
67 | r, err := c.Get(url) | ||
68 | if err != nil { | ||
69 | return err | ||
70 | } | ||
71 | defer r.Body.Close() | ||
72 | |||
73 | d := json.NewDecoder(r.Body) | ||
74 | err = d.Decode(rv) | ||
75 | if err != nil { | ||
76 | return err | ||
77 | } | ||
78 | |||
79 | return nil | ||
80 | } | ||
@@ -4,7 +4,8 @@ import ( | |||
4 | "context" | 4 | "context" |
5 | "crypto/rand" | 5 | "crypto/rand" |
6 | "encoding/hex" | 6 | "encoding/hex" |
7 | _ "github.com/dgrijalva/jwt-go" | 7 | "fmt" |
8 | "gopkg.in/square/go-jose.v2" | ||
8 | "log" | 9 | "log" |
9 | "net/http" | 10 | "net/http" |
10 | "net/http/httputil" | 11 | "net/http/httputil" |
@@ -33,12 +34,58 @@ type ProxyConfig struct { | |||
33 | UpstreamURL string | 34 | UpstreamURL string |
34 | ListenOn string | 35 | ListenOn string |
35 | TrustedCACert string | 36 | TrustedCACert string |
37 | IsOptional bool | ||
36 | RequestMFA bool | 38 | RequestMFA bool |
37 | AllowedMFAMethods []string // An OR set | 39 | AllowedMFAMethods []string // An OR set |
38 | RequiredMFAMethods []string // An AND set | 40 | RequiredMFAMethods []string // An AND set |
39 | reverseProxy *httputil.ReverseProxy | 41 | reverseProxy *httputil.ReverseProxy |
40 | } | 42 | } |
41 | 43 | ||
44 | type IdPConfig struct { | ||
45 | AuthorizationEndpoint string `json:"authorization_endpoint"` | ||
46 | Issuer string `json:"issuer"` | ||
47 | JwksUri string `json:"jwks_uri"` | ||
48 | SupportedGrantTypes []string `json:"grant_types_supported"` | ||
49 | IdTokenSigningAlgs []string `json:"id_token_signing_alg_values_supported"` | ||
50 | ResponseModes []string `json:"response_modes_supported"` | ||
51 | ResponseTypes []string `json:"response_types_supported"` | ||
52 | Scopes []string `json:"scopes_supported"` | ||
53 | SubjectTypes []string `json:"subject_types_supported"` | ||
54 | } | ||
55 | |||
56 | func FetchIdPConfig(h CautiousHTTPClient, idp_url string) (*IdPConfig, error) { | ||
57 | u, err := url.Parse(idp_url) | ||
58 | if err != nil { | ||
59 | return nil, err | ||
60 | } | ||
61 | u.Path = "/.well-known/openid-configuration" | ||
62 | |||
63 | var idpc IdPConfig | ||
64 | err = h.GetJSON(u.String(), &idpc) | ||
65 | if err != nil { | ||
66 | return nil, err | ||
67 | } | ||
68 | |||
69 | return &idpc, nil | ||
70 | } | ||
71 | |||
72 | // TODO: Optimization to fetch only if expired (per http headers) | ||
73 | func FetchJWKS(h CautiousHTTPClient, jwks_url string) (map[string]jose.JSONWebKey, error) { | ||
74 | var jwks jose.JSONWebKeySet | ||
75 | err := h.GetJSON(jwks_url, &jwks) | ||
76 | if err != nil { | ||
77 | return nil, err | ||
78 | } | ||
79 | |||
80 | keys := make(map[string]jose.JSONWebKey, len(jwks.Keys)) | ||
81 | |||
82 | for _, k := range jwks.Keys { | ||
83 | keys[k.KeyID] = k | ||
84 | } | ||
85 | |||
86 | return keys, nil | ||
87 | } | ||
88 | |||
42 | func URLMustParse(u string) *url.URL { | 89 | func URLMustParse(u string) *url.URL { |
43 | o, err := url.Parse(u) | 90 | o, err := url.Parse(u) |
44 | if err != nil { | 91 | if err != nil { |
@@ -91,15 +138,18 @@ func DownloadCRL() { | |||
91 | func ValidateCertificate() { | 138 | func ValidateCertificate() { |
92 | } | 139 | } |
93 | 140 | ||
141 | // TODO | ||
94 | func MakeClientID(r *http.Request) string { | 142 | func MakeClientID(r *http.Request) string { |
95 | if strings.Contains(r.Host, ":") { | 143 | if strings.Contains(r.Host, ":") { |
96 | return r.Host | 144 | return r.Host |
97 | } | 145 | } |
146 | return "" | ||
98 | } | 147 | } |
99 | 148 | ||
100 | // TODO | 149 | // TODO |
101 | func RedirectToIDP(w http.ResponseWriter, r *http.Request) { | 150 | func RedirectToIDP(w http.ResponseWriter, r *http.Request) { |
102 | nonce := GenerateNonce() | 151 | nonce, _ := GenerateNonce() |
152 | _ = nonce | ||
103 | nonceh := "" // SHA256 nonce | 153 | nonceh := "" // SHA256 nonce |
104 | 154 | ||
105 | // Set nonce cookie | 155 | // Set nonce cookie |
@@ -117,13 +167,34 @@ func SetTokenCookieAndRedirect(w http.ResponseWriter, r *http.Request, token str | |||
117 | } | 167 | } |
118 | 168 | ||
119 | // TODO | 169 | // TODO |
170 | // Occasionally refresh IDP config (per HTTP caching headers) | ||
171 | // | ||
172 | // Fetch ${IDP_HOST}/.well-known/openid-configuration | ||
173 | // - validate certificate chains to a trusted root | ||
174 | // - validate scopes_supported contains "openid" | ||
175 | // - validate response_types_supported contains "id_token" | ||
176 | // - validate grant_types_supported contains "implicit" | ||
177 | // - validate id_token_signing_alg_values_supported contains a supported signing type (see below) | ||
178 | // - Cache authorization_endpoint for redirecting users | ||
179 | // | ||
180 | // Fetch jwks_uri endpoint | ||
181 | // - Build key map indexed by kid for all keys that are suppored by our rules | ||
182 | // - kty == RSA | ||
183 | // - alg header must be one of [PS256, PS385, PS512] | ||
184 | // - pem decode x5c and validate the certificate chain as below | ||
185 | // - validate first item of x5c matches n and e | ||
186 | func RefreshIDPConfig() { | ||
187 | } | ||
188 | |||
189 | // TODO | ||
190 | // If x5u exists in header | ||
120 | // Fetch cert from x5u URL | 191 | // Fetch cert from x5u URL |
121 | // Get CRL from cert, fetch (connect timeout 1s, read timeout 30s, read size 1M) | 192 | // Get CRL from cert, fetch (connect timeout 1s, read timeout 30s, read size 1M) |
122 | // | 193 | // |
123 | // exp claim has passed +- 5 minutes | 194 | // exp claim has passed +- 5 minutes |
124 | // iat claim is greater than 24 hours +- 5 minutes | 195 | // iat claim is greater than 24 hours +- 5 minutes |
125 | // aud claim is exact match for client_id | 196 | // aud claim is exact match for client_id |
126 | // iss claim is exact match for idp (ex: foo.example.com | 197 | // iss claim is exact match for idp (ex: foo.example.com) |
127 | // if other aud claims validate that they are known | 198 | // if other aud claims validate that they are known |
128 | // nonce in JWT must be SHA256 of rfp cookie value | 199 | // nonce in JWT must be SHA256 of rfp cookie value |
129 | // Validate cert | 200 | // Validate cert |
@@ -151,8 +222,9 @@ func RequestHasForwardedUser(w http.ResponseWriter, r *http.Request) bool { | |||
151 | } | 222 | } |
152 | } | 223 | } |
153 | 224 | ||
154 | func RequestIsOverSecureChannel(w http.ResponseWriter, r *http.Request) { | 225 | func RequestIsOverSecureChannel(w http.ResponseWriter, r *http.Request) bool { |
155 | if https, ok := r.Header["X-Forwarded-Proto"]; !ok || len(https) != 1 { | 226 | https, ok := r.Header["X-Forwarded-Proto"] |
227 | if !ok || len(https) != 1 { | ||
156 | log.Printf("ERROR: Request does not contain X-Forwarded-Proto header") | 228 | log.Printf("ERROR: Request does not contain X-Forwarded-Proto header") |
157 | http.Error(w, "Bad Request", http.StatusBadRequest) | 229 | http.Error(w, "Bad Request", http.StatusBadRequest) |
158 | return false | 230 | return false |
@@ -202,13 +274,13 @@ func AuthProxyController(w http.ResponseWriter, r *http.Request) { | |||
202 | tokenc, err := r.Cookie(TOKEN_COOKIE_NAME) | 274 | tokenc, err := r.Cookie(TOKEN_COOKIE_NAME) |
203 | if err != nil { | 275 | if err != nil { |
204 | log.Printf("ERROR: No token cookie") | 276 | log.Printf("ERROR: No token cookie") |
205 | RedirectToIDP() | 277 | RedirectToIDP(w, r) |
206 | return | 278 | return |
207 | } | 279 | } |
208 | 280 | ||
209 | if !ValidateJWT(tokenc.Value, rfpc.Value) { | 281 | if !ValidateJWT(tokenc.Value, rfpc.Value) { |
210 | log.Printf("ERROR: Token is invalid") | 282 | log.Printf("ERROR: Token is invalid") |
211 | RedirectToIDP() | 283 | RedirectToIDP(w, r) |
212 | return | 284 | return |
213 | } | 285 | } |
214 | 286 | ||
@@ -235,6 +307,15 @@ func LogoutController(w http.ResponseWriter, r *http.Request) { | |||
235 | } | 307 | } |
236 | 308 | ||
237 | // TODO | 309 | // TODO |
310 | func LoginController(w http.ResponseWriter, r *http.Request) { | ||
311 | } | ||
312 | |||
313 | // TODO | ||
314 | // Optional login allows for applications that can operate in anonymous mode or | ||
315 | // authenticated mode. When in anonmyous mode the request is proxied through | ||
316 | // without an X-Forwarded-User header. Upstream servers should either expose or | ||
317 | // map a URL for /.oidc/login to allow users to login. On successful login the | ||
318 | // user will be redirected back to the main page for the site (/) | ||
238 | func parseConfig() *ProxyConfig { | 319 | func parseConfig() *ProxyConfig { |
239 | return &ProxyConfig{ | 320 | return &ProxyConfig{ |
240 | IDProviderURL: "", | 321 | IDProviderURL: "", |
@@ -242,13 +323,37 @@ func parseConfig() *ProxyConfig { | |||
242 | UpstreamURL: "http://localhost:9991/", | 323 | UpstreamURL: "http://localhost:9991/", |
243 | ListenOn: ":9992", | 324 | ListenOn: ":9992", |
244 | TrustedCACert: "", | 325 | TrustedCACert: "", |
326 | IsOptional: false, | ||
245 | } | 327 | } |
246 | } | 328 | } |
247 | 329 | ||
248 | func main() { | 330 | func main() { |
331 | h := NewCautiousHTTPClient() | ||
332 | |||
333 | idpc, err := FetchIdPConfig(h, "http://mcrute-virt:9993") | ||
334 | if err != nil { | ||
335 | fmt.Printf("%s\n", err) | ||
336 | return | ||
337 | } | ||
338 | |||
339 | jwks, err := FetchJWKS(h, idpc.JwksUri) | ||
340 | if err != nil { | ||
341 | fmt.Printf("%s\n", err) | ||
342 | return | ||
343 | } | ||
344 | fmt.Printf("%+v\n", jwks) | ||
345 | return | ||
346 | |||
249 | cfg := parseConfig() | 347 | cfg := parseConfig() |
250 | cfg.reverseProxy = httputil.NewSingleHostReverseProxy(URLMustParse(cfg.UpstreamURL)) | 348 | cfg.reverseProxy = httputil.NewSingleHostReverseProxy(URLMustParse(cfg.UpstreamURL)) |
251 | 349 | ||
350 | if cfg.IsOptional { | ||
351 | http.HandleFunc("/.oidc/login", func(w http.ResponseWriter, r *http.Request) { | ||
352 | LoginController(w, | ||
353 | r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) | ||
354 | }) | ||
355 | } | ||
356 | |||
252 | http.HandleFunc("/.oidc/logout", func(w http.ResponseWriter, r *http.Request) { | 357 | http.HandleFunc("/.oidc/logout", func(w http.ResponseWriter, r *http.Request) { |
253 | LogoutController(w, | 358 | LogoutController(w, |
254 | r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) | 359 | r.WithContext(context.WithValue(r.Context(), "ProxyConfig", cfg))) |