package api import ( "context" "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strconv" "strings" "time" "git.rootprojects.org/root/keypairs" "git.rootprojects.org/root/keypairs/keyfetch" "github.com/go-chi/chi" ) // TrustProxy will respect X-Forwarded-* headers var TrustProxy bool // OIDCWhitelist is a list of allowed issuers var OIDCWhitelist string var issuers keyfetch.Whitelist // RandReader is a crypto/rand.Reader by default var RandReader io.Reader = rand.Reader var startedAt = time.Now() var defaultMaxBytes int64 = 1 << 20 var apiIsReady bool // Init will add the API routes to the given router func Init(pub keypairs.PublicKey, r chi.Router) http.Handler { // TODO more of this stuff should be options for the API { // block-scoped so we don't keep temp vars around var err error list := strings.Fields(strings.ReplaceAll(strings.TrimSpace(OIDCWhitelist), ",", " ")) issuers, err = keyfetch.NewWhitelist(list) if nil != err { log.Fatal("error parsing oidc whitelist:", err) } } // OIDC Routes if nil != pub { fmt.Println("Public Key Thumbprint:", pub.Thumbprint()) fmt.Println("OIDC enabled at /.well-known/openid-configuration") r.Get("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { baseURL := getBaseURL(r) w.Header().Set("Content-Type", "application/json") w.Write([]byte(fmt.Sprintf( `{ "issuer": "%s", "jwks_uri": "%s/.well-known/jwks.json" }`+"\n", baseURL, baseURL, ))) }) fmt.Println("JWKs enabled at /.well-known/jwks.json") r.Get("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // non-standard: add expiry for when key should be fetched again // TODO expiry should also go in the HTTP caching headers exp := time.Now().Add(2 * time.Hour) b := pubToOIDC(pub, exp) // it's the little things w.Write(append(b, '\n')) }) } r.Route("/api", func(r chi.Router) { r.Use(limitResponseSize) r.Use(jsonAllTheThings) /* n, err := countAdmins() if nil != err { log.Fatal("could not connect to database on boot:", err) } apiIsReady = n > 0 */ // Unauthenticated routes r.Route("/public", func(r chi.Router) { r.Post("/setup", publicSetup) r.Get("/ping", ping) }) // Require admin-level permission r.Route("/admin", func(r chi.Router) { r.Use(errorUnlessReady()) r.Use(mustAdmin()) r.Get("/ping", ping) }) // Any authenticated user r.Route("/user", func(r chi.Router) { r.Use(errorUnlessReady()) r.Use(canImpersonate()) r.Get("/ping", ping) // TODO get ALL of the user's data //r.Get("/", userComplete) }) }) return r } // Reset sets the API back to its initial state func Reset() { apiIsReady = false } // utils func getBaseURL(r *http.Request) string { var scheme string if nil != r.TLS || (TrustProxy && "https" == r.Header.Get("X-Forwarded-Proto")) { scheme = "https:" } else { scheme = "http:" } return fmt.Sprintf( "%s//%s", scheme, r.Host, ) } func mustAuthn(r *http.Request) (*http.Request, error) { authzParts := strings.Split(r.Header.Get("Authorization"), " ") if 2 != len(authzParts) { return nil, fmt.Errorf("Bad Request: missing Auhorization header") } jwt := authzParts[1] // TODO should probably add an error to keypairs jws := keypairs.JWTToJWS(jwt) if nil == jws { return nil, fmt.Errorf("Bad Request: malformed Authorization header") } if err := jws.DecodeComponents(); nil != err { return nil, fmt.Errorf("Bad Request: malformed JWT") } kid, _ := jws.Header["kid"].(string) if "" == kid { return nil, fmt.Errorf("Bad Request: missing 'kid' identifier") } iss, _ := jws.Claims["iss"].(string) // TODO beware domain fronting, we should set domain statically // See https://pkg.go.dev/git.rootprojects.org/root/keypairs@v0.6.2/keyfetch // (Caddy does protect against Domain-Fronting by default: // https://github.com/caddyserver/caddy/issues/2500) if "" == iss || !issuers.IsTrustedIssuer(iss, r) { return nil, fmt.Errorf("Bad Request: 'iss' is not a trusted issuer") } pub, err := keyfetch.OIDCJWK(kid, iss) if nil != err { return nil, fmt.Errorf("Bad Request: 'kid' could not be matched to a known public key") } errs := keypairs.VerifyClaims(pub, jws) if nil != errs { strs := []string{} for _, err := range errs { strs = append(strs, err.Error()) } return nil, fmt.Errorf("invalid jwt:\n%s", strings.Join(strs, "\n\t")) } email, _ := jws.Claims["email"].(string) ppid, _ := jws.Claims["sub"].(string) if "" == email || "" == ppid { return nil, fmt.Errorf("valid signed token, but missing claim for either 'email' or 'sub'") } ctx := context.WithValue(r.Context(), userPPID, ppid) return r.WithContext(ctx), nil } func pubToOIDC(pub keypairs.PublicKey, exp time.Time) []byte { exps := strconv.FormatInt(exp.Unix(), 10) jsons := string(keypairs.MarshalJWKPublicKey(pub)) // this isn't as fragile as it looks, just adding some OIDC keys and such // make prettier jsons = strings.Replace(jsons, `{"`, `{ "`, 1) jsons = strings.Replace(jsons, `",`, `" ,`, -1) // nix trailing } jsons = jsons[0 : len(jsons)-1] // add on the OIDC stuff (exp is non-standard, but used by pocketid) jsons = `{ "keys": [ ` + jsons + fmt.Sprintf(`, "ext": true , "key_ops": ["verify"], "exp": %s }`, exps) + " ] }" return []byte(jsons) } // HTTPResponse gives a basic status message // TODO sanitize all error messages and define error codes type HTTPResponse struct { Error string `json:"error,omitempty"` Code string `json:"code,omitempty"` Success bool `json:"success"` Data interface{} `json:"result,omitempty"` } func ping(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // as of yet, only known to be a user ppid, _ := ctx.Value(userPPID).(string) w.Write([]byte(fmt.Sprintf( `{ "success": true, "uptime": %.0f, "ppid": %q }`+"\n", time.Since(startedAt).Seconds(), ppid, ))) } func noImpl(w http.ResponseWriter, r *http.Request) { w.Write([]byte( `{ "success": false, "error": "not implemented" }` + "\n", )) } func publicSetup(w http.ResponseWriter, r *http.Request) { if apiIsReady { // default is already 404, methinks http.Error(w, "Not Found", http.StatusNotFound) return } r, err := mustAuthn(r) if nil != err { userError(w, err) return } ctx := r.Context() ppid := ctx.Value(userPPID).(string) if "" == ppid { if nil != err { userError(w, err) return } } if err := adminBootstrap(ppid); nil != err { serverError("publicSetup.bootstrap", w, err) return } apiIsReady = true w.Write([]byte(fmt.Sprintf( `{ "success": true, "sub": %q }`+"\n", ppid, ))) } func reply(w http.ResponseWriter, msg interface{}) { w.WriteHeader(http.StatusOK) b, _ := json.MarshalIndent(&HTTPResponse{ Success: true, Data: msg, }, "", " ") w.Write(append(b, '\n')) } func userError(w http.ResponseWriter, err error) { w.WriteHeader(http.StatusBadRequest) b, _ := json.Marshal(&HTTPResponse{ Error: err.Error(), }) w.Write(append(b, '\n')) } func dbError(hint string, w http.ResponseWriter, err error) { serverError(hint, w, err) } func serverError(hint string, w http.ResponseWriter, err error) { // TODO check constraint errors and such, as those are likely user errors if sql.ErrNoRows == err { userError(w, fmt.Errorf("E_NOT_FOUND: %s", err)) return } else if strings.Contains(err.Error(), "constraint") { userError(w, fmt.Errorf("E_DUPLICATE_NAME: %s", err)) return } log.Printf("[%s] error: %v", hint, err) w.WriteHeader(http.StatusInternalServerError) b, _ := json.Marshal(&HTTPResponse{ Error: err.Error(), }) w.Write(append(b, '\n')) } var errSetID = errors.New("you may not set the player's ID") // Middleware func errorUnlessReady() func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !apiIsReady { http.Error(w, "Not Found", http.StatusNotFound) return } next.ServeHTTP(w, r) }) } } type adminCtx string const ( adminPPID adminCtx = "ppid" ) type userCtx string const ( userPPID userCtx = "ppid" ) func canImpersonate() func(next http.Handler) http.Handler { return actAsAdmin(false) } func mustAdmin() func(next http.Handler) http.Handler { return actAsAdmin(true) } func countAdmins() (int, error) { return 0, errors.New("not implemented") } func adminBootstrap(ppid string) error { return errors.New("not implemented") } func actAsAdmin(must bool) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error r, err = mustAuthn(r) if nil != err { userError(w, err) return } ctx := r.Context() // as of yet, only known to be a user ppid, _ := ctx.Value(userPPID).(string) //ok, err := isAdmin(ppid) ok, err := false, errors.New("not implemented") if nil != err { serverError("actAsAdmin", w, err) return } if !ok { if must { userError(w, errors.New("you're not an admin")) return } next.ServeHTTP(w, r) return } // we now know this is an admin, adjust accordingly ctx = context.WithValue(r.Context(), adminPPID, ppid) // also, an admin can impersonate //uemail := r.URL.Query().Get("user_email") uppid := r.URL.Query().Get("manager_id") if "" == uppid { uppid = ppid } ctx = context.WithValue(ctx, userPPID, uppid) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func jsonAllTheThings(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // just setting a default, other handlers can change this w.Header().Set("Content-Type", "application/json") next.ServeHTTP(w, r) }) } func limitResponseSize(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, defaultMaxBytes) next.ServeHTTP(w, r) }) }