From 14f4d7d2ef4d6482dc5c6fd0ddad69a8d9591387 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Sat, 29 Jan 2022 22:50:45 +0500 Subject: [PATCH] :art: Format domain package code --- internal/domain/action.go | 36 ++++--- internal/domain/client.go | 16 ++- internal/domain/client_id.go | 41 +++++--- internal/domain/code_challenge_method.go | 50 +++++----- internal/domain/config.go | 15 ++- internal/domain/email.go | 14 +-- internal/domain/error.go | 4 + internal/domain/grant_type.go | 26 ++--- internal/domain/me.go | 25 +++-- internal/domain/provider.go | 108 ++++++++++++++++++++ internal/domain/response_type.go | 36 ++++--- internal/domain/scope.go | 121 +++++++++++++++-------- internal/domain/session.go | 24 ++++- internal/domain/ticket.go | 1 + internal/domain/token.go | 23 +++-- internal/domain/url.go | 15 ++- internal/domain/user.go | 1 + 17 files changed, 401 insertions(+), 155 deletions(-) create mode 100644 internal/domain/provider.go diff --git a/internal/domain/action.go b/internal/domain/action.go index 9b68e65..5c75c23 100644 --- a/internal/domain/action.go +++ b/internal/domain/action.go @@ -1,44 +1,49 @@ package domain import ( - "errors" "fmt" "strconv" "strings" ) +// Action represent action for token endpoint supported by IndieAuth. +// // NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety: // https://threedots.tech/post/safer-enums-in-go/#struct-based-enums type Action struct { - slug string + uid string } //nolint: gochecknoglobals // NOTE(toby3d): structs cannot be constants var ( - ActionUndefined = Action{slug: ""} - ActionRevoke = Action{slug: "revoke"} - ActionTicket = Action{slug: "ticket"} + ActionUndefined = Action{uid: ""} + + // ActionRevoke represent action for revoke token. + ActionRevoke = Action{uid: "revoke"} + + // ActionTicket represent action for TicketAuth extension. + ActionTicket = Action{uid: "ticket"} ) -var ErrActionUnknown = errors.New("unknown action method") +var ErrActionUnknown error = NewError(ErrorCodeInvalidRequest, "unknown action method") // ParseAction parse string identifier of action into struct enum. -func ParseAction(slug string) (Action, error) { - switch strings.ToLower(slug) { - case ActionRevoke.slug: +func ParseAction(uid string) (Action, error) { + switch strings.ToLower(uid) { + case ActionRevoke.uid: return ActionRevoke, nil - case ActionTicket.slug: + case ActionTicket.uid: return ActionTicket, nil } - return ActionUndefined, fmt.Errorf("%w: %s", ErrActionUnknown, slug) + return ActionUndefined, fmt.Errorf("%w: %s", ErrActionUnknown, uid) } // UnmarshalForm implements custom unmarshler for form values. func (a *Action) UnmarshalForm(v []byte) error { action, err := ParseAction(string(v)) if err != nil { - return fmt.Errorf("action: %w", err) + return fmt.Errorf("UnmarshalForm: %w", err) } *a = action @@ -46,15 +51,16 @@ func (a *Action) UnmarshalForm(v []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (a *Action) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } action, err := ParseAction(src) if err != nil { - return fmt.Errorf("action: %w", err) + return fmt.Errorf("UnmarshalJSON: %w", err) } *a = action @@ -64,5 +70,5 @@ func (a *Action) UnmarshalJSON(v []byte) error { // String returns string representation of action. func (a Action) String() string { - return a.slug + return a.uid } diff --git a/internal/domain/client.go b/internal/domain/client.go index 9ad447c..701e13d 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -16,7 +16,18 @@ type Client struct { Name []string } -// TestClient returns a valid Client with the generated test data filled in. +// NewClient creates a new empty Client with provided ClientID, if any. +func NewClient(cid *ClientID) *Client { + return &Client{ + ID: cid, + Logo: make([]*URL, 0), + RedirectURI: make([]*URL, 0), + URL: make([]*URL, 0), + Name: make([]string, 0), + } +} + +// TestClient returns valid random generated client for tests. func TestClient(tb testing.TB) *Client { tb.Helper() @@ -76,6 +87,7 @@ func (c *Client) ValidateRedirectURI(redirectURI *URL) bool { return false } +// GetName safe returns first name, if any. func (c Client) GetName() string { if len(c.Name) < 1 { return "" @@ -84,6 +96,7 @@ func (c Client) GetName() string { return c.Name[0] } +// GetURL safe returns first uRL, if any. func (c Client) GetURL() *URL { if len(c.URL) < 1 { return nil @@ -92,6 +105,7 @@ func (c Client) GetURL() *URL { return c.URL[0] } +// GetLogo safe returns first logo, if any. func (c Client) GetLogo() *URL { if len(c.Logo) < 1 { return nil diff --git a/internal/domain/client_id.go b/internal/domain/client_id.go index fa5eb5a..bb4b1ef 100644 --- a/internal/domain/client_id.go +++ b/internal/domain/client_id.go @@ -25,63 +25,70 @@ var ( localhostIPv6 = netaddr.MustParseIP("::1") ) +// ParseClientID parse string as client ID URL identifier. //nolint: funlen -func ParseClientID(raw string) (*ClientID, error) { - clientID := http.AcquireURI() - if err := clientID.Parse(nil, []byte(raw)); err != nil { +func ParseClientID(src string) (*ClientID, error) { + cid := http.AcquireURI() + if err := cid.Parse(nil, []byte(src)); err != nil { return nil, Error{ Code: ErrorCodeInvalidRequest, Description: err.Error(), URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } - scheme := string(clientID.Scheme()) + scheme := string(cid.Scheme()) if scheme != "http" && scheme != "https" { return nil, Error{ Code: ErrorCodeInvalidRequest, Description: "client identifier URL MUST have either an https or http scheme", URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } - path := string(clientID.PathOriginal()) + path := string(cid.PathOriginal()) if path == "" || strings.Contains(path, "/.") || strings.Contains(path, "/..") { return nil, Error{ Code: ErrorCodeInvalidRequest, Description: "client identifier URL MUST contain a path component and MUST NOT contain " + "single-dot or double-dot path segments", URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } - if clientID.Hash() != nil { + if cid.Hash() != nil { return nil, Error{ Code: ErrorCodeInvalidRequest, Description: "client identifier URL MUST NOT contain a fragment component", URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } - if clientID.Username() != nil || clientID.Password() != nil { + if cid.Username() != nil || cid.Password() != nil { return nil, Error{ Code: ErrorCodeInvalidRequest, Description: "client identifier URL MUST NOT contain a username or password component", URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } - domain := string(clientID.Host()) + domain := string(cid.Host()) if domain == "" { return nil, Error{ Code: ErrorCodeInvalidRequest, Description: "client host name MUST be domain name or a loopback interface", URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } @@ -90,7 +97,9 @@ func ParseClientID(raw string) (*ClientID, error) { if err != nil { ipPort, err := netaddr.ParseIPPort(domain) if err != nil { - return &ClientID{clientID: clientID}, nil + return &ClientID{ + clientID: cid, + }, nil } ip = ipPort.IP() @@ -102,14 +111,17 @@ func ParseClientID(raw string) (*ClientID, error) { Description: "client identifier URL MUST NOT be IPv4 or IPv6 addresses except for IPv4 " + "127.0.0.1 or IPv6 [::1]", URI: "https://indieauth.net/source/#client-identifier", + State: "", frame: xerrors.Caller(1), } } - return &ClientID{clientID: clientID}, nil + return &ClientID{ + clientID: cid, + }, nil } -// TestClientID returns a valid random generated ClientID for tests. +// TestClientID returns valid random generated ClientID for tests. func TestClientID(tb testing.TB) *ClientID { tb.Helper() @@ -119,7 +131,7 @@ func TestClientID(tb testing.TB) *ClientID { return clientID } -// UnmarshalForm implements a custom form.Unmarshaler. +// UnmarshalForm implements custom unmarshler for form values. func (cid *ClientID) UnmarshalForm(v []byte) error { clientID, err := ParseClientID(string(v)) if err != nil { @@ -131,10 +143,11 @@ func (cid *ClientID) UnmarshalForm(v []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (cid *ClientID) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } clientID, err := ParseClientID(src) @@ -147,6 +160,7 @@ func (cid *ClientID) UnmarshalJSON(v []byte) error { return nil } +// MarshalForm implements custom marshler for JSON. func (cid ClientID) MarshalJSON() ([]byte, error) { return []byte(strconv.Quote(cid.String())), nil } @@ -160,6 +174,7 @@ func (cid ClientID) URI() *http.URI { return u } +// URL returns url.URL representation of client ID. func (cid ClientID) URL() *url.URL { return &url.URL{ Scheme: string(cid.clientID.Scheme()), diff --git a/internal/domain/code_challenge_method.go b/internal/domain/code_challenge_method.go index a862680..9c6b371 100644 --- a/internal/domain/code_challenge_method.go +++ b/internal/domain/code_challenge_method.go @@ -6,79 +6,80 @@ import ( "crypto/sha256" "crypto/sha512" "encoding/base64" - "errors" "fmt" "hash" "strconv" "strings" ) +// CodeChallengeMethod represent a PKCE challenge method for validate verifier. +// // NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety: // https://threedots.tech/post/safer-enums-in-go/#struct-based-enums type CodeChallengeMethod struct { hash hash.Hash - slug string + uid string } //nolint: gochecknoglobals // NOTE(toby3d): structs cannot be constants var ( CodeChallengeMethodUndefined = CodeChallengeMethod{ - slug: "", + uid: "", hash: nil, } CodeChallengeMethodPLAIN = CodeChallengeMethod{ - slug: "PLAIN", + uid: "PLAIN", hash: nil, } CodeChallengeMethodMD5 = CodeChallengeMethod{ - slug: "MD5", + uid: "MD5", hash: md5.New(), } CodeChallengeMethodS1 = CodeChallengeMethod{ - slug: "S1", + uid: "S1", hash: sha1.New(), } CodeChallengeMethodS256 = CodeChallengeMethod{ - slug: "S256", + uid: "S256", hash: sha256.New(), } CodeChallengeMethodS512 = CodeChallengeMethod{ - slug: "S512", + uid: "S512", hash: sha512.New(), } ) -var ErrCodeChallengeMethodUnknown = errors.New("unknown code challenge method") +var ErrCodeChallengeMethodUnknown error = NewError(ErrorCodeInvalidRequest, "unknown code_challene_method") //nolint: gochecknoglobals // NOTE(toby3d): maps cannot be constants var slugsMethods = map[string]CodeChallengeMethod{ - CodeChallengeMethodMD5.slug: CodeChallengeMethodMD5, - CodeChallengeMethodPLAIN.slug: CodeChallengeMethodPLAIN, - CodeChallengeMethodS1.slug: CodeChallengeMethodS1, - CodeChallengeMethodS256.slug: CodeChallengeMethodS256, - CodeChallengeMethodS512.slug: CodeChallengeMethodS512, + CodeChallengeMethodMD5.uid: CodeChallengeMethodMD5, + CodeChallengeMethodPLAIN.uid: CodeChallengeMethodPLAIN, + CodeChallengeMethodS1.uid: CodeChallengeMethodS1, + CodeChallengeMethodS256.uid: CodeChallengeMethodS256, + CodeChallengeMethodS512.uid: CodeChallengeMethodS512, } // ParseCodeChallengeMethod parse string identifier of code challenge method // into struct enum. -func ParseCodeChallengeMethod(slug string) (CodeChallengeMethod, error) { - if method, ok := slugsMethods[strings.ToUpper(slug)]; ok { +func ParseCodeChallengeMethod(uid string) (CodeChallengeMethod, error) { + if method, ok := slugsMethods[strings.ToUpper(uid)]; ok { return method, nil } - return CodeChallengeMethodUndefined, fmt.Errorf("%w: %s", ErrCodeChallengeMethodUnknown, slug) + return CodeChallengeMethodUndefined, fmt.Errorf("%w: %s", ErrCodeChallengeMethodUnknown, uid) } // UnmarshalForm implements custom unmarshler for form values. func (ccm *CodeChallengeMethod) UnmarshalForm(v []byte) error { method, err := ParseCodeChallengeMethod(string(v)) if err != nil { - return fmt.Errorf("code_challenge_method: %w", err) + return fmt.Errorf("UnmarshalForm: %w", err) } *ccm = method @@ -86,15 +87,16 @@ func (ccm *CodeChallengeMethod) UnmarshalForm(v []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (ccm *CodeChallengeMethod) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } method, err := ParseCodeChallengeMethod(src) if err != nil { - return fmt.Errorf("code_challenge_method: %w", err) + return fmt.Errorf("UnmarshalJSON: %w", err) } *ccm = method @@ -104,15 +106,17 @@ func (ccm *CodeChallengeMethod) UnmarshalJSON(v []byte) error { // String returns string representation of code challenge method. func (ccm CodeChallengeMethod) String() string { - return ccm.slug + return ccm.uid } +// Validate checks for a match to the verifier with the hashed version of the +// challenge via the chosen method. func (ccm CodeChallengeMethod) Validate(codeChallenge, verifier string) bool { - if ccm.slug == CodeChallengeMethodUndefined.slug { + if ccm.uid == CodeChallengeMethodUndefined.uid { return false } - if ccm.slug == CodeChallengeMethodPLAIN.slug { + if ccm.uid == CodeChallengeMethodPLAIN.uid { return codeChallenge == verifier } diff --git a/internal/domain/config.go b/internal/domain/config.go index df86fca..94e6750 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -62,9 +62,20 @@ type ( Expiry time.Duration `yaml:"expiry"` // 1m Length int `yaml:"length"` // 24 } + + ConfigRelMeAuth struct { + Enabled bool `yaml:"enabled"` // true + Providers []ConfigRelMeAuthProvider `yaml:"providers"` + } + + ConfigRelMeAuthProvider struct { + Type string `yaml:"type"` + ID string `yaml:"id"` + Secret string `yaml:"secret"` + } ) -// TestConfig returns a valid *viper.Viper with the generated test data filled in. +// TestConfig returns a valid config for tests. func TestConfig(tb testing.TB) *Config { tb.Helper() @@ -112,7 +123,7 @@ func (cs ConfigServer) GetAddress() string { return net.JoinHostPort(cs.Host, cs.Port) } -// GetRootURL returns generated from template RootURL. +// GetRootURL returns generated root URL from template RootURL. func (cs ConfigServer) GetRootURL() string { return fasttemplate.ExecuteString(cs.RootURL, `{{`, `}}`, map[string]interface{}{ "domain": cs.Domain, diff --git a/internal/domain/email.go b/internal/domain/email.go index b646ba8..0abbae7 100644 --- a/internal/domain/email.go +++ b/internal/domain/email.go @@ -3,23 +3,17 @@ package domain import ( "strings" "testing" - - "golang.org/x/xerrors" ) +// Email represent email identifier. type Email struct { user string host string } -var ErrEmailInvalid error = Error{ - Code: ErrorCodeInvalidRequest, - Description: "cannot parse email", - URI: "", - State: "", - frame: xerrors.Caller(1), -} +var ErrEmailInvalid error = NewError(ErrorCodeInvalidRequest, "cannot parse email") +// ParseEmail parse strings to email identifier. func ParseEmail(src string) (*Email, error) { parts := strings.Split(strings.TrimPrefix(src, "mailto:"), "@") if len(parts) != 2 { //nolint: gomnd @@ -32,6 +26,7 @@ func ParseEmail(src string) (*Email, error) { }, nil } +// TestEmail returns valid random generated email identifier. func TestEmail(tb testing.TB) *Email { tb.Helper() @@ -41,6 +36,7 @@ func TestEmail(tb testing.TB) *Email { } } +// String returns string representation of email identifier. func (e Email) String() string { return e.user + "@" + e.host } diff --git a/internal/domain/error.go b/internal/domain/error.go index f7bc54e..0bfd70a 100644 --- a/internal/domain/error.go +++ b/internal/domain/error.go @@ -140,6 +140,10 @@ func (e Error) FormatError(p xerrors.Printer) error { p.Print(": ", e.Description) } + if !p.Detail() { + return nil + } + e.frame.Format(p) return nil diff --git a/internal/domain/grant_type.go b/internal/domain/grant_type.go index 3de39e3..e681f7d 100644 --- a/internal/domain/grant_type.go +++ b/internal/domain/grant_type.go @@ -10,31 +10,33 @@ import ( // NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety: // https://threedots.tech/post/safer-enums-in-go/#struct-based-enums type GrantType struct { - slug string + uid string } //nolint: gochecknoglobals // NOTE(toby3d): structs cannot be constants var ( - GrantTypeUndefined = GrantType{slug: ""} - GrantTypeAuthorizationCode = GrantType{slug: "authorization_code"} + GrantTypeUndefined = GrantType{uid: ""} + GrantTypeAuthorizationCode = GrantType{uid: "authorization_code"} // TicketAuth extension. - GrantTypeTicket = GrantType{slug: "ticket"} + GrantTypeTicket = GrantType{uid: "ticket"} ) -var ErrGrantTypeUnknown = errors.New("unknown grant type") +var ErrGrantTypeUnknown error = errors.New("unknown grant type") -func ParseGrantType(slug string) (GrantType, error) { - switch strings.ToLower(slug) { - case GrantTypeAuthorizationCode.slug: +// ParseGrantType parse grant_type value as GrantType struct enum. +func ParseGrantType(uid string) (GrantType, error) { + switch strings.ToLower(uid) { + case GrantTypeAuthorizationCode.uid: return GrantTypeAuthorizationCode, nil - case GrantTypeTicket.slug: + case GrantTypeTicket.uid: return GrantTypeTicket, nil } - return GrantTypeUndefined, fmt.Errorf("%w: %s", ErrGrantTypeUnknown, slug) + return GrantTypeUndefined, fmt.Errorf("%w: %s", ErrGrantTypeUnknown, uid) } +// UnmarshalForm implements custom unmarshler for form values. func (gt *GrantType) UnmarshalForm(src []byte) error { responseType, err := ParseGrantType(string(src)) if err != nil { @@ -46,6 +48,7 @@ func (gt *GrantType) UnmarshalForm(src []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (gt *GrantType) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { @@ -62,6 +65,7 @@ func (gt *GrantType) UnmarshalJSON(v []byte) error { return nil } +// String returns string representation of grant type. func (gt GrantType) String() string { - return gt.slug + return gt.uid } diff --git a/internal/domain/me.go b/internal/domain/me.go index deff435..5780e2e 100644 --- a/internal/domain/me.go +++ b/internal/domain/me.go @@ -18,6 +18,7 @@ type Me struct { me *http.URI } +// ParseMe parse string as me URL identifier. //nolint: funlen func ParseMe(raw string) (*Me, error) { me := http.AcquireURI() @@ -26,6 +27,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: err.Error(), URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -36,6 +38,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: "profile URL MUST have either an https or http scheme", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -47,6 +50,7 @@ func ParseMe(raw string) (*Me, error) { Description: "profile URL MUST contain a path component (/ is a valid path), MUST NOT " + "contain single-dot or double-dot path segments", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -56,6 +60,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: "profile URL MUST NOT contain a fragment component", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -65,6 +70,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: "profile URL MUST NOT contain a username or password component", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -75,6 +81,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: "profile host name MUST be a domain name", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -84,6 +91,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: "profile MUST NOT contain a port", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -93,6 +101,7 @@ func ParseMe(raw string) (*Me, error) { Code: ErrorCodeInvalidRequest, Description: "profile MUST NOT be ipv4 or ipv6 addresses", URI: "https://indieauth.net/source/#user-profile-url", + State: "", frame: xerrors.Caller(1), } } @@ -100,7 +109,7 @@ func ParseMe(raw string) (*Me, error) { return &Me{me: me}, nil } -// TestMe returns a valid random generated Me for tests. +// TestMe returns valid random generated me for tests. func TestMe(tb testing.TB, src string) *Me { tb.Helper() @@ -110,7 +119,7 @@ func TestMe(tb testing.TB, src string) *Me { return me } -// UnmarshalForm parses the value of the form key into the Me domain. +// UnmarshalForm implements custom unmarshler for form values. func (m *Me) UnmarshalForm(v []byte) error { me, err := ParseMe(string(v)) if err != nil { @@ -122,15 +131,16 @@ func (m *Me) UnmarshalForm(v []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (m *Me) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } me, err := ParseMe(src) if err != nil { - return fmt.Errorf("UnmarshalForm: %w", err) + return fmt.Errorf("UnmarshalJSON: %w", err) } *m = *me @@ -138,11 +148,12 @@ func (m *Me) UnmarshalJSON(v []byte) error { return nil } +// MarshalJSON implements custom marshler for JSON. func (m Me) MarshalJSON() ([]byte, error) { return []byte(strconv.Quote(m.String())), nil } -// URI returns copy of parsed Me in *fasthttp.URI representation. +// URI returns copy of parsed me in *fasthttp.URI representation. // This copy MUST be released via fasthttp.ReleaseURI. func (m Me) URI() *http.URI { if m.me == nil { @@ -155,7 +166,7 @@ func (m Me) URI() *http.URI { return u } -// URL returns copy of parsed Me in *url.URL representation. +// URL returns copy of parsed me in *url.URL representation. func (m Me) URL() *url.URL { if m.me == nil { return nil @@ -171,7 +182,7 @@ func (m Me) URL() *url.URL { } } -// String returns string representation of Me. +// String returns string representation of me. func (m Me) String() string { if m.me == nil { return "" diff --git a/internal/domain/provider.go b/internal/domain/provider.go new file mode 100644 index 0000000..8d3d768 --- /dev/null +++ b/internal/domain/provider.go @@ -0,0 +1,108 @@ +package domain + +import ( + "path" + "strings" + + http "github.com/valyala/fasthttp" +) + +// Provider represent 3rd party RelMeAuth provider. +type Provider struct { + Scopes []string + AuthURL string + ClientID string + ClientSecret string + Name string + Photo string + RedirectURL string + TokenURL string + UID string + URL string +} + +//nolint: gochecknoglobals +var ( + DefaultProviderDirect = Provider{ + AuthURL: "/authorize", + Name: "IndieAuth", + Photo: path.Join("static", "icon.svg"), + RedirectURL: path.Join("callback"), + Scopes: []string{}, + TokenURL: "/token", + UID: "direct", + URL: "/", + } + + DefaultProviderTwitter = Provider{ + AuthURL: "https://twitter.com/i/oauth2/authorize", + Name: "Twitter", + Photo: path.Join("static", "providers", "twitter.svg"), + RedirectURL: path.Join("callback", "twitter"), + Scopes: []string{ + "tweet.read", + "users.read", + }, + TokenURL: "https://api.twitter.com/2/oauth2/token", + UID: "twitter", + URL: "https://twitter.com/", + } + + DefaultProviderGitHub = Provider{ + AuthURL: "https://github.com/login/oauth/authorize", + Name: "GitHub", + Photo: path.Join("static", "providers", "github.svg"), + RedirectURL: path.Join("callback", "github"), + Scopes: []string{ + "read:user", + "user:email", + }, + TokenURL: "https://github.com/login/oauth/access_token", + UID: "github", + URL: "https://github.com/", + } + + DefaultProviderGitLab = Provider{ + AuthURL: "https://gitlab.com/oauth/authorize", + Name: "GitLab", + Photo: path.Join("static", "providers", "gitlab.svg"), + RedirectURL: path.Join("callback", "gitlab"), + Scopes: []string{ + "read_user", + }, + TokenURL: "https://gitlab.com/oauth/token", + UID: "gitlab", + URL: "https://gitlab.com/", + } + + DefaultProviderMastodon = Provider{ + AuthURL: "https://mstdn.io/oauth/authorize", + Name: "Mastodon", + Photo: path.Join("static", "providers", "mastodon.svg"), + RedirectURL: path.Join("callback", "mastodon"), + Scopes: []string{ + "read:accounts", + }, + TokenURL: "https://mstdn.io/oauth/token", + UID: "mastodon", + URL: "https://mstdn.io/", + } +) + +// AuthCodeURL returns URL for authorize user in RelMeAuth client. +func (p Provider) AuthCodeURL(state string) *URL { + u := http.AcquireURI() + u.Update(p.AuthURL) + + for k, v := range map[string]string{ + "client_id": p.ClientID, + "redirect_uri": p.RedirectURL, + "response_type": "code", + "scope": strings.Join(p.Scopes, " "), + "state": state, + } { + u.QueryArgs().Set(k, v) + } + + return &URL{URI: u} +} diff --git a/internal/domain/response_type.go b/internal/domain/response_type.go index eba79e6..148c488 100644 --- a/internal/domain/response_type.go +++ b/internal/domain/response_type.go @@ -10,42 +10,44 @@ import ( // NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety: // https://threedots.tech/post/safer-enums-in-go/#struct-based-enums type ResponseType struct { - slug string + uid string } //nolint: gochecknoglobals // NOTE(toby3d): structs cannot be constants var ( - ResponseTypeUndefined ResponseType = ResponseType{slug: ""} + ResponseTypeUndefined ResponseType = ResponseType{uid: ""} // Deprecated(toby3d): Only accept response_type=code requests, and for // backwards-compatible support, treat response_type=id requests as // response_type=code requests: // https://aaronparecki.com/2020/12/03/1/indieauth-2020#response-type - ResponseTypeID ResponseType = ResponseType{slug: "id"} + ResponseTypeID ResponseType = ResponseType{uid: "id"} // Indicates to the authorization server that an authorization code // should be returned as the response: // https://indieauth.net/source/#authorization-request-li-1 - ResponseTypeCode ResponseType = ResponseType{slug: "code"} + ResponseTypeCode ResponseType = ResponseType{uid: "code"} ) -var ErrResponseTypeUnknown = errors.New("unknown grant type") +var ErrResponseTypeUnknown error = errors.New("unknown grant type") -func ParseResponseType(slug string) (ResponseType, error) { - switch strings.ToLower(slug) { - case ResponseTypeCode.slug: +// ParseResponseType parse string as response type struct enum. +func ParseResponseType(uid string) (ResponseType, error) { + switch strings.ToLower(uid) { + case ResponseTypeCode.uid: return ResponseTypeCode, nil - case ResponseTypeID.slug: + case ResponseTypeID.uid: return ResponseTypeID, nil } - return ResponseTypeUndefined, fmt.Errorf("%w: %s", ErrResponseTypeUnknown, slug) + return ResponseTypeUndefined, fmt.Errorf("%w: %s", ErrResponseTypeUnknown, uid) } +// UnmarshalForm implements custom unmarshler for form values. func (rt *ResponseType) UnmarshalForm(src []byte) error { responseType, err := ParseResponseType(string(src)) if err != nil { - return fmt.Errorf("response_type: %w", err) + return fmt.Errorf("UnmarshalForm: %w", err) } *rt = responseType @@ -53,15 +55,16 @@ func (rt *ResponseType) UnmarshalForm(src []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (rt *ResponseType) UnmarshalJSON(v []byte) error { - src, err := strconv.Unquote(string(v)) + uid, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } - responseType, err := ParseResponseType(string(src)) + responseType, err := ParseResponseType(string(uid)) if err != nil { - return fmt.Errorf("response_type: %w", err) + return fmt.Errorf("UnmarshalJSON: %w", err) } *rt = responseType @@ -69,6 +72,7 @@ func (rt *ResponseType) UnmarshalJSON(v []byte) error { return nil } +// String returns string representation of response type. func (rt ResponseType) String() string { - return rt.slug + return rt.uid } diff --git a/internal/domain/scope.go b/internal/domain/scope.go index 20fb454..e11ebc7 100644 --- a/internal/domain/scope.go +++ b/internal/domain/scope.go @@ -1,7 +1,6 @@ package domain import ( - "errors" "fmt" "sort" "strconv" @@ -9,40 +8,43 @@ import ( ) type ( - // NOTE(toby3d): https://threedots.tech/post/safer-enums-in-go/#struct-based-enums + // Scope represent single token scope supported by IndieAuth. + // + // NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety: + // https://threedots.tech/post/safer-enums-in-go/#struct-based-enums Scope struct { - slug string + uid string } // Scopes represent set of Scope domains. Scopes []Scope ) -var ErrScopeUnknown = errors.New("unknown scope") +var ErrScopeUnknown error = NewError(ErrorCodeInvalidRequest, "unknown scope") //nolint: gochecknoglobals // NOTE(toby3d): structs cannot be constants var ( - ScopeUndefined = Scope{slug: ""} + ScopeUndefined = Scope{uid: ""} // https://indieweb.org/scope#Micropub_Scopes - ScopeCreate = Scope{slug: "create"} - ScopeDelete = Scope{slug: "delete"} - ScopeDraft = Scope{slug: "draft"} - ScopeMedia = Scope{slug: "media"} - ScopeUpdate = Scope{slug: "update"} + ScopeCreate = Scope{uid: "create"} + ScopeDelete = Scope{uid: "delete"} + ScopeDraft = Scope{uid: "draft"} + ScopeMedia = Scope{uid: "media"} + ScopeUpdate = Scope{uid: "update"} // https://indieweb.org/scope#Microsub_Scopes - ScopeBlock = Scope{slug: "block"} - ScopeChannels = Scope{slug: "channels"} - ScopeFollow = Scope{slug: "follow"} - ScopeMute = Scope{slug: "mute"} - ScopeRead = Scope{slug: "read"} + ScopeBlock = Scope{uid: "block"} + ScopeChannels = Scope{uid: "channels"} + ScopeFollow = Scope{uid: "follow"} + ScopeMute = Scope{uid: "mute"} + ScopeRead = Scope{uid: "read"} // This scope requests access to the user's default profile information // which include the following properties: name, `photo, url. // // NOTE(toby3d): https://indieauth.net/source/#profile-information - ScopeProfile = Scope{slug: "profile"} + ScopeProfile = Scope{uid: "profile"} // This scope requests access to the user's email address in the // following property: email. @@ -52,45 +54,49 @@ var ( // and must be requested along with the profile scope if desired. // // NOTE(toby3d): https://indieauth.net/source/#profile-information - ScopeEmail = Scope{slug: "email"} + ScopeEmail = Scope{uid: "email"} ) //nolint: gochecknoglobals // NOTE(toby3d): maps cannot be constants -var slugsScopes = map[string]Scope{ - ScopeBlock.slug: ScopeBlock, - ScopeChannels.slug: ScopeChannels, - ScopeCreate.slug: ScopeCreate, - ScopeDelete.slug: ScopeDelete, - ScopeDraft.slug: ScopeDraft, - ScopeEmail.slug: ScopeEmail, - ScopeFollow.slug: ScopeFollow, - ScopeMedia.slug: ScopeMedia, - ScopeMute.slug: ScopeMute, - ScopeProfile.slug: ScopeProfile, - ScopeRead.slug: ScopeRead, - ScopeUpdate.slug: ScopeUpdate, +var uidsScopes = map[string]Scope{ + ScopeBlock.uid: ScopeBlock, + ScopeChannels.uid: ScopeChannels, + ScopeCreate.uid: ScopeCreate, + ScopeDelete.uid: ScopeDelete, + ScopeDraft.uid: ScopeDraft, + ScopeEmail.uid: ScopeEmail, + ScopeFollow.uid: ScopeFollow, + ScopeMedia.uid: ScopeMedia, + ScopeMute.uid: ScopeMute, + ScopeProfile.uid: ScopeProfile, + ScopeRead.uid: ScopeRead, + ScopeUpdate.uid: ScopeUpdate, } // ParseScope parses scope slug into Scope domain. -func ParseScope(slug string) (Scope, error) { - if scope, ok := slugsScopes[strings.ToLower(slug)]; ok { +func ParseScope(uid string) (Scope, error) { + if scope, ok := uidsScopes[strings.ToLower(uid)]; ok { return scope, nil } - return ScopeUndefined, fmt.Errorf("%w: %s", ErrScopeUnknown, slug) + return ScopeUndefined, fmt.Errorf("%w: %s", ErrScopeUnknown, uid) } -// UnmarshalForm parses the value of the form key into the Scope domain. +// UnmarshalForm implements custom unmarshler for form values. func (s *Scopes) UnmarshalForm(v []byte) error { scopes := make(Scopes, 0) for _, rawScope := range strings.Fields(string(v)) { scope, err := ParseScope(rawScope) if err != nil { - return fmt.Errorf("scopes: %w", err) + return fmt.Errorf("UnmarshalForm: %w", err) } - *s = append(scopes, scope) + if scopes.Has(scope) { + continue + } + + scopes = append(scopes, scope) } *s = scopes @@ -98,18 +104,23 @@ func (s *Scopes) UnmarshalForm(v []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (s *Scopes) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } - result := make([]Scope, 0) + result := make(Scopes, 0) for _, scope := range strings.Fields(src) { s, err := ParseScope(scope) if err != nil { - return fmt.Errorf("scope: %w", err) + return fmt.Errorf("UnmarshalJSON: %w", err) + } + + if result.Has(s) { + continue } result = append(result, s) @@ -120,6 +131,7 @@ func (s *Scopes) UnmarshalJSON(v []byte) error { return nil } +// UnmarshalJSON implements custom marshler for JSON. func (s Scopes) MarshalJSON() ([]byte, error) { scopes := make([]string, len(s)) @@ -132,11 +144,12 @@ func (s Scopes) MarshalJSON() ([]byte, error) { return []byte(strconv.Quote(strings.Join(scopes, " "))), nil } -// String returns scope slug as string. +// String returns string representation of scope. func (s Scope) String() string { - return s.slug + return s.uid } +// String returns string representation of scopes. func (s Scopes) String() string { scopes := make([]string, len(s)) @@ -146,3 +159,29 @@ func (s Scopes) String() string { return strings.Join(scopes, " ") } + +// IsEmpty returns true if the set does not contain valid scope. +func (s Scopes) IsEmpty() bool { + for i := range s { + if s[i] == ScopeUndefined { + continue + } + + return false + } + + return true +} + +// Has check what input scope contains in current scopes collection. +func (s Scopes) Has(scope Scope) bool { + for i := range s { + if s[i] != scope { + continue + } + + return true + } + + return false +} diff --git a/internal/domain/session.go b/internal/domain/session.go index 293a946..cf6a0ac 100644 --- a/internal/domain/session.go +++ b/internal/domain/session.go @@ -1,27 +1,41 @@ package domain -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/require" + + "source.toby3d.me/website/indieauth/internal/random" +) type Session struct { ClientID *ClientID Me *Me RedirectURI *URL + Profile *Profile CodeChallengeMethod CodeChallengeMethod Scope Scopes Code string CodeChallenge string } +// TestSession returns valid random generated session for tests. func TestSession(tb testing.TB) *Session { tb.Helper() + code, err := random.String(24) + require.NoError(tb, err) + return &Session{ ClientID: TestClientID(tb), + Code: code, + CodeChallenge: "hackme", + CodeChallengeMethod: CodeChallengeMethodPLAIN, Me: TestMe(tb, "https://user.example.net/"), RedirectURI: TestURL(tb, "https://example.com/callback"), - CodeChallengeMethod: CodeChallengeMethodPLAIN, - Scope: Scopes{ScopeProfile, ScopeEmail}, - Code: "abcdefg", - CodeChallenge: "hackme", + Scope: Scopes{ + ScopeEmail, + ScopeProfile, + }, } } diff --git a/internal/domain/ticket.go b/internal/domain/ticket.go index 3c8c1c9..e246301 100644 --- a/internal/domain/ticket.go +++ b/internal/domain/ticket.go @@ -15,6 +15,7 @@ type Ticket struct { Subject *Me } +// TestTicket returns valid random generated ticket for tests. func TestTicket(tb testing.TB) *Ticket { tb.Helper() diff --git a/internal/domain/token.go b/internal/domain/token.go index d1038b3..a567864 100644 --- a/internal/domain/token.go +++ b/internal/domain/token.go @@ -16,28 +16,31 @@ import ( type ( // Token describes the data of the token used by the clients. Token struct { - AccessToken string + Scope Scopes ClientID *ClientID Me *Me - Scope Scopes + AccessToken string } + // NewTokenOptions contains options for NewToken function. NewTokenOptions struct { - Algorithm string Expiration time.Duration - Issuer *ClientID - NonceLength int Scope Scopes - Secret []byte + Issuer *ClientID Subject *Me + Secret []byte + Algorithm string + NonceLength int } ) +//nolint: gochecknoglobals var DefaultNewTokenOptions = NewTokenOptions{ Algorithm: "HS256", NonceLength: 32, } +// NewToken create a new token by provided options. func NewToken(opts NewTokenOptions) (*Token, error) { if opts.NonceLength == 0 { opts.NonceLength = DefaultNewTokenOptions.NonceLength @@ -55,13 +58,16 @@ func NewToken(opts NewTokenOptions) (*Token, error) { } t := jwt.New() - t.Set(jwt.IssuerKey, opts.Issuer.String()) t.Set(jwt.SubjectKey, opts.Subject.String()) t.Set(jwt.NotBeforeKey, now) t.Set(jwt.IssuedAtKey, now) t.Set("scope", opts.Scope) t.Set("nonce", nonce) + if opts.Issuer != nil { + t.Set(jwt.IssuerKey, opts.Issuer.String()) + } + if opts.Expiration != 0 { t.Set(jwt.ExpirationKey, now.Add(opts.Expiration)) } @@ -79,7 +85,7 @@ func NewToken(opts NewTokenOptions) (*Token, error) { }, err } -// TestToken returns a valid Token with the generated test data filled in. +// TestToken returns valid random generated token for tests. func TestToken(tb testing.TB) *Token { tb.Helper() @@ -129,6 +135,7 @@ func (t Token) SetAuthHeader(r *http.Request) { r.Header.Set(http.HeaderAuthorization, t.String()) } +// String returns string representation of token. func (t Token) String() string { if t.AccessToken == "" { return "" diff --git a/internal/domain/url.go b/internal/domain/url.go index dc51cae..c9d7c7d 100644 --- a/internal/domain/url.go +++ b/internal/domain/url.go @@ -1,6 +1,7 @@ package domain import ( + "fmt" "net/url" "strconv" "testing" @@ -8,19 +9,22 @@ import ( http "github.com/valyala/fasthttp" ) +// URL describe any valid HTTP URL. type URL struct { *http.URI } +// ParseURL parse strings as URL. func ParseURL(src string) (*URL, error) { u := http.AcquireURI() if err := u.Parse(nil, []byte(src)); err != nil { - return nil, err + return nil, fmt.Errorf("cannot parse URL: %w", err) } return &URL{URI: u}, nil } +// TestURL returns URL of provided input for tests. func TestURL(tb testing.TB, src string) *URL { tb.Helper() @@ -32,10 +36,11 @@ func TestURL(tb testing.TB, src string) *URL { } } +// UnmarshalForm implements custom unmarshler for form values. func (u *URL) UnmarshalForm(v []byte) error { url, err := ParseURL(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalForm: %w", err) } *u = *url @@ -43,15 +48,16 @@ func (u *URL) UnmarshalForm(v []byte) error { return nil } +// UnmarshalJSON implements custom unmarshler for JSON. func (u *URL) UnmarshalJSON(v []byte) error { src, err := strconv.Unquote(string(v)) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } url, err := ParseURL(src) if err != nil { - return err + return fmt.Errorf("UnmarshalJSON: %w", err) } *u = *url @@ -59,6 +65,7 @@ func (u *URL) UnmarshalJSON(v []byte) error { return nil } +// URL returns url.URL representation of URL. func (u URL) URL() *url.URL { if u.URI == nil { return nil diff --git a/internal/domain/user.go b/internal/domain/user.go index e1900ad..63c8ea8 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -15,6 +15,7 @@ type User struct { *Profile } +// TestUser returns valid random generated user for tests. func TestUser(tb testing.TB) *User { tb.Helper()