├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── client_store.go ├── client_store_options.go ├── go.mod ├── go.sum ├── token_store.go ├── token_store_options.go └── token_store_test.go /.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 | vendor/ 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.11" 5 | - "1.12" 6 | 7 | services: 8 | - mysql 9 | 10 | addons: 11 | apt: 12 | sources: 13 | - mysql-5.7-trusty 14 | packages: 15 | - mysql-server 16 | 17 | env: 18 | global: 19 | - MYSQL_URI="root:password@tcp(127.0.0.1:3306)/oauth2_test?parseTime=true" 20 | 21 | before_install: 22 | - sudo mysql -e "use mysql; update user set authentication_string=PASSWORD('password') where User='root'; update user set plugin='mysql_native_password';FLUSH PRIVILEGES;" 23 | - sudo mysql_upgrade -u root -ppassword 24 | - sudo service mysql restart 25 | - mysql -u root -ppassword -e 'CREATE DATABASE oauth2_test;' 26 | 27 | script: 28 | - echo $MYSQL_URI 29 | - MYSQL_URI=$MYSQL_URI go test -coverprofile=coverage.txt -covermode=atomic . 30 | 31 | after_success: 32 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Imre Nagi 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MySQL Storage for [OAuth 2.0](https://github.com/go-oauth2/oauth2) 2 | 3 | [![Build][Build-Status-Image]][Build-Status-Url] [![Codecov][codecov-image]][codecov-url] [![GoDoc][godoc-image]][godoc-url] [![License][license-image]][license-url] 4 | 5 | ## Install 6 | 7 | ```bash 8 | $ go get -u -v github.com/imrenagi/go-oauth2-mysql 9 | ``` 10 | 11 | ## MySQL drivers 12 | 13 | The store accepts an `sqlx.DB` which interacts with the DB. `sqlx.DB` is a specific implementations from [`github.com/jmoiron/sqlx`](https://github.com/jmoiron/sqlx) 14 | 15 | ## Usage example 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | _ "github.com/go-sql-driver/mysql" 22 | mysql "github.com/imrenagi/go-oauth2-mysql" 23 | "github.com/jmoiron/sqlx" 24 | ) 25 | 26 | func main() { 27 | db, err := sqlx.Connect("mysql", "user:password@tcp(127.0.0.1:3306)/oauth_db?parseTime=true") 28 | if err != nil { 29 | log.Fatalln(err) 30 | } 31 | 32 | clientStore, _ := mysql.NewClientStore(db, mysql.WithClientStoreTableName("custom_table_name")) 33 | tokenStore, _ := mysql.NewTokenStore(db) 34 | } 35 | ``` 36 | 37 | ## How to run tests 38 | 39 | You will need running MySQL instance. E.g. the one running in docker and exposing a port to a host system 40 | 41 | ```bash 42 | $ docker run -it -p 3306:3306 -e MYSQL_ROOT_PASSWORD=oauth2 -d mysql 43 | $ docker exec -it bash 44 | $ mysql -u root -poauth2 45 | > create database oauth_db 46 | ``` 47 | 48 | ```bash 49 | $ MYSQL_URI=root:oauth2@tcp(127.0.0.1:3306)/oauth_db?parseTime=true go test . 50 | ``` 51 | 52 | ## MIT License 53 | 54 | ``` 55 | Copyright (c) 2019 Imre Nagi 56 | ``` 57 | 58 | ## Credits 59 | 60 | - Oauth Postgres Implementation [`github.com/vgarvardt/go-pg-adapter`](https://github.com/vgarvardt/go-pg-adapter) 61 | 62 | 63 | [Build-Status-Url]: https://travis-ci.org/imrenagi/go-oauth2-mysql 64 | [Build-Status-Image]: https://travis-ci.org/imrenagi/go-oauth2-mysql.svg?branch=master 65 | [codecov-url]: https://codecov.io/gh/imrenagi/go-oauth2-mysql 66 | [codecov-image]: https://codecov.io/gh/imrenagi/go-oauth2-mysql/branch/master/graph/badge.svg 67 | [godoc-url]: https://godoc.org/github.com/imrenagi/go-oauth2-mysql 68 | [godoc-image]: https://godoc.org/github.com/imrenagi/go-oauth2-mysql?status.svg 69 | [license-url]: http://opensource.org/licenses/MIT 70 | [license-image]: https://img.shields.io/npm/l/express.svg 71 | -------------------------------------------------------------------------------- /client_store.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/jmoiron/sqlx" 9 | jsoniter "github.com/json-iterator/go" 10 | "gopkg.in/oauth2.v3" 11 | "gopkg.in/oauth2.v3/models" 12 | ) 13 | 14 | type ClientStore struct { 15 | db *sqlx.DB 16 | tableName string 17 | 18 | initTableDisabled bool 19 | maxLifetime time.Duration 20 | maxOpenConns int 21 | maxIdleConns int 22 | } 23 | 24 | // ClientStoreItem data item 25 | type ClientStoreItem struct { 26 | ID string `db:"id"` 27 | Secret string `db:"secret"` 28 | Domain string `db:"domain"` 29 | Data string `db:"data"` 30 | } 31 | 32 | // NewClientStore creates PostgreSQL store instance 33 | func NewClientStore(db *sqlx.DB, options ...ClientStoreOption) (*ClientStore, error) { 34 | 35 | store := &ClientStore{ 36 | db: db, 37 | tableName: "oauth2_clients", 38 | maxLifetime: time.Hour * 2, 39 | maxOpenConns: 50, 40 | maxIdleConns: 25, 41 | } 42 | 43 | for _, o := range options { 44 | o(store) 45 | } 46 | 47 | var err error 48 | if !store.initTableDisabled { 49 | err = store.initTable() 50 | } 51 | 52 | if err != nil { 53 | return store, err 54 | } 55 | 56 | store.db.SetMaxOpenConns(store.maxOpenConns) 57 | store.db.SetMaxIdleConns(store.maxIdleConns) 58 | store.db.SetConnMaxLifetime(store.maxLifetime) 59 | 60 | return store, err 61 | } 62 | 63 | func (s *ClientStore) initTable() error { 64 | 65 | query := fmt.Sprintf(` 66 | CREATE TABLE IF NOT EXISTS %s ( 67 | id VARCHAR(255) NOT NULL PRIMARY KEY, 68 | secret VARCHAR(255) NOT NULL, 69 | domain VARCHAR(255) NOT NULL, 70 | data TEXT NOT NULL 71 | ); 72 | `, s.tableName) 73 | 74 | stmt, err := s.db.Prepare(query) 75 | if err != nil { 76 | return err 77 | } 78 | _, err = stmt.Exec() 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func (s *ClientStore) toClientInfo(data string) (oauth2.ClientInfo, error) { 87 | var cm models.Client 88 | err := jsoniter.Unmarshal([]byte(data), &cm) 89 | return &cm, err 90 | } 91 | 92 | // GetByID retrieves and returns client information by id 93 | func (s *ClientStore) GetByID(id string) (oauth2.ClientInfo, error) { 94 | if id == "" { 95 | return nil, nil 96 | } 97 | 98 | var item ClientStoreItem 99 | err := s.db.QueryRowx(fmt.Sprintf("SELECT * FROM %s WHERE id = ?", s.tableName), id).StructScan(&item) 100 | switch { 101 | case err == sql.ErrNoRows: 102 | return nil, nil 103 | case err != nil: 104 | return nil, err 105 | } 106 | 107 | return s.toClientInfo(item.Data) 108 | } 109 | 110 | // Create creates and stores the new client information 111 | func (s *ClientStore) Create(info oauth2.ClientInfo) error { 112 | data, err := jsoniter.Marshal(info) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | _, err = s.db.Exec(fmt.Sprintf("INSERT INTO %s (id, secret, domain, data) VALUES (?,?,?,?)", s.tableName), 118 | info.GetID(), 119 | info.GetSecret(), 120 | info.GetDomain(), 121 | string(data), 122 | ) 123 | if err != nil { 124 | return err 125 | } 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /client_store_options.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | // ClientStoreOption is the configuration options type for client store 4 | type ClientStoreOption func(s *ClientStore) 5 | 6 | // WithClientStoreTableName returns option that sets client store table name 7 | func WithClientStoreTableName(tableName string) ClientStoreOption { 8 | return func(s *ClientStore) { 9 | s.tableName = tableName 10 | } 11 | } 12 | 13 | // WithClientStoreInitTableDisabled returns option that disables table creation on client store instantiation 14 | func WithClientStoreInitTableDisabled() ClientStoreOption { 15 | return func(s *ClientStore) { 16 | s.initTableDisabled = true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/imrenagi/go-oauth2-mysql 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.4.0 7 | github.com/jmoiron/sqlx v1.2.0 8 | github.com/json-iterator/go v1.1.6 9 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 10 | github.com/modern-go/reflect2 v1.0.1 // indirect 11 | github.com/stretchr/testify v1.3.0 12 | github.com/vgarvardt/go-pg-adapter v0.3.0 13 | gopkg.in/oauth2.v3 v3.10.0 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 2 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 7 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 8 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 9 | github.com/gavv/httpexpect v0.0.0-20180803094507-bdde30871313/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= 10 | github.com/gavv/monotime v0.0.0-20171021193802-6f8212e8d10d/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA= 11 | github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0= 12 | github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= 13 | github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 14 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 15 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 16 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 17 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 18 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 19 | github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= 20 | github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= 21 | github.com/jackc/pgx v3.5.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= 22 | github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= 23 | github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 24 | github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= 25 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 26 | github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 27 | github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= 28 | github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 29 | github.com/klauspost/cpuid v0.0.0-20180405133222-e7e905edc00e/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 30 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 31 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 32 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 33 | github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= 34 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 35 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 36 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 37 | github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= 38 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 39 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 41 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 42 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 43 | github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= 44 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 45 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 46 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 47 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 51 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 52 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 53 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 54 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= 55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 56 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 57 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 58 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 59 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 60 | github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= 61 | github.com/tidwall/buntdb v1.0.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE= 62 | github.com/tidwall/gjson v1.1.3/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA= 63 | github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= 64 | github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= 65 | github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= 66 | github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= 67 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 68 | github.com/valyala/fasthttp v1.0.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk8LWSxF3s= 69 | github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= 70 | github.com/vgarvardt/go-pg-adapter v0.3.0 h1:YQkTVNU7eQU1mM55H8N4/XSqZP6dOR3A6uCg6RDMecM= 71 | github.com/vgarvardt/go-pg-adapter v0.3.0/go.mod h1:+ogRTaGusDQb1lhZGoUxKQAGIbL+Lv43ePl/NUQArPI= 72 | github.com/vgarvardt/pgx-helpers v0.0.0-20190703163610-cbb413594454/go.mod h1:xp2aDvL8NKu92fXxNr9kbH03+OJ+dIVu/dYfPxt3LWs= 73 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 74 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 75 | github.com/xeipuuv/gojsonschema v0.0.0-20181112162635-ac52e6811b56/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= 76 | github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= 77 | github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= 78 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= 79 | github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= 80 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 81 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 82 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 83 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 84 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 85 | golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 86 | golang.org/x/net v0.0.0-20181217023233-e147a9138326/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 87 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 88 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 89 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 90 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 91 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 92 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 93 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 94 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 95 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 96 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 97 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 98 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 99 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 100 | google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= 101 | google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 102 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 103 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 106 | gopkg.in/oauth2.v3 v3.10.0 h1:yBiewwkAh1wHQNNBqWqLaEOsIxJKsTBeHRLxzTHIiwk= 107 | gopkg.in/oauth2.v3 v3.10.0/go.mod h1:nTG+m2PRcHR9jzGNrGdxSsUKz7vvwkqSlhFrstgZcRU= 108 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 109 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 110 | -------------------------------------------------------------------------------- /token_store.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/jmoiron/sqlx" 10 | jsoniter "github.com/json-iterator/go" 11 | "gopkg.in/oauth2.v3" 12 | "gopkg.in/oauth2.v3/models" 13 | ) 14 | 15 | type TokenStore struct { 16 | db *sqlx.DB 17 | tableName string 18 | gcDisabled bool 19 | gcInterval time.Duration 20 | ticker *time.Ticker 21 | initTableDisabled bool 22 | maxLifetime time.Duration 23 | maxOpenConns int 24 | maxIdleConns int 25 | } 26 | 27 | // TokenStoreItem data item 28 | type TokenStoreItem struct { 29 | ID int64 `db:"id"` 30 | CreatedAt time.Time `db:"created_at"` 31 | ExpiredAt time.Time `db:"expired_at"` 32 | Code string `db:"code"` 33 | Access string `db:"access"` 34 | Refresh string `db:"refresh"` 35 | Data string `db:"data"` 36 | } 37 | 38 | // NewTokenStore creates PostgreSQL store instance 39 | func NewTokenStore(db *sqlx.DB, options ...TokenStoreOption) (*TokenStore, error) { 40 | 41 | store := &TokenStore{ 42 | db: db, 43 | tableName: "oauth2_tokens", 44 | gcInterval: 10 * time.Minute, 45 | maxLifetime: time.Hour * 2, 46 | maxOpenConns: 50, 47 | maxIdleConns: 25, 48 | } 49 | 50 | for _, o := range options { 51 | o(store) 52 | } 53 | 54 | var err error 55 | if !store.initTableDisabled { 56 | err = store.initTable() 57 | } 58 | 59 | if err != nil { 60 | return store, err 61 | } 62 | 63 | if !store.gcDisabled { 64 | store.ticker = time.NewTicker(store.gcInterval) 65 | go store.gc() 66 | } 67 | 68 | store.db.SetMaxOpenConns(store.maxOpenConns) 69 | store.db.SetMaxIdleConns(store.maxIdleConns) 70 | store.db.SetConnMaxLifetime(store.maxLifetime) 71 | 72 | return store, err 73 | } 74 | 75 | // Close close the store 76 | func (s *TokenStore) Close() error { 77 | if !s.gcDisabled { 78 | s.ticker.Stop() 79 | } 80 | return nil 81 | } 82 | 83 | func (s *TokenStore) gc() { 84 | for range s.ticker.C { 85 | s.clean() 86 | } 87 | } 88 | 89 | func (s *TokenStore) initTable() error { 90 | 91 | query := fmt.Sprintf(` 92 | CREATE TABLE IF NOT EXISTS %s ( 93 | id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, 94 | code VARCHAR(255), 95 | access VARCHAR(255) NOT NULL, 96 | refresh VARCHAR(255) NOT NULL, 97 | data TEXT NOT NULL, 98 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 99 | expired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 100 | KEY access_k(access), 101 | KEY refresh_k (refresh), 102 | KEY expired_at_k (expired_at), 103 | KEY code_k (code) 104 | ); 105 | `, s.tableName) 106 | 107 | stmt, err := s.db.Prepare(query) 108 | if err != nil { 109 | return err 110 | } 111 | _, err = stmt.Exec() 112 | if err != nil { 113 | return err 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func (s *TokenStore) clean() { 120 | 121 | now := time.Now().Unix() 122 | query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE expired_at<=? OR (code='' AND access='' AND refresh='')", s.tableName) 123 | 124 | var count int64 125 | err := s.db.QueryRow(query, now).Scan(&count) 126 | if err != nil || count == 0 { 127 | if err != nil { 128 | log.Println(err.Error()) 129 | } 130 | return 131 | } 132 | 133 | _, err = s.db.Exec(fmt.Sprintf("DELETE FROM %s WHERE expired_at<=? OR (code='' AND access='' AND refresh='')", s.tableName), now) 134 | if err != nil { 135 | log.Println(err.Error()) 136 | } 137 | } 138 | 139 | // Create create and store the new token information 140 | func (s *TokenStore) Create(info oauth2.TokenInfo) error { 141 | buf, _ := jsoniter.Marshal(info) 142 | item := &TokenStoreItem{ 143 | Data: string(buf), 144 | CreatedAt: time.Now(), 145 | } 146 | 147 | if code := info.GetCode(); code != "" { 148 | item.Code = code 149 | item.ExpiredAt = info.GetCodeCreateAt().Add(info.GetCodeExpiresIn()) 150 | } else { 151 | item.Access = info.GetAccess() 152 | item.ExpiredAt = info.GetAccessCreateAt().Add(info.GetAccessExpiresIn()) 153 | 154 | if refresh := info.GetRefresh(); refresh != "" { 155 | item.Refresh = info.GetRefresh() 156 | item.ExpiredAt = info.GetRefreshCreateAt().Add(info.GetRefreshExpiresIn()) 157 | } 158 | } 159 | 160 | fmt.Print(item.CreatedAt) 161 | 162 | _, err := s.db.Exec( 163 | fmt.Sprintf("INSERT INTO %s (created_at, expired_at, code, access, refresh, data) VALUES (?,?,?,?,?,?)", s.tableName), 164 | item.CreatedAt, 165 | item.ExpiredAt, 166 | item.Code, 167 | item.Access, 168 | item.Refresh, 169 | item.Data) 170 | if err != nil { 171 | return err 172 | } 173 | return nil 174 | } 175 | 176 | // RemoveByCode delete the authorization code 177 | func (s *TokenStore) RemoveByCode(code string) error { 178 | query := fmt.Sprintf("DELETE FROM %s WHERE code=? LIMIT 1", s.tableName) 179 | _, err := s.db.Exec(query, code) 180 | if err != nil && err == sql.ErrNoRows { 181 | return nil 182 | } 183 | return err 184 | } 185 | 186 | // RemoveByAccess use the access token to delete the token information 187 | func (s *TokenStore) RemoveByAccess(access string) error { 188 | query := fmt.Sprintf("DELETE FROM %s WHERE access=? LIMIT 1", s.tableName) 189 | _, err := s.db.Exec(query, access) 190 | if err != nil && err == sql.ErrNoRows { 191 | return nil 192 | } 193 | return err 194 | } 195 | 196 | // RemoveByRefresh use the refresh token to delete the token information 197 | func (s *TokenStore) RemoveByRefresh(refresh string) error { 198 | query := fmt.Sprintf("DELETE FROM %s WHERE refresh=? LIMIT 1", s.tableName) 199 | _, err := s.db.Exec(query, refresh) 200 | if err != nil && err == sql.ErrNoRows { 201 | return nil 202 | } 203 | return err 204 | } 205 | 206 | func (s *TokenStore) toTokenInfo(data string) oauth2.TokenInfo { 207 | var tm models.Token 208 | jsoniter.Unmarshal([]byte(data), &tm) 209 | return &tm 210 | } 211 | 212 | // GetByCode use the authorization code for token information data 213 | func (s *TokenStore) GetByCode(code string) (oauth2.TokenInfo, error) { 214 | if code == "" { 215 | return nil, nil 216 | } 217 | 218 | query := fmt.Sprintf("SELECT * FROM %s WHERE code=? LIMIT 1", s.tableName) 219 | var item TokenStoreItem 220 | err := s.db.QueryRowx(query, code).StructScan(&item) 221 | switch { 222 | case err == sql.ErrNoRows: 223 | return nil, nil 224 | case err != nil: 225 | return nil, err 226 | } 227 | 228 | return s.toTokenInfo(item.Data), nil 229 | } 230 | 231 | // GetByAccess use the access token for token information data 232 | func (s *TokenStore) GetByAccess(access string) (oauth2.TokenInfo, error) { 233 | if access == "" { 234 | return nil, nil 235 | } 236 | 237 | query := fmt.Sprintf("SELECT * FROM %s WHERE access=? LIMIT 1", s.tableName) 238 | var item TokenStoreItem 239 | err := s.db.QueryRowx(query, access).StructScan(&item) 240 | switch { 241 | case err == sql.ErrNoRows: 242 | return nil, nil 243 | case err != nil: 244 | return nil, err 245 | } 246 | return s.toTokenInfo(item.Data), nil 247 | } 248 | 249 | // GetByRefresh use the refresh token for token information data 250 | func (s *TokenStore) GetByRefresh(refresh string) (oauth2.TokenInfo, error) { 251 | if refresh == "" { 252 | return nil, nil 253 | } 254 | 255 | query := fmt.Sprintf("SELECT * FROM %s WHERE refresh=? LIMIT 1", s.tableName) 256 | var item TokenStoreItem 257 | err := s.db.QueryRowx(query, refresh).StructScan(&item) 258 | switch { 259 | case err == sql.ErrNoRows: 260 | return nil, nil 261 | case err != nil: 262 | return nil, err 263 | } 264 | return s.toTokenInfo(item.Data), nil 265 | } 266 | -------------------------------------------------------------------------------- /token_store_options.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import "time" 4 | 5 | // TokenStoreOption is the configuration options type for token store 6 | type TokenStoreOption func(s *TokenStore) 7 | 8 | // WithTokenStoreTableName returns option that sets token store table name 9 | func WithTokenStoreTableName(tableName string) TokenStoreOption { 10 | return func(s *TokenStore) { 11 | s.tableName = tableName 12 | } 13 | } 14 | 15 | // WithTokenStoreGCInterval returns option that sets token store garbage collection interval 16 | func WithTokenStoreGCInterval(gcInterval time.Duration) TokenStoreOption { 17 | return func(s *TokenStore) { 18 | s.gcInterval = gcInterval 19 | } 20 | } 21 | 22 | // WithTokenStoreGCDisabled returns option that disables token store garbage collection 23 | func WithTokenStoreGCDisabled() TokenStoreOption { 24 | return func(s *TokenStore) { 25 | s.gcDisabled = true 26 | } 27 | } 28 | 29 | // WithTokenStoreInitTableDisabled returns option that disables table creation on token store instantiation 30 | func WithTokenStoreInitTableDisabled() TokenStoreOption { 31 | return func(s *TokenStore) { 32 | s.initTableDisabled = true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /token_store_test.go: -------------------------------------------------------------------------------- 1 | package mysql_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | _ "github.com/go-sql-driver/mysql" 10 | "github.com/jmoiron/sqlx" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | pgadapter "github.com/vgarvardt/go-pg-adapter" 14 | "gopkg.in/oauth2.v3/models" 15 | 16 | . "github.com/imrenagi/go-oauth2-mysql" 17 | ) 18 | 19 | func generateTokenTableName() string { 20 | return fmt.Sprintf("token_%d", time.Now().UnixNano()) 21 | } 22 | 23 | func generateClientTableName() string { 24 | return fmt.Sprintf("client_%d", time.Now().UnixNano()) 25 | } 26 | 27 | func TestMYSQLConn(t *testing.T) { 28 | 29 | db, err := sqlx.Connect("mysql", os.Getenv("MYSQL_URI")) 30 | require.NoError(t, err) 31 | 32 | defer func() { 33 | assert.NoError(t, db.Close()) 34 | }() 35 | 36 | tokenStore, err := NewTokenStore( 37 | db, 38 | WithTokenStoreTableName(generateTokenTableName()), 39 | WithTokenStoreGCInterval(time.Second), 40 | ) 41 | require.NoError(t, err) 42 | defer func() { 43 | assert.NoError(t, tokenStore.Close()) 44 | }() 45 | 46 | clientStore, err := NewClientStore( 47 | db, 48 | WithClientStoreTableName(generateClientTableName()), 49 | ) 50 | require.NoError(t, err) 51 | 52 | runTokenStoreTest(t, tokenStore) 53 | runClientStoreTest(t, clientStore) 54 | } 55 | 56 | func TestSQL(t *testing.T) { 57 | db, err := sqlx.Connect("mysql", os.Getenv("MYSQL_URI")) 58 | require.NoError(t, err) 59 | 60 | defer func() { 61 | assert.NoError(t, db.Close()) 62 | }() 63 | 64 | tokenStore, err := NewTokenStore( 65 | db, 66 | WithTokenStoreTableName(generateTokenTableName()), 67 | WithTokenStoreGCInterval(time.Second), 68 | ) 69 | require.NoError(t, err) 70 | defer func() { 71 | assert.NoError(t, tokenStore.Close()) 72 | }() 73 | 74 | clientStore, err := NewClientStore( 75 | db, 76 | WithClientStoreTableName(generateClientTableName()), 77 | ) 78 | require.NoError(t, err) 79 | 80 | runTokenStoreTest(t, tokenStore) 81 | runClientStoreTest(t, clientStore) 82 | } 83 | 84 | func runTokenStoreTest(t *testing.T, store *TokenStore) { 85 | runTokenStoreCodeTest(t, store) 86 | runTokenStoreAccessTest(t, store) 87 | runTokenStoreRefreshTest(t, store) 88 | 89 | // sleep for a while just to wait for GC run for sure to ensure there were no errors there 90 | time.Sleep(3 * time.Second) 91 | } 92 | 93 | func runTokenStoreCodeTest(t *testing.T, store *TokenStore) { 94 | code := fmt.Sprintf("code %s", time.Now().String()) 95 | 96 | tokenCode := models.NewToken() 97 | tokenCode.SetCode(code) 98 | tokenCode.SetCodeCreateAt(time.Now()) 99 | tokenCode.SetCodeExpiresIn(time.Minute) 100 | require.NoError(t, store.Create(tokenCode)) 101 | 102 | token, err := store.GetByCode(code) 103 | require.NoError(t, err) 104 | assert.Equal(t, code, token.GetCode()) 105 | 106 | require.NoError(t, store.RemoveByCode(code)) 107 | 108 | _, err = store.GetByCode(code) 109 | assert.Equal(t, pgadapter.ErrNoRows, err) 110 | } 111 | 112 | func runTokenStoreAccessTest(t *testing.T, store *TokenStore) { 113 | code := fmt.Sprintf("access %s", time.Now().String()) 114 | 115 | tokenCode := models.NewToken() 116 | tokenCode.SetAccess(code) 117 | tokenCode.SetAccessCreateAt(time.Now()) 118 | tokenCode.SetAccessExpiresIn(time.Minute) 119 | require.NoError(t, store.Create(tokenCode)) 120 | 121 | token, err := store.GetByAccess(code) 122 | require.NoError(t, err) 123 | assert.Equal(t, code, token.GetAccess()) 124 | 125 | require.NoError(t, store.RemoveByAccess(code)) 126 | 127 | _, err = store.GetByAccess(code) 128 | assert.Equal(t, pgadapter.ErrNoRows, err) 129 | } 130 | 131 | func runTokenStoreRefreshTest(t *testing.T, store *TokenStore) { 132 | code := fmt.Sprintf("refresh %s", time.Now().String()) 133 | 134 | tokenCode := models.NewToken() 135 | tokenCode.SetRefresh(code) 136 | tokenCode.SetRefreshCreateAt(time.Now()) 137 | tokenCode.SetRefreshExpiresIn(time.Minute) 138 | require.NoError(t, store.Create(tokenCode)) 139 | 140 | token, err := store.GetByRefresh(code) 141 | require.NoError(t, err) 142 | assert.Equal(t, code, token.GetRefresh()) 143 | 144 | require.NoError(t, store.RemoveByRefresh(code)) 145 | 146 | _, err = store.GetByRefresh(code) 147 | assert.Equal(t, pgadapter.ErrNoRows, err) 148 | } 149 | 150 | func runClientStoreTest(t *testing.T, store *ClientStore) { 151 | originalClient := &models.Client{ 152 | ID: fmt.Sprintf("id %s", time.Now().String()), 153 | Secret: fmt.Sprintf("secret %s", time.Now().String()), 154 | Domain: fmt.Sprintf("domain %s", time.Now().String()), 155 | UserID: fmt.Sprintf("user id %s", time.Now().String()), 156 | } 157 | 158 | require.NoError(t, store.Create(originalClient)) 159 | 160 | client, err := store.GetByID(originalClient.GetID()) 161 | require.NoError(t, err) 162 | assert.Equal(t, originalClient.GetID(), client.GetID()) 163 | assert.Equal(t, originalClient.GetSecret(), client.GetSecret()) 164 | assert.Equal(t, originalClient.GetDomain(), client.GetDomain()) 165 | assert.Equal(t, originalClient.GetUserID(), client.GetUserID()) 166 | } 167 | --------------------------------------------------------------------------------