From f0011e183da862b4a1ad42923de9da2a0f46ee5d Mon Sep 17 00:00:00 2001 From: Melora Hugues Date: Sat, 19 Oct 2024 16:21:04 +0200 Subject: [PATCH] Add refresh token flow (#42) --- polyculeconnect/cmd/serve/serve.go | 5 +- polyculeconnect/internal/db/base.go | 6 ++ polyculeconnect/internal/db/token/token.go | 68 +++++++++++++++++++ polyculeconnect/internal/model/token.go | 47 +++++++++++++ polyculeconnect/internal/storage/storage.go | 56 ++++++++++++--- polyculeconnect/migrations/1_tokens.down.sql | 1 + polyculeconnect/migrations/1_tokens.up.sql | 9 +++ polyculeconnect/polyculeconnect.db | Bin 61440 -> 69632 bytes 8 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 polyculeconnect/internal/db/token/token.go create mode 100644 polyculeconnect/migrations/1_tokens.down.sql create mode 100644 polyculeconnect/migrations/1_tokens.up.sql diff --git a/polyculeconnect/cmd/serve/serve.go b/polyculeconnect/cmd/serve/serve.go index 091effb..946c3cf 100644 --- a/polyculeconnect/cmd/serve/serve.go +++ b/polyculeconnect/cmd/serve/serve.go @@ -68,8 +68,9 @@ func serve() { st := storage.Storage{LocalStorage: userDB, InitializedBackends: backends, Key: &signingKey} opConf := op.Config{ - CryptoKey: key, - CodeMethodS256: false, + CryptoKey: key, + CodeMethodS256: false, + GrantTypeRefreshToken: true, } slogger := slog.New(zapslog.NewHandler(logger.L.Desugar().Core(), nil)) // slogger := diff --git a/polyculeconnect/internal/db/base.go b/polyculeconnect/internal/db/base.go index 54c79e2..0a61e13 100644 --- a/polyculeconnect/internal/db/base.go +++ b/polyculeconnect/internal/db/base.go @@ -9,6 +9,7 @@ import ( "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/authrequest" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/backend" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/client" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/token" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/user" ) @@ -19,6 +20,7 @@ type Storage interface { AuthRequestStorage() authrequest.AuthRequestDB AuthCodeStorage() authcode.AuthCodeDB UserStorage() user.UserDB + TokenStorage() token.TokenDB } type sqlStorage struct { @@ -49,6 +51,10 @@ func (s *sqlStorage) UserStorage() user.UserDB { return user.New(s.db) } +func (s *sqlStorage) TokenStorage() token.TokenDB { + return token.New(s.db) +} + func New(conf config.AppConfig) (Storage, error) { db, err := sql.Open("sqlite3", conf.StorageConfig.File) if err != nil { diff --git a/polyculeconnect/internal/db/token/token.go b/polyculeconnect/internal/db/token/token.go new file mode 100644 index 0000000..0ba9142 --- /dev/null +++ b/polyculeconnect/internal/db/token/token.go @@ -0,0 +1,68 @@ +package token + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" + "github.com/google/uuid" +) + +func strArrayToSlice(rawVal string) []string { + var res []string + if err := json.Unmarshal([]byte(rawVal), &res); err != nil { + return nil + } + return res +} + +func sliceToStrArray(rawVal []string) string { + res, err := json.Marshal(rawVal) + if err != nil { + return "[]" + } + return string(res) +} + +type TokenDB interface { + AddRefreshToken(ctx context.Context, refreshToken *model.RefreshToken) error + GetRefreshTokenByID(ctx context.Context, id uuid.UUID) (*model.RefreshToken, error) +} + +type sqlTokenDB struct { + db *sql.DB +} + +func (db *sqlTokenDB) AddRefreshToken(ctx context.Context, refreshToken *model.RefreshToken) error { + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `INSERT INTO "refresh_token" ("id", "client_id", "user_id", "scopes", "auth_time") VALUES ($1, $2, $3, $4, $5)`, refreshToken.ID, refreshToken.ClientID, refreshToken.UserID, sliceToStrArray(refreshToken.Scopes), refreshToken.AuthTime); err != nil { + return fmt.Errorf("failed to exec query: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func (db *sqlTokenDB) GetRefreshTokenByID(ctx context.Context, id uuid.UUID) (*model.RefreshToken, error) { + row := db.db.QueryRowContext(ctx, `SELECT "id", "client_id", "user_id", "scopes", "auth_time" FROM "refresh_token" WHERE "id" = ?`, id) + var res model.RefreshToken + var strScopes string + if err := row.Scan(&res.ID, &res.ClientID, &res.UserID, &strScopes, &res.AuthTime); err != nil { + return nil, fmt.Errorf("failed to query DB: %w", err) + } + res.Scopes = strArrayToSlice(strScopes) + return &res, nil +} + +func New(db *sql.DB) TokenDB { + return &sqlTokenDB{db: db} +} diff --git a/polyculeconnect/internal/model/token.go b/polyculeconnect/internal/model/token.go index afde439..9ba5688 100644 --- a/polyculeconnect/internal/model/token.go +++ b/polyculeconnect/internal/model/token.go @@ -17,5 +17,52 @@ type Token struct { type RefreshToken struct { ID uuid.UUID + ClientID string + UserID string + Scopes []string AuthTime time.Time } + +func (t RefreshToken) Request() *RefreshTokenRequest { + return &RefreshTokenRequest{ + userID: t.UserID, + clientID: t.ClientID, + scopes: t.Scopes, + authTime: t.AuthTime, + } +} + +type RefreshTokenRequest struct { + clientID string + authTime time.Time + userID string + scopes []string +} + +func (r RefreshTokenRequest) GetAMR() []string { + return []string{} +} + +func (r RefreshTokenRequest) GetAudience() []string { + return []string{} +} + +func (r RefreshTokenRequest) GetAuthTime() time.Time { + return r.authTime +} + +func (r RefreshTokenRequest) GetClientID() string { + return r.clientID +} + +func (r RefreshTokenRequest) GetScopes() []string { + return r.scopes +} + +func (r RefreshTokenRequest) GetSubject() string { + return r.userID +} + +func (r *RefreshTokenRequest) SetCurrentScopes(scopes []string) { + r.scopes = scopes +} diff --git a/polyculeconnect/internal/storage/storage.go b/polyculeconnect/internal/storage/storage.go index fd32649..dbe4777 100644 --- a/polyculeconnect/internal/storage/storage.go +++ b/polyculeconnect/internal/storage/storage.go @@ -2,6 +2,7 @@ package storage import ( "context" + "database/sql" "errors" "fmt" "time" @@ -135,14 +136,20 @@ func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error { func (s *Storage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (accessTokenID string, expiration time.Time, err error) { accessTokenUUID := uuid.New() + var authTime time.Time - // we are expecting our own request model - authRequest, ok := req.(*model.AuthRequest) - if !ok { + switch typedReq := req.(type) { + case *model.AuthRequest: + logger.L.Debug("Creating access token for new authentication") + authTime = typedReq.AuthTime + case *model.RefreshTokenRequest: + logger.L.Debug("Handling refresh token request") + authTime = typedReq.GetAuthTime() + default: + logger.L.Errorf("Unexpected type for request %v", err) return "", time.Time{}, errors.New("failed to parse auth request") } - authTime := authRequest.AuthTime.UTC() expiration = authTime.Add(5 * time.Minute) // token := model.Token{ @@ -160,14 +167,23 @@ func (s *Storage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (a func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshTokenID string, expiration time.Time, err error) { accessTokenUUID := uuid.New() refreshTokenUUID := uuid.New() + var authTime time.Time + var clientID string - // we are expecting our own request model - authRequest, ok := request.(*model.AuthRequest) - if !ok { + switch typedReq := request.(type) { + case *model.AuthRequest: + logger.L.Debug("Creating access token for new authentication") + clientID = typedReq.ClientID + authTime = typedReq.AuthTime + case *model.RefreshTokenRequest: + logger.L.Debug("Handling refresh token request") + clientID = typedReq.GetClientID() + authTime = typedReq.GetAuthTime() + default: + logger.L.Errorf("Unexpected type for request %v", err) return "", "", time.Time{}, errors.New("failed to parse auth request") } - authTime := authRequest.AuthTime.UTC() expiration = authTime.Add(5 * time.Minute) // token := model.Token{ @@ -178,12 +194,34 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T // Audiences: request.GetAudience(), // Scopes: request.GetScopes(), // } + refreshToken := model.RefreshToken{ + ID: refreshTokenUUID, + ClientID: clientID, + UserID: request.GetSubject(), + Scopes: request.GetScopes(), + AuthTime: authTime, + } + if err := s.LocalStorage.TokenStorage().AddRefreshToken(ctx, &refreshToken); err != nil { + return "", "", time.Time{}, fmt.Errorf("failed to insert token in DB: %w", err) + } return accessTokenUUID.String(), refreshTokenUUID.String(), expiration, nil } func (s *Storage) TokenRequestByRefreshToken(ctx context.Context, refreshTokenID string) (op.RefreshTokenRequest, error) { - return nil, ErrNotImplemented("TokenRequestByRefreshToken") + parsedID, err := uuid.Parse(refreshTokenID) + if err != nil { + return nil, fmt.Errorf("invalid format for refresh token id: %w", err) + } + + refreshToken, err := s.LocalStorage.TokenStorage().GetRefreshTokenByID(ctx, parsedID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, op.ErrInvalidRefreshToken + } + return nil, fmt.Errorf("failed to get refresh token: %w", err) + } + return refreshToken.Request(), nil } func (s *Storage) TerminateSession(ctx context.Context, userID string, clientID string) error { diff --git a/polyculeconnect/migrations/1_tokens.down.sql b/polyculeconnect/migrations/1_tokens.down.sql new file mode 100644 index 0000000..3e12ff9 --- /dev/null +++ b/polyculeconnect/migrations/1_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE refresh_token; diff --git a/polyculeconnect/migrations/1_tokens.up.sql b/polyculeconnect/migrations/1_tokens.up.sql new file mode 100644 index 0000000..818e48d --- /dev/null +++ b/polyculeconnect/migrations/1_tokens.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE refresh_token ( + id TEXT NOT NULL PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scopes blob NOT NULL, -- list of strings, json-encoded + auth_time timestamp NOT NULL, + FOREIGN KEY(client_id) REFERENCES client(id), + FOREIGN KEY(user_id) REFERENCES user(id) +); diff --git a/polyculeconnect/polyculeconnect.db b/polyculeconnect/polyculeconnect.db index 9430bab47ad99895cab480ec921bde873e1ec9b2..41e982e0f92d7df1e87623f685f5b22f19f938a9 100644 GIT binary patch delta 2492 zcma)7NpD+K7h3sDV{BugDk(wrOiVG%@VhlY4;WUI8VhRuhNL@fj5I!Y1qY^5H-u)uQ zMouy+!@{;C5HMQ`KyA!`VY!Z8D#?{=LTux^jA9y+DWacK&Y8R?qlWM)f~Dj{Q06g) zPcjM)U1mT6YzJyar2h;iI54n*g@Ftu%ztZS9-~l9Af?p3Yt#@vB^e`-OF}%x&`Czi zGLYk12)IzUmSWQaCcy%j%rI@+VuA_dZjE@FsVT}K)$PXOo;8wbh;&y4IRaQ(Q9upu0t1>9m@Xp{+SoB|^B+b?rWu{0dMBxb;P+zmAX-NuFhrDbug1Vh zM%_3@DMQ*SHq$k7OY6uOuni<#W=h0duf{K9SWY9|FolrdM9S`s&(8Lrc~ujRC=s4) zy|+GX1|~CX=hV%Sj+IEc?iJm4Vq z2z$WxAhL#zw)}rJf*x>?CI&p9%U!qYeE0s@@L0|l+l>AkeIfEm3QhN5G0hH36}h=Fo6Kxo z!L`h?s4W!p1(>Cvyw37_cJ%ZVEHL=gj$FykQCp|h7q5iRK zOcAqmT0om?hbn=95|#xh;ext~SkN&Ja+lIMncc1=8%rcTa|z`V*_9e!!OdbbOCWZr zv2`H8K`y^oUX0INS}UXyR(W$Nadx7Qa!53kQh*? zq&}7{fh8$YhM*W>xst5TmszpGQc8-&h34UGC7Ym!3(2i=`5JMS=eK9wa=Tg4nVGqE zeU&x$mrPclY02V&IbSZdDhu^ANVf(tT4y5AKbGU#hG{At#8BvCnbZMFDh05u5U~vE z>IvV1>txSN%xz!UT6e4MN~&?q-ZS8GYi;FlwUo|O6J-n+OLIHdi!f8)ovSbE(5ltT zHPb0>)UQ-`O8fC@u{DURI}^i4-a@8Blu3Zq?-nIerzqxt*t`vyjxb~~du?W8(T?wK z=GW@YMk={quBPTMHwcekPE>I9O2H}A=Q9WSBrh(Mm)BCQ6ke|twmGw2W&a)s86yHj*GmuYfgx0zY3&NVV?bMs(BRIj(<_np|W|Kwip z=O5tE*>LohFZNaJm)Of*p!I`{_9lP)@F>Uz8Pk633^F+V)EUIe{nTj>;;w$|(EIEC kuiV34KG|A<{}aIP0kIzQ-Z#Id$3MC~J~Y}Jx_u}1H%VBeIRF3v delta 338 zcmXwxze~eF9K|ocYDt@13W61tt`45&l1p;!;-nO`qhpav(i`eOAS$&kP6dsf+$s)2 zmkx)sj!x1cLpNRhE9wRD3y%ly`+09T(TCsK{YI??0Dw~ex0&tu)fU+8&#lq}u#I+h zwk2xLyDr+sk)mCI*YUA2weWgr?VzFj*?8Zoi{8p(T`7xYV+jk}8=NYt7~I`BHPw>i zx;X;&vvpYcF-LOm$`qsNxlz?&7r+Ia!CTmsaR-jOZ!cxl&H?P(xlBH6k$<|%stRX7 zVhcG{m8wXYqCS7Jv&rT4wsxYBIHHV&K1wL2h_J{*EQwP@y}(Ojj>D7@qX1KvM@bO) z2vh7zFi4T?C5Z7Lpgk_%v9A|kQNa