Skip to content

Commit fc1a05b

Browse files
chocolatkeyCopilot
andauthored
Add JWT-based auth to serve command (#79)
Co-authored-by: Copilot <[email protected]>
1 parent f884935 commit fc1a05b

File tree

16 files changed

+298
-71
lines changed

16 files changed

+298
-71
lines changed

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ builds:
2424
goarm:
2525
- '7'
2626
goamd64:
27-
- v3
27+
- v2
2828
ldflags:
2929
- -s -w
3030

CHANGELOG.MD

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44

55
**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.
66

7+
## [0.6.0] - 2025-11-03
8+
9+
### Added
10+
11+
- When using the serve command, a new `-m` flag allows for authenticated access to publications using a JWT in the route to the publication instead of the encoded path. The subject (`sub`) of the JWT will instead be used as the path to the publication. The first new mode is `jwt` mode, which uses the HS256 method of authentication and a shared secret that is either provided using `--jwt-shared-secret` or autogenerated at startup. The second mode, `jwks`, is combined with the `--jwks-url` flag that points to JWKS file, which can contain multiple keys used to validate the JWT, allowing for key rotation and other algorithms using public/private keypairs
12+
- The path of a publication with no resource specified now redirects to the manifest file
13+
14+
### Changed
15+
16+
- The GOAMD64 value for release builds has been changed from `v3` to `v2`. The discussion regarding this is [here](https://github.com/readium/cli/issues/78). This allows execution of the built binaries on older x64 CPUs
17+
- The HTTP client configuration used for streaming of remote publications has been changed to require, at minimum, TLSv1.2 for HTTPS connections
18+
- The serve command's routes are now prefixed with `/webpub`. So `<domain>/<path>/manifest.json` is now `<domain>/webpub/<path>/manifest.json`
19+
20+
### Removed
21+
22+
- The `/list.json` route in the serve command's webserver has been removed. It is not compatible with the new authenticated access schemes, and was only intended to be temporary. It may be replaced in the future by an OPDS2 feed
23+
724
## [0.5.1] - 2025-10-14
825

926
### Fixed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ go 1.24.2
55
require (
66
cloud.google.com/go/storage v1.57.1
77
github.com/CAFxX/httpcompression v0.0.9
8+
github.com/MicahParks/jwkset v0.11.0
9+
github.com/MicahParks/keyfunc/v3 v3.7.0
810
github.com/aws/aws-sdk-go-v2 v1.39.5
911
github.com/aws/aws-sdk-go-v2/config v1.31.16
1012
github.com/aws/aws-sdk-go-v2/credentials v1.18.20
1113
github.com/aws/aws-sdk-go-v2/service/s3 v1.89.1
14+
github.com/golang-jwt/jwt/v5 v5.2.2
1215
github.com/gorilla/mux v1.8.1
1316
github.com/gotd/contrib v0.21.1
1417
github.com/pkg/errors v0.9.1

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
4949
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
5050
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
5151
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
52+
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
53+
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
54+
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
55+
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
5256
github.com/agext/regexp v1.3.0 h1:6+9tp+S41TU48gFNV47bX+pp1q7WahGofw6JccmsCDs=
5357
github.com/agext/regexp v1.3.0/go.mod h1:6phv1gViOJXWcTfpxOi9VMS+MaSAo+SUDf7do3ur1HA=
5458
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
@@ -143,6 +147,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
143147
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
144148
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
145149
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
150+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
151+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
146152
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
147153
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
148154
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=

internal/cli/root.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ package cli
33
import (
44
"os"
55

6-
"github.com/readium/go-toolkit/pkg/util/version"
6+
"github.com/readium/cli/internal/version"
7+
gv "github.com/readium/go-toolkit/pkg/util/version"
78
"github.com/spf13/cobra"
89
)
910

@@ -17,7 +18,7 @@ var rootCmd = &cobra.Command{
1718
// This is called by main.main(). It only needs to happen once to the rootCmd.
1819
func Execute() {
1920
if rootCmd.Version == "" {
20-
rootCmd.Version = Version + " (go-toolkit " + version.Version + ")"
21+
rootCmd.Version = version.Version + " (go-toolkit " + gv.Version + ")"
2122
}
2223
err := rootCmd.Execute()
2324
if err != nil {

internal/cli/serve.go

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cli
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/hex"
57
"fmt"
68
"log"
79
"net/http"
@@ -22,6 +24,7 @@ import (
2224
"github.com/aws/aws-sdk-go-v2/service/s3"
2325
"github.com/pkg/errors"
2426
"github.com/readium/cli/pkg/serve"
27+
"github.com/readium/cli/pkg/serve/auth"
2528
"github.com/readium/cli/pkg/serve/client"
2629
"github.com/readium/go-toolkit/pkg/streamer"
2730
"github.com/readium/go-toolkit/pkg/util/url"
@@ -39,6 +42,11 @@ var schemeFlag []string
3942

4043
var fileDirectoryFlag string
4144

45+
var mode string
46+
47+
var jwtSharedSecret string
48+
var jwksURL string
49+
4250
// Cloud-related flags
4351
var s3EndpointFlag string
4452
var s3RegionFlag string
@@ -57,24 +65,20 @@ var remoteArchiveCacheAll uint32
5765

5866
var serveCmd = &cobra.Command{
5967
Use: "serve",
60-
Short: "Start a local HTTP server, serving a specified directory of publications",
61-
Long: `Start a local HTTP server, serving a specified directory of publications.
68+
Short: "Start a local HTTP server, serving publications locally or remotely",
69+
Long: `Start a local HTTP server, serving publications locally or remotely.
6270
63-
This command will start an HTTP serve listening by default on 'localhost:15080',
71+
This command will start an HTTP server listening by default on 'localhost:15080',
6472
serving all compatible files (EPUB, PDF, CBZ, etc.) available from the enabled
6573
access schemes (file, http, https, s3, gs, or a local path if file scheme is enabled)
6674
as Readium Web Publications. To get started, the manifest can be accessed from
6775
'http://localhost:15080/<filename in base64url encoding without padding>/manifest.json'.
6876
This file serves as the entry point and contains metadata and links to the rest
6977
of the files that can be accessed for the publication.
7078
71-
If local file access is enabled, the server also exposes a '/list.json' endpoint that,
72-
for debugging purposes, returns a list of all the publications found in the directory
73-
along with their encoded paths. This will be replaced by an OPDS 2 feed (or similar)
74-
in a future release.
75-
76-
Note: Take caution before exposing this server on the internet. It does not
77-
implement any authentication, and may have more access to files than expected.`,
79+
Authentication can be enabled using the -m flag, which replaces the encoded path
80+
with a JWT. Before exposing this server publicly, consider using this flag to secure
81+
access to publications and prevent abuse or unauthorized access.`,
7882
Args: func(cmd *cobra.Command, args []string) error {
7983
if len(args) > 0 {
8084
// For users migrating from previous versions of the CLI
@@ -213,17 +217,58 @@ implement any authentication, and may have more access to files than expected.`,
213217
remote.Config.Timeout = time.Duration(remoteArchiveTimeoutFlag) * time.Second
214218
remote.Config.CacheAllThreshold = int64(remoteArchiveCacheAll)
215219

220+
var authProvider auth.AuthProvider
221+
switch mode {
222+
case "base64":
223+
authProvider = auth.NewB64EncodedAuthProvider()
224+
slog.Info("Operating in open access mode with base64url encoding (insecure)")
225+
case "jwt":
226+
var sharedSecret []byte
227+
if jwtSharedSecret == "" {
228+
// Auto-generate shared secret
229+
var rawSecret [32]byte
230+
_, err := rand.Reader.Read(rawSecret[:])
231+
if err != nil {
232+
return fmt.Errorf("failed to generate random shared secret: %w", err)
233+
}
234+
sharedSecret = rawSecret[:]
235+
slog.Info("Operating in HS256 JWT access mode", "secret", hex.EncodeToString(sharedSecret))
236+
} else {
237+
sharedSecret, err = hex.DecodeString(jwtSharedSecret)
238+
if err != nil {
239+
return fmt.Errorf("failed to decode hex-encoded JWT shared secret: %w", err)
240+
}
241+
slog.Info("Operating in HS256 JWT access mode", "secret", "<jwt-shared-secret flag>")
242+
}
243+
authProvider, err = auth.NewJWTAuthProvider(sharedSecret)
244+
if err != nil {
245+
return fmt.Errorf("failed creating JWT auth provider: %w", err)
246+
}
247+
case "jwks":
248+
if jwksURL == "" {
249+
return fmt.Errorf("jwks-url must be specified in jwks mode")
250+
}
251+
slog.Info("Operating in JWKS JWT access mode", "jwks_url", jwksURL)
252+
authProvider, err = auth.NewJWKSAuthProvider(context.Background(), remote.HTTP, jwksURL)
253+
if err != nil {
254+
return fmt.Errorf("failed creating JWKS auth provider: %w", err)
255+
}
256+
default:
257+
return fmt.Errorf("invalid access mode %q, acceptable values: base64, jwt, jwks", mode)
258+
}
259+
216260
// Create server
217261
pubServer := serve.NewServer(serve.ServerConfig{
218262
Debug: debugFlag,
219263
JSONIndent: indentFlag,
220264
InferA11yMetadata: streamer.InferA11yMetadata(inferA11yFlag),
265+
Auth: authProvider,
221266
}, remote)
222267

223268
bind := fmt.Sprintf("%s:%d", bindAddressFlag, bindPortFlag)
224269
httpServer := &http.Server{
225270
ReadTimeout: 10 * time.Second,
226-
WriteTimeout: 10 * time.Second,
271+
WriteTimeout: 600 * time.Second, // 5 minutes for server to respond with resource
227272
MaxHeaderBytes: 1 << 20,
228273
Addr: bind,
229274
Handler: pubServer.Routes(),
@@ -248,6 +293,10 @@ func init() {
248293
serveCmd.Flags().StringVarP(&indentFlag, "indent", "i", "", "Indentation used to pretty-print JSON files")
249294
serveCmd.Flags().Var(&inferA11yFlag, "infer-a11y", "Infer accessibility metadata: no, merged, split")
250295
serveCmd.Flags().BoolVarP(&debugFlag, "debug", "d", false, "Enable debug mode")
296+
serveCmd.Flags().StringVarP(&mode, "mode", "m", "base64", "Access mode: base64 (default, base64url-encoded paths), jwt (JWT auth with a shared secret), jwks (JWT auth with keys in a JWKS)")
297+
298+
serveCmd.Flags().StringVar(&jwtSharedSecret, "jwt-shared-secret", "", "Hex-encoded shared secret used for HS256 JWT signature validation. If omitted, but JWT auth is enabled, the secret is auto-generated and logged (debug) at runtime")
299+
serveCmd.Flags().StringVar(&jwksURL, "jwks-url", "", "URL to a JWKS (JSON Web Key Set) used for JWT signature validation when in 'jwks' mode")
251300

252301
serveCmd.Flags().StringVar(&fileDirectoryFlag, "file-directory", "", "Local directory path to serve publications from")
253302

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package cli
1+
package version
22

33
import (
44
"runtime/debug"

pkg/serve/api.go

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ package serve
33
import (
44
"bytes"
55
"context"
6-
"encoding/base64"
76
"encoding/json"
87
"log/slog"
98
"net/http"
10-
"os"
119
"path"
1210
"path/filepath"
1311
"slices"
@@ -29,43 +27,8 @@ import (
2927
"github.com/zeebo/xxh3"
3028
)
3129

32-
type demoListItem struct {
33-
Filename string `json:"filename"`
34-
Path string `json:"path"`
35-
}
36-
37-
// TODO: replace with OPDS or something better
38-
func (s *Server) demoList(w http.ResponseWriter, req *http.Request) {
39-
if s.remote.LocalDirectory == "" {
40-
slog.Warn("demo publication list requested, but no local directory configured")
41-
w.WriteHeader(404)
42-
return
43-
}
44-
45-
fi, err := os.ReadDir(s.remote.LocalDirectory)
46-
if err != nil {
47-
slog.Error("failed reading publications directory", "error", err)
48-
w.WriteHeader(500)
49-
return
50-
}
51-
files := make([]demoListItem, len(fi))
52-
for i, f := range fi {
53-
files[i] = demoListItem{
54-
Filename: f.Name(),
55-
Path: base64.RawURLEncoding.EncodeToString([]byte(f.Name())),
56-
}
57-
}
58-
enc := json.NewEncoder(w)
59-
enc.SetIndent("", s.config.JSONIndent)
60-
enc.Encode(files)
61-
}
62-
6330
func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publication, bool, time.Time, error) {
64-
fpath, err := base64.RawURLEncoding.DecodeString(filename)
65-
if err != nil {
66-
return nil, false, time.Time{}, err
67-
}
68-
loc, err := url.URLFromString(string(fpath))
31+
loc, err := url.URLFromString(filename)
6932
if err != nil {
7033
return nil, false, time.Time{}, errors.Wrap(err, "failed creating URL from filepath")
7134
}
@@ -142,7 +105,7 @@ func (s *Server) getPublication(ctx context.Context, filename string) (*pub.Publ
142105

143106
func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) {
144107
vars := mux.Vars(req)
145-
filename := vars["path"]
108+
filename := req.Context().Value(ContextPathKey).(string)
146109

147110
// Load the publication
148111
publication, _, cachedAt, err := s.getPublication(req.Context(), filename)
@@ -211,7 +174,7 @@ func (s *Server) getManifest(w http.ResponseWriter, req *http.Request) {
211174

212175
func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) {
213176
vars := mux.Vars(r)
214-
filename := vars["path"]
177+
filename := r.Context().Value(ContextPathKey).(string)
215178

216179
// Load the publication
217180
publication, remote, _, err := s.getPublication(r.Context(), filename)
@@ -306,6 +269,10 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) {
306269

307270
cres, ok := res.(fetcher.CompressedResource)
308271
normalResponse := func() {
272+
if r.Method == http.MethodHead {
273+
return
274+
}
275+
309276
if remote {
310277
var bin []byte
311278
bin, rerr = res.Read(r.Context(), start, end)
@@ -326,6 +293,10 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) {
326293
w.Header().Set("content-encoding", "deflate")
327294
w.Header().Set("content-length", strconv.FormatInt(cres.CompressedLength(r.Context()), 10))
328295
}
296+
if r.Method == http.MethodHead {
297+
headers()
298+
return
299+
}
329300
if remote {
330301
var bin []byte
331302
bin, rerr = cres.ReadCompressed(r.Context())
@@ -345,6 +316,10 @@ func (s *Server) getAsset(w http.ResponseWriter, r *http.Request) {
345316
w.Header().Set("content-encoding", "gzip")
346317
w.Header().Set("content-length", strconv.FormatInt(cres.CompressedLength(r.Context())+archive.GzipWrapperLength, 10))
347318
}
319+
if r.Method == http.MethodHead {
320+
headers()
321+
return
322+
}
348323
if remote {
349324
var bin []byte
350325
bin, rerr = cres.ReadCompressedGzip(r.Context())

pkg/serve/auth/auth.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package auth
2+
3+
type AuthProvider interface {
4+
Validate(token string) (string, int, error)
5+
}

pkg/serve/auth/encoded.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package auth
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"net/http"
7+
)
8+
9+
type B64EncodedAuthProvider struct{}
10+
11+
func (n *B64EncodedAuthProvider) Validate(token string) (string, int, error) {
12+
path, err := base64.RawURLEncoding.DecodeString(token)
13+
if err != nil {
14+
return "", http.StatusBadRequest, fmt.Errorf("invalid base64url path: %w", err)
15+
}
16+
return string(path), http.StatusOK, nil
17+
}
18+
19+
func NewB64EncodedAuthProvider() *B64EncodedAuthProvider {
20+
return &B64EncodedAuthProvider{}
21+
}

0 commit comments

Comments
 (0)