diff --git a/client/handle_http.go b/client/handle_http.go index 3668cc4af..1ac396480 100644 --- a/client/handle_http.go +++ b/client/handle_http.go @@ -3079,6 +3079,10 @@ func uploadObject(transfer *transferFile) (transferResult TransferResults, err e Scheme: "https", Path: transfer.remoteURL.Path, } + // Add the oss.asize query parameter for PUT requests + query := dest.Query() + query.Set("oss.asize", fmt.Sprintf("%d", sizer.Size())) + dest.RawQuery = query.Encode() attempt.Endpoint = dest.Host // Create the wrapped reader and send it to the request closed := make(chan bool, 1) diff --git a/origin/migrations/20240528174529_create_db_tables.sql b/database/origin_migrations/20251111180645_create_globus_table.sql similarity index 80% rename from origin/migrations/20240528174529_create_db_tables.sql rename to database/origin_migrations/20251111180645_create_globus_table.sql index 0c4e8f246..0863b6359 100644 --- a/origin/migrations/20240528174529_create_db_tables.sql +++ b/database/origin_migrations/20251111180645_create_globus_table.sql @@ -5,6 +5,7 @@ CREATE TABLE globus_collections ( name TEXT NOT NULL DEFAULT '', server_url TEXT NOT NULL DEFAULT '', refresh_token TEXT NOT NULL DEFAULT '', + transfer_refresh_token TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); @@ -12,4 +13,5 @@ CREATE TABLE globus_collections ( -- +goose Down -- +goose StatementBegin +DROP TABLE IF EXISTS globus_collections -- +goose StatementEnd diff --git a/database/server.go b/database/server.go index 03f7d6c69..12420900c 100644 --- a/database/server.go +++ b/database/server.go @@ -25,6 +25,9 @@ var embedUniversalMigrations embed.FS //go:embed registry_migrations/*.sql var embedRegistryMigrations embed.FS +//go:embed origin_migrations/*.sql +var embedOriginMigrations embed.FS + type Counter struct { Key string `gorm:"primaryKey"` Value int `gorm:"not null;default:0"` @@ -92,6 +95,8 @@ func runServerTypeMigrations(sqlDB *sql.DB, serverType server_structs.ServerType switch serverType { case server_structs.RegistryType: return utils.MigrateServerSpecificDB(sqlDB, embedRegistryMigrations, "registry_migrations", "registry") + case server_structs.OriginType: + return utils.MigrateServerSpecificDB(sqlDB, embedOriginMigrations, "origin_migrations", "origin") default: log.Debugf("No specific migrations for server type: %s", serverType.String()) } diff --git a/docs/parameters.yaml b/docs/parameters.yaml index 9e3a8a171..898b48c44 100644 --- a/docs/parameters.yaml +++ b/docs/parameters.yaml @@ -1241,7 +1241,7 @@ description: |+ Authorization header. If the origin backend is configured with the `globus` storage type, any value set here will be overridden with the filepath to - the first file ending in `.tok` found in the $(Origin.GlobusConfigLocation)/tokens directory + the file ending in `.tok` found in the $(Origin.GlobusConfigLocation)/tokens directory type: filename default: none components: ["origin"] @@ -1313,6 +1313,17 @@ root_default: /run/pelican/xrootd/origin/globus default: $XDG_RUNTIME_DIR/pelican/xrootd/origin/globus components: ["origin"] --- +name: Origin.GlobusTransferTokenFile +description: |+ + When set, all requests from the Globus backend to the Globus Transfer API will include the contents + of the file as a bearer token in the authorization header. + + Any value set here will be overridden with the filepath to the file ending in `.transfer.tok` found + in the $(Origin.GlobusConfigLocation)/tokens directory +type: filename +default: none +components: ["origin"] +--- name: Origin.FedTokenLocation description: |+ A path to the file containing a token issued by the federation's issuer. This token may be consumed by other federation services diff --git a/launchers/origin_serve.go b/launchers/origin_serve.go index c509c9156..17ce4a7a2 100644 --- a/launchers/origin_serve.go +++ b/launchers/origin_serve.go @@ -62,10 +62,6 @@ func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, // Initialize PKCS#11 helper after the defaults are set up initPKCS11(ctx, modules) - if err := origin.InitializeDB(); err != nil { - return nil, errors.Wrap(err, "failed to initialize origin sqlite database") - } - if err := database.InitServerDatabase(server_structs.OriginType); err != nil { return nil, errors.Wrap(err, "failed to initialize server sqlite database") } @@ -194,7 +190,7 @@ func OriginServeFinish(ctx context.Context, egrp *errgroup.Group) error { egrp.Go(func() error { <-ctx.Done() - return origin.ShutdownOriginDB() + return database.ShutdownDB() }) return nil diff --git a/origin/advertise.go b/origin/advertise.go index c929cb054..19938d637 100644 --- a/origin/advertise.go +++ b/origin/advertise.go @@ -29,6 +29,7 @@ import ( "github.com/pelicanplatform/pelican/config" "github.com/pelicanplatform/pelican/features" "github.com/pelicanplatform/pelican/metrics" + pelican_oauth2 "github.com/pelicanplatform/pelican/oauth2" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" @@ -211,7 +212,12 @@ func (server *OriginServer) CreateAdvertisement(name, id, originUrlStr, originWe if len(prefixes) == 0 { if isGlobusBackend { activateUrl := param.Server_ExternalWebUrl.GetString() + "/view/origin/globus" - return nil, fmt.Errorf("failed to create advertisement: no activated Globus collection. Go to %s to activate your collection.", activateUrl) + callbackUrl, err := pelican_oauth2.GetRedirectURL(globusCallbackPath) + errMsg := fmt.Sprintf("failed to create advertisement: no activated Globus collection. Go to %s to activate your collection", activateUrl) + if err == nil { + errMsg += fmt.Sprintf(". The Globus app expects the following redirect URL: %s ", callbackUrl) + } + return nil, errors.New(errMsg) } return nil, errors.New("failed to create advertisement: no valid export") diff --git a/origin/globus.go b/origin/globus.go index b1308e421..e33fa6598 100644 --- a/origin/globus.go +++ b/origin/globus.go @@ -44,12 +44,15 @@ type globusExportStatus string // For internal globusExports map type globusExport struct { - DisplayName string `json:"displayName"` - FederationPrefix string `json:"federationPrefix"` - Status globusExportStatus `json:"status"` - Description string `json:"description,omitempty"` // status description - HttpsServer string `json:"httpsServer"` // server url to access files in the collection - Token *oauth2.Token `json:"-"` + DisplayName string `json:"displayName"` + FederationPrefix string `json:"federationPrefix"` + Status globusExportStatus `json:"status"` + Description string `json:"description,omitempty"` // status description + HttpsServer string `json:"httpsServer"` // server url to access files in the collection + Token *oauth2.Token `json:"-"` + TransferToken *oauth2.Token `json:"-"` + TokenFile string `json:"-"` + TransferTokenFile string `json:"-"` } // For UI @@ -58,26 +61,39 @@ type globusExportUI struct { UUID string `json:"uuid"` } +// GlobusTokenType represents the type of Globus token +type GlobusTokenType string + +const ( + TokenTypeCollection GlobusTokenType = "collection" + TokenTypeTransfer GlobusTokenType = "transfer" +) + const ( GlobusInactive = "Inactive" GlobusActivated = "Activated" ) -const GlobusTokenFileExt = ".tok" // File extension for caching Globus access token - var ( // An in-memory map-struct to keep Globus collections information with key being the collection UUID. globusExports map[string]*globusExport globusExportsMutex = sync.RWMutex{} ) -// InitGlobusBackend does the following things to initialize Globus-related logic -// 1. It initializes the global map structure globusExports to store collection information in-memory -// 2. It checks and setup location for Globus access tokens after user activates the collection -// 3. It loads the Globus OAuth client for OAuth-based authorization to access collection data -// 4. It populates the global map by the exported Origin prefixes/collections. It reads the persisted credentials -// from the origin's SQLite DB and populate the global map, refresh the access token by the persisted -// refresh token +// loadTokenFromDB loads and refreshes a token from the database for a specific token type +func loadTokenFromDB(cid string, refreshToken string, tokenType GlobusTokenType, globusAuthCfg *oauth2.Config) (*oauth2.Token, error) { + refToken := &oauth2.Token{ + RefreshToken: refreshToken, + } + tokenSource := globusAuthCfg.TokenSource(context.Background(), refToken) + token, err := tokenSource.Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh %s token for collection %s: %v", tokenType, cid, err) + } + return token, nil +} + +// InitGlobusBackend initializes the Globus backend by loading existing collections from the database func InitGlobusBackend(exps []server_utils.OriginExport) error { uid, err := config.GetDaemonUID() if err != nil { @@ -112,27 +128,28 @@ func InitGlobusBackend(exps []server_utils.OriginExport) error { globusAuthCfg, err := GetGlobusOAuthCfg() if err != nil { - return errors.Wrap(err, "failed to get Globus OAuth2 config") + return errors.Wrap(err, "failed to get Globus OAuth config") } - // Populate globusExports map - globusExportsMutex.Lock() - defer globusExportsMutex.Unlock() for _, esp := range exps { + if esp.GlobusCollectionID == "" { + continue + } + globusEsp := globusExport{ DisplayName: esp.GlobusCollectionName, FederationPrefix: esp.FederationPrefix, Status: GlobusInactive, - Description: "Server start", + Description: "Not activated", } - // We check the origin db and see if we already have the refresh token in-place - // If so, use the token to initialize the collection + + // Check if the collection exists in the database ok, err := collectionExistsByUUID(esp.GlobusCollectionID) if err != nil { - return errors.Wrapf(err, "failed to check credential status for Globus collection %s with name %s", esp.GlobusCollectionID, esp.GlobusCollectionName) + return errors.Wrapf(err, "failed to check if Globus collection %s with name %s exists in DB", esp.GlobusCollectionID, esp.GlobusCollectionName) } if !ok { - log.Infof("Globus collection %s with name %s is not activated. You need to activate it in the admin website before using this collection", esp.GlobusCollectionID, esp.GlobusCollectionName) + // Collection doesn't exist in DB, mark as inactive globusExports[esp.GlobusCollectionID] = &globusEsp continue } @@ -142,22 +159,36 @@ func InitGlobusBackend(exps []server_utils.OriginExport) error { return errors.Wrapf(err, "failed to get credentials for Globus collection %s with name %s", esp.GlobusCollectionID, esp.GlobusCollectionName) } - refToken := &oauth2.Token{ - RefreshToken: col.RefreshToken, - } - tokenSource := globusAuthCfg.TokenSource(context.Background(), refToken) - collectionToken, err := tokenSource.Token() - // If we can't get the access token, we want to evict the entry from db and - // ask the user to redo the authentication + // Load collection token + collectionToken, err := loadTokenFromDB(col.UUID, col.RefreshToken, TokenTypeCollection, globusAuthCfg) if err != nil { if err := deleteCollectionByUUID(col.UUID); err != nil { return errors.Wrapf(err, "failed to delete expired credential record for Globus collection %s with name %s", esp.GlobusCollectionID, esp.GlobusCollectionName) } log.Infof("Access credentials for Globus collection %s with name %s is expired and removed.", esp.GlobusCollectionID, esp.GlobusCollectionName) + globusExports[esp.GlobusCollectionID] = &globusEsp + continue } - // Save the new access token - if err := persistAccessToken(col.UUID, collectionToken); err != nil { + // Load transfer token + transferToken, err := loadTokenFromDB(col.UUID, col.TransferRefreshToken, TokenTypeTransfer, globusAuthCfg) + if err != nil { + if err := deleteCollectionByUUID(col.UUID); err != nil { + return errors.Wrapf(err, "failed to delete expired credential record for Globus collection %s with name %s", esp.GlobusCollectionID, esp.GlobusCollectionName) + } + log.Infof("Transfer access credentials for Globus collection %s with name %s is expired and removed.", esp.GlobusCollectionID, esp.GlobusCollectionName) + globusExports[esp.GlobusCollectionID] = &globusEsp + continue + } + + // Save the new access tokens + var tokenFileName string + var transferTokenFileName string + if tokenFileName, err = persistToken(col.UUID, collectionToken, TokenTypeCollection); err != nil { + return err + } + + if transferTokenFileName, err = persistToken(col.UUID, transferToken, TokenTypeTransfer); err != nil { return err } @@ -170,8 +201,11 @@ func InitGlobusBackend(exps []server_utils.OriginExport) error { globusEsp.Status = GlobusActivated globusEsp.Token = collectionToken + globusEsp.TransferToken = transferToken globusEsp.HttpsServer = col.ServerURL globusEsp.Description = "Activated with cached credentials" + globusEsp.TokenFile = tokenFileName + globusEsp.TransferTokenFile = transferTokenFileName globusExports[esp.GlobusCollectionID] = &globusEsp } return nil @@ -191,7 +225,28 @@ func isExportActivated(fedPrefix string) (ok bool) { return false } -// Iterate over all Globus exports and refresh the token. Skip any inactive exports. +// refreshTokenWithRetry handles token refresh with retry logic for a specific token type +func refreshTokenWithRetry(cid string, token *oauth2.Token, tokenType GlobusTokenType, exp *globusExport) (*oauth2.Token, error) { + newTok, err := refreshGlobusToken(cid, token, tokenType) + if err != nil { + log.Errorf("Failed to refresh Globus %s token for collection %s with name %s. Will retry once: %v", tokenType, cid, exp.DisplayName, err) + newTok, err = refreshGlobusToken(cid, token, tokenType) + if err != nil { + log.Errorf("Failed to retry refreshing Globus %s token for collection %s with name %s: %v", tokenType, cid, exp.DisplayName, err) + exp.Status = GlobusInactive + exp.Description = fmt.Sprintf("Failed to refresh %s token: %v", tokenType, err) + return nil, err + } + } + if newTok == nil { + log.Debugf("Globus %s token for collection %s with name %s is still valid. Refresh skipped", tokenType, cid, exp.DisplayName) + } else { + log.Debugf("Globus %s token for collection %s with name %s is refreshed", tokenType, cid, exp.DisplayName) + } + return newTok, nil +} + +// Iterate over all Globus exports and refresh the tokens. Skip any inactive exports. // Retry once if first attempt failed. If retry failed, mark the activated export to inactive // and provide error detail in the export description. // @@ -206,24 +261,25 @@ func doGlobusTokenRefresh() error { if exp.Status == GlobusInactive { return nil } - newTok, err := refreshGlobusToken(cid, exp.Token) + + // Refresh collection token + newTok, err := refreshTokenWithRetry(cid, exp.Token, TokenTypeCollection, exp) if err != nil { - log.Errorf("Failed to refresh Globus token for collection %s with name %s. Will retry once: %v", cid, exp.DisplayName, err) - newTok, err = refreshGlobusToken(cid, exp.Token) - if err != nil { - log.Errorf("Failed to retry refreshing Globus token for collection %s with name %s: %v", cid, exp.DisplayName, err) - exp.Status = GlobusInactive - exp.Description = fmt.Sprintf("Failed to refresh token: %v", err) - return err - } + return err } - if newTok == nil { - log.Debugf("Globus token for collection %s with name %s is still valid. Refresh skipped", cid, exp.DisplayName) - } else { - // Update globusExport with the new token + if newTok != nil { expInt.Token = newTok - log.Debugf("Globus token for collection %s with name %s is refreshed", cid, exp.DisplayName) } + + // Refresh transfer token + newTransferTok, err := refreshTokenWithRetry(cid, exp.TransferToken, TokenTypeTransfer, exp) + if err != nil { + return err + } + if newTransferTok != nil { + expInt.TransferToken = newTransferTok + } + return nil }(cid, exp) if err != nil && firstErr == nil { diff --git a/origin/globus_client.go b/origin/globus_client.go index f1f07010a..0fb7564d1 100644 --- a/origin/globus_client.go +++ b/origin/globus_client.go @@ -64,7 +64,7 @@ var ( const ( globusIssuerEndpoint = "https://auth.globus.org/" // Globus issuer endpoint globusTransferServer = "transfer.api.globus.org" // The resource name for the Globus transfer API server - globusTransferEndpointBaseUrl = "https://transfer.api.globus.org/v0.10/endpoint/" + globusTransferEndpointBaseUrl = "https://transfer.api.globusonline.org/v0.10/" globusTransferBaseScope = "urn:globus:auth:scope:transfer.api.globus.org:all" ) @@ -73,6 +73,9 @@ const ( globusCallbackPath = "/view/origin/globus/callback" ) +const globusTokenFileExt = ".tok" // File extension for caching Globus access token +const globusTransferTokenFileExt = ".transfer.tok" // File extension for caching Globus transfer token + // Setup the OAuth2 config for Globus backend func setupGlobusOAuthCfg() { // First we try the server onboard OIDC issuer @@ -124,7 +127,9 @@ func setupGlobusOAuthCfg() { redirUrl, err := pelican_oauth2.GetRedirectURL(globusCallbackPath) if err != nil { - globusOAuthCfgError = err + errMsg := err.Error() + errMsg += fmt.Sprintf(". The Globus app expects the following redirect URL: %s", redirUrl) + globusOAuthCfgError = errors.New(errMsg) return } @@ -132,7 +137,7 @@ func setupGlobusOAuthCfg() { RedirectURL: redirUrl, ClientID: clientID, ClientSecret: clientSecret, - Scopes: append(iss.ScopesSupported, globusTransferBaseScope), + Scopes: iss.ScopesSupported, Endpoint: oauth2.Endpoint{ AuthURL: iss.AuthURL, DeviceAuthURL: iss.DeviceAuthURL, @@ -161,7 +166,7 @@ func getGlobusResourceToken(token *oauth2.Token, name string) (globusTok *oauth2 for _, tokInt := range tokArr { tok, ok := tokInt.(map[string]interface{}) if !ok { - err = fmt.Errorf("Globus resource token is not a map with string keys and interface values: %T", tokInt) + err = fmt.Errorf("the Globus resource token is not a map with string keys and interface values: %T", tokInt) return } rs, ok := tok["resource_server"] @@ -336,7 +341,7 @@ func handleGlobusCallback(ctx *gin.Context) { token, err := client.Exchange(c, req.Code) if err != nil { - log.Errorf("Error in exchanging code for token: %v", err) + log.Errorf("Error in exchanging code for token: %v", err) ctx.JSON(http.StatusInternalServerError, server_structs.SimpleApiResp{ Status: server_structs.RespFailed, @@ -370,7 +375,7 @@ func handleGlobusCallback(ctx *gin.Context) { } // Get the https server of the collection from Globus transfer API server - transferReq, err := http.NewRequest(http.MethodGet, globusTransferEndpointBaseUrl+cid, nil) + transferReq, err := http.NewRequest(http.MethodGet, globusTransferEndpointBaseUrl+"endpoint/"+cid, nil) if err != nil { log.Errorf("Error creating http request for Globus transfer API: %v", err) ctx.JSON(http.StatusInternalServerError, @@ -438,15 +443,20 @@ func handleGlobusCallback(ctx *gin.Context) { } // We have all the data in place, let's create/update related data structures: - // 1. Pesist access token to disk for XRootD to read - // 2. Update in-memory globusExports struct with the OAuth token (both access and refresh token), - // HttpsServer, and display name (from Globus API) - // 3. Update origin DB to persist the refresh token, HttpsServer, and display name + // 1. Persist access tokens to disk for XRootD to read (both transfer and collection tokens) + // 2. Update in-memory globusExports struct with the OAuth token (both access and refresh token), + // HttpsServer, and display name (from Globus API) + // 3. Update origin DB to persist the refresh tokens, HttpsServer, and display name err = func() error { globusExportsMutex.Lock() defer globusExportsMutex.Unlock() - if err := persistAccessToken(cid, collectionToken); err != nil { + var collectionTokenFile string + if collectionTokenFile, err = persistToken(cid, collectionToken, TokenTypeCollection); err != nil { + return err + } + var transferTokenFile string + if transferTokenFile, err = persistToken(cid, transferToken, TokenTypeTransfer); err != nil { return err } @@ -454,14 +464,17 @@ func handleGlobusCallback(ctx *gin.Context) { log.Infof("Updating existing Globus export %s with new token", cid) globusExports[cid].HttpsServer = transferJSON.HttpsServer globusExports[cid].Token = collectionToken + globusExports[cid].TransferToken = transferToken globusExports[cid].Status = GlobusActivated globusExports[cid].Description = "" + globusExports[cid].TokenFile = collectionTokenFile + globusExports[cid].TransferTokenFile = transferTokenFile if globusExports[cid].DisplayName == "" || globusExports[cid].DisplayName == cid { globusExports[cid].DisplayName = transferJSON.DisplayName } } else { // We should never go here - return fmt.Errorf("Globus collection %s with name %s does not exist in Pelican", cid, transferJSON.DisplayName) + return fmt.Errorf("the Globus collection %s with name %s does not exist in Pelican", cid, transferJSON.DisplayName) } ok, err := collectionExistsByUUID(cid) @@ -470,19 +483,21 @@ func handleGlobusCallback(ctx *gin.Context) { } if !ok { // First time activate this collection gc := GlobusCollection{ - UUID: cid, - Name: transferJSON.DisplayName, - ServerURL: transferJSON.HttpsServer, - RefreshToken: collectionToken.RefreshToken, + UUID: cid, + Name: transferJSON.DisplayName, + ServerURL: transferJSON.HttpsServer, + RefreshToken: collectionToken.RefreshToken, + TransferRefreshToken: transferToken.RefreshToken, } return createCollection(&gc) } else { // Activated this collection before, but for some reason we want to update the credentials // although in the token refresh logic, if any of the credentials expires, // we should hard-delete this collection entry gc := GlobusCollection{ - Name: transferJSON.DisplayName, - ServerURL: transferJSON.HttpsServer, - RefreshToken: collectionToken.RefreshToken, + Name: transferJSON.DisplayName, + ServerURL: transferJSON.HttpsServer, + RefreshToken: collectionToken.RefreshToken, + TransferRefreshToken: transferToken.RefreshToken, } return updateCollection(cid, &gc) } @@ -501,6 +516,7 @@ func handleGlobusCallback(ctx *gin.Context) { ctx.JSON(http.StatusOK, globusAuthCallbackRes{NextUrl: nextUrl}) // Restart the server + log.Debugf("Sending the signal to restart the server") config.RestartFlag <- true } @@ -549,6 +565,7 @@ func handleGlobusAuth(ctx *gin.Context) { baseScopes := client.Scopes reqScopes := append( baseScopes, + fmt.Sprintf("%s[*https://auth.globus.org/scopes/%s/data_access]", globusTransferBaseScope, cid), fmt.Sprintf("https://auth.globus.org/scopes/%s/https", cid), fmt.Sprintf("https://auth.globus.org/scopes/%s/data_access", cid), ) @@ -560,75 +577,98 @@ func handleGlobusAuth(ctx *gin.Context) { ctx.Redirect(http.StatusTemporaryRedirect, redirectUrl) } -// Persist the access token on the disk -func persistAccessToken(collectionID string, token *oauth2.Token) error { +// Persist a Globus access token on the disk +func persistToken(collectionID string, token *oauth2.Token, tokenType GlobusTokenType) (string, error) { uid, err := config.GetDaemonUID() if err != nil { - return errors.Wrap(err, "failed to persist Globus access token on disk: failed to get uid") + return "", errors.Wrapf(err, "failed to persist Globus %s access token on disk: failed to get uid", tokenType) } gid, err := config.GetDaemonGID() if err != nil { - return errors.Wrap(err, "failed to persist Globus access token on disk: failed to get gid") + return "", errors.Wrapf(err, "failed to persist Globus %s access token on disk: failed to get gid", tokenType) } globusFdr := param.Origin_GlobusConfigLocation.GetString() tokBase := filepath.Join(globusFdr, "tokens") if filepath.Clean(tokBase) == "" { - return fmt.Errorf("failed to update Globus token: Origin.GlobusTokenLocation is not a valid path: %s", tokBase) + return "", fmt.Errorf("failed to update Globus %s token: Origin.GlobusTokenLocation is not a valid path: %s", tokenType, tokBase) + } + + var filename string + switch tokenType { + case TokenTypeCollection: + filename = collectionID + globusTokenFileExt + case TokenTypeTransfer: + filename = collectionID + globusTransferTokenFileExt + default: + return "", fmt.Errorf("unknown token type: %s", tokenType) } - tokFileName := filepath.Join(tokBase, collectionID+GlobusTokenFileExt) - tmpTokFile, err := os.CreateTemp(tokBase, collectionID+GlobusTokenFileExt) + + tokFileName := filepath.Join(tokBase, filename) + tmpTokFile, err := os.CreateTemp(tokBase, filename) if err != nil { - return errors.Wrap(err, "failed to update Globus token: unable to create a temporary Globus token file") + return "", errors.Wrapf(err, "failed to update Globus %s token: unable to create a temporary Globus %s token file", tokenType, tokenType) } // We need to change the directory and file permission to XRootD user/group so that it can access the token if err = tmpTokFile.Chown(uid, gid); err != nil { - return errors.Wrapf(err, "unable to change the ownership of Globus token file at %s to xrootd daemon", tmpTokFile.Name()) + return "", errors.Wrapf(err, "unable to change the ownership of Globus %s token file at %s to xrootd daemon", tokenType, tmpTokFile.Name()) } defer tmpTokFile.Close() _, err = tmpTokFile.Write([]byte(token.AccessToken + "\n")) if err != nil { - return errors.Wrap(err, "failed to update Globus token: unable to write token to the tmp file") + return "", errors.Wrapf(err, "failed to update Globus %s token: unable to write token to the tmp file", tokenType) } if err = tmpTokFile.Sync(); err != nil { - return errors.Wrap(err, "failed to update Globus token: unable to flush tmp file to disk") + return "", errors.Wrapf(err, "failed to update Globus %s token: unable to flush tmp file to disk", tokenType) } if err := os.Rename(tmpTokFile.Name(), tokFileName); err != nil { - return errors.Wrap(err, "failed to update Globus token: unable to rename tmp file to the token file") + return "", errors.Wrapf(err, "failed to update Globus %s token: unable to rename tmp file to the token file", tokenType) } - return nil + return tokFileName, nil } -// Refresh a Globus OAuth2 token for collection access +// Refresh a Globus OAuth2 token // -// Returns nil if the token is still valid (expire time > 5min) or the refreshed token. +// Returns nil if the token is still valid (expire time > 10 min) or the refreshed token. // Returns error if any -func refreshGlobusToken(cid string, token *oauth2.Token) (*oauth2.Token, error) { +func refreshGlobusToken(cid string, token *oauth2.Token, tokenType GlobusTokenType) (*oauth2.Token, error) { if token == nil { - return nil, fmt.Errorf("failed to update Globus token for collection %s: token is nil", cid) + return nil, fmt.Errorf("failed to update Globus %s token for collection %s: token is nil", tokenType, cid) } - // If token is not expired in the next 5min, return - if !token.Expiry.Before(time.Now().Add(5 * time.Minute)) { + // If token is not expired in the next 10 min, return + if !token.Expiry.Before(time.Now().Add(10 * time.Minute)) { return nil, nil } config, err := GetGlobusOAuthCfg() if err != nil { - return nil, fmt.Errorf("failed to get Globus client to update Globus token for collection %s:", cid) + return nil, fmt.Errorf("failed to get Globus client to update Globus %s token for collection %s", tokenType, cid) } ts := config.TokenSource(context.Background(), token) newTok, err := ts.Token() if err != nil { - return nil, fmt.Errorf("failed to update Globus token for collection %s:", cid) + return nil, fmt.Errorf("failed to update Globus %s token for collection %s", tokenType, cid) } // Update access token location with the new token - if err := persistAccessToken(cid, token); err != nil { + _, err = persistToken(cid, newTok, tokenType) + if err != nil { return nil, err } - if err := updateCollection(cid, &GlobusCollection{RefreshToken: newTok.RefreshToken}); err != nil { + // Update the appropriate refresh token in the database + var updateData *GlobusCollection + switch tokenType { + case TokenTypeCollection: + updateData = &GlobusCollection{RefreshToken: newTok.RefreshToken} + case TokenTypeTransfer: + updateData = &GlobusCollection{TransferRefreshToken: newTok.RefreshToken} + default: + return nil, fmt.Errorf("unknown token type: %s", tokenType) + } + + if err := updateCollection(cid, updateData); err != nil { return nil, err } diff --git a/origin/origin_db.go b/origin/origin_db.go index 6ecaa4289..16a3d0428 100644 --- a/origin/origin_db.go +++ b/origin/origin_db.go @@ -19,72 +19,29 @@ package origin import ( - "embed" "time" "github.com/pkg/errors" - "gorm.io/gorm" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/param" - "github.com/pelicanplatform/pelican/server_utils" + "github.com/pelicanplatform/pelican/database" ) type GlobusCollection struct { - UUID string `gorm:"primaryKey"` - Name string `gorm:"not null;default:''"` - ServerURL string `gorm:"not null;default:''"` - RefreshToken string `gorm:"not null;default:''"` + UUID string `gorm:"primaryKey"` + Name string `gorm:"not null;default:''"` + ServerURL string `gorm:"not null;default:''"` + RefreshToken string `gorm:"not null;default:''"` + TransferRefreshToken string `gorm:"not null;default:''"` // We don't use gorm default gorm.Model to use UUID as the pk // and don't allow soft delete CreatedAt time.Time UpdatedAt time.Time } -/* -Declare the DB handle as an unexported global so that all -functions in the package can access it without having to -pass it around. This simplifies the HTTP handlers, and -the handle is already thread-safe! The approach being used -is based off of 1.b from -https://www.alexedwards.net/blog/organising-database-access -*/ -var db *gorm.DB - -//go:embed migrations/*.sql -var embedMigrations embed.FS - -func InitializeDB() error { - dbPath := param.Origin_DbLocation.GetString() - - tdb, err := server_utils.InitSQLiteDB(dbPath) - if err != nil { - return err - } - - db = tdb - - sqldb, err := db.DB() - - if err != nil { - return errors.Wrapf(err, "Failed to get sql.DB from gorm DB: %s", dbPath) - } - - // Run database migrations - if err := server_utils.MigrateDB(sqldb, embedMigrations); err != nil { - return err - } - - return nil -} - -func ShutdownOriginDB() error { - return server_utils.ShutdownDB(db) -} - func collectionExistsByUUID(uuid string) (bool, error) { var count int64 - err := db.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Count(&count).Error + err := database.ServerDatabase.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Count(&count).Error if err != nil { return false, err } @@ -93,7 +50,7 @@ func collectionExistsByUUID(uuid string) (bool, error) { func getCollectionByUUID(uuid string) (*GlobusCollection, error) { var collection GlobusCollection - err := db.First(&collection, "uuid = ?", uuid).Error + err := database.ServerDatabase.First(&collection, "uuid = ?", uuid).Error if err != nil { return nil, err } @@ -116,7 +73,24 @@ func getCollectionByUUID(uuid string) (*GlobusCollection, error) { newEncrypted, err := config.EncryptString(collection.RefreshToken) if err == nil { // Only update if re-encryption succeeded - db.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Update("refresh_token", newEncrypted) + database.ServerDatabase.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Update("refresh_token", newEncrypted) + } + } + } + if collection.TransferRefreshToken != "" { + var keyID string + collection.TransferRefreshToken, keyID, err = config.DecryptString(collection.TransferRefreshToken) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt the transfer refresh token") + } + currentIssuerKey, err := config.GetIssuerPrivateJWK() + if err != nil { + return nil, errors.Wrap(err, "failed to get current issuer key") + } + if keyID != currentIssuerKey.KeyID() { + newEncrypted, err := config.EncryptString(collection.TransferRefreshToken) + if err == nil { + database.ServerDatabase.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Update("transfer_refresh_token", newEncrypted) } } } @@ -124,28 +98,42 @@ func getCollectionByUUID(uuid string) (*GlobusCollection, error) { } func createCollection(collection *GlobusCollection) error { - var err error if collection.RefreshToken != "" { + var err error collection.RefreshToken, err = config.EncryptString(collection.RefreshToken) if err != nil { return errors.Wrap(err, "failed to encrypt the refresh token") } } - if err = db.Create(collection).Error; err != nil { + if collection.TransferRefreshToken != "" { + var err error + collection.TransferRefreshToken, err = config.EncryptString(collection.TransferRefreshToken) + if err != nil { + return errors.Wrap(err, "failed to encrypt the transfer refresh token") + } + } + if err := database.ServerDatabase.Create(collection).Error; err != nil { return err } return nil } func updateCollection(uuid string, updatedCollection *GlobusCollection) error { - var err error if updatedCollection.RefreshToken != "" { + var err error updatedCollection.RefreshToken, err = config.EncryptString(updatedCollection.RefreshToken) if err != nil { return errors.Wrap(err, "failed to encrypt the refresh token") } } - if err = db.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Updates(updatedCollection).Error; err != nil { + if updatedCollection.TransferRefreshToken != "" { + var err error + updatedCollection.TransferRefreshToken, err = config.EncryptString(updatedCollection.TransferRefreshToken) + if err != nil { + return errors.Wrap(err, "failed to encrypt the transfer refresh token") + } + } + if err := database.ServerDatabase.Model(&GlobusCollection{}).Where("uuid = ?", uuid).Updates(updatedCollection).Error; err != nil { return err } @@ -154,5 +142,5 @@ func updateCollection(uuid string, updatedCollection *GlobusCollection) error { // Hard-delete the collection from the DB func deleteCollectionByUUID(uuid string) error { - return db.Delete(&GlobusCollection{}, "uuid = ?", uuid).Error + return database.ServerDatabase.Delete(&GlobusCollection{}, "uuid = ?", uuid).Error } diff --git a/origin/origin_db_test.go b/origin/origin_db_test.go index 6d046522b..45091c306 100644 --- a/origin/origin_db_test.go +++ b/origin/origin_db_test.go @@ -31,32 +31,39 @@ import ( "gorm.io/gorm" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/database" "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_utils" ) const ( - mockRefreshTok1 = "randomtokenstirng123" - mockRefreshTok2 = "randomtokenstirng456" - mockRefreshTok3 = "randomtokenstirng789" - mockRefreshTok4 = "randomtokenstirng101112" + mockRefreshTok1 = "randomtokenstirng123" + mockRefreshTok2 = "randomtokenstirng456" + mockRefreshTok3 = "randomtokenstirng789" + mockRefreshTok4 = "randomtokenstirng101112" + mockTransferTok1 = "transfertokenstring123" + mockTransferTok2 = "transfertokenstring456" + mockTransferTok3 = "transfertokenstring789" + mockTransferTok4 = "transfertokenstring101112" ) var ( mockGC []GlobusCollection = []GlobusCollection{ - {UUID: uuid.NewString(), Name: "mock1", ServerURL: "https://mock1.org", RefreshToken: mockRefreshTok1}, - {UUID: uuid.NewString(), Name: "mock2", ServerURL: "https://mock2.org", RefreshToken: mockRefreshTok2}, - {UUID: uuid.NewString(), Name: "mock3", ServerURL: "https://mock3.org", RefreshToken: mockRefreshTok3}, - {UUID: uuid.NewString(), Name: "mock4", ServerURL: "https://mock4.org", RefreshToken: mockRefreshTok4}, + {UUID: uuid.NewString(), Name: "mock1", ServerURL: "https://mock1.org", RefreshToken: mockRefreshTok1, TransferRefreshToken: mockTransferTok1}, + {UUID: uuid.NewString(), Name: "mock2", ServerURL: "https://mock2.org", RefreshToken: mockRefreshTok2, TransferRefreshToken: mockTransferTok2}, + {UUID: uuid.NewString(), Name: "mock3", ServerURL: "https://mock3.org", RefreshToken: mockRefreshTok3, TransferRefreshToken: mockTransferTok3}, + {UUID: uuid.NewString(), Name: "mock4", ServerURL: "https://mock4.org", RefreshToken: mockRefreshTok4, TransferRefreshToken: mockTransferTok4}, } ) // Setup helper functions func setupMockOriginDB(t *testing.T) { mockDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - db = mockDB require.NoError(t, err, "Error setting up mock origin DB") - err = db.AutoMigrate(&GlobusCollection{}) + + database.ServerDatabase = mockDB + + err = database.ServerDatabase.AutoMigrate(&GlobusCollection{}) require.NoError(t, err, "Failed to migrate DB for Globus table") // Setup encryption @@ -69,11 +76,15 @@ func setupMockOriginDB(t *testing.T) { encrypted, err := config.EncryptString(mockGC[idx].RefreshToken) require.NoError(t, err) mockGC[idx].RefreshToken = encrypted + + encryptedTransfer, err := config.EncryptString(mockGC[idx].TransferRefreshToken) + require.NoError(t, err) + mockGC[idx].TransferRefreshToken = encryptedTransfer } } func resetGlobusTable(t *testing.T) { - err := db.Where("1 = 1").Delete(&GlobusCollection{}).Error + err := database.ServerDatabase.Where("1 = 1").Delete(&GlobusCollection{}).Error require.NoError(t, err, "Error resetting Globus table") } @@ -82,16 +93,23 @@ func teardownMockOriginDB(t *testing.T) { mockGC[1].RefreshToken = mockRefreshTok2 mockGC[2].RefreshToken = mockRefreshTok3 mockGC[3].RefreshToken = mockRefreshTok4 + mockGC[0].TransferRefreshToken = mockTransferTok1 + mockGC[1].TransferRefreshToken = mockTransferTok2 + mockGC[2].TransferRefreshToken = mockTransferTok3 + mockGC[3].TransferRefreshToken = mockTransferTok4 - err := ShutdownOriginDB() + err := database.ShutdownDB() require.NoError(t, err, "Error tearing down mock namespace DB") + + database.ServerDatabase = nil } func insertMockDBData(gc []GlobusCollection) error { - return db.Create(&gc).Error + return database.ServerDatabase.Create(&gc).Error } func compareCollection(a, b GlobusCollection) bool { + // Only compare non-token fields since tokens are encrypted in DB and decrypted when retrieved return a.UUID == b.UUID && a.Name == b.Name && a.ServerURL == b.ServerURL } @@ -146,6 +164,7 @@ func TestGetCollectionByUUID(t *testing.T) { assert.True(t, compareCollection(mockGC[0], *get), fmt.Sprintf("Expected: %#v\nGot: %#v", mockGC[0], *get)) // Refresh token is encrypted in the mock data, but the get result is decrypted assert.Equal(t, mockRefreshTok1, get.RefreshToken) + assert.Equal(t, mockTransferTok1, get.TransferRefreshToken) }) t.Run("collection-DNE", func(t *testing.T) { @@ -173,10 +192,11 @@ func TestCreateCollection(t *testing.T) { t.Run("create-collection-returns-no-err", func(t *testing.T) { mockGCCreate := GlobusCollection{ - UUID: uuid.NewString(), - Name: "mock1", - ServerURL: "https://mock1.org", - RefreshToken: mockRefreshTok1, + UUID: uuid.NewString(), + Name: "mock1", + ServerURL: "https://mock1.org", + RefreshToken: mockRefreshTok1, + TransferRefreshToken: mockTransferTok1, } err := createCollection(&mockGCCreate) require.NoError(t, err) @@ -184,6 +204,7 @@ func TestCreateCollection(t *testing.T) { require.NoError(t, err) assert.True(t, compareCollection(mockGCCreate, *get)) assert.Equal(t, mockRefreshTok1, get.RefreshToken) + assert.Equal(t, mockTransferTok1, get.TransferRefreshToken) }) t.Run("create-collection-wo-token-returns-no-err", func(t *testing.T) { @@ -200,6 +221,7 @@ func TestCreateCollection(t *testing.T) { require.NoError(t, err) assert.True(t, compareCollection(mockGCCreate, *get)) assert.Empty(t, get.RefreshToken) + assert.Empty(t, get.TransferRefreshToken) }) } @@ -212,10 +234,11 @@ func TestUpdateCollection(t *testing.T) { }) mockGCCreate := GlobusCollection{ - UUID: uuid.NewString(), - Name: "mock1", - ServerURL: "https://mock1.org", - RefreshToken: mockRefreshTok1, + UUID: uuid.NewString(), + Name: "mock1", + ServerURL: "https://mock1.org", + RefreshToken: mockRefreshTok1, + TransferRefreshToken: mockTransferTok1, } err := createCollection(&mockGCCreate) require.NoError(t, err) @@ -231,6 +254,7 @@ func TestUpdateCollection(t *testing.T) { mockGCCreate.ServerURL = "https://new.org" assert.True(t, compareCollection(mockGCCreate, *get)) assert.Equal(t, mockRefreshTok1, get.RefreshToken) + assert.Equal(t, mockTransferTok1, get.TransferRefreshToken) } func TestDeleteCollectionByUUID(t *testing.T) { @@ -242,10 +266,11 @@ func TestDeleteCollectionByUUID(t *testing.T) { }) mockGCCreate := GlobusCollection{ - UUID: uuid.NewString(), - Name: "mock1", - ServerURL: "https://mock1.org", - RefreshToken: mockRefreshTok1, + UUID: uuid.NewString(), + Name: "mock1", + ServerURL: "https://mock1.org", + RefreshToken: mockRefreshTok1, + TransferRefreshToken: mockTransferTok1, } err := createCollection(&mockGCCreate) require.NoError(t, err) @@ -260,3 +285,80 @@ func TestDeleteCollectionByUUID(t *testing.T) { require.NoError(t, err) assert.False(t, ok) } + +func TestUpdateTransferRefreshToken(t *testing.T) { + server_utils.ResetTestState() + setupMockOriginDB(t) + t.Cleanup(func() { + server_utils.ResetTestState() + teardownMockOriginDB(t) + }) + + mockGCCreate := GlobusCollection{ + UUID: uuid.NewString(), + Name: "mock1", + ServerURL: "https://mock1.org", + RefreshToken: mockRefreshTok1, + TransferRefreshToken: mockTransferTok1, + } + err := createCollection(&mockGCCreate) + require.NoError(t, err) + + // Update only the transfer refresh token + newTransferToken := "newtransfertoken123" + err = updateCollection(mockGCCreate.UUID, &GlobusCollection{TransferRefreshToken: newTransferToken}) + require.NoError(t, err) + + get, err := getCollectionByUUID(mockGCCreate.UUID) + require.NoError(t, err) + // Original refresh token should remain unchanged + assert.Equal(t, mockRefreshTok1, get.RefreshToken) + // Transfer refresh token should be updated + assert.Equal(t, newTransferToken, get.TransferRefreshToken) +} + +func TestCreateCollectionWithOnlyTransferToken(t *testing.T) { + server_utils.ResetTestState() + setupMockOriginDB(t) + t.Cleanup(func() { + server_utils.ResetTestState() + teardownMockOriginDB(t) + }) + + mockGCCreate := GlobusCollection{ + UUID: uuid.NewString(), + Name: "mock1", + ServerURL: "https://mock1.org", + TransferRefreshToken: mockTransferTok1, + // No RefreshToken set + } + err := createCollection(&mockGCCreate) + require.NoError(t, err) + get, err := getCollectionByUUID(mockGCCreate.UUID) + require.NoError(t, err) + assert.Empty(t, get.RefreshToken) + assert.Equal(t, mockTransferTok1, get.TransferRefreshToken) +} + +func TestCreateCollectionWithOnlyRefreshToken(t *testing.T) { + server_utils.ResetTestState() + setupMockOriginDB(t) + t.Cleanup(func() { + server_utils.ResetTestState() + teardownMockOriginDB(t) + }) + + mockGCCreate := GlobusCollection{ + UUID: uuid.NewString(), + Name: "mock1", + ServerURL: "https://mock1.org", + RefreshToken: mockRefreshTok1, + // No TransferRefreshToken set + } + err := createCollection(&mockGCCreate) + require.NoError(t, err) + get, err := getCollectionByUUID(mockGCCreate.UUID) + require.NoError(t, err) + assert.Equal(t, mockRefreshTok1, get.RefreshToken) + assert.Empty(t, get.TransferRefreshToken) +} diff --git a/param/parameters.go b/param/parameters.go index 560fbeab8..3a8ba83f2 100644 --- a/param/parameters.go +++ b/param/parameters.go @@ -1599,6 +1599,7 @@ var ( Origin_GlobusCollectionID = StringParam{"Origin.GlobusCollectionID"} Origin_GlobusCollectionName = StringParam{"Origin.GlobusCollectionName"} Origin_GlobusConfigLocation = StringParam{"Origin.GlobusConfigLocation"} + Origin_GlobusTransferTokenFile = StringParam{"Origin.GlobusTransferTokenFile"} Origin_HttpAuthTokenFile = StringParam{"Origin.HttpAuthTokenFile"} Origin_HttpServiceUrl = StringParam{"Origin.HttpServiceUrl"} Origin_Mode = StringParam{"Origin.Mode"} diff --git a/param/parameters_struct.go b/param/parameters_struct.go index 3ffd839f9..dfa58600d 100644 --- a/param/parameters_struct.go +++ b/param/parameters_struct.go @@ -254,6 +254,7 @@ type Config struct { GlobusCollectionID string `mapstructure:"globuscollectionid" yaml:"GlobusCollectionID"` GlobusCollectionName string `mapstructure:"globuscollectionname" yaml:"GlobusCollectionName"` GlobusConfigLocation string `mapstructure:"globusconfiglocation" yaml:"GlobusConfigLocation"` + GlobusTransferTokenFile string `mapstructure:"globustransfertokenfile" yaml:"GlobusTransferTokenFile"` HttpAuthTokenFile string `mapstructure:"httpauthtokenfile" yaml:"HttpAuthTokenFile"` HttpServiceUrl string `mapstructure:"httpserviceurl" yaml:"HttpServiceUrl"` Mode string `mapstructure:"mode" yaml:"Mode"` @@ -636,6 +637,7 @@ type configWithType struct { GlobusCollectionID struct { Type string; Value string } GlobusCollectionName struct { Type string; Value string } GlobusConfigLocation struct { Type string; Value string } + GlobusTransferTokenFile struct { Type string; Value string } HttpAuthTokenFile struct { Type string; Value string } HttpServiceUrl struct { Type string; Value string } Mode struct { Type string; Value string } diff --git a/server_utils/origin_globus.go b/server_utils/origin_globus.go index 07e40d31d..bd56c1357 100644 --- a/server_utils/origin_globus.go +++ b/server_utils/origin_globus.go @@ -53,14 +53,6 @@ func (o *GlobusOrigin) validateExtra(e *OriginExport, numExports int) (err error return errors.Errorf("GlobusCollectionName is required for export '%s'", e.FederationPrefix) } - if e.Capabilities.Writes { - return errors.Errorf("export %s sets the 'Writes' capability; writes are not yet supported for origins with %s of 'globus'", e.FederationPrefix, param.Origin_StorageType.GetName()) - } - - if e.Capabilities.Listings { - return errors.Errorf("export %s sets the 'Listings' capability; listings are not yet supported for origins with %s of 'globus'", e.FederationPrefix, param.Origin_StorageType.GetName()) - } - if viper.GetString(param.OIDC_Issuer.GetName()) != "globus" { clientIDFile := param.Origin_GlobusClientIDFile.GetString() if clientIDFile == "" { diff --git a/web_ui/frontend/app/origin/globus/callback/page.tsx b/web_ui/frontend/app/origin/globus/callback/page.tsx index 26b26ba8b..54a1eb842 100644 --- a/web_ui/frontend/app/origin/globus/callback/page.tsx +++ b/web_ui/frontend/app/origin/globus/callback/page.tsx @@ -36,26 +36,26 @@ export default function Home() { const router = useRouter(); - const postCallback = async (search: string): Promise => { - const url = new URL( - '/api/v1.0/origin_ui/globus/auth/callback' + search, - window.location.origin - ); + useEffect(() => { + const postCallback = async (search: string): Promise => { + const url = new URL( + '/api/v1.0/origin_ui/globus/auth/callback' + search, + window.location.origin + ); - const response = await fetch(url, { - credentials: 'include', - }); + const response = await fetch(url, { + credentials: 'include', + }); - if (response.ok) { - const resData = await response.json(); - return resData; - } else { - const errMsg = await getErrorMessage(response); - return Promise.reject(errMsg); - } - }; + if (response.ok) { + const resData = await response.json(); + return resData; + } else { + const errMsg = await getErrorMessage(response); + return Promise.reject(errMsg); + } + }; - useEffect(() => { const cbQuery = window.location.search; const cbQueryParsed = new URLSearchParams(cbQuery); if (!cbQueryParsed.get('code') || !cbQueryParsed.get('state')) { @@ -69,9 +69,10 @@ export default function Home() { postCallback(cbQuery) .then((res) => { + const redirectTarget = res.nextUrl || '/origin/globus'; setNextUrl(res.nextUrl); timeout = setTimeout(() => { - router.replace(nextUrl || '/origin/globus'); + router.replace(redirectTarget); }, 5000); timer = setInterval(() => { @@ -88,7 +89,7 @@ export default function Home() { timeout && clearTimeout(timeout); timer && clearInterval(timer); }; - }, [nextUrl, router]); + }, [router]); return (