♻️ Refactored TicketAuth support
This commit is contained in:
parent
70e6b7612a
commit
ba1be08915
|
@ -1,54 +1,109 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
"github.com/goccy/go-json"
|
||||
http "github.com/valyala/fasthttp"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"source.toby3d.me/toby3d/form"
|
||||
"source.toby3d.me/toby3d/middleware"
|
||||
"source.toby3d.me/website/indieauth/internal/common"
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/random"
|
||||
"source.toby3d.me/website/indieauth/internal/ticket"
|
||||
"source.toby3d.me/website/indieauth/web"
|
||||
)
|
||||
|
||||
type (
|
||||
Request struct {
|
||||
// A random string that can be redeemed for an access token.
|
||||
Ticket string `form:"ticket"`
|
||||
GenerateRequest struct {
|
||||
// The access token should be used when acting on behalf of this URL.
|
||||
Subject *domain.URL `form:"subject"`
|
||||
|
||||
// The access token will work at this URL.
|
||||
Resource *domain.URL `form:"resource"`
|
||||
}
|
||||
|
||||
ExchangeRequest struct {
|
||||
// A random string that can be redeemed for an access token.
|
||||
Ticket string `form:"ticket"`
|
||||
|
||||
// The access token should be used when acting on behalf of this URL.
|
||||
// WARN(toby3d): deadcode for now
|
||||
Subject *domain.Me `form:"subject"`
|
||||
Subject *domain.URL `form:"subject"`
|
||||
|
||||
// The access token will work at this URL.
|
||||
Resource *domain.URL `form:"resource"`
|
||||
}
|
||||
|
||||
RequestHandler struct {
|
||||
useCase ticket.UseCase
|
||||
config *domain.Config
|
||||
matcher language.Matcher
|
||||
tickets ticket.UseCase
|
||||
}
|
||||
)
|
||||
|
||||
func NewRequestHandler(useCase ticket.UseCase) *RequestHandler {
|
||||
func NewRequestHandler(tickets ticket.UseCase, matcher language.Matcher, config *domain.Config) *RequestHandler {
|
||||
return &RequestHandler{
|
||||
useCase: useCase,
|
||||
config: config,
|
||||
matcher: matcher,
|
||||
tickets: tickets,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RequestHandler) Register(r *router.Router) {
|
||||
r.POST("/ticket", h.update)
|
||||
chain := middleware.Chain{
|
||||
middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
CookieSameSite: http.CookieSameSiteLaxMode,
|
||||
ContextKey: "csrf",
|
||||
CookieName: "_csrf",
|
||||
TokenLookup: "form:_csrf",
|
||||
CookieSecure: true,
|
||||
CookieHTTPOnly: true,
|
||||
Skipper: func(ctx *http.RequestCtx) bool {
|
||||
matched, _ := path.Match("/ticket*", string(ctx.Path()))
|
||||
|
||||
return ctx.IsPost() && matched
|
||||
},
|
||||
}),
|
||||
middleware.LogFmt(),
|
||||
}
|
||||
// TODO(toby3d): secure this via JWT middleware
|
||||
r.GET("/ticket", chain.RequestHandler(h.handleRender))
|
||||
r.POST("/api/ticket", chain.RequestHandler(h.handleSend))
|
||||
r.POST("/ticket", chain.RequestHandler(h.handleExchange))
|
||||
}
|
||||
|
||||
func (h *RequestHandler) update(ctx *http.RequestCtx) {
|
||||
func (h *RequestHandler) handleRender(ctx *http.RequestCtx) {
|
||||
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
|
||||
|
||||
tags, _, _ := language.ParseAcceptLanguage(string(ctx.Request.Header.Peek(http.HeaderAcceptLanguage)))
|
||||
tag, _, _ := h.matcher.Match(tags...)
|
||||
|
||||
csrf, _ := ctx.UserValue("csrf").([]byte)
|
||||
|
||||
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
|
||||
web.WriteTemplate(ctx, &web.TicketPage{
|
||||
BaseOf: web.BaseOf{
|
||||
Config: h.config,
|
||||
Language: tag,
|
||||
Printer: message.NewPrinter(tag),
|
||||
},
|
||||
CSRF: csrf,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RequestHandler) handleSend(ctx *http.RequestCtx) {
|
||||
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
encoder := json.NewEncoder(ctx)
|
||||
|
||||
req := new(Request)
|
||||
req := new(ExchangeRequest)
|
||||
if err := req.bind(ctx); err != nil {
|
||||
ctx.SetStatusCode(http.StatusBadRequest)
|
||||
encoder.Encode(err)
|
||||
|
@ -56,7 +111,53 @@ func (h *RequestHandler) update(ctx *http.RequestCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
token, err := h.useCase.Redeem(ctx, &domain.Ticket{
|
||||
ticket := &domain.Ticket{
|
||||
Ticket: "",
|
||||
Resource: req.Resource,
|
||||
Subject: req.Subject,
|
||||
}
|
||||
|
||||
var err error
|
||||
if ticket.Ticket, err = random.String(h.config.TicketAuth.Length); err != nil {
|
||||
ctx.SetStatusCode(http.StatusInternalServerError)
|
||||
encoder.Encode(&domain.Error{
|
||||
Code: "unauthorized_client",
|
||||
Description: err.Error(),
|
||||
Frame: xerrors.Caller(1),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = h.tickets.Generate(ctx, ticket); err != nil {
|
||||
ctx.SetStatusCode(http.StatusInternalServerError)
|
||||
encoder.Encode(&domain.Error{
|
||||
Code: "unauthorized_client",
|
||||
Description: err.Error(),
|
||||
Frame: xerrors.Caller(1),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
|
||||
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
encoder := json.NewEncoder(ctx)
|
||||
|
||||
req := new(ExchangeRequest)
|
||||
if err := req.bind(ctx); err != nil {
|
||||
ctx.SetStatusCode(http.StatusBadRequest)
|
||||
encoder.Encode(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.tickets.Exchange(ctx, &domain.Ticket{
|
||||
Ticket: req.Ticket,
|
||||
Resource: req.Resource,
|
||||
Subject: req.Subject,
|
||||
|
@ -82,7 +183,38 @@ func (h *RequestHandler) update(ctx *http.RequestCtx) {
|
|||
}`, token.AccessToken, token.Scope.String(), token.Me.String()))
|
||||
}
|
||||
|
||||
func (req *Request) bind(ctx *http.RequestCtx) (err error) {
|
||||
func (req *GenerateRequest) 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.Resource == nil {
|
||||
return domain.Error{
|
||||
Code: "invalid_request",
|
||||
Description: "resource value MUST be set",
|
||||
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: "subject value MUST be set",
|
||||
URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
|
||||
Frame: xerrors.Caller(1),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (req *ExchangeRequest) bind(ctx *http.RequestCtx) (err error) {
|
||||
if err = form.Unmarshal(ctx.Request.PostArgs(), req); err != nil {
|
||||
return domain.Error{
|
||||
Code: "invalid_request",
|
||||
|
@ -104,7 +236,7 @@ func (req *Request) bind(ctx *http.RequestCtx) (err error) {
|
|||
if req.Resource == nil {
|
||||
return domain.Error{
|
||||
Code: "invalid_request",
|
||||
Description: "invalid resource value",
|
||||
Description: "resource value MUST be set",
|
||||
URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
|
||||
Frame: xerrors.Caller(1),
|
||||
}
|
||||
|
@ -113,7 +245,7 @@ func (req *Request) bind(ctx *http.RequestCtx) (err error) {
|
|||
if req.Subject == nil {
|
||||
return domain.Error{
|
||||
Code: "invalid_request",
|
||||
Description: "invalid subject value",
|
||||
Description: "subject value MUST be set",
|
||||
URI: "https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
|
||||
Frame: xerrors.Caller(1),
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package http_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
|
@ -10,6 +8,8 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
http "github.com/valyala/fasthttp"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/common"
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
|
@ -19,39 +19,42 @@ import (
|
|||
ucase "source.toby3d.me/website/indieauth/internal/ticket/usecase"
|
||||
)
|
||||
|
||||
// TODO(toby3d): looks ugly, refactor this?
|
||||
func TestUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config := domain.TestConfig(t)
|
||||
ticket := domain.TestTicket(t)
|
||||
|
||||
// NOTE(toby3d): user token endpoint
|
||||
token := domain.TestToken(t)
|
||||
|
||||
store := new(sync.Map)
|
||||
store.Store(
|
||||
path.Join(ticketrepo.DefaultPathPrefix, ticket.Resource.String()),
|
||||
domain.TestURL(t, "https://example.com/token"),
|
||||
)
|
||||
|
||||
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()))
|
||||
userRouter := router.New()
|
||||
// NOTE(toby3d): private resource
|
||||
userRouter.GET(ticket.Resource.URL().EscapedPath(), func(ctx *http.RequestCtx) {
|
||||
ctx.SuccessString(common.MIMETextHTMLCharsetUTF8,
|
||||
`<link rel="token_endpoint" href="https://auth.example.org/token">`,
|
||||
)
|
||||
})
|
||||
// NOTE(toby3d): token endpoint
|
||||
userRouter.POST("/token", func(ctx *http.RequestCtx) {
|
||||
ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, `{
|
||||
"access_token": "`+token.AccessToken+`",
|
||||
"me": "`+token.Me.String()+`",
|
||||
"scope": "`+token.Scope.String()+`",
|
||||
"token_type": "Bearer"
|
||||
}`)
|
||||
})
|
||||
|
||||
userClient, _, userCleanup := httptest.New(t, userRouter.Handler)
|
||||
t.Cleanup(userCleanup)
|
||||
|
||||
// NOTE(toby3d): current token endpoint
|
||||
r := router.New()
|
||||
delivery.NewRequestHandler(
|
||||
ucase.NewTicketUseCase(ticketrepo.NewMemoryTicketRepository(new(sync.Map), config), userClient),
|
||||
language.NewMatcher(message.DefaultCatalog.Languages()), config,
|
||||
).Register(r)
|
||||
|
||||
client, _, cleanup := httptest.New(t, r.Handler)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
delivery.NewRequestHandler(ucase.NewTicketUseCase(ticketrepo.NewMemoryTicketRepository(store), userClient)).
|
||||
Register(r)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.com/ticket", []byte(
|
||||
`ticket=`+ticket.Ticket+
|
||||
`&resource=`+ticket.Resource.String()+
|
||||
|
|
|
@ -8,8 +8,9 @@ import (
|
|||
)
|
||||
|
||||
type Repository interface {
|
||||
// Get returns token endpoint founded by resource URL.
|
||||
Get(ctx context.Context, resource *domain.URL) (*domain.URL, error)
|
||||
Create(ctx context.Context, ticket *domain.Ticket) error
|
||||
GetAndDelete(ctx context.Context, ticket string) (*domain.Ticket, error)
|
||||
GC()
|
||||
}
|
||||
|
||||
var ErrNotExist = errors.New("token_endpoint not found on resource URL")
|
||||
|
|
|
@ -1,174 +0,0 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/tomnomnom/linkheader"
|
||||
http "github.com/valyala/fasthttp"
|
||||
"willnorris.com/go/microformats"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/ticket"
|
||||
)
|
||||
|
||||
type (
|
||||
//nolint: tagliatelle
|
||||
Response struct {
|
||||
// The server's issuer identifier. The issuer identifier is a
|
||||
// URL that uses the "https" scheme and has no query or fragment
|
||||
// components. The identifier MUST be a prefix of the
|
||||
// indieauth-metadata URL. e.g. for an indieauth-metadata
|
||||
// endpoint
|
||||
// https://example.com/.well-known/oauth-authorization-server,
|
||||
// the issuer URL could be https://example.com/, or for a
|
||||
// metadata URL of
|
||||
// https://example.com/wp-json/indieauth/1.0/metadata, the
|
||||
// issuer URL could be https://example.com/wp-json/indieauth/1.0
|
||||
Issuer *domain.URL `json:"issuer"`
|
||||
|
||||
// The Authorization Endpoint.
|
||||
AuthorizationEndpoint *domain.URL `json:"authorization_endpoint"`
|
||||
|
||||
// The Token Endpoint.
|
||||
TokenEndpoint *domain.URL `json:"token_endpoint"`
|
||||
|
||||
// JSON array containing scope values supported by the
|
||||
// IndieAuth server. Servers MAY choose not to advertise some
|
||||
// supported scope values even when this parameter is used.
|
||||
ScopesSupported domain.Scopes `json:"scopes_supported,omitempty"`
|
||||
|
||||
// JSON array containing the response_type values supported.
|
||||
// This differs from RFC8414 in that this parameter is OPTIONAL
|
||||
// and that, if omitted, the default is code.
|
||||
ResponseTypesSupported []domain.ResponseType `json:"response_types_supported,omitempty"`
|
||||
|
||||
// JSON array containing grant type values supported. If
|
||||
// omitted, the default value differs from RFC8414 and is
|
||||
// authorization_code.
|
||||
GrantTypesSupported []domain.GrantType `json:"grant_types_supported,omitempty"`
|
||||
|
||||
// URL of a page containing human-readable information that
|
||||
// developers might need to know when using the server. This
|
||||
// might be a link to the IndieAuth spec or something more
|
||||
// personal to your implementation.
|
||||
ServiceDocumentation *domain.URL `json:"service_documentation,omitempty"`
|
||||
|
||||
// JSON array containing the methods supported for PKCE. This
|
||||
// parameter differs from RFC8414 in that it is not optional as
|
||||
// PKCE is REQUIRED.
|
||||
CodeChallengeMethodsSupported []domain.CodeChallengeMethod `json:"code_challenge_methods_supported"`
|
||||
|
||||
// Boolean parameter indicating whether the authorization server
|
||||
// provides the iss parameter. If omitted, the default value is
|
||||
// false. As the iss parameter is REQUIRED, this is provided for
|
||||
// compatibility with OAuth 2.0 servers implementing the
|
||||
// parameter.
|
||||
//
|
||||
//nolint: lll
|
||||
AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"`
|
||||
}
|
||||
|
||||
httpTicketRepository struct {
|
||||
client *http.Client
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
relIndieAuthMetadata string = "indieauth-metadata"
|
||||
relTokenEndpoint string = "token_endpoint"
|
||||
)
|
||||
|
||||
func NewHTTPTicketRepository(client *http.Client) ticket.Repository {
|
||||
return &httpTicketRepository{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *httpTicketRepository) Get(_ context.Context, resource *domain.URL) (*domain.URL, error) {
|
||||
req := http.AcquireRequest()
|
||||
defer http.ReleaseRequest(req)
|
||||
req.Header.SetMethod(http.MethodGet)
|
||||
req.SetRequestURIBytes(resource.FullURI())
|
||||
|
||||
resp := http.AcquireResponse()
|
||||
defer http.ReleaseResponse(resp)
|
||||
|
||||
if err := repo.client.Do(req, resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch resource URL: %w", err)
|
||||
}
|
||||
|
||||
if metadataEndpoint := extractEndpoint(resp, relIndieAuthMetadata); metadataEndpoint != nil {
|
||||
if result, err := extractFromMetadata(repo.client, metadataEndpoint); err == nil && result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result := extractEndpoint(resp, relTokenEndpoint); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return nil, ticket.ErrNotExist
|
||||
}
|
||||
|
||||
func extractEndpoint(resp *http.Response, name string) *domain.URL {
|
||||
u, err := extractEndpointFromHeader(resp, name)
|
||||
if err == nil && u != nil {
|
||||
return u
|
||||
}
|
||||
|
||||
if u, err = extractEndpointFromBody(resp, name); err == nil && u != nil {
|
||||
return u
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractEndpointFromHeader(resp *http.Response, name string) (*domain.URL, error) {
|
||||
for _, link := range linkheader.Parse(string(resp.Header.Peek(http.HeaderLink))) {
|
||||
if !strings.EqualFold(link.Rel, name) {
|
||||
continue
|
||||
}
|
||||
|
||||
u := http.AcquireURI()
|
||||
if err := u.Parse(resp.Header.Peek(http.HeaderHost), []byte(link.URL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.URL{URI: u}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func extractEndpointFromBody(resp *http.Response, name string) (*domain.URL, error) {
|
||||
host, err := url.Parse(string(resp.Header.Peek(http.HeaderHost)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse host header: %w", err)
|
||||
}
|
||||
|
||||
endpoints, ok := microformats.Parse(bytes.NewReader(resp.Body()), host).Rels[name]
|
||||
if !ok || len(endpoints) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return domain.NewURL(endpoints[len(endpoints)-1])
|
||||
}
|
||||
|
||||
func extractFromMetadata(client *http.Client, endpoint *domain.URL) (*domain.URL, error) {
|
||||
_, body, err := client.Get(nil, endpoint.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := new(Response)
|
||||
if err = json.Unmarshal(body, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp.TokenEndpoint, nil
|
||||
}
|
|
@ -1,104 +0,0 @@
|
|||
package http_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
http "github.com/valyala/fasthttp"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/common"
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/testing/httptest"
|
||||
repository "source.toby3d.me/website/indieauth/internal/ticket/repository/http"
|
||||
)
|
||||
|
||||
type TestCase struct {
|
||||
name string
|
||||
bodyLinks map[string]string
|
||||
metadata string
|
||||
}
|
||||
|
||||
const testBody string = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Secret</title>
|
||||
%s
|
||||
</head>
|
||||
<body>
|
||||
<h1>Nothing to see here.</h1>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resource := domain.TestURL(t, "https://alice.example.com/private")
|
||||
endpoint := domain.TestURL(t, "https://example.org/token")
|
||||
|
||||
for _, testCase := range []TestCase{{
|
||||
name: "link",
|
||||
bodyLinks: map[string]string{
|
||||
"token_endpoint": endpoint.String(),
|
||||
},
|
||||
metadata: `{"token_endpoint": ""}`,
|
||||
}, {
|
||||
name: "metadata",
|
||||
bodyLinks: map[string]string{
|
||||
"indieauth-metadata": "https://example.com/.well-known/oauth-authorization-server",
|
||||
},
|
||||
metadata: `{"token_endpoint": "` + endpoint.String() + `"}`,
|
||||
}, {
|
||||
name: "fallback",
|
||||
bodyLinks: map[string]string{
|
||||
"token_endpoint": endpoint.String(),
|
||||
"indieauth-metadata": "https://example.com/.well-known/oauth-authorization-server",
|
||||
},
|
||||
metadata: `{}`,
|
||||
}, {
|
||||
name: "priority",
|
||||
bodyLinks: map[string]string{
|
||||
"token_endpoint": "dont-touch-me",
|
||||
"indieauth-metadata": "https://example.com/.well-known/oauth-authorization-server",
|
||||
},
|
||||
metadata: `{"token_endpoint": "` + endpoint.String() + `"}`,
|
||||
}} {
|
||||
testCase := testCase
|
||||
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := router.New()
|
||||
r.GET("/.well-known/oauth-authorization-server", func(ctx *http.RequestCtx) {
|
||||
ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, testCase.metadata)
|
||||
})
|
||||
r.GET("/private", func(ctx *http.RequestCtx) {
|
||||
bodyLinks := make([]string, 0)
|
||||
for k, v := range testCase.bodyLinks {
|
||||
bodyLinks = append(bodyLinks, `<link rel="`+k+`" href="`+v+`">`)
|
||||
}
|
||||
|
||||
ctx.SuccessString(
|
||||
common.MIMETextHTMLCharsetUTF8,
|
||||
fmt.Sprintf(testBody, strings.Join(bodyLinks, "\n")),
|
||||
)
|
||||
})
|
||||
|
||||
client, _, cleanup := httptest.New(t, r.Handler)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
result, err := repository.NewHTTPTicketRepository(client).
|
||||
Get(context.Background(), resource)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, endpoint.String(), result.String())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -4,33 +4,86 @@ import (
|
|||
"context"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/ticket"
|
||||
)
|
||||
|
||||
type memoryTicketRepository struct {
|
||||
store *sync.Map
|
||||
}
|
||||
type (
|
||||
Ticket struct {
|
||||
CreatedAt time.Time
|
||||
*domain.Ticket
|
||||
}
|
||||
|
||||
memoryTicketRepository struct {
|
||||
config *domain.Config
|
||||
store *sync.Map
|
||||
}
|
||||
)
|
||||
|
||||
const DefaultPathPrefix string = "tickets"
|
||||
|
||||
func NewMemoryTicketRepository(store *sync.Map) ticket.Repository {
|
||||
func NewMemoryTicketRepository(store *sync.Map, config *domain.Config) ticket.Repository {
|
||||
return &memoryTicketRepository{
|
||||
store: store,
|
||||
config: config,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *memoryTicketRepository) Get(_ context.Context, resource *domain.URL) (*domain.URL, error) {
|
||||
src, ok := repo.store.Load(path.Join(DefaultPathPrefix, resource.String()))
|
||||
func (repo *memoryTicketRepository) Create(_ context.Context, t *domain.Ticket) error {
|
||||
repo.store.Store(path.Join(DefaultPathPrefix, t.Ticket), &Ticket{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Ticket: t,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *memoryTicketRepository) GetAndDelete(_ context.Context, t string) (*domain.Ticket, error) {
|
||||
src, ok := repo.store.LoadAndDelete(path.Join(DefaultPathPrefix, t))
|
||||
if !ok {
|
||||
return nil, ticket.ErrNotExist
|
||||
}
|
||||
|
||||
result, ok := src.(*domain.URL)
|
||||
result, ok := src.(*Ticket)
|
||||
if !ok {
|
||||
return nil, ticket.ErrNotExist
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result.Ticket, nil
|
||||
}
|
||||
|
||||
func (repo *memoryTicketRepository) GC() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for ts := range ticker.C {
|
||||
ts := ts.UTC()
|
||||
|
||||
repo.store.Range(func(key, value interface{}) bool {
|
||||
k, ok := key.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
matched, err := path.Match(DefaultPathPrefix+"/*", k)
|
||||
if err != nil || !matched {
|
||||
return false
|
||||
}
|
||||
|
||||
val, ok := value.(*Ticket)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if val.CreatedAt.Add(repo.config.Code.Expiry).After(ts) {
|
||||
return false
|
||||
}
|
||||
|
||||
repo.store.Delete(key)
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"path"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
@ -13,16 +14,40 @@ import (
|
|||
repository "source.toby3d.me/website/indieauth/internal/ticket/repository/memory"
|
||||
)
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
func TestCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := new(sync.Map)
|
||||
ticket := domain.TestTicket(t)
|
||||
|
||||
require.NoError(t, repository.NewMemoryTicketRepository(store, domain.TestConfig(t)).
|
||||
Create(context.Background(), ticket))
|
||||
|
||||
src, ok := store.Load(path.Join(repository.DefaultPathPrefix, ticket.Ticket))
|
||||
require.True(t, ok)
|
||||
|
||||
result, ok := src.(*repository.Ticket)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, ticket, result.Ticket)
|
||||
}
|
||||
|
||||
func TestGetAndDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ticket := domain.TestTicket(t)
|
||||
user := domain.TestUser(t)
|
||||
|
||||
store := new(sync.Map)
|
||||
store.Store(path.Join(repository.DefaultPathPrefix, ticket.Resource.String()), user.TokenEndpoint)
|
||||
store.Store(path.Join(repository.DefaultPathPrefix, ticket.Ticket), &repository.Ticket{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Ticket: ticket,
|
||||
})
|
||||
|
||||
result, err := repository.NewMemoryTicketRepository(store).Get(context.Background(), ticket.Resource)
|
||||
result, err := repository.NewMemoryTicketRepository(store, domain.TestConfig(t)).
|
||||
GetAndDelete(context.Background(), ticket.Ticket)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.TokenEndpoint.String(), result.String())
|
||||
assert.Equal(t, ticket, result)
|
||||
|
||||
src, ok := store.Load(path.Join(repository.DefaultPathPrefix, ticket.Ticket))
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, src)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
package sqlite3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/ticket"
|
||||
"source.toby3d.me/website/indieauth/internal/token"
|
||||
)
|
||||
|
||||
type (
|
||||
Ticket struct {
|
||||
CreatedAt sql.NullTime `db:"created_at"`
|
||||
Resource string `db:"resource"`
|
||||
Subject string `db:"subject"`
|
||||
Ticket string `db:"ticket"`
|
||||
}
|
||||
|
||||
sqlite3TicketRepository struct {
|
||||
config *domain.Config
|
||||
db *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
QueryTable string = `CREATE TABLE IF NOT EXISTS tickets (
|
||||
created_at DATETIME NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
ticket TEXT UNIQUE PRIMARY KEY NOT NULL
|
||||
);`
|
||||
|
||||
QueryGet string = `SELECT *
|
||||
FROM tickets
|
||||
WHERE ticket=$1;`
|
||||
|
||||
QueryCreate string = `INSERT INTO tickets (created_at, resource, subject, ticket)
|
||||
VALUES (:created_at, :resource, :subject, :ticket);`
|
||||
|
||||
QueryDelete string = `DELETE FROM tickets
|
||||
WHERE ticket=$1;`
|
||||
)
|
||||
|
||||
func NewSQLite3TicketRepository(db *sqlx.DB, config *domain.Config) ticket.Repository {
|
||||
return &sqlite3TicketRepository{
|
||||
config: config,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *sqlite3TicketRepository) Create(ctx context.Context, t *domain.Ticket) error {
|
||||
if _, err := repo.db.NamedExecContext(ctx, QueryTable+QueryCreate, NewTicket(t)); err != nil {
|
||||
return fmt.Errorf("cannot create token record in db: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *sqlite3TicketRepository) GetAndDelete(ctx context.Context, ticket string) (*domain.Ticket, error) {
|
||||
t := new(Ticket)
|
||||
|
||||
tx, err := repo.db.Beginx()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
if err = tx.GetContext(ctx, t, QueryTable+QueryGet, ticket); err != nil {
|
||||
defer tx.Rollback()
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, token.ErrNotExist
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot find ticket in db: %w", err)
|
||||
}
|
||||
|
||||
if _, err = tx.ExecContext(ctx, QueryDelete, ticket); err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return nil, fmt.Errorf("cannot remove ticket from db: %w", err)
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
result := new(domain.Ticket)
|
||||
t.Populate(result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (repo *sqlite3TicketRepository) GC() {}
|
||||
|
||||
func NewTicket(src *domain.Ticket) *Ticket {
|
||||
return &Ticket{
|
||||
CreatedAt: sql.NullTime{
|
||||
Time: time.Now().UTC(),
|
||||
Valid: true,
|
||||
},
|
||||
Resource: src.Resource.String(),
|
||||
Subject: src.Subject.String(),
|
||||
Ticket: src.Ticket,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Ticket) Populate(dst *domain.Ticket) {
|
||||
dst.Ticket = t.Ticket
|
||||
dst.Subject, _ = domain.NewURL(t.Subject)
|
||||
dst.Resource, _ = domain.NewURL(t.Resource)
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package sqlite3_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/testing/sqltest"
|
||||
repository "source.toby3d.me/website/indieauth/internal/ticket/repository/sqlite3"
|
||||
)
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := sqltest.Open(t)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
ticket := domain.TestTicket(t)
|
||||
require.NoError(t, repository.NewSQLite3TicketRepository(db, domain.TestConfig(t)).
|
||||
Create(context.Background(), ticket))
|
||||
|
||||
results := make([]*repository.Ticket, 0)
|
||||
require.NoError(t, db.Select(&results, "SELECT * FROM tickets;"))
|
||||
require.Len(t, results, 1)
|
||||
|
||||
result := new(domain.Ticket)
|
||||
results[0].Populate(result)
|
||||
|
||||
assert.Equal(t, ticket.Ticket, result.Ticket)
|
||||
}
|
||||
|
||||
func TestGetAndDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := sqltest.Open(t)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
ticket := domain.TestTicket(t)
|
||||
_, err := db.NamedExec(repository.QueryTable+repository.QueryCreate, repository.NewTicket(ticket))
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := repository.NewSQLite3TicketRepository(db, domain.TestConfig(t)).
|
||||
GetAndDelete(context.Background(), ticket.Ticket)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ticket.Ticket, result.Ticket)
|
||||
|
||||
results := make([]*repository.Ticket, 0)
|
||||
require.NoError(t, db.Select(&results, "SELECT * FROM tickets;"))
|
||||
assert.Empty(t, results)
|
||||
}
|
|
@ -7,6 +7,8 @@ import (
|
|||
)
|
||||
|
||||
type UseCase interface {
|
||||
// Redeem transform received ticket into access token.
|
||||
Redeem(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error)
|
||||
Generate(ctx context.Context, ticket *domain.Ticket) error
|
||||
|
||||
// Exchange transform received ticket into access token.
|
||||
Exchange(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error)
|
||||
}
|
||||
|
|
|
@ -2,14 +2,15 @@ package usecase
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
http "github.com/valyala/fasthttp"
|
||||
|
||||
"source.toby3d.me/website/indieauth/internal/common"
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/ticket"
|
||||
"source.toby3d.me/website/indieauth/internal/util"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -22,38 +23,105 @@ type (
|
|||
}
|
||||
|
||||
ticketUseCase struct {
|
||||
client *http.Client
|
||||
repo ticket.Repository
|
||||
client *http.Client
|
||||
tickets ticket.Repository
|
||||
}
|
||||
)
|
||||
|
||||
func NewTicketUseCase(repo ticket.Repository, client *http.Client) ticket.UseCase {
|
||||
func NewTicketUseCase(tickets ticket.Repository, client *http.Client) ticket.UseCase {
|
||||
return &ticketUseCase{
|
||||
client: client,
|
||||
repo: repo,
|
||||
client: client,
|
||||
tickets: tickets,
|
||||
}
|
||||
}
|
||||
|
||||
func (useCase *ticketUseCase) Redeem(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error) {
|
||||
endpoint, err := useCase.repo.Get(ctx, ticket.Resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot discovery token endpoint: %w", err)
|
||||
}
|
||||
|
||||
func (useCase *ticketUseCase) Generate(ctx context.Context, ticket *domain.Ticket) 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.Ticket)
|
||||
req.Header.SetMethod(http.MethodGet)
|
||||
req.SetRequestURIBytes(ticket.Subject.RequestURI())
|
||||
|
||||
resp := http.AcquireResponse()
|
||||
defer http.ReleaseResponse(resp)
|
||||
|
||||
if err := useCase.client.Do(req, resp); err != nil {
|
||||
return nil, err
|
||||
return fmt.Errorf("cannot discovery ticket subject: %w", err)
|
||||
}
|
||||
|
||||
var ticketEndpoint *domain.URL
|
||||
|
||||
// NOTE(toby3d): find metadata first
|
||||
if metadata, err := util.ExtractMetadata(resp, useCase.client); err == nil && metadata != nil {
|
||||
ticketEndpoint = metadata.TicketEndpoint
|
||||
} else { // NOTE(toby3d): fallback to old links searching
|
||||
if endpoints := util.ExtractEndpoints(resp, "ticket_endpoint"); endpoints != nil && len(endpoints) > 0 {
|
||||
ticketEndpoint = endpoints[len(endpoints)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if ticketEndpoint == nil {
|
||||
return fmt.Errorf("cannot discovery ticket_endpoint on ticket resource")
|
||||
}
|
||||
|
||||
if err := useCase.tickets.Create(ctx, ticket); err != nil {
|
||||
return fmt.Errorf("cannot save ticket in store: %w", err)
|
||||
}
|
||||
|
||||
req.Reset()
|
||||
req.Header.SetMethod(http.MethodPost)
|
||||
req.SetRequestURIBytes(ticketEndpoint.RequestURI())
|
||||
req.Header.SetContentType(common.MIMEApplicationForm)
|
||||
req.PostArgs().Set("ticket", ticket.Ticket)
|
||||
req.PostArgs().Set("subject", ticket.Subject.String())
|
||||
req.PostArgs().Set("resource", ticket.Resource.String())
|
||||
resp.Reset()
|
||||
|
||||
if err := useCase.client.Do(req, resp); err != nil {
|
||||
return fmt.Errorf("cannot send ticket to subject ticket_endpoint: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (useCase *ticketUseCase) Exchange(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error) {
|
||||
req := http.AcquireRequest()
|
||||
defer http.ReleaseRequest(req)
|
||||
req.SetRequestURI(ticket.Resource.String())
|
||||
req.Header.SetMethod(http.MethodGet)
|
||||
|
||||
resp := http.AcquireResponse()
|
||||
defer http.ReleaseResponse(resp)
|
||||
|
||||
if err := useCase.client.Do(req, resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot discovery ticket resource: %w", err)
|
||||
}
|
||||
|
||||
var tokenEndpoint *domain.URL
|
||||
|
||||
// NOTE(toby3d): find metadata first
|
||||
if metadata, err := util.ExtractMetadata(resp, useCase.client); err == nil && metadata != nil {
|
||||
tokenEndpoint = metadata.TokenEndpoint
|
||||
} else { // NOTE(toby3d): fallback to old links searching
|
||||
if endpoints := util.ExtractEndpoints(resp, "token_endpoint"); endpoints != nil && len(endpoints) > 0 {
|
||||
tokenEndpoint = endpoints[len(endpoints)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if tokenEndpoint == nil {
|
||||
return nil, fmt.Errorf("cannot discovery token_endpoint on ticket resource")
|
||||
}
|
||||
|
||||
req.Reset()
|
||||
req.Header.SetMethod(http.MethodPost)
|
||||
req.SetRequestURI(tokenEndpoint.String())
|
||||
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.Ticket)
|
||||
resp.Reset()
|
||||
|
||||
if err := useCase.client.Do(req, resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot exchange ticket on token_endpoint: %w", err)
|
||||
}
|
||||
|
||||
data := new(Response)
|
||||
|
|
|
@ -3,10 +3,9 @@ package usecase_test
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
http "github.com/valyala/fasthttp"
|
||||
|
@ -14,23 +13,21 @@ import (
|
|||
"source.toby3d.me/website/indieauth/internal/common"
|
||||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
"source.toby3d.me/website/indieauth/internal/testing/httptest"
|
||||
repo "source.toby3d.me/website/indieauth/internal/ticket/repository/memory"
|
||||
ucase "source.toby3d.me/website/indieauth/internal/ticket/usecase"
|
||||
)
|
||||
|
||||
func TestRedeem(t *testing.T) {
|
||||
func TestExchange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := domain.TestToken(t)
|
||||
ticket := domain.TestTicket(t)
|
||||
|
||||
store := new(sync.Map)
|
||||
store.Store(
|
||||
path.Join(repo.DefaultPathPrefix, ticket.Resource.String()),
|
||||
domain.TestURL(t, "https://example.com/token"),
|
||||
)
|
||||
|
||||
client, _, cleanup := httptest.New(t, func(ctx *http.RequestCtx) {
|
||||
r := router.New()
|
||||
r.GET(string(ticket.Resource.Path()), func(ctx *http.RequestCtx) {
|
||||
ctx.SuccessString(common.MIMETextHTMLCharsetUTF8, `<link rel="token_endpoint" href="`+
|
||||
ticket.Subject.String()+`token">`)
|
||||
})
|
||||
r.POST("/token", func(ctx *http.RequestCtx) {
|
||||
ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, fmt.Sprintf(`{
|
||||
"token_type": "Bearer",
|
||||
"access_token": "%s",
|
||||
|
@ -38,10 +35,11 @@ func TestRedeem(t *testing.T) {
|
|||
"me": "%s"
|
||||
}`, token.AccessToken, token.Scope.String(), token.Me.String()))
|
||||
})
|
||||
|
||||
client, _, cleanup := httptest.New(t, r.Handler)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
result, err := ucase.NewTicketUseCase(repo.NewMemoryTicketRepository(store), client).
|
||||
Redeem(context.Background(), ticket)
|
||||
result, err := ucase.NewTicketUseCase(nil, client).Exchange(context.Background(), ticket)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, token.AccessToken, result.AccessToken)
|
||||
assert.Equal(t, token.Me.String(), result.Me.String())
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
{% code type TicketPage struct {
|
||||
BaseOf
|
||||
CSRF []byte
|
||||
} %}
|
||||
|
||||
{% collapsespace %}
|
||||
{% func (p *TicketPage) Body() %}
|
||||
<header>
|
||||
<h1>{%= p.T("TicketAuth") %}</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<form
|
||||
accept-charset="utf-8"
|
||||
action="/api/ticket"
|
||||
autocomplete="off"
|
||||
enctype="application/x-www-form-urlencoded"
|
||||
method="post"
|
||||
target="_self">
|
||||
|
||||
{% if p.CSRF != nil %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="_csrf"
|
||||
value="{%z p.CSRF %}">
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="subject">{%= p.T("Recipient") %}</label>
|
||||
<input
|
||||
id="subject"
|
||||
type="url"
|
||||
name="subject"
|
||||
inputmode="url"
|
||||
placeholder="https://bob.example.org"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="resource">{%= p.T("Resource") %}</label>
|
||||
<input
|
||||
id="resource"
|
||||
type="url"
|
||||
name="resource"
|
||||
inputmode="url"
|
||||
placeholder="https://alice.example.com/private/"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit">{%= p.T("Send") %}</button>
|
||||
</form>
|
||||
</main>
|
||||
{% endfunc %}
|
||||
{% endcollapsespace %}
|
|
@ -0,0 +1,85 @@
|
|||
// Code generated by qtc from "ticket.qtpl". DO NOT EDIT.
|
||||
// See https://github.com/valyala/quicktemplate for details.
|
||||
|
||||
//line web/ticket.qtpl:1
|
||||
package web
|
||||
|
||||
//line web/ticket.qtpl:1
|
||||
import (
|
||||
qtio422016 "io"
|
||||
|
||||
qt422016 "github.com/valyala/quicktemplate"
|
||||
)
|
||||
|
||||
//line web/ticket.qtpl:1
|
||||
var (
|
||||
_ = qtio422016.Copy
|
||||
_ = qt422016.AcquireByteBuffer
|
||||
)
|
||||
|
||||
//line web/ticket.qtpl:1
|
||||
type TicketPage struct {
|
||||
BaseOf
|
||||
CSRF []byte
|
||||
}
|
||||
|
||||
//line web/ticket.qtpl:7
|
||||
func (p *TicketPage) StreamBody(qw422016 *qt422016.Writer) {
|
||||
//line web/ticket.qtpl:7
|
||||
qw422016.N().S(` <header> <h1>`)
|
||||
//line web/ticket.qtpl:9
|
||||
p.StreamT(qw422016, "TicketAuth")
|
||||
//line web/ticket.qtpl:9
|
||||
qw422016.N().S(`</h1> </header> <main> <form accept-charset="utf-8" action="/api/ticket" autocomplete="off" enctype="application/x-www-form-urlencoded" method="post" target="_self"> `)
|
||||
//line web/ticket.qtpl:21
|
||||
if p.CSRF != nil {
|
||||
//line web/ticket.qtpl:21
|
||||
qw422016.N().S(` <input type="hidden" name="_csrf" value="`)
|
||||
//line web/ticket.qtpl:25
|
||||
qw422016.E().Z(p.CSRF)
|
||||
//line web/ticket.qtpl:25
|
||||
qw422016.N().S(`"> `)
|
||||
//line web/ticket.qtpl:26
|
||||
}
|
||||
//line web/ticket.qtpl:26
|
||||
qw422016.N().S(` <div> <label for="subject">`)
|
||||
//line web/ticket.qtpl:29
|
||||
p.StreamT(qw422016, "Recipient")
|
||||
//line web/ticket.qtpl:29
|
||||
qw422016.N().S(`</label> <input id="subject" type="url" name="subject" inputmode="url" placeholder="https://bob.example.org" required> </div> <div> <label for="resource">`)
|
||||
//line web/ticket.qtpl:40
|
||||
p.StreamT(qw422016, "Resource")
|
||||
//line web/ticket.qtpl:40
|
||||
qw422016.N().S(`</label> <input id="resource" type="url" name="resource" inputmode="url" placeholder="https://alice.example.com/private/" required> </div> <button type="submit">`)
|
||||
//line web/ticket.qtpl:50
|
||||
p.StreamT(qw422016, "Send")
|
||||
//line web/ticket.qtpl:50
|
||||
qw422016.N().S(`</button> </form> </main> `)
|
||||
//line web/ticket.qtpl:53
|
||||
}
|
||||
|
||||
//line web/ticket.qtpl:53
|
||||
func (p *TicketPage) WriteBody(qq422016 qtio422016.Writer) {
|
||||
//line web/ticket.qtpl:53
|
||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
||||
//line web/ticket.qtpl:53
|
||||
p.StreamBody(qw422016)
|
||||
//line web/ticket.qtpl:53
|
||||
qt422016.ReleaseWriter(qw422016)
|
||||
//line web/ticket.qtpl:53
|
||||
}
|
||||
|
||||
//line web/ticket.qtpl:53
|
||||
func (p *TicketPage) Body() string {
|
||||
//line web/ticket.qtpl:53
|
||||
qb422016 := qt422016.AcquireByteBuffer()
|
||||
//line web/ticket.qtpl:53
|
||||
p.WriteBody(qb422016)
|
||||
//line web/ticket.qtpl:53
|
||||
qs422016 := string(qb422016.B)
|
||||
//line web/ticket.qtpl:53
|
||||
qt422016.ReleaseByteBuffer(qb422016)
|
||||
//line web/ticket.qtpl:53
|
||||
return qs422016
|
||||
//line web/ticket.qtpl:53
|
||||
}
|
Loading…
Reference in New Issue