Commit 1dd7d800 authored by Sietse Ringers's avatar Sietse Ringers

refactor: move session callback and static sessions into servercore

parent e463aa2e
......@@ -29,6 +29,7 @@ type Server struct {
sessions sessionStore
scheduler *gocron.Scheduler
stopScheduler chan bool
handlers map[string]server.SessionHandler
}
func New(conf *server.Configuration) (*Server, error) {
......@@ -46,8 +47,8 @@ func New(conf *server.Configuration) (*Server, error) {
},
ServerSentEvents: sse.NewServer(&sse.Options{
ChannelNameFunc: func(r *http.Request) string {
token, noun, _, err := ParsePath(r.URL.Path)
if err == nil && token != "" && noun == "statusevents" {
component, token, noun, _, err := Route(r.URL.Path, r.Method)
if err == nil && component == server.ComponentSession && noun == "statusevents" {
return "session/" + token
}
return ""
......@@ -59,6 +60,7 @@ func New(conf *server.Configuration) (*Server, error) {
},
Logger: log.New(conf.Logger.WithField("type", "sse").WriterLevel(logrus.DebugLevel), "", 0),
}),
handlers: make(map[string]server.SessionHandler),
}
s.scheduler.Every(10).Seconds().Do(func() {
......@@ -101,7 +103,7 @@ func (s *Server) validateRequest(request irma.SessionRequest) error {
return request.Disclosure().Disclose.Validate(s.conf.IrmaConfiguration)
}
func (s *Server) StartSession(req interface{}) (*irma.Qr, string, error) {
func (s *Server) StartSession(req interface{}, handler server.SessionHandler) (*irma.Qr, string, error) {
rrequest, err := server.ParseSessionRequest(req)
if err != nil {
return nil, "", err
......@@ -127,6 +129,9 @@ func (s *Server) StartSession(req interface{}) (*irma.Qr, string, error) {
} else {
s.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Info("Session request (purged of attribute values): ", server.ToJson(purgeRequest(rrequest)))
}
if handler != nil {
s.handlers[session.token] = handler
}
return &irma.Qr{
Type: action,
URL: s.conf.URL + "session/" + session.clientToken,
......@@ -164,23 +169,24 @@ func (s *Server) Revoke(credid irma.CredentialTypeIdentifier, key string, issued
return s.conf.IrmaConfiguration.Revocation.Revoke(credid, key, issued)
}
const (
ComponentRevocation = "revocation"
ComponentSession = "session"
)
func ParsePath(path string) (component, token, noun string, arg []string, err error) {
rev := regexp.MustCompile(ComponentRevocation + "/(events|update|issuancerecord)/?(.*)$")
func Route(path, method string) (component, token, noun string, arg []string, err error) {
rev := regexp.MustCompile(server.ComponentRevocation + "/(events|update|issuancerecord)/?(.*)$")
matches := rev.FindStringSubmatch(path)
if len(matches) == 3 {
args := strings.Split(matches[2], "/")
return ComponentRevocation, "", matches[1], args, nil
return server.ComponentRevocation, "", matches[1], args, nil
}
static := regexp.MustCompile(server.ComponentSession + "/(\\w+)$")
matches = static.FindStringSubmatch(path)
if len(matches) == 2 && method == http.MethodPost {
return server.ComponentStatic, matches[1], "", nil, nil
}
client := regexp.MustCompile(ComponentSession + "/(\\w+)/?(|commitments|proofs|status|statusevents)$")
client := regexp.MustCompile(server.ComponentSession + "/(\\w+)/?(|commitments|proofs|status|statusevents)$")
matches = client.FindStringSubmatch(path)
if len(matches) == 3 {
return ComponentSession, matches[1], matches[2], nil, nil
return server.ComponentSession, matches[1], matches[2], nil, nil
}
return "", "", "", nil, server.LogWarning(errors.Errorf("Invalid URL: %s", path))
......@@ -259,16 +265,18 @@ func (s *Server) handleProtocolMessage(
}
}
component, token, noun, args, err := ParsePath(path)
component, token, noun, args, err := Route(path, method)
if err != nil {
status, output = server.JsonResponse(nil, server.RemoteError(server.ErrorUnsupported, ""))
}
switch component {
case ComponentSession:
case server.ComponentSession:
status, output, result = s.handleClientMessage(token, noun, method, headers, message)
case ComponentRevocation:
case server.ComponentRevocation:
status, output, retheaders = s.handleRevocationMessage(noun, method, args, headers, message)
case server.ComponentStatic:
status, output = s.handleStaticMessage(token, method, message)
default:
status, output = server.JsonResponse(nil, server.RemoteError(server.ErrorUnsupported, component))
}
......@@ -294,6 +302,12 @@ func (s *Server) handleClientMessage(
if session.status != session.prevStatus {
session.prevStatus = session.status
result = session.result
if result != nil && result.Status.Finished() {
if handler := s.handlers[result.Token]; handler != nil {
go handler(result)
delete(s.handlers, token)
}
}
}
}()
......@@ -474,3 +488,33 @@ func (s *Server) handleRevocationMessage(
return server.BinaryResponse(nil, server.RemoteError(server.ErrorInvalidRequest, ""), nil)
}
func (s *Server) handleStaticMessage(
id, method string, message []byte,
) (int, []byte) {
if method != http.MethodPost {
return server.JsonResponse(nil, server.RemoteError(server.ErrorInvalidRequest, ""))
}
rrequest := s.conf.StaticSessionRequests[id]
if rrequest == nil {
return server.JsonResponse(nil, server.RemoteError(server.ErrorInvalidRequest, "unknown static session"))
}
qr, _, err := s.StartSession(rrequest, s.doResultCallback)
if err != nil {
return server.JsonResponse(nil, server.RemoteError(server.ErrorMalformedInput, err.Error()))
}
return server.JsonResponse(qr, nil)
}
func (s *Server) doResultCallback(result *server.SessionResult) {
url := s.GetRequest(result.Token).Base().CallbackURL
if url == "" {
return
}
server.DoResultCallback(url,
result,
s.conf.JwtIssuer,
s.GetRequest(result.Token).Base().ResultJwtValidity,
s.conf.JwtRSAPrivateKey,
)
}
......@@ -114,6 +114,20 @@ var JwtServerConfiguration = &requestorserver.Configuration{
RevocationServerURL: "http://localhost:48683/",
},
},
JwtPrivateKeyFile: filepath.Join(testdata, "jwtkeys", "sk.pem"),
StaticSessions: map[string]interface{}{
"staticsession": irma.ServiceProviderRequest{
RequestorBaseRequest: irma.RequestorBaseRequest{
CallbackURL: "http://localhost:48685",
},
Request: &irma.DisclosureRequest{
BaseRequest: irma.BaseRequest{LDContext: irma.LDContextDisclosureRequest},
Disclose: irma.AttributeConDisCon{
{{irma.NewAttributeRequest("irma-demo.RU.studentCard.level")}},
},
},
},
},
},
Port: 48682,
DisableRequestorAuthentication: false,
......@@ -137,18 +151,4 @@ var JwtServerConfiguration = &requestorserver.Configuration{
AuthenticationKey: "eGE2PSomOT84amVVdTU+LmYtJXJWZ2BmNjNwSGltCg==",
},
},
StaticSessions: map[string]interface{}{
"staticsession": irma.ServiceProviderRequest{
RequestorBaseRequest: irma.RequestorBaseRequest{
CallbackURL: "http://localhost:48685",
},
Request: &irma.DisclosureRequest{
BaseRequest: irma.BaseRequest{LDContext: irma.LDContextDisclosureRequest},
Disclose: irma.AttributeConDisCon{
{{irma.NewAttributeRequest("irma-demo.RU.studentCard.level")}},
},
},
},
},
JwtPrivateKeyFile: filepath.Join(testdata, "jwtkeys", "sk.pem"),
}
......@@ -216,6 +216,9 @@ func configureServer(cmd *cobra.Command) error {
LogJSON: viper.GetBool("log-json"),
Logger: logger,
Production: viper.GetBool("production"),
JwtIssuer: viper.GetString("jwt-issuer"),
JwtPrivateKey: viper.GetString("jwt-privkey"),
JwtPrivateKeyFile: viper.GetString("jwt-privkey-file"),
},
Permissions: requestorserver.Permissions{
Disclosing: handlePermission("disclose-perms"),
......@@ -229,9 +232,6 @@ func configureServer(cmd *cobra.Command) error {
ClientPort: viper.GetInt("client-port"),
DisableRequestorAuthentication: viper.GetBool("no-auth"),
Requestors: make(map[string]requestorserver.Requestor),
JwtIssuer: viper.GetString("jwt-issuer"),
JwtPrivateKey: viper.GetString("jwt-privkey"),
JwtPrivateKeyFile: viper.GetString("jwt-privkey-file"),
MaxRequestAge: viper.GetInt("max-request-age"),
StaticPath: viper.GetString("static-path"),
StaticPrefix: viper.GetString("static-prefix"),
......
package server
import (
"crypto/rsa"
"encoding/hex"
"encoding/json"
"fmt"
......@@ -10,8 +11,10 @@ import (
"reflect"
"runtime"
"runtime/debug"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-errors/errors"
"github.com/privacybydesign/irmago"
"github.com/sirupsen/logrus"
......@@ -39,6 +42,10 @@ type SessionResult struct {
LegacySession bool `json:"-"` // true if request was started with legacy (i.e. pre-condiscon) session request
}
// SessionHandler is a function that can handle a session result
// once an IRMA session has completed.
type SessionHandler func(*SessionResult)
// Status is the status of an IRMA session.
type Status string
......@@ -50,6 +57,12 @@ const (
StatusTimeout Status = "TIMEOUT" // Session timed out
)
const (
ComponentRevocation = "revocation"
ComponentSession = "session"
ComponentStatic = "static"
)
// Remove this when dropping support for legacy pre-condiscon session requests
type LegacySessionResult struct {
Token string `json:"token"`
......@@ -256,6 +269,64 @@ func TypeString(x interface{}) string {
return reflect.TypeOf(x).String()
}
func ResultJwt(sessionresult *SessionResult, issuer string, validity int, privatekey *rsa.PrivateKey) (string, error) {
standardclaims := jwt.StandardClaims{
Issuer: issuer,
IssuedAt: time.Now().Unix(),
Subject: string(sessionresult.Type) + "_result",
}
standardclaims.ExpiresAt = time.Now().Unix() + int64(validity)
var claims jwt.Claims
if sessionresult.LegacySession {
claims = struct {
jwt.StandardClaims
*LegacySessionResult
}{standardclaims, sessionresult.Legacy()}
} else {
claims = struct {
jwt.StandardClaims
*SessionResult
}{standardclaims, sessionresult}
}
// Sign the jwt and return it
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privatekey)
}
func DoResultCallback(callbackUrl string, result *SessionResult, issuer string, validity int, privatekey *rsa.PrivateKey) {
logger := Logger.WithFields(logrus.Fields{"session": result.Token, "callbackUrl": callbackUrl})
if !strings.HasPrefix(callbackUrl, "https") {
logger.Warn("POSTing session result to callback URL without TLS: attributes are unencrypted in traffic")
} else {
logger.Debug("POSTing session result")
}
var res string
if privatekey != nil {
var err error
res, err = ResultJwt(result, issuer, validity, privatekey)
if err != nil {
_ = LogError(errors.WrapPrefix(err, "Failed to create JWT for result callback", 0))
return
}
} else {
bts, err := json.Marshal(result)
if err != nil {
_ = LogError(errors.WrapPrefix(err, "Failed to marshal session result for result callback", 0))
return
}
res = string(bts)
}
var x string // dummy for the server's return value that we don't care about
if err := irma.NewHTTPTransport(callbackUrl).Post("", &x, res); err != nil {
// not our problem, log it and go on
logger.Warn(errors.WrapPrefix(err, "Failed to POST session result to callback URL", 0))
}
}
func log(level logrus.Level, err error) error {
writer := Logger.WithFields(logrus.Fields{"err": TypeString(err)}).WriterLevel(level)
if e, ok := err.(*errors.Error); ok && Logger.IsLevelEnabled(logrus.DebugLevel) {
......
package server
import (
"crypto/rsa"
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/dgrijalva/jwt-go"
"github.com/go-errors/errors"
"github.com/privacybydesign/gabi"
"github.com/privacybydesign/gabi/big"
......@@ -46,6 +50,19 @@ type Configuration struct {
// Enable server sent events for status updates (experimental; tends to hang when a reverse proxy is used)
EnableSSE bool `json:"enable_sse" mapstructure:"enable_sse"`
// Static session requests that can be created by POST /session/{name}
StaticSessions map[string]interface{} `json:"static_sessions"`
// Static session requests after parsing
StaticSessionRequests map[string]irma.RequestorRequest `json:"-"`
// Used in the "iss" field of result JWTs from /result-jwt and /getproof
JwtIssuer string `json:"jwt_issuer" mapstructure:"jwt_issuer"`
// Private key to sign result JWTs with. If absent, /result-jwt and /getproof are disabled.
JwtPrivateKey string `json:"jwt_privkey" mapstructure:"jwt_privkey"`
JwtPrivateKeyFile string `json:"jwt_privkey_file" mapstructure:"jwt_privkey_file"`
// Parsed JWT private key
JwtRSAPrivateKey *rsa.PrivateKey `json:"-"`
// Logging verbosity level: 0 is normal, 1 includes DEBUG level, 2 includes TRACE level
Verbose int `json:"verbose" mapstructure:"verbose"`
// Don't log anything at all
......@@ -76,7 +93,13 @@ func (conf *Configuration) Check() error {
// loop to avoid repetetive err != nil line triplets
for _, f := range []func() error{
conf.verifyIrmaConf, conf.verifyPrivateKeys, conf.verifyURL, conf.verifyEmail, conf.verifyRevocation,
conf.verifyIrmaConf,
conf.verifyPrivateKeys,
conf.verifyURL,
conf.verifyEmail,
conf.verifyRevocation,
conf.verifyStaticSessions,
conf.verifyJwtPrivateKey,
} {
if err := f(); err != nil {
_ = LogError(err)
......@@ -104,6 +127,32 @@ func (conf *Configuration) HavePrivateKeys() bool {
// helpers
func (conf *Configuration) verifyStaticSessions() error {
conf.StaticSessionRequests = make(map[string]irma.RequestorRequest)
for name, r := range conf.StaticSessions {
if !regexp.MustCompile("^[a-zA-Z0-9_]+$").MatchString(name) {
return errors.Errorf("static session name %s not allowed, must be alphanumeric", name)
}
j, err := json.Marshal(r)
if err != nil {
return errors.WrapPrefix(err, "failed to parse static session request "+name, 0)
}
rrequest, err := ParseSessionRequest(j)
if err != nil {
return errors.WrapPrefix(err, "failed to parse static session request "+name, 0)
}
action := rrequest.SessionRequest().Action()
if action != irma.ActionDisclosing && action != irma.ActionSigning {
return errors.Errorf("static session %s must be either a disclosing or signing session", name)
}
if rrequest.Base().CallbackURL == "" {
return errors.Errorf("static session %s has no callback URL", name)
}
conf.StaticSessionRequests[name] = rrequest
}
return nil
}
func (conf *Configuration) verifyIrmaConf() error {
if conf.IrmaConfiguration == nil {
var (
......@@ -325,3 +374,18 @@ func (conf *Configuration) verifyEmail() error {
}
return nil
}
func (conf *Configuration) verifyJwtPrivateKey() error {
if conf.JwtPrivateKey == "" && conf.JwtPrivateKeyFile == "" {
return nil
}
keybytes, err := fs.ReadKey(conf.JwtPrivateKey, conf.JwtPrivateKeyFile)
if err != nil {
return errors.WrapPrefix(err, "failed to read private key", 0)
}
conf.JwtRSAPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keybytes)
conf.Logger.Info("Private key parsed, JWT endpoints enabled")
return err
}
......@@ -18,13 +18,8 @@ import (
// Server is an irmaserver instance.
type Server struct {
*servercore.Server
handlers map[string]SessionHandler
}
// SessionHandler is a function that can handle a session result
// once an IRMA session has completed.
type SessionHandler func(*server.SessionResult)
// Default server instance
var s *Server
......@@ -41,8 +36,7 @@ func New(conf *server.Configuration) (*Server, error) {
return nil, err
}
return &Server{
Server: s,
handlers: make(map[string]SessionHandler),
Server: s,
}, nil
}
......@@ -59,18 +53,11 @@ func (s *Server) Stop() {
// and CancelSession().
// The request parameter can be an irma.RequestorRequest, or an irma.SessionRequest, or a
// ([]byte or string) JSON representation of one of those (for more details, see server.ParseSessionRequest().)
func StartSession(request interface{}, handler SessionHandler) (*irma.Qr, string, error) {
func StartSession(request interface{}, handler server.SessionHandler) (*irma.Qr, string, error) {
return s.StartSession(request, handler)
}
func (s *Server) StartSession(request interface{}, handler SessionHandler) (*irma.Qr, string, error) {
qr, token, err := s.Server.StartSession(request)
if err != nil {
return nil, "", err
}
if handler != nil {
s.handlers[token] = handler
}
return qr, token, nil
func (s *Server) StartSession(request interface{}, handler server.SessionHandler) (*irma.Qr, string, error) {
return s.Server.StartSession(request, handler)
}
// GetSessionResult retrieves the result of the specified IRMA session.
......@@ -137,8 +124,8 @@ func (s *Server) HandlerFunc() http.HandlerFunc {
}
}
component, token, noun, _, err := servercore.ParsePath(r.URL.Path)
if err == nil && component == servercore.ComponentSession && noun == "statusevents" { // if err != nil we let it be handled by HandleProtocolMessage below
component, token, noun, _, err := servercore.Route(r.URL.Path, r.Method)
if err == nil && component == server.ComponentSession && noun == "statusevents" { // if err != nil we let it be handled by HandleProtocolMessage below
if err = s.SubscribeServerSentEvents(w, r, token, false); err != nil {
server.WriteResponse(w, nil, &irma.RemoteError{
Status: server.ErrorUnsupported.Status,
......@@ -149,7 +136,7 @@ func (s *Server) HandlerFunc() http.HandlerFunc {
return
}
status, response, headers, result := s.HandleProtocolMessage(r.URL.Path, r.Method, r.Header, message)
status, response, headers, _ := s.HandleProtocolMessage(r.URL.Path, r.Method, r.Header, message)
for key, h := range headers {
for _, header := range h {
w.Header().Add(key, header)
......@@ -160,10 +147,5 @@ func (s *Server) HandlerFunc() http.HandlerFunc {
if err != nil {
_ = server.LogError(errors.WrapPrefix(err, "http.ResponseWriter.Write() returned error", 0))
}
if result != nil && result.Status.Finished() {
if handler := s.handlers[result.Token]; handler != nil {
go handler(result)
}
}
}
}
package requestorserver
import (
"crypto/rsa"
"crypto/tls"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/dgrijalva/jwt-go"
"github.com/go-errors/errors"
"github.com/privacybydesign/irmago"
"github.com/privacybydesign/irmago/internal/fs"
......@@ -50,13 +47,6 @@ type Configuration struct {
// Requestor-specific permission and authentication configuration
Requestors map[string]Requestor `json:"requestors"`
// Used in the "iss" field of result JWTs from /result-jwt and /getproof
JwtIssuer string `json:"jwt_issuer" mapstructure:"jwt_issuer"`
// Private key to sign result JWTs with. If absent, /result-jwt and /getproof are disabled.
JwtPrivateKey string `json:"jwt_privkey" mapstructure:"jwt_privkey"`
JwtPrivateKeyFile string `json:"jwt_privkey_file" mapstructure:"jwt_privkey_file"`
// Max age in seconds of a session request JWT (using iat field)
MaxRequestAge int `json:"max_request_age" mapstructure:"max_request_age"`
......@@ -64,11 +54,6 @@ type Configuration struct {
StaticPath string `json:"static_path" mapstructure:"static_path"`
// Host static files under this URL prefix
StaticPrefix string `json:"static_prefix" mapstructure:"static_prefix"`
StaticSessions map[string]interface{} `json:"static_sessions"`
staticSessions map[string]irma.RequestorRequest
jwtPrivateKey *rsa.PrivateKey
}
// Permissions specify which attributes or credential a requestor may verify or issue.
......@@ -166,10 +151,6 @@ func (conf *Configuration) CanRevoke(requestor string, cred irma.CredentialTypeI
}
func (conf *Configuration) initialize() error {
if err := conf.readPrivateKey(); err != nil {
return err
}
if conf.DisableRequestorAuthentication {
authenticators = map[AuthenticationMethod]Authenticator{AuthenticationMethodNone: NilAuthenticator{}}
conf.Logger.Warn("Authentication of incoming session requests disabled: anyone who can reach this server can use it")
......@@ -274,31 +255,9 @@ func (conf *Configuration) initialize() error {
}
}
if len(conf.StaticSessions) != 0 && conf.jwtPrivateKey == nil {
if len(conf.StaticSessions) != 0 && conf.JwtRSAPrivateKey == nil {
conf.Logger.Warn("Static sessions enabled and no JWT private key installed. Ensure that POSTs to the callback URLs of static sessions are trustworthy by keeping the callback URLs secret and by using HTTPS.")
}
conf.staticSessions = make(map[string]irma.RequestorRequest)
for name, r := range conf.StaticSessions {
if !regexp.MustCompile("^[a-zA-Z0-9_]+$").MatchString(name) {
return errors.Errorf("static session name %s not allowed, must be alphanumeric", name)
}
j, err := json.Marshal(r)
if err != nil {
return errors.WrapPrefix(err, "failed to parse static session request "+name, 0)
}
rrequest, err := server.ParseSessionRequest(j)
if err != nil {
return errors.WrapPrefix(err, "failed to parse static session request "+name, 0)
}
action := rrequest.SessionRequest().Action()
if action != irma.ActionDisclosing && action != irma.ActionSigning {
return errors.Errorf("static session %s must be either a disclosing or signing session", name)
}
if rrequest.Base().CallbackURL == "" {
return errors.Errorf("static session %s has no callback URL", name)
}
conf.staticSessions[name] = rrequest
}
return nil
}
......@@ -433,21 +392,6 @@ func (conf *Configuration) readTlsConf(cert, certfile, key, keyfile string) (*tl
}, nil
}
func (conf *Configuration) readPrivateKey() error {
if conf.JwtPrivateKey == "" && conf.JwtPrivateKeyFile == "" {
return nil
}
keybytes, err := fs.ReadKey(conf.JwtPrivateKey, conf.JwtPrivateKeyFile)
if err != nil {
return errors.WrapPrefix(err, "failed to read private key", 0)
}
conf.jwtPrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keybytes)
conf.Logger.Info("Private key parsed, JWT endpoints enabled")
return err
}
func (conf *Configuration) separateClientServer() bool {
return conf.ClientPort != 0
}
......
......@@ -14,14 +14,12 @@ import (
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/go-errors/errors"
"github.com/privacybydesign/irmago"
"github.com/privacybydesign/irmago/server"
"github.com/privacybydesign/irmago/server/irmaserver"
......@@ -173,12 +171,6 @@ func (s *Server) attachClientEndpoints(router *chi.Mux) {
if s.conf.StaticPath != "" {
router.Mount(s.conf.StaticPrefix, s.StaticFilesHandler())
}
router.Group(func(r chi.Router) {
if s.conf.Verbose >= 2 {
r.Use(s.logHandler("staticsession", true, true, true))
}
r.Post("/irma/session/{name}", s.handleCreateStatic)
})
}
// Handler returns a http.Handler that handles all IRMA requestor messages
......@@ -320,21 +312,6 @@ func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
s.createSession(w, requestor, rrequest)
}
func (s *Server) handleCreateStatic(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
rrequest := s.conf.staticSessions[name]
if rrequest == nil {
server.WriteError(w, server.ErrorInvalidRequest, "unknown static session")
return
}
qr, _, err := s.irmaserv.StartSession(rrequest, s.doResultCallback)
if err != nil {
server.WriteError(w, server.ErrorInvalidRequest, err.Error())
return
}
server.WriteJson(w, qr)
}
func (s *Server) handleRevocation(w http.ResponseWriter, r *http.Request) {