diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1cc1689..571cda8 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - http "github.com/valyala/fasthttp" "source.toby3d.me/website/oauth/internal/domain" ) @@ -67,17 +66,22 @@ func initConfig() { log.Fatalln("fail to read config:", err) } - u, logo, redirect := http.AcquireURI(), http.AcquireURI(), http.AcquireURI() - if err = u.Parse(nil, []byte(rootURL)); err != nil { - log.Fatalln("cannot parse client URL:", err) + url, err := domain.NewURL(rootURL) + if err != nil { + log.Fatalln("cannot parse root URL as client URL:", err) } - u.CopyTo(logo) - u.CopyTo(redirect) - redirect.SetPath("/callback") - logo.SetPath(config.Server.StaticURLPrefix + "/icon.svg") + logo, err := domain.NewURL(rootURL + config.Server.StaticURLPrefix + "/icon.svg") + if err != nil { + log.Fatalln("cannot parse root URL as client URL:", err) + } - client.URL = []*http.URI{u} - client.Logo = []*http.URI{logo} - client.RedirectURI = []*http.URI{redirect} + redirectURI, err := domain.NewURL(rootURL + "/callback") + if err != nil { + log.Fatalln("cannot parse root URL as client URL:", err) + } + + client.URL = []*domain.URL{url} + client.Logo = []*domain.URL{logo} + client.RedirectURI = []*domain.URL{redirectURI} } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 007c33f..46f7cb1 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -21,14 +21,11 @@ import ( "golang.org/x/text/message" clienthttpdelivery "source.toby3d.me/website/oauth/internal/client/delivery/http" - clientrepo "source.toby3d.me/website/oauth/internal/client/repository/http" - clientucase "source.toby3d.me/website/oauth/internal/client/usecase" healthhttpdelivery "source.toby3d.me/website/oauth/internal/health/delivery/http" metadatahttpdelivery "source.toby3d.me/website/oauth/internal/metadata/delivery/http" tickethttpdelivery "source.toby3d.me/website/oauth/internal/ticket/delivery/http" + ticketrepo "source.toby3d.me/website/oauth/internal/ticket/repository/http" ticketucase "source.toby3d.me/website/oauth/internal/ticket/usecase" - userrepo "source.toby3d.me/website/oauth/internal/user/repository/http" - userucase "source.toby3d.me/website/oauth/internal/user/usecase" ) const ( @@ -84,9 +81,15 @@ func startServer(cmd *cobra.Command, args []string) { healthhttpdelivery.NewRequestHandler().Register(r) metadatahttpdelivery.NewRequestHandler(config).Register(r) clienthttpdelivery.NewRequestHandler(config, client, matcher).Register(r) + /* + serverhttpdelivery.NewRequestHandler(serverhttpdelivery.Config{ + Config: config, + Clients: clientucase.NewClientUseCase(clientrepo.NewHTTPClientRepository(httpClient)), + Matcher: matcher, + }).Register(r) + */ tickethttpdelivery.NewRequestHandler( - ticketucase.NewTicketUseCase(httpClient), - userucase.NewUserUseCase(userrepo.NewHTTPUserRepository(httpClient)), + ticketucase.NewTicketUseCase(ticketrepo.NewHTTPTicketRepository(httpClient), httpClient), ).Register(r) if enablePprof { diff --git a/internal/domain/url.go b/internal/domain/url.go index 6ef003a..5cbbd08 100644 --- a/internal/domain/url.go +++ b/internal/domain/url.go @@ -1,6 +1,7 @@ package domain import ( + "net/url" "strconv" "testing" @@ -59,3 +60,16 @@ func (u *URL) UnmarshalJSON(v []byte) error { return nil } + +func (u *URL) URL() *url.URL { + if u.URI == nil { + return nil + } + + result, err := url.ParseRequestURI(u.URI.String()) + if err != nil { + return nil + } + + return result +} diff --git a/internal/ticket/delivery/http/ticket_http.go b/internal/ticket/delivery/http/ticket_http.go index 20d5691..7d58737 100644 --- a/internal/ticket/delivery/http/ticket_http.go +++ b/internal/ticket/delivery/http/ticket_http.go @@ -12,7 +12,6 @@ import ( "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 ( @@ -24,19 +23,18 @@ type ( Resource *domain.URL `form:"resource"` // The access token should be used when acting on behalf of this URL. + // WARN(toby3d): deadcode for now Subject *domain.Me `form:"subject"` } RequestHandler struct { - users user.UseCase - tickets ticket.UseCase + useCase ticket.UseCase } ) -func NewRequestHandler(tickets ticket.UseCase, users user.UseCase) *RequestHandler { +func NewRequestHandler(useCase ticket.UseCase) *RequestHandler { return &RequestHandler{ - tickets: tickets, - users: users, + useCase: useCase, } } @@ -58,20 +56,11 @@ func (h *RequestHandler) update(ctx *http.RequestCtx) { 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) + token, err := h.useCase.Redeem(ctx, &domain.Ticket{ + Ticket: req.Ticket, + Resource: req.Resource, + Subject: req.Subject, + }) if err != nil { ctx.SetStatusCode(http.StatusBadRequest) encoder.Encode(domain.Error{ diff --git a/internal/ticket/delivery/http/ticket_http_test.go b/internal/ticket/delivery/http/ticket_http_test.go index 642d99a..6c42691 100644 --- a/internal/ticket/delivery/http/ticket_http_test.go +++ b/internal/ticket/delivery/http/ticket_http_test.go @@ -15,9 +15,8 @@ import ( "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" + ticketrepo "source.toby3d.me/website/oauth/internal/ticket/repository/memory" 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? @@ -30,7 +29,10 @@ func TestUpdate(t *testing.T) { token := domain.TestToken(t) store := new(sync.Map) - store.Store(path.Join(userrepo.DefaultPathPrefix, ticket.Subject.String()), domain.TestUser(t)) + 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(`{ @@ -47,9 +49,8 @@ func TestUpdate(t *testing.T) { client, _, cleanup := httptest.New(t, r.Handler) t.Cleanup(cleanup) - delivery.NewRequestHandler( - ucase.NewTicketUseCase(userClient), userucase.NewUserUseCase(userrepo.NewMemoryUserRepository(store)), - ).Register(r) + delivery.NewRequestHandler(ucase.NewTicketUseCase(ticketrepo.NewMemoryTicketRepository(store), userClient)). + Register(r) req := httptest.NewRequest(http.MethodPost, "https://example.com/ticket", []byte( `ticket=`+ticket.Ticket+ diff --git a/internal/ticket/repository.go b/internal/ticket/repository.go new file mode 100644 index 0000000..8179110 --- /dev/null +++ b/internal/ticket/repository.go @@ -0,0 +1,15 @@ +package ticket + +import ( + "context" + "errors" + + "source.toby3d.me/website/oauth/internal/domain" +) + +type Repository interface { + // Get returns token endpoint founded by resource URL. + Get(ctx context.Context, resource *domain.URL) (*domain.URL, error) +} + +var ErrNotExist = errors.New("token_endpoint not found on resource URL") diff --git a/internal/ticket/repository/http/http_ticket.go b/internal/ticket/repository/http/http_ticket.go new file mode 100644 index 0000000..07662e6 --- /dev/null +++ b/internal/ticket/repository/http/http_ticket.go @@ -0,0 +1,174 @@ +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/oauth/internal/domain" + "source.toby3d.me/website/oauth/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 +} diff --git a/internal/ticket/repository/http/http_ticket_test.go b/internal/ticket/repository/http/http_ticket_test.go new file mode 100644 index 0000000..a102d43 --- /dev/null +++ b/internal/ticket/repository/http/http_ticket_test.go @@ -0,0 +1,104 @@ +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/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/testing/httptest" + repository "source.toby3d.me/website/oauth/internal/ticket/repository/http" +) + +type TestCase struct { + name string + bodyLinks map[string]string + metadata string +} + +const testBody string = ` + + +
+ + +