diff --git a/internal/common/common.go b/internal/common/common.go new file mode 100644 index 0000000..ad29957 --- /dev/null +++ b/internal/common/common.go @@ -0,0 +1,7 @@ +package common + +const ( + MIMEApplicationJSON string = "application/json" + MIMETextHTML string = "text/html" + MIMEApplicationXWWWFormUrlencoded string = "application/x-www-form-urlencoded" +) diff --git a/internal/model/error.go b/internal/model/error.go index 75eb59a..c397d7a 100644 --- a/internal/model/error.go +++ b/internal/model/error.go @@ -6,8 +6,6 @@ import ( "golang.org/x/xerrors" ) -// TODO(toby3d): make more informative errors. -// See https://indieauth.spec.indieweb.org/#authorization-request type Error struct { Code string `json:"error"` Description string `json:"error_description,omitempty"` @@ -15,48 +13,43 @@ type Error struct { Frame xerrors.Frame `json:"-"` } -var ( - ErrInvalidRequest Error = Error{ - Code: "invalid_request", - Description: "the request is missing a required parameter, includes an invalid parameter value, or is otherwise malformed", - } - ErrUnauthorizedClient Error = Error{ - Code: "unauthorized_client", - Description: "the client is not authorized to request an authorization code using this method", - } - ErrAccessDenied Error = Error{ - Code: "access_denied", - Description: "", - } - ErrUnsupportedResponseType Error = Error{ - Code: "unsupported_response_type", - Description: "the authorization server does not support obtaining an authorization code using this method", - } - ErrInvalidScope Error = Error{ - Code: "invalid_scope", - Description: "the requested scope is invalid, unknown, or malformed", - } - ErrServerError Error = Error{ - Code: "server_error", - Description: "the authorization server encountered an unexpected condition which prevented it from fulfilling the request", - } - ErrTemporarilyUnavailable Error = Error{ - Code: "temporarily_unavailable", - Description: "the authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server", - } +const ( + ErrAccessDenied string = "access_denied" + ErrInvalidClient string = "invalid_client" + ErrInvalidGrant string = "invalid_grant" + ErrInvalidRequest string = "invalid_request" + ErrInvalidScope string = "invalid_scope" + ErrInvalidToken string = "invalid_token" + ErrServerError string = "server_error" + ErrTemporarilyUnavailable string = "temporarily_unavailable" + ErrUnauthorizedClient string = "unauthorized_client" + ErrUnsupportedResponseType string = "unsupported_response_type" ) -func (e Error) FormatError(p xerrors.Printer) error { - p.Printf("%s: %s", e.Code, e.Description) - e.Frame.Format(p) +const errorColor string = "\033[31m" - return nil +func (e Error) Error() string { + return fmt.Sprint(e) } func (e Error) Format(s fmt.State, r rune) { xerrors.FormatError(e, s, r) } -func (e Error) Error() string { - return fmt.Sprint(e) +func (e Error) FormatError(p xerrors.Printer) error { + p.Print(errorColor, e.Code) + + if e.Description != "" { + p.Printf(": %s", e.Description) + } + + if e.URI != "" { + p.Printf("%4s", e.URI) + } + + if p.Detail() { + e.Frame.Format(p) + } + + return nil } diff --git a/internal/model/profile.go b/internal/model/profile.go index 33e376a..a04b0f6 100644 --- a/internal/model/profile.go +++ b/internal/model/profile.go @@ -1,8 +1,12 @@ package model type Profile struct { - Name string `json:"name"` - URL string `json:"url"` - Photo string `json:"photo"` - Email string `json:"email,omitempty"` + Name string + URL URL + Photo URL + Email string +} + +func NewProfile() *Profile { + return new(Profile) } diff --git a/internal/model/token.go b/internal/model/token.go index cb00b5f..1e101d4 100644 --- a/internal/model/token.go +++ b/internal/model/token.go @@ -1,12 +1,17 @@ package model type Token struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - Me string `json:"me"` - ClientID string `json:"client_id"` - Profile *Profile `json:"profile,omitempty"` + Profile *Profile + Scopes []string + AccessToken string + Type string + Me URL + ClientID URL } -func (Token) Bucket() []byte { return []byte("tokens") } +func NewToken() *Token { + t := new(Token) + t.Scopes = make([]string, 0) + + return t +} diff --git a/internal/model/url.go b/internal/model/url.go new file mode 100644 index 0000000..55a4b36 --- /dev/null +++ b/internal/model/url.go @@ -0,0 +1,3 @@ +package model + +type URL string diff --git a/internal/token/delivery/http/token_http.go b/internal/token/delivery/http/token_http.go index 19ffccb..99d314a 100644 --- a/internal/token/delivery/http/token_http.go +++ b/internal/token/delivery/http/token_http.go @@ -1,186 +1,151 @@ package http import ( + "bytes" "strings" "github.com/fasthttp/router" json "github.com/goccy/go-json" http "github.com/valyala/fasthttp" - "gitlab.com/toby3d/indieauth/internal/model" - "gitlab.com/toby3d/indieauth/internal/token" + "golang.org/x/xerrors" + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/model" + "source.toby3d.me/website/oauth/internal/token" ) type ( - Handler struct { + RequestHandler struct { useCase token.UseCase } - ExchangeRequest struct { - GrantType string - Code string - ClientID string - RedirectURI string - CodeVerifier string - } - - RevokeRequest struct { + RevocationRequest struct { Action string Token string } - ExchangeResponse struct { - AccessToken string `json:"access_token"` - Me string `json:"me"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` + //nolint: tagliatelle + VerificationResponse struct { + Me model.URL `json:"me"` + ClientID model.URL `json:"client_id"` + Scope string `json:"scope"` } - VerificationResponse struct { - Me string `json:"me"` - ClientID string `json:"client_id"` - Scope string `json:"scope"` - } + RevocationResponse struct{} ) -func NewTokenHandler(useCase token.UseCase) *Handler { - return &Handler{ +const ( + Action string = "action" + ActionRevoke string = "revoke" +) + +func NewRequestHandler(useCase token.UseCase) *RequestHandler { + return &RequestHandler{ useCase: useCase, } } -func (h *Handler) Register(r *router.Router) { - r.GET("/token", h.Verification) +func (h *RequestHandler) Register(r *router.Router) { + r.GET("/token", h.Read) r.POST("/token", h.Update) } -func (h *Handler) Verification(ctx *http.RequestCtx) { +func (h *RequestHandler) Read(ctx *http.RequestCtx) { + ctx.SetContentType(common.MIMEApplicationJSON) + ctx.SetStatusCode(http.StatusOK) + encoder := json.NewEncoder(ctx) + rawToken := ctx.Request.Header.Peek(http.HeaderAuthorization) - token, err := h.useCase.Verify(ctx, - strings.TrimPrefix(string(ctx.Request.Header.Peek(http.HeaderAuthorization)), "Bearer "), - ) + token, err := h.useCase.Verify(ctx, string(bytes.TrimSpace(bytes.TrimPrefix(rawToken, []byte("Bearer"))))) if err != nil { - ctx.Error(err.Error(), http.StatusBadRequest) + ctx.SetStatusCode(http.StatusBadRequest) - return - } - - ctx.SetContentType("application/json") - _ = encoder.Encode(&VerificationResponse{ - Me: token.Me, - ClientID: token.ClientID, - Scope: token.Scope, - }) -} - -func (h *Handler) Update(ctx *http.RequestCtx) { - if ctx.PostArgs().Has("action") { - h.Revoke(ctx) - - return - } - - h.Exchange(ctx) -} - -func (r *ExchangeRequest) bind(ctx *http.RequestCtx) error { - if r.GrantType = string(ctx.PostArgs().Peek("grant_type")); r.GrantType != "authorization_code" { - return model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'grant_type' must be 'authorization_code'", + if err = encoder.Encode(err); err != nil { + ctx.Error(err.Error(), http.StatusInternalServerError) } - } - - if r.Code = string(ctx.PostArgs().Peek("code")); r.Code == "" { - return model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'code' query is required", - } - } - - if r.ClientID = string(ctx.PostArgs().Peek("client_id")); r.ClientID == "" { - return model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'client_id' query is required", - } - } - - if r.RedirectURI = string(ctx.PostArgs().Peek("redirect_uri")); r.RedirectURI == "" { - return model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'redirect_uri' query is required", - } - } - - r.CodeVerifier = string(ctx.PostArgs().Peek("code_verifier")) - - return nil -} - -func (h *Handler) Exchange(ctx *http.RequestCtx) { - encoder := json.NewEncoder(ctx) - req := new(ExchangeRequest) - - if err := req.bind(ctx); err != nil { - ctx.Error(err.Error(), http.StatusBadRequest) - - return - } - - token, err := h.useCase.Exchange(ctx, &model.ExchangeRequest{ - ClientID: req.ClientID, - Code: req.Code, - CodeVerifier: req.CodeVerifier, - RedirectURI: req.RedirectURI, - }) - if err != nil { - ctx.Error(err.Error(), http.StatusInternalServerError) return } if token == nil { - ctx.Error(model.ErrUnauthorizedClient.Error(), http.StatusUnauthorized) + ctx.SetStatusCode(http.StatusUnauthorized) return } - ctx.SetContentType("application/json") - _ = encoder.Encode(&ExchangeResponse{ - AccessToken: token.AccessToken, - Me: token.Me, - Scope: token.Scope, - TokenType: token.TokenType, - }) + if err := encoder.Encode(&VerificationResponse{ + Me: token.Me, + ClientID: token.ClientID, + Scope: strings.Join(token.Scopes, " "), + }); err != nil { + ctx.SetStatusCode(http.StatusInternalServerError) + + if err = encoder.Encode(err); err != nil { + ctx.Error(err.Error(), http.StatusInternalServerError) + } + } } -func (r *RevokeRequest) bind(ctx *http.RequestCtx) error { - if r.Action = string(ctx.PostArgs().Peek("action")); r.Action != "revoke" { +func (h *RequestHandler) Update(ctx *http.RequestCtx) { + if strings.EqualFold(string(ctx.FormValue(Action)), ActionRevoke) { + h.Revocation(ctx) + } +} + +func (h *RequestHandler) Revocation(ctx *http.RequestCtx) { + ctx.SetContentType(common.MIMEApplicationJSON) + ctx.SetStatusCode(http.StatusOK) + + encoder := json.NewEncoder(ctx) + + req := new(RevocationRequest) + if err := req.bind(ctx); err != nil { + ctx.SetStatusCode(http.StatusBadRequest) + + if err = encoder.Encode(err); err != nil { + ctx.Error(err.Error(), http.StatusInternalServerError) + } + + return + } + + if err := h.useCase.Revoke(ctx, req.Token); err != nil { + ctx.SetStatusCode(http.StatusBadRequest) + + if err = encoder.Encode(err); err != nil { + ctx.Error(err.Error(), http.StatusInternalServerError) + } + + return + } + + if err := encoder.Encode(&RevocationResponse{}); err != nil { + ctx.SetStatusCode(http.StatusInternalServerError) + + if err = encoder.Encode(err); err != nil { + ctx.Error(err.Error(), http.StatusInternalServerError) + } + } +} + +func (r *RevocationRequest) bind(ctx *http.RequestCtx) error { + if r.Action = string(ctx.FormValue(Action)); !strings.EqualFold(r.Action, ActionRevoke) { return model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'action' must be 'revoke'", + Code: "invalid_request", + Description: "request MUST contain 'action' key with value 'revoke'", + URI: "https://indieauth.spec.indieweb.org/#token-revocation-request", + Frame: xerrors.Caller(1), } } - if r.Token = string(ctx.PostArgs().Peek("token")); r.Token == "" { + if r.Token = string(ctx.FormValue("token")); r.Token == "" { return model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'token' query is required", + Code: "invalid_request", + Description: "request MUST contain the 'token' key with the valid access token as its value", + URI: "https://indieauth.spec.indieweb.org/#token-revocation-request", + Frame: xerrors.Caller(1), } } return nil } - -func (h *Handler) Revoke(ctx *http.RequestCtx) { - req := new(RevokeRequest) - if err := req.bind(ctx); err != nil { - ctx.Error(err.Error(), http.StatusBadRequest) - - return - } - - _ = h.useCase.Revoke(ctx, string(ctx.PostArgs().Peek("token"))) - - ctx.SuccessString("application/json", "{}") -} diff --git a/internal/token/delivery/http/token_http_test.go b/internal/token/delivery/http/token_http_test.go new file mode 100644 index 0000000..e121655 --- /dev/null +++ b/internal/token/delivery/http/token_http_test.go @@ -0,0 +1,101 @@ +package http_test + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/brianvoe/gofakeit" + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + http "github.com/valyala/fasthttp" + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/model" + delivery "source.toby3d.me/website/oauth/internal/token/delivery/http" + repository "source.toby3d.me/website/oauth/internal/token/repository/memory" + "source.toby3d.me/website/oauth/internal/token/usecase" + "source.toby3d.me/website/oauth/internal/util" +) + +func TestVerification(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + store := new(sync.Map) + repo := repository.NewMemoryTokenRepository(store) + accessToken := model.Token{ + AccessToken: gofakeit.Password(true, true, true, true, false, 32), + Type: "Bearer", + ClientID: "https://app.example.com/", + Me: "https://user.example.net/", + Scopes: []string{"create", "update", "delete"}, + Profile: nil, + } + + require.NoError(repo.Create(context.TODO(), &accessToken)) + + req := http.AcquireRequest() + defer http.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodGet) + req.SetRequestURI("http://localhost/token") + req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) + req.Header.Set(http.HeaderAuthorization, "Bearer "+accessToken.AccessToken) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + require.NoError(util.Serve(delivery.NewRequestHandler(usecase.NewTokenUseCase(repo)).Read, req, resp)) + assert.Equal(http.StatusOK, resp.StatusCode()) + + token := new(delivery.VerificationResponse) + require.NoError(json.Unmarshal(resp.Body(), token)) + assert.Equal(&delivery.VerificationResponse{ + Me: accessToken.Me, + ClientID: accessToken.ClientID, + Scope: strings.Join(accessToken.Scopes, " "), + }, token) +} + +func TestRevocation(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + store := new(sync.Map) + repo := repository.NewMemoryTokenRepository(store) + accessToken := gofakeit.Password(true, true, true, true, false, 32) + + require.NoError(repo.Create(context.TODO(), &model.Token{ + AccessToken: accessToken, + Type: "Bearer", + ClientID: "https://app.example.com/", + Me: "https://user.example.net/", + Scopes: []string{"create", "update", "delete"}, + Profile: nil, + })) + + req := http.AcquireRequest() + defer http.ReleaseRequest(req) + + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI("http://localhost/token") + req.Header.SetContentType(common.MIMEApplicationXWWWFormUrlencoded) + req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) + req.PostArgs().Set("action", "revoke") + req.PostArgs().Set("token", accessToken) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + require.NoError(util.Serve(delivery.NewRequestHandler(usecase.NewTokenUseCase(repo)).Update, req, resp)) + assert.Equal(http.StatusOK, resp.StatusCode()) + assert.Equal(`{}`, strings.TrimSpace(string(resp.Body()))) + + token, err := repo.Get(context.TODO(), accessToken) + require.NoError(err) + assert.Nil(token) +} diff --git a/internal/token/repository.go b/internal/token/repository.go index d67cce6..b203362 100644 --- a/internal/token/repository.go +++ b/internal/token/repository.go @@ -3,11 +3,20 @@ package token import ( "context" - "gitlab.com/toby3d/indieauth/internal/model" + "golang.org/x/xerrors" + "source.toby3d.me/website/oauth/internal/model" ) type Repository interface { - Create(ctx context.Context, token *model.Token) error - Get(ctx context.Context, token string) (*model.Token, error) - Delete(ctx context.Context, token string) error + Get(ctx context.Context, accessToken string) (*model.Token, error) + Create(ctx context.Context, accessToken *model.Token) error + Update(ctx context.Context, accessToken *model.Token) error + Remove(ctx context.Context, accessToken string) error +} + +var ErrExist error = model.Error{ + Code: "invalid_request", + Description: "this token is already exists", + URI: "", + Frame: xerrors.Caller(1), } diff --git a/internal/token/repository/bolt/bolt_token.go b/internal/token/repository/bolt/bolt_token.go index 53d592e..32530e8 100644 --- a/internal/token/repository/bolt/bolt_token.go +++ b/internal/token/repository/bolt/bolt_token.go @@ -2,24 +2,40 @@ package bolt import ( "context" + "strings" json "github.com/goccy/go-json" - "gitlab.com/toby3d/indieauth/internal/model" - "gitlab.com/toby3d/indieauth/internal/token" + "github.com/pkg/errors" bolt "go.etcd.io/bbolt" + "golang.org/x/xerrors" + "source.toby3d.me/website/oauth/internal/model" + "source.toby3d.me/website/oauth/internal/token" ) -type boltTokenRepository struct { - db *bolt.DB -} +type ( + Token struct { + AccessToken string `json:"accessToken"` + ClientID string `json:"clientId"` + Me string `json:"me"` + Scope string `json:"scope"` + Type string `json:"type"` + } + + boltTokenRepository struct { + db *bolt.DB + } +) + +var ErrNotExist error = xerrors.New("key not exist") func NewBoltTokenRepository(db *bolt.DB) (token.Repository, error) { if err := db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists(model.Token{}.Bucket()) + //nolint: exhaustivestruct + _, err := tx.CreateBucketIfNotExists(Token{}.Bucket()) - return err + return errors.Wrap(err, "failed to create a bucket") }); err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to update the storage structure") } return &boltTokenRepository{ @@ -27,31 +43,101 @@ func NewBoltTokenRepository(db *bolt.DB) (token.Repository, error) { }, nil } -func (repo *boltTokenRepository) Create(ctx context.Context, token *model.Token) error { - jsonToken, err := json.Marshal(token) - if err != nil { - return err - } - - return repo.db.Update(func(tx *bolt.Tx) error { - return tx.Bucket(model.Token{}.Bucket()).Put([]byte(token.AccessToken), jsonToken) - }) -} - -func (repo *boltTokenRepository) Get(ctx context.Context, token string) (*model.Token, error) { - t := new(model.Token) +func (repo *boltTokenRepository) Get(ctx context.Context, accessToken string) (*model.Token, error) { + result := model.NewToken() if err := repo.db.View(func(tx *bolt.Tx) error { - return json.Unmarshal(tx.Bucket(model.Token{}.Bucket()).Get([]byte(token)), t) + //nolint: exhaustivestruct + if src := tx.Bucket(Token{}.Bucket()).Get([]byte(accessToken)); src != nil { + return NewToken().Bind(src, result) + } + + return ErrNotExist }); err != nil { - return nil, err + if !xerrors.Is(err, ErrNotExist) { + return nil, errors.Wrap(err, "failed to retrieve token from storage") + } + + return nil, nil } - return t, nil + return result, nil } -func (repo *boltTokenRepository) Delete(ctx context.Context, token string) error { - return repo.db.Update(func(tx *bolt.Tx) error { - return tx.Bucket(model.Token{}.Bucket()).Delete([]byte(token)) - }) +func (repo *boltTokenRepository) Create(ctx context.Context, accessToken *model.Token) error { + t, err := repo.Get(ctx, accessToken.AccessToken) + if err != nil { + return errors.Wrap(err, "failed to verify the existence of the token") + } + + if t != nil { + return token.ErrExist + } + + return repo.Update(ctx, accessToken) +} + +func (repo *boltTokenRepository) Update(ctx context.Context, accessToken *model.Token) error { + dto := NewToken() + dto.Populate(accessToken) + + src, err := json.Marshal(dto) + if err != nil { + return errors.Wrap(err, "failed to marshal token") + } + + if err = repo.db.Update(func(tx *bolt.Tx) error { + if err := tx.Bucket(dto.Bucket()).Put([]byte(dto.AccessToken), src); err != nil { + return errors.Wrap(err, "failed to overwrite the token in the bucket") + } + + return nil + }); err != nil { + return errors.Wrap(err, "failed to update the token in the repository") + } + + return nil +} + +func (repo *boltTokenRepository) Remove(ctx context.Context, accessToken string) error { + if err := repo.db.Update(func(tx *bolt.Tx) error { + //nolint: exhaustivestruct + if err := tx.Bucket(Token{}.Bucket()).Delete([]byte(accessToken)); err != nil { + return errors.Wrap(err, "failed to remove token in bucket") + } + + return nil + }); err != nil { + return errors.Wrap(err, "failed to remove token from storage") + } + + return nil +} + +func NewToken() *Token { + return new(Token) +} + +func (Token) Bucket() []byte { return []byte("tokens") } + +func (t *Token) Populate(src *model.Token) { + t.AccessToken = src.AccessToken + t.ClientID = string(src.ClientID) + t.Me = string(src.Me) + t.Scope = strings.Join(src.Scopes, " ") + t.Type = src.Type +} + +func (t *Token) Bind(src []byte, dst *model.Token) error { + if err := json.Unmarshal(src, t); err != nil { + return errors.Wrap(err, "cannot unmarshal token source") + } + + dst.AccessToken = t.AccessToken + dst.Scopes = strings.Fields(t.Scope) + dst.Type = t.Type + dst.ClientID = model.URL(t.ClientID) + dst.Me = model.URL(t.Me) + + return nil } diff --git a/internal/token/repository/bolt/bolt_token_test.go b/internal/token/repository/bolt/bolt_token_test.go new file mode 100644 index 0000000..7ae62ae --- /dev/null +++ b/internal/token/repository/bolt/bolt_token_test.go @@ -0,0 +1,205 @@ +//nolint: wrapcheck +package bolt_test + +import ( + "context" + "log" + "os" + "path/filepath" + "testing" + + json "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" + "source.toby3d.me/website/oauth/internal/model" + "source.toby3d.me/website/oauth/internal/random" + "source.toby3d.me/website/oauth/internal/token" + "source.toby3d.me/website/oauth/internal/token/repository/bolt" +) + +//nolint: gochecknoglobals +var ( + db *bbolt.DB + repo token.Repository +) + +func TestMain(m *testing.M) { + var err error + + dbPath := filepath.Join("..", "..", "..", "..", "test", "testing.db") + if db, err = bbolt.Open(dbPath, os.ModePerm, nil); err != nil { + log.Fatalln(err) + } + + if repo, err = bolt.NewBoltTokenRepository(db); err != nil { + _ = db.Close() + + log.Fatalln(err) + } + + code := m.Run() + _ = db.Close() + _ = os.RemoveAll(dbPath) + + os.Exit(code) +} + +func TestGet(t *testing.T) { + t.Parallel() + + accessToken := random.New().String(32) + + t.Cleanup(func() { + _ = db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Delete([]byte(accessToken)) + }) + }) + + src, err := json.Marshal(&bolt.Token{ + AccessToken: accessToken, + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Scope: "read update delete", + Type: "Bearer", + }) + require.NoError(t, err) + + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Put([]byte(accessToken), src) + })) + + tkn, err := repo.Get(context.TODO(), accessToken) + require.NoError(t, err) + assert.Equal(t, &model.Token{ + AccessToken: accessToken, + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Scopes: []string{"read", "update", "delete"}, + Type: "Bearer", + Profile: nil, + }, tkn) +} + +func TestCreate(t *testing.T) { + t.Parallel() + + accessToken := random.New().String(32) + + t.Cleanup(func() { + _ = db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Delete([]byte(accessToken)) + }) + }) + + tkn := &model.Token{ + AccessToken: accessToken, + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Scopes: []string{"read", "update", "delete"}, + Type: "Bearer", + Profile: nil, + } + + require.NoError(t, repo.Create(context.TODO(), tkn)) + + result := model.NewToken() + + require.NoError(t, db.View(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return bolt.NewToken().Bind(tx.Bucket(bolt.Token{}.Bucket()).Get([]byte(tkn.AccessToken)), result) + })) + assert.Equal(t, tkn, result) + + assert.EqualError(t, repo.Create(context.TODO(), tkn), token.ErrExist.Error()) +} + +func TestUpdate(t *testing.T) { + t.Parallel() + + accessToken := random.New().String(32) + + t.Cleanup(func() { + _ = db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Delete([]byte(accessToken)) + }) + }) + + src, err := json.Marshal(&bolt.Token{ + AccessToken: accessToken, + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Scope: "read update delete", + Type: "Bearer", + }) + require.NoError(t, err) + + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Put([]byte(accessToken), src) + })) + + require.NoError(t, repo.Update(context.TODO(), &model.Token{ + AccessToken: accessToken, + ClientID: "https://client.example.com/", + Me: "https://toby3d.ru/", + Scopes: []string{"read"}, + Type: "Bearer", + Profile: nil, + })) + + result := model.NewToken() + + require.NoError(t, db.View(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return bolt.NewToken().Bind(tx.Bucket(bolt.Token{}.Bucket()).Get([]byte(accessToken)), result) + })) + assert.Equal(t, &model.Token{ + AccessToken: accessToken, + ClientID: "https://client.example.com/", + Me: "https://toby3d.ru/", + Scopes: []string{"read"}, + Type: "Bearer", + Profile: nil, + }, result) +} + +func TestDelete(t *testing.T) { + t.Parallel() + + accessToken := random.New().String(32) + + t.Cleanup(func() { + _ = db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Delete([]byte(accessToken)) + }) + }) + + src, err := json.Marshal(&bolt.Token{ + AccessToken: accessToken, + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Scope: "read update delete", + Type: "Bearer", + }) + require.NoError(t, err) + + require.NoError(t, db.Update(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + return tx.Bucket(bolt.Token{}.Bucket()).Put([]byte(accessToken), src) + })) + + require.NoError(t, repo.Remove(context.TODO(), accessToken)) + + require.NoError(t, db.View(func(tx *bbolt.Tx) error { + //nolint: exhaustivestruct + assert.Nil(t, tx.Bucket(bolt.Token{}.Bucket()).Get([]byte(accessToken))) + + return nil + })) +} diff --git a/internal/token/repository/memory/memory_token.go b/internal/token/repository/memory/memory_token.go index 608c235..aa0d248 100644 --- a/internal/token/repository/memory/memory_token.go +++ b/internal/token/repository/memory/memory_token.go @@ -4,37 +4,55 @@ import ( "context" "sync" - "gitlab.com/toby3d/indieauth/internal/model" - "gitlab.com/toby3d/indieauth/internal/token" + "source.toby3d.me/website/oauth/internal/model" + "source.toby3d.me/website/oauth/internal/token" ) type memoryTokenRepository struct { tokens *sync.Map } -func NewMemoryTokenRepository() token.Repository { +func NewMemoryTokenRepository(tokens *sync.Map) token.Repository { return &memoryTokenRepository{ - tokens: new(sync.Map), + tokens: tokens, } } -func (repo *memoryTokenRepository) Create(ctx context.Context, token *model.Token) error { - repo.tokens.Store(token.AccessToken, token) - - return nil -} - -func (repo *memoryTokenRepository) Delete(ctx context.Context, token string) error { - repo.tokens.Delete(token) - - return nil -} - -func (repo *memoryTokenRepository) Get(ctx context.Context, token string) (*model.Token, error) { - t, ok := repo.tokens.Load(token) +func (repo *memoryTokenRepository) Get(ctx context.Context, accessToken string) (*model.Token, error) { + src, ok := repo.tokens.Load(accessToken) if !ok { return nil, nil } - return t.(*model.Token), nil + result, ok := src.(*model.Token) + if !ok { + return nil, nil + } + + return result, nil +} + +func (repo *memoryTokenRepository) Create(ctx context.Context, accessToken *model.Token) error { + t, err := repo.Get(ctx, accessToken.AccessToken) + if err != nil { + return err + } + + if t != nil { + return token.ErrExist + } + + return repo.Update(ctx, accessToken) +} + +func (repo *memoryTokenRepository) Update(ctx context.Context, accessToken *model.Token) error { + repo.tokens.Store(accessToken.AccessToken, accessToken) + + return nil +} + +func (repo *memoryTokenRepository) Remove(ctx context.Context, accessToken string) error { + repo.tokens.Delete(accessToken) + + return nil } diff --git a/internal/token/repository/memory/memory_token_test.go b/internal/token/repository/memory/memory_token_test.go new file mode 100644 index 0000000..4db6692 --- /dev/null +++ b/internal/token/repository/memory/memory_token_test.go @@ -0,0 +1,126 @@ +package memory_test + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "source.toby3d.me/website/oauth/internal/model" + "source.toby3d.me/website/oauth/internal/random" + "source.toby3d.me/website/oauth/internal/token" + "source.toby3d.me/website/oauth/internal/token/repository/memory" +) + +func TestGet(t *testing.T) { + t.Parallel() + + store := new(sync.Map) + accessToken := &model.Token{ + AccessToken: random.New().String(32), + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Profile: &model.Profile{ + Name: "Maxim Lebedev", + URL: "https://toby3d.me/", + Photo: "https://toby3d.me/photo.jpg", + Email: "hey@toby3d.me", + }, + Scopes: []string{"read", "update", "delete"}, + Type: "Bearer", + } + + store.Store(accessToken.AccessToken, accessToken) + + result, err := memory.NewMemoryTokenRepository(store).Get(context.TODO(), accessToken.AccessToken) + require.NoError(t, err) + assert.Equal(t, accessToken, result) +} + +func TestCreate(t *testing.T) { + t.Parallel() + + store := new(sync.Map) + accessToken := &model.Token{ + AccessToken: random.New().String(32), + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Profile: &model.Profile{ + Name: "Maxim Lebedev", + URL: "https://toby3d.me/", + Photo: "https://toby3d.me/photo.jpg", + Email: "hey@toby3d.me", + }, + Scopes: []string{"read", "update", "delete"}, + Type: "Bearer", + } + + repo := memory.NewMemoryTokenRepository(store) + require.NoError(t, repo.Create(context.TODO(), accessToken)) + + result, ok := store.Load(accessToken.AccessToken) + assert.True(t, ok) + assert.Equal(t, accessToken, result) + + assert.EqualError(t, repo.Create(context.TODO(), accessToken), token.ErrExist.Error()) +} + +func TestUpdate(t *testing.T) { + t.Parallel() + + store := new(sync.Map) + accessToken := &model.Token{ + AccessToken: random.New().String(32), + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Profile: &model.Profile{ + Name: "Maxim Lebedev", + URL: "https://toby3d.me/", + Photo: "https://toby3d.me/photo.jpg", + Email: "hey@toby3d.me", + }, + Scopes: []string{"read", "update", "delete"}, + Type: "Bearer", + } + + store.Store(accessToken.AccessToken, accessToken) + + tokenCopy := *accessToken + tokenCopy.ClientID = "https://client.example.com/" + tokenCopy.Me = "https://toby3d.ru/" + + require.NoError(t, memory.NewMemoryTokenRepository(store).Update(context.TODO(), &tokenCopy)) + + result, ok := store.Load(accessToken.AccessToken) + assert.True(t, ok) + assert.NotEqual(t, accessToken, result) + assert.Equal(t, &tokenCopy, result) +} + +func TestDelete(t *testing.T) { + t.Parallel() + + store := new(sync.Map) + accessToken := &model.Token{ + AccessToken: random.New().String(32), + ClientID: "https://app.example.com/", + Me: "https://toby3d.me/", + Profile: &model.Profile{ + Name: "Maxim Lebedev", + URL: "https://toby3d.me/", + Photo: "https://toby3d.me/photo.jpg", + Email: "hey@toby3d.me", + }, + Scopes: []string{"read", "update", "delete"}, + Type: "Bearer", + } + + store.Store(accessToken.AccessToken, accessToken) + + require.NoError(t, memory.NewMemoryTokenRepository(store).Remove(context.TODO(), accessToken.AccessToken)) + + result, ok := store.Load(accessToken.AccessToken) + assert.False(t, ok) + assert.Nil(t, result) +} diff --git a/internal/token/usecase.go b/internal/token/usecase.go index 02b0cac..c1bb153 100644 --- a/internal/token/usecase.go +++ b/internal/token/usecase.go @@ -3,11 +3,10 @@ package token import ( "context" - "gitlab.com/toby3d/indieauth/internal/model" + "source.toby3d.me/website/oauth/internal/model" ) type UseCase interface { - Exchange(ctx context.Context, req *model.ExchangeRequest) (*model.Token, error) - Verify(ctx context.Context, token string) (*model.Token, error) - Revoke(ctx context.Context, token string) error + Verify(ctx context.Context, accessToken string) (*model.Token, error) + Revoke(ctx context.Context, accessToken string) error } diff --git a/internal/token/usecase/token_usecase.go b/internal/token/usecase/token_usecase.go index de4a0aa..81fb76d 100644 --- a/internal/token/usecase/token_usecase.go +++ b/internal/token/usecase/token_usecase.go @@ -2,93 +2,35 @@ package usecase import ( "context" - "time" - "gitlab.com/toby3d/indieauth/internal/auth" - "gitlab.com/toby3d/indieauth/internal/model" - "gitlab.com/toby3d/indieauth/internal/pkce" - "gitlab.com/toby3d/indieauth/internal/random" - "gitlab.com/toby3d/indieauth/internal/token" + "github.com/pkg/errors" + "source.toby3d.me/website/oauth/internal/model" + "source.toby3d.me/website/oauth/internal/token" ) type tokenUseCase struct { - authRepo auth.Repository - tokenRepo token.Repository + tokens token.Repository } -func NewTokenUseCase(authRepo auth.Repository, tokenRepo token.Repository) token.UseCase { +func NewTokenUseCase(tokens token.Repository) token.UseCase { return &tokenUseCase{ - authRepo: authRepo, - tokenRepo: tokenRepo, + tokens: tokens, } } -func (useCase *tokenUseCase) Exchange(ctx context.Context, req *model.ExchangeRequest) (*model.Token, error) { - login, err := useCase.authRepo.Get(ctx, req.Code) +func (useCase *tokenUseCase) Verify(ctx context.Context, accessToken string) (*model.Token, error) { + t, err := useCase.tokens.Get(ctx, accessToken) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to retrieve token from storage") } - if login == nil { - return nil, nil - } - - _ = useCase.authRepo.Delete(ctx, login.Code) - - if time.Now().UTC().After(time.Unix(login.CreatedAt, 0).Add(10 * time.Minute)) { - return nil, nil - } - - if login.ClientID != req.ClientID { - return nil, model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'client_id' value must be identical to the value at the start of the authorization flow", - } - } - - if login.RedirectURI != req.RedirectURI { - return nil, model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "'client_id' value must be identical to the value at the start of the authorization flow", - } - } - - if login.CodeChallenge != "" { - code, err := pkce.New(login.CodeChallengeMethod) - if err != nil { - return nil, err - } - - code.Verifier = req.CodeVerifier - - code.Generate() - - if login.CodeChallenge != code.Challenge { - return nil, model.Error{ - Code: model.ErrInvalidRequest.Code, - Description: "PKCE validation failed, please go through the authorization flow again", - } - } - } - - token := new(model.Token) - token.AccessToken = random.New().String(64) - token.ClientID = login.ClientID - token.Me = login.Me - token.Scope = login.Scope - token.TokenType = "Bearer" - - if err := useCase.tokenRepo.Create(ctx, token); err != nil { - return nil, err - } - - return token, nil + return t, nil } -func (useCase *tokenUseCase) Verify(ctx context.Context, token string) (*model.Token, error) { - return useCase.tokenRepo.Get(ctx, token) -} +func (useCase *tokenUseCase) Revoke(ctx context.Context, accessToken string) error { + if err := useCase.tokens.Remove(ctx, accessToken); err != nil { + return errors.Wrap(err, "failed to delete a token in the vault") + } -func (useCase *tokenUseCase) Revoke(ctx context.Context, token string) error { - return useCase.tokenRepo.Delete(ctx, token) + return nil } diff --git a/internal/token/usecase/token_usecase_test.go b/internal/token/usecase/token_usecase_test.go new file mode 100644 index 0000000..961fa31 --- /dev/null +++ b/internal/token/usecase/token_usecase_test.go @@ -0,0 +1,68 @@ +package usecase_test + +import ( + "context" + "sync" + "testing" + + "github.com/brianvoe/gofakeit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "source.toby3d.me/website/oauth/internal/model" + repository "source.toby3d.me/website/oauth/internal/token/repository/memory" + "source.toby3d.me/website/oauth/internal/token/usecase" +) + +func TestVerify(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + store := new(sync.Map) + repo := repository.NewMemoryTokenRepository(store) + ucase := usecase.NewTokenUseCase(repo) + accessToken := &model.Token{ + AccessToken: gofakeit.Password(true, true, true, true, false, 32), + Type: "Bearer", + ClientID: "https://app.example.com/", + Me: "https://user.example.net/", + Scopes: []string{"create", "update", "delete"}, + Profile: nil, + } + + require.NoError(repo.Create(context.TODO(), accessToken)) + + token, err := ucase.Verify(context.TODO(), accessToken.AccessToken) + require.NoError(err) + assert.Equal(accessToken, token) +} + +func TestRevoke(t *testing.T) { + t.Parallel() + + require := require.New(t) + assert := assert.New(t) + store := new(sync.Map) + repo := repository.NewMemoryTokenRepository(store) + ucase := usecase.NewTokenUseCase(repo) + accessToken := gofakeit.Password(true, true, true, true, false, 32) + + require.NoError(repo.Create(context.TODO(), &model.Token{ + AccessToken: accessToken, + Type: "Bearer", + ClientID: "https://app.example.com/", + Me: "https://user.example.net/", + Scopes: []string{"create", "update", "delete"}, + Profile: nil, + })) + + token, err := repo.Get(context.TODO(), accessToken) + require.NoError(err) + assert.NotNil(token) + + require.NoError(ucase.Revoke(context.TODO(), token.AccessToken)) + + token, err = repo.Get(context.TODO(), token.AccessToken) + require.NoError(err) + assert.Nil(token) +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..6e4c467 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,27 @@ +package util + +import ( + "net" + + http "github.com/valyala/fasthttp" + httputil "github.com/valyala/fasthttp/fasthttputil" +) + +func Serve(handler http.RequestHandler, req *http.Request, res *http.Response) error { + ln := httputil.NewInmemoryListener() + defer ln.Close() + + go func() { + if err := http.Serve(ln, handler); err != nil { + panic(err) + } + }() + + client := http.Client{ + Dial: func(addr string) (net.Conn, error) { + return ln.Dial() + }, + } + + return client.Do(req, res) +}