mirror of
https://github.com/int128/kubelogin.git
synced 2026-05-03 06:36:34 +00:00
Check nonce of ID token on authentication (#112)
This commit is contained in:
@@ -2,6 +2,8 @@ package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -74,11 +76,15 @@ func (c *client) AuthenticateByCode(ctx context.Context, in adaptors.OIDCAuthent
|
||||
if c.httpClient != nil {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient)
|
||||
}
|
||||
nonce, err := newNonce()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not generate a nonce parameter")
|
||||
}
|
||||
config := oauth2cli.Config{
|
||||
OAuth2Config: c.oauth2Config,
|
||||
LocalServerPort: in.LocalServerPort,
|
||||
SkipOpenBrowser: in.SkipOpenBrowser,
|
||||
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline},
|
||||
AuthCodeOptions: []oauth2.AuthCodeOption{oauth2.AccessTypeOffline, oidc.Nonce(nonce)},
|
||||
ShowLocalServerURL: in.ShowLocalServerURL.ShowLocalServerURL,
|
||||
}
|
||||
token, err := oauth2cli.GetToken(ctx, config)
|
||||
@@ -94,6 +100,9 @@ func (c *client) AuthenticateByCode(ctx context.Context, in adaptors.OIDCAuthent
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("could not verify the id_token: %w", err)
|
||||
}
|
||||
if verifiedIDToken.Nonce != nonce {
|
||||
return nil, xerrors.Errorf("nonce of ID token did not match (want %s but was %s)", nonce, verifiedIDToken.Nonce)
|
||||
}
|
||||
claims, err := dumpClaims(verifiedIDToken)
|
||||
if err != nil {
|
||||
c.logger.Debugf(1, "incomplete claims of the ID token: %w", err)
|
||||
@@ -106,6 +115,14 @@ func (c *client) AuthenticateByCode(ctx context.Context, in adaptors.OIDCAuthent
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newNonce() (string, error) {
|
||||
var n uint64
|
||||
if err := binary.Read(rand.Reader, binary.LittleEndian, &n); err != nil {
|
||||
return "", xerrors.Errorf("error while reading random: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", n), nil
|
||||
}
|
||||
|
||||
// AuthenticateByPassword performs the resource owner password credentials flow.
|
||||
func (c *client) AuthenticateByPassword(ctx context.Context, in adaptors.OIDCAuthenticateByPasswordIn) (*adaptors.OIDCAuthenticateOut, error) {
|
||||
if c.httpClient != nil {
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
//
|
||||
func TestCmd_Run(t *testing.T) {
|
||||
timeout := 1 * time.Second
|
||||
tokenExpiry := time.Now().Add(time.Hour)
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -42,12 +41,8 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticateCode("openid").Return("YOUR_AUTH_CODE", nil)
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{Issuer: serverURL})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
@@ -71,7 +66,7 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
idToken := newIDToken(t, serverURL, "", time.Hour)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticatePassword("USER", "PASS", "openid").
|
||||
@@ -97,12 +92,8 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticateCode("openid").Return("YOUR_AUTH_CODE", nil)
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{Issuer: serverURL})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
@@ -128,12 +119,8 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticateCode("profile groups openid").Return("YOUR_AUTH_CODE", nil)
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "profile groups openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
@@ -160,12 +147,8 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.StartTLS(t, keys.TLSServerCert, keys.TLSServerKey, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticateCode("openid").Return("YOUR_AUTH_CODE", nil)
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
@@ -192,12 +175,8 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.StartTLS(t, keys.TLSServerCert, keys.TLSServerKey, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticateCode("openid").Return("YOUR_AUTH_CODE", nil)
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
@@ -224,7 +203,7 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", time.Hour)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
|
||||
@@ -252,7 +231,7 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
idToken := newIDToken(t, serverURL, "YOUR_NONCE", time.Hour)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().Refresh("VALID_REFRESH_TOKEN").
|
||||
@@ -260,7 +239,7 @@ func TestCmd_Run(t *testing.T) {
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDToken: newIDToken(t, serverURL, time.Now().Add(-time.Hour)), // expired
|
||||
IDToken: newIDToken(t, serverURL, "YOUR_NONCE", -time.Hour), // expired
|
||||
RefreshToken: "VALID_REFRESH_TOKEN",
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
@@ -282,19 +261,15 @@ func TestCmd_Run(t *testing.T) {
|
||||
service := mock_idp.NewMockService(ctrl)
|
||||
serverURL, server := localserver.Start(t, idp.NewHandler(t, service))
|
||||
defer server.Shutdown(t, ctx)
|
||||
idToken := newIDToken(t, serverURL, tokenExpiry)
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
var idToken string
|
||||
setupMockIDPForCodeFlow(t, service, serverURL, "openid", &idToken)
|
||||
service.EXPECT().Refresh("EXPIRED_REFRESH_TOKEN").
|
||||
Return(nil, &idp.ErrorResponse{Code: "invalid_request", Description: "token has expired"}).
|
||||
MaxTimes(2) // package oauth2 will retry refreshing the token
|
||||
service.EXPECT().AuthenticateCode("openid").Return("YOUR_AUTH_CODE", nil)
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
Return(idp.NewTokenResponse(idToken, "YOUR_REFRESH_TOKEN"), nil)
|
||||
|
||||
kubeConfigFilename := kubeconfig.Create(t, &kubeconfig.Values{
|
||||
Issuer: serverURL,
|
||||
IDToken: newIDToken(t, serverURL, time.Now().Add(-time.Hour)), // expired
|
||||
IDToken: newIDToken(t, serverURL, "YOUR_NONCE", -time.Hour), // expired
|
||||
RefreshToken: "EXPIRED_REFRESH_TOKEN",
|
||||
})
|
||||
defer os.Remove(kubeConfigFilename)
|
||||
@@ -308,19 +283,21 @@ func TestCmd_Run(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newIDToken(t *testing.T, issuer string, expiry time.Time) string {
|
||||
func newIDToken(t *testing.T, issuer, nonce string, expiration time.Duration) string {
|
||||
t.Helper()
|
||||
var claims struct {
|
||||
jwt.StandardClaims
|
||||
Nonce string `json:"nonce"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
claims.StandardClaims = jwt.StandardClaims{
|
||||
Issuer: issuer,
|
||||
Audience: "kubernetes",
|
||||
ExpiresAt: expiry.Unix(),
|
||||
Subject: "SUBJECT",
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: time.Now().Add(expiration).Unix(),
|
||||
}
|
||||
claims.Nonce = nonce
|
||||
claims.Groups = []string{"admin", "users"}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
s, err := token.SignedString(keys.JWSKeyPair)
|
||||
@@ -330,6 +307,22 @@ func newIDToken(t *testing.T, issuer string, expiry time.Time) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func setupMockIDPForCodeFlow(t *testing.T, service *mock_idp.MockService, serverURL, scope string, idToken *string) {
|
||||
var nonce string
|
||||
service.EXPECT().Discovery().Return(idp.NewDiscoveryResponse(serverURL))
|
||||
service.EXPECT().GetCertificates().Return(idp.NewCertificatesResponse(keys.JWSKeyPair))
|
||||
service.EXPECT().AuthenticateCode(scope, gomock.Any()).
|
||||
DoAndReturn(func(_, gotNonce string) (string, error) {
|
||||
nonce = gotNonce
|
||||
return "YOUR_AUTH_CODE", nil
|
||||
})
|
||||
service.EXPECT().Exchange("YOUR_AUTH_CODE").
|
||||
DoAndReturn(func(string) (*idp.TokenResponse, error) {
|
||||
*idToken = newIDToken(t, serverURL, nonce, time.Hour)
|
||||
return idp.NewTokenResponse(*idToken, "YOUR_REFRESH_TOKEN"), nil
|
||||
})
|
||||
}
|
||||
|
||||
func runCmd(t *testing.T, ctx context.Context, s usecases.LoginShowLocalServerURL, args ...string) {
|
||||
t.Helper()
|
||||
cmd := di.NewCmdWith(logger.New(t), s)
|
||||
|
||||
@@ -14,18 +14,23 @@ func NewHandler(t *testing.T, service Service) *Handler {
|
||||
return &Handler{t, service}
|
||||
}
|
||||
|
||||
// Handler provides a HTTP handler for the identity provider of OpenID Connect.
|
||||
// You need to implement the Service interface.
|
||||
// Note that this skips some security checks and is only for testing.
|
||||
type Handler struct {
|
||||
t *testing.T
|
||||
service Service
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.serveHTTP(w, r)
|
||||
wr := &responseWriterRecorder{w, 200}
|
||||
err := h.serveHTTP(wr, r)
|
||||
if err == nil {
|
||||
h.t.Logf("%d %s %s", wr.statusCode, r.Method, r.RequestURI)
|
||||
return
|
||||
}
|
||||
if errResp := new(ErrorResponse); xerrors.As(err, &errResp) {
|
||||
h.t.Logf("idp/handler: 400 Bad Request: %+v", errResp)
|
||||
h.t.Logf("400 %s %s: %s", r.Method, r.RequestURI, err)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(400)
|
||||
e := json.NewEncoder(w)
|
||||
@@ -34,14 +39,23 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
h.t.Errorf("idp/handler: 500 Server Error: %s", err)
|
||||
h.t.Logf("500 %s %s: %s", r.Method, r.RequestURI, err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
|
||||
type responseWriterRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (w *responseWriterRecorder) WriteHeader(statusCode int) {
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
w.statusCode = statusCode
|
||||
}
|
||||
|
||||
func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
m := r.Method
|
||||
p := r.URL.Path
|
||||
h.t.Logf("idp/handler: %s %s", m, r.RequestURI)
|
||||
switch {
|
||||
case m == "GET" && p == "/.well-known/openid-configuration":
|
||||
discoveryResponse := h.service.Discovery()
|
||||
@@ -58,11 +72,11 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
case m == "GET" && p == "/auth":
|
||||
// Authentication Response
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
|
||||
// 3.1.2.1. Authentication Request
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
q := r.URL.Query()
|
||||
redirectURI, scope, state := q.Get("redirect_uri"), q.Get("scope"), q.Get("state")
|
||||
code, err := h.service.AuthenticateCode(scope)
|
||||
redirectURI, scope, state, nonce := q.Get("redirect_uri"), q.Get("scope"), q.Get("state"), q.Get("nonce")
|
||||
code, err := h.service.AuthenticateCode(scope, nonce)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("authentication error: %w", err)
|
||||
}
|
||||
@@ -76,8 +90,7 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
// 3.1.3.1. Token Request
|
||||
// 3.1.3.3. Successful Token Response
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
|
||||
code := r.Form.Get("code")
|
||||
tokenResponse, err := h.service.Exchange(code)
|
||||
if err != nil {
|
||||
@@ -89,7 +102,7 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
case "password":
|
||||
// Token Response
|
||||
// 4.3. Resource Owner Password Credentials Grant
|
||||
// https://tools.ietf.org/html/rfc6749#section-4.3
|
||||
username, password, scope := r.Form.Get("username"), r.Form.Get("password"), r.Form.Get("scope")
|
||||
tokenResponse, err := h.service.AuthenticatePassword(username, password, scope)
|
||||
@@ -103,7 +116,6 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
case "refresh_token":
|
||||
// 12.1. Refresh Request
|
||||
// 12.2. Successful Refresh Response
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken
|
||||
refreshToken := r.Form.Get("refresh_token")
|
||||
tokenResponse, err := h.service.Refresh(refreshToken)
|
||||
@@ -116,7 +128,12 @@ func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||
return xerrors.Errorf("could not render json: %w", err)
|
||||
}
|
||||
default:
|
||||
return xerrors.Errorf("invalid grant_type: %s", grantType)
|
||||
// 5.2. Error Response
|
||||
// https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
return &ErrorResponse{
|
||||
Code: "invalid_grant",
|
||||
Description: fmt.Sprintf("unknown grant_type %s", grantType),
|
||||
}
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
|
||||
@@ -34,16 +34,16 @@ func (m *MockService) EXPECT() *MockServiceMockRecorder {
|
||||
}
|
||||
|
||||
// AuthenticateCode mocks base method
|
||||
func (m *MockService) AuthenticateCode(arg0 string) (string, error) {
|
||||
ret := m.ctrl.Call(m, "AuthenticateCode", arg0)
|
||||
func (m *MockService) AuthenticateCode(arg0, arg1 string) (string, error) {
|
||||
ret := m.ctrl.Call(m, "AuthenticateCode", arg0, arg1)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AuthenticateCode indicates an expected call of AuthenticateCode
|
||||
func (mr *MockServiceMockRecorder) AuthenticateCode(arg0 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateCode", reflect.TypeOf((*MockService)(nil).AuthenticateCode), arg0)
|
||||
func (mr *MockServiceMockRecorder) AuthenticateCode(arg0, arg1 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateCode", reflect.TypeOf((*MockService)(nil).AuthenticateCode), arg0, arg1)
|
||||
}
|
||||
|
||||
// AuthenticatePassword mocks base method
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
type Service interface {
|
||||
Discovery() *DiscoveryResponse
|
||||
GetCertificates() *CertificatesResponse
|
||||
AuthenticateCode(scope string) (code string, err error)
|
||||
AuthenticateCode(scope, nonce string) (code string, err error)
|
||||
Exchange(code string) (*TokenResponse, error)
|
||||
AuthenticatePassword(username, password, scope string) (*TokenResponse, error)
|
||||
Refresh(refreshToken string) (*TokenResponse, error)
|
||||
|
||||
Reference in New Issue
Block a user