♻️ Simplify httputil package utils usage
This commit is contained in:
parent
d6c89a8d00
commit
ed78b50f4e
|
@ -8,27 +8,40 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/tomnomnom/linkheader"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
|
"willnorris.com/go/microformats"
|
||||||
|
|
||||||
"source.toby3d.me/toby3d/auth/internal/client"
|
"source.toby3d.me/toby3d/auth/internal/client"
|
||||||
"source.toby3d.me/toby3d/auth/internal/common"
|
"source.toby3d.me/toby3d/auth/internal/common"
|
||||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||||
"source.toby3d.me/toby3d/auth/internal/httputil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type httpClientRepository struct {
|
type (
|
||||||
client *http.Client
|
//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 (
|
httpClientRepository struct {
|
||||||
DefaultMaxRedirectsCount int = 10
|
client *http.Client
|
||||||
|
}
|
||||||
hApp string = "h-app"
|
|
||||||
hXApp string = "h-x-app"
|
|
||||||
propertyLogo string = "logo"
|
|
||||||
propertyName string = "name"
|
|
||||||
propertyURL string = "url"
|
|
||||||
relRedirectURI string = "redirect_uri"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewHTTPClientRepository(c *http.Client) client.Repository {
|
func NewHTTPClientRepository(c *http.Client) client.Repository {
|
||||||
|
@ -46,9 +59,9 @@ func (repo httpClientRepository) Get(ctx context.Context, cid domain.ClientID) (
|
||||||
out := &domain.Client{
|
out := &domain.Client{
|
||||||
ID: cid,
|
ID: cid,
|
||||||
RedirectURI: make([]*url.URL, 0),
|
RedirectURI: make([]*url.URL, 0),
|
||||||
Logo: make([]*url.URL, 0),
|
Logo: nil,
|
||||||
URL: make([]*url.URL, 0),
|
URL: nil,
|
||||||
Name: make([]string, 0),
|
Name: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if cid.IsLocalhost() {
|
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)
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocognit,cyclop
|
func parseProfile(src map[string][]any, dst *domain.Client) {
|
||||||
func extract(r io.Reader, u *url.URL, dst *domain.Client, header string) {
|
for _, val := range src[common.PropertyName] {
|
||||||
body, _ := io.ReadAll(r)
|
v, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
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() {
|
|
||||||
continue
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,7 @@ func testHandler(tb testing.TB, client domain.Client) http.Handler {
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
|
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
|
||||||
w.Header().Set(common.HeaderLink, `<`+client.RedirectURI[0].String()+`>; rel="redirect_uri"`)
|
w.Header().Set(common.HeaderLink, `<`+client.RedirectURI[1].String()+`>; rel="redirect_uri"`)
|
||||||
fmt.Fprintf(w, testBody, client.Name[0], client.URL[0], client.Logo[0], client.RedirectURI[1])
|
fmt.Fprintf(w, testBody, client.Name, client.URL, client.Logo, client.RedirectURI[0])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,4 +27,29 @@ const (
|
||||||
HeaderXCSRFToken string = "X-CSRF-Token"
|
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"
|
const Und string = "und"
|
||||||
|
|
|
@ -9,21 +9,21 @@ import (
|
||||||
|
|
||||||
// Client describes the client requesting data about the user.
|
// Client describes the client requesting data about the user.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
Logo *url.URL
|
||||||
|
URL *url.URL
|
||||||
ID ClientID
|
ID ClientID
|
||||||
Logo []*url.URL
|
Name string
|
||||||
RedirectURI []*url.URL
|
RedirectURI []*url.URL
|
||||||
URL []*url.URL
|
|
||||||
Name []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new empty Client with provided ClientID, if any.
|
// NewClient creates a new empty Client with provided ClientID, if any.
|
||||||
func NewClient(cid ClientID) *Client {
|
func NewClient(cid ClientID) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
ID: cid,
|
ID: cid,
|
||||||
Logo: make([]*url.URL, 0),
|
Logo: nil,
|
||||||
RedirectURI: make([]*url.URL, 0),
|
RedirectURI: make([]*url.URL, 0),
|
||||||
URL: make([]*url.URL, 0),
|
URL: nil,
|
||||||
Name: make([]string, 0),
|
Name: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,9 +33,9 @@ func TestClient(tb testing.TB) *Client {
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
ID: *TestClientID(tb),
|
ID: *TestClientID(tb),
|
||||||
Name: []string{"Example App"},
|
Name: "Example App",
|
||||||
URL: []*url.URL{{Scheme: "https", Host: "app.example.com", Path: "/"}},
|
URL: &url.URL{Scheme: "https", Host: "app.example.com", Path: "/"},
|
||||||
Logo: []*url.URL{{Scheme: "https", Host: "app.example.com", Path: "/logo.png"}},
|
Logo: &url.URL{Scheme: "https", Host: "app.example.com", Path: "/logo.png"},
|
||||||
RedirectURI: []*url.URL{
|
RedirectURI: []*url.URL{
|
||||||
{Scheme: "https", Host: "app.example.com", Path: "/redirect"},
|
{Scheme: "https", Host: "app.example.com", Path: "/redirect"},
|
||||||
{Scheme: "https", Host: "app.example.net", Path: "/redirect"},
|
{Scheme: "https", Host: "app.example.net", Path: "/redirect"},
|
||||||
|
@ -81,30 +81,3 @@ func (c *Client) ValidateRedirectURI(redirectURI *url.URL) bool {
|
||||||
|
|
||||||
return false
|
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]
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,8 +13,8 @@ func TestClient_ValidateRedirectURI(t *testing.T) {
|
||||||
client := domain.TestClient(t)
|
client := domain.TestClient(t)
|
||||||
|
|
||||||
for name, in := range map[string]*url.URL{
|
for name, in := range map[string]*url.URL{
|
||||||
"client_id prefix": client.ID.URL().JoinPath("/callback"),
|
"prefix": client.ID.URL().JoinPath("/callback"),
|
||||||
"registered redirect_uri": client.RedirectURI[len(client.RedirectURI)-1],
|
"redirect_uri": client.RedirectURI[len(client.RedirectURI)-1],
|
||||||
} {
|
} {
|
||||||
name, in := name, in
|
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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -7,18 +7,18 @@ import (
|
||||||
|
|
||||||
// Profile describes the data about the user.
|
// Profile describes the data about the user.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Photo []*url.URL `json:"photo,omitempty"`
|
Photo *url.URL `json:"photo,omitempty"`
|
||||||
URL []*url.URL `json:"url,omitempty"`
|
URL *url.URL `json:"url,omitempty"`
|
||||||
Email []*Email `json:"email,omitempty"`
|
Email *Email `json:"email,omitempty"`
|
||||||
Name []string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProfile() *Profile {
|
func NewProfile() *Profile {
|
||||||
return &Profile{
|
return &Profile{
|
||||||
Photo: make([]*url.URL, 0),
|
Photo: new(url.URL),
|
||||||
URL: make([]*url.URL, 0),
|
URL: new(url.URL),
|
||||||
Email: make([]*Email, 0),
|
Email: new(Email),
|
||||||
Name: make([]string, 0),
|
Name: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,61 +27,9 @@ func TestProfile(tb testing.TB) *Profile {
|
||||||
tb.Helper()
|
tb.Helper()
|
||||||
|
|
||||||
return &Profile{
|
return &Profile{
|
||||||
Email: []*Email{TestEmail(tb)},
|
Email: TestEmail(tb),
|
||||||
Name: []string{"Example User"},
|
Name: "Example User",
|
||||||
Photo: []*url.URL{{Scheme: "https", Host: "user.example.net", Path: "/photo.jpg"}},
|
Photo: &url.URL{Scheme: "https", Host: "user.example.net", Path: "/photo.jpg"},
|
||||||
URL: []*url.URL{{Scheme: "https", Host: "user.example.net", Path: "/"}},
|
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]
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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 = `<html>
|
|
||||||
<head>
|
|
||||||
<link rel="lipsum" href="https://example.com/">
|
|
||||||
<link rel="lipsum" href="https://example.net/">
|
|
||||||
</head>
|
|
||||||
<body class="h-page">
|
|
||||||
<main class="h-app">
|
|
||||||
<h1 class="p-name">Sample Name</h1>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
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"})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -42,8 +42,6 @@ type (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const relIndieauthMetadata = "indieauth-metadata"
|
|
||||||
|
|
||||||
func NewHTTPMetadataRepository(client *http.Client) metadata.Repository {
|
func NewHTTPMetadataRepository(client *http.Client) metadata.Repository {
|
||||||
return &httpMetadataRepository{
|
return &httpMetadataRepository{
|
||||||
client: client,
|
client: client,
|
||||||
|
|
|
@ -13,11 +13,9 @@ import (
|
||||||
// ValuesExtractor defines a function for extracting values (keys/tokens) from the given context.
|
// ValuesExtractor defines a function for extracting values (keys/tokens) from the given context.
|
||||||
type ValuesExtractor func(w http.ResponseWriter, r *http.Request) ([]string, error)
|
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
|
||||||
// extractorLimit is arbitrary number to limit values extractor can return. this limits possible resource
|
// exhaustion attack vector.
|
||||||
// exhaustion attack vector.
|
const extractorLimit = 20
|
||||||
extractorLimit = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errHeaderExtractorValueMissing = errors.New("missing value in request header")
|
errHeaderExtractorValueMissing = errors.New("missing value in request header")
|
||||||
|
|
|
@ -8,8 +8,11 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"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/domain"
|
||||||
"source.toby3d.me/toby3d/auth/internal/httputil"
|
|
||||||
"source.toby3d.me/toby3d/auth/internal/profile"
|
"source.toby3d.me/toby3d/auth/internal/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -17,17 +20,6 @@ type httpProfileRepository struct {
|
||||||
client *http.Client
|
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 {
|
func NewHTPPClientRepository(client *http.Client) profile.Repository {
|
||||||
return &httpProfileRepository{
|
return &httpProfileRepository{
|
||||||
client: client,
|
client: client,
|
||||||
|
@ -39,64 +31,69 @@ func (repo *httpProfileRepository) Create(_ context.Context, _ domain.Me, _ doma
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:cyclop
|
//nolint:cyclop,funlen
|
||||||
func (repo *httpProfileRepository) Get(ctx context.Context, me domain.Me) (*domain.Profile, error) {
|
func (repo *httpProfileRepository) Get(_ context.Context, me domain.Me) (*domain.Profile, error) {
|
||||||
resp, err := repo.client.Get(me.String())
|
resp, err := repo.client.Get(me.String())
|
||||||
if err != nil {
|
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)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
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)
|
mf2 := microformats.Parse(bytes.NewReader(body), resp.Request.URL)
|
||||||
result := domain.NewProfile()
|
out := new(domain.Profile)
|
||||||
|
|
||||||
for _, name := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyName) {
|
for i := range mf2.Items {
|
||||||
if n, ok := name.(string); ok {
|
if !slices.Contains(mf2.Items[i].Type, common.HCard) {
|
||||||
result.Name = append(result.Name, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rawEmail := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyEmail) {
|
|
||||||
email, ok := rawEmail.(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if e, err := domain.ParseEmail(email); err == nil {
|
parseProfile(mf2.Items[i].Properties, out)
|
||||||
result.Email = append(result.Email, e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rawURL := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyURL) {
|
return out, nil
|
||||||
rawURL, ok := rawURL.(string)
|
}
|
||||||
if !ok {
|
|
||||||
continue
|
func parseProfile(src map[string][]any, dst *domain.Profile) {
|
||||||
}
|
for _, val := range src[common.PropertyName] {
|
||||||
|
v, ok := val.(string)
|
||||||
if u, err := url.Parse(rawURL); err == nil {
|
if !ok {
|
||||||
result.URL = append(result.URL, u)
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
dst.Name = v
|
||||||
for _, rawPhoto := range httputil.ExtractProperty(buf, me.URL(), hCard, propertyPhoto) {
|
|
||||||
photo, ok := rawPhoto.(string)
|
break
|
||||||
if !ok {
|
}
|
||||||
continue
|
|
||||||
}
|
for _, val := range src[common.PropertyURL] {
|
||||||
|
v, ok := val.(string)
|
||||||
if p, err := url.Parse(photo); err == nil {
|
if !ok {
|
||||||
result.Photo = append(result.Photo, p)
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
var err error
|
||||||
// TODO(toby3d): create method like result.Empty()?
|
if dst.URL, err = url.Parse(v); err != nil {
|
||||||
if result.GetName() == "" && result.GetURL() == nil && result.GetPhoto() == nil && result.GetEmail() == nil {
|
continue
|
||||||
return nil, profile.ErrNotExist
|
}
|
||||||
}
|
|
||||||
|
break
|
||||||
return result, nil
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,34 +8,45 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/tomnomnom/linkheader"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
|
"willnorris.com/go/microformats"
|
||||||
|
|
||||||
"source.toby3d.me/toby3d/auth/internal/common"
|
"source.toby3d.me/toby3d/auth/internal/common"
|
||||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||||
"source.toby3d.me/toby3d/auth/internal/httputil"
|
|
||||||
"source.toby3d.me/toby3d/auth/internal/user"
|
"source.toby3d.me/toby3d/auth/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type httpUserRepository struct {
|
type (
|
||||||
client *http.Client
|
//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 (
|
httpUserRepository struct {
|
||||||
DefaultMaxRedirectsCount int = 10
|
client *http.Client
|
||||||
|
}
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DefaultMaxRedirectsCount int = 10
|
||||||
|
|
||||||
func NewHTTPUserRepository(client *http.Client) user.Repository {
|
func NewHTTPUserRepository(client *http.Client) user.Repository {
|
||||||
return &httpUserRepository{
|
return &httpUserRepository{
|
||||||
client: client,
|
client: client,
|
||||||
|
@ -47,113 +58,166 @@ func (httpUserRepository) Create(_ context.Context, _ domain.User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:funlen
|
||||||
func (repo *httpUserRepository) Get(ctx context.Context, me domain.Me) (*domain.User, error) {
|
func (repo *httpUserRepository) Get(ctx context.Context, me domain.Me) (*domain.User, error) {
|
||||||
resp, err := repo.client.Get(me.String())
|
resp, err := repo.client.Get(me.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot fetch user by me: %w", err)
|
return nil, fmt.Errorf("cannot fetch user by me: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &domain.User{
|
out := &domain.User{
|
||||||
AuthorizationEndpoint: nil,
|
Profile: new(domain.Profile),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
// 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)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
extractUser(me.URL(), user, body, resp.Header.Get(common.HeaderLink))
|
mf2 := microformats.Parse(bytes.NewReader(body), resp.Request.URL)
|
||||||
extractProfile(me.URL(), user.Profile, body)
|
|
||||||
|
|
||||||
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
|
parseProfile(mf2.Items[i].Properties, out.Profile)
|
||||||
func extractUser(u *url.URL, dst *domain.User, body []byte, header string) {
|
|
||||||
for key, target := range map[string]**url.URL{
|
break
|
||||||
relAuthorizationEndpoint: &dst.AuthorizationEndpoint,
|
}
|
||||||
relIndieAuthMetadata: &dst.IndieAuthMetadata,
|
|
||||||
relMicropub: &dst.Micropub,
|
// NOTE(toby3d): fetch endpoints from HTML nodes
|
||||||
relMicrosub: &dst.Microsub,
|
for key, dst := range map[string]**url.URL{
|
||||||
relTicketEndpoint: &dst.TicketEndpoint,
|
common.RelAuthorizationEndpoint: &out.AuthorizationEndpoint,
|
||||||
relTokenEndpoint: &dst.TokenEndpoint,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if endpoints := httputil.ExtractEndpoints(bytes.NewReader(body), u, header, key); len(endpoints) > 0 {
|
for i := range vals {
|
||||||
*target = endpoints[len(endpoints)-1]
|
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 parseProfile(src map[string][]any, dst *domain.Profile) {
|
||||||
func extractProfile(u *url.URL, dst *domain.Profile, body []byte) {
|
for _, val := range src[common.PropertyName] {
|
||||||
for _, name := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyName) {
|
v, ok := val.(string)
|
||||||
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)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if e, err := domain.ParseEmail(email); err == nil && !slices.Contains(dst.Email, e) {
|
dst.Name = v
|
||||||
dst.Email = append(dst.Email, e)
|
|
||||||
}
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rawURL := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyURL) {
|
for _, val := range src[common.PropertyURL] {
|
||||||
rawURL, ok := rawURL.(string)
|
v, ok := val.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsedURL, err := url.Parse(rawURL); err == nil && !containsUrl(dst.URL, u) {
|
var err error
|
||||||
dst.URL = append(dst.URL, parsedURL)
|
if dst.URL, err = url.Parse(v); err != nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rawPhoto := range httputil.ExtractProperty(bytes.NewReader(body), u, hCard, propertyPhoto) {
|
for _, val := range src[common.PropertyPhoto] {
|
||||||
photo, ok := rawPhoto.(string)
|
v, ok := val.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if p, err := url.Parse(photo); err == nil && !containsUrl(dst.Photo, p) {
|
var err error
|
||||||
dst.Photo = append(dst.Photo, p)
|
if dst.Photo, err = url.Parse(v); err != nil {
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsUrl(src []*url.URL, find *url.URL) bool {
|
|
||||||
for i := range src {
|
|
||||||
if src[i].String() != find.String() {
|
|
||||||
continue
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
@ -39,14 +40,15 @@ func TestGet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
user := domain.TestUser(t)
|
user := domain.TestUser(t)
|
||||||
|
user.Me = nil
|
||||||
|
|
||||||
srv := httptest.NewServer(testHandler(t, user))
|
srv := httptest.NewServer(testHandler(t, user))
|
||||||
t.Cleanup(srv.Close)
|
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()).
|
result, err := repository.NewHTTPUserRepository(srv.Client()).
|
||||||
Get(context.Background(), *user.Me)
|
Get(context.Background(), *domain.TestMe(t, srv.URL+"/"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -70,12 +72,12 @@ func testHandler(tb testing.TB, user *domain.User) http.Handler {
|
||||||
`<` + user.TokenEndpoint.String() + `>; rel="token_endpoint"`,
|
`<` + user.TokenEndpoint.String() + `>; rel="token_endpoint"`,
|
||||||
}, ", "))
|
}, ", "))
|
||||||
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
|
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) {
|
mux.HandleFunc(user.IndieAuthMetadata.Path, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
|
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
|
||||||
fmt.Fprint(w, `{
|
fmt.Fprint(w, `{
|
||||||
"issuer": "`+user.Me.String()+`",
|
"issuer": "https://auth.example.com/",
|
||||||
"authorization_endpoint": "`+user.AuthorizationEndpoint.String()+`",
|
"authorization_endpoint": "`+user.AuthorizationEndpoint.String()+`",
|
||||||
"token_endpoint": "`+user.TokenEndpoint.String()+`"
|
"token_endpoint": "`+user.TokenEndpoint.String()+`"
|
||||||
}`)
|
}`)
|
||||||
|
|
Loading…
Reference in New Issue