diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 16fd938..18b3a8b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -76,7 +76,7 @@ jobs: env: LOG_PRETTY: True LOG_LEVEL: Trace - ISSUER: "https://token.actions.githubusercontent.com" + ISSUERS: "https://token.actions.githubusercontent.com" AUDIENCE: "https://github.com/equinor" SUBJECTS: repo:equinor/radix-oauth-guard:pull_request,testmultiplesubjects GH_TOKEN: ${{ steps.get-id-token.outputs.result }} diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..810cb8c --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,15 @@ +run: + timeout: 30m + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - zerologlint + +issues: + max-same-issues: 0 \ No newline at end of file diff --git a/auth.go b/auth.go index b7d8e37..aee6c1e 100644 --- a/auth.go +++ b/auth.go @@ -3,68 +3,125 @@ package main import ( "context" "errors" + "fmt" "net/http" + "net/url" "slices" "strings" "time" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/rs/zerolog" + "github.com/auth0/go-jwt-middleware/v2/jwks" "github.com/rs/zerolog/log" + "gopkg.in/go-jose/go-jose.v2/jwt" ) var ( errInvalidAuthorizationHeader = errors.New("invalid Authorization header") ) -type Verifier interface { - Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) +type KeyFunc func(ctx context.Context) (interface{}, error) +type controller struct { + providers map[string]KeyFunc + audience string + subjects []string } -// AuthHandler returns a Handler to authenticate requests -func AuthHandler(subjects []string, verifier Verifier) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Trace().Func(func(e *zerolog.Event) { - headers := r.Header.Clone() - headers.Del("Authorization") - if r.Header.Get("Authorization") != "" { - headers.Set("Authorization", "!REMOVED!") - } - e.Interface("headers", headers) - }).Msg("Request details") - t := time.Now() - - auth := r.Header.Get("Authorization") - jwt, err := parseAuthHeader(auth) +// NewAuthHandler returns a Handler to authenticate requests +func NewAuthHandler(audience string, subjects, issuers []string) (RouteMapper, error) { + providers := make(map[string]KeyFunc, len(issuers)) + for _, issuer := range issuers { + issuerUrl, err := url.Parse(issuer) if err != nil { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("Forbidden")) - log.Info().Err(err).Dur("elappsed_ms", time.Since(t)).Int("status", http.StatusUnauthorized).Msg("Unauthorized") - return + return nil, err } - token, err := verifier.Verify(r.Context(), jwt) + provider := jwks.NewCachingProvider(issuerUrl, 5*time.Hour) + providers[issuer] = provider.KeyFunc + } - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("Forbidden")) - log.Info().Err(err).Dur("elappsed_ms", time.Since(t)).Int("status", http.StatusUnauthorized).Msg("Unauthorized") - return - } + c := &controller{ + providers: providers, + audience: audience, + subjects: subjects, + } + return func(mux *http.ServeMux) { + mux.Handle("/auth", c) + }, nil +} - subject := token.Subject - found := slices.Contains(subjects, subject) - if !found { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte("Forbidden")) - log.Info().Err(err).Dur("elappsed_ms", time.Since(t)).Int("status", http.StatusForbidden).Str("sub", subject).Msg("Forbidden") - return - } +func (c *controller) ServeHTTP(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + authHeader, err := parseAuthHeader(auth) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + log.Info().Err(err).Msg("Unauthorized: Invalid auth header") + return + } + + claims, err := c.getClaims(r.Context(), authHeader) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + log.Warn().Err(err).Msg("Forbidden: Invalid token") + return + } + + subject := claims.Subject + + found := slices.Contains(c.subjects, subject) + if !found { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Forbidden")) + log.Warn().Str("sub", subject).Msg("Forbidden") + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + log.Info().Str("sub", subject).Msg("Authorized") +} + +func (c *controller) getClaims(ctx context.Context, authHeader string) (*jwt.Claims, error) { + var unsafeClaims jwt.Claims + token, err := jwt.ParseSigned(authHeader) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT token: %w", err) + } + err = token.UnsafeClaimsWithoutVerification(&unsafeClaims) + if err != nil { + return nil, fmt.Errorf("failed to extract JWT unsafeClaims: %w", err) + } + var keyId string + if len(token.Headers) == 1 { + keyId = token.Headers[0].KeyID + } + if keyId == "" { + return nil, fmt.Errorf("failed to find keyId in headers") + } + + issuer := unsafeClaims.Issuer + keyFunc, ok := c.providers[issuer] + if !ok { + return nil, fmt.Errorf("unknown issuer: %s", issuer) + } + key, err := keyFunc(ctx) + if err != nil { + return nil, fmt.Errorf("error getting the keys from the key func: %w", err) + } + + var verifiedClaims jwt.Claims + err = token.Claims(key, &verifiedClaims) + if err != nil { + return nil, fmt.Errorf("failed to verify token unsafeClaims: %w", err) + } + + expected := jwt.Expected{Audience: []string{c.audience}} + if err = verifiedClaims.Validate(expected); err != nil { + return nil, fmt.Errorf("failed to verify token unsafeClaims: %w", err) + } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("OK")) - log.Info().Dur("elappsed_ms", time.Since(t)).Int("status", http.StatusOK).Str("sub", subject).Msg("Authorized") - }) + return &verifiedClaims, nil } func parseAuthHeader(authorization string) (string, error) { diff --git a/auth_test.go b/auth_test.go index b04b258..8b002a8 100644 --- a/auth_test.go +++ b/auth_test.go @@ -1,76 +1,185 @@ package main_test import ( - "context" - "errors" "net/http" "net/http/httptest" "testing" - "github.com/coreos/go-oidc/v3/oidc" guard "github.com/equinor/radix-oauth-guard" + "github.com/golang-jwt/jwt/v5" + "github.com/oauth2-proxy/mockoidc" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -type FakeVerifier func(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) +const fake_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IkJCOENlRlZxeWFHckdOdWVoSklpTDRkZmp6dyIsImtpZCI6IkJCOENlRlZxeWFHckdOdWVoSklpTDRkZmp6dyJ9.eyJhdWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0MjQ1YTJlYzEiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8xMjM0NTY3OC03NTY1LTIzNDItMjM0Mi0xMjM0MDViNDU5YjAvIiwiaWF0IjoxNTc1MzU1NTA4LCJuYmYiOjE1NzUzNTU1MDgsImV4cCI6MTU3NTM1OTQwOCwiYWNyIjoiMSIsImFpbyI6IjQyYXNkYXMiLCJhbXIiOlsicHdkIl0sImFwcGlkIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDc5MDM5YTkwIiwiYXBwaWRhY3IiOiIwIiwiZmFtaWx5X25hbWUiOiJKb2huIiwiZ2l2ZW5fbmFtZSI6IkRvZSIsImhhc2dyb3VwcyI6InRydWUiLCJpcGFkZHIiOiIxMC4xMC4xMC4xMCIsIm5hbWUiOiJKb2huIERvZSIsIm9pZCI6IjEyMzQ1Njc4LTEyMzQtMTIzNC0xMjM0LTEyMzRmYzhmYTBlYSIsIm9ucHJlbV9zaWQiOiJTLTEtNS0yMS0xMjM0NTY3ODktMTIzNDU2OTc4MC0xMjM0NTY3ODktMTIzNDU2NyIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6IjBoa2JpbEo3MTIzNHpSU3h6eHZiSW1hc2RmZ3N4amI2YXNkZmVOR2FzZGYiLCJ0aWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0MDViNDU5YjAiLCJ1bmlxdWVfbmFtZSI6Im5vdC1leGlzdGluZy1yYWRpeC1lbWFpbEBlcXVpbm9yLmNvbSIsInVwbiI6Im5vdC1leGlzdGluZy10ZXN0LXJhZGl4LWVtYWlsQGVxdWlub3IuY29tIiwidXRpIjoiQlMxMmFzR2R1RXlyZUVjRGN2aDJBRyIsInZlciI6IjEuMCJ9.EB5z7Mk34NkFPCP8MqaNMo4UeWgNyO4-qEmzOVPxfoBqbgA16Ar4xeONXODwjZn9iD-CwJccusW6GP0xZ_PJHBFpfaJO_tLaP1k0KhT-eaANt112TvDBt0yjHtJg6He6CEDqagREIsH3w1mSm40zWLKGZeRLdnGxnQyKsTmNJ1rFRdY3AyoEgf6-pnJweUt0LaFMKmIJ2HornStm2hjUstBaji_5cSS946zqp4tgrc-RzzDuaQXzqlVL2J22SR2S_Oux_3yw88KmlhEFFP9axNcbjZrzW3L9XWnPT6UzVIaVRaNRSWfqDATg-jeHg4Gm1bp8w0aIqLdDxc9CfFMjuQ" -func (f FakeVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) { - return f(ctx, rawIDToken) +func TestValidTokenSucceeds(t *testing.T) { + issuer := createServer(t) + token := createUser(t, issuer, "audience", "radix1") + mux := http.NewServeMux() + + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer}) + require.NoError(t, err) + mapper(mux) + + writer := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/auth", nil) + req.Header.Set("Authorization", "Bearer "+token) + mux.ServeHTTP(writer, req) + + assert.Equal(t, http.StatusOK, writer.Code) + assert.Equal(t, `OK`, writer.Body.String()) } +func TestMultipleIssuers(t *testing.T) { + issuer := createServer(t) + issuer2 := createServer(t) + issuer3 := createServer(t) + mux := http.NewServeMux() + + token := createUser(t, issuer, "audience", "radix1") + token2 := createUser(t, issuer2, "audience", "radix2") + token3 := createUser(t, issuer3, "audience", "radix3") -func TestAuthHandler(t *testing.T) { - handler := guard.AuthHandler([]string{"radix1", "radix2"}, FakeVerifier(func(_ context.Context, _ string) (*oidc.IDToken, error) { - return &oidc.IDToken{ - Subject: "radix1", - }, nil - })) + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer, issuer2}) + require.NoError(t, err) + mapper(mux) writer := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/auth", nil) - req.Header.Set("Authorization", "Bearer abcd.abcd.abcd") - handler.ServeHTTP(writer, req) + req.Header.Set("Authorization", "Bearer "+token) + mux.ServeHTTP(writer, req) assert.Equal(t, http.StatusOK, writer.Code) assert.Equal(t, `OK`, writer.Body.String()) + + writer = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/auth", nil) + req.Header.Set("Authorization", "Bearer "+token2) + mux.ServeHTTP(writer, req) + + assert.Equal(t, http.StatusOK, writer.Code) + assert.Equal(t, `OK`, writer.Body.String()) + + writer = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/auth", nil) + req.Header.Set("Authorization", "Bearer "+token3) + mux.ServeHTTP(writer, req) + + assert.Equal(t, http.StatusUnauthorized, writer.Code) + assert.Equal(t, `Unauthorized`, writer.Body.String()) } -func TestMissingAuthHeaderFails(t *testing.T) { - handler := guard.AuthHandler([]string{"radix1", "radix2"}, FakeVerifier(func(_ context.Context, _ string) (*oidc.IDToken, error) { - return &oidc.IDToken{ - Subject: "radix-fake", - }, nil - })) +func TestInvalidAudienceFails(t *testing.T) { + issuer := createServer(t) + token := createUser(t, issuer, "audience-invalid", "radix1") + + mux := http.NewServeMux() + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer}) + require.NoError(t, err) + mapper(mux) writer := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/auth", nil) - handler.ServeHTTP(writer, req) + req.Header.Set("Authorization", "Bearer "+token) + mux.ServeHTTP(writer, req) assert.Equal(t, http.StatusUnauthorized, writer.Code) + assert.Equal(t, `Unauthorized`, writer.Body.String()) } +func TestMissingAuthHeaderFails(t *testing.T) { + issuer := createServer(t) -func TestAuthFailureFails(t *testing.T) { - handler := guard.AuthHandler([]string{"radix1", "radix2"}, FakeVerifier(func(_ context.Context, _ string) (*oidc.IDToken, error) { - return nil, errors.New("some error") - })) + mux := http.NewServeMux() + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer}) + require.NoError(t, err) + mapper(mux) writer := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/auth", nil) - req.Header.Set("Authorization", "Bearer abcdabcd") - handler.ServeHTTP(writer, req) + mux.ServeHTTP(writer, req) assert.Equal(t, http.StatusUnauthorized, writer.Code) + assert.Equal(t, `Unauthorized`, writer.Body.String()) +} + +func TestAuthInvalidSubjectFails(t *testing.T) { + issuer := createServer(t) + + mux := http.NewServeMux() + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer}) + require.NoError(t, err) + mapper(mux) + + token := createUser(t, issuer, "audience", "radix3") + require.NoError(t, err) + + writer := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/auth", nil) + req.Header.Set("Authorization", "Bearer "+token) + mux.ServeHTTP(writer, req) + + assert.Equal(t, http.StatusForbidden, writer.Code) + assert.Equal(t, `Forbidden`, writer.Body.String()) } func TestInvalidJWTFails(t *testing.T) { - handler := guard.AuthHandler([]string{"radix1", "radix2"}, FakeVerifier(func(_ context.Context, _ string) (*oidc.IDToken, error) { - return &oidc.IDToken{ - Subject: "radix-fail", - }, nil - })) + issuer := createServer(t) + + mux := http.NewServeMux() + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer}) + require.NoError(t, err) + mapper(mux) writer := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/auth", nil) req.Header.Set("Authorization", "Bearer abcd.abcd.abcd") - handler.ServeHTTP(writer, req) + mux.ServeHTTP(writer, req) - assert.Equal(t, http.StatusForbidden, writer.Code) + assert.Equal(t, http.StatusUnauthorized, writer.Code) + assert.Equal(t, `Unauthorized`, writer.Body.String()) +} + +func TestUnsupportedIssuerFails(t *testing.T) { + issuer := createServer(t) + + mux := http.NewServeMux() + mapper, err := guard.NewAuthHandler("audience", []string{"radix1", "radix2"}, []string{issuer}) + require.NoError(t, err) + mapper(mux) + + writer := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/auth", nil) + req.Header.Set("Authorization", "Bearer "+fake_token) + mux.ServeHTTP(writer, req) + + assert.Equal(t, http.StatusUnauthorized, writer.Code) + assert.Equal(t, `Unauthorized`, writer.Body.String()) +} + +func createServer(t *testing.T) string { + m, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { + _ = m.Shutdown() + }) + return m.Issuer() +} + +func createUser(t *testing.T, issuer, audience, subject string) string { + user := mockoidc.DefaultUser() + key, err := mockoidc.DefaultKeypair() + require.NoError(t, err) + + claims, err := user.Claims([]string{"profile", "email"}, &mockoidc.IDTokenClaims{ + RegisteredClaims: &jwt.RegisteredClaims{ + Issuer: issuer, + Subject: subject, + Audience: []string{audience}, + }, + }) + require.NoError(t, err) + + signJWT, err := key.SignJWT(claims) + require.NoError(t, err) + return signJWT } diff --git a/config.go b/config.go new file mode 100644 index 0000000..8cd61ef --- /dev/null +++ b/config.go @@ -0,0 +1,36 @@ +package main + +import ( + "github.com/kelseyhightower/envconfig" + "github.com/rs/zerolog/log" +) + +type Config struct { + Issuers []string `envconfig:"ISSUERS" required:"true"` + Audience string `envconfig:"AUDIENCE" required:"true"` + Subjects []string `envconfig:"SUBJECTS" required:"true"` + + LogLevel string `envconfig:"LOG_LEVEL" default:"info"` + LogPretty bool `envconfig:"LOG_PRETTY" default:"false"` + Port int `envconfig:"PORT" default:"8000"` +} + +func MustParseConfig() Config { + var c Config + err := envconfig.Process("", &c) + if err != nil { + _ = envconfig.Usage("", &c) + log.Fatal().Msg(err.Error()) + } + + initLogger(c) + log.Info().Msg("Starting") + log.Info().Int("Port", c.Port).Send() + log.Info().Strs("ISSUER", c.Issuers).Send() + log.Info().Str("AUDIENCE", c.Audience).Send() + log.Info().Str("LOG_LEVEL", c.LogLevel).Send() + log.Info().Bool("LOG_PRETTY", c.LogPretty).Send() + log.Info().Strs("SUBJECTS", c.Subjects).Send() + + return c +} diff --git a/go.mod b/go.mod index f98c39f..f541815 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,28 @@ module github.com/equinor/radix-oauth-guard go 1.22 require ( - github.com/coreos/go-oidc/v3 v3.10.0 + github.com/auth0/go-jwt-middleware/v2 v2.2.2 + github.com/felixge/httpsnoop v1.0.4 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 + github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.32.0 - github.com/sethvargo/go-envconfig v1.0.1 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 + github.com/urfave/negroni v1.0.0 + golang.org/x/sys v0.17.0 + gopkg.in/go-jose/go-jose.v2 v2.6.3 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-jose/go-jose/v4 v4.0.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/go-jose/go-jose/v3 v3.0.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/crypto v0.19.0 // indirect - golang.org/x/oauth2 v0.15.0 // indirect - golang.org/x/sys v0.17.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.33.0 // indirect + golang.org/x/sync v0.8.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4480e3b..a2fb376 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,22 @@ -github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= -github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= +github.com/auth0/go-jwt-middleware/v2 v2.2.2 h1:vrvkFZf72r3Qbt45KLjBG3/6Xq2r3NTixWKu2e8de9I= +github.com/auth0/go-jwt-middleware/v2 v2.2.2/go.mod h1:4vwxpVtu/Kl4c4HskT+gFLjq0dra8F1joxzamrje6J0= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= -github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -26,61 +29,46 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk= +github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/sethvargo/go-envconfig v1.0.1 h1:9wglip/5fUfaH0lQecLM8AyOClMw0gT0A9K2c2wozao= -github.com/sethvargo/go-envconfig v1.0.1/go.mod h1:OKZ02xFaD3MvWBBmEW45fQr08sJEsonGrrOdicvQmQA= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= +gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..edea984 --- /dev/null +++ b/logger.go @@ -0,0 +1,50 @@ +package main + +import ( + "io" + "net/http" + "os" + "time" + + "github.com/felixge/httpsnoop" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/urfave/negroni" +) + +func initLogger(opts Config) { + logLevel, err := zerolog.ParseLevel(opts.LogLevel) + if err != nil { + logLevel = zerolog.InfoLevel + log.Warn().Msgf("Invalid log level '%s', fallback to '%s'", opts.LogLevel, logLevel.String()) + } + + if logLevel == zerolog.NoLevel { + logLevel = zerolog.InfoLevel + } + opts.LogLevel = logLevel.String() + + var logWriter io.Writer = os.Stderr + if opts.LogPretty { + logWriter = &zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.TimeOnly} + } + + zerolog.DurationFieldUnit = time.Millisecond + logger := zerolog.New(logWriter).Level(logLevel).With().Timestamp().Logger() + + log.Logger = logger + zerolog.DefaultContextLogger = &logger +} + +func NewLoggingMiddleware() negroni.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request, next http.HandlerFunc) { + metrics := httpsnoop.CaptureMetrics(next, writer, request) + log.Info(). + Str("path", request.URL.Path). + Str("referer", request.Referer()). + Dur("duration", metrics.Duration). + Int("status_code", metrics.Code). + Int64("response_size", metrics.Written). + Msg("Handled request") + } +} diff --git a/main.go b/main.go index c8b6abd..74f7d30 100644 --- a/main.go +++ b/main.go @@ -3,92 +3,65 @@ package main import ( "context" "errors" - "io" + "fmt" "net/http" - "os" + "os/signal" "time" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" - - "github.com/sethvargo/go-envconfig" + "github.com/urfave/negroni" + "golang.org/x/sys/unix" ) -type Options struct { - Issuer string `env:"ISSUER, required"` - Audience string `env:"AUDIENCE, required"` - LogLevel string `env:"LOG_LEVEL, default=info"` - LogPretty bool `env:"LOG_PRETTY"` - Subjects []string `env:"SUBJECTS, required"` -} - func main() { - ctx := context.Background() - var opts Options - err := envconfig.Process(ctx, &opts) - initLogger(&opts) + ctx, cancel := signal.NotifyContext(context.Background(), unix.SIGTERM, unix.SIGINT) + defer cancel() - log.Info().Msg("Starting") - log.Info().Str("ISSUER", opts.Issuer).Send() - log.Info().Str("AUDIENCE", opts.Audience).Send() - log.Info().Str("LOG_LEVEL", opts.LogLevel).Send() - log.Info().Bool("LOG_PRETTY", opts.LogPretty).Send() - log.Info().Strs("SUBJECTS", opts.Subjects).Send() + config := MustParseConfig() - // Print any failures from proccessing ENV here, - // se we can see available options + authHandler, err := NewAuthHandler(config.Audience, config.Subjects, config.Issuers) if err != nil { - log.Fatal().Msg(err.Error()) + log.Fatal().Err(err).Msg("Failed to create auth handler") } + router := NewRouter(authHandler) - Run(ctx, opts) + err = Serve(ctx, config.Port, router) + log.Err(err).Msg("Terminated") } -func initLogger(opts *Options) { - logLevel, err := zerolog.ParseLevel(opts.LogLevel) - if err != nil { - logLevel = zerolog.InfoLevel - log.Warn().Msgf("Invalid log level '%s', fallback to '%s'", opts.LogLevel, logLevel.String()) - } +type RouteMapper func(mux *http.ServeMux) - if logLevel == zerolog.NoLevel { - logLevel = zerolog.InfoLevel +func NewRouter(handlers ...RouteMapper) *negroni.Negroni { + mux := http.NewServeMux() + for _, handler := range handlers { + handler(mux) } - opts.LogLevel = logLevel.String() - var logWriter io.Writer = os.Stderr - if opts.LogPretty { - logWriter = &zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.TimeOnly} - } - - zerolog.DurationFieldUnit = time.Millisecond - logger := zerolog.New(logWriter).Level(logLevel).With().Timestamp().Logger() - - log.Logger = logger - zerolog.DefaultContextLogger = &logger + return negroni.New( + NewZerologRequestIdMiddleware(), + NewLoggingMiddleware(), + negroni.Wrap(mux), + ) } -func Run(ctx context.Context, opts Options) { +func Serve(ctx context.Context, port int, router http.Handler) error { - provider, err := oidc.NewProvider(ctx, opts.Issuer) - if err != nil { - log.Fatal().Err(err).Str("issuer", opts.Issuer).Msg("Failed to create oidc provider") + s := &http.Server{ + Handler: router, + Addr: fmt.Sprintf(":%d", port), } + go func() { + log.Ctx(ctx).Info().Msgf("Starting server on http://localhost:%d/", port) - oidcConfig := &oidc.Config{ - ClientID: opts.Audience, - } - verifier := provider.Verifier(oidcConfig) + if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Ctx(ctx).Fatal().Msg(err.Error()) + } + }() - authHandler := AuthHandler(opts.Subjects, verifier) - http.Handle("/auth", authHandler) + <-ctx.Done() - log.Info().Msg("Listening on http://localhost:8000...") - err = http.ListenAndServe(":8000", nil) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatal().Err(err).Msgf("listen: %s", err) - } + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() - log.Info().Msg("Server exiting") + return s.Shutdown(shutdownCtx) } diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..7badb07 --- /dev/null +++ b/middleware.go @@ -0,0 +1,18 @@ +package main + +import ( + "net/http" + + "github.com/rs/xid" + "github.com/rs/zerolog/log" + "github.com/urfave/negroni" +) + +func NewZerologRequestIdMiddleware() negroni.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + logger := log.Ctx(r.Context()).With().Str("request_id", xid.New().String()).Logger() + r = r.WithContext(logger.WithContext(r.Context())) + + next(w, r) + } +}