✨ Created TicketAuth package
This commit is contained in:
parent
3a85ba2e0a
commit
042348a89f
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue