From ed78b50f4e9a84c9511bdac63dda463e69661f15 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Mon, 7 Aug 2023 09:07:55 +0600 Subject: [PATCH] :recycle: Simplify httputil package utils usage --- .../client/repository/http/http_client.go | 172 +++++++------ .../repository/http/http_client_test.go | 4 +- internal/common/common.go | 25 ++ internal/domain/client.go | 45 +--- internal/domain/client_test.go | 31 +-- internal/domain/profile.go | 76 +----- internal/httputil/httputil.go | 138 ----------- internal/httputil/httputil_test.go | 66 ----- .../metadata/repository/http/http_metadata.go | 2 - internal/middleware/extractor.go | 8 +- .../profile/repository/http/http_profile.go | 113 +++++---- internal/user/repository/http/http_user.go | 228 +++++++++++------- .../user/repository/http/http_user_test.go | 10 +- 13 files changed, 358 insertions(+), 560 deletions(-) delete mode 100644 internal/httputil/httputil.go delete mode 100644 internal/httputil/httputil_test.go diff --git a/internal/client/repository/http/http_client.go b/internal/client/repository/http/http_client.go index 58c5d09..fae0762 100644 --- a/internal/client/repository/http/http_client.go +++ b/internal/client/repository/http/http_client.go @@ -8,27 +8,40 @@ import ( "net/http" "net/url" + "github.com/tomnomnom/linkheader" "golang.org/x/exp/slices" + "willnorris.com/go/microformats" "source.toby3d.me/toby3d/auth/internal/client" "source.toby3d.me/toby3d/auth/internal/common" "source.toby3d.me/toby3d/auth/internal/domain" - "source.toby3d.me/toby3d/auth/internal/httputil" ) -type httpClientRepository struct { - client *http.Client -} +type ( + //nolint:tagliatelle,lll + Response struct { + TicketEndpoint domain.URL `json:"ticket_endpoint"` + AuthorizationEndpoint domain.URL `json:"authorization_endpoint"` + IntrospectionEndpoint domain.URL `json:"introspection_endpoint"` + RevocationEndpoint domain.URL `json:"revocation_endpoint,omitempty"` + ServiceDocumentation domain.URL `json:"service_documentation,omitempty"` + TokenEndpoint domain.URL `json:"token_endpoint"` + UserinfoEndpoint domain.URL `json:"userinfo_endpoint,omitempty"` + Microsub domain.URL `json:"microsub"` + Issuer domain.URL `json:"issuer"` + Micropub domain.URL `json:"micropub"` + GrantTypesSupported []domain.GrantType `json:"grant_types_supported,omitempty"` + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + ScopesSupported []domain.Scope `json:"scopes_supported,omitempty"` + ResponseTypesSupported []domain.ResponseType `json:"response_types_supported,omitempty"` + CodeChallengeMethodsSupported []domain.CodeChallengeMethod `json:"code_challenge_methods_supported"` + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` + } -const ( - DefaultMaxRedirectsCount int = 10 - - hApp string = "h-app" - hXApp string = "h-x-app" - propertyLogo string = "logo" - propertyName string = "name" - propertyURL string = "url" - relRedirectURI string = "redirect_uri" + httpClientRepository struct { + client *http.Client + } ) func NewHTTPClientRepository(c *http.Client) client.Repository { @@ -46,9 +59,9 @@ func (repo httpClientRepository) Get(ctx context.Context, cid domain.ClientID) ( out := &domain.Client{ ID: cid, RedirectURI: make([]*url.URL, 0), - Logo: make([]*url.URL, 0), - URL: make([]*url.URL, 0), - Name: make([]string, 0), + Logo: nil, + URL: nil, + Name: "", } if cid.IsLocalhost() { @@ -64,71 +77,82 @@ func (repo httpClientRepository) Get(ctx context.Context, cid domain.ClientID) ( return nil, fmt.Errorf("%w: status on client page is not 200", client.ErrNotExist) } - extract(resp.Body, resp.Request.URL, out, resp.Header.Get(common.HeaderLink)) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response body: %w", err) + } + + // NOTE(toby3d): fetch redirect uri's and application profile from HTML nodes + mf2 := microformats.Parse(bytes.NewReader(body), resp.Request.URL) + + for i := range mf2.Items { + if !slices.Contains(mf2.Items[i].Type, common.HApp) && + !slices.Contains(mf2.Items[i].Type, common.HXApp) { + continue + } + + parseProfile(mf2.Items[i].Properties, out) + } + + for _, val := range mf2.Rels[common.RelRedirectURI] { + var u *url.URL + if u, err = url.Parse(val); err == nil { + out.RedirectURI = append(out.RedirectURI, u) + } + } + + // NOTE(toby3d): fetch redirect uri's from Link header + for _, link := range linkheader.Parse(resp.Header.Get(common.HeaderLink)) { + if link.Rel != common.RelRedirectURI { + continue + } + + var u *url.URL + if u, err = url.Parse(link.URL); err == nil { + out.RedirectURI = append(out.RedirectURI, u) + } + } return out, nil } -//nolint:gocognit,cyclop -func extract(r io.Reader, u *url.URL, dst *domain.Client, header string) { - body, _ := io.ReadAll(r) - - for _, endpoint := range httputil.ExtractEndpoints(bytes.NewReader(body), u, header, relRedirectURI) { - if !containsUrl(dst.RedirectURI, endpoint) { - dst.RedirectURI = append(dst.RedirectURI, endpoint) - } - } - - for _, itemType := range []string{hApp, hXApp} { - for _, name := range httputil.ExtractProperty(bytes.NewReader(body), u, itemType, propertyName) { - if n, ok := name.(string); ok && !slices.Contains(dst.Name, n) { - dst.Name = append(dst.Name, n) - } - } - - for _, logo := range httputil.ExtractProperty(bytes.NewReader(body), u, itemType, propertyLogo) { - var ( - logoURL *url.URL - err error - ) - - switch l := logo.(type) { - case string: - logoURL, err = url.Parse(l) - case map[string]string: - if value, ok := l["value"]; ok { - logoURL, err = url.Parse(value) - } - } - - if err != nil || containsUrl(dst.Logo, logoURL) { - continue - } - - dst.Logo = append(dst.Logo, logoURL) - } - - for _, property := range httputil.ExtractProperty(bytes.NewReader(body), u, itemType, propertyURL) { - prop, ok := property.(string) - if !ok { - continue - } - - if u, err := url.Parse(prop); err == nil && !containsUrl(dst.URL, u) { - dst.URL = append(dst.URL, u) - } - } - } -} - -func containsUrl(src []*url.URL, find *url.URL) bool { - for i := range src { - if src[i].String() != find.String() { +func parseProfile(src map[string][]any, dst *domain.Client) { + for _, val := range src[common.PropertyName] { + v, ok := val.(string) + if !ok { continue } - return true + dst.Name = v + + break } - return false + for _, val := range src[common.PropertyURL] { + v, ok := val.(string) + if !ok { + continue + } + + var err error + if dst.URL, err = url.Parse(v); err != nil { + continue + } + + break + } + + for _, val := range src[common.PropertyLogo] { + v, ok := val.(string) + if !ok { + continue + } + + var err error + if dst.Logo, err = url.Parse(v); err != nil { + continue + } + + break + } } diff --git a/internal/client/repository/http/http_client_test.go b/internal/client/repository/http/http_client_test.go index d1fb87a..6c36df4 100644 --- a/internal/client/repository/http/http_client_test.go +++ b/internal/client/repository/http/http_client_test.go @@ -75,7 +75,7 @@ func testHandler(tb testing.TB, client domain.Client) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8) - w.Header().Set(common.HeaderLink, `<`+client.RedirectURI[0].String()+`>; rel="redirect_uri"`) - fmt.Fprintf(w, testBody, client.Name[0], client.URL[0], client.Logo[0], client.RedirectURI[1]) + w.Header().Set(common.HeaderLink, `<`+client.RedirectURI[1].String()+`>; rel="redirect_uri"`) + fmt.Fprintf(w, testBody, client.Name, client.URL, client.Logo, client.RedirectURI[0]) }) } diff --git a/internal/common/common.go b/internal/common/common.go index 9a5a60f..09db682 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -27,4 +27,29 @@ const ( HeaderXCSRFToken string = "X-CSRF-Token" ) +const ( + HApp string = "h-app" + HCard string = "h-card" + HXApp string = "h-x-app" +) + +const ( + PropertyEmail string = "email" + PropertyLogo string = "logo" + PropertyName string = "name" + PropertyPhoto string = "photo" + PropertyURL string = "url" +) + +const ( + RelAuthn string = "authn" + RelAuthorizationEndpoint string = "authorization_endpoint" + RelIndieAuthMetadata string = "indieauth-metadata" + RelMicropub string = "micropub" + RelMicrosub string = "microsub" + RelRedirectURI string = "redirect_uri" + RelTicketEndpoint string = "ticket_endpoint" + RelTokenEndpoint string = "token_endpoint" +) + const Und string = "und" diff --git a/internal/domain/client.go b/internal/domain/client.go index 44131a3..9bf8821 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -9,21 +9,21 @@ import ( // Client describes the client requesting data about the user. type Client struct { + Logo *url.URL + URL *url.URL ID ClientID - Logo []*url.URL + Name string RedirectURI []*url.URL - URL []*url.URL - Name []string } // NewClient creates a new empty Client with provided ClientID, if any. func NewClient(cid ClientID) *Client { return &Client{ ID: cid, - Logo: make([]*url.URL, 0), + Logo: nil, RedirectURI: make([]*url.URL, 0), - URL: make([]*url.URL, 0), - Name: make([]string, 0), + URL: nil, + Name: "", } } @@ -33,9 +33,9 @@ func TestClient(tb testing.TB) *Client { return &Client{ ID: *TestClientID(tb), - Name: []string{"Example App"}, - URL: []*url.URL{{Scheme: "https", Host: "app.example.com", Path: "/"}}, - Logo: []*url.URL{{Scheme: "https", Host: "app.example.com", Path: "/logo.png"}}, + Name: "Example App", + URL: &url.URL{Scheme: "https", Host: "app.example.com", Path: "/"}, + Logo: &url.URL{Scheme: "https", Host: "app.example.com", Path: "/logo.png"}, RedirectURI: []*url.URL{ {Scheme: "https", Host: "app.example.com", Path: "/redirect"}, {Scheme: "https", Host: "app.example.net", Path: "/redirect"}, @@ -81,30 +81,3 @@ func (c *Client) ValidateRedirectURI(redirectURI *url.URL) bool { return false } - -// GetName safe returns first name, if any. -func (c Client) GetName() string { - if len(c.Name) == 0 { - return "" - } - - return c.Name[0] -} - -// GetURL safe returns first URL, if any. -func (c Client) GetURL() *url.URL { - if len(c.URL) == 0 { - return nil - } - - return c.URL[0] -} - -// GetLogo safe returns first logo, if any. -func (c Client) GetLogo() *url.URL { - if len(c.Logo) == 0 { - return nil - } - - return c.Logo[0] -} diff --git a/internal/domain/client_test.go b/internal/domain/client_test.go index ae7cef8..0d376ac 100644 --- a/internal/domain/client_test.go +++ b/internal/domain/client_test.go @@ -13,8 +13,8 @@ func TestClient_ValidateRedirectURI(t *testing.T) { client := domain.TestClient(t) for name, in := range map[string]*url.URL{ - "client_id prefix": client.ID.URL().JoinPath("/callback"), - "registered redirect_uri": client.RedirectURI[len(client.RedirectURI)-1], + "prefix": client.ID.URL().JoinPath("/callback"), + "redirect_uri": client.RedirectURI[len(client.RedirectURI)-1], } { name, in := name, in @@ -27,30 +27,3 @@ func TestClient_ValidateRedirectURI(t *testing.T) { }) } } - -func TestClient_GetName(t *testing.T) { - t.Parallel() - - client := domain.TestClient(t) - if result := client.GetName(); result != client.Name[0] { - t.Errorf("GetName() = %v, want %v", result, client.Name[0]) - } -} - -func TestClient_GetURL(t *testing.T) { - t.Parallel() - - client := domain.TestClient(t) - if result := client.GetURL(); result != client.URL[0] { - t.Errorf("GetURL() = %v, want %v", result, client.URL[0]) - } -} - -func TestClient_GetLogo(t *testing.T) { - t.Parallel() - - client := domain.TestClient(t) - if result := client.GetLogo(); result != client.Logo[0] { - t.Errorf("GetLogo() = %v, want %v", result, client.Logo[0]) - } -} diff --git a/internal/domain/profile.go b/internal/domain/profile.go index 55fdb01..7c2272e 100644 --- a/internal/domain/profile.go +++ b/internal/domain/profile.go @@ -7,18 +7,18 @@ import ( // Profile describes the data about the user. type Profile struct { - Photo []*url.URL `json:"photo,omitempty"` - URL []*url.URL `json:"url,omitempty"` - Email []*Email `json:"email,omitempty"` - Name []string `json:"name,omitempty"` + Photo *url.URL `json:"photo,omitempty"` + URL *url.URL `json:"url,omitempty"` + Email *Email `json:"email,omitempty"` + Name string `json:"name,omitempty"` } func NewProfile() *Profile { return &Profile{ - Photo: make([]*url.URL, 0), - URL: make([]*url.URL, 0), - Email: make([]*Email, 0), - Name: make([]string, 0), + Photo: new(url.URL), + URL: new(url.URL), + Email: new(Email), + Name: "", } } @@ -27,61 +27,9 @@ func TestProfile(tb testing.TB) *Profile { tb.Helper() return &Profile{ - Email: []*Email{TestEmail(tb)}, - Name: []string{"Example User"}, - Photo: []*url.URL{{Scheme: "https", Host: "user.example.net", Path: "/photo.jpg"}}, - URL: []*url.URL{{Scheme: "https", Host: "user.example.net", Path: "/"}}, + Email: TestEmail(tb), + Name: "Example User", + Photo: &url.URL{Scheme: "https", Host: "user.example.net", Path: "/photo.jpg"}, + URL: &url.URL{Scheme: "https", Host: "user.example.net", Path: "/"}, } } - -func (p Profile) HasName() bool { - return len(p.Name) > 0 -} - -// GetName safe returns first name, if any. -func (p Profile) GetName() string { - if len(p.Name) == 0 { - return "" - } - - return p.Name[0] -} - -func (p Profile) HasURL() bool { - return len(p.URL) > 0 -} - -// GetURL safe returns first URL, if any. -func (p Profile) GetURL() *url.URL { - if len(p.URL) == 0 { - return nil - } - - return p.URL[0] -} - -func (p Profile) HasPhoto() bool { - return len(p.Photo) > 0 -} - -// GetPhoto safe returns first photo, if any. -func (p Profile) GetPhoto() *url.URL { - if len(p.Photo) == 0 { - return nil - } - - return p.Photo[0] -} - -func (p Profile) HasEmail() bool { - return len(p.Email) > 0 -} - -// GetEmail safe returns first email, if any. -func (p Profile) GetEmail() *Email { - if len(p.Email) == 0 { - return nil - } - - return p.Email[0] -} diff --git a/internal/httputil/httputil.go b/internal/httputil/httputil.go deleted file mode 100644 index 212c1f1..0000000 --- a/internal/httputil/httputil.go +++ /dev/null @@ -1,138 +0,0 @@ -package httputil - -import ( - "bytes" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/goccy/go-json" - "github.com/tomnomnom/linkheader" - "golang.org/x/exp/slices" - "willnorris.com/go/microformats" - - "source.toby3d.me/toby3d/auth/internal/common" - "source.toby3d.me/toby3d/auth/internal/domain" -) - -const RelIndieauthMetadata = "indieauth-metadata" - -var ErrEndpointNotExist = domain.NewError( - domain.ErrorCodeServerError, - "cannot found any endpoints", - "https://indieauth.net/source/#discovery-0", -) - -func ExtractFromMetadata(client *http.Client, u string) (*domain.Metadata, error) { - req, err := http.NewRequest(http.MethodGet, u, nil) - if err != nil { - return nil, err - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - buf := bytes.NewBuffer(body) - - endpoints := ExtractEndpoints(buf, resp.Request.URL, resp.Header.Get(common.HeaderLink), RelIndieauthMetadata) - if len(endpoints) == 0 { - return nil, ErrEndpointNotExist - } - - if resp, err = client.Get(endpoints[len(endpoints)-1].String()); err != nil { - return nil, fmt.Errorf("failed to fetch metadata endpoint configuration: %w", err) - } - - result := new(domain.Metadata) - if err = json.NewDecoder(resp.Body).Decode(result); err != nil { - return nil, fmt.Errorf("cannot unmarshal emtadata configuration: %w", err) - } - - return result, nil -} - -func ExtractEndpoints(body io.Reader, u *url.URL, linkHeader, rel string) []*url.URL { - results := make([]*url.URL, 0) - - urls, err := ExtractEndpointsFromHeader(linkHeader, rel) - if err == nil { - results = append(results, urls...) - } - - urls, err = ExtractEndpointsFromBody(body, u, rel) - if err == nil { - results = append(results, urls...) - } - - return results -} - -func ExtractEndpointsFromHeader(linkHeader, rel string) ([]*url.URL, error) { - results := make([]*url.URL, 0) - - for _, link := range linkheader.Parse(linkHeader) { - if !strings.EqualFold(link.Rel, rel) { - continue - } - - u, err := url.Parse(link.URL) - if err != nil { - return nil, fmt.Errorf("cannot parse header endpoint: %w", err) - } - - results = append(results, u) - } - - return results, nil -} - -func ExtractEndpointsFromBody(body io.Reader, u *url.URL, rel string) ([]*url.URL, error) { - endpoints, ok := microformats.Parse(body, u).Rels[rel] - if !ok || len(endpoints) == 0 { - return nil, ErrEndpointNotExist - } - - results := make([]*url.URL, 0) - - for i := range endpoints { - u, err := url.Parse(endpoints[i]) - if err != nil { - return nil, fmt.Errorf("cannot parse body endpoint: %w", err) - } - - results = append(results, u) - } - - return results, nil -} - -func ExtractProperty(body io.Reader, u *url.URL, itemType, key string) []any { - if data := microformats.Parse(body, u); data != nil { - return FindProperty(data.Items, itemType, key) - } - - return nil -} - -func FindProperty(src []*microformats.Microformat, itemType, key string) []any { - for _, item := range src { - if slices.Contains(item.Type, itemType) { - return item.Properties[key] - } - - if result := FindProperty(item.Children, itemType, key); result != nil { - return result - } - } - - return nil -} diff --git a/internal/httputil/httputil_test.go b/internal/httputil/httputil_test.go deleted file mode 100644 index 2bb8f74..0000000 --- a/internal/httputil/httputil_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package httputil_test - -import ( - "io" - "net/http" - "net/url" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - - "source.toby3d.me/toby3d/auth/internal/httputil" -) - -const testBody = ` - - - - - -
-

Sample Name

-
- -` - -func TestExtractEndpointsFromBody(t *testing.T) { - t.Parallel() - - req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil) - if err != nil { - t.Fatal(err) - } - - in := &http.Response{Body: io.NopCloser(strings.NewReader(testBody))} - - out, err := httputil.ExtractEndpointsFromBody(in.Body, req.URL, "lipsum") - if err != nil { - t.Fatal(err) - } - - exp := []*url.URL{ - {Scheme: "https", Host: "example.com", Path: "/"}, - {Scheme: "https", Host: "example.net", Path: "/"}, - } - - if !cmp.Equal(out, exp) { - t.Errorf(`ExtractProperty(resp, "h-card", "name") = %+s, want %+s`, out, exp) - } -} - -func TestExtractProperty(t *testing.T) { - t.Parallel() - - req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil) - if err != nil { - t.Fatal(err) - } - - in := &http.Response{Body: io.NopCloser(strings.NewReader(testBody))} - - if out := httputil.ExtractProperty(in.Body, req.URL, "h-app", "name"); out == nil || out[0] != "Sample Name" { - t.Errorf(`ExtractProperty(%s, %s, %s) = %+s, want %+s`, req.URL, "h-app", "name", out, - []string{"Sample Name"}) - } -} diff --git a/internal/metadata/repository/http/http_metadata.go b/internal/metadata/repository/http/http_metadata.go index 011a2cc..146cb02 100644 --- a/internal/metadata/repository/http/http_metadata.go +++ b/internal/metadata/repository/http/http_metadata.go @@ -42,8 +42,6 @@ type ( } ) -const relIndieauthMetadata = "indieauth-metadata" - func NewHTTPMetadataRepository(client *http.Client) metadata.Repository { return &httpMetadataRepository{ client: client, diff --git a/internal/middleware/extractor.go b/internal/middleware/extractor.go index ce7380f..b7db08d 100644 --- a/internal/middleware/extractor.go +++ b/internal/middleware/extractor.go @@ -13,11 +13,9 @@ import ( // ValuesExtractor defines a function for extracting values (keys/tokens) from the given context. type ValuesExtractor func(w http.ResponseWriter, r *http.Request) ([]string, error) -const ( - // extractorLimit is arbitrary number to limit values extractor can return. this limits possible resource - // exhaustion attack vector. - extractorLimit = 20 -) +// extractorLimit is arbitrary number to limit values extractor can return. this limits possible resource +// exhaustion attack vector. +const extractorLimit = 20 var ( errHeaderExtractorValueMissing = errors.New("missing value in request header") diff --git a/internal/profile/repository/http/http_profile.go b/internal/profile/repository/http/http_profile.go index f8dadab..ca31fe7 100644 --- a/internal/profile/repository/http/http_profile.go +++ b/internal/profile/repository/http/http_profile.go @@ -8,8 +8,11 @@ import ( "net/http" "net/url" + "golang.org/x/exp/slices" + "willnorris.com/go/microformats" + + "source.toby3d.me/toby3d/auth/internal/common" "source.toby3d.me/toby3d/auth/internal/domain" - "source.toby3d.me/toby3d/auth/internal/httputil" "source.toby3d.me/toby3d/auth/internal/profile" ) @@ -17,17 +20,6 @@ type httpProfileRepository struct { client *http.Client } -const ( - ErrPrefix string = "http" - DefaultMaxRedirectsCount int = 10 - - hCard string = "h-card" - propertyEmail string = "email" - propertyName string = "name" - propertyPhoto string = "photo" - propertyURL string = "url" -) - func NewHTPPClientRepository(client *http.Client) profile.Repository { return &httpProfileRepository{ client: client, @@ -39,64 +31,69 @@ func (repo *httpProfileRepository) Create(_ context.Context, _ domain.Me, _ doma return nil } -//nolint:cyclop -func (repo *httpProfileRepository) Get(ctx context.Context, me domain.Me) (*domain.Profile, error) { +//nolint:cyclop,funlen +func (repo *httpProfileRepository) Get(_ context.Context, me domain.Me) (*domain.Profile, error) { resp, err := repo.client.Get(me.String()) if err != nil { - return nil, fmt.Errorf("%s: cannot fetch user by me: %w", ErrPrefix, err) + return nil, fmt.Errorf("%w: cannot fetch user by me: %w", profile.ErrNotExist, err) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("cannot read response body: %w", err) + return nil, fmt.Errorf("%w: cannot read response body: %w", profile.ErrNotExist, err) } - buf := bytes.NewReader(body) - result := domain.NewProfile() + mf2 := microformats.Parse(bytes.NewReader(body), resp.Request.URL) + out := new(domain.Profile) - for _, name := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyName) { - if n, ok := name.(string); ok { - result.Name = append(result.Name, n) - } - } - - for _, rawEmail := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyEmail) { - email, ok := rawEmail.(string) - if !ok { + for i := range mf2.Items { + if !slices.Contains(mf2.Items[i].Type, common.HCard) { continue } - if e, err := domain.ParseEmail(email); err == nil { - result.Email = append(result.Email, e) - } + parseProfile(mf2.Items[i].Properties, out) } - for _, rawURL := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyURL) { - rawURL, ok := rawURL.(string) - if !ok { - continue - } - - if u, err := url.Parse(rawURL); err == nil { - result.URL = append(result.URL, u) - } - } - - for _, rawPhoto := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyPhoto) { - photo, ok := rawPhoto.(string) - if !ok { - continue - } - - if p, err := url.Parse(photo); err == nil { - result.Photo = append(result.Photo, p) - } - } - - // TODO(toby3d): create method like result.Empty()? - if result.GetName() == "" && result.GetURL() == nil && result.GetPhoto() == nil && result.GetEmail() == nil { - return nil, profile.ErrNotExist - } - - return result, nil + return out, nil +} + +func parseProfile(src map[string][]any, dst *domain.Profile) { + for _, val := range src[common.PropertyName] { + v, ok := val.(string) + if !ok { + continue + } + + dst.Name = v + + break + } + + for _, val := range src[common.PropertyURL] { + v, ok := val.(string) + if !ok { + continue + } + + var err error + if dst.URL, err = url.Parse(v); err != nil { + continue + } + + break + } + + for _, val := range src[common.PropertyPhoto] { + v, ok := val.(string) + if !ok { + continue + } + + var err error + if dst.Photo, err = url.Parse(v); err != nil { + continue + } + + break + } } diff --git a/internal/user/repository/http/http_user.go b/internal/user/repository/http/http_user.go index 831ac3a..6e57d08 100644 --- a/internal/user/repository/http/http_user.go +++ b/internal/user/repository/http/http_user.go @@ -8,34 +8,45 @@ import ( "net/http" "net/url" + "github.com/goccy/go-json" + "github.com/tomnomnom/linkheader" "golang.org/x/exp/slices" + "willnorris.com/go/microformats" "source.toby3d.me/toby3d/auth/internal/common" "source.toby3d.me/toby3d/auth/internal/domain" - "source.toby3d.me/toby3d/auth/internal/httputil" "source.toby3d.me/toby3d/auth/internal/user" ) -type httpUserRepository struct { - client *http.Client -} +type ( + //nolint:tagliatelle,lll + MetadataResponse struct { + TicketEndpoint domain.URL `json:"ticket_endpoint"` + AuthorizationEndpoint domain.URL `json:"authorization_endpoint"` + IntrospectionEndpoint domain.URL `json:"introspection_endpoint"` + RevocationEndpoint domain.URL `json:"revocation_endpoint,omitempty"` + ServiceDocumentation domain.URL `json:"service_documentation,omitempty"` + TokenEndpoint domain.URL `json:"token_endpoint"` + UserinfoEndpoint domain.URL `json:"userinfo_endpoint,omitempty"` + Microsub domain.URL `json:"microsub"` + Issuer domain.URL `json:"issuer"` + Micropub domain.URL `json:"micropub"` + GrantTypesSupported []domain.GrantType `json:"grant_types_supported,omitempty"` + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + ScopesSupported []domain.Scope `json:"scopes_supported,omitempty"` + ResponseTypesSupported []domain.ResponseType `json:"response_types_supported,omitempty"` + CodeChallengeMethodsSupported []domain.CodeChallengeMethod `json:"code_challenge_methods_supported"` + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` + } -const ( - DefaultMaxRedirectsCount int = 10 - - hCard string = "h-card" - propertyEmail string = "email" - propertyName string = "name" - propertyPhoto string = "photo" - propertyURL string = "url" - relAuthorizationEndpoint string = "authorization_endpoint" - relIndieAuthMetadata string = "indieauth-metadata" - relMicropub string = "micropub" - relMicrosub string = "microsub" - relTicketEndpoint string = "ticket_endpoint" - relTokenEndpoint string = "token_endpoint" + httpUserRepository struct { + client *http.Client + } ) +const DefaultMaxRedirectsCount int = 10 + func NewHTTPUserRepository(client *http.Client) user.Repository { return &httpUserRepository{ client: client, @@ -47,113 +58,166 @@ func (httpUserRepository) Create(_ context.Context, _ domain.User) error { return nil } +//nolint:funlen func (repo *httpUserRepository) Get(ctx context.Context, me domain.Me) (*domain.User, error) { resp, err := repo.client.Get(me.String()) if err != nil { return nil, fmt.Errorf("cannot fetch user by me: %w", err) } - user := &domain.User{ - AuthorizationEndpoint: nil, - IndieAuthMetadata: nil, - Me: &me, - Micropub: nil, - Microsub: nil, - Profile: domain.NewProfile(), - TicketEndpoint: nil, - TokenEndpoint: nil, - } - - var metadata *domain.Metadata - if metadata, err = httputil.ExtractFromMetadata(repo.client, me.String()); err == nil { - user.AuthorizationEndpoint = metadata.AuthorizationEndpoint - user.Micropub = metadata.MicropubEndpoint - user.Microsub = metadata.MicrosubEndpoint - user.TicketEndpoint = metadata.TicketEndpoint - user.TokenEndpoint = metadata.TokenEndpoint + out := &domain.User{ + Profile: new(domain.Profile), } + // NOTE(toby3d): resolved Me may be different from user-provided Me + out.Me, _ = domain.ParseMe(resp.Request.URL.String()) body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("cannot read response body: %w", err) } - extractUser(me.URL(), user, body, resp.Header.Get(common.HeaderLink)) - extractProfile(me.URL(), user.Profile, body) + mf2 := microformats.Parse(bytes.NewReader(body), resp.Request.URL) - return user, nil -} + // NOTE(toby3d): fetch user profile from nodes + for i := range mf2.Items { + if !slices.Contains(mf2.Items[i].Type, common.HCard) { + continue + } -//nolint:cyclop -func extractUser(u *url.URL, dst *domain.User, body []byte, header string) { - for key, target := range map[string]**url.URL{ - relAuthorizationEndpoint: &dst.AuthorizationEndpoint, - relIndieAuthMetadata: &dst.IndieAuthMetadata, - relMicropub: &dst.Micropub, - relMicrosub: &dst.Microsub, - relTicketEndpoint: &dst.TicketEndpoint, - relTokenEndpoint: &dst.TokenEndpoint, + parseProfile(mf2.Items[i].Properties, out.Profile) + + break + } + + // NOTE(toby3d): fetch endpoints from HTML nodes + for key, dst := range map[string]**url.URL{ + common.RelAuthorizationEndpoint: &out.AuthorizationEndpoint, + common.RelIndieAuthMetadata: &out.IndieAuthMetadata, + common.RelMicropub: &out.Micropub, + common.RelMicrosub: &out.Microsub, + common.RelTicketEndpoint: &out.TicketEndpoint, + common.RelTokenEndpoint: &out.TokenEndpoint, } { - if target == nil { + vals, ok := mf2.Rels[key] + if !ok || len(vals) == 0 { continue } - if endpoints := httputil.ExtractEndpoints(bytes.NewReader(body), u, header, key); len(endpoints) > 0 { - *target = endpoints[len(endpoints)-1] + for i := range vals { + var u *url.URL + if u, err = url.Parse(vals[i]); err == nil { + *dst = u + + break + } } } + + // NOTE(toby3d): fetch endpoints from Link header + for _, link := range linkheader.Parse(resp.Header.Get(common.HeaderLink)) { + for key, dst := range map[string]**url.URL{ + common.RelAuthorizationEndpoint: &out.AuthorizationEndpoint, + common.RelIndieAuthMetadata: &out.IndieAuthMetadata, + common.RelMicropub: &out.Micropub, + common.RelMicrosub: &out.Microsub, + common.RelTicketEndpoint: &out.TicketEndpoint, + common.RelTokenEndpoint: &out.TokenEndpoint, + } { + if link.Rel != key { + continue + } + + var u *url.URL + if u, err = url.Parse(link.URL); err == nil { + *dst = u + + break + } + } + } + + if out.IndieAuthMetadata == nil { + return out, nil + } + + // NOTE(toby3d): fetch endpoints from metadata payload + if resp, err = repo.client.Get(out.IndieAuthMetadata.String()); err != nil { + return out, fmt.Errorf("cannot fetch endpoints from provided metadata URL: %w", err) + } + + metadata := new(MetadataResponse) + if err = json.NewDecoder(resp.Body).Decode(metadata); err != nil { + return out, fmt.Errorf("cannot decode metadata response: %w", err) + } + + for src, dst := range map[domain.URL]**url.URL{ + metadata.AuthorizationEndpoint: &out.AuthorizationEndpoint, + metadata.Micropub: &out.Micropub, + metadata.Microsub: &out.Microsub, + metadata.TicketEndpoint: &out.TicketEndpoint, + metadata.TokenEndpoint: &out.TokenEndpoint, + } { + if src.URL == nil { + continue + } + + *dst = src.URL + } + + return out, nil } -//nolint:cyclop -func extractProfile(u *url.URL, dst *domain.Profile, body []byte) { - for _, name := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyName) { - if n, ok := name.(string); ok && !slices.Contains(dst.Name, n) { - dst.Name = append(dst.Name, n) - } - } - - for _, rawEmail := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyEmail) { - email, ok := rawEmail.(string) +func parseProfile(src map[string][]any, dst *domain.Profile) { + for _, val := range src[common.PropertyName] { + v, ok := val.(string) if !ok { continue } - if e, err := domain.ParseEmail(email); err == nil && !slices.Contains(dst.Email, e) { - dst.Email = append(dst.Email, e) - } + dst.Name = v + + break } - for _, rawURL := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyURL) { - rawURL, ok := rawURL.(string) + for _, val := range src[common.PropertyURL] { + v, ok := val.(string) if !ok { continue } - if parsedURL, err := url.Parse(rawURL); err == nil && !containsUrl(dst.URL, u) { - dst.URL = append(dst.URL, parsedURL) + var err error + if dst.URL, err = url.Parse(v); err != nil { + continue } + + break } - for _, rawPhoto := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyPhoto) { - photo, ok := rawPhoto.(string) + for _, val := range src[common.PropertyPhoto] { + v, ok := val.(string) if !ok { continue } - if p, err := url.Parse(photo); err == nil && !containsUrl(dst.Photo, p) { - dst.Photo = append(dst.Photo, p) - } - } -} - -func containsUrl(src []*url.URL, find *url.URL) bool { - for i := range src { - if src[i].String() != find.String() { + var err error + if dst.Photo, err = url.Parse(v); err != nil { continue } - return true + break } - return false + for _, val := range src[common.PropertyEmail] { + v, ok := val.(string) + if !ok { + continue + } + + var err error + if dst.Email, err = domain.ParseEmail(v); err != nil { + continue + } + + break + } } diff --git a/internal/user/repository/http/http_user_test.go b/internal/user/repository/http/http_user_test.go index 34c7aba..510fd0d 100644 --- a/internal/user/repository/http/http_user_test.go +++ b/internal/user/repository/http/http_user_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -39,14 +40,15 @@ func TestGet(t *testing.T) { t.Parallel() user := domain.TestUser(t) + user.Me = nil srv := httptest.NewServer(testHandler(t, user)) t.Cleanup(srv.Close) - user.Me = domain.TestMe(t, srv.URL+"/") + user.IndieAuthMetadata, _ = url.Parse(srv.URL + user.IndieAuthMetadata.Path) result, err := repository.NewHTTPUserRepository(srv.Client()). - Get(context.Background(), *user.Me) + Get(context.Background(), *domain.TestMe(t, srv.URL+"/")) if err != nil { t.Fatal(err) } @@ -70,12 +72,12 @@ func testHandler(tb testing.TB, user *domain.User) http.Handler { `<` + user.TokenEndpoint.String() + `>; rel="token_endpoint"`, }, ", ")) w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8) - fmt.Fprintf(w, testBody, user.Name[0], user.URL[0].String(), user.Photo[0].String(), user.Email[0]) + fmt.Fprintf(w, testBody, user.Name, user.URL, user.Photo, user.Email) }) mux.HandleFunc(user.IndieAuthMetadata.Path, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8) fmt.Fprint(w, `{ - "issuer": "`+user.Me.String()+`", + "issuer": "https://auth.example.com/", "authorization_endpoint": "`+user.AuthorizationEndpoint.String()+`", "token_endpoint": "`+user.TokenEndpoint.String()+`" }`)