🔀 Merge branch 'feature/refactoring' into develop

This commit is contained in:
Maxim Lebedev 2023-08-07 09:11:05 +06:00
commit e94159bc80
Signed by: toby3d
GPG key ID: 1F14E25B7C119FC5
46 changed files with 575 additions and 1826 deletions

View file

@ -268,18 +268,8 @@ func (h *Handler) handleExchange(w http.ResponseWriter, r *http.Request) {
return
}
var userInfo *AuthProfileResponse
if profile != nil {
userInfo = &AuthProfileResponse{
Email: profile.GetEmail(),
Photo: &domain.URL{URL: profile.GetPhoto()},
URL: &domain.URL{URL: profile.GetURL()},
Name: profile.GetName(),
}
}
_ = encoder.Encode(&AuthExchangeResponse{
Me: *me,
Profile: userInfo,
Me: me.String(),
Profile: NewAuthProfileResponse(profile),
})
}

View file

@ -79,15 +79,15 @@ type (
}
AuthExchangeResponse struct {
Me domain.Me `json:"me"`
Profile *AuthProfileResponse `json:"profile,omitempty"`
Me string `json:"me"`
}
AuthProfileResponse struct {
Email *domain.Email `json:"email,omitempty"`
Photo *domain.URL `json:"photo,omitempty"`
URL *domain.URL `json:"url,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Photo string `json:"photo,omitempty"`
URL string `json:"url,omitempty"`
Name string `json:"name,omitempty"`
}
)
@ -192,3 +192,27 @@ func (r *AuthExchangeRequest) bind(req *http.Request) error {
return nil
}
func NewAuthProfileResponse(in *domain.Profile) *AuthProfileResponse {
out := new(AuthProfileResponse)
if in == nil {
return out
}
out.Name = in.Name
if in.URL != nil {
out.URL = in.URL.String()
}
if in.Email != nil {
out.Email = in.Email.String()
}
if in.Photo != nil {
out.Photo = in.Photo.String()
}
return out
}

View file

@ -31,13 +31,14 @@ type Dependencies struct {
authService auth.UseCase
clients client.Repository
clientService client.UseCase
config *domain.Config
matcher language.Matcher
profiles profile.Repository
sessions session.Repository
users user.Repository
config *domain.Config
}
//nolint:funlen
func TestAuthorize(t *testing.T) {
t.Parallel()
@ -98,7 +99,7 @@ func TestAuthorize(t *testing.T) {
t.Errorf("%s %s = %d, want %d", req.Method, u.String(), resp.StatusCode, http.StatusOK)
}
expResult := `Authorize ` + client.GetName()
expResult := `Authorize ` + client.Name
if result := string(body); !strings.Contains(result, expResult) {
t.Errorf("%s %s = %s, want %s", req.Method, u.String(), result, expResult)
}
@ -113,7 +114,7 @@ func NewDependencies(tb testing.TB) Dependencies {
users := userrepo.NewMemoryUserRepository()
sessions := sessionrepo.NewMemorySessionRepository(*config)
profiles := profilerepo.NewMemoryProfileRepository()
authService := ucase.NewAuthUseCase(sessions, profiles, config)
authService := ucase.NewAuthUseCase(sessions, profiles, *config)
clientService := clientucase.NewClientUseCase(clients)
return Dependencies{

View file

@ -12,13 +12,13 @@ import (
)
type authUseCase struct {
config *domain.Config
sessions session.Repository
profiles profile.Repository
config domain.Config
}
// NewAuthUseCase creates a new authentication use case.
func NewAuthUseCase(sessions session.Repository, profiles profile.Repository, config *domain.Config) auth.UseCase {
func NewAuthUseCase(sessions session.Repository, profiles profile.Repository, config domain.Config) auth.UseCase {
return &authUseCase{
config: config,
sessions: sessions,

View file

@ -58,7 +58,7 @@ func NewDependencies(tb testing.TB) Dependencies {
tokens := tokenrepo.NewMemoryTokenRepository()
profiles := profilerepo.NewMemoryProfileRepository()
tokenService := tokenucase.NewTokenUseCase(tokenucase.Config{
Config: config,
Config: *config,
Profiles: profiles,
Sessions: sessions,
Tokens: tokens,

View file

@ -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
}
}

View file

@ -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])
})
}

View file

@ -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"

View file

@ -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]
}

View file

@ -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])
}
}

View file

@ -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]
}

View file

@ -10,7 +10,7 @@ type Ticket struct {
Resource *url.URL
// The access token should be used when acting on behalf of this URL.
Subject *Me
Subject Me
// A random string that can be redeemed for an access token.
Ticket string
@ -22,7 +22,7 @@ func TestTicket(tb testing.TB) *Ticket {
return &Ticket{
Resource: &url.URL{Scheme: "https", Host: "alice.example.com", Path: "/private/"},
Subject: TestMe(tb, "https://bob.example.com/"),
Subject: *TestMe(tb, "https://bob.example.com/"),
Ticket: "32985723984723985792834",
}
}

View file

@ -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
}

View file

@ -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"})
}
}

View file

@ -42,8 +42,6 @@ type (
}
)
const relIndieauthMetadata = "indieauth-metadata"
func NewHTTPMetadataRepository(client *http.Client) metadata.Repository {
return &httpMetadataRepository{
client: client,

View file

@ -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")

View file

@ -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
}
}

View file

@ -1,198 +0,0 @@
package http
import (
"fmt"
"net/http"
"github.com/goccy/go-json"
"github.com/lestrrat-go/jwx/v2/jwa"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/middleware"
"source.toby3d.me/toby3d/auth/internal/random"
"source.toby3d.me/toby3d/auth/internal/ticket"
"source.toby3d.me/toby3d/auth/internal/urlutil"
"source.toby3d.me/toby3d/auth/web"
)
type Handler struct {
matcher language.Matcher
tickets ticket.UseCase
config domain.Config
}
func NewHandler(tickets ticket.UseCase, matcher language.Matcher, config domain.Config) *Handler {
return &Handler{
config: config,
matcher: matcher,
tickets: tickets,
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//nolint:exhaustivestruct
chain := middleware.Chain{
middleware.CSRFWithConfig(middleware.CSRFConfig{
Skipper: func(_ http.ResponseWriter, r *http.Request) bool {
head, _ := urlutil.ShiftPath(r.URL.Path)
return r.Method == http.MethodPost && head == "ticket"
},
CookieMaxAge: 0,
CookieSameSite: http.SameSiteStrictMode,
ContextKey: "csrf",
CookieDomain: h.config.Server.Domain,
CookieName: "__Secure-csrf",
CookiePath: "/ticket",
TokenLookup: "form:_csrf",
TokenLength: 0,
CookieSecure: true,
CookieHTTPOnly: true,
}),
middleware.JWTWithConfig(middleware.JWTConfig{
AuthScheme: "Bearer",
BeforeFunc: nil,
Claims: nil,
ContextKey: "token",
ErrorHandler: nil,
ErrorHandlerWithContext: nil,
ParseTokenFunc: nil,
SigningKey: []byte(h.config.JWT.Secret),
SigningKeys: nil,
SigningMethod: jwa.SignatureAlgorithm(h.config.JWT.Algorithm),
Skipper: middleware.DefaultSkipper,
SuccessHandler: nil,
TokenLookup: "header:" + common.HeaderAuthorization +
",cookie:__Secure-auth-token",
}),
}
chain.Handler(h.handleFunc).ServeHTTP(w, r)
}
func (h *Handler) handleFunc(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = urlutil.ShiftPath(r.URL.Path)
switch r.Method {
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
case "", http.MethodGet:
if head != "" {
http.NotFound(w, r)
return
}
h.handleRender(w, r)
case http.MethodPost:
switch head {
default:
http.NotFound(w, r)
case "":
h.handleRedeem(w, r)
case "send":
h.handleSend(w, r)
}
}
}
func (h *Handler) handleRender(w http.ResponseWriter, r *http.Request) {
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
tags, _, _ := language.ParseAcceptLanguage(r.Header.Get(common.HeaderAcceptLanguage))
tag, _, _ := h.matcher.Match(tags...)
baseOf := web.BaseOf{
Config: &h.config,
Language: tag,
Printer: message.NewPrinter(tag),
}
csrf, _ := r.Context().Value("csrf").([]byte)
web.WriteTemplate(w, &web.TicketPage{
BaseOf: baseOf,
CSRF: csrf,
})
}
func (h *Handler) handleSend(w http.ResponseWriter, r *http.Request) {
w.Header().Set(common.HeaderAccessControlAllowOrigin, h.config.Server.Domain)
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
encoder := json.NewEncoder(w)
req := new(TicketGenerateRequest)
if err := req.bind(r); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
ticket := &domain.Ticket{
Ticket: "",
Resource: req.Resource.URL,
Subject: &req.Subject,
}
var err error
if ticket.Ticket, err = random.String(h.config.TicketAuth.Length); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = encoder.Encode(domain.NewError(domain.ErrorCodeServerError, err.Error(), ""))
return
}
if err = h.tickets.Generate(r.Context(), *ticket); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = encoder.Encode(domain.NewError(domain.ErrorCodeServerError, err.Error(), ""))
return
}
w.WriteHeader(http.StatusOK)
}
func (h *Handler) handleRedeem(w http.ResponseWriter, r *http.Request) {
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
encoder := json.NewEncoder(w)
req := new(TicketExchangeRequest)
if err := req.bind(r); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
token, err := h.tickets.Redeem(r.Context(), domain.Ticket{
Ticket: req.Ticket,
Resource: req.Resource.URL,
Subject: &req.Subject,
})
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = encoder.Encode(domain.NewError(domain.ErrorCodeServerError, err.Error(), ""))
return
}
// TODO(toby3d): print the result as part of the debugging. Instead, we
// need to send or save the token to the recipient for later use.
fmt.Fprintf(w, `{
"access_token": "%s",
"token_type": "Bearer",
"scope": "%s",
"me": "%s"
}`, token.AccessToken, token.Scope.String(), token.Me.String())
w.WriteHeader(http.StatusOK)
}

View file

@ -1,90 +0,0 @@
package http
import (
"errors"
"net/http"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/form"
)
type (
TicketGenerateRequest struct {
// The access token should be used when acting on behalf of this URL.
Subject domain.Me `form:"subject"`
// The access token will work at this URL.
Resource domain.URL `form:"resource"`
}
TicketExchangeRequest struct {
// The access token should be used when acting on behalf of this URL.
Subject domain.Me `form:"subject"`
// The access token will work at this URL.
Resource domain.URL `form:"resource"`
// A random string that can be redeemed for an access token.
Ticket string `form:"ticket"`
}
)
func (r *TicketGenerateRequest) bind(req *http.Request) error {
indieAuthError := new(domain.Error)
if err := req.ParseForm(); err != nil {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#authorization-request",
)
}
if err := form.Unmarshal([]byte(req.PostForm.Encode()), r); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
return nil
}
func (r *TicketExchangeRequest) bind(req *http.Request) error {
indieAuthError := new(domain.Error)
if err := req.ParseForm(); err != nil {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#authorization-request",
)
}
if err := form.Unmarshal([]byte(req.PostForm.Encode()), r); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
if r.Ticket == "" {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"ticket parameter is required",
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
return nil
}

View file

@ -1,121 +0,0 @@
package http_test
/* TODO(toby3d): move CSRF middleware into main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/ticket"
delivery "source.toby3d.me/toby3d/auth/internal/ticket/delivery/http"
ticketrepo "source.toby3d.me/toby3d/auth/internal/ticket/repository/memory"
ucase "source.toby3d.me/toby3d/auth/internal/ticket/usecase"
)
type Dependencies struct {
server *httptest.Server
client *http.Client
config *domain.Config
matcher language.Matcher
store *sync.Map
ticket *domain.Ticket
tickets ticket.Repository
ticketService ticket.UseCase
token *domain.Token
}
func TestUpdate(t *testing.T) {
t.Parallel()
deps := NewDependencies(t)
t.Cleanup(deps.server.Close)
req := httptest.NewRequest(http.MethodPost, "https://example.com/", strings.NewReader(
`ticket=`+deps.ticket.Ticket+
`&resource=`+deps.ticket.Resource.String()+
`&subject=`+deps.ticket.Subject.String(),
))
req.Header.Set(common.HeaderContentType, common.MIMEApplicationForm)
deps.token.SetAuthHeader(req)
w := httptest.NewRecorder()
delivery.NewHandler(deps.ticketService, deps.matcher, *deps.config).
Handler().
ServeHTTP(w, req)
domain.TestToken(t).SetAuthHeader(req)
resp := w.Result()
if resp.StatusCode != http.StatusOK &&
resp.StatusCode != http.StatusAccepted {
t.Errorf("%s %s = %d, want %d or %d", req.Method, req.RequestURI, resp.StatusCode, http.StatusOK,
http.StatusAccepted)
}
// TODO(toby3d): print the result as part of the debugging. Instead, you
// need to send or save the token to the recipient for later use.
if resp.Body == nil {
t.Errorf("%s %s = nil, want not nil", req.Method, req.RequestURI)
}
}
func NewDependencies(tb testing.TB) Dependencies {
tb.Helper()
config := domain.TestConfig(tb)
matcher := language.NewMatcher(message.DefaultCatalog.Languages())
store := new(sync.Map)
ticket := domain.TestTicket(tb)
token := domain.TestToken(tb)
mux := http.NewServeMux()
// NOTE(toby3d): private resource
mux.HandleFunc(ticket.Resource.Path, func(w http.ResponseWriter, r *http.Request) {