From 93d7b1392816d42b08792c5d5164a93855c473c0 Mon Sep 17 00:00:00 2001 From: Melora Hugues Date: Wed, 16 Oct 2024 21:42:39 +0200 Subject: [PATCH] Link userinfo from backend to the clients #48 --- .../controller/auth/authcallback.go | 77 ++++++++++++++---- .../controller/auth/authdispatch.go | 54 ++++++++++++ .../controller/auth/authredirect.go | 40 ++++----- .../internal/db/authrequest/authrequest.go | 66 +++++++++++++-- polyculeconnect/internal/model/authrequest.go | 7 +- polyculeconnect/internal/model/user.go | 9 ++ polyculeconnect/internal/storage/storage.go | 17 +++- .../6_add_auth_request_auth_user.down.sql | 3 + .../6_add_auth_request_auth_user.up.sql | 3 + polyculeconnect/polyculeconnect.db | Bin 151552 -> 155648 bytes polyculeconnect/server/server.go | 29 ++++++- 11 files changed, 255 insertions(+), 50 deletions(-) create mode 100644 polyculeconnect/controller/auth/authdispatch.go create mode 100644 polyculeconnect/internal/model/user.go create mode 100644 polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql create mode 100644 polyculeconnect/migrations/6_add_auth_request_auth_user.up.sql diff --git a/polyculeconnect/controller/auth/authcallback.go b/polyculeconnect/controller/auth/authcallback.go index d31bb51..50e588a 100644 --- a/polyculeconnect/controller/auth/authcallback.go +++ b/polyculeconnect/controller/auth/authcallback.go @@ -4,8 +4,11 @@ import ( "net/http" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/helpers" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/storage" "github.com/google/uuid" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "github.com/zitadel/oidc/v3/pkg/oidc" "go.uber.org/zap" ) @@ -23,23 +26,7 @@ func NewAuthCallbackController(l *zap.SugaredLogger, st *storage.Storage) *AuthC } } -func (c *AuthCallbackController) ServeHTTP(w http.ResponseWriter, r *http.Request) { - errMsg := r.URL.Query().Get("error") - if errMsg != "" { - errorDesc := r.URL.Query().Get("error_description") - c.l.Errorf("Failed to perform authentication: %s (%s)", errMsg, errorDesc) - helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform authentication"), c.l) - return - } - - code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - if code == "" || state == "" { - c.l.Error("Missing code or state in response") - helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform authentication"), c.l) - return - } - +func (c *AuthCallbackController) HandleUserInfoCallback(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { requestID, err := uuid.Parse(state) if err != nil { c.l.Errorf("Invalid state, should be a request UUID, but got %s: %s", state, err) @@ -47,7 +34,14 @@ func (c *AuthCallbackController) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - err = c.st.LocalStorage.AuthRequestStorage().ValidateAuthRequest(r.Context(), requestID) + c.l.Infof("Successful login from %s", info.Email) + user := model.User{ + ID: uuid.New(), + Email: info.Email, + Username: info.PreferredUsername, + } + + err = c.st.LocalStorage.AuthRequestStorage().ValidateAuthRequest(r.Context(), requestID, &user) if err != nil { c.l.Errorf("Failed to validate auth request from storage: %s", err) helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform authentication"), c.l) @@ -56,3 +50,50 @@ func (c *AuthCallbackController) ServeHTTP(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, "/authorize/callback?id="+state, http.StatusFound) } + +type CallbackDispatchController struct { + l *zap.SugaredLogger + st *storage.Storage + callbackHandlers map[uuid.UUID]http.Handler +} + +func NewCallbackDispatchController(l *zap.SugaredLogger, st *storage.Storage, handlers map[uuid.UUID]http.Handler) *CallbackDispatchController { + return &CallbackDispatchController{ + l: l, + st: st, + callbackHandlers: handlers, + } +} + +func (c *CallbackDispatchController) ServeHTTP(w http.ResponseWriter, r *http.Request) { + errMsg := r.URL.Query().Get("error") + if errMsg != "" { + errorDesc := r.URL.Query().Get("error_description") + c.l.Errorf("Failed to perform authentication: %s (%s)", errMsg, errorDesc) + helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform authentication"), c.l) + return + } + + state := r.URL.Query().Get("state") + requestID, err := uuid.Parse(state) + if err != nil { + c.l.Errorf("Invalid state, should be a request UUID, but got %s: %s", state, err) + helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform authentication"), c.l) + return + } + + req, err := c.st.LocalStorage.AuthRequestStorage().GetAuthRequestByID(r.Context(), requestID) + if err != nil { + c.l.Errorf("Failed to get auth request from DB: %s", err) + helpers.HandleResponse(w, r, http.StatusBadRequest, []byte("unknown request id"), c.l) + return + } + + callbackHandler, ok := c.callbackHandlers[req.BackendID] + if !ok { + c.l.Errorf("Backend %s does not exist for request %s", req.ID, req.BackendID) + helpers.HandleResponse(w, r, http.StatusNotFound, []byte("unknown backend"), c.l) + return + } + callbackHandler.ServeHTTP(w, r) +} diff --git a/polyculeconnect/controller/auth/authdispatch.go b/polyculeconnect/controller/auth/authdispatch.go new file mode 100644 index 0000000..cf36b12 --- /dev/null +++ b/polyculeconnect/controller/auth/authdispatch.go @@ -0,0 +1,54 @@ +package auth + +import ( + "net/http" + + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/helpers" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/storage" + "github.com/google/uuid" + "go.uber.org/zap" +) + +type AuthDispatchController struct { + l *zap.SugaredLogger + st *storage.Storage + redirectHandlers map[uuid.UUID]http.Handler +} + +func NewAuthDispatchController(l *zap.SugaredLogger, storage *storage.Storage, redirectHandlers map[uuid.UUID]http.Handler) *AuthDispatchController { + return &AuthDispatchController{ + l: l, + st: storage, + redirectHandlers: redirectHandlers, + } +} + +func (c *AuthDispatchController) ServeHTTP(w http.ResponseWriter, r *http.Request) { + requestIDStr := r.URL.Query().Get("request_id") + if requestIDStr == "" { + helpers.HandleResponse(w, r, http.StatusBadRequest, []byte("no request ID in request"), c.l) + return + } + + requestID, err := uuid.Parse(requestIDStr) + if err != nil { + c.l.Errorf("Invalid UUID format for request ID: %s", err) + helpers.HandleResponse(w, r, http.StatusBadRequest, []byte("invalid request id"), c.l) + return + } + + req, err := c.st.LocalStorage.AuthRequestStorage().GetAuthRequestByID(r.Context(), requestID) + if err != nil { + c.l.Errorf("Failed to get auth request from DB: %s", err) + helpers.HandleResponse(w, r, http.StatusBadRequest, []byte("unknown request id"), c.l) + return + } + + loginHandler, ok := c.redirectHandlers[req.BackendID] + if !ok { + c.l.Errorf("Backend %s does not exist for request %s", req.ID, req.BackendID) + helpers.HandleResponse(w, r, http.StatusNotFound, []byte("unknown backend"), c.l) + return + } + loginHandler.ServeHTTP(w, r) +} diff --git a/polyculeconnect/controller/auth/authredirect.go b/polyculeconnect/controller/auth/authredirect.go index e128d64..0d44ebc 100644 --- a/polyculeconnect/controller/auth/authredirect.go +++ b/polyculeconnect/controller/auth/authredirect.go @@ -13,14 +13,16 @@ import ( const AuthRedirectRoute = "/perform_auth" type AuthRedirectController struct { - l *zap.SugaredLogger - st *storage.Storage + provider rp.RelyingParty + l *zap.SugaredLogger + st *storage.Storage } -func NewAuthRedirectController(l *zap.SugaredLogger, storage *storage.Storage) *AuthRedirectController { +func NewAuthRedirectController(l *zap.SugaredLogger, provider rp.RelyingParty, storage *storage.Storage) *AuthRedirectController { return &AuthRedirectController{ - l: l, - st: storage, + l: l, + st: storage, + provider: provider, } } @@ -38,26 +40,26 @@ func (c *AuthRedirectController) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - req, err := c.st.LocalStorage.AuthRequestStorage().GetAuthRequestByID(r.Context(), requestID) + _, err = c.st.LocalStorage.AuthRequestStorage().GetAuthRequestByID(r.Context(), requestID) if err != nil { c.l.Errorf("Failed to get auth request from DB: %s", err) helpers.HandleResponse(w, r, http.StatusBadRequest, []byte("unknown request id"), c.l) return } - backend, err := c.st.LocalStorage.BackendStorage().GetBackendByID(r.Context(), req.BackendID) - if err != nil { - c.l.Errorf("Failed to get backend from DB: %s", err) - helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform auth"), c.l) - return - } + // backend, err := c.st.LocalStorage.BackendStorage().GetBackendByID(r.Context(), req.BackendID) + // if err != nil { + // c.l.Errorf("Failed to get backend from DB: %s", err) + // helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform auth"), c.l) + // return + // } - provider, err := rp.NewRelyingPartyOIDC(r.Context(), backend.Config.Issuer, backend.Config.ClientID, backend.Config.ClientSecret, backend.Config.RedirectURI, req.Scopes) - if err != nil { - c.l.Errorf("Failed to init relying party: %s", err) - helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform auth"), c.l) - return - } + // provider, err := rp.NewRelyingPartyOIDC(r.Context(), backend.Config.Issuer, backend.Config.ClientID, backend.Config.ClientSecret, backend.Config.RedirectURI, req.Scopes) + // if err != nil { + // c.l.Errorf("Failed to init relying party: %s", err) + // helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform auth"), c.l) + // return + // } - rp.AuthURLHandler(func() string { return requestIDStr }, provider).ServeHTTP(w, r) + rp.AuthURLHandler(func() string { return requestIDStr }, c.provider).ServeHTTP(w, r) } diff --git a/polyculeconnect/internal/db/authrequest/authrequest.go b/polyculeconnect/internal/db/authrequest/authrequest.go index 0b878a0..0b51f9f 100644 --- a/polyculeconnect/internal/db/authrequest/authrequest.go +++ b/polyculeconnect/internal/db/authrequest/authrequest.go @@ -15,12 +15,13 @@ import ( var ErrNotFound = errors.New("backend not found") -const authRequestRows = `"id", "client_id", "backend_id", "scopes", "redirect_uri", "state", "nonce", "response_type", "creation_time", "done", "code_challenge", "code_challenge_method", "auth_time"` +const authRequestRows = `"id", "client_id", "backend_id", "scopes", "redirect_uri", "state", "nonce", "response_type", "creation_time", "done", "code_challenge", "code_challenge_method", "auth_time", "claim_user_id", "claim_username", "claim_email"` type AuthRequestDB interface { GetAuthRequestByID(ctx context.Context, id uuid.UUID) (*model.AuthRequest, error) + GetAuthRequestByUserID(ctx context.Context, id uuid.UUID) (*model.AuthRequest, error) CreateAuthRequest(ctx context.Context, req model.AuthRequest) error - ValidateAuthRequest(ctx context.Context, reqID uuid.UUID) error + ValidateAuthRequest(ctx context.Context, reqID uuid.UUID, user *model.User) error DeleteAuthRequest(ctx context.Context, reqID uuid.UUID) error } @@ -28,22 +29,75 @@ type sqlAuthRequestDB struct { db *sql.DB } +type dbUser struct { + id string + username string + email string +} + func (db *sqlAuthRequestDB) GetAuthRequestByID(ctx context.Context, id uuid.UUID) (*model.AuthRequest, error) { logger.L.Debugf("Getting auth request with id %s", id) query := fmt.Sprintf(`SELECT %s FROM "auth_request_2" WHERE "id" = ?`, authRequestRows) row := db.db.QueryRowContext(ctx, query, id) var res model.AuthRequest + var user dbUser var scopesStr []byte var timestamp *time.Time - if err := row.Scan(&res.ID, &res.ClientID, &res.BackendID, &scopesStr, &res.RedirectURI, &res.State, &res.Nonce, &res.ResponseType, &res.CreationDate, &res.DoneVal, &res.CodeChallenge, &res.CodeChallengeMethod, ×tamp); err != nil { + if err := row.Scan(&res.ID, &res.ClientID, &res.BackendID, &scopesStr, &res.RedirectURI, &res.State, &res.Nonce, &res.ResponseType, &res.CreationDate, &res.DoneVal, &res.CodeChallenge, &res.CodeChallengeMethod, ×tamp, &user.id, &user.username, &user.email); err != nil { return nil, fmt.Errorf("failed to get auth request from DB: %w", err) } if timestamp != nil { res.AuthTime = *timestamp } + if user.id != "" { + userID, err := uuid.Parse(user.id) + if err != nil { + return nil, fmt.Errorf("invalid format for user id: %w", err) + } + res.User = &model.User{ + ID: userID, + Username: user.username, + Email: user.email, + } + } + if err := json.Unmarshal(scopesStr, &res.Scopes); err != nil { + return nil, fmt.Errorf("invalid format for scopes: %w", err) + } + + return &res, nil +} + +func (db *sqlAuthRequestDB) GetAuthRequestByUserID(ctx context.Context, id uuid.UUID) (*model.AuthRequest, error) { + logger.L.Debugf("Getting auth request with user id %s", id) + query := fmt.Sprintf(`SELECT %s FROM "auth_request_2" WHERE "claim_user_id" = ?`, authRequestRows) + row := db.db.QueryRowContext(ctx, query, id) + + var res model.AuthRequest + var user dbUser + var scopesStr []byte + + var timestamp *time.Time + + if err := row.Scan(&res.ID, &res.ClientID, &res.BackendID, &scopesStr, &res.RedirectURI, &res.State, &res.Nonce, &res.ResponseType, &res.CreationDate, &res.DoneVal, &res.CodeChallenge, &res.CodeChallengeMethod, ×tamp, &user.id, &user.username, &user.email); err != nil { + return nil, fmt.Errorf("failed to get auth request from DB: %w", err) + } + if timestamp != nil { + res.AuthTime = *timestamp + } + if user.id != "" { + userID, err := uuid.Parse(user.id) + if err != nil { + return nil, fmt.Errorf("invalid format for user id: %w", err) + } + res.User = &model.User{ + ID: userID, + Username: user.username, + Email: user.email, + } + } if err := json.Unmarshal(scopesStr, &res.Scopes); err != nil { return nil, fmt.Errorf("invalid format for scopes: %w", err) } @@ -65,7 +119,7 @@ func (db *sqlAuthRequestDB) CreateAuthRequest(ctx context.Context, req model.Aut } // TODO: when the old table is done, rename into auth_request - query := fmt.Sprintf(`INSERT INTO "auth_request_2" (%s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)`, authRequestRows) + query := fmt.Sprintf(`INSERT INTO "auth_request_2" (%s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, '', '', '')`, authRequestRows) _, err = tx.ExecContext(ctx, query, req.ID, req.ClientID, req.BackendID, scopesStr, req.RedirectURI, req.State, @@ -83,7 +137,7 @@ func (db *sqlAuthRequestDB) CreateAuthRequest(ctx context.Context, req model.Aut return nil } -func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid.UUID) error { +func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid.UUID, user *model.User) error { logger.L.Debugf("Validating auth request %s", reqID) tx, err := db.db.BeginTx(ctx, nil) if err != nil { @@ -91,7 +145,7 @@ func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid. } defer func() { _ = tx.Rollback() }() - res, err := tx.ExecContext(ctx, `UPDATE "auth_request_2" SET done = true, auth_time = $1 WHERE id = $2`, time.Now().UTC(), reqID.String()) + res, err := tx.ExecContext(ctx, `UPDATE "auth_request_2" SET done = true, auth_time = $1, claim_user_id = $2, claim_username = $3, claim_email = $4 WHERE id = $5`, time.Now().UTC(), user.ID, user.Username, user.Email, reqID.String()) if err != nil { return fmt.Errorf("failed to update in DB: %w", err) } diff --git a/polyculeconnect/internal/model/authrequest.go b/polyculeconnect/internal/model/authrequest.go index 343a72e..c1e1475 100644 --- a/polyculeconnect/internal/model/authrequest.go +++ b/polyculeconnect/internal/model/authrequest.go @@ -30,7 +30,7 @@ type AuthRequest struct { BackendID uuid.UUID Backend *Backend - UserID uuid.UUID + User *User DoneVal bool } @@ -91,7 +91,10 @@ func (a AuthRequest) GetState() string { } func (a AuthRequest) GetSubject() string { - return a.UserID.String() + if a.User == nil { + return "" + } + return a.User.ID.String() } func (a AuthRequest) Done() bool { diff --git a/polyculeconnect/internal/model/user.go b/polyculeconnect/internal/model/user.go new file mode 100644 index 0000000..75e87e8 --- /dev/null +++ b/polyculeconnect/internal/model/user.go @@ -0,0 +1,9 @@ +package model + +import "github.com/google/uuid" + +type User struct { + ID uuid.UUID + Email string + Username string +} diff --git a/polyculeconnect/internal/storage/storage.go b/polyculeconnect/internal/storage/storage.go index 44520f5..6575daa 100644 --- a/polyculeconnect/internal/storage/storage.go +++ b/polyculeconnect/internal/storage/storage.go @@ -251,7 +251,22 @@ func (s *Storage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientS } func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error { - // we'll use FromRequest instead + logger.L.Debugf("Setting user info for user %s", userID) + + parsedID, err := uuid.Parse(userID) + if err != nil { + return fmt.Errorf("invalid userID: %w", err) + } + req, err := s.LocalStorage.AuthRequestStorage().GetAuthRequestByUserID(ctx, parsedID) + if err != nil { + return fmt.Errorf("failed to get auth request from DB: %w", err) + } + if req.User == nil { + return errors.New("no user associated to that ID") + } + + userinfo.PreferredUsername = req.User.Username + userinfo.Email = req.User.Email return nil } diff --git a/polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql b/polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql new file mode 100644 index 0000000..10a21a1 --- /dev/null +++ b/polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE "auth_request_2" DROP COLUMN claim_user_id; +ALTER TABLE "auth_request_2" DROP COLUMN claim_username; +ALTER TABLE "auth_request_2" DROP COLUMN claim_email; diff --git a/polyculeconnect/migrations/6_add_auth_request_auth_user.up.sql b/polyculeconnect/migrations/6_add_auth_request_auth_user.up.sql new file mode 100644 index 0000000..bdea95c --- /dev/null +++ b/polyculeconnect/migrations/6_add_auth_request_auth_user.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE "auth_request_2" ADD COLUMN claim_user_id string; +ALTER TABLE "auth_request_2" ADD COLUMN claim_username string; +ALTER TABLE "auth_request_2" ADD COLUMN claim_email string; diff --git a/polyculeconnect/polyculeconnect.db b/polyculeconnect/polyculeconnect.db index 46f8e1980435c6fd4d71fa135c07375e6b0a1e29..61f7ea91cb34fbb64b25b11890847bb990f06406 100644 GIT binary patch delta 4614 zcmeHLS!i6>8J;W0i!_^q>Ud2oFEKIBnVx+SNvPJ*5{)!kMzUy`oOABIGb4=_Tb3m` z1!YVV@>YjD2+}^J5CWwoDU?)AAKF3#DYPLa4J7ofq3ugj2!!~>(DvMsEZec&&U-UR zSJItt{`1}cKmT{WU(dbT{M>JwuN{3bh{a-O-1p5Ff9Y}J1+{11dd7Qw*eh)89r4b1 zh0V-}_n6o6z1-2g2hX1MUYy+gQQq6#)bV(mt?J5Lad|1YSgiPMOP4NI=1aHVo!4{0 zS8fM$x>DV~xB2>*cm4@)&o8cd|4zFfuX+!_L{s{DMCZ};jr8@+{eSZQk)C#co<8O- zCsQrauPsl;;%_A0Oa3lZN`EI!)A5$Srano3-15sfNj#gp(DMEC3#q5#&nI6=9Y~C( z-j07eaW>f(|0wxc`pv{^scVVr@s^e+lgAUWmhk5%_W!ea{jpsWo^PyRLKqN+J_OWV zfeI}IzA@U@1o{fX(-APVMqn055OC}RO05BsVg`)QEkc9@h_h1>kP?hBgA71FAb?6_ zfL87!gi6e$yBSJ&G6Jq+%PsuEK^#E$0#r!> z3{)5xYAr+vDh>U%2(4A*JP5OP3KTr3=Y|5Ga8VAc8?`O^7Vi4@W?1T~0zA02ojSD90*F z5OL|`)`r-nh{$6R5JD9iZ1g(dflmR&#scjd?mh#NOFM&H*`pCsVW@qCISBmFIG`h7 zP-ua#XkdH?#|9pWfT6ZR5%)H?(yc8N1DaV^97YWah-DP>!x2#0fN)|>rE(k_}W2B5L3}qYxMFjfWY_0WBomR+vqLJo+gi^VpxXq>@6F@TI8zEd>1Dcy0 z(i3#?^2(T=8CR8T_X_GWqm}OBa2AqfVf$tOOm@8o6TQ;2GOI^HadKsNxL6uvVczfI z7fM&U15hY6(jc|ggLPU&XazABjuwovg(;9BCqN2Ktqhc<6ql-nj=5EmTj;K47Z%nf zoYcjx#kpmEp-_TTLB~qaGuRi*c3fHP=)+TMGk)o5v^bQ7a;8`w?_VA3sB~5rb-t1P zTWfu&PRq3l4H1O^VG{rfH3UXU42U-ChWRvbyFDnC)>fb$S{NMSMJ;=l=s;zy%AT%t z4-O4p9v7x3H(ee+Kh73&9m}>)mh-(`-2**kRUIb-lRZ;-IA0hV$T!k0wblc5S~g_C z=C8l-o=kMP203*kQsQIr=VR$NlgHC9#NS9gm8OZ&#IuR_obrB_h$XJ4KTh_g4y3N7 zK1p7Pf0T|VUrD^?H2dvTDRK5K*p!Xeb(blvxm5wMh&f3k(nd-}0oDu|81PUMSXT?= zLy4tC03qRk#<)sZK>!N^Vx%=vQBoI+Mw+uO7VC}lOIO&d?NHGM5kUyIZKy*PCyPDd@0 zioG98{XO}I#LwazE#Ga{`+E1D+q3_HFL(W=={MfHvG?6s{u5SPK9-s|H4*mblLyB3 zC6lp6e9K112i}i|c^i`LM7Bef!4NB3-2p-51PY>1-Dybi)%(2L_^hIKz}ALb-Qtu5$R! zRT?=R>Z`Q7a6F30iJ%xt#kL~4+)#krLB`t97KCLWBQjc8Zb3G`q)qAgH}>uMG?w0% zdN6r7{;!t5HNUj)sXd=MYyRgw^6JRx2Y0FzJ^MuRn~hlYdQR6}p;SAe?TBWGB1|C3 z$XB@SKxBq0Ar&ELyO!?wu~s-A8*txw$uMLVVBF0OMgp!vmtjIhG$A3tso`93p?m}HCVcS3&OXgQ7G<~*FS^6lHJFO12oen< z>>k?gQ_KTN36-5T`c8$(OU? z+iFw0^vKQ%`Dg{~M#TR$A=cBI+oH|e8Fib1Iu}Q`(_349cqe9EYa1CR>oL#NV>+!R zG9##PCE+&(D^mKV)Q7S3OzJ}?7Qauq@$6XLt2W{(VQy-vyINIdXrho`hH7AFarE*y z?aa>iR(M%`MjR{?cxrHNwA?#7Q|<04=&pgW5xSV`m4z#-m{#)PG@UEu=}5&N&kh!O_WWAU Zss*NdVSK4CL$l+9VdXr&RBA+V{sS!U(24*6 delta 2843 zcma)8No?F_75CUlQ+qZyCbT7Ovot^>_|Ug6Qm8bs$4lbf9%pQUoNskJGg<6-ybP5l zmJo0Z;$W$#9-^uzidunHPDmU9i31YhNF{m-5(v^u5AgoeiW_Q1`exDm{%`rc_kQnt z@SDL0?+iXX`@(o%U*Ff^ybh-y4*Ti1S#JFB<}YvN-kr-G9i5ubeIs{t{GIvS8@Zq1 z;mXhti)T;0c=>Yf$F<{|3%Qg1$L}uXRD+$jC2^yX3NAGu5L_k)4s4_@z| zEzr!xnZl#K^83YenaMH-XHN>BmLC@QzVsiNfH|jHm_zt@F?KnfP~i$aU;N@#|iOB z1@{3n>F6sNP>LzLo)nwAe-qZXJQVy-LV(CQ!1BXrqG+Sian|n1CIkMHfk+3TPtWTPTi*G}-|d z(OCCVhU76xDYQmjBmtZ&f}AoE#fULajz+KKiy06zFF9rmQJ1o86>zS&MM79GtT+~y zf3@e^8g3;~$Vk}@h-Z8{l{8Q(gkw-FtMr)+No-0G3q&OnGvLQlB%G$^hG0C}+3<$ry@YqASEp$idVuv&r7*UQ>v|edK`RQJi zgm^5ZN0ul+;$VPt3F(;R1Hlr~XD?(x?4d_wWJnRhfk|qSF*YKfh{Vz{DuNu&fQ$$n zpj6021!-f!knI`>00@S(BVsflf?|kwYHcUhoq6vH;z-JgO;cpLSiW-t%Fa645?t?lrj`i z6(AiPFie|-{679)*H!%zheO-VAL!Wg4qU=pzf(p~7OB~_qGf@e8o9i%jTBLUhT zih?ZzJ)z;wKxWdq7^R2tf{wPp5RJ%1&?J>24ipCu&EEgbf!d((Mv@4M;=BPM4ge(4 zBPw9*d5V$hw{pI}WQ~6K6 z*b&9|il+;2Ex~-Y%k1DOplU{ zW|$BwH?D`VtGf%k3$^Apo2~1aF<&uur@lXp_T(*keH1NUtJnmIU<3V=KoMblJxHd8 z?lT5N$2q7qNrxlndtp7x4nXtEV~tLGrPbbE-)^mpN4~SNIlXrOZ)y9q1!Oj-5VO^05IoN4%gIl-ls;{Z8yn8U;Xw6opcg@=P z#13svFRssH<>v0tcIECw*mDZn4-^sT0g2;0f`nO35jISi0R5*KJsf$h7uK_^w~lUB zI`_8I)Y5dlj=R%oX%D4EQ>j)b#ErXqWw)Sr{$CRDgTaEhO{n|`@>F)HzOm%j3w7K7|ug+H&FrzreY(L|W zZA$@n9wHPHT~Dpoo&{sn;mFy~s|YuRFS{sII}=yszFDVf;m%k?O_4RXy4~!o!3;Jk zttq=SJ~uXXXSuT6qP6<^?HVEED!bO)yFHuI`dD>)ZG5J+y