Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support OpenID tokens for getting using information #122

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
This library provides "social login" with Github, Google, Facebook, Microsoft, Twitter, Yandex, Battle.net, Apple, Patreon and Telegram as well as custom auth providers and email verification.

- Multiple oauth2 providers can be used at the same time
- Support of ID Tokens (OpenID) for loading user details
- Special `dev` provider allows local testing and development
- JWT stored in a secure cookie with XSRF protection. Cookies can be session-only
- Minimal scopes with user name, id and picture (avatar) only
Expand Down Expand Up @@ -320,6 +321,36 @@ In order to add a new oauth2 provider following input is required:
service.AddCustomProvider("custom123", auth.Client{Cid: "cid", Csecret: "csecret"}, prov.HandlerOpt)
```

### Using ID Tokens (OpenID Connect)

Example of configuring OAuth2 with OpenID Connect:

```go
c := auth.Client{
Cid: os.Getenv("AEXMPL_CIDE"),
Csecret: os.Getenv("AEXMPL_CSED"),
}

service.AddOpenIDProvider("my-openid", c, provider.CustomHandlerOpt{
Endpoint: oauth2.Endpoint{
AuthURL: "https://my-open-id-provider.com/oauth2/authorize",
TokenURL: "https://my-open-id-provider.com/oauth2/token",
},
JwksURL: "https://my-open-id-provider.com/.well-known/jwks",
InfoURL: "https://my-open-id-provider.com/user/",
MapUserFn: func (data provider.UserData, _ []byte) token.User {
userInfo := token.User{
ID: data.Value("sub"), // standard OpenID Connect claims are available
Name: data.Value("given_name"),
}
return userInfo
},
Scopes: []string{"openid", "email", "profile"}, // defaulted to "openid" if not specified
})
```

JWKS are loaded on application start, and then cached. There is no background refresh, but requesting unknown key (kid) will trigger keys reload.

### Self-implemented auth handler
Additionally it is possible to implement own auth handler. It may be useful if auth provider does not conform to oauth standard. Self-implemented handler has to implement `provider.Provider` interface.
```go
Expand Down
36 changes: 35 additions & 1 deletion auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ func (s *Service) Middleware() middleware.Authenticator {

// AddProvider adds provider for given name
func (s *Service) AddProvider(name, cid, csecret string) {

p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Expand Down Expand Up @@ -270,6 +269,20 @@ func (s *Service) AddDevProvider(port int) {
s.providers = append(s.providers, provider.NewService(provider.NewDev(p)))
}

// AddDevOpenIDProvider with a custom port that is using OpenID tokens
func (s *Service) AddDevOpenIDProvider(port int) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
L: s.logger,
Port: port,
UseOpenID: true,
}
s.providers = append(s.providers, provider.NewService(provider.NewDev(p)))
}

// AddAppleProvider allow SignIn with Apple ID
func (s *Service) AddAppleProvider(appleConfig provider.AppleConfig, privKeyLoader provider.PrivateKeyLoaderInterface) error {
p := provider.Params{
Expand Down Expand Up @@ -306,6 +319,27 @@ func (s *Service) AddCustomProvider(name string, client Client, copts provider.C
s.authMiddleware.Providers = s.providers
}

// AddOpenIDProvider adds custom provider (e.g. https://gopkg.in/oauth2.v3) that uses OpenID instead of pure OAuth2
func (s *Service) AddOpenIDProvider(name string, client Client, copts provider.CustomHandlerOpt) {
p := provider.Params{
URL: s.opts.URL,
JwtService: s.jwtService,
Issuer: s.issuer,
AvatarSaver: s.avatarProxy,
Cid: client.Cid,
Csecret: client.Csecret,
L: s.logger,
UseOpenID: true,
}

if copts.Scopes == nil {
copts.Scopes = []string{"openid"}
}

s.providers = append(s.providers, provider.NewService(provider.NewCustom(name, p, copts)))
s.authMiddleware.Providers = s.providers
}

// AddDirectProvider adds provider with direct check against data store
// it doesn't do any handshake and uses provided credChecker to verify user and password from the request
func (s *Service) AddDirectProvider(name string, credChecker provider.CredChecker) {
Expand Down
48 changes: 48 additions & 0 deletions auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
"io"
"io/ioutil"
"net"
Expand Down Expand Up @@ -374,6 +375,53 @@ func TestDirectProvider(t *testing.T) {
assert.NoError(t, resp.Body.Close())
}

func TestDevOpenIDProvider(t *testing.T) {
service := NewService(Opts{Logger: logger.Std, SecretReader: token.SecretFunc(func(aud string) (string, error) {
return "secret", nil
})})
service.AddDevOpenIDProvider(18089)

devAuth, err := service.DevAuth()
require.NoError(t, err)

go devAuth.Run(context.Background())
defer devAuth.Shutdown()

for i := 1; i < 20; i++ {
time.Sleep(time.Duration(i*10) * time.Millisecond)

dial, e := net.Dial("tcp", "localhost:18089")
if e == nil {
e = dial.Close()
require.NoError(t, e)

break
}
}

jwksResp, err := http.Get("http://localhost:18089/jwks")
require.NoError(t, err)
assert.Equal(t, 200, jwksResp.StatusCode)

service.AddOpenIDProvider("openid", Client{Cid: "cid", Csecret: "csecret"}, provider.CustomHandlerOpt{
Endpoint: oauth2.Endpoint{
AuthURL: "http://localhost:18089/login/oauth/authorize",
TokenURL: "http://localhost:18089/login/oauth/access_token",
AuthStyle: oauth2.AuthStyleAutoDetect,
},
InfoURL: "http://localhost:18089/user",
JwksURL: "http://localhost:18089/jwks",
MapUserFn: func(data provider.UserData, bytes []byte) token.User {
return token.User{
Name: data.Value("sub"),
}
},
})

assert.Len(t, service.Providers(), 2)
// OpenID flow is tested in the openid_test.go, but coverage tool isn't picking it up
}

func TestDirectProvider_WithCustomUserIDFunc(t *testing.T) {
_, teardown := prepService(t)
defer teardown()
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ require (

require (
cloud.google.com/go/compute v1.6.1 // indirect
github.com/MicahParks/keyfunc v1.1.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/golang-jwt/jwt/v4 v4.4.1 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.1.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/MicahParks/keyfunc v1.1.0 h1:9NcnRwS0ciuVeVNi+vTdYVMTmk62OID7VlG6y9BgLK0=
github.com/MicahParks/keyfunc v1.1.0/go.mod h1:a4yfunv77gZ0RgTNw7tOYS+bjtHk5565e+1dPz+YJI8=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
Expand Down Expand Up @@ -115,6 +117,8 @@ github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down
2 changes: 2 additions & 0 deletions provider/custom_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
type CustomHandlerOpt struct {
Endpoint oauth2.Endpoint
InfoURL string
JwksURL string
MapUserFn func(UserData, []byte) token.User
Scopes []string
}
Expand Down Expand Up @@ -208,6 +209,7 @@ func NewCustom(name string, p Params, copts CustomHandlerOpt) Oauth2Handler {
endpoint: copts.Endpoint,
scopes: copts.Scopes,
infoURL: copts.InfoURL,
jwksURL: copts.JwksURL,
mapUser: copts.MapUserFn,
})
}
Expand Down
120 changes: 115 additions & 5 deletions provider/dev_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ package provider

import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/golang-jwt/jwt"
"html/template"
"math/big"
"net/http"
"strings"
"sync"
Expand All @@ -25,9 +31,10 @@ const defDevAuthPort = 8084
// desired user name, this is the mode used for development. Non-interactive mode for tests only.
type DevAuthServer struct {
logger.L
Provider Oauth2Handler
Automatic bool
GetEmailFn func(string) string
Provider Oauth2Handler
Automatic bool
GetEmailFn func(string) string
CustomizeIDTokenFn func(map[string]interface{}) map[string]interface{}

username string // unsafe, but fine for dev
httpServer *http.Server
Expand All @@ -50,6 +57,15 @@ func (d *DevAuthServer) Run(ctx context.Context) { //nolint (gocyclo)
return
}

var privateKey *rsa.PrivateKey
if d.Provider.UseOpenID {
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
d.Logf("[ERROR] failed to generate keys")
return
}
}

d.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", d.Provider.Port),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -74,7 +90,8 @@ func (d *DevAuthServer) Run(ctx context.Context) { //nolint (gocyclo)
}

state := r.URL.Query().Get("state")
callbackURL := fmt.Sprintf("%s?code=g0ZGZmNjVmOWI&state=%s", d.Provider.conf.RedirectURL, state)
redirectURI := r.URL.Query().Get("redirect_uri")
callbackURL := fmt.Sprintf("%s?code=g0ZGZmNjVmOWI&state=%s", redirectURI, state)

Check warning

Code scanning / CodeQL

Open URL redirect

Untrusted URL redirection due to [user-provided value](1).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this change will break back compat for dev provider?

d.Logf("[DEBUG] callback url=%s", callbackURL)
w.Header().Add("Location", callbackURL)
w.WriteHeader(http.StatusFound)
Expand All @@ -87,13 +104,95 @@ func (d *DevAuthServer) Run(ctx context.Context) { //nolint (gocyclo)
"refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk",
"scope":"create",
"state":"12345678"
}`
}`

if d.Provider.UseOpenID {
email := d.username
if d.GetEmailFn != nil {
email = d.GetEmailFn(d.username)
}

idClaims := map[string]interface{}{
// required OpenID claims
"iss": "dev-auth",
"sub": "%s",
"aud": "client-id",
"iat": time.Now().Unix(),
"exp": time.Now().Add(1 * time.Hour).Unix(),

// optional OpenID claims
"picture": fmt.Sprintf("http://127.0.0.1:%d/avatar?user=%s", d.Provider.Port, d.username),
"given_name": d.username,
"email": email,
}

if d.CustomizeIDTokenFn != nil {
idClaims = d.CustomizeIDTokenFn(idClaims)
}

tk := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(idClaims))
tk.Header["kid"] = "dev-auth-key-1"

signedTk, e := tk.SignedString(privateKey)
if e != nil {
d.Logf("[ERROR] failed to sign ID token")
w.WriteHeader(http.StatusInternalServerError)
return
}

res = fmt.Sprintf(`{
"access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3",
"id_token": "%s",
"token_type":"bearer",
"expires_in":3600,
"refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk",
"scope":"create",
"state":"12345678"
}`, signedTk)
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err = w.Write([]byte(res)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

case strings.HasPrefix(r.URL.Path, "/jwks") && d.Provider.UseOpenID:
type jwkKey struct {
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
Alg string `json:"alg"`
Kid string `json:"kid"`
}

e := big.NewInt(int64(privateKey.E))
key := jwkKey{
Kty: "RSA",
Alg: "RS256",
Kid: "dev-auth-key-1",
N: base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(e.Bytes()),
}

jwks, er := json.Marshal(struct {
Keys []jwkKey `json:"keys"`
}{
Keys: []jwkKey{key},
})
if er != nil {
d.Logf("[ERROR] failed to marshal jwks")
w.WriteHeader(http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
wr, er := w.Write(jwks)
if er != nil || wr == 0 {
d.Logf("[ERROR] failed to write jwks")
w.WriteHeader(http.StatusInternalServerError)
}

case strings.HasPrefix(r.URL.Path, "/user"):
ava := fmt.Sprintf("http://127.0.0.1:%d/avatar?user=%s", d.Provider.Port, d.username)
res := fmt.Sprintf(`{
Expand Down Expand Up @@ -175,13 +274,24 @@ func NewDev(p Params) Oauth2Handler {
},
scopes: []string{"user:email"},
infoURL: fmt.Sprintf("http://127.0.0.1:%d/user", p.Port),
jwksURL: fmt.Sprintf("http://127.0.0.1:%d/jwks", p.Port),
mapUser: func(data UserData, _ []byte) token.User {
if p.UseOpenID {
return token.User{
ID: data.Value("sub"),
Name: data.Value("given_name"),
Picture: data.Value("picture"),
Email: data.Value("email"),
}
}

userInfo := token.User{
ID: data.Value("id"),
Name: data.Value("name"),
Picture: data.Value("picture"),
Email: data.Value("email"),
}

return userInfo
},
})
Expand Down
Loading