2015-08-18 05:57:27 +05:30
|
|
|
package user
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"net/mail"
|
2015-10-17 03:17:58 +05:30
|
|
|
"net/url"
|
2015-08-18 05:57:27 +05:30
|
|
|
"os"
|
|
|
|
"sort"
|
|
|
|
|
2015-11-10 04:05:11 +05:30
|
|
|
"github.com/jonboulle/clockwork"
|
2015-12-30 22:24:31 +05:30
|
|
|
"github.com/pborman/uuid"
|
2015-08-18 05:57:27 +05:30
|
|
|
|
|
|
|
"github.com/coreos/dex/repo"
|
|
|
|
"github.com/coreos/go-oidc/jose"
|
2015-10-17 03:17:58 +05:30
|
|
|
"github.com/coreos/go-oidc/key"
|
|
|
|
"github.com/coreos/go-oidc/oidc"
|
2015-08-18 05:57:27 +05:30
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
MaxEmailLength = 200
|
2015-10-17 03:17:58 +05:30
|
|
|
|
|
|
|
// ClaimPasswordResetPassword represents the hash of the password to be
|
|
|
|
// reset; in other words, the old password
|
|
|
|
ClaimPasswordResetPassword = "http://coreos.com/password/old-hash"
|
|
|
|
|
|
|
|
// ClaimEmailVerificationEmail represents the email to be verified. Note
|
|
|
|
// that we are intentionally not using the "email" claim for this purpose.
|
|
|
|
ClaimEmailVerificationEmail = "http://coreos.com/email/verificationEmail"
|
|
|
|
|
|
|
|
// ClaimPasswordResetCallback represents where a user should be sent after
|
|
|
|
// resetting their password.
|
|
|
|
ClaimPasswordResetCallback = "http://coreos.com/password/reset-callback"
|
|
|
|
|
|
|
|
// Claim representing where a user should be sent after verifying their email address.
|
|
|
|
ClaimEmailVerificationCallback = "http://coreos.com/email/verification-callback"
|
|
|
|
|
|
|
|
// Claim representing where a user should be sent after responding to an invitation
|
|
|
|
ClaimInvitationCallback = "http://coreos.com/invitation/callback"
|
2015-08-18 05:57:27 +05:30
|
|
|
)
|
|
|
|
|
2015-11-10 04:05:11 +05:30
|
|
|
var (
|
|
|
|
clock = clockwork.NewRealClock()
|
|
|
|
)
|
|
|
|
|
2015-10-20 23:14:33 +05:30
|
|
|
func assertStringClaim(claims jose.Claims, k string) string {
|
|
|
|
s, ok, err := claims.StringClaim(k)
|
|
|
|
if !ok || err != nil {
|
2015-11-10 04:05:11 +05:30
|
|
|
panic(fmt.Sprintf("claims were not validated correctly, missing or wrong claim: %v", k))
|
2015-10-20 23:14:33 +05:30
|
|
|
}
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
|
|
|
func assertURLClaim(claims jose.Claims, k string) *url.URL {
|
|
|
|
ustring := assertStringClaim(claims, k)
|
|
|
|
ret, err := url.Parse(ustring)
|
|
|
|
if err != nil {
|
2015-11-10 04:05:11 +05:30
|
|
|
panic(fmt.Sprintf("url claim was not validated correctly: %v", k))
|
2015-10-20 23:14:33 +05:30
|
|
|
}
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
2015-08-18 05:57:27 +05:30
|
|
|
type UserIDGenerator func() (string, error)
|
|
|
|
|
|
|
|
func DefaultUserIDGenerator() (string, error) {
|
|
|
|
return uuid.New(), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type User struct {
|
|
|
|
// ID is the machine-generated, stable, unique identifier for this User.
|
|
|
|
ID string
|
|
|
|
|
|
|
|
// DisplayName is human readable name meant for display purposes.
|
|
|
|
// DisplayName is not neccesarily unique with a UserRepo.
|
|
|
|
DisplayName string
|
|
|
|
|
|
|
|
Email string
|
|
|
|
|
|
|
|
EmailVerified bool
|
|
|
|
|
|
|
|
Admin bool
|
|
|
|
|
2015-09-26 02:54:51 +05:30
|
|
|
Disabled bool
|
|
|
|
|
2015-08-18 05:57:27 +05:30
|
|
|
CreatedAt time.Time
|
|
|
|
}
|
|
|
|
|
|
|
|
type UserFilter struct {
|
|
|
|
// TODO(bobbyrullo): actually put stuff in here.
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddToClaims adds basic information about the user to the given Claims.
|
|
|
|
// http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
|
|
func (u *User) AddToClaims(claims jose.Claims) {
|
|
|
|
claims.Add("name", u.DisplayName)
|
|
|
|
if u.Email != "" {
|
|
|
|
claims.Add("email", u.Email)
|
|
|
|
if u.EmailVerified {
|
|
|
|
claims.Add("email_verified", true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// UserRepo implementations maintain a persistent set of users.
|
|
|
|
// The following invariants must be maintained:
|
|
|
|
// * Users must have a unique Email and ID
|
|
|
|
// * No other Users may have the same RemoteIdentity as one of the
|
|
|
|
// users. (This constraint may be relaxed in the future)
|
|
|
|
type UserRepo interface {
|
|
|
|
Get(tx repo.Transaction, id string) (User, error)
|
|
|
|
|
|
|
|
// List returns a list of users meeting the given conditions.
|
|
|
|
// A nextPageToken is returned when there are further results to be had,
|
|
|
|
// with the expectation that it will be passed into a subsequent List
|
|
|
|
// call. When nextPageToken is non-empty filter and maxResults are ignored.
|
|
|
|
List(tx repo.Transaction, filter UserFilter, maxResults int, nextPageToken string) ([]User, string, error)
|
|
|
|
|
|
|
|
Create(repo.Transaction, User) error
|
|
|
|
|
|
|
|
GetByEmail(tx repo.Transaction, email string) (User, error)
|
|
|
|
|
2015-09-29 02:21:48 +05:30
|
|
|
Disable(tx repo.Transaction, id string, disabled bool) error
|
|
|
|
|
2015-08-18 05:57:27 +05:30
|
|
|
Update(repo.Transaction, User) error
|
|
|
|
|
|
|
|
GetByRemoteIdentity(repo.Transaction, RemoteIdentity) (User, error)
|
|
|
|
|
|
|
|
AddRemoteIdentity(tx repo.Transaction, userID string, remoteID RemoteIdentity) error
|
|
|
|
|
|
|
|
RemoveRemoteIdentity(tx repo.Transaction, userID string, remoteID RemoteIdentity) error
|
|
|
|
|
|
|
|
GetRemoteIdentities(tx repo.Transaction, userID string) ([]RemoteIdentity, error)
|
|
|
|
|
|
|
|
GetAdminCount(repo.Transaction) (int, error)
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
ErrorDuplicateID = errors.New("ID not available")
|
|
|
|
ErrorDuplicateEmail = errors.New("email not available")
|
|
|
|
|
|
|
|
ErrorDuplicateRemoteIdentity = errors.New("remote identity already in use for another user")
|
|
|
|
ErrorInvalidEmail = errors.New("invalid Email")
|
|
|
|
ErrorInvalidID = errors.New("invalid ID")
|
|
|
|
ErrorNotFound = errors.New("user not found in repository")
|
|
|
|
)
|
|
|
|
|
|
|
|
// RemoteIdentity represents a User's identity at an IDP.
|
|
|
|
type RemoteIdentity struct {
|
|
|
|
// IDPCID is the identifier of the IDP which hosts this identity.
|
|
|
|
ConnectorID string
|
|
|
|
|
|
|
|
// ID is the identifier of this User at the IDP.
|
|
|
|
ID string
|
|
|
|
}
|
|
|
|
|
|
|
|
func ValidEmail(email string) bool {
|
|
|
|
address, err := mail.ParseAddress(email)
|
|
|
|
if err != nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2015-12-01 22:36:30 +05:30
|
|
|
// Has mail.ParseAddress parsed the entire string and only found a single
|
|
|
|
// address without a name?
|
|
|
|
return address.Address == email
|
2015-08-18 05:57:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
func ValidPassword(plaintext string) bool {
|
|
|
|
return len(plaintext) > 5
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewUserRepo returns an in-memory UserRepo useful for development.
|
|
|
|
func NewUserRepo() UserRepo {
|
|
|
|
return &memUserRepo{
|
|
|
|
usersByID: make(map[string]User),
|
|
|
|
userIDsByEmail: make(map[string]string),
|
|
|
|
userIDsByRemoteID: make(map[RemoteIdentity]string),
|
|
|
|
remoteIDsByUserID: make(map[string]map[RemoteIdentity]struct{}),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type memUserRepo struct {
|
|
|
|
usersByID map[string]User
|
|
|
|
userIDsByEmail map[string]string
|
|
|
|
userIDsByRemoteID map[RemoteIdentity]string
|
|
|
|
remoteIDsByUserID map[string]map[RemoteIdentity]struct{}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) Get(_ repo.Transaction, id string) (User, error) {
|
|
|
|
user, ok := r.usersByID[id]
|
|
|
|
if !ok {
|
|
|
|
return User{}, ErrorNotFound
|
|
|
|
}
|
|
|
|
return user, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type usersByEmail []User
|
|
|
|
|
|
|
|
func (s usersByEmail) Len() int { return len(s) }
|
|
|
|
func (s usersByEmail) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
|
|
|
func (s usersByEmail) Less(i, j int) bool { return s[i].Email < s[j].Email }
|
|
|
|
|
|
|
|
func (r *memUserRepo) List(tx repo.Transaction, filter UserFilter, maxResults int, nextPageToken string) ([]User, string, error) {
|
|
|
|
var offset int
|
|
|
|
var err error
|
|
|
|
if nextPageToken != "" {
|
|
|
|
filter, maxResults, offset, err = DecodeNextPageToken(nextPageToken)
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return nil, "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
users := []User{}
|
|
|
|
for _, usr := range r.usersByID {
|
|
|
|
users = append(users, usr)
|
|
|
|
}
|
|
|
|
|
|
|
|
sort.Sort(usersByEmail(users))
|
|
|
|
|
|
|
|
high := offset + maxResults
|
|
|
|
|
|
|
|
var tok string
|
|
|
|
if high >= len(users) {
|
|
|
|
high = len(users)
|
|
|
|
} else {
|
|
|
|
tok, err = EncodeNextPageToken(filter, maxResults, high)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(users[offset:high]) == 0 {
|
|
|
|
return nil, "", ErrorNotFound
|
|
|
|
}
|
|
|
|
return users[offset:high], tok, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) GetByEmail(tx repo.Transaction, email string) (User, error) {
|
|
|
|
userID, ok := r.userIDsByEmail[email]
|
|
|
|
if !ok {
|
|
|
|
return User{}, ErrorNotFound
|
|
|
|
}
|
|
|
|
return r.Get(tx, userID)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) Create(_ repo.Transaction, user User) error {
|
|
|
|
if user.ID == "" {
|
|
|
|
return ErrorInvalidID
|
|
|
|
}
|
|
|
|
|
|
|
|
if !ValidEmail(user.Email) {
|
|
|
|
return ErrorInvalidEmail
|
|
|
|
}
|
|
|
|
|
|
|
|
// make sure no one has the same ID; if using UUID the chances of this
|
|
|
|
// happening are astronomically small.
|
|
|
|
_, ok := r.usersByID[user.ID]
|
|
|
|
if ok {
|
|
|
|
return ErrorDuplicateID
|
|
|
|
}
|
|
|
|
|
|
|
|
// make sure there's no other user with the same Email
|
|
|
|
_, ok = r.userIDsByEmail[user.Email]
|
|
|
|
if ok {
|
|
|
|
return ErrorDuplicateEmail
|
|
|
|
}
|
|
|
|
|
|
|
|
r.set(user)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) Update(_ repo.Transaction, user User) error {
|
|
|
|
if user.ID == "" {
|
|
|
|
return ErrorInvalidID
|
|
|
|
}
|
|
|
|
|
|
|
|
if !ValidEmail(user.Email) {
|
|
|
|
return ErrorInvalidEmail
|
|
|
|
}
|
|
|
|
|
|
|
|
// make sure this user exists already
|
|
|
|
_, ok := r.usersByID[user.ID]
|
|
|
|
if !ok {
|
|
|
|
return ErrorNotFound
|
|
|
|
}
|
|
|
|
|
|
|
|
// make sure there's no other user with the same Email
|
|
|
|
otherID, ok := r.userIDsByEmail[user.Email]
|
|
|
|
if ok && otherID != user.ID {
|
|
|
|
return ErrorDuplicateEmail
|
|
|
|
}
|
|
|
|
|
|
|
|
r.set(user)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2015-09-29 02:21:48 +05:30
|
|
|
func (r *memUserRepo) Disable(_ repo.Transaction, id string, disable bool) error {
|
2015-09-30 03:57:15 +05:30
|
|
|
if id == "" {
|
|
|
|
return ErrorInvalidID
|
|
|
|
}
|
2015-09-29 02:21:48 +05:30
|
|
|
user, ok := r.usersByID[id]
|
|
|
|
if !ok {
|
|
|
|
return ErrorNotFound
|
|
|
|
}
|
|
|
|
user.Disabled = disable
|
|
|
|
r.set(user)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2015-08-18 05:57:27 +05:30
|
|
|
func (r *memUserRepo) AddRemoteIdentity(_ repo.Transaction, userID string, ri RemoteIdentity) error {
|
|
|
|
_, ok := r.usersByID[userID]
|
|
|
|
if !ok {
|
|
|
|
return ErrorNotFound
|
|
|
|
}
|
|
|
|
_, ok = r.userIDsByRemoteID[ri]
|
|
|
|
if ok {
|
|
|
|
return ErrorDuplicateRemoteIdentity
|
|
|
|
}
|
|
|
|
|
|
|
|
r.userIDsByRemoteID[ri] = userID
|
|
|
|
rIDs, ok := r.remoteIDsByUserID[userID]
|
|
|
|
if !ok {
|
|
|
|
rIDs = make(map[RemoteIdentity]struct{})
|
|
|
|
r.remoteIDsByUserID[userID] = rIDs
|
|
|
|
}
|
|
|
|
|
|
|
|
rIDs[ri] = struct{}{}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) RemoveRemoteIdentity(_ repo.Transaction, userID string, ri RemoteIdentity) error {
|
|
|
|
otherID, ok := r.userIDsByRemoteID[ri]
|
|
|
|
if !ok {
|
|
|
|
return ErrorNotFound
|
|
|
|
}
|
|
|
|
if otherID != userID {
|
|
|
|
return ErrorNotFound
|
|
|
|
}
|
|
|
|
delete(r.userIDsByRemoteID, ri)
|
|
|
|
delete(r.remoteIDsByUserID[userID], ri)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) GetByRemoteIdentity(_ repo.Transaction, ri RemoteIdentity) (User, error) {
|
|
|
|
userID, ok := r.userIDsByRemoteID[ri]
|
|
|
|
if !ok {
|
|
|
|
return User{}, ErrorNotFound
|
|
|
|
}
|
|
|
|
|
|
|
|
user, ok := r.usersByID[userID]
|
|
|
|
if !ok {
|
|
|
|
return User{}, ErrorNotFound
|
|
|
|
}
|
|
|
|
return user, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) GetRemoteIdentities(_ repo.Transaction, userID string) ([]RemoteIdentity, error) {
|
|
|
|
ids := []RemoteIdentity{}
|
|
|
|
for id := range r.remoteIDsByUserID[userID] {
|
|
|
|
ids = append(ids, id)
|
|
|
|
}
|
|
|
|
return ids, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) GetAdminCount(_ repo.Transaction) (int, error) {
|
|
|
|
var i int
|
|
|
|
for _, usr := range r.usersByID {
|
|
|
|
if usr.Admin {
|
|
|
|
i++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return i, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *memUserRepo) set(user User) error {
|
|
|
|
r.usersByID[user.ID] = user
|
|
|
|
r.userIDsByEmail[user.Email] = user.ID
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type UserWithRemoteIdentities struct {
|
|
|
|
User User `json:"user"`
|
|
|
|
RemoteIdentities []RemoteIdentity `json:"remoteIdentities"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewUserRepoFromFile returns an in-memory UserRepo useful for development given a JSON serialized file of Users.
|
|
|
|
func NewUserRepoFromFile(loc string) (UserRepo, error) {
|
|
|
|
us, err := readUsersFromFile(loc)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return NewUserRepoFromUsers(us), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewUserRepoFromUsers(us []UserWithRemoteIdentities) UserRepo {
|
|
|
|
memUserRepo := NewUserRepo().(*memUserRepo)
|
|
|
|
for _, u := range us {
|
|
|
|
memUserRepo.set(u.User)
|
|
|
|
for _, ri := range u.RemoteIdentities {
|
|
|
|
memUserRepo.AddRemoteIdentity(nil, u.User.ID, ri)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return memUserRepo
|
|
|
|
}
|
|
|
|
|
|
|
|
func newUsersFromReader(r io.Reader) ([]UserWithRemoteIdentities, error) {
|
|
|
|
var us []UserWithRemoteIdentities
|
|
|
|
err := json.NewDecoder(r).Decode(&us)
|
|
|
|
return us, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func readUsersFromFile(loc string) ([]UserWithRemoteIdentities, error) {
|
|
|
|
uf, err := os.Open(loc)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("unable to read users from file %q: %v", loc, err)
|
|
|
|
}
|
|
|
|
defer uf.Close()
|
|
|
|
|
|
|
|
us, err := newUsersFromReader(uf)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return us, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (u *User) UnmarshalJSON(data []byte) error {
|
|
|
|
var dec struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
DisplayName string `json:"displayName"`
|
|
|
|
Email string `json:"email"`
|
|
|
|
EmailVerified bool `json:"emailVerified"`
|
|
|
|
}
|
|
|
|
|
|
|
|
err := json.Unmarshal(data, &dec)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("invalid User entry: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
u.ID = dec.ID
|
|
|
|
u.DisplayName = dec.DisplayName
|
|
|
|
u.Email = dec.Email
|
|
|
|
u.EmailVerified = dec.EmailVerified
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (u *RemoteIdentity) UnmarshalJSON(data []byte) error {
|
|
|
|
var dec struct {
|
|
|
|
ConnectorID string `json:"connectorID"`
|
|
|
|
ID string `json:"id"`
|
|
|
|
}
|
|
|
|
|
|
|
|
err := json.Unmarshal(data, &dec)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("invalid RemoteIdentity entry: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
u.ID = dec.ID
|
|
|
|
u.ConnectorID = dec.ConnectorID
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2015-10-17 03:17:58 +05:30
|
|
|
|
|
|
|
type TokenClaims struct {
|
|
|
|
Claims jose.Claims
|
|
|
|
}
|
|
|
|
|
2015-10-17 05:24:22 +05:30
|
|
|
// Returns TokenClaims if and only if
|
|
|
|
// - the given token string is an appropriately formatted JWT
|
|
|
|
// - the JWT contains nonempty "aud" and "sub" claims
|
|
|
|
// - the JWT can be verified for the client associated with the "aud" claim
|
|
|
|
// using the given keys
|
2015-10-17 03:17:58 +05:30
|
|
|
func parseAndVerifyTokenClaims(token string, issuer url.URL, keys []key.PublicKey) (TokenClaims, error) {
|
|
|
|
jwt, err := jose.ParseJWT(token)
|
|
|
|
if err != nil {
|
|
|
|
return TokenClaims{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
claims, err := jwt.Claims()
|
|
|
|
if err != nil {
|
|
|
|
return TokenClaims{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
clientID, ok, err := claims.StringClaim("aud")
|
|
|
|
if err != nil {
|
|
|
|
return TokenClaims{}, err
|
|
|
|
}
|
|
|
|
if !ok || clientID == "" {
|
|
|
|
return TokenClaims{}, errors.New("no aud(client ID) claim")
|
|
|
|
}
|
|
|
|
|
|
|
|
sub, ok, err := claims.StringClaim("sub")
|
|
|
|
if err != nil {
|
|
|
|
return TokenClaims{}, err
|
|
|
|
}
|
|
|
|
if !ok || sub == "" {
|
|
|
|
return TokenClaims{}, errors.New("no sub claim")
|
|
|
|
}
|
|
|
|
|
|
|
|
noop := func() error { return nil }
|
|
|
|
|
|
|
|
keysFunc := func() []key.PublicKey {
|
|
|
|
return keys
|
|
|
|
}
|
|
|
|
|
|
|
|
verifier := oidc.NewJWTVerifier(issuer.String(), clientID, noop, keysFunc)
|
|
|
|
if err := verifier.Verify(jwt); err != nil {
|
|
|
|
return TokenClaims{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return TokenClaims{claims}, nil
|
|
|
|
}
|