feat: add metadata endpoint
All checks were successful
CI / Tests (pull_request) Successful in 14s
CI / Style (pull_request) Successful in 9s

Add the metadata endpoint so that clients can get the server's
authorization and token endpoints.

Changes:

- Add a config object to allow administrators to configure the binding
  address, binding port and domain name.
- Add a target in the magefiles and a step in the CI workflow to run
  go vet.
- Add the Dockerfile.
- Replace the --address flag with the --config flag.
- Add the metadata endpoint to the router to return a JSON document
  containing the URLs to the metadata and token endpoints.
This commit is contained in:
Dan Anglin 2024-10-15 18:22:55 +01:00
parent 4fdad2d805
commit 7d0336d3db
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
11 changed files with 165 additions and 27 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
*
!__build/indieauth-server

View file

@ -28,6 +28,10 @@ jobs:
uses: https://codeflow.dananglin.me.uk/actions/mage-ci@main uses: https://codeflow.dananglin.me.uk/actions/mage-ci@main
with: with:
target: gosec target: gosec
- name: Run go vet
uses: https://codeflow.dananglin.me.uk/actions/mage-ci@main
with:
target: govet
style: style:
name: Style name: Style

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/__build/* /__build/*
!__build/.gitkeep !__build/.gitkeep
/.environment/*

5
Dockerfile Normal file
View file

@ -0,0 +1,5 @@
FROM gcr.io/distroless/static-debian12
COPY ./__build/indieauth-server /indieauth-server
ENTRYPOINT ["/indieauth-server"]

38
internal/config/config.go Normal file
View file

@ -0,0 +1,38 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type Config struct {
BindAddress string `json:"bindAddress"`
Port int32 `json:"port"`
Domain string `json:"domain"`
}
func NewConfig(path string) (Config, error) {
path = filepath.Clean(path)
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf(
"unable to read the config from %q: %w",
path,
err,
)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf(
"unable to decode the JSON data: %w",
err,
)
}
return cfg, nil
}

View file

@ -3,18 +3,15 @@ package executors
import ( import (
"flag" "flag"
"fmt" "fmt"
"log/slog"
"net/http"
"time"
"codeflow.dananglin.me.uk/apollo/indieauth-server/internal/info" "codeflow.dananglin.me.uk/apollo/indieauth-server/internal/config"
"codeflow.dananglin.me.uk/apollo/indieauth-server/internal/router" "codeflow.dananglin.me.uk/apollo/indieauth-server/internal/server"
) )
type serveExecutor struct { type serveExecutor struct {
*flag.FlagSet *flag.FlagSet
address string configFile string
} }
func executeServeCommand(args []string) error { func executeServeCommand(args []string) error {
@ -24,7 +21,7 @@ func executeServeCommand(args []string) error {
FlagSet: flag.NewFlagSet(executorName, flag.ExitOnError), FlagSet: flag.NewFlagSet(executorName, flag.ExitOnError),
} }
executor.StringVar(&executor.address, "address", "0.0.0.0:8080", "The address that the server will listen on") executor.StringVar(&executor.configFile, "config", "", "The path to the config file")
if err := executor.Parse(args); err != nil { if err := executor.Parse(args); err != nil {
return fmt.Errorf("(%s) flag parsing error: %w", executorName, err) return fmt.Errorf("(%s) flag parsing error: %w", executorName, err)
@ -38,18 +35,12 @@ func executeServeCommand(args []string) error {
} }
func (e *serveExecutor) execute() error { func (e *serveExecutor) execute() error {
server := http.Server{ cfg, err := config.NewConfig(e.configFile)
Addr: e.address,
Handler: router.NewServeMux(),
ReadHeaderTimeout: 1 * time.Second,
}
slog.Info(info.ApplicationName+" is listening for web requests", "address", e.address)
err := server.ListenAndServe()
if err != nil { if err != nil {
return fmt.Errorf("error running the server: %w", err) return fmt.Errorf("unable to load the config: %w", err)
} }
return nil srv := server.NewServer(cfg)
return srv.Serve()
} }

View file

@ -1,9 +0,0 @@
package router
import "net/http"
func NewServeMux() *http.ServeMux {
mux := http.NewServeMux()
return mux
}

View file

@ -0,0 +1,22 @@
package server
import (
"encoding/json"
"log/slog"
"net/http"
)
func sendJSONResponse(w http.ResponseWriter, statusCode int, payload any) {
data, err := json.Marshal(payload)
if err != nil {
slog.Error("Error marshalling the response to JSON", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, _ = w.Write(data)
}

36
internal/server/router.go Normal file
View file

@ -0,0 +1,36 @@
package server
import (
"fmt"
"net/http"
"codeflow.dananglin.me.uk/apollo/indieauth-server/internal/config"
)
func newMux(cfg config.Config) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/oauth-authorization-server", metadataHandleFunc(cfg.Domain))
return mux
}
func metadataHandleFunc(domain string) http.HandlerFunc {
return func(writer http.ResponseWriter, _ *http.Request) {
metadata := struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
ServiceDocumentation string `json:"service_documentation"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
}{
Issuer: fmt.Sprintf("https://%s/", domain),
AuthorizationEndpoint: fmt.Sprintf("https://%s/auth", domain),
TokenEndpoint: fmt.Sprintf("https://%s/token", domain),
ServiceDocumentation: "https://indieauth.spec.indieweb.org",
CodeChallengeMethodsSupported: []string{"S256"},
}
sendJSONResponse(writer, http.StatusOK, metadata)
}
}

41
internal/server/server.go Normal file
View file

@ -0,0 +1,41 @@
package server
import (
"fmt"
"log/slog"
"net/http"
"time"
"codeflow.dananglin.me.uk/apollo/indieauth-server/internal/config"
"codeflow.dananglin.me.uk/apollo/indieauth-server/internal/info"
)
type Server struct {
httpServer *http.Server
}
func NewServer(cfg config.Config) *Server {
address := fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.Port)
httpServer := http.Server{
Addr: address,
ReadHeaderTimeout: 1 * time.Second,
Handler: newMux(cfg),
}
server := Server{
httpServer: &httpServer,
}
return &server
}
func (s *Server) Serve() error {
slog.Info(info.ApplicationName+" is now ready to serve web requests", "address", s.httpServer.Addr)
if err := s.httpServer.ListenAndServe(); err != nil {
return fmt.Errorf("error running the server: %w", err)
}
return nil
}

View file

@ -55,10 +55,12 @@ func Lint() error {
return sh.RunV("golangci-lint", "run", "--color", "always") return sh.RunV("golangci-lint", "run", "--color", "always")
} }
// Gosec runs gosec against the code.
func Gosec() error { func Gosec() error {
return sh.RunV("gosec", "./...") return sh.RunV("gosec", "./...")
} }
// Staticcheck runs staticcheck against the code.
func Staticcheck() error { func Staticcheck() error {
return sh.RunV("staticcheck", "./...") return sh.RunV("staticcheck", "./...")
} }
@ -90,6 +92,11 @@ func Gofmt() error {
return nil return nil
} }
// Govet runs go vet against the code.
func Govet() error {
return sh.RunV("go", "vet", "./...")
}
// Build build the executable. // Build build the executable.
// To rebuild packages that are already up-to-date set PROJECT_BUILD_REBUILD_ALL=1 // To rebuild packages that are already up-to-date set PROJECT_BUILD_REBUILD_ALL=1
// To enable verbose mode set PROJECT_BUILD_VERBOSE=1 // To enable verbose mode set PROJECT_BUILD_VERBOSE=1