package adminapi import ( "encoding/json" "errors" "net/http" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // registrationToken is a token as the API shows it. type registrationToken struct { Token string `json:"token"` // UsesAllowed is null for a token with no use limit. UsesAllowed *int `json:"uses_allowed"` Completed int `json:"completed"` // ExpiryTimeMS is null for a token that never expires. ExpiryTimeMS *int64 `json:"expiry_time_ms"` CreatedAtMS int64 `json:"created_at_ms"` // Valid is whether a registration could use the token now. Valid bool `json:"valid"` } func (a *AdminAPI) registrationTokenOf(t storage.RegistrationToken) registrationToken { out := registrationToken{ Token: t.Token, UsesAllowed: t.UsesAllowed, Completed: t.Completed, CreatedAtMS: t.CreatedAtMS, Valid: t.Usable(a.now().UnixMilli()), } if t.ExpiresAtMS != 0 { expiry := t.ExpiresAtMS out.ExpiryTimeMS = &expiry } return out } // handleGetRegistrationTokens implements GET /_warp/admin/v1/registration_tokens. // // The tokens of the config file are not listed. They are in the config // file, and listing them here would be the one place a secret from it is // served over HTTP. func (a *AdminAPI) handleGetRegistrationTokens(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { tokens, err := a.tokens.List(r.Context()) if err != nil { return err } out := make([]registrationToken, 0, len(tokens)) for _, t := range tokens { out = append(out, a.registrationTokenOf(t)) } httputil.NoCache(w) httputil.WriteJSON(w, http.StatusOK, map[string]any{ "registration_tokens": out, // Whether the sign-up flow asks for a token at all. A token made // on a server that does not ask for one is never needed. "required": a.tokens.Required(), }) return nil } // createTokenRequest is the POST /registration_tokens body. type createTokenRequest struct { // Token is the token to make; empty generates one of Length // characters, 16 if that is absent too. Token string `json:"token"` Length int `json:"length"` UsesAllowed *int `json:"uses_allowed"` ExpiryTimeMS *int64 `json:"expiry_time_ms"` } // handlePostRegistrationToken implements POST /_warp/admin/v1/registration_tokens. func (a *AdminAPI) handlePostRegistrationToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { var req createTokenRequest if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil { return err } var expiry int64 if req.ExpiryTimeMS != nil { expiry = *req.ExpiryTimeMS } created, err := a.tokens.CreateRegistrationToken(r.Context(), userapi.NewRegistrationToken{ Token: req.Token, Length: req.Length, UsesAllowed: req.UsesAllowed, ExpiresAtMS: expiry, }) switch { case errors.Is(err, userapi.ErrInvalidRegistrationToken): return httputil.InvalidParam("%s", err.Error()) case errors.Is(err, userapi.ErrRegistrationTokenExists): return httputil.InvalidParam("That registration token already exists") case err != nil: return err } a.audit(r.Context(), auth, auditTokenCreate, created.Token, map[string]any{ "uses_allowed": created.UsesAllowed, "expiry_time_ms": created.ExpiresAtMS, }) httputil.WriteJSON(w, http.StatusCreated, a.registrationTokenOf(created)) return nil } // handleGetRegistrationToken implements GET /_warp/admin/v1/registration_tokens/{token}. func (a *AdminAPI) handleGetRegistrationToken(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { t, err := a.tokens.RegistrationToken(r.Context(), r.PathValue("token")) if err != nil { return tokenError(err) } httputil.NoCache(w) httputil.WriteJSON(w, http.StatusOK, a.registrationTokenOf(t)) return nil } // updateTokenRequest is the PUT /registration_tokens/{token} body. Each // field is raw so that "absent" (keep it) can be told from null (remove // the limit). type updateTokenRequest struct { UsesAllowed json.RawMessage `json:"uses_allowed"` ExpiryTimeMS json.RawMessage `json:"expiry_time_ms"` } // handlePutRegistrationToken implements PUT /_warp/admin/v1/registration_tokens/{token}. // // A field left out keeps its value; a field set to null removes the // limit. Raising uses_allowed on a used-up token makes it usable again. func (a *AdminAPI) handlePutRegistrationToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { token := r.PathValue("token") current, err := a.tokens.RegistrationToken(r.Context(), token) if err != nil { return tokenError(err) } var req updateTokenRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } uses, expiry := current.UsesAllowed, current.ExpiresAtMS if len(req.UsesAllowed) > 0 { if string(req.UsesAllowed) == "null" { uses = nil } else { var n int if err := json.Unmarshal(req.UsesAllowed, &n); err != nil { return httputil.InvalidParam("uses_allowed must be a number or null") } uses = &n } } if len(req.ExpiryTimeMS) > 0 { if string(req.ExpiryTimeMS) == "null" { expiry = 0 } else if err := json.Unmarshal(req.ExpiryTimeMS, &expiry); err != nil { return httputil.InvalidParam("expiry_time_ms must be a number or null") } } updated, err := a.tokens.Update(r.Context(), token, uses, expiry) if err != nil { return tokenError(err) } a.audit(r.Context(), auth, auditTokenUpdate, token, map[string]any{ "uses_allowed": updated.UsesAllowed, "expiry_time_ms": updated.ExpiresAtMS, }) httputil.WriteJSON(w, http.StatusOK, a.registrationTokenOf(updated)) return nil } // handleDeleteRegistrationToken implements DELETE // /_warp/admin/v1/registration_tokens/{token}. Accounts registered with // the token are not affected. func (a *AdminAPI) handleDeleteRegistrationToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { token := r.PathValue("token") if err := a.tokens.Delete(r.Context(), token); err != nil { return tokenError(err) } a.audit(r.Context(), auth, auditTokenDelete, token, nil) httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } func tokenError(err error) error { switch { case errors.Is(err, userapi.ErrNoSuchRegistrationToken): return httputil.NotFound("No such registration token") case errors.Is(err, userapi.ErrInvalidRegistrationToken): return httputil.InvalidParam("%s", err.Error()) default: return err } }