🔀 Merge branch 'feature/refactoring' into develop
This commit is contained in:
commit
e94159bc80
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
return &httpMetadataRepository{
|
||||
client: client,
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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) {
|
||||
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
|
||||
fmt.Fprintf(w, `<link rel="token_endpoint" href="https://auth.example.org/token">`)
|
||||
})
|
||||
// NOTE(toby3d): token endpoint
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
|
||||
fmt.Fprintf(w, `{
|
||||
"access_token": "`+token.AccessToken+`",
|
||||
"me": "`+token.Me.String()+`",
|
||||
"scope": "`+token.Scope.String()+`",
|
||||
"token_type": "Bearer"
|
||||
}`)
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
client := server.Client()
|
||||
tickets := ticketrepo.NewMemoryTicketRepository(store, config)
|
||||
ticketService := ucase.NewTicketUseCase(tickets, client, config)
|
||||
|
||||
return Dependencies{
|
||||
server: server,
|
||||
client: client,
|
||||
config: config,
|
||||
matcher: matcher,
|
||||
store: store,
|
||||
ticket: ticket,
|
||||
tickets: tickets,
|
||||
ticketService: ticketService,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
*/
|
|
@ -1,15 +0,0 @@
|
|||
package ticket
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, ticket domain.Ticket) error
|
||||
GetAndDelete(ctx context.Context, ticket string) (*domain.Ticket, error)
|
||||
GC()
|
||||
}
|
||||
|
||||
var ErrNotExist error = domain.NewError(domain.ErrorCodeInvalidRequest, "ticket not exist or expired", "")
|
|
@ -1,86 +0,0 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||
"source.toby3d.me/toby3d/auth/internal/ticket"
|
||||
)
|
||||
|
||||
type (
|
||||
Ticket struct {
|
||||
CreatedAt time.Time
|
||||
domain.Ticket
|
||||
}
|
||||
|
||||
memoryTicketRepository struct {
|
||||
mutex *sync.RWMutex
|
||||
tickets map[string]Ticket
|
||||
config domain.Config
|
||||
}
|
||||
)
|
||||
|
||||
func NewMemoryTicketRepository(config domain.Config) ticket.Repository {
|
||||
return &memoryTicketRepository{
|
||||
config: config,
|
||||
mutex: new(sync.RWMutex),
|
||||
tickets: make(map[string]Ticket),
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *memoryTicketRepository) Create(_ context.Context, t domain.Ticket) error {
|
||||
repo.mutex.Lock()
|
||||
defer repo.mutex.Unlock()
|
||||
|
||||
repo.tickets[t.Ticket] = Ticket{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Ticket: t,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *memoryTicketRepository) GetAndDelete(_ context.Context, t string) (*domain.Ticket, error) {
|
||||
repo.mutex.RLock()
|
||||
|
||||
out, ok := repo.tickets[t]
|
||||
if !ok {
|
||||
repo.mutex.RUnlock()
|
||||
|
||||
return nil, ticket.ErrNotExist
|
||||
}
|
||||
|
||||
repo.mutex.RUnlock()
|
||||
repo.mutex.Lock()
|
||||
delete(repo.tickets, t)
|
||||
repo.mutex.Unlock()
|
||||
|
||||
return &out.Ticket, nil
|
||||
}
|
||||
|
||||
func (repo *memoryTicketRepository) GC() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for ts := range ticker.C {
|
||||
ts = ts.UTC()
|
||||
|
||||
repo.mutex.RLock()
|
||||
|
||||
for _, t := range repo.tickets {
|
||||
if t.CreatedAt.Add(repo.config.Code.Expiry).After(ts) {
|
||||
continue
|
||||
}
|
||||
|
||||
repo.mutex.RUnlock()
|
||||
repo.mutex.Lock()
|
||||
delete(repo.tickets, t.Ticket.Ticket)
|
||||
repo.mutex.Unlock()
|
||||
repo.mutex.RLock()
|
||||
}
|
||||
|
||||
repo.mutex.RUnlock()
|
||||
}
|
||||
}
|
|
@ -1,122 +0,0 @@
|
|||
package sqlite3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||
"source.toby3d.me/toby3d/auth/internal/ticket"
|
||||
)
|
||||
|
||||
type (
|
||||
Ticket struct {
|
||||
Resource string `db:"resource"`
|
||||
Subject string `db:"subject"`
|
||||
Ticket string `db:"ticket"`
|
||||
CreatedAt sql.NullTime `db:"created_at"`
|
||||
}
|
||||
|
||||
sqlite3TicketRepository struct {
|
||||
config *domain.Config
|
||||
db *sqlx.DB
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
QueryTable string = `CREATE TABLE IF NOT EXISTS tickets (
|
||||
created_at DATETIME NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
ticket TEXT UNIQUE PRIMARY KEY NOT NULL
|
||||
);`
|
||||
|
||||
QueryGet string = `SELECT *
|
||||
FROM tickets
|
||||
WHERE ticket=$1;`
|
||||
|
||||
QueryCreate string = `INSERT INTO tickets (created_at, resource, subject, ticket)
|
||||
VALUES (:created_at, :resource, :subject, :ticket);`
|
||||
|
||||
QueryDelete string = `DELETE FROM tickets
|
||||
WHERE ticket=$1;`
|
||||
)
|
||||
|
||||
func NewSQLite3TicketRepository(db *sqlx.DB, config *domain.Config) ticket.Repository {
|
||||
db.MustExec(QueryTable)
|
||||
|
||||
return &sqlite3TicketRepository{
|
||||
config: config,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *sqlite3TicketRepository) Create(ctx context.Context, t domain.Ticket) error {
|
||||
if _, err := repo.db.NamedExecContext(ctx, QueryCreate, NewTicket(&t)); err != nil {
|
||||
return fmt.Errorf("cannot create token record in db: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *sqlite3TicketRepository) GetAndDelete(ctx context.Context, rawTicket string) (*domain.Ticket, error) {
|
||||
tx, err := repo.db.Beginx()
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
tkt := new(Ticket)
|
||||
if err = tx.GetContext(ctx, tkt, QueryGet, rawTicket); err != nil {
|
||||
//nolint:errcheck // deffered method
|
||||
defer tx.Rollback()
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ticket.ErrNotExist
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot find ticket in db: %w", err)
|
||||
}
|
||||
|
||||
if _, err = tx.ExecContext(ctx, QueryDelete, rawTicket); err != nil {
|
||||
_ = tx.Rollback()
|
||||
|
||||
return nil, fmt.Errorf("cannot remove ticket from db: %w", err)
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
result := new(domain.Ticket)
|
||||
|
||||
tkt.Populate(result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (repo *sqlite3TicketRepository) GC() {}
|
||||
|
||||
func NewTicket(src *domain.Ticket) *Ticket {
|
||||
return &Ticket{
|
||||
CreatedAt: sql.NullTime{
|
||||
Time: time.Now().UTC(),
|
||||
Valid: true,
|
||||
},
|
||||
Resource: src.Resource.String(),
|
||||
Subject: src.Subject.String(),
|
||||
Ticket: src.Ticket,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Ticket) Populate(dst *domain.Ticket) {
|
||||
dst.Ticket = t.Ticket
|
||||
dst.Subject, _ = domain.ParseMe(t.Subject)
|
||||
dst.Resource, _ = url.Parse(t.Resource)
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
package sqlite3_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||
"source.toby3d.me/toby3d/auth/internal/testing/sqltest"
|
||||
repository "source.toby3d.me/toby3d/auth/internal/ticket/repository/sqlite3"
|
||||
)
|
||||
|
||||
// nolint: gochecknoglobals // slices cannot be contants
|
||||
var tableColumns = []string{"created_at", "resource", "subject", "ticket"}
|
||||
|
||||
func TestCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ticket := domain.TestTicket(t)
|
||||
model := repository.NewTicket(ticket)
|
||||
db, mock, cleanup := sqltest.Open(t)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
createTable(t, mock)
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO tickets`)).
|
||||
WithArgs(
|
||||
sqltest.Time{},
|
||||
model.Resource,
|
||||
model.Subject,
|
||||
model.Ticket,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
if err := repository.NewSQLite3TicketRepository(db, domain.TestConfig(t)).
|
||||
Create(context.Background(), *ticket); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAndDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ticket := domain.TestTicket(t)
|
||||
model := repository.NewTicket(ticket)
|
||||
db, mock, cleanup := sqltest.Open(t)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
createTable(t, mock)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM tickets`)).
|
||||
WithArgs(model.Ticket).
|
||||
WillReturnRows(sqlmock.NewRows(tableColumns).
|
||||
AddRow(
|
||||
model.CreatedAt.Time,
|
||||
model.Resource,
|
||||
model.Subject,
|
||||
model.Ticket,
|
||||
))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM tickets`)).
|
||||
WithArgs(model.Ticket).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := repository.NewSQLite3TicketRepository(db, domain.TestConfig(t)).
|
||||
GetAndDelete(context.Background(), ticket.Ticket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.Ticket != ticket.Ticket {
|
||||
t.Errorf("GetAndDelete(%s) = %+v, want %+v", ticket.Ticket, result, ticket)
|
||||
}
|
||||
}
|
||||
|
||||
func createTable(tb testing.TB, mock sqlmock.Sqlmock) {
|
||||
tb.Helper()
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta(repository.QueryTable)).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package ticket
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||
)
|
||||
|
||||
type UseCase interface {
|
||||
Generate(ctx context.Context, ticket domain.Ticket) error
|
||||
|
||||
// Redeem transform received ticket into access token.
|
||||
Redeem(ctx context.Context, ticket domain.Ticket) (*domain.Token, error)
|
||||
Exchange(ctx context.Context, ticket string) (*domain.Token, error)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTicketEndpointNotExist error = domain.NewError(
|
||||
domain.ErrorCodeServerError, "ticket_endpoint not found on ticket resource", "",
|
||||
)
|
||||
ErrTokenEndpointNotExist error = domain.NewError(
|
||||
domain.ErrorCodeServerError, "token_endpoint not found on ticket resource", "",
|
||||
)
|
||||
)
|
|
@ -1,175 +0,0 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"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/ticket"
|
||||
)
|
||||
|
||||
type (
|
||||
//nolint:tagliatelle // https://indieauth.net/source/#access-token-response
|
||||
AccessToken struct {
|
||||
Me *domain.Me `json:"me"`
|
||||
Profile *Profile `json:"profile,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in,omitempty"`
|
||||
}
|
||||
|
||||
Profile struct {
|
||||
Email *domain.Email `json:"email,omitempty"`
|
||||
Photo *domain.URL `json:"photo,omitempty"`
|
||||
URL *domain.URL `json:"url,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
ticketUseCase struct {
|
||||
config *domain.Config
|
||||
client *http.Client
|
||||
tickets ticket.Repository
|
||||
}
|
||||
)
|
||||
|
||||
func NewTicketUseCase(tickets ticket.Repository, client *http.Client, config *domain.Config) ticket.UseCase {
|
||||
return &ticketUseCase{
|
||||
client: client,
|
||||
tickets: tickets,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (useCase *ticketUseCase) Generate(ctx context.Context, tkt domain.Ticket) error {
|
||||
resp, err := useCase.client.Get(tkt.Subject.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot discovery ticket subject: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
buf := bytes.NewReader(body)
|
||||
ticketEndpoint := new(url.URL)
|
||||
|
||||
// NOTE(toby3d): find metadata first
|
||||
metadata, err := httputil.ExtractFromMetadata(useCase.client, tkt.Subject.String())
|
||||
if err == nil && metadata != nil {
|
||||
ticketEndpoint = metadata.TicketEndpoint
|
||||
} else { // NOTE(toby3d): fallback to old links searching
|
||||
endpoints := httputil.ExtractEndpoints(buf, tkt.Subject.URL(), resp.Header.Get(common.HeaderLink),
|
||||
"ticket_endpoint")
|
||||
if len(endpoints) > 0 {
|
||||
ticketEndpoint = endpoints[len(endpoints)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if ticketEndpoint == nil {
|
||||
return ticket.ErrTicketEndpointNotExist
|
||||
}
|
||||
|
||||
if err = useCase.tickets.Create(ctx, tkt); err != nil {
|
||||
return fmt.Errorf("cannot save ticket in store: %w", err)
|
||||
}
|
||||
|
||||
payload := make(url.Values)
|
||||
payload.Set("ticket", tkt.Ticket)
|
||||
payload.Set("subject", tkt.Subject.String())
|
||||
payload.Set("resource", tkt.Resource.String())
|
||||
|
||||
if _, err = useCase.client.PostForm(ticketEndpoint.String(), payload); err != nil {
|
||||
return fmt.Errorf("cannot send ticket to subject ticket_endpoint: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (useCase *ticketUseCase) Redeem(ctx context.Context, tkt domain.Ticket) (*domain.Token, error) {
|
||||
resp, err := useCase.client.Get(tkt.Resource.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot discovery ticket resource: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read response body: %w", err)
|
||||
}
|
||||
|
||||
buf := bytes.NewReader(body)
|
||||
tokenEndpoint := new(url.URL)
|
||||
|
||||
// NOTE(toby3d): find metadata first
|
||||
metadata, err := httputil.ExtractFromMetadata(useCase.client, tkt.Resource.String())
|
||||
if err == nil && metadata != nil {
|
||||
tokenEndpoint = metadata.TokenEndpoint
|
||||
} else { // NOTE(toby3d): fallback to old links searching
|
||||
endpoints := httputil.ExtractEndpoints(buf, tkt.Resource, resp.Header.Get(common.HeaderLink),
|
||||
"token_endpoint")
|
||||
if len(endpoints) > 0 {
|
||||
tokenEndpoint = endpoints[len(endpoints)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if tokenEndpoint == nil || tokenEndpoint.String() == "" {
|
||||
return nil, ticket.ErrTokenEndpointNotExist
|
||||
}
|
||||
|
||||
payload := make(url.Values)
|
||||
payload.Set("grant_type", domain.GrantTypeTicket.String())
|
||||
payload.Set("ticket", tkt.Ticket)
|
||||
|
||||
resp, err = useCase.client.PostForm(tokenEndpoint.String(), payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot exchange ticket on token_endpoint: %w", err)
|
||||
}
|
||||
|
||||
data := new(AccessToken)
|
||||
if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
|
||||
return nil, fmt.Errorf("cannot unmarshal access token response: %w", err)
|
||||
}
|
||||
|
||||
return &domain.Token{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Expiry: time.Unix(data.ExpiresIn, 0),
|
||||
Scope: nil, // TODO(toby3d)
|
||||
// TODO(toby3d): should this also include client_id?
|
||||
// https://github.com/indieweb/indieauth/issues/85
|
||||
ClientID: domain.ClientID{},
|
||||
Me: *data.Me,
|
||||
AccessToken: data.AccessToken,
|
||||
RefreshToken: "", // TODO(toby3d)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (useCase *ticketUseCase) Exchange(ctx context.Context, ticket string) (*domain.Token, error) {
|
||||
tkt, err := useCase.tickets.GetAndDelete(ctx, ticket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot find provided ticket: %w", err)
|
||||
}
|
||||
|
||||
token, err := domain.NewToken(domain.NewTokenOptions{
|
||||
Expiration: useCase.config.JWT.Expiry,
|
||||
Scope: domain.Scopes{domain.ScopeRead},
|
||||
Issuer: domain.ClientID{},
|
||||
Subject: *tkt.Subject,
|
||||
Secret: []byte(useCase.config.JWT.Secret),
|
||||
Algorithm: useCase.config.JWT.Algorithm,
|
||||
NonceLength: useCase.config.JWT.NonceLength,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate a new access token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"source.toby3d.me/toby3d/auth/internal/common"
|
||||
"source.toby3d.me/toby3d/auth/internal/domain"
|
||||
ucase "source.toby3d.me/toby3d/auth/internal/ticket/usecase"
|
||||
)
|
||||
|
||||
func TestRedeem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := domain.TestToken(t)
|
||||
ticket := domain.TestTicket(t)
|
||||
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
|
||||
fmt.Fprintf(w, `{
|
||||
"token_type": "Bearer",
|
||||
"access_token": "%s",
|
||||
"scope": "%s",
|
||||
"me": "%s"
|
||||
}`, token.AccessToken, token.Scope.String(), token.Me.String())
|
||||
}))
|
||||
t.Cleanup(tokenServer.Close)
|
||||
|
||||
subjectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
|
||||
fmt.Fprint(w, `<link rel="token_endpoint" href="`+tokenServer.URL+`/token">`)
|
||||
}))
|
||||
t.Cleanup(subjectServer.Close)
|
||||
|
||||
ticket.Resource, _ = url.Parse(subjectServer.URL + "/")
|
||||
|
||||
result, err := ucase.NewTicketUseCase(nil, subjectServer.Client(), domain.TestConfig(t)).
|
||||
Redeem(context.Background(), *ticket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.String() != token.String() {
|
||||
t.Errorf("Redeem(%+v) = %s, want %s", ticket, result, token)
|
||||
}
|
||||
}
|
|
@ -9,22 +9,19 @@ import (
|
|||
"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/ticket"
|
||||
"source.toby3d.me/toby3d/auth/internal/token"
|
||||
"source.toby3d.me/toby3d/auth/internal/urlutil"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
config *domain.Config
|
||||
tokens token.UseCase
|
||||
tickets ticket.UseCase
|
||||
config domain.Config
|
||||
tokens token.UseCase
|
||||
}
|
||||
|
||||
func NewHandler(tokens token.UseCase, tickets ticket.UseCase, config *domain.Config) *Handler {
|
||||
func NewHandler(tokens token.UseCase, config domain.Config) *Handler {
|
||||
return &Handler{
|
||||
config: config,
|
||||
tokens: tokens,
|
||||
tickets: tickets,
|
||||
config: config,
|
||||
tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -144,13 +141,10 @@ func (h *Handler) handleAction(w http.ResponseWriter, r *http.Request) {
|
|||
switch action {
|
||||
case domain.ActionRevoke:
|
||||
h.handleRevokation(w, r)
|
||||
case domain.ActionTicket:
|
||||
h.handleTicket(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:funlen
|
||||
func (h *Handler) handleExchange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
|
@ -190,35 +184,10 @@ func (h *Handler) handleExchange(w http.ResponseWriter, r *http.Request) {
|
|||
AccessToken: token.AccessToken,
|
||||
ExpiresIn: token.Expiry.Unix(),
|
||||
Me: token.Me.String(),
|
||||
Profile: nil,
|
||||
Profile: NewTokenProfileResponse(profile),
|
||||
RefreshToken: "", // TODO(toby3d)
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
_ = encoder.Encode(resp)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resp.Profile = &TokenProfileResponse{
|
||||
Name: profile.GetName(),
|
||||
URL: "",
|
||||
Photo: "",
|
||||
Email: "",
|
||||
}
|
||||
|
||||
if url := profile.GetURL(); url != nil {
|
||||
resp.Profile.URL = url.String()
|
||||
}
|
||||
|
||||
if photo := profile.GetPhoto(); photo != nil {
|
||||
resp.Profile.Photo = photo.String()
|
||||
}
|
||||
|
||||
if email := profile.GetEmail(); email != nil {
|
||||
resp.Profile.Email = email.String()
|
||||
}
|
||||
|
||||
_ = encoder.Encode(resp)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
@ -256,44 +225,3 @@ func (h *Handler) handleRevokation(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) handleTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(common.HeaderContentType, common.MIMEApplicationJSONCharsetUTF8)
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
req := new(TokenTicketRequest)
|
||||
if err := req.bind(r); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
_ = encoder.Encode(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tkn, err := h.tickets.Exchange(r.Context(), req.Ticket)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
_ = encoder.Encode(domain.NewError(domain.ErrorCodeInvalidRequest, err.Error(),
|
||||
"https://indieauth.net/source/#request"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = encoder.Encode(&TokenExchangeResponse{
|
||||
AccessToken: tkn.AccessToken,
|
||||
Me: tkn.Me.String(),
|
||||
Profile: nil,
|
||||
ExpiresIn: tkn.Expiry.Unix(),
|
||||
RefreshToken: "", // TODO(toby3d)
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
|
|
@ -37,11 +37,6 @@ type (
|
|||
Token string `form:"token"`
|
||||
}
|
||||
|
||||
TokenTicketRequest struct {
|
||||
Action domain.Action `form:"action"`
|
||||
Ticket string `form:"ticket"`
|
||||
}
|
||||
|
||||
TokenIntrospectRequest struct {
|
||||
Token string `form:"token"`
|
||||
}
|
||||
|
@ -113,6 +108,35 @@ type (
|
|||
TokenRevocationResponse struct{}
|
||||
)
|
||||
|
||||
func NewTokenProfileResponse(in *domain.Profile) *TokenProfileResponse {
|
||||
out := &TokenProfileResponse{
|
||||
Name: "",
|
||||
URL: "",
|
||||
Photo: "",
|
||||
Email: "",
|
||||
}
|
||||
|
||||
if in == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
out.Name = in.Name
|
||||
|
||||
if in.URL != nil {
|
||||
out.URL = in.URL.String()
|
||||
}
|
||||
|
||||
if in.Photo != nil {
|
||||
out.Photo = in.Photo.String()
|
||||
}
|
||||
|
||||
if in.Email != nil {
|
||||
out.Email = in.Email.String()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *TokenExchangeRequest) bind(req *http.Request) error {
|
||||
indieAuthError := new(domain.Error)
|
||||
|
||||
|
@ -165,24 +189,6 @@ func (r *TokenRevocationRequest) bind(req *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *TokenTicketRequest) bind(req *http.Request) error {
|
||||
indieAuthError := new(domain.Error)
|
||||
|
||||
if err := form.Unmarshal([]byte(req.URL.Query().Encode()), r); err != nil {
|
||||
if errors.As(err, indieAuthError) {
|
||||
return indieAuthError
|
||||
}
|
||||
|
||||
return domain.NewError(
|
||||
domain.ErrorCodeInvalidRequest,
|
||||
err.Error(),
|
||||
"https://indieauth.net/source/#request",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TokenIntrospectRequest) bind(req *http.Request) error {
|
||||
indieAuthError := new(domain.Error)
|
||||
|
||||
|
|
|
@ -17,9 +17,6 @@ import (
|
|||
profilerepo "source.toby3d.me/toby3d/auth/internal/profile/repository/memory"
|
||||
"source.toby3d.me/toby3d/auth/internal/session"
|
||||
sessionrepo "source.toby3d.me/toby3d/auth/internal/session/repository/memory"
|
||||
"source.toby3d.me/toby3d/auth/internal/ticket"
|
||||
ticketrepo "source.toby3d.me/toby3d/auth/internal/ticket/repository/memory"
|
||||
ticketucase "source.toby3d.me/toby3d/auth/internal/ticket/usecase"
|
||||
"source.toby3d.me/toby3d/auth/internal/token"
|
||||
delivery "source.toby3d.me/toby3d/auth/internal/token/delivery/http"
|
||||
tokenrepo "source.toby3d.me/toby3d/auth/internal/token/repository/memory"
|
||||
|
@ -27,15 +24,13 @@ import (
|
|||
)
|
||||
|
||||
type Dependencies struct {
|
||||
client *http.Client
|
||||
config *domain.Config
|
||||
profiles profile.Repository
|
||||
sessions session.Repository
|
||||
tickets ticket.Repository
|
||||
ticketService ticket.UseCase
|
||||
token *domain.Token
|
||||
tokens token.Repository
|
||||
tokenService token.UseCase
|
||||
client *http.Client
|
||||
config *domain.Config
|
||||
profiles profile.Repository
|
||||
sessions session.Repository
|
||||
token *domain.Token
|
||||
tokens token.Repository
|
||||
tokenService token.UseCase
|
||||
}
|
||||
|
||||
/* TODO(toby3d)
|
||||
|
@ -55,7 +50,7 @@ func TestIntrospection(t *testing.T) {
|
|||
req.Header.Set(common.HeaderContentType, common.MIMEApplicationForm)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
delivery.NewHandler(deps.tokenService, deps.ticketService, deps.config).
|
||||
delivery.NewHandler(deps.tokenService, *deps.config).
|
||||
ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
|
@ -89,7 +84,7 @@ func TestRevocation(t *testing.T) {
|
|||
req.Header.Set(common.HeaderAccept, common.MIMEApplicationJSON)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
delivery.NewHandler(deps.tokenService, deps.ticketService, deps.config).
|
||||
delivery.NewHandler(deps.tokenService, *deps.config).
|
||||
ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
|
@ -126,25 +121,21 @@ func NewDependencies(tb testing.TB) Dependencies {
|
|||
token := domain.TestToken(tb)
|
||||
profiles := profilerepo.NewMemoryProfileRepository()
|
||||
sessions := sessionrepo.NewMemorySessionRepository(*config)
|
||||
tickets := ticketrepo.NewMemoryTicketRepository(*config)
|
||||
tokens := tokenrepo.NewMemoryTokenRepository()
|
||||
ticketService := ticketucase.NewTicketUseCase(tickets, client, config)
|
||||
tokenService := tokenucase.NewTokenUseCase(tokenucase.Config{
|
||||
Config: config,
|
||||
Config: *config,
|
||||
Profiles: profiles,
|
||||
Sessions: sessions,
|
||||
Tokens: tokens,
|
||||
})
|
||||
|
||||
return Dependencies{
|
||||
client: client,
|
||||
config: config,
|
||||
profiles: profiles,
|
||||
sessions: sessions,
|
||||
tickets: tickets,
|
||||
ticketService: ticketService,
|
||||
token: token,
|
||||
tokens: tokens,
|
||||
tokenService: tokenService,
|
||||
client: client,
|
||||
config: config,
|
||||
profiles: profiles,
|
||||
sessions: sessions,
|
||||
token: token,
|
||||
tokens: tokens,
|
||||
tokenService: tokenService,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,17 +16,17 @@ import (
|
|||
|
||||
type (
|
||||
Config struct {
|
||||
Config *domain.Config
|
||||
Profiles profile.Repository
|
||||
Sessions session.Repository
|
||||
Tokens token.Repository
|
||||
Config domain.Config
|
||||
}
|
||||
|
||||
tokenUseCase struct {
|
||||
config *domain.Config
|
||||
profiles profile.Repository
|
||||
sessions session.Repository
|
||||
tokens token.Repository
|
||||
config domain.Config
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -132,7 +132,7 @@ func (uc *tokenUseCase) Verify(ctx context.Context, accessToken string) (*domain
|
|||
return result, nil, nil //nolint:nilerr // it's okay to return result without profile
|
||||
}
|
||||
|
||||
if !result.Scope.Has(domain.ScopeEmail) && len(profile.Email) > 0 {
|
||||
if !result.Scope.Has(domain.ScopeEmail) && profile.Email != nil {
|
||||
profile.Email = nil
|
||||
}
|
||||
|
||||
|
|
|
@ -45,7 +45,7 @@ func TestExchange(t *testing.T) {
|
|||
}
|
||||
|
||||
tkn, userInfo, err := usecase.NewTokenUseCase(usecase.Config{
|
||||
Config: deps.config,
|
||||
Config: *deps.config,
|
||||
Profiles: deps.profiles,
|
||||
Sessions: deps.sessions,
|
||||
Tokens: deps.tokens,
|
||||
|
@ -68,7 +68,7 @@ func TestVerify(t *testing.T) {
|
|||
|
||||
deps := NewDependencies(t)
|
||||
ucase := usecase.NewTokenUseCase(usecase.Config{
|
||||
Config: deps.config,
|
||||
Config: *deps.config,
|
||||
Profiles: deps.profiles,
|
||||
Sessions: deps.sessions,
|
||||
Tokens: deps.tokens,
|
||||
|
@ -115,7 +115,7 @@ func TestRevoke(t *testing.T) {
|
|||
|
||||
deps := NewDependencies(t)
|
||||
if err := usecase.NewTokenUseCase(usecase.Config{
|
||||
Config: deps.config,
|
||||
Config: *deps.config,
|
||||
Profiles: deps.profiles,
|
||||
Sessions: deps.sessions,
|
||||
Tokens: deps.tokens,
|
||||
|
|
|
@ -14,11 +14,11 @@ import (
|
|||
)
|
||||
|
||||
type Handler struct {
|
||||
config *domain.Config
|
||||
config domain.Config
|
||||
tokens token.UseCase
|
||||
}
|
||||
|
||||
func NewHandler(tokens token.UseCase, config *domain.Config) *Handler {
|
||||
func NewHandler(tokens token.UseCase, config domain.Config) *Handler {
|
||||
return &Handler{
|
||||
tokens: tokens,
|
||||
config: config,
|
||||
|
@ -78,8 +78,7 @@ func (h *Handler) handleFunc(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
//nolint:errchkjson
|
||||
_ = encoder.Encode(NewUserInformationResponse(userInfo,
|
||||
userInfo.HasEmail() && tkn.Scope.Has(domain.ScopeEmail)))
|
||||
_ = encoder.Encode(NewUserInformationResponse(userInfo, tkn.Scope.Has(domain.ScopeEmail)))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ package http
|
|||
import "source.toby3d.me/toby3d/auth/internal/domain"
|
||||
|
||||
type UserInformationResponse struct {
|
||||
URL *domain.URL `json:"url,omitempty"`
|
||||
Photo *domain.URL `json:"photo,omitempty"`
|
||||
Email *domain.Email `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Photo string `json:"photo,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
func NewUserInformationResponse(in *domain.Profile, hasEmail bool) *UserInformationResponse {
|
||||
|
@ -16,20 +16,18 @@ func NewUserInformationResponse(in *domain.Profile, hasEmail bool) *UserInformat
|
|||
return out
|
||||
}
|
||||
|
||||
if in.HasName() {
|
||||
out.Name = in.GetName()
|
||||
out.Name = in.Name
|
||||
|
||||
if in.URL != nil {
|
||||
out.URL = in.URL.String()
|
||||
}
|
||||
|
||||
if in.HasURL() {
|
||||
out.URL = &domain.URL{URL: in.GetURL()}
|
||||
if in.Photo != nil {
|
||||
out.Photo = in.Photo.String()
|
||||
}
|
||||
|
||||
if in.HasPhoto() {
|
||||
out.Photo = &domain.URL{URL: in.GetPhoto()}
|
||||
}
|
||||
|
||||
if hasEmail {
|
||||
out.Email = in.GetEmail()
|
||||
if hasEmail && in.Email != nil {
|
||||
out.Email = in.Email.String()
|
||||
}
|
||||
|
||||
return out
|
||||
|
|
|
@ -43,11 +43,10 @@ func TestUserInfo(t *testing.T) {
|
|||
req.Header.Set(common.HeaderAuthorization, "Bearer "+deps.token.AccessToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
delivery.NewHandler(deps.tokenService, deps.config).
|
||||
delivery.NewHandler(deps.tokenService, *deps.config).
|
||||
ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
|
||||
if exp := http.StatusOK; resp.StatusCode != exp {
|
||||
t.Errorf("%s %s = %d, want %d", req.Method, req.RequestURI, resp.StatusCode, exp)
|
||||
}
|
||||
|
@ -57,13 +56,7 @@ func TestUserInfo(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
exp := &delivery.UserInformationResponse{
|
||||
Name: deps.profile.GetName(),
|
||||
URL: &domain.URL{URL: deps.profile.GetURL()},
|
||||
Photo: &domain.URL{URL: deps.profile.GetPhoto()},
|
||||
Email: deps.profile.GetEmail(),
|
||||
}
|
||||
|
||||
exp := delivery.NewUserInformationResponse(deps.profile, true)
|
||||
if diff := cmp.Diff(result, exp, cmp.AllowUnexported(domain.URL{}, domain.Email{})); diff != "" {
|
||||
t.Errorf("%s %s = %+v, want %+v", req.Method, req.RequestURI, result, exp)
|
||||
}
|
||||
|
@ -85,7 +78,7 @@ func NewDependencies(tb testing.TB) Dependencies {
|
|||
token: domain.TestToken(tb),
|
||||
tokens: tokens,
|
||||
tokenService: tokenucase.NewTokenUseCase(tokenucase.Config{
|
||||
Config: config,
|
||||
Config: *config,
|
||||
Profiles: profiles,
|
||||
Sessions: sessions,
|
||||
Tokens: tokens,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()+`"
|
||||
}`)
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "name"
|
||||
"expr": "p.Client.Name"
|
||||
}
|
||||
],
|
||||
"fuzzy": true
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "name"
|
||||
"expr": "p.Client.Name"
|
||||
}
|
||||
],
|
||||
"fuzzy": true
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "name"
|
||||
"expr": "p.Client.Name"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "name"
|
||||
"expr": "p.Client.Name"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
74
main.go
74
main.go
|
@ -47,11 +47,6 @@ import (
|
|||
sessionmemoryrepo "source.toby3d.me/toby3d/auth/internal/session/repository/memory"
|
||||
sessionsqlite3repo "source.toby3d.me/toby3d/auth/internal/session/repository/sqlite3"
|
||||
sessionucase "source.toby3d.me/toby3d/auth/internal/session/usecase"
|
||||
"source.toby3d.me/toby3d/auth/internal/ticket"
|
||||
tickethttpdelivery "source.toby3d.me/toby3d/auth/internal/ticket/delivery/http"
|
||||
ticketmemoryrepo "source.toby3d.me/toby3d/auth/internal/ticket/repository/memory"
|
||||
ticketsqlite3repo "source.toby3d.me/toby3d/auth/internal/ticket/repository/sqlite3"
|
||||
ticketucase "source.toby3d.me/toby3d/auth/internal/ticket/usecase"
|
||||
"source.toby3d.me/toby3d/auth/internal/token"
|
||||
tokenhttpdelivery "source.toby3d.me/toby3d/auth/internal/token/delivery/http"
|
||||
tokenmemoryrepo "source.toby3d.me/toby3d/auth/internal/token/repository/memory"
|
||||
|
@ -67,7 +62,6 @@ type (
|
|||
clients client.UseCase
|
||||
matcher language.Matcher
|
||||
sessions session.UseCase
|
||||
tickets ticket.UseCase
|
||||
profiles profile.UseCase
|
||||
tokens token.UseCase
|
||||
static fs.FS
|
||||
|
@ -77,7 +71,6 @@ type (
|
|||
Client *http.Client
|
||||
Clients client.Repository
|
||||
Sessions session.Repository
|
||||
Tickets ticket.Repository
|
||||
Tokens token.Repository
|
||||
Profiles profile.Repository
|
||||
Static fs.FS
|
||||
|
@ -94,18 +87,14 @@ var (
|
|||
// NOTE(toby3d): write logs in stdout, see: https://12factor.net/logs
|
||||
logger = log.New(os.Stdout, "IndieAuth\t", log.Lmsgprefix|log.LstdFlags|log.LUTC)
|
||||
// NOTE(toby3d): read configuration from environment, see: https://12factor.net/config
|
||||
config = new(domain.Config)
|
||||
indieAuthClient = &domain.Client{
|
||||
ID: domain.ClientID{},
|
||||
Logo: make([]*url.URL, 1),
|
||||
RedirectURI: make([]*url.URL, 1),
|
||||
URL: make([]*url.URL, 1),
|
||||
Name: make([]string, 0),
|
||||
}
|
||||
config = new(domain.Config)
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var cpuProfilePath, memProfilePath string
|
||||
var (
|
||||
indieAuthClient *domain.Client
|
||||
cpuProfilePath, memProfilePath string
|
||||
)
|
||||
|
||||
//go:embed web/static/*
|
||||
var static embed.FS
|
||||
|
@ -116,31 +105,27 @@ func init() {
|
|||
flag.StringVar(&memProfilePath, "memprofile", "", "set path to saving pprof memory profile")
|
||||
flag.Parse()
|
||||
|
||||
if err := env.ParseWithOptions(config, env.Options{Prefix: "AUTH_"}); err != nil {
|
||||
if err := env.ParseWithOptions(config, env.Options{Prefix: "INDIEAUTH_"}); err != nil {
|
||||
logger.Fatalln(err)
|
||||
}
|
||||
|
||||
// NOTE(toby3d): The server instance itself can be as a client.
|
||||
rootURL := config.Server.GetRootURL()
|
||||
indieAuthClient.Name = []string{config.Name}
|
||||
rootUrl, err := url.Parse(config.Server.GetRootURL())
|
||||
if err != nil {
|
||||
logger.Fatalln(err)
|
||||
}
|
||||
|
||||
cid, err := domain.ParseClientID(rootURL)
|
||||
cid, err := domain.ParseClientID(rootUrl.String())
|
||||
if err != nil {
|
||||
logger.Fatalln("fail to read config:", err)
|
||||
}
|
||||
|
||||
indieAuthClient.ID = *cid
|
||||
|
||||
if indieAuthClient.URL[0], err = url.Parse(rootURL); err != nil {
|
||||
logger.Fatalln("cannot parse root URL as client URL:", err)
|
||||
}
|
||||
|
||||
if indieAuthClient.Logo[0], err = url.Parse(rootURL + "icon.svg"); err != nil {
|
||||
logger.Fatalln("cannot parse root URL as client URL:", err)
|
||||
}
|
||||
|
||||
if indieAuthClient.RedirectURI[0], err = url.Parse(rootURL + "callback"); err != nil {
|
||||
logger.Fatalln("cannot parse root URL as client URL:", err)
|
||||
indieAuthClient = &domain.Client{
|
||||
Logo: rootUrl.JoinPath("icon.svg"),
|
||||
URL: rootUrl,
|
||||
ID: *cid,
|
||||
Name: config.Name,
|
||||
RedirectURI: []*url.URL{rootUrl.JoinPath("callback")},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -159,7 +144,6 @@ func main() {
|
|||
default:
|
||||
opts.Tokens = tokenmemoryrepo.NewMemoryTokenRepository()
|
||||
opts.Sessions = sessionmemoryrepo.NewMemorySessionRepository(*config)
|
||||
opts.Tickets = ticketmemoryrepo.NewMemoryTicketRepository(*config)
|
||||
case "sqlite3":
|
||||
store, err := sqlx.Open("sqlite", config.Database.Path)
|
||||
if err != nil {
|
||||
|
@ -172,7 +156,6 @@ func main() {
|
|||
|
||||
opts.Tokens = tokensqlite3repo.NewSQLite3TokenRepository(store)
|
||||
opts.Sessions = sessionsqlite3repo.NewSQLite3SessionRepository(store)
|
||||
opts.Tickets = ticketsqlite3repo.NewSQLite3TicketRepository(store, config)
|
||||
}
|
||||
|
||||
go opts.Sessions.GC()
|
||||
|
@ -180,9 +163,7 @@ func main() {
|
|||
opts.Client = new(http.Client)
|
||||
opts.Clients = clienthttprepo.NewHTTPClientRepository(opts.Client)
|
||||
opts.Profiles = profilehttprepo.NewHTPPClientRepository(opts.Client)
|
||||
|
||||
app := NewApp(opts)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: config.Server.GetAddress(),
|
||||
BaseContext: nil,
|
||||
|
@ -219,7 +200,12 @@ func main() {
|
|||
logger.Printf("started at %s, available at %s", config.Server.GetAddress(),
|
||||
config.Server.GetRootURL())
|
||||
|
||||
err = server.ListenAndServe()
|
||||
if config.Server.CertificateFile != "" && config.Server.KeyFile != "" {
|
||||
err = server.ListenAndServeTLS(config.Server.CertificateFile, config.Server.KeyFile)
|
||||
} else {
|
||||
err = server.ListenAndServe()
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Fatalln("cannot listen and serve:", err)
|
||||
}
|
||||
|
@ -251,14 +237,13 @@ func main() {
|
|||
func NewApp(opts NewAppOptions) *App {
|
||||
return &App{
|
||||
static: opts.Static,
|
||||
auth: authucase.NewAuthUseCase(opts.Sessions, opts.Profiles, config),
|
||||
auth: authucase.NewAuthUseCase(opts.Sessions, opts.Profiles, *config),
|
||||
clients: clientucase.NewClientUseCase(opts.Clients),
|
||||
matcher: language.NewMatcher(message.DefaultCatalog.Languages()),
|
||||
profiles: profileucase.NewProfileUseCase(opts.Profiles),
|
||||
sessions: sessionucase.NewSessionUseCase(opts.Sessions),
|
||||
tickets: ticketucase.NewTicketUseCase(opts.Tickets, opts.Client, config),
|
||||
tokens: tokenucase.NewTokenUseCase(tokenucase.Config{
|
||||
Config: config,
|
||||
Config: *config,
|
||||
Profiles: opts.Profiles,
|
||||
Sessions: opts.Sessions,
|
||||
Tokens: opts.Tokens,
|
||||
|
@ -327,15 +312,14 @@ func (app *App) Handler() http.Handler {
|
|||
Matcher: app.matcher,
|
||||
Profiles: app.profiles,
|
||||
})
|
||||
token := tokenhttpdelivery.NewHandler(app.tokens, app.tickets, config)
|
||||
token := tokenhttpdelivery.NewHandler(app.tokens, *config)
|
||||
client := clienthttpdelivery.NewHandler(clienthttpdelivery.NewHandlerOptions{
|
||||
Client: *indieAuthClient,
|
||||
Config: *config,
|
||||
Matcher: app.matcher,
|
||||
Tokens: app.tokens,
|
||||
})
|
||||
user := userhttpdelivery.NewHandler(app.tokens, config)
|
||||
ticket := tickethttpdelivery.NewHandler(app.tickets, app.matcher, *config)
|
||||
user := userhttpdelivery.NewHandler(app.tokens, *config)
|
||||
staticHandler := http.FileServer(http.FS(app.static))
|
||||
|
||||
return http.HandlerFunc(middleware.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -368,10 +352,6 @@ func (app *App) Handler() http.Handler {
|
|||
r.URL.Path = tail
|
||||
|
||||
user.ServeHTTP(w, r)
|
||||
case "ticket":
|
||||
r.URL.Path = tail
|
||||
|
||||
ticket.ServeHTTP(w, r)
|
||||
}
|
||||
}).Intercept(middleware.LogFmt()))
|
||||
}
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
} %}
|
||||
|
||||
{% func (p *AuthorizePage) title() %}
|
||||
{% if name := p.Client.GetName(); name != "" %}
|
||||
{%= p.t("Authorize %s", name) %}
|
||||
{% if p.Client.Name != "" %}
|
||||
{%= p.t("Authorize %s", p.Client.Name) %}
|
||||
{% else %}
|
||||
{%= p.t("Authorize application") %}
|
||||
{% endif %}
|
||||
|
@ -26,7 +26,7 @@
|
|||
|
||||
{% func (p *AuthorizePage) body() %}
|
||||
<header>
|
||||
{% if p.Client.GetLogo() != nil %}
|
||||
{% if p.Client.Logo != nil %}
|
||||
<img class=""
|
||||
crossorigin="anonymous"
|
||||
decoding="async"
|
||||
|
@ -34,21 +34,21 @@
|
|||
importance="high"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
src="{%s p.Client.GetLogo().String() %}"
|
||||
alt="{%s p.Client.GetName() %}"
|
||||
src="{%s p.Client.Logo.String() %}"
|
||||
alt="{%s p.Client.Name %}"
|
||||
width="140">
|
||||
{% endif %}
|
||||
|
||||
<h2>
|
||||
{% if p.Client.GetURL() != nil %}
|
||||
<a href="{%s p.Client.GetURL().String() %}">
|
||||
{% if p.Client.URL != nil %}
|
||||
<a href="{%s p.Client.URL.String() %}">
|
||||
{% endif %}
|
||||
{% if p.Client.GetName() != "" %}
|
||||
{%s p.Client.GetName() %}
|
||||
{% if p.Client.Name != "" %}
|
||||
{%s p.Client.Name %}
|
||||
{% else %}
|
||||
{%s p.Client.ID.String() %}
|
||||
{% endif %}
|
||||
{% if p.Client.GetURL() != nil %}
|
||||
{% if p.Client.URL != nil %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
|
|
@ -43,12 +43,12 @@ func (p *AuthorizePage) streamtitle(qw422016 *qt422016.Writer) {
|
|||
qw422016.N().S(`
|
||||
`)
|
||||
//line web/authorize.qtpl:20
|
||||
if name := p.Client.GetName(); name != "" {
|
||||
if p.Client.Name != "" {
|
||||
//line web/authorize.qtpl:20
|
||||
qw422016.N().S(`
|
||||
`)
|
||||
//line web/authorize.qtpl:21
|
||||
p.streamt(qw422016, "Authorize %s", name)
|
||||
p.streamt(qw422016, "Authorize %s", p.Client.Name)
|
||||
//line web/authorize.qtpl:21
|
||||
qw422016.N().S(`
|
||||
`)
|
||||
|
@ -103,7 +103,7 @@ func (p *AuthorizePage) streambody(qw422016 *qt422016.Writer) {
|
|||
<header>
|
||||
`)
|
||||
//line web/authorize.qtpl:29
|
||||
if p.Client.GetLogo() != nil {
|
||||
if p.Client.Logo != nil {
|
||||
//line web/authorize.qtpl:29
|
||||
qw422016.N().S(`
|
||||
<img class=""
|
||||
|
@ -115,12 +115,12 @@ func (p *AuthorizePage) streambody(qw422016 *qt422016.Writer) {
|
|||
referrerpolicy="no-referrer-when-downgrade"
|
||||
src="`)
|
||||
//line web/authorize.qtpl:37
|
||||
qw422016.E().S(p.Client.GetLogo().String())
|
||||
qw422016.E().S(p.Client.Logo.String())
|
||||
//line web/authorize.qtpl:37
|
||||
qw422016.N().S(`"
|
||||
alt="`)
|
||||
//line web/authorize.qtpl:38
|
||||
qw422016.E().S(p.Client.GetName())
|
||||
qw422016.E().S(p.Client.Name)
|
||||
//line web/authorize.qtpl:38
|
||||
qw422016.N().S(`"
|
||||
width="140">
|
||||
|
@ -133,12 +133,12 @@ func (p *AuthorizePage) streambody(qw422016 *qt422016.Writer) {
|
|||
<h2>
|
||||
`)
|
||||
//line web/authorize.qtpl:43
|
||||
if p.Client.GetURL() != nil {
|
||||
if p.Client.URL != nil {
|
||||
//line web/authorize.qtpl:43
|
||||
qw422016.N().S(`
|
||||
<a href="`)
|
||||
//line web/authorize.qtpl:44
|
||||
qw422016.E().S(p.Client.GetURL().String())
|
||||
qw422016.E().S(p.Client.URL.String())
|
||||
//line web/authorize.qtpl:44
|
||||
qw422016.N().S(`">
|
||||
`)
|
||||
|
@ -148,12 +148,12 @@ func (p *AuthorizePage) streambody(qw422016 *qt422016.Writer) {
|
|||
qw422016.N().S(`
|
||||
`)
|
||||
//line web/authorize.qtpl:46
|
||||
if p.Client.GetName() != "" {
|
||||
if p.Client.Name != "" {
|
||||
//line web/authorize.qtpl:46
|
||||
qw422016.N().S(`
|
||||
`)
|
||||
//line web/authorize.qtpl:47
|
||||
qw422016.E().S(p.Client.GetName())
|
||||
qw422016.E().S(p.Client.Name)
|
||||
//line web/authorize.qtpl:47
|
||||
qw422016.N().S(`
|
||||
`)
|
||||
|
@ -173,7 +173,7 @@ func (p *AuthorizePage) streambody(qw422016 *qt422016.Writer) {
|
|||
qw422016.N().S(`
|
||||
`)
|
||||
//line web/authorize.qtpl:51
|
||||
if p.Client.GetURL() != nil {
|
||||
if p.Client.URL != nil {
|
||||
//line web/authorize.qtpl:51
|
||||
qw422016.N().S(`
|
||||
</a>
|
||||
|
|
|
@ -19,21 +19,23 @@
|
|||
|
||||
{% func (p *HomePage) body() %}
|
||||
<header class="h-app h-x-app">
|
||||
{% if p.Client.Logo != nil %}
|
||||
<img class="u-logo"
|
||||
src="{%s p.Client.GetLogo().String() %}"
|
||||
alt="{%s p.Client.GetName() %}"
|
||||
src="{%s p.Client.Logo.String() %}"
|
||||
alt="{%s p.Client.Name %}"
|
||||
crossorigin="anonymous"
|
||||
decoding="async"
|
||||
height="140"
|
||||
importance="high"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
width="140">
|
||||
{% endif %}
|
||||
|
||||
<h1>
|
||||
<a class="p-name u-url"
|
||||
href="{%s p.Client.GetURL().String() %}">
|
||||
href="{%s p.Client.URL.String() %}">
|
||||
|
||||
{%s p.Client.GetName() %}
|
||||
{%s p.Client.Name %}
|
||||
</a>
|
||||
</h1>
|
||||
</header>
|
||||
|
|
|
@ -81,24 +81,32 @@ func (p *HomePage) head() string {
|
|||
//line web/home.qtpl:20
|
||||
func (p *HomePage) streambody(qw422016 *qt422016.Writer) {
|
||||
//line web/home.qtpl:20
|
||||
qw422016.N().S(` <header class="h-app h-x-app"> <img class="u-logo" src="`)
|
||||
//line web/home.qtpl:23
|
||||
qw422016.E().S(p.Client.GetLogo().String())
|
||||
//line web/home.qtpl:23
|
||||
qw422016.N().S(`" alt="`)
|
||||
qw422016.N().S(` <header class="h-app h-x-app"> `)
|
||||
//line web/home.qtpl:22
|
||||
if p.Client.Logo != nil {
|
||||
//line web/home.qtpl:22
|
||||
qw422016.N().S(` <img class="u-logo" src="`)
|
||||
//line web/home.qtpl:24
|
||||
qw422016.E().S(p.Client.GetName())
|
||||
qw422016.E().S(p.Client.Logo.String())
|
||||
//line web/home.qtpl:24
|
||||
qw422016.N().S(`" crossorigin="anonymous" decoding="async" height="140" importance="high" referrerpolicy="no-referrer-when-downgrade" width="140"> <h1> <a class="p-name u-url" href="`)
|
||||
//line web/home.qtpl:34
|
||||
qw422016.E().S(p.Client.GetURL().String())
|
||||
//line web/home.qtpl:34
|
||||
qw422016.N().S(`" alt="`)
|
||||
//line web/home.qtpl:25
|
||||
qw422016.E().S(p.Client.Name)
|
||||
//line web/home.qtpl:25
|
||||
qw422016.N().S(`" crossorigin="anonymous" decoding="async" height="140" importance="high" referrerpolicy="no-referrer-when-downgrade" width="140"> `)
|
||||
//line web/home.qtpl:32
|
||||
}
|
||||
//line web/home.qtpl:32
|
||||
qw422016.N().S(` <h1> <a class="p-name u-url" href="`)
|
||||
//line web/home.qtpl:36
|
||||
qw422016.E().S(p.Client.URL.String())
|
||||
//line web/home.qtpl:36
|
||||
qw422016.N().S(`"> `)
|
||||
//line web/home.qtpl:36
|
||||
qw422016.E().S(p.Client.GetName())
|
||||
//line web/home.qtpl:36
|
||||
//line web/home.qtpl:38
|
||||
qw422016.E().S(p.Client.Name)
|
||||
//line web/home.qtpl:38
|
||||
qw422016.N().S(` </a> </h1> </header> <main> <form class="" method="get" action="/authorize" enctype="application/x-www-form-urlencoded" accept-charset="utf-8" target="_self"> `)
|
||||
//line web/home.qtpl:49
|
||||
//line web/home.qtpl:51
|
||||
for name, value := range map[string]string{
|
||||
"client_id": p.Client.ID.String(),
|
||||
"redirect_uri": p.Client.RedirectURI[0].String(),
|
||||
|
@ -106,49 +114,49 @@ func (p *HomePage) streambody(qw422016 *qt422016.Writer) {
|
|||
"scope": domain.Scopes{domain.ScopeEmail, domain.ScopeProfile}.String(),
|
||||
"state": p.State,
|
||||
} {
|
||||
//line web/home.qtpl:55
|
||||
//line web/home.qtpl:57
|
||||
qw422016.N().S(` <input type="hidden" name="`)
|
||||
//line web/home.qtpl:57
|
||||
//line web/home.qtpl:59
|
||||
qw422016.E().S(name)
|
||||
//line web/home.qtpl:57
|
||||
//line web/home.qtpl:59
|
||||
qw422016.N().S(`" value="`)
|
||||
//line web/home.qtpl:58
|
||||
//line web/home.qtpl:60
|
||||
qw422016.E().S(value)
|
||||
//line web/home.qtpl:58
|
||||
//line web/home.qtpl:60
|
||||
qw422016.N().S(`"> `)
|
||||
//line web/home.qtpl:59
|
||||
//line web/home.qtpl:61
|
||||
}
|
||||
//line web/home.qtpl:59
|
||||
//line web/home.qtpl:61
|
||||
qw422016.N().S(` <input type="url" name="me" placeholder="https://example.com/" inputmode="url" autocomplete="url" required> <button type="submit">`)
|
||||
//line web/home.qtpl:68
|
||||
//line web/home.qtpl:70
|
||||
p.streamt(qw422016, "Sign In")
|
||||
//line web/home.qtpl:68
|
||||
//line web/home.qtpl:70
|
||||
qw422016.N().S(`</button> </form> </main> `)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
}
|
||||
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
func (p *HomePage) writebody(qq422016 qtio422016.Writer) {
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
p.streambody(qw422016)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
qt422016.ReleaseWriter(qw422016)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
}
|
||||
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
func (p *HomePage) body() string {
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
qb422016 := qt422016.AcquireByteBuffer()
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
p.writebody(qb422016)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
qs422016 := string(qb422016.B)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
qt422016.ReleaseByteBuffer(qb422016)
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
return qs422016
|
||||
//line web/home.qtpl:71
|
||||
//line web/home.qtpl:73
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue