├── go.mod ├── go.sum ├── .gitignore ├── test ├── sample_key.pub └── sample_key ├── discovery ├── client_test.go ├── client.go └── config.go ├── LICENSE ├── validator.go ├── README.md ├── secret_provider.go └── validator_test.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/osstotalsoft/oidc-jwt-go 2 | 3 | go 1.17 4 | 5 | require github.com/golang-jwt/jwt/v4 v4.3.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog= 2 | github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # IDE settings 15 | .idea/** -------------------------------------------------------------------------------- /test/sample_key.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4f5wg5l2hKsTeNem/V41 3 | fGnJm6gOdrj8ym3rFkEU/wT8RDtnSgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7 4 | mCpz9Er5qLaMXJwZxzHzAahlfA0icqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBp 5 | HssPnpYGIn20ZZuNlX2BrClciHhCPUIIZOQn/MmqTD31jSyjoQoV7MhhMTATKJx2 6 | XrHhR+1DcKJzQBSTAGnpYVaqpsARap+nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3b 7 | ODIRe1AuTyHceAbewn8b462yEWKARdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy 8 | 7wIDAQAB 9 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /discovery/client_test.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestClient_GetOpenidConfiguration(t *testing.T) { 8 | cl := NewClient(Options{Authority: "https://tech0.eu.auth0.com/"}) 9 | r, err := cl.GetOpenidConfiguration() 10 | 11 | if err != nil { 12 | t.Error(err) 13 | } 14 | 15 | if r.Issuer != cl.Options.Authority { 16 | t.Errorf("Issuer does not match : expected %s got %s", r.Issuer, cl.Options.Authority) 17 | } 18 | 19 | if len(r.JsonWebKeySet) == 0 { 20 | t.Errorf("no JsonWebKeySetdoes found") 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 osstotalsoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /discovery/client.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | type Options struct { 10 | Authority string 11 | } 12 | 13 | type Client struct { 14 | Options Options 15 | } 16 | 17 | type Discoverer interface { 18 | GetOpenidConfiguration() (OpenidConfiguration, error) 19 | } 20 | 21 | func NewClient(options Options) *Client { 22 | return &Client{Options: options} 23 | } 24 | 25 | func (client *Client) GetOpenidConfiguration() (OpenidConfiguration, error) { 26 | var cfg = OpenidConfiguration{} 27 | 28 | resp, err := http.Get(singleJoiningSlash(client.Options.Authority, ".well-known/openid-configuration")) 29 | if err != nil { 30 | return cfg, err 31 | } 32 | defer resp.Body.Close() 33 | 34 | err = json.NewDecoder(resp.Body).Decode(&cfg) 35 | if err != nil { 36 | return cfg, err 37 | } 38 | 39 | resp2, err := http.Get(cfg.JwksUri) 40 | if err != nil { 41 | return cfg, err 42 | } 43 | defer resp2.Body.Close() 44 | 45 | var jwks = JsonWebKeySet{} 46 | err = json.NewDecoder(resp2.Body).Decode(&jwks) 47 | if err != nil { 48 | return cfg, err 49 | } 50 | 51 | cfg.JsonWebKeySet = jwks.Keys 52 | 53 | return cfg, err 54 | } 55 | 56 | func singleJoiningSlash(a, b string) string { 57 | aslash := strings.HasSuffix(a, "/") 58 | bslash := strings.HasPrefix(b, "/") 59 | switch { 60 | case aslash && bslash: 61 | return a + b[1:] 62 | case !aslash && !bslash && b != "": 63 | return a + "/" + b 64 | } 65 | return a + b 66 | } 67 | -------------------------------------------------------------------------------- /validator.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "errors" 5 | "github.com/golang-jwt/jwt/v4" 6 | jwtRequest "github.com/golang-jwt/jwt/v4/request" 7 | "net/http" 8 | ) 9 | 10 | func NewJWTValidator(extractor jwtRequest.Extractor, provider SecretProvider, audience string, authority string) func(request *http.Request) (*jwt.Token, error) { 11 | if extractor == nil { 12 | extractor = jwtRequest.OAuth2Extractor 13 | } 14 | 15 | return func(request *http.Request) (*jwt.Token, error) { 16 | 17 | token, err := jwtRequest.ParseFromRequest(request, extractor, func(token *jwt.Token) (i interface{}, e error) { 18 | if id, ok := token.Header["kid"]; ok { 19 | return provider.GetSecret(id.(string)) 20 | } 21 | return provider.GetSecret("") 22 | }) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | checkAud := verifyAudience(audience, token.Claims.(jwt.MapClaims)["aud"]) 28 | if !checkAud { 29 | return token, errors.New("invalid audience") 30 | } 31 | 32 | // Verify 'iss' claim 33 | checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(authority, true) 34 | if !checkIss { 35 | return token, errors.New("invalid issuer") 36 | } 37 | return token, err 38 | } 39 | } 40 | 41 | func verifyAudience(audience string, tokenAudience interface{}) bool { 42 | switch tokenAudience.(type) { 43 | case string: 44 | return tokenAudience == audience 45 | case []interface{}: 46 | { 47 | for _, aud := range tokenAudience.([]interface{}) { 48 | if aud == audience { 49 | return true 50 | } 51 | } 52 | } 53 | } 54 | 55 | return false 56 | } 57 | -------------------------------------------------------------------------------- /test/sample_key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA4f5wg5l2hKsTeNem/V41fGnJm6gOdrj8ym3rFkEU/wT8RDtn 3 | SgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7mCpz9Er5qLaMXJwZxzHzAahlfA0i 4 | cqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBpHssPnpYGIn20ZZuNlX2BrClciHhC 5 | PUIIZOQn/MmqTD31jSyjoQoV7MhhMTATKJx2XrHhR+1DcKJzQBSTAGnpYVaqpsAR 6 | ap+nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3bODIRe1AuTyHceAbewn8b462yEWKA 7 | Rdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy7wIDAQABAoIBAQCwia1k7+2oZ2d3 8 | n6agCAbqIE1QXfCmh41ZqJHbOY3oRQG3X1wpcGH4Gk+O+zDVTV2JszdcOt7E5dAy 9 | MaomETAhRxB7hlIOnEN7WKm+dGNrKRvV0wDU5ReFMRHg31/Lnu8c+5BvGjZX+ky9 10 | POIhFFYJqwCRlopGSUIxmVj5rSgtzk3iWOQXr+ah1bjEXvlxDOWkHN6YfpV5ThdE 11 | KdBIPGEVqa63r9n2h+qazKrtiRqJqGnOrHzOECYbRFYhexsNFz7YT02xdfSHn7gM 12 | IvabDDP/Qp0PjE1jdouiMaFHYnLBbgvlnZW9yuVf/rpXTUq/njxIXMmvmEyyvSDn 13 | FcFikB8pAoGBAPF77hK4m3/rdGT7X8a/gwvZ2R121aBcdPwEaUhvj/36dx596zvY 14 | mEOjrWfZhF083/nYWE2kVquj2wjs+otCLfifEEgXcVPTnEOPO9Zg3uNSL0nNQghj 15 | FuD3iGLTUBCtM66oTe0jLSslHe8gLGEQqyMzHOzYxNqibxcOZIe8Qt0NAoGBAO+U 16 | I5+XWjWEgDmvyC3TrOSf/KCGjtu0TSv30ipv27bDLMrpvPmD/5lpptTFwcxvVhCs 17 | 2b+chCjlghFSWFbBULBrfci2FtliClOVMYrlNBdUSJhf3aYSG2Doe6Bgt1n2CpNn 18 | /iu37Y3NfemZBJA7hNl4dYe+f+uzM87cdQ214+jrAoGAXA0XxX8ll2+ToOLJsaNT 19 | OvNB9h9Uc5qK5X5w+7G7O998BN2PC/MWp8H+2fVqpXgNENpNXttkRm1hk1dych86 20 | EunfdPuqsX+as44oCyJGFHVBnWpm33eWQw9YqANRI+pCJzP08I5WK3osnPiwshd+ 21 | hR54yjgfYhBFNI7B95PmEQkCgYBzFSz7h1+s34Ycr8SvxsOBWxymG5zaCsUbPsL0 22 | 4aCgLScCHb9J+E86aVbbVFdglYa5Id7DPTL61ixhl7WZjujspeXZGSbmq0Kcnckb 23 | mDgqkLECiOJW2NHP/j0McAkDLL4tysF8TLDO8gvuvzNC+WQ6drO2ThrypLVZQ+ry 24 | eBIPmwKBgEZxhqa0gVvHQG/7Od69KWj4eJP28kq13RhKay8JOoN0vPmspXJo1HY3 25 | CKuHRG+AP579dncdUnOMvfXOtkdM4vk0+hWASBQzM9xzVcztCa+koAugjVaLS9A+ 26 | 9uQoqEeVNTckxx0S2bYevRy7hGQmUJTyQm3j1zEUR5jpdbL83Fbq 27 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oidc-jwt-go 2 | OpenID Connect package to secure your API using JWT Bearer tokens. 3 | It uses [dgrijalva/jwt-go](https://github.com/golang-jwt/jwt) for jwt decoding and signature verification 4 | 5 | ## Installation 6 | `go get "github.com/osstotalsoft/oidc-jwt-go" ` 7 | 8 | ## Usage 9 | ````go 10 | import ( 11 | "log" 12 | "net/http" 13 | 14 | jwtRequest "github.com/golang-jwt/jwt/request" 15 | "github.com/osstotalsoft/oidc-jwt-go" 16 | ) 17 | 18 | func middleware() func(next http.Handler) http.Handler { 19 | authority := "https://accounts.google.com" //or other OIDC provider 20 | audience := "YOUR_API_NAME" 21 | 22 | secretProvider := oidc.NewOidcSecretProvider(discovery.NewClient(discovery.Options{authority})) 23 | validator := oidc.NewJWTValidator(jwtRequest.OAuth2Extractor, secretProvider, audience, authority) 24 | 25 | return func(next http.Handler) http.Handler { 26 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 27 | token, err := validator(request) 28 | if err != nil { 29 | log.Error("AuthorizationFilter: Token is not valid", err) 30 | UnauthorizedWithHeader(writer, err.Error()) 31 | return 32 | } 33 | next.ServeHttp(writer, request) 34 | }) 35 | } 36 | } 37 | 38 | //UnauthorizedWithHeader adds to the response a WWW-Authenticate header and returns a StatusUnauthorized error 39 | func UnauthorizedWithHeader(writer http.ResponseWriter, err string) { 40 | writer.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\", error_description=\""+err+"\"") 41 | http.Error(writer, "", http.StatusUnauthorized) 42 | } 43 | ```` 44 | 45 | ## Caching 46 | The Secret Provider uses a simple sync.Map, with no expiration, to cache the rsa.PublicKey by a Key ID string 47 | 48 | ## TODO 49 | - Token Introspection [rfc7662](https://tools.ietf.org/html/rfc7662) 50 | - UserInfo [UserInfo](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) 51 | 52 | ## Similar projects 53 | - https://github.com/auth0-community/go-auth0 54 | - https://github.com/auth0/go-jwt-middleware 55 | - https://github.com/appleboy/gin-jwt 56 | -------------------------------------------------------------------------------- /secret_provider.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "crypto/rsa" 5 | "errors" 6 | "github.com/golang-jwt/jwt/v4" 7 | "github.com/osstotalsoft/oidc-jwt-go/discovery" 8 | "sync" 9 | ) 10 | 11 | type SecretProvider interface { 12 | GetSecret(tokenKeyId string) (key *rsa.PublicKey, err error) 13 | } 14 | 15 | type SecretProviderFunc func(tokenKeyId string) (*rsa.PublicKey, error) 16 | 17 | func (f SecretProviderFunc) GetSecret(tokenKeyId string) (*rsa.PublicKey, error) { 18 | return f(tokenKeyId) 19 | } 20 | 21 | // NewKeyProvider provide a simple passphrase key provider. 22 | func NewKeyProvider(publicKey *rsa.PublicKey) SecretProvider { 23 | return SecretProviderFunc(func(tokenKeyId string) (*rsa.PublicKey, error) { 24 | return publicKey, nil 25 | }) 26 | } 27 | 28 | type oidcSecretProvider struct { 29 | configurationDiscoverer discovery.Discoverer 30 | cache sync.Map 31 | } 32 | 33 | func NewOidcSecretProvider(configurationDiscoverer discovery.Discoverer) *oidcSecretProvider { 34 | return &oidcSecretProvider{configurationDiscoverer: configurationDiscoverer} 35 | } 36 | 37 | func (p *oidcSecretProvider) GetSecret(tokenKeyId string) (*rsa.PublicKey, error) { 38 | 39 | if tokenKeyId == "" { 40 | return nil, errors.New("KeyId header not found in token") 41 | } 42 | 43 | key, found := p.cache.Load(tokenKeyId) 44 | if found { 45 | return key.(*rsa.PublicKey), nil 46 | } 47 | 48 | publicKey, err := p.interalGetSecret(tokenKeyId) 49 | if err == nil { 50 | p.cache.Store(tokenKeyId, publicKey) 51 | } 52 | return publicKey, err 53 | } 54 | 55 | func (p *oidcSecretProvider) interalGetSecret(tokenKeyId string) (*rsa.PublicKey, error) { 56 | 57 | config, err := p.configurationDiscoverer.GetOpenidConfiguration() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | var cert = "" 63 | for _, jwk := range config.JsonWebKeySet { 64 | if tokenKeyId == jwk.Kid { 65 | cert = "-----BEGIN CERTIFICATE-----\n" + jwk.X5c[0] + "\n-----END CERTIFICATE-----" 66 | } 67 | } 68 | 69 | if cert == "" { 70 | err := errors.New("unable to find appropriate key") 71 | return nil, err 72 | } 73 | 74 | return jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) 75 | } 76 | -------------------------------------------------------------------------------- /discovery/config.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | type OpenidConfiguration struct { 4 | Issuer string `json:"issuer"` 5 | JwksUri string `json:"jwks_uri"` 6 | AuthorizationEndpoint string `json:"authorization_endpoint"` 7 | TokenEndpoint string `json:"token_endpoint"` 8 | UserinfoEndpoint string `json:"userinfo_endpoint"` 9 | EndSessionEndpoint string `json:"end_session_endpoint"` 10 | CheckSessionIframe string `json:"check_session_iframe"` 11 | RevocationEndpoint string `json:"revocation_endpoint"` 12 | IntrospectionEndpoint string `json:"introspection_endpoint"` 13 | FrontchannelLogoutSupported bool `json:"frontchannel_logout_supported"` 14 | FrontchannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"` 15 | BackchannelLogoutSupported bool `json:"backchannel_logout_supported"` 16 | BackchannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported"` 17 | ScopesSupported []string `json:"scopes_supported"` 18 | ClaimsSupported []string `json:"claims_supported"` 19 | GrantTypesSupported []string `json:"grant_types_supported"` 20 | ResponseTypesSupported []string `json:"response_types_supported"` 21 | ResponseModesSupported []string `json:"response_modes_supported"` 22 | TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` 23 | SubjectTypesSupported []string `json:"subject_types_supported"` 24 | IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` 25 | CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` 26 | JsonWebKeySet []JsonWebKey `json:"-"` 27 | } 28 | 29 | type JsonWebKeySet struct { 30 | Keys []JsonWebKey `json:"keys"` 31 | } 32 | 33 | type JsonWebKey struct { 34 | Kty string `json:"kty"` 35 | Alg string `json:"alg"` 36 | Kid string `json:"kid"` 37 | Use string `json:"use"` 38 | N string `json:"n"` 39 | E string `json:"e"` 40 | X5c []string `json:"x5c"` 41 | } 42 | -------------------------------------------------------------------------------- /validator_test.go: -------------------------------------------------------------------------------- 1 | package oidc 2 | 3 | import ( 4 | "github.com/golang-jwt/jwt/v4" 5 | jwtRequest "github.com/golang-jwt/jwt/v4/request" 6 | "github.com/golang-jwt/jwt/v4/test" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | ) 12 | 13 | var Authority = "http://kube-worker1:30692" 14 | var Audience = "LSNG.Api" 15 | 16 | var claims = jwt.MapClaims{ 17 | "iss": "http://kube-worker1:30692", 18 | "aud": []string{ 19 | "http://kube-worker1:30692/resources", 20 | "LSNG.Api", 21 | "Notifier.Api", 22 | }, 23 | "client_id": "CharismaFinancialServices", 24 | "sub": "c8124881-ad67-443e-9473-08d5777d1ba8", 25 | "idp": "local", 26 | "partner_id": "-100", 27 | "charisma_user_id": "1", 28 | "scope": []string{ 29 | "openid", 30 | "profile", 31 | "roles", 32 | "LSNG.Api.read_only", 33 | "charisma_data", 34 | "Notifier.Api.write", 35 | }, 36 | "amr": []string{ 37 | "pwd", 38 | }, 39 | } 40 | 41 | func TestValidator(t *testing.T) { 42 | privateKey := test.LoadRSAPrivateKeyFromDisk("./test/sample_key") 43 | publicKey := test.LoadRSAPublicKeyFromDisk("./test/sample_key.pub") 44 | 45 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) 46 | tokenString, _ := token.SignedString(privateKey) 47 | 48 | validator := NewJWTValidator(jwtRequest.OAuth2Extractor, NewKeyProvider(publicKey), Audience, Authority) 49 | 50 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | _, err := validator(r) 52 | if err != nil { 53 | w.WriteHeader(http.StatusUnauthorized) 54 | _, _ = io.WriteString(w, err.Error()) 55 | } else { 56 | _, _ = io.WriteString(w, "OK") 57 | } 58 | }) 59 | req := httptest.NewRequest("GET", "/whatever", nil) 60 | req.Header.Add("Authorization", "Bearer "+tokenString) 61 | w := httptest.NewRecorder() 62 | handler.ServeHTTP(w, req) 63 | result := w.Result() 64 | 65 | if result.StatusCode != http.StatusOK { 66 | t.Error("request failed status: ", result.StatusCode) 67 | } 68 | } 69 | 70 | func BenchmarkTestValidator(b *testing.B) { 71 | privateKey := test.LoadRSAPrivateKeyFromDisk("./test/sample_key") 72 | publicKey := test.LoadRSAPublicKeyFromDisk("./test/sample_key.pub") 73 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) 74 | tokenString, _ := token.SignedString(privateKey) 75 | 76 | validator := NewJWTValidator(jwtRequest.OAuth2Extractor, NewKeyProvider(publicKey), Audience, Authority) 77 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 | _, err := validator(r) 79 | if err != nil { 80 | w.WriteHeader(http.StatusUnauthorized) 81 | _, _ = io.WriteString(w, err.Error()) 82 | } else { 83 | _, _ = io.WriteString(w, "OK") 84 | } 85 | }) 86 | req := httptest.NewRequest("GET", "/whatever", nil) 87 | req.Header.Add("Authorization", "Bearer "+tokenString) 88 | w := httptest.NewRecorder() 89 | 90 | for i := 0; i < b.N; i++ { 91 | handler.ServeHTTP(w, req) 92 | w.Result() 93 | } 94 | } 95 | --------------------------------------------------------------------------------