✨ 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