Basic logout workflow
This commit is contained in:
parent
06285a206f
commit
5d672ad6e1
97
client.go
97
client.go
|
@ -1,7 +1,10 @@
|
||||||
package oidc
|
package oidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc"
|
"github.com/coreos/go-oidc"
|
||||||
"github.com/dchest/uniuri"
|
"github.com/dchest/uniuri"
|
||||||
|
@ -41,10 +44,30 @@ func (c *Client) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
panic(errors.Wrap(err, "could not save session"))
|
panic(errors.Wrap(err, "could not save session"))
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, c.oauth2.AuthCodeURL(state), http.StatusFound)
|
authCodeOptions := []oauth2.AuthCodeOption{}
|
||||||
|
|
||||||
|
rawIDToken, _ := RawIDToken(w, r)
|
||||||
|
if rawIDToken != "" {
|
||||||
|
authCodeOptions = append(
|
||||||
|
authCodeOptions,
|
||||||
|
oauth2.SetAuthURLParam("id_token_hint", rawIDToken),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
authCodeURL := c.oauth2.AuthCodeURL(
|
||||||
|
state,
|
||||||
|
authCodeOptions...,
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Redirect(w, r, authCodeURL, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Logout(w http.ResponseWriter, r *http.Request) {
|
func (c *Client) Logout(w http.ResponseWriter, r *http.Request, postLogoutRedirectURL string) {
|
||||||
|
rawIDToken, err := RawIDToken(w, r)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not retrieve raw id token"))
|
||||||
|
}
|
||||||
|
|
||||||
ctn := container.Must(r.Context())
|
ctn := container.Must(r.Context())
|
||||||
|
|
||||||
sess, err := session.Must(ctn).Get(w, r)
|
sess, err := session.Must(ctn).Get(w, r)
|
||||||
|
@ -55,50 +78,102 @@ func (c *Client) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
state := uniuri.New()
|
state := uniuri.New()
|
||||||
|
|
||||||
sess.Set(SessionOIDCStateKey, state)
|
sess.Set(SessionOIDCStateKey, state)
|
||||||
|
sess.Unset(SessionOIDCRawTokenKey)
|
||||||
|
sess.Unset(SessionOIDCTokenKey)
|
||||||
|
|
||||||
if err := sess.Save(w, r); err != nil {
|
if err := sess.Save(w, r); err != nil {
|
||||||
panic(errors.Wrap(err, "could not save session"))
|
panic(errors.Wrap(err, "could not save session"))
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, c.oauth2.AuthCodeURL(state), http.StatusFound)
|
sessionEndURL, err := c.sessionEndURL(rawIDToken, state, postLogoutRedirectURL)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not retrieve session end url"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionEndURL != "" {
|
||||||
|
http.Redirect(w, r, sessionEndURL, http.StatusFound)
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Validate(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) {
|
func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string) (string, error) {
|
||||||
|
sessionEndEndpoint := &struct {
|
||||||
|
URL string `json:"end_session_endpoint"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := c.provider.Claims(&sessionEndEndpoint); err != nil {
|
||||||
|
return "", errors.Wrap(err, "could not unmarshal claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionEndEndpoint.URL == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteString(sessionEndEndpoint.URL)
|
||||||
|
|
||||||
|
v := url.Values{}
|
||||||
|
|
||||||
|
if idTokenHint != "" {
|
||||||
|
v.Set("id_token_hint", idTokenHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if postLogoutRedirectURL != "" {
|
||||||
|
v.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state != "" {
|
||||||
|
v.Set("state", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(sessionEndEndpoint.URL, "?") {
|
||||||
|
buf.WriteByte('&')
|
||||||
|
} else {
|
||||||
|
buf.WriteByte('?')
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(v.Encode())
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Validate(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, string, error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
ctn := container.Must(ctx)
|
ctn := container.Must(ctx)
|
||||||
|
|
||||||
sess, err := session.Must(ctn).Get(w, r)
|
sess, err := session.Must(ctn).Get(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "could not retrieve session")
|
return nil, "", errors.Wrap(err, "could not retrieve session")
|
||||||
}
|
}
|
||||||
|
|
||||||
state, ok := sess.Get(SessionOIDCStateKey).(string)
|
state, ok := sess.Get(SessionOIDCStateKey).(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("invalid state")
|
return nil, "", errors.New("invalid state")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.Query().Get("state") != state {
|
if r.URL.Query().Get("state") != state {
|
||||||
return nil, errors.New("state mismatch")
|
return nil, "", errors.New("state mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
|
|
||||||
token, err := c.oauth2.Exchange(ctx, code)
|
token, err := c.oauth2.Exchange(ctx, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "could not exchange token")
|
return nil, "", errors.Wrap(err, "could not exchange token")
|
||||||
}
|
}
|
||||||
|
|
||||||
rawIDToken, ok := token.Extra("id_token").(string)
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("could not find id token")
|
return nil, "", errors.New("could not find id token")
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
idToken, err := c.verifier.Verify(ctx, rawIDToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "could not verify id token")
|
return nil, "", errors.Wrap(err, "could not verify id token")
|
||||||
}
|
}
|
||||||
|
|
||||||
return idToken, nil
|
return idToken, rawIDToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(opts ...OptionFunc) *Client {
|
func NewClient(opts ...OptionFunc) *Client {
|
||||||
|
|
|
@ -9,7 +9,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="buttons is-right">
|
<div class="buttons is-right">
|
||||||
|
{{if .IDToken}}
|
||||||
<a class="button" href="/logout">Logout</a>
|
<a class="button" href="/logout">Logout</a>
|
||||||
|
{{else}}
|
||||||
|
<a class="button is-primary" href="/login">Login</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,8 +3,6 @@
|
||||||
<section class="home is-fullheight section">
|
<section class="home is-fullheight section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
<h2 class="is-size-4">Jeton OpenID Connect</h2>
|
|
||||||
<pre>{{ .JSONIDToken }}</pre>
|
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
{{define "title"}}Accueil{{end}}
|
||||||
|
{{define "body"}}
|
||||||
|
<section class="home is-fullheight section">
|
||||||
|
<div class="container">
|
||||||
|
{{template "header" .}}
|
||||||
|
<h2 class="is-size-4">Jeton OpenID Connect</h2>
|
||||||
|
<pre>{{ .JSONIDToken }}</pre>
|
||||||
|
{{template "footer" .}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{template "base" .}}
|
1
go.sum
1
go.sum
|
@ -34,6 +34,7 @@ github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CL
|
||||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
||||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gitlab.com/wpetit/goweb/middleware/container"
|
||||||
|
"gitlab.com/wpetit/goweb/service/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveHomePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctn := container.Must(r.Context())
|
||||||
|
tmpl := template.Must(ctn)
|
||||||
|
|
||||||
|
idToken, _ := oidc.IDToken(w, r)
|
||||||
|
|
||||||
|
if idToken != nil {
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := extendTemplateData(w, r, template.Data{})
|
||||||
|
|
||||||
|
if err := tmpl.RenderPage(w, "home.html.tmpl", data); err != nil {
|
||||||
|
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,41 +1,20 @@
|
||||||
package route
|
package route
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"gitlab.com/wpetit/goweb/logger"
|
"gitlab.com/wpetit/goweb/logger"
|
||||||
"gitlab.com/wpetit/goweb/middleware/container"
|
"gitlab.com/wpetit/goweb/middleware/container"
|
||||||
"gitlab.com/wpetit/goweb/service/template"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveHomePage(w http.ResponseWriter, r *http.Request) {
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
ctn := container.Must(r.Context())
|
ctn := container.Must(r.Context())
|
||||||
tmpl := template.Must(ctn)
|
client := oidc.Must(ctn)
|
||||||
|
client.Login(w, r)
|
||||||
idToken, err := oidc.IDToken(w, r)
|
|
||||||
if err != nil {
|
|
||||||
panic(errors.Wrap(err, "could not retrieve idToken"))
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonIDToken, err := json.MarshalIndent(idToken, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
panic(errors.Wrap(err, "could not encode idToken"))
|
|
||||||
}
|
|
||||||
|
|
||||||
data := extendTemplateData(w, r, template.Data{
|
|
||||||
"IDToken": idToken,
|
|
||||||
"JSONIDToken": string(jsonIDToken),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := tmpl.RenderPage(w, "home.html.tmpl", data); err != nil {
|
|
||||||
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
idToken, err := oidc.IDToken(w, r)
|
idToken, err := oidc.IDToken(w, r)
|
||||||
|
|
|
@ -23,5 +23,5 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
client := oidc.Must(ctn)
|
client := oidc.Must(ctn)
|
||||||
|
|
||||||
client.Logout(w, r)
|
client.Logout(w, r, "")
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,14 +9,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func Mount(r *chi.Mux, config *config.Config) error {
|
func Mount(r *chi.Mux, config *config.Config) error {
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(oidc.Middleware)
|
|
||||||
|
|
||||||
|
r.With(oidc.HandleCallback).Get("/oauth2/callback", handleLoginCallback)
|
||||||
r.Get("/", serveHomePage)
|
r.Get("/", serveHomePage)
|
||||||
})
|
|
||||||
|
|
||||||
r.With(oidc.HandleCallback).Get("/oauth2/callback", handleLogin)
|
|
||||||
r.Get("/logout", handleLogout)
|
r.Get("/logout", handleLogout)
|
||||||
|
r.Get("/login", handleLogin)
|
||||||
|
|
||||||
|
r.Route("/profile", func(r chi.Router) {
|
||||||
|
r.Use(oidc.Middleware)
|
||||||
|
r.Get("/", serveProfilePage)
|
||||||
|
})
|
||||||
|
|
||||||
notFoundHandler := r.NotFoundHandler()
|
notFoundHandler := r.NotFoundHandler()
|
||||||
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))
|
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gitlab.com/wpetit/goweb/middleware/container"
|
||||||
|
"gitlab.com/wpetit/goweb/service/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctn := container.Must(r.Context())
|
||||||
|
tmpl := template.Must(ctn)
|
||||||
|
|
||||||
|
idToken, err := oidc.IDToken(w, r)
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not retrieve idToken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonIDToken, err := json.MarshalIndent(idToken, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
panic(errors.Wrap(err, "could not encode idToken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := extendTemplateData(w, r, template.Data{
|
||||||
|
"IDToken": idToken,
|
||||||
|
"JSONIDToken": string(jsonIDToken),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := tmpl.RenderPage(w, "profile.html.tmpl", data); err != nil {
|
||||||
|
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SessionOIDCTokenKey = "oidc-token"
|
SessionOIDCTokenKey = "oidc-token"
|
||||||
|
SessionOIDCRawTokenKey = "oidc-raw-token"
|
||||||
SessionOIDCStateKey = "oidc-state"
|
SessionOIDCStateKey = "oidc-state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -47,7 +48,7 @@ func HandleCallback(next http.Handler) http.Handler {
|
||||||
ctn := container.Must(ctx)
|
ctn := container.Must(ctx)
|
||||||
client := Must(ctn)
|
client := Must(ctn)
|
||||||
|
|
||||||
idToken, err := client.Validate(w, r)
|
idToken, rawIDToken, err := client.Validate(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(ctx, "could not validate oidc token", logger.E(err))
|
logger.Error(ctx, "could not validate oidc token", logger.E(err))
|
||||||
|
|
||||||
|
@ -62,6 +63,7 @@ func HandleCallback(next http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
sess.Set(SessionOIDCTokenKey, idToken)
|
sess.Set(SessionOIDCTokenKey, idToken)
|
||||||
|
sess.Set(SessionOIDCRawTokenKey, rawIDToken)
|
||||||
|
|
||||||
if err := sess.Save(w, r); err != nil {
|
if err := sess.Save(w, r); err != nil {
|
||||||
panic(errors.Wrap(err, "could not save session"))
|
panic(errors.Wrap(err, "could not save session"))
|
||||||
|
@ -73,14 +75,6 @@ func HandleCallback(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(fn)
|
return http.HandlerFunc(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// ctx := r.Context()
|
|
||||||
// ctn := container.Must(ctx)
|
|
||||||
// client := Must(ctn)
|
|
||||||
|
|
||||||
// client
|
|
||||||
}
|
|
||||||
|
|
||||||
func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) {
|
func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) {
|
||||||
ctn := container.Must(r.Context())
|
ctn := container.Must(r.Context())
|
||||||
|
|
||||||
|
@ -96,3 +90,19 @@ func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) {
|
||||||
|
|
||||||
return idToken, nil
|
return idToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RawIDToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||||
|
ctn := container.Must(r.Context())
|
||||||
|
|
||||||
|
sess, err := session.Must(ctn).Get(w, r)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "could not retrieve session")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawIDToken, ok := sess.Get(SessionOIDCRawTokenKey).(string)
|
||||||
|
if !ok || rawIDToken == "" {
|
||||||
|
return "", errors.New("invalid raw id token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawIDToken, nil
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,12 @@ type Option struct {
|
||||||
Scopes []string
|
Scopes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithRedirectURL(url string) OptionFunc {
|
||||||
|
return func(opt *Option) {
|
||||||
|
opt.RedirectURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithCredentials(clientID, clientSecret string) OptionFunc {
|
func WithCredentials(clientID, clientSecret string) OptionFunc {
|
||||||
return func(opt *Option) {
|
return func(opt *Option) {
|
||||||
opt.ClientID = clientID
|
opt.ClientID = clientID
|
||||||
|
|
Loading…
Reference in New Issue