An experimental IndieWeb site built in Go.
1package services
2
3import (
4 "context"
5 "crypto/sha256"
6 "crypto/subtle"
7 "encoding/json"
8 "errors"
9 "net/http"
10 "net/url"
11 "os"
12 "strings"
13 "time"
14
15 "github.com/puregarlic/space/html/layouts"
16 "github.com/puregarlic/space/html/pages"
17 "github.com/puregarlic/space/storage"
18
19 "github.com/aidarkhanov/nanoid"
20 "github.com/golang-jwt/jwt/v5"
21 "go.hacdias.com/indielib/indieauth"
22)
23
24type IndieAuth struct {
25 ProfileURL string
26 Server *indieauth.Server
27}
28
29func (i *IndieAuth) storeAuthorization(req *indieauth.AuthenticationRequest) string {
30 code := nanoid.New()
31
32 storage.AuthCache().Set(code, req, 0)
33
34 return code
35}
36
37type CustomTokenClaims struct {
38 Scopes []string `json:"scopes"`
39 jwt.RegisteredClaims
40}
41
42type contextKey string
43
44const (
45 scopesContextKey contextKey = "scopes"
46)
47
48func (i *IndieAuth) HandleAuthGET(w http.ResponseWriter, r *http.Request) {
49 req, err := i.Server.ParseAuthorization(r)
50 if err != nil {
51 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
52 return
53 }
54
55 app, _ := i.Server.DiscoverApplicationMetadata(r.Context(), req.ClientID)
56
57 nonceId, nonce := nanoid.New(), nanoid.New()
58 storage.NonceCache().Set(nonceId, nonce, 0)
59
60 layouts.RenderDefault("authorize", pages.Auth(req, app, nonceId, nonce)).ServeHTTP(w, r)
61}
62
63func (i *IndieAuth) HandleAuthPOST(w http.ResponseWriter, r *http.Request) {
64 i.authorizationCodeExchange(w, r, false)
65}
66
67func (i *IndieAuth) HandleToken(w http.ResponseWriter, r *http.Request) {
68 if r.Method != http.MethodPost {
69 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
70 return
71 }
72
73 if r.Form.Get("grant_type") == "refresh_token" {
74 // NOTE: this server does not implement refresh tokens.
75 // https://indieauth.spec.indieweb.org/#refresh-tokens
76 w.WriteHeader(http.StatusNotImplemented)
77 return
78 }
79
80 i.authorizationCodeExchange(w, r, true)
81}
82
83type tokenResponse struct {
84 Me string `json:"me"`
85 AccessToken string `json:"access_token,omitempty"`
86 TokenType string `json:"token_type,omitempty"`
87 Scope string `json:"scope,omitempty"`
88 ExpiresIn int64 `json:"expires_in,omitempty"`
89}
90
91func (i *IndieAuth) authorizationCodeExchange(w http.ResponseWriter, r *http.Request, withToken bool) {
92 if err := r.ParseForm(); err != nil {
93 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", err.Error())
94 return
95 }
96
97 // t := s.getAuthorization(r.Form.Get("code"))
98 req, present := storage.AuthCache().GetAndDelete(r.Form.Get("code"))
99 if !present {
100 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", "invalid authorization")
101 return
102 }
103 authRequest := req.Value()
104
105 err := i.Server.ValidateTokenExchange(authRequest, r)
106 if err != nil {
107 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", err.Error())
108 return
109 }
110
111 response := &tokenResponse{
112 Me: i.ProfileURL,
113 }
114
115 scopes := authRequest.Scopes
116
117 if withToken {
118 now := time.Now()
119 expiresAt := now.Add(15 * time.Minute)
120 claims := CustomTokenClaims{
121 scopes,
122 jwt.RegisteredClaims{
123 ExpiresAt: jwt.NewNumericDate(expiresAt),
124 IssuedAt: jwt.NewNumericDate(now),
125 NotBefore: jwt.NewNumericDate(now),
126 },
127 }
128 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
129
130 secret := os.Getenv("JWT_SECRET")
131 jwt, err := token.SignedString([]byte(secret))
132 if err != nil {
133 panic(err)
134 }
135
136 response.AccessToken = jwt
137 response.TokenType = "Bearer"
138 response.ExpiresIn = int64(time.Until(expiresAt).Seconds())
139 response.Scope = strings.Join(scopes, " ")
140 }
141
142 // An actual server may want to include the "profile" in the response if the
143 // scope "profile" is included.
144 SendJSON(w, http.StatusOK, response)
145}
146
147func (i *IndieAuth) HandleAuthApproval(w http.ResponseWriter, r *http.Request) {
148 id := r.FormValue("nonce_id")
149 nonce := r.FormValue("nonce")
150
151 stored, ok := storage.NonceCache().GetAndDelete(id)
152 if !ok {
153 SendErrorJSON(w, http.StatusBadRequest, "bad_request", "nonce does not match")
154 } else if stored.Value() != nonce {
155 SendErrorJSON(w, http.StatusBadRequest, "bad_request", "nonce does not match")
156 }
157
158 req, err := i.Server.ParseAuthorization(r)
159 if err != nil {
160 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", err.Error())
161 return
162 }
163
164 code := i.storeAuthorization(req)
165
166 // Redirect to client callback.
167 query := url.Values{}
168 query.Set("code", code)
169 query.Set("state", req.State)
170 http.Redirect(w, r, req.RedirectURI+"?"+query.Encode(), http.StatusFound)
171}
172
173func MustAuth(next http.Handler) http.Handler {
174 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175 tokenStr := r.Header.Get("Authorization")
176 tokenStr = strings.TrimPrefix(tokenStr, "Bearer")
177 tokenStr = strings.TrimSpace(tokenStr)
178
179 if len(tokenStr) <= 0 {
180 SendErrorJSON(w, http.StatusUnauthorized, "invalid_request", "no credentials")
181 return
182 }
183
184 token, err := jwt.ParseWithClaims(tokenStr, &CustomTokenClaims{}, func(t *jwt.Token) (interface{}, error) {
185 return []byte(os.Getenv("JWT_SECRET")), nil
186 })
187
188 if err != nil {
189 SendErrorJSON(w, http.StatusUnauthorized, "invalid_request", "invalid token")
190 return
191 } else if claims, ok := token.Claims.(*CustomTokenClaims); ok {
192 ctx := context.WithValue(r.Context(), scopesContextKey, claims.Scopes)
193 next.ServeHTTP(w, r.WithContext(ctx))
194 return
195 } else {
196 SendErrorJSON(w, http.StatusUnauthorized, "invalid_request", "malformed claims")
197 return
198 }
199 })
200}
201
202func MustBasicAuth(next http.Handler) http.Handler {
203 user, ok := os.LookupEnv("ADMIN_USERNAME")
204 if !ok {
205 panic(errors.New("ADMIN_USERNAME is not set, cannot start"))
206 }
207
208 pass, ok := os.LookupEnv("ADMIN_PASSWORD")
209 if !ok {
210 panic(errors.New("ADMIN_PASSWORD is not set, cannot start"))
211 }
212
213 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
214 username, password, ok := r.BasicAuth()
215 if ok {
216 usernameHash := sha256.Sum256([]byte(username))
217 passwordHash := sha256.Sum256([]byte(password))
218 expectedUsernameHash := sha256.Sum256([]byte(user))
219 expectedPasswordHash := sha256.Sum256([]byte(pass))
220
221 usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1)
222 passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1)
223
224 if usernameMatch && passwordMatch {
225 next.ServeHTTP(w, r)
226 return
227 }
228 }
229
230 w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
231 http.Error(w, "Unauthorized", http.StatusUnauthorized)
232 })
233}
234
235func SendJSON(w http.ResponseWriter, code int, data interface{}) {
236 w.Header().Set("Content-Type", "application/json; charset=utf-8")
237 w.WriteHeader(code)
238 _ = json.NewEncoder(w).Encode(data)
239}
240
241func SendErrorJSON(w http.ResponseWriter, code int, err, errDescription string) {
242 SendJSON(w, code, map[string]string{
243 "error": err,
244 "error_description": errDescription,
245 })
246}