Skip to content

Commit

Permalink
feat(indieauth): application metadata discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
hacdias committed Apr 12, 2024
1 parent fbc6b5f commit ceb53cd
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- IndieAuth: added `context.Context` as argument to all functions that perform HTTP requests.
- IndieAuth: added `DiscoverApplicationMetadata` to the `Server`, which implements the [Application Information Discovery](https://indieauth.spec.indieweb.org/#application-information).

### Deprecated

Expand Down
20 changes: 10 additions & 10 deletions examples/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ type client struct {
}

// indexHandler serves a simple index page with a login form.
func (s *client) indexHandler(w http.ResponseWriter, r *http.Request) {
func (c *client) indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(indexTemplate))
}

// loginHandler handles the login process after submitting the domain via the
// index page.
func (s *client) loginHandler(w http.ResponseWriter, r *http.Request) {
func (c *client) loginHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
Expand All @@ -125,15 +125,15 @@ func (s *client) loginHandler(w http.ResponseWriter, r *http.Request) {
// Generates the redirect request to the target profile so that the user can
// authorize the request. We also ask for the "profile" and "email" scope so
// that we can get more information about the user.
authInfo, redirect, err := s.iac.Authenticate(r.Context(), profileURL, "profile email")
authInfo, redirect, err := c.iac.Authenticate(r.Context(), profileURL, "profile email")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

// We store the authInfo in a cookie. This information will be later needed
// to validate the callback request from the authentication server.
err = s.storeAuthInfo(w, r, authInfo)
err = c.storeAuthInfo(w, r, authInfo)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
Expand All @@ -144,16 +144,16 @@ func (s *client) loginHandler(w http.ResponseWriter, r *http.Request) {
}

// callbackHandler handles the callback from the authentication server.
func (s *client) callbackHandler(w http.ResponseWriter, r *http.Request) {
func (c *client) callbackHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve the authentication info from the cookie.
authInfo, err := s.getAuthInfo(w, r)
authInfo, err := c.getAuthInfo(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

// Validate the callback using authInfo and the current request.
code, err := s.iac.ValidateCallback(authInfo, r)
code, err := c.iac.ValidateCallback(authInfo, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
Expand All @@ -162,7 +162,7 @@ func (s *client) callbackHandler(w http.ResponseWriter, r *http.Request) {
// We now fetch the profile of the user so we know more about the user.
// Depending on the authentication server, this information might be more
// or less complete. However, ".Me" must always be present.
profile, err := s.iac.FetchProfile(r.Context(), authInfo, code)
profile, err := c.iac.FetchProfile(r.Context(), authInfo, code)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
Expand All @@ -186,7 +186,7 @@ func (s *client) callbackHandler(w http.ResponseWriter, r *http.Request) {
// required to then validate the request once the callback is received. Note that
// this is just an example. You could use other methods, such as encoding with JWT
// tokens, a database, you name it.
func (s *client) storeAuthInfo(w http.ResponseWriter, r *http.Request, i *indieauth.AuthInfo) error {
func (c *client) storeAuthInfo(w http.ResponseWriter, r *http.Request, i *indieauth.AuthInfo) error {
data, err := json.Marshal(i)
if err != nil {
return err
Expand All @@ -206,7 +206,7 @@ func (s *client) storeAuthInfo(w http.ResponseWriter, r *http.Request, i *indiea
}

// getAuthInfo gets the [indieauth.AuthInfo] stored into a cookie.
func (s *client) getAuthInfo(w http.ResponseWriter, r *http.Request) (*indieauth.AuthInfo, error) {
func (c *client) getAuthInfo(w http.ResponseWriter, r *http.Request) (*indieauth.AuthInfo, error) {
cookie, err := r.Cookie(oauthCookieName)
if err != nil {
return nil, err
Expand Down
10 changes: 9 additions & 1 deletion examples/server/indieauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,19 @@ func (s *server) authorizationGetHandler(w http.ResponseWriter, r *http.Request)
return
}

// Do a best effort attempt at fetching more information about the application
// that we can show to the user. Not all applications provide this sort of
// information.
app, _ := s.ias.DiscoverApplicationMetadata(r.Context(), req.ClientID)

// Here, we just display a small HTML document where the user has to press
// to authorize this request. Please note that this template contains a form
// where we dump all the request information. This makes it possible to reuse
// [indieauth.Server.ParseAuthorization] when the user authorizes the request.
serveHTML(w, "auth.html", req)
serveHTML(w, "auth.html", map[string]any{
"Request": req,
"Application": app,
})
}

// authorizationPostHandler handles the POST method for the authorization endpoint.
Expand Down
29 changes: 19 additions & 10 deletions examples/server/templates/auth.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,31 @@
<body>
<h1>IndieAuth Server Demo: Authorization</h1>

<p>You received an authorization request from the following client:</p>
<p>
You received an authorization request from
{{ with .Application }}
{{ with .Logo }}<img style='width: 1em; vertical-align: middle;' src="{{ . }}">{{ end }}
<strong>{{ .Name }}</strong>{{ with .Author }} by {{ . }}{{ end }}:
{{ else }}
the following client:
{{ end }}
</p>

<ul>
<li><strong>Redirect:</strong> <code>{{ .ClientID }}</code></li>
<li><strong>Client:</strong> <code>{{ .RedirectURI }}</code></li>
<li><strong>Redirect:</strong> <code>{{ .Request.ClientID }}</code></li>
<li><strong>Client:</strong> <code>{{ .Request.RedirectURI }}</code></li>
</ul>

<p>For the following scopes:{{ range .Scopes}} <code>{{ . }}</code>{{ end }}.</p>
<p>For the following scopes:{{ range .Request.Scopes}} <code>{{ . }}</code>{{ end }}.</p>

<form method='post' action='/authorization/accept'>
<input type="hidden" name="response_type" value="code">
<input type="hidden" name="scope" value="{{ range .Scopes}} {{ . }}{{ end }}">
<input type="hidden" name="redirect_uri" value="{{ .RedirectURI }}">
<input type="hidden" name="client_id" value="{{ .ClientID }}">
<input type="hidden" name="state" value="{{ .State }}">
<input type="hidden" name="code_challenge" value="{{ .CodeChallenge }}">
<input type="hidden" name="code_challenge_method" value="{{ .CodeChallengeMethod }}">
<input type="hidden" name="scope" value="{{ range .Request.Scopes}} {{ . }}{{ end }}">
<input type="hidden" name="redirect_uri" value="{{ .Request.RedirectURI }}">
<input type="hidden" name="client_id" value="{{ .Request.ClientID }}">
<input type="hidden" name="state" value="{{ .Request.State }}">
<input type="hidden" name="code_challenge" value="{{ .Request.CodeChallenge }}">
<input type="hidden" name="code_challenge_method" value="{{ .Request.CodeChallengeMethod }}">

<p>In a production server, this page could be behind some sort of authentication mechanism, such as username and password, PassKey, etc.</p>

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/stretchr/testify v1.8.4
golang.org/x/net v0.22.0
golang.org/x/oauth2 v0.18.0
willnorris.com/go/microformats v1.2.1-0.20240301064101-b5d1b9d2120e
willnorris.com/go/webmention v0.0.0-20220108183051-4a23794272f0
)

Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand Down Expand Up @@ -65,5 +65,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
willnorris.com/go/microformats v1.2.1-0.20240301064101-b5d1b9d2120e h1:TRIOwo0NxN4KVSgYlYmiQktd9I96YgZ3942/JVzhwTM=
willnorris.com/go/microformats v1.2.1-0.20240301064101-b5d1b9d2120e/go.mod h1:zzo0hFA/E/nl1ZAjXiXA7KCKwCTdgBU+7HXltGgHeGA=
willnorris.com/go/webmention v0.0.0-20220108183051-4a23794272f0 h1:V5+O+YZHchEwu6ZmPcqT1dQ+mHgE356Q+w9SVOQ+QZg=
willnorris.com/go/webmention v0.0.0-20220108183051-4a23794272f0/go.mod h1:DgeruqKIsZtcDXVXNbBHa0YYEm88oAnK7PahkDtuCvw=
121 changes: 118 additions & 3 deletions indieauth/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package indieauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -11,6 +12,7 @@ import (

"golang.org/x/net/html"
"golang.org/x/net/html/atom"
"willnorris.com/go/microformats"
"willnorris.com/go/webmention/third_party/header"
)

Expand Down Expand Up @@ -109,13 +111,13 @@ type endpointRequest struct {
err error
}

func (s *Client) discoverEndpoints(ctx context.Context, urlStr string, rels ...string) ([]*endpointRequest, error) {
headEndpoints, found, errHead := s.discoverRequest(ctx, http.MethodHead, urlStr, rels...)
func (c *Client) discoverEndpoints(ctx context.Context, urlStr string, rels ...string) ([]*endpointRequest, error) {
headEndpoints, found, errHead := c.discoverRequest(ctx, http.MethodHead, urlStr, rels...)
if errHead == nil && headEndpoints != nil && found {
return headEndpoints, nil
}

getEndpoints, found, errGet := s.discoverRequest(ctx, http.MethodGet, urlStr, rels...)
getEndpoints, found, errGet := c.discoverRequest(ctx, http.MethodGet, urlStr, rels...)
if errGet == nil && getEndpoints != nil && found {
return getEndpoints, nil
}
Expand Down Expand Up @@ -293,3 +295,116 @@ func resolveReferences(base string, refs ...*endpointRequest) error {
}
return nil
}

type ApplicationMetadata struct {
Name string
Logo string
URL string
Summary string
Author string
}

// ErrNoApplicationMetadata is returned when no `h-app` or `h-x-app` Microformat
// has been found at a given URL.
var ErrNoApplicationMetadata error = errors.New("application metadata (h-app, h-x-app) not found")

// DiscoverApplicationMetadata fetches metadata for the application at the
// provided URL. This metadata is given by the `h-app` or `h-x-app` [Microformat].
// This information can be used by the server, for example, to display relevant
// information about the application in the authorization page. If no information
// has been found, an [ErrNoApplicationMetadata] error will be returned.
//
// Please note that this function only parses the first `h-app` or `h-x-app`
// Microformat with information that it encounters.
//
// [Microformat]: https://microformats.org/wiki/h-app
func (s *Server) DiscoverApplicationMetadata(ctx context.Context, clientID string) (*ApplicationMetadata, error) {
err := IsValidClientIdentifier(clientID)
if err != nil {
return nil, err
}

r, err := http.NewRequestWithContext(ctx, http.MethodGet, clientID, nil)
if err != nil {
return nil, err
}
r.Header.Add("Accept", "text/html")

res, err := s.Client.Do(r)
if err != nil {
return nil, err
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: expected 200, got %d", res.StatusCode)
}

contentType := res.Header.Get("Content-Type")
if !strings.Contains(contentType, "text/html") {
return nil, fmt.Errorf("content-type: expected to include text/html, got '%q'", contentType)
}

data := microformats.Parse(res.Body, res.Request.URL)
if data == nil {
return nil, ErrNoApplicationMetadata
}

for _, item := range data.Items {
isApp := false
for _, typ := range item.Type {
// h-x-app for legacy support
if typ == "h-app" || typ == "h-x-app" {
isApp = true
break
}
}
if !isApp {
continue
}

name := getFirstStringProperty(item, "name")
url := getFirstStringProperty(item, "url")
logo := getFirstStringProperty(item, "logo")
if logo == "" {
logo = getFirstStringProperty(item, "photo")
}
summary := getFirstStringProperty(item, "summary")
author := getFirstStringProperty(item, "author")

if name == "" && url == "" && logo == "" && summary == "" && author == "" {
continue
}

return &ApplicationMetadata{
Name: name,
URL: url,
Logo: logo,
Summary: summary,
Author: author,
}, nil
}

return nil, ErrNoApplicationMetadata
}

func getFirstStringProperty(item *microformats.Microformat, key string) string {
vv, ok := item.Properties[key]
if !ok {
return ""
}

for _, v := range vv {
if s, ok := v.(string); ok && s != "" {
return s
}

if m, ok := v.(map[string]string); ok {
if mv, ok := m["value"]; ok && mv != "" {
return mv
}
}
}

return ""
}
Loading

0 comments on commit ceb53cd

Please sign in to comment.