From 042348a89f176a963b07bac740b409b80fb81c99 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Thu, 30 Dec 2021 01:20:14 +0500 Subject: [PATCH] :sparkles: Created TicketAuth package --- internal/ticket/delivery/http/ticket_http.go | 134 ++++++++++++++++++ .../ticket/delivery/http/ticket_http_test.go | 72 ++++++++++ internal/ticket/usecase.go | 12 ++ internal/ticket/usecase/ticket_ucase.go | 65 +++++++++ internal/ticket/usecase/ticket_ucase_test.go | 46 ++++++ 5 files changed, 329 insertions(+) create mode 100644 internal/ticket/delivery/http/ticket_http.go create mode 100644 internal/ticket/delivery/http/ticket_http_test.go create mode 100644 internal/ticket/usecase.go create mode 100644 internal/ticket/usecase/ticket_ucase.go create mode 100644 internal/ticket/usecase/ticket_ucase_test.go diff --git a/internal/ticket/delivery/http/ticket_http.go b/internal/ticket/delivery/http/ticket_http.go new file mode 100644 index 0000000..20d5691 --- /dev/null +++ b/internal/ticket/delivery/http/ticket_http.go @@ -0,0 +1,134 @@ +package http + +import ( + "encoding/json" + "fmt" + + "github.com/fasthttp/router" + http "github.com/valyala/fasthttp" + "golang.org/x/xerrors" + + "source.toby3d.me/toby3d/form" + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/ticket" + "source.toby3d.me/website/oauth/internal/user" +) + +type ( + Request struct { + // A random string that can be redeemed for an access token. + Ticket string `form:"ticket"` + + // The access token will work at this URL. + Resource *domain.URL `form:"resource"` + + // The access token should be used when acting on behalf of this URL. + Subject *domain.Me `form:"subject"` + } + + RequestHandler struct { + users user.UseCase + tickets ticket.UseCase + } +) + +func NewRequestHandler(tickets ticket.UseCase, users user.UseCase) *RequestHandler { + return &RequestHandler{ + tickets: tickets, + users: users, + } +} + +func (h *RequestHandler) Register(r *router.Router) { + r.POST("/ticket", h.update) +} + +func (h *RequestHandler) update(ctx *http.RequestCtx) { + ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8) + ctx.SetStatusCode(http.StatusOK) + + encoder := json.NewEncoder(ctx) + + req := new(Request) + if err := req.bind(ctx); err != nil { + ctx.SetStatusCode(http.StatusBadRequest) + encoder.Encode(err) + + return + } + + // TODO(toby3d): fetch token endpoint on Resource URL instead + u, err := h.users.Fetch(ctx, req.Subject) + if err != nil { + ctx.SetStatusCode(http.StatusBadRequest) + encoder.Encode(domain.Error{ + Code: "invalid_request", + Description: err.Error(), + Frame: xerrors.Caller(1), + }) + + return + } + + token, err := h.tickets.Redeem(ctx, u.TokenEndpoint, req.Ticket) + if err != nil { + ctx.SetStatusCode(http.StatusBadRequest) + encoder.Encode(domain.Error{ + Code: "invalid_request", + Description: err.Error(), + Frame: xerrors.Caller(1), + }) + + return + } + + // TODO(toby3d): print the result as part of the debugging. Instead, we + // need to send or save the token to the recipient for later use. + ctx.SetBodyString(fmt.Sprintf(`{ + "access_token": "%s", + "token_type": "Bearer", + "scope": "%s", + "me": "%s" + }`, token.AccessToken, token.Scope.String(), token.Me.String())) +} + +func (req *Request) bind(ctx *http.RequestCtx) (err error) { + if err = form.Unmarshal(ctx.Request.PostArgs(), req); err != nil { + return domain.Error{ + Code: "invalid_request", + Description: err.Error(), + URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket", + Frame: xerrors.Caller(1), + } + } + + if req.Ticket == "" { + return domain.Error{ + Code: "invalid_request", + Description: "ticket parameter is required", + URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket", + Frame: xerrors.Caller(1), + } + } + + if req.Resource == nil { + return domain.Error{ + Code: "invalid_request", + Description: "invalid resource value", + URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket", + Frame: xerrors.Caller(1), + } + } + + if req.Subject == nil { + return domain.Error{ + Code: "invalid_request", + Description: "invalid subject value", + URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket", + Frame: xerrors.Caller(1), + } + } + + return nil +} diff --git a/internal/ticket/delivery/http/ticket_http_test.go b/internal/ticket/delivery/http/ticket_http_test.go new file mode 100644 index 0000000..642d99a --- /dev/null +++ b/internal/ticket/delivery/http/ticket_http_test.go @@ -0,0 +1,72 @@ +package http_test + +import ( + "fmt" + "path" + "sync" + "testing" + + "github.com/fasthttp/router" + "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/domain" + "source.toby3d.me/website/oauth/internal/testing/httptest" + delivery "source.toby3d.me/website/oauth/internal/ticket/delivery/http" + ucase "source.toby3d.me/website/oauth/internal/ticket/usecase" + userrepo "source.toby3d.me/website/oauth/internal/user/repository/memory" + userucase "source.toby3d.me/website/oauth/internal/user/usecase" +) + +// TODO(toby3d): looks ugly, refactor this? +func TestUpdate(t *testing.T) { + t.Parallel() + + ticket := domain.TestTicket(t) + + // NOTE(toby3d): user token endpoint + token := domain.TestToken(t) + + store := new(sync.Map) + store.Store(path.Join(userrepo.DefaultPathPrefix, ticket.Subject.String()), domain.TestUser(t)) + + userClient, _, userCleanup := httptest.New(t, func(ctx *http.RequestCtx) { + ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, fmt.Sprintf(`{ + "access_token": "%s", + "token_type": "Bearer", + "scope": "%s", + "me": "%s" + }`, token.AccessToken, token.Scope.String(), token.Me.String())) + }) + t.Cleanup(userCleanup) + + // NOTE(toby3d): current token endpoint + r := router.New() + client, _, cleanup := httptest.New(t, r.Handler) + t.Cleanup(cleanup) + + delivery.NewRequestHandler( + ucase.NewTicketUseCase(userClient), userucase.NewUserUseCase(userrepo.NewMemoryUserRepository(store)), + ).Register(r) + + req := httptest.NewRequest(http.MethodPost, "https://example.com/ticket", []byte( + `ticket=`+ticket.Ticket+ + `&resource=`+ticket.Resource.String()+ + `&subject=`+ticket.Subject.String(), + )) + defer http.ReleaseRequest(req) + req.Header.SetContentType(common.MIMEApplicationForm) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + require.NoError(t, client.Do(req, resp)) + assert.Condition(t, func() bool { + return resp.StatusCode() == http.StatusOK || resp.StatusCode() == http.StatusAccepted + }, "the ticket endpoint MUST return an HTTP 200 OK code or HTTP 202 Accepted") + // TODO(toby3d): print the result as part of the debugging. Instead, you + // need to send or save the token to the recipient for later use. + assert.NotNil(t, resp.Body()) +} diff --git a/internal/ticket/usecase.go b/internal/ticket/usecase.go new file mode 100644 index 0000000..3a2e34e --- /dev/null +++ b/internal/ticket/usecase.go @@ -0,0 +1,12 @@ +package ticket + +import ( + "context" + + "source.toby3d.me/website/oauth/internal/domain" +) + +type UseCase interface { + // Redeem transform received ticket into access token. + Redeem(ctx context.Context, endpoint *domain.URL, ticket string) (*domain.Token, error) +} diff --git a/internal/ticket/usecase/ticket_ucase.go b/internal/ticket/usecase/ticket_ucase.go new file mode 100644 index 0000000..1546b5a --- /dev/null +++ b/internal/ticket/usecase/ticket_ucase.go @@ -0,0 +1,65 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + + http "github.com/valyala/fasthttp" + + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/ticket" +) + +type ( + //nolint: tagliatelle + Response struct { + Me *domain.Me `json:"me"` + Scope domain.Scopes `json:"scope"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + + ticketUseCase struct { + client *http.Client + } +) + +func NewTicketUseCase(client *http.Client) ticket.UseCase { + return &ticketUseCase{ + client: client, + } +} + +func (useCase *ticketUseCase) Redeem(ctx context.Context, endpoint *domain.URL, ticket string) (*domain.Token, error) { + req := http.AcquireRequest() + defer http.ReleaseRequest(req) + req.Header.SetMethod(http.MethodPost) + req.SetRequestURIBytes(endpoint.FullURI()) + req.Header.SetContentType(common.MIMEApplicationForm) + req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) + req.PostArgs().Set("grant_type", domain.GrantTypeTicket.String()) + req.PostArgs().Set("ticket", ticket) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + if err := useCase.client.Do(req, resp); err != nil { + return nil, err + } + + data := new(Response) + if err := json.Unmarshal(resp.Body(), data); err != nil { + return nil, fmt.Errorf("cannot unmarshal access token response: %w", err) + } + + // TODO(toby3d): should this also include client_id? + // https://github.com/indieweb/indieauth/issues/85 + return &domain.Token{ + ClientID: nil, + AccessToken: data.AccessToken, + Me: data.Me, + Scope: data.Scope, + }, nil +} diff --git a/internal/ticket/usecase/ticket_ucase_test.go b/internal/ticket/usecase/ticket_ucase_test.go new file mode 100644 index 0000000..5c567ab --- /dev/null +++ b/internal/ticket/usecase/ticket_ucase_test.go @@ -0,0 +1,46 @@ +package usecase_test + +import ( + "context" + "fmt" + "path" + "sync" + "testing" + + "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/domain" + "source.toby3d.me/website/oauth/internal/testing/httptest" + ucase "source.toby3d.me/website/oauth/internal/ticket/usecase" + userrepo "source.toby3d.me/website/oauth/internal/user/repository/memory" +) + +func TestRedeem(t *testing.T) { + t.Parallel() + + token := domain.TestToken(t) + ticket := domain.TestTicket(t) + + store := new(sync.Map) + store.Store(path.Join(userrepo.DefaultPathPrefix, ticket.Subject.String()), domain.TestUser(t)) + + client, _, cleanup := httptest.New(t, func(ctx *http.RequestCtx) { + ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, fmt.Sprintf(`{ + "access_token": "%s", + "token_type": "Bearer", + "scope": "%s", + "me": "%s" + }`, token.AccessToken, token.Scope.String(), token.Me.String())) + }) + t.Cleanup(cleanup) + + result, err := ucase.NewTicketUseCase(client). + Redeem(context.Background(), domain.TestURL(t, "https://bob.example.com/token"), ticket.Ticket) + require.NoError(t, err) + assert.Equal(t, token.AccessToken, result.AccessToken) + assert.Equal(t, token.Me.String(), result.Me.String()) + assert.Equal(t, token.Scope, result.Scope) +}