From 8d805cefe6e1881e558b90bf0fb4707910e2313e Mon Sep 17 00:00:00 2001 From: Melora Hugues Date: Fri, 18 Oct 2024 22:06:05 +0200 Subject: [PATCH] Cleanup DB a bit, and start correctly handling users (#42) --- polyculeconnect/cmd/app/add.go | 23 ++++-- polyculeconnect/cmd/backend/add.go | 17 +++++ polyculeconnect/cmd/backend/show.go | 7 +- polyculeconnect/cmd/serve/serve.go | 31 -------- .../controller/auth/authcallback.go | 18 ++++- .../controller/auth/authredirect.go | 15 ---- .../internal/db/authcode/authcode.go | 4 +- .../internal/db/authrequest/authrequest.go | 71 ++---------------- .../internal/db/backend/backend.go | 19 ++++- polyculeconnect/internal/db/base.go | 6 ++ polyculeconnect/internal/db/client/client.go | 31 ++++++++ polyculeconnect/internal/db/user/user.go | 63 ++++++++++++++++ polyculeconnect/internal/model/authrequest.go | 5 +- polyculeconnect/internal/model/backend.go | 1 + polyculeconnect/internal/model/user.go | 21 +++++- polyculeconnect/internal/storage/storage.go | 60 +++++++++++---- .../0_create_backend_table.down.sql | 1 - .../migrations/0_create_backend_table.up.sql | 8 -- .../migrations/0_initial_schema.down.sql | 5 ++ .../migrations/0_initial_schema.up.sql | 58 ++++++++++++++ .../migrations/1_create_auth_request.down.sql | 1 - .../migrations/1_create_auth_request.up.sql | 11 --- .../2_add_auth_request_done.down.sql | 1 - .../migrations/2_add_auth_request_done.up.sql | 1 - .../migrations/3_add_auth_code.down.sql | 1 - .../migrations/3_add_auth_code.up.sql | 5 -- .../migrations/4_add_code_challenge.down.sql | 2 - .../migrations/4_add_code_challenge.up.sql | 2 - .../5_add_auth_request_auth_time.down.sql | 1 - .../5_add_auth_request_auth_time.up.sql | 1 - .../6_add_auth_request_auth_user.down.sql | 3 - .../6_add_auth_request_auth_user.up.sql | 3 - polyculeconnect/polyculeconnect.db | Bin 155648 -> 61440 bytes polyculeconnect/server/server.go | 2 +- 34 files changed, 312 insertions(+), 186 deletions(-) create mode 100644 polyculeconnect/internal/db/user/user.go delete mode 100644 polyculeconnect/migrations/0_create_backend_table.down.sql delete mode 100644 polyculeconnect/migrations/0_create_backend_table.up.sql create mode 100644 polyculeconnect/migrations/0_initial_schema.down.sql create mode 100644 polyculeconnect/migrations/0_initial_schema.up.sql delete mode 100644 polyculeconnect/migrations/1_create_auth_request.down.sql delete mode 100644 polyculeconnect/migrations/1_create_auth_request.up.sql delete mode 100644 polyculeconnect/migrations/2_add_auth_request_done.down.sql delete mode 100644 polyculeconnect/migrations/2_add_auth_request_done.up.sql delete mode 100644 polyculeconnect/migrations/3_add_auth_code.down.sql delete mode 100644 polyculeconnect/migrations/3_add_auth_code.up.sql delete mode 100644 polyculeconnect/migrations/4_add_code_challenge.down.sql delete mode 100644 polyculeconnect/migrations/4_add_code_challenge.up.sql delete mode 100644 polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql delete mode 100644 polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql delete mode 100644 polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql delete mode 100644 polyculeconnect/migrations/6_add_auth_request_auth_user.up.sql diff --git a/polyculeconnect/cmd/app/add.go b/polyculeconnect/cmd/app/add.go index 6a020df..1ad636c 100644 --- a/polyculeconnect/cmd/app/add.go +++ b/polyculeconnect/cmd/app/add.go @@ -1,12 +1,14 @@ package cmd import ( + "context" "fmt" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/services" - "git.faercol.me/faercol/polyculeconnect/polyculeconnect/services/app" - "github.com/dexidp/dex/storage" "github.com/spf13/cobra" ) @@ -60,7 +62,12 @@ func generateSecret(interactive bool, currentValue, valueName string) (string, e func addNewApp() { c := utils.InitConfig("") - s := utils.InitStorage(c) + logger.Init(c.LogLevel) + + s, err := db.New(*c) + if err != nil { + utils.Failf("failed to init storage: %s", err.Error()) + } clientID, err := generateSecret(appInteractive, appClientID, "client ID") if err != nil { @@ -71,14 +78,18 @@ func addNewApp() { utils.Fail(err.Error()) } - appConf := storage.Client{ + appConf := model.ClientConfig{ ID: clientID, Secret: clientSecret, Name: appName, RedirectURIs: appRedirectURIs, } - if err := app.New(s).AddApp(appConf); err != nil { - utils.Failf("Failed to add new app to storage: %s", err.Error()) + clt := model.Client{ + ClientConfig: appConf, + } + + if err := s.ClientStorage().AddClient(context.Background(), &clt); err != nil { + utils.Failf("failed to create app: %s", err) } fmt.Printf("New app %s added.\n", appName) diff --git a/polyculeconnect/cmd/backend/add.go b/polyculeconnect/cmd/backend/add.go index 2ca8c6f..4cd1f6d 100644 --- a/polyculeconnect/cmd/backend/add.go +++ b/polyculeconnect/cmd/backend/add.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "strings" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db" @@ -18,6 +19,7 @@ var ( backendIssuer string backendClientID string backendClientSecret string + backendScopes []string ) var backendAddCmd = &cobra.Command{ @@ -38,6 +40,15 @@ Parameters to provide: }, } +func scopesValid(scopes []string) bool { + for _, s := range scopes { + if s == "openid" { + return true + } + } + return false +} + func addNewBackend() { c := utils.InitConfig("") logger.Init(c.LogLevel) @@ -54,6 +65,10 @@ func addNewBackend() { utils.Fail("Empty client secret") } + if !scopesValid(backendScopes) { + utils.Failf("Invalid list of scopes %s", strings.Join(backendScopes, ", ")) + } + backendIDUUID := uuid.New() backendConf := model.Backend{ @@ -64,6 +79,7 @@ func addNewBackend() { ClientSecret: backendClientSecret, Issuer: backendIssuer, RedirectURI: c.RedirectURI(), + Scopes: backendScopes, }, } if err := s.BackendStorage().AddBackend(context.Background(), &backendConf); err != nil { @@ -81,4 +97,5 @@ func init() { backendAddCmd.Flags().StringVarP(&backendIssuer, "issuer", "d", "", "Full hostname of the backend") backendAddCmd.Flags().StringVarP(&backendClientID, "client-id", "", "", "OIDC Client ID for the backend") backendAddCmd.Flags().StringVarP(&backendClientSecret, "client-secret", "", "", "OIDC Client secret for the backend") + backendAddCmd.Flags().StringArrayVarP(&backendScopes, "scopes", "s", []string{"openid", "profile", "email"}, "OIDC Scopes asked to the backend") } diff --git a/polyculeconnect/cmd/backend/show.go b/polyculeconnect/cmd/backend/show.go index b988978..6381e40 100644 --- a/polyculeconnect/cmd/backend/show.go +++ b/polyculeconnect/cmd/backend/show.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "strings" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/cmd/utils" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/backend" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger" "github.com/dexidp/dex/storage" "github.com/spf13/cobra" ) @@ -21,10 +23,12 @@ Optional parameters: - app-id: id of the backend to display. If empty, display the list of available backends instead`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - s, err := db.New(*utils.InitConfig("")) + conf := utils.InitConfig("") + s, err := db.New(*conf) if err != nil { utils.Failf("Failed to init storage: %s", err.Error()) } + logger.Init(conf.LogLevel) if len(args) > 0 { showBackend(args[0], s.BackendStorage()) @@ -50,6 +54,7 @@ func showBackend(backendName string, backendService backend.BackendDB) { printProperty("Client ID", backendConfig.Config.ClientID) printProperty("Client secret", backendConfig.Config.ClientSecret) printProperty("Redirect URI", backendConfig.Config.RedirectURI) + printProperty("Scopes", strings.Join(backendConfig.Config.Scopes, ", ")) } func listBackends(backendStorage backend.BackendDB) { diff --git a/polyculeconnect/cmd/serve/serve.go b/polyculeconnect/cmd/serve/serve.go index 93a68e3..091effb 100644 --- a/polyculeconnect/cmd/serve/serve.go +++ b/polyculeconnect/cmd/serve/serve.go @@ -19,7 +19,6 @@ import ( "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/storage" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/server" - "git.faercol.me/faercol/polyculeconnect/polyculeconnect/services" "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/spf13/cobra" @@ -49,9 +48,6 @@ func serve() { logger.Init(conf.LogLevel) logger.L.Infof("Initialized logger with level %v", conf.LogLevel) - storageType := utils.InitStorage(conf) - logger.L.Infof("Initialized storage backend %q", conf.StorageType) - userDB, err := db.New(*conf) if err != nil { utils.Failf("failed to init user DB: %s", err.Error()) @@ -83,38 +79,11 @@ func serve() { op.WithHttpInterceptors(middlewares.WithBackendFromRequestMiddleware), } - // logger.L.Info("Initializing authentication backends") - // backendConfs, err := userDB.BackendStorage().GetAllBackends(context.Background()) - // if err != nil { - // utils.Failf("failed to get backend configs from the DB: %s", err.Error()) - // } - - // TODO: check if we need to do it this way or - // - do a try-loop? - // - only init when using them in a request? - // for _, c := range backendConfs { - // logger.L.Debugf("Initializing backend %s", c.Name) - // b, err := client.New(context.Background(), c, logger.L) - // if err != nil { - // utils.Failf("failed to init backend client: %s", err.Error()) - // } - // backends[c.ID] = b - // } - // if len(backends) == 0 { - // logger.L.Warn("No auth backend loaded") - // } else { - // logger.L.Infof("Initialized %d auth backends", len(backends)) - // } - provider, err := op.NewProvider(&opConf, &st, op.StaticIssuer(conf.Issuer), options...) if err != nil { utils.Failf("failed to init OIDC provider: %s", err.Error()) } - if err := services.AddDefaultBackend(storageType); err != nil { - logger.L.Errorf("Failed to add connector for backend RefuseAll to stage: %s", err.Error()) - } - logger.L.Info("Initializing server") s, err := server.New(conf, provider, &st, logger.L) if err != nil { diff --git a/polyculeconnect/controller/auth/authcallback.go b/polyculeconnect/controller/auth/authcallback.go index 50e588a..f7ca9be 100644 --- a/polyculeconnect/controller/auth/authcallback.go +++ b/polyculeconnect/controller/auth/authcallback.go @@ -36,17 +36,27 @@ func (c *AuthCallbackController) HandleUserInfoCallback(w http.ResponseWriter, r c.l.Infof("Successful login from %s", info.Email) user := model.User{ - ID: uuid.New(), - Email: info.Email, - Username: info.PreferredUsername, + Subject: info.Subject, + Name: info.Name, + FamilyName: info.FamilyName, + GivenName: info.GivenName, + Picture: info.Picture, + UpdatedAt: info.UpdatedAt.AsTime(), + Email: info.Email, + EmailVerified: bool(info.EmailVerified), } - err = c.st.LocalStorage.AuthRequestStorage().ValidateAuthRequest(r.Context(), requestID, &user) + err = c.st.LocalStorage.AuthRequestStorage().ValidateAuthRequest(r.Context(), requestID, user.Subject) 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) return } + if err := c.st.LocalStorage.UserStorage().AddUser(r.Context(), &user); err != nil { + c.l.Errorf("Failed to add related user to storageL %w", err) + helpers.HandleResponse(w, r, http.StatusInternalServerError, []byte("failed to perform authentication"), c.l) + return + } http.Redirect(w, r, "/authorize/callback?id="+state, http.StatusFound) } diff --git a/polyculeconnect/controller/auth/authredirect.go b/polyculeconnect/controller/auth/authredirect.go index 0d44ebc..d611090 100644 --- a/polyculeconnect/controller/auth/authredirect.go +++ b/polyculeconnect/controller/auth/authredirect.go @@ -46,20 +46,5 @@ func (c *AuthRedirectController) ServeHTTP(w http.ResponseWriter, r *http.Reques 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 - // } - - // 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 }, c.provider).ServeHTTP(w, r) } diff --git a/polyculeconnect/internal/db/authcode/authcode.go b/polyculeconnect/internal/db/authcode/authcode.go index f3a9084..f005abc 100644 --- a/polyculeconnect/internal/db/authcode/authcode.go +++ b/polyculeconnect/internal/db/authcode/authcode.go @@ -29,7 +29,7 @@ func (db *sqlAuthCodeDB) CreateAuthCode(ctx context.Context, code model.AuthCode } defer func() { _ = tx.Rollback() }() - query := `INSERT INTO "auth_code_2" ("id", "auth_request_id", "code") VALUES ($1, $2, $3)` + query := `INSERT INTO "auth_code" ("id", "auth_request_id", "code") VALUES ($1, $2, $3)` _, err = tx.ExecContext(ctx, query, code.CodeID, code.RequestID, code.Code) if err != nil { return fmt.Errorf("failed to insert in DB: %w", err) @@ -43,7 +43,7 @@ func (db *sqlAuthCodeDB) CreateAuthCode(ctx context.Context, code model.AuthCode func (db *sqlAuthCodeDB) GetAuthCodeByCode(ctx context.Context, code string) (*model.AuthCode, error) { logger.L.Debugf("Getting auth code %s from DB", code) - query := `SELECT "id", "auth_request_id", "code" FROM "auth_code_2" WHERE "code" = ?` + query := `SELECT "id", "auth_request_id", "code" FROM "auth_code" WHERE "code" = ?` row := db.db.QueryRowContext(ctx, query, code) var res model.AuthCode diff --git a/polyculeconnect/internal/db/authrequest/authrequest.go b/polyculeconnect/internal/db/authrequest/authrequest.go index 0b51f9f..55aa451 100644 --- a/polyculeconnect/internal/db/authrequest/authrequest.go +++ b/polyculeconnect/internal/db/authrequest/authrequest.go @@ -15,13 +15,12 @@ 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", "claim_user_id", "claim_username", "claim_email"` +const authRequestRows = `"id", "client_id", "backend_id", "scopes", "redirect_uri", "state", "nonce", "response_type", "creation_time", "done", "code_challenge", "code_challenge_method", "auth_time", "user_id"` 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, user *model.User) error + ValidateAuthRequest(ctx context.Context, reqID uuid.UUID, userID string) error DeleteAuthRequest(ctx context.Context, reqID uuid.UUID) error } @@ -29,75 +28,22 @@ 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) + query := fmt.Sprintf(`SELECT %s FROM "auth_request" 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, &user.id, &user.username, &user.email); 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, &res.UserID); 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) } @@ -118,8 +64,7 @@ func (db *sqlAuthRequestDB) CreateAuthRequest(ctx context.Context, req model.Aut return fmt.Errorf("failed to serialize scopes: %w", err) } - // 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" (%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, @@ -137,7 +82,7 @@ func (db *sqlAuthRequestDB) CreateAuthRequest(ctx context.Context, req model.Aut return nil } -func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid.UUID, user *model.User) error { +func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid.UUID, userID string) error { logger.L.Debugf("Validating auth request %s", reqID) tx, err := db.db.BeginTx(ctx, nil) if err != nil { @@ -145,7 +90,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, claim_user_id = $2, claim_username = $3, claim_email = $4 WHERE id = $5`, time.Now().UTC(), user.ID, user.Username, user.Email, reqID.String()) + res, err := tx.ExecContext(ctx, `UPDATE "auth_request" SET done = true, auth_time = $1, user_id = $2 WHERE id = $3`, time.Now().UTC(), userID, reqID) if err != nil { return fmt.Errorf("failed to update in DB: %w", err) } @@ -172,7 +117,7 @@ func (db *sqlAuthRequestDB) DeleteAuthRequest(ctx context.Context, reqID uuid.UU } defer func() { _ = tx.Rollback() }() - _, err = tx.ExecContext(ctx, `DELETE FROM "auth_request_2" WHERE id = $1`, reqID.String()) + _, err = tx.ExecContext(ctx, `DELETE FROM "auth_request" WHERE id = $1`, reqID.String()) if err != nil { return fmt.Errorf("failed to delete auth request: %w", err) } diff --git a/polyculeconnect/internal/db/backend/backend.go b/polyculeconnect/internal/db/backend/backend.go index f16c726..cfac9fe 100644 --- a/polyculeconnect/internal/db/backend/backend.go +++ b/polyculeconnect/internal/db/backend/backend.go @@ -3,6 +3,7 @@ package backend import ( "context" "database/sql" + "encoding/json" "errors" "fmt" @@ -13,7 +14,7 @@ import ( var ErrNotFound = errors.New("backend not found") -const backendRows = `"id", "name", "oidc_issuer", "oidc_client_id", "oidc_client_secret", "oidc_redirect_uri"` +const backendRows = `"id", "name", "oidc_issuer", "oidc_client_id", "oidc_client_secret", "oidc_redirect_uri", "oidc_scopes"` type scannable interface { Scan(dest ...any) error @@ -36,13 +37,19 @@ type sqlBackendDB struct { func backendFromRow(row scannable) (*model.Backend, error) { var res model.Backend + var scopesStr []byte - if err := row.Scan(&res.ID, &res.Name, &res.Config.Issuer, &res.Config.ClientID, &res.Config.ClientSecret, &res.Config.RedirectURI); err != nil { + fmt.Println(string(scopesStr)) + + if err := row.Scan(&res.ID, &res.Name, &res.Config.Issuer, &res.Config.ClientID, &res.Config.ClientSecret, &res.Config.RedirectURI, &scopesStr); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("invalid format for backend: %w", err) } + if err := json.Unmarshal(scopesStr, &res.Config.Scopes); err != nil { + return nil, fmt.Errorf("invalid value for scopes: %w", err) + } return &res, nil } @@ -85,12 +92,18 @@ func (db *sqlBackendDB) AddBackend(ctx context.Context, newBackend *model.Backen } defer func() { _ = tx.Rollback() }() - query := fmt.Sprintf(`INSERT INTO "backend" (%s) VALUES ($1, $2, $3, $4, $5, $6)`, backendRows) + scopesStr, err := json.Marshal(newBackend.Config.Scopes) + if err != nil { + return fmt.Errorf("failed to serialize scopes: %w", err) + } + + query := fmt.Sprintf(`INSERT INTO "backend" (%s) VALUES ($1, $2, $3, $4, $5, $6, $7)`, backendRows) _, err = tx.ExecContext( ctx, query, newBackend.ID, newBackend.Name, newBackend.Config.Issuer, newBackend.Config.ClientID, newBackend.Config.ClientSecret, newBackend.Config.RedirectURI, + scopesStr, ) if err != nil { return fmt.Errorf("failed to insert in DB: %w", err) diff --git a/polyculeconnect/internal/db/base.go b/polyculeconnect/internal/db/base.go index 6c0e7f8..54c79e2 100644 --- a/polyculeconnect/internal/db/base.go +++ b/polyculeconnect/internal/db/base.go @@ -9,6 +9,7 @@ import ( "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/authrequest" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/backend" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/client" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/user" ) type Storage interface { @@ -17,6 +18,7 @@ type Storage interface { BackendStorage() backend.BackendDB AuthRequestStorage() authrequest.AuthRequestDB AuthCodeStorage() authcode.AuthCodeDB + UserStorage() user.UserDB } type sqlStorage struct { @@ -43,6 +45,10 @@ func (s *sqlStorage) AuthCodeStorage() authcode.AuthCodeDB { return authcode.New(s.db) } +func (s *sqlStorage) UserStorage() user.UserDB { + return user.New(s.db) +} + func New(conf config.AppConfig) (Storage, error) { db, err := sql.Open("sqlite3", conf.StorageConfig.File) if err != nil { diff --git a/polyculeconnect/internal/db/client/client.go b/polyculeconnect/internal/db/client/client.go index 7155b3f..9617744 100644 --- a/polyculeconnect/internal/db/client/client.go +++ b/polyculeconnect/internal/db/client/client.go @@ -18,6 +18,7 @@ const clientRows = `"client"."id", "client"."secret", "client"."redirect_uris", type ClientDB interface { GetClientByID(ctx context.Context, id string) (*model.Client, error) + AddClient(ctx context.Context, client *model.Client) error } type sqlClientDB struct { @@ -32,6 +33,14 @@ func strArrayToSlice(rawVal string) []string { return res } +func sliceToStrArray(rawVal []string) string { + res, err := json.Marshal(rawVal) + if err != nil { + return "[]" + } + return string(res) +} + func clientFromRow(row *sql.Row) (*model.Client, error) { var res model.Client redirectURIsStr := "" @@ -57,6 +66,28 @@ func (db *sqlClientDB) GetClientByID(ctx context.Context, id string) (*model.Cli return clientFromRow(row) } +func (db *sqlClientDB) AddClient(ctx context.Context, client *model.Client) error { + logger.L.Debugf("Creating client %s", client.Name) + query := `INSERT INTO "client" ("id", "secret", "redirect_uris", "trusted_peers", "name") VALUES ($1, $2, $3, $4, $5)` + + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + if affectedRows, err := tx.ExecContext(ctx, query, client.ID, client.Secret, sliceToStrArray(client.RedirectURIs()), sliceToStrArray(client.TrustedPeers), client.Name); err != nil { + return fmt.Errorf("failed to insert in DB: %w", err) + } else if nbAffected, err := affectedRows.RowsAffected(); err != nil { + return fmt.Errorf("failed to check number of affected rows: %w", err) + } else if nbAffected != 1 { + return fmt.Errorf("unexpected number of affected rows: %d", nbAffected) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + func New(db *sql.DB) *sqlClientDB { return &sqlClientDB{db: db} } diff --git a/polyculeconnect/internal/db/user/user.go b/polyculeconnect/internal/db/user/user.go new file mode 100644 index 0000000..f3eaee0 --- /dev/null +++ b/polyculeconnect/internal/db/user/user.go @@ -0,0 +1,63 @@ +package user + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" +) + +type UserDB interface { + AddUser(ctx context.Context, user *model.User) error + GetUserBySubject(ctx context.Context, subject string) (*model.User, error) +} + +var ErrNotFound = errors.New("not found") + +const getUserQuery = ` + SELECT id, name, family_name, given_name, nickname, picture, updated_at, email, email_verified + FROM user + WHERE id = ? +` +const insertUserQuery = ` + INSERT INTO user (id, name, family_name, given_name, nickname, picture, updated_at, email, email_verified) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +` + +type sqlUserDB struct { + db *sql.DB +} + +func (db *sqlUserDB) GetUserBySubject(ctx context.Context, subject string) (*model.User, error) { + row := db.db.QueryRowContext(ctx, getUserQuery, subject) + var res model.User + if err := row.Scan(&res.Subject, &res.Name, &res.FamilyName, &res.GivenName, &res.Nickname, &res.Picture, &res.UpdatedAt, &res.Email, &res.EmailVerified); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to read result from DB: %w", err) + } + return &res, nil +} + +func (db *sqlUserDB) AddUser(ctx context.Context, user *model.User) error { + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, insertUserQuery, user.Subject, user.Name, user.FamilyName, user.GivenName, user.Nickname, user.Picture, user.UpdatedAt, user.Email, user.EmailVerified); err != nil { + return fmt.Errorf("failed to insert in DB: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func New(db *sql.DB) *sqlUserDB { + return &sqlUserDB{db: db} +} diff --git a/polyculeconnect/internal/model/authrequest.go b/polyculeconnect/internal/model/authrequest.go index c1e1475..43a462d 100644 --- a/polyculeconnect/internal/model/authrequest.go +++ b/polyculeconnect/internal/model/authrequest.go @@ -30,7 +30,8 @@ type AuthRequest struct { BackendID uuid.UUID Backend *Backend - User *User + UserID string + User *User DoneVal bool } @@ -94,7 +95,7 @@ func (a AuthRequest) GetSubject() string { if a.User == nil { return "" } - return a.User.ID.String() + return a.User.Subject } func (a AuthRequest) Done() bool { diff --git a/polyculeconnect/internal/model/backend.go b/polyculeconnect/internal/model/backend.go index 888a363..d16b911 100644 --- a/polyculeconnect/internal/model/backend.go +++ b/polyculeconnect/internal/model/backend.go @@ -7,6 +7,7 @@ type BackendOIDCConfig struct { ClientID string ClientSecret string RedirectURI string + Scopes []string } type Backend struct { diff --git a/polyculeconnect/internal/model/user.go b/polyculeconnect/internal/model/user.go index 75e87e8..1360cb8 100644 --- a/polyculeconnect/internal/model/user.go +++ b/polyculeconnect/internal/model/user.go @@ -1,9 +1,22 @@ package model -import "github.com/google/uuid" +import ( + "time" +) type User struct { - ID uuid.UUID - Email string - Username string + // Part of openid scope + Subject string + + // Part of profile scope + Name string + FamilyName string + GivenName string + Nickname string + Picture string + UpdatedAt time.Time + + // part of email scope + Email string + EmailVerified bool } diff --git a/polyculeconnect/internal/storage/storage.go b/polyculeconnect/internal/storage/storage.go index 6575daa..fd32649 100644 --- a/polyculeconnect/internal/storage/storage.go +++ b/polyculeconnect/internal/storage/storage.go @@ -66,7 +66,20 @@ func (s *Storage) AuthRequestByID(ctx context.Context, requestID string) (op.Aut return nil, fmt.Errorf("invalid format for uuid: %w", err) } - return s.LocalStorage.AuthRequestStorage().GetAuthRequestByID(ctx, id) + req, err := s.LocalStorage.AuthRequestStorage().GetAuthRequestByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get auth request from DB: %w", err) + } + if req.UserID == "" { + return req, nil + } + + user, err := s.LocalStorage.UserStorage().GetUserBySubject(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("failed to get user information from DB: %w", err) + } + req.User = user + return req, nil } func (s *Storage) AuthRequestByCode(ctx context.Context, requestCode string) (op.AuthRequest, error) { @@ -77,7 +90,20 @@ func (s *Storage) AuthRequestByCode(ctx context.Context, requestCode string) (op return nil, fmt.Errorf("failed to get auth code from DB: %w", err) } - return s.LocalStorage.AuthRequestStorage().GetAuthRequestByID(ctx, authCode.RequestID) + req, err := s.LocalStorage.AuthRequestStorage().GetAuthRequestByID(ctx, authCode.RequestID) + if err != nil { + return nil, fmt.Errorf("failed to get auth request from DB: %w", err) + } + if req.UserID == "" { + return req, nil + } + + user, err := s.LocalStorage.UserStorage().GetUserBySubject(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("failed to get user information from DB: %w", err) + } + req.User = user + return req, nil } func (s *Storage) SaveAuthCode(ctx context.Context, id string, code string) error { @@ -253,20 +279,28 @@ 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 { logger.L.Debugf("Setting user info for user %s", userID) - parsedID, err := uuid.Parse(userID) + user, err := s.LocalStorage.UserStorage().GetUserBySubject(ctx, 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") + return fmt.Errorf("failed to get user from DB: %w", err) + } + + for _, s := range scopes { + switch s { + case "openid": + userinfo.Subject = user.Subject + case "profile": + userinfo.Name = user.Name + userinfo.FamilyName = user.FamilyName + userinfo.GivenName = user.GivenName + userinfo.Nickname = user.Nickname + userinfo.Picture = user.Picture + userinfo.UpdatedAt = oidc.FromTime(user.UpdatedAt) + case "email": + userinfo.Email = user.Email + userinfo.EmailVerified = oidc.Bool(user.EmailVerified) + } } - userinfo.PreferredUsername = req.User.Username - userinfo.Email = req.User.Email return nil } diff --git a/polyculeconnect/migrations/0_create_backend_table.down.sql b/polyculeconnect/migrations/0_create_backend_table.down.sql deleted file mode 100644 index 898fe6b..0000000 --- a/polyculeconnect/migrations/0_create_backend_table.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "backend"; \ No newline at end of file diff --git a/polyculeconnect/migrations/0_create_backend_table.up.sql b/polyculeconnect/migrations/0_create_backend_table.up.sql deleted file mode 100644 index 498fef0..0000000 --- a/polyculeconnect/migrations/0_create_backend_table.up.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "backend" ( - id TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - oidc_issuer TEXT NOT NULL, - oidc_client_id TEXT NOT NULL, - oidc_client_secret TEXT NOT NULL, - oidc_redirect_uri TEXT NOT NULL -); diff --git a/polyculeconnect/migrations/0_initial_schema.down.sql b/polyculeconnect/migrations/0_initial_schema.down.sql new file mode 100644 index 0000000..aa0b196 --- /dev/null +++ b/polyculeconnect/migrations/0_initial_schema.down.sql @@ -0,0 +1,5 @@ +DROP TABLE "auth_code"; +DROP TABLE "auth_request"; +DROP TABLE "user"; +DROP TABLE "backend"; +DROP TABLE "client"; diff --git a/polyculeconnect/migrations/0_initial_schema.up.sql b/polyculeconnect/migrations/0_initial_schema.up.sql new file mode 100644 index 0000000..a699c06 --- /dev/null +++ b/polyculeconnect/migrations/0_initial_schema.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE "backend" ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + oidc_issuer TEXT NOT NULL, + oidc_client_id TEXT NOT NULL, + oidc_client_secret TEXT NOT NULL, + oidc_redirect_uri TEXT NOT NULL, + oidc_scopes blob NOT NULL DEFAULT '[]' -- list of strings, json-encoded, +); + +CREATE TABLE "client" ( + id TEXT NOT NULL PRIMARY KEY, + secret TEXT NOT NULL, + redirect_uris blob NOT NULL, + trusted_peers blob NOT NULL, + public integer NOT NULL DEFAULT 0, + name TEXT NOT NULL +); + +CREATE TABLE "user" ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + family_name TEXT NOT NULL DEFAULT '', + given_name TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + picture TEXT NOT NULL DEFAULT '', + updated_at timestamp, + email TEXT NOT NULL DEFAULT '', + email_verified INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE "auth_request" ( + id TEXT NOT NULL PRIMARY KEY, + client_id TEXT NOT NULL, + backend_id TEXT NOT NULL, + scopes blob NOT NULL, -- list of strings, json-encoded + redirect_uri TEXT NOT NULL, + state TEXT NOT NULL, + nonce TEXT NOT NULL, + response_type TEXT NOT NULL, + creation_time timestamp NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + code_challenge STRING NOT NULL DEFAULT '', + code_challenge_method STRING NOT NULL DEFAULT '', + auth_time timestamp, + user_id TEXT NOT NULL DEFAULT '', + FOREIGN KEY(backend_id) REFERENCES backend(id), + FOREIGN KEY(client_id) REFERENCES client(id), + FOREIGN KEY(user_id) REFERENCES user(id) +); + +CREATE TABLE "auth_code" ( + id TEXT NOT NULL PRIMARY KEY, + code TEXT NOT NULL, + auth_request_id TEXT NOT NULL, + FOREIGN KEY(auth_request_id) REFERENCES auth_request(id) +); + diff --git a/polyculeconnect/migrations/1_create_auth_request.down.sql b/polyculeconnect/migrations/1_create_auth_request.down.sql deleted file mode 100644 index 998bfa6..0000000 --- a/polyculeconnect/migrations/1_create_auth_request.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "auth_request_2"; \ No newline at end of file diff --git a/polyculeconnect/migrations/1_create_auth_request.up.sql b/polyculeconnect/migrations/1_create_auth_request.up.sql deleted file mode 100644 index 803dc3b..0000000 --- a/polyculeconnect/migrations/1_create_auth_request.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE "auth_request_2" ( - id TEXT NOT NULL PRIMARY KEY, - client_id TEXT NOT NULL, - backend_id TEXT NOT NULL, - scopes blob NOT NULL, -- list of strings, json-encoded - redirect_uri TEXT NOT NULL, - state TEXT NOT NULL, - nonce TEXT NOT NULL, - response_type TEXT NOT NULL, - creation_time timestamp NOT NULL -); \ No newline at end of file diff --git a/polyculeconnect/migrations/2_add_auth_request_done.down.sql b/polyculeconnect/migrations/2_add_auth_request_done.down.sql deleted file mode 100644 index 1ad631c..0000000 --- a/polyculeconnect/migrations/2_add_auth_request_done.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "auth_request_2" DROP COLUMN done; diff --git a/polyculeconnect/migrations/2_add_auth_request_done.up.sql b/polyculeconnect/migrations/2_add_auth_request_done.up.sql deleted file mode 100644 index 1b72baa..0000000 --- a/polyculeconnect/migrations/2_add_auth_request_done.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "auth_request_2" ADD COLUMN done INTEGER NOT NULL DEFAULT 0; diff --git a/polyculeconnect/migrations/3_add_auth_code.down.sql b/polyculeconnect/migrations/3_add_auth_code.down.sql deleted file mode 100644 index e5911b4..0000000 --- a/polyculeconnect/migrations/3_add_auth_code.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "auth_code_2"; diff --git a/polyculeconnect/migrations/3_add_auth_code.up.sql b/polyculeconnect/migrations/3_add_auth_code.up.sql deleted file mode 100644 index 563af7c..0000000 --- a/polyculeconnect/migrations/3_add_auth_code.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE "auth_code_2" ( - id TEXT NOT NULL PRIMARY KEY, - code TEXT NOT NULL, - auth_request_id TEXT NOT NULL -); diff --git a/polyculeconnect/migrations/4_add_code_challenge.down.sql b/polyculeconnect/migrations/4_add_code_challenge.down.sql deleted file mode 100644 index f3aa033..0000000 --- a/polyculeconnect/migrations/4_add_code_challenge.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "auth_request_2" DROP COLUMN code_challenge; -ALTER TABLE "auth_request_2" DROP COLUMN code_challenge_method; diff --git a/polyculeconnect/migrations/4_add_code_challenge.up.sql b/polyculeconnect/migrations/4_add_code_challenge.up.sql deleted file mode 100644 index bc8f9cd..0000000 --- a/polyculeconnect/migrations/4_add_code_challenge.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "auth_request_2" ADD COLUMN code_challenge STRING NOT NULL DEFAULT ''; -ALTER TABLE "auth_request_2" ADD COLUMN code_challenge_method STRING NOT NULL DEFAULT ''; diff --git a/polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql b/polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql deleted file mode 100644 index fc5738d..0000000 --- a/polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "auth_request_2" DROP COLUMN auth_time; diff --git a/polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql b/polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql deleted file mode 100644 index e8d84b0..0000000 --- a/polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "auth_request_2" ADD COLUMN auth_time timestamp; diff --git a/polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql b/polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql deleted file mode 100644 index 10a21a1..0000000 --- a/polyculeconnect/migrations/6_add_auth_request_auth_user.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index bdea95c..0000000 --- a/polyculeconnect/migrations/6_add_auth_request_auth_user.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 61f7ea91cb34fbb64b25b11890847bb990f06406..2becc055360af6ac5a1414aff652aa53bd8b6099 100644 GIT binary patch literal 61440 zcmeI*$#2`(9S3kSb}Y+@taPTX8l-4A4pPU6X>*e(We$_YvLlR_*p{3P0m0?bqC=62 zNX2mmb5Pp3^irTe(LbPnLr-(*pU~s{6N6&T?V*pPEK6Pz6`8Hhmlh?8e0=2lyx$`w zF4oq@iW3sf_FD}-0E6Mwy!OTC%cK7T*8d|Cz=4*>{3;O8#Tsr@>YyLy#< zvmfe)OZ0YF*G%6!9LU_;EZ?b?x$2#}D`jrFmtdN^F~QN!vAAmaQI%U;r+sZ@g?q4B zS-rFQnESl^crH#6WjB~686RiWA}`t`2(_cxBuSRmH_MgfwJ5I}N0QHSo8_hQW_j&i zd5i0xcEhn|CuW!ffg_^Y{NBN%8Vnu6&j4zA%nwkypVBm+n(Fis{Gi zqoE`kG_f~0Iq@fEJc{bCHhz`LUBAx$ksL-&w>JB$jWX^+guQ~9B(I><8}kzxJ7`@={+90%uCi7w zFPArah2j>=OLuB3RZd6}Mdi@Uy6(Ef+acUmb+fXzd_48c%t^_$1_|rF_0GBCnjT!& zB)fJ%TE}Xnw~7Y~?Qh?Q)i}_!yQ8C4o#fm6l@=^{(}6H!a#!EUkaznbam}=#Kl2(#2`Cm!##` z4zb=6_!F};Q)9VLr%tQD$Xyeo=ZTD+R9MIRD3!Z>nf<*QS6C7{$#ZJ3(vp$GS6e`6 zV-5$4mXx`FyK;2P6mNJ!8Z;4ClaSVl37T!gbR{{8i;M0i;CTH_%-;NXEce;v z(}a>jYGU#_p8ah%tk<)t+?6ZrAKI}}w@r3mMhBg`V~2-2zLs2Vt+G)oCtAK^nVJ&> zZMr8OXa#+fyQ_Wjhln_XZi>?@B~H~}Gp8=>q&-Sce&*@3nRo78<|bzOUySAMTsf^8 zx`vw69T?*;4U=5#CO=Pi&+$IGaW0jcU0^%ecz;e?P(VKeX>HGmK6IIN+OV|44?ZK^ zdQ9+XGg~Y_;s)p8*4MaW^Km!2iDn%(m0O^TW8)|}mhjk!a|4lcY;rGSu1!CR6_h!; zI}Z0b!}s0U3@sv?{Wn7&=pXE!(XT_$_?>=h6tf&c^{009U<00Izz z00bZa0SJ750-aCU@!HLeJYQKPqN>}9CG!QTRNxiUw)moM75S1;vL#d04Ovy2zPoR> zU1Iv4NAHQNhha0gwXkqx^t`Q;mg&3m^!_n=k0gI&a#(i)dM}5jYsrlmQ7$wS*Ee;y z?g!zmydVe*^o<+c$?$ZV-n!>G*7V$Tv*p{4OXyI1Gtl(2>>>&UF2tWV=5P$##AOHafKmYn!Qa*3w4ap=5DOz$tPz+lnl0i&~s8YdDWkV72aAoWk$@%{;nCus4tehwg1Rwwb2tWV=5P$##AOHaf zK;Y*g&^gCm=wxOxwEJu!eb3pDpX_cwe_XYkRYy=(AFfI()s4N?#no`VT6_IuOLSJH z$KrZ*+j+8B$v=5m*}Av9BwEXL*I0JztHkwNI(NUl(y z5Q#4ea*>w}f#gNmwj^2TyU2Z*e}w;*AIY1W_HEmBJfi8QNrGVd+1u}~uW)-#Q0GFQ z+tcZ5W1n+c(TF|z9Pa6%SvP%)NP?vBqQHwqPNL`NhrTLN~ z3zDjca+2hIFB`z`|NWGqjb%ds0uX=z1Rwwb2tWV=5P$##-cKO<{l8(3k|g*4?=sol z_gfz3h5!U0009U<00Izz00bZa0SG`K66k!)p09N-*W`j}tcXiHroa1Y!+Ts@6}DSSX{)^Y zK=b$O%jS#FySshYUL&toUb@P9b#IvO|9$8GqeB4~X@7zC(dabo$Gd&1@0AXsg8&2| z009U<00Izz00bZa0SKIZfg2+=Rx#9~t`}t9EQ<6F1YMQr7kI)=#TW#U*uEH zM+q&#R?U1~rq}t?i~SWnZ}D_Klh^HhzDR63*^o!0gwZUbs*Aj&6lp?w9Dt`^;B8(l zC=$KgUoF@|X(UQmluJd+RB1lMG#G(=zenM-HC c&QL-KKmY;|fB*y_009U<00Izzz=tgGUjb<1ApigX literal 155648 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