diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/DBWConstants.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/DBWConstants.java index e2fcae3536..4b29e2e7d6 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/DBWConstants.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/DBWConstants.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,7 @@ public interface DBWConstants { GLOBAL, EMBEDDED } - + String TASK_STATUS_FINISHED = "Finished"; //public static final String PERMISSION_USER = "user"; } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/CustomCancelableJob.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/CustomCancelableJob.java new file mode 100644 index 0000000000..6ccc7159fe --- /dev/null +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/CustomCancelableJob.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.model; + +import io.cloudbeaver.model.session.WebSession; +import org.jkiss.code.NotNull; + +public interface CustomCancelableJob { + void cancelJob(@NotNull WebSession webSession, @NotNull WebAsyncTaskInfo taskInfo); +} diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebAsyncTaskInfo.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebAsyncTaskInfo.java index e168a54597..6930bb32e1 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebAsyncTaskInfo.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/WebAsyncTaskInfo.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,7 @@ public class WebAsyncTaskInfo { private Throwable jobError; private AbstractJob job; + private boolean cancelled = false; public WebAsyncTaskInfo(@NotNull String id, @NotNull String name) { this.id = id; @@ -107,5 +108,4 @@ public class WebAsyncTaskInfo { public void setJob(AbstractJob job) { this.job = job; } - } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java index 9c554d852c..e02f4e779b 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java @@ -34,6 +34,7 @@ import java.util.Map; /** * WebAuthInfo */ +//TODO: create serializable model? public class WebAuthInfo implements SMSessionPrincipal, WebUserAuthToken { private static final Log log = Log.getLog(WebAuthInfo.class); diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java index fea5d93a04..6471b07c89 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package io.cloudbeaver.model.session; import io.cloudbeaver.*; +import io.cloudbeaver.model.CustomCancelableJob; import io.cloudbeaver.model.WebAsyncTaskInfo; import io.cloudbeaver.model.WebConnectionInfo; import io.cloudbeaver.model.WebServerMessage; @@ -544,15 +545,36 @@ public class WebSession extends BaseWebSession } } AbstractJob job = taskInfo.getJob(); + if (job instanceof CustomCancelableJob cancelableJob) { + cancelableJob.cancelJob(this, taskInfo); + } if (job != null) { job.cancel(); } return true; } - public WebAsyncTaskInfo createAndRunAsyncTask(@NotNull String taskName, @NotNull WebAsyncTaskProcessor runnable) { + + public WebAsyncTaskInfo createAsyncTask(@NotNull String taskName) { int taskId = TASK_ID.incrementAndGet(); WebAsyncTaskInfo asyncTask = getAsyncTask(String.valueOf(taskId), taskName, true); + return asyncTask; + } + + public List findTasksByJob(@NotNull Class jobClass) { + synchronized (asyncTasks) { + List result = new ArrayList<>(); + for (WebAsyncTaskInfo task : asyncTasks.values()) { + if (task.getJob() != null && jobClass.isAssignableFrom(task.getJob().getClass())) { + result.add(task); + } + } + return result; + } + } + + public WebAsyncTaskInfo createAndRunAsyncTask(@NotNull String taskName, @NotNull WebAsyncTaskProcessor runnable) { + WebAsyncTaskInfo asyncTask = createAsyncTask(taskName); AbstractJob job = new AbstractJob(taskName) { @Override @@ -571,7 +593,7 @@ public class WebSession extends BaseWebSession runnable.run(taskMonitor); asyncTask.setResult(runnable.getResult()); asyncTask.setExtendedResult(runnable.getExtendedResults()); - asyncTask.setStatus("Finished"); + asyncTask.setStatus(DBWConstants.TASK_STATUS_FINISHED); } catch (InvocationTargetException e) { addSessionError(e.getTargetException()); asyncTask.setJobError(e.getTargetException()); diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderDescriptor.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderDescriptor.java index 3ef0f8a93e..6156d3b5de 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderDescriptor.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderDescriptor.java @@ -16,6 +16,7 @@ */ package io.cloudbeaver.registry; +import io.cloudbeaver.auth.SMAuthProviderFederated; import org.eclipse.core.runtime.IConfigurationElement; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -229,4 +230,13 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor { public boolean isServiceProvider() { return serviceProvider; } + + public boolean isFederated() { + try { + implType.checkObjectClass(SMAuthProviderFederated.class); + return true; + } catch (DBException e) { + return false; + } + } } diff --git a/server/bundles/io.cloudbeaver.server/schema/service.events.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.events.graphqls index 46f335471b..04ad4a20ab 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.events.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.events.graphqls @@ -37,8 +37,7 @@ enum CBServerEventId { cb_transaction_count @since(version: "24.3.3") - cb_session_task_info_updated @since(version: "24.3.1"), - cb_web_session_auth @since(version: "25.0.1") + cb_session_task_info_updated @since(version: "24.3.1") } # Events sent by client @@ -227,12 +226,6 @@ type WSTransactionalCountEvent implements CBServerEvent { transactionalCount: Int! } -type WSWebSessionAuthEvent implements CBServerEvent { - id: CBServerEventId! - topicId: CBEventTopic! - userTokens: [UserAuthToken!] -} - extend type Query { emptyEvent: Boolean } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java index 028e937780..65ded95c17 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/core/DBWServiceCore.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -201,10 +201,10 @@ public interface DBWServiceCore extends DBWService { /////////////////////////////////////////// // Async tasks - @WebAction + @WebAction(authRequired = false) WebAsyncTaskInfo getAsyncTaskInfo(WebSession webSession, String taskId, Boolean removeOnFinish) throws DBWebException; - @WebAction + @WebAction(authRequired = false) boolean cancelAsyncTask(WebSession webSession, String taskId) throws DBWebException; } diff --git a/server/bundles/io.cloudbeaver.service.auth/plugin.xml b/server/bundles/io.cloudbeaver.service.auth/plugin.xml index c276ded7b3..d34aea2e28 100644 --- a/server/bundles/io.cloudbeaver.service.auth/plugin.xml +++ b/server/bundles/io.cloudbeaver.service.auth/plugin.xml @@ -19,15 +19,10 @@ - - - - - - - - - + + + + + diff --git a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls index 2586130800..ebe58954ef 100644 --- a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls +++ b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls @@ -81,17 +81,25 @@ type AuthProviderInfo { required: Boolean! } -type AuthInfo { - redirectLink: String +type AuthInfo { + redirectLink: String @deprecated authId: String @deprecated - authStatus: AuthStatus! + authStatus: AuthStatus! @deprecated userTokens: [UserAuthToken!] } +type FederatedAuthInfo @since(version: "25.0.3") { + redirectLink: String! + taskInfo: AsyncTaskInfo! +} + +type FederatedAuthResult @since(version: "25.0.3") { + userTokens: [UserAuthToken!]! @since(version: "25.0.3") +} type LogoutInfo @since(version: "23.3.3") { redirectLinks: [String!]! @@ -158,6 +166,10 @@ extend type Query { # If forceSessionsLogout=true then kill another sessions authLogin(provider: ID!, configuration: ID, credentials: Object, linkUser: Boolean, forceSessionsLogout: Boolean): AuthInfo! + @since(version: "25.0.3") + federatedAuthTaskResult(taskId: String!): FederatedAuthResult! + + @deprecated authUpdateStatus(authId: ID!, linkUser: Boolean): AuthInfo! # Logouts user. If provider not specified then all authorizations are revoked from session. @@ -187,4 +199,6 @@ extend type Mutation { # Updates user preferences setUserPreferences(preferences: Object!): UserInfo! @since(version: "24.0.1") + @since(version: "25.0.3") + federatedLogin(provider: ID!, configuration: ID, linkUser: Boolean, forceSessionsLogout: Boolean): FederatedAuthInfo! } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java index 12d1266369..4a843da817 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,8 +39,23 @@ public interface DBWServiceAuth extends DBWService { @Nullable String providerConfigurationId, @Nullable Map credentials, boolean linkWithActiveUser, - boolean forceSessionsLogout) throws DBWebException; + boolean forceSessionsLogout + ) throws DBWebException; + @WebAction(authRequired = false) + WebAsyncAuthStatus federatedLogin( + @NotNull WebSession webSession, + @NotNull String providerId, + @Nullable String providerConfigurationId, + boolean linkWithActiveUser, + boolean forceSessionsLogout + ) throws DBWebException; + + @WebAction(authRequired = false) + WebAsyncAuthTaskResult federatedAuthTaskResult( + @NotNull WebSession webSession, + @NotNull String taskId + ) throws DBWebException; @WebAction(authRequired = false) WebAuthStatus authUpdateStatus(@NotNull WebSession webSession, @NotNull String authId, boolean linkWithActiveUser) throws DBWebException; diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthJob.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthJob.java new file mode 100644 index 0000000000..b10bde7c96 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthJob.java @@ -0,0 +1,78 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.auth; + +import io.cloudbeaver.model.CustomCancelableJob; +import io.cloudbeaver.model.WebAsyncTaskInfo; +import io.cloudbeaver.model.session.WebAuthInfo; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.utils.WebEventUtils; +import org.eclipse.core.runtime.IStatus; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.runtime.AbstractJob; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; + +import java.util.List; + +public class WebAsyncAuthJob extends AbstractJob implements CustomCancelableJob { + @NotNull + private final String authId; + private final boolean linkWithUser; + //result from task do used, because it cannot be serialized into 'object' gql type and separate request is used + //to get auth result + @Nullable + private List authResult; + + public WebAsyncAuthJob(@NotNull String name, @NotNull String authId, boolean linkWithUser) { + super(name); + this.authId = authId; + this.linkWithUser = linkWithUser; + } + + //do nothing, this job is workaround to use exist async process + @Override + protected IStatus run(DBRProgressMonitor monitor) { + return null; + } + + @NotNull + public String getAuthId() { + return authId; + } + + public boolean isLinkWithUser() { + return linkWithUser; + } + + @Nullable + public List getAuthResult() { + return authResult; + } + + public void setAuthResult(@Nullable List authResult) { + this.authResult = authResult; + } + + @Override + public void cancelJob(@NotNull WebSession webSession, @NotNull WebAsyncTaskInfo taskInfo) { + taskInfo.setRunning(false); + taskInfo.setJobError(new DBException("Canceled by the user")); + WebEventUtils.sendAsyncTaskEvent(webSession, taskInfo); + } +} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthStatus.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthStatus.java new file mode 100644 index 0000000000..1e1b698182 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthStatus.java @@ -0,0 +1,46 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.auth; + +import io.cloudbeaver.model.WebAsyncTaskInfo; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.meta.Property; + +public class WebAsyncAuthStatus { + @NotNull + private final String redirectLink; + + @NotNull + private final WebAsyncTaskInfo taskInfo; + + public WebAsyncAuthStatus(@NotNull String redirectLink, @NotNull WebAsyncTaskInfo taskInfo) { + this.redirectLink = redirectLink; + this.taskInfo = taskInfo; + } + + @Property + @NotNull + public String getRedirectLink() { + return redirectLink; + } + + @NotNull + @Property + public WebAsyncTaskInfo getTaskInfo() { + return taskInfo; + } +} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthTaskResult.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthTaskResult.java new file mode 100644 index 0000000000..c5f789bc6f --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebAsyncAuthTaskResult.java @@ -0,0 +1,38 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.auth; + +import io.cloudbeaver.model.session.WebAuthInfo; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.meta.Property; + +import java.util.List; + +public class WebAsyncAuthTaskResult { + @NotNull + private final List userTokens; + + public WebAsyncAuthTaskResult(@NotNull List userTokens) { + this.userTokens = userTokens; + } + + @NotNull + @Property + public List getUserTokens() { + return userTokens; + } +} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java index 7c01c9610c..e05f4d628a 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java @@ -1,6 +1,6 @@ /* * DBeaver - Universal Database Manager - * Copyright (C) 2010-2024 DBeaver Corp and others + * Copyright (C) 2010-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,10 @@ public class WebServiceBindingAuth extends WebServiceBindingBase CommonUtils.toBoolean(env.getArgument("linkUser")), CommonUtils.toBoolean(env.getArgument("forceSessionsLogout")) )) + .dataFetcher("federatedAuthTaskResult", env -> getService(env).federatedAuthTaskResult( + getWebSession(env, false), + env.getArgument("taskId") + )) .dataFetcher("authLogoutExtended", env -> getService(env).authLogout( getWebSession(env, false), env.getArgument("provider"), @@ -78,6 +82,13 @@ public class WebServiceBindingAuth extends WebServiceBindingBase .dataFetcher("setUserPreferences", env -> getService(env).setUserConfigurationParameters(getWebSession(env), env.getArgument("preferences"))) + .dataFetcher("federatedLogin", env -> getService(env).federatedLogin( + getWebSession(env, false), + env.getArgument("provider"), + env.getArgument("configuration"), + CommonUtils.toBoolean(env.getArgument("linkUser")), + CommonUtils.toBoolean(env.getArgument("forceSessionsLogout")) + )) ; } } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WSAuthSessionEventHandler.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WSAuthSessionEventHandler.java index 6ab7d190fa..64defe95bd 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WSAuthSessionEventHandler.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WSAuthSessionEventHandler.java @@ -16,11 +16,18 @@ */ package io.cloudbeaver.service.auth.handler; +import io.cloudbeaver.DBWConstants; import io.cloudbeaver.DBWebException; -import io.cloudbeaver.model.session.*; +import io.cloudbeaver.model.WebAsyncTaskInfo; +import io.cloudbeaver.model.session.BaseWebSession; +import io.cloudbeaver.model.session.WebAuthInfo; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.model.session.WebSessionAuthProcessor; import io.cloudbeaver.server.WebAppSessionManager; import io.cloudbeaver.server.WebAppUtils; import io.cloudbeaver.server.WebApplication; +import io.cloudbeaver.service.auth.WebAsyncAuthJob; +import io.cloudbeaver.utils.WebEventUtils; import org.jkiss.code.NotNull; import org.jkiss.dbeaver.DBException; import org.jkiss.dbeaver.Log; @@ -48,6 +55,27 @@ public class WSAuthSessionEventHandler implements WSEventHandler { log.trace("No web session found in current node with id '" + sessionId + "'"); return; } + List allAuthJobs = webSession.findTasksByJob(WebAsyncAuthJob.class); + WebAsyncTaskInfo relatedTask = allAuthJobs.stream().filter( + task -> { + WebAsyncAuthJob job = (WebAsyncAuthJob) task.getJob(); + return job.getAuthId().equals(authInfo.getAuthAttemptId()); + }) + .findFirst().orElse(null); + if (relatedTask == null) { + String message = "No related authentication task was found in'" + sessionId + "'," + + " probably authentication was canceled"; + log.warn(message); + webSession.addWarningMessage(message); + return; + } + if (!relatedTask.isRunning()) { + String message = "Related authentication task was canceled"; + log.warn(message); + webSession.addWarningMessage(message); + return; + } + WebAsyncAuthJob relatedJob = (WebAsyncAuthJob) relatedTask.getJob(); switch (authInfo.getAuthStatus()) { case SUCCESS: boolean linkCredentialsWithActiveUser = !webApplication.isConfigurationMode() @@ -58,23 +86,25 @@ public class WSAuthSessionEventHandler implements WSEventHandler { authInfo, linkCredentialsWithActiveUser ).authenticateSession(); - List tokenInfos = newInfos - .stream() - .map(WebUserAuthTokenInfo::new) - .toList(); - webSession.addSessionEvent(new WebSessionAuthEvent(tokenInfos)); + relatedJob.setAuthResult(newInfos); } catch (DBException e) { webSession.addSessionError(e); + relatedTask.setJobError(e); } - break; case ERROR: - webSession.addSessionEvent(new WebSessionAuthEvent(new DBWebException(authInfo.getError(), authInfo.getErrorCode()))); + var error = new DBWebException(authInfo.getError(), authInfo.getErrorCode()); + relatedTask.setJobError(error); break; - case IN_PROGRESS, EXPIRED: - log.error("Invalid auth status: " + authInfo.getAuthStatus()); default: - log.error("Unknown auth status: " + authInfo.getAuthStatus()); + String message = "Invalid auth status: " + authInfo.getAuthStatus(); + log.error(message); + var exception = new DBWebException(message); + webSession.addSessionError(exception); + relatedTask.setJobError(new DBWebException(message)); } + relatedTask.setRunning(false); + relatedTask.setStatus(DBWConstants.TASK_STATUS_FINISHED); + WebEventUtils.sendAsyncTaskEvent(webSession, relatedTask); } } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WebSessionAuthEvent.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WebSessionAuthEvent.java deleted file mode 100644 index 67b79d7492..0000000000 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WebSessionAuthEvent.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * DBeaver - Universal Database Manager - * Copyright (C) 2010-2025 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.cloudbeaver.service.auth.handler; - -import io.cloudbeaver.DBWebException; -import org.jkiss.code.NotNull; -import org.jkiss.code.Nullable; -import org.jkiss.dbeaver.model.websocket.WSConstants; -import org.jkiss.dbeaver.model.websocket.event.WSAbstractEvent; - -import java.util.List; - -public class WebSessionAuthEvent extends WSAbstractEvent { - @Nullable - private final List userTokens; - - private final DBWebException error; - - protected WebSessionAuthEvent(@NotNull List userTokens) { - super("cb_web_session_auth", WSConstants.TOPIC_SESSION); - this.userTokens = userTokens; - this.error = null; - } - protected WebSessionAuthEvent(@NotNull DBWebException error) { - super("cb_web_session_auth", WSConstants.TOPIC_SESSION); - this.userTokens = null; - this.error = error; - } - - @Nullable - public List getUserTokens() { - return userTokens; - } - - public DBWebException getError() { - return error; - } -} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WebUserAuthTokenInfo.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WebUserAuthTokenInfo.java deleted file mode 100644 index 14177a1694..0000000000 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/handler/WebUserAuthTokenInfo.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * DBeaver - Universal Database Manager - * Copyright (C) 2010-2025 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.cloudbeaver.service.auth.handler; - -import io.cloudbeaver.model.session.WebAuthInfo; -import io.cloudbeaver.model.session.WebUserAuthToken; -import org.jkiss.code.NotNull; -import org.jkiss.code.Nullable; - -import java.time.OffsetDateTime; - -public class WebUserAuthTokenInfo implements WebUserAuthToken { - @NotNull - private final String authProvider; - @Nullable - private final String authConfiguration; - @NotNull - private final OffsetDateTime loginTime; - @NotNull - private final String userId; - @NotNull - private final String displayName; - @Nullable - private final String message; - - public WebUserAuthTokenInfo( - @NotNull WebAuthInfo authInfo - ) { - this( - authInfo.getAuthProvider(), - authInfo.getAuthConfiguration(), - authInfo.getDisplayName(), - authInfo.getLoginTime(), - authInfo.getMessage(), - authInfo.getUserId() - ); - } - - public WebUserAuthTokenInfo( - @NotNull String authProvider, - @Nullable String authConfiguration, - @NotNull String displayName, - @NotNull OffsetDateTime loginTime, - @Nullable String message, - @NotNull String userId - ) { - this.authConfiguration = authConfiguration; - this.authProvider = authProvider; - this.displayName = displayName; - this.loginTime = loginTime; - this.message = message; - this.userId = userId; - } - - @Override - @Nullable - public String getAuthConfiguration() { - return authConfiguration; - } - - @Override - @NotNull - public String getAuthProvider() { - return authProvider; - } - - @Override - @NotNull - public String getDisplayName() { - return displayName; - } - - @Override - @NotNull - public OffsetDateTime getLoginTime() { - return loginTime; - } - - @Override - @Nullable - public String getMessage() { - return message; - } - - @Override - @NotNull - public String getUserId() { - return userId; - } -} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java index cf4f71597d..9d4c2796eb 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java @@ -20,6 +20,7 @@ import io.cloudbeaver.DBWebException; import io.cloudbeaver.WebServiceUtils; import io.cloudbeaver.auth.SMSignOutLinkProvider; import io.cloudbeaver.auth.provider.local.LocalAuthProvider; +import io.cloudbeaver.model.WebAsyncTaskInfo; import io.cloudbeaver.model.WebPropertyInfo; import io.cloudbeaver.model.app.ServletAppConfiguration; import io.cloudbeaver.model.session.WebAuthInfo; @@ -30,10 +31,7 @@ import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebAuthProviderRegistry; import io.cloudbeaver.registry.WebMetaParametersRegistry; import io.cloudbeaver.server.CBApplication; -import io.cloudbeaver.service.auth.DBWServiceAuth; -import io.cloudbeaver.service.auth.WebAuthStatus; -import io.cloudbeaver.service.auth.WebLogoutInfo; -import io.cloudbeaver.service.auth.WebUserInfo; +import io.cloudbeaver.service.auth.*; import io.cloudbeaver.service.auth.model.user.WebAuthProviderInfo; import io.cloudbeaver.service.security.SMUtils; import org.jkiss.code.NotNull; @@ -72,6 +70,89 @@ public class WebServiceAuthImpl implements DBWServiceAuth { boolean linkWithActiveUser, boolean forceSessionsLogout ) throws DBWebException { + try { + var smAuthInfo = initiateAuthentication(webSession, providerId, providerConfigurationId, authParameters, forceSessionsLogout); + //TODO deprecated, use asyncAuthLogin for federated auth, exits for backward compatibility + linkWithActiveUser = linkWithActiveUser && CBApplication.getInstance().getAppConfiguration() + .isLinkExternalCredentialsWithUser(); + if (smAuthInfo.getAuthStatus() == SMAuthStatus.IN_PROGRESS) { + //run async auth process + return new WebAuthStatus(smAuthInfo.getAuthAttemptId(), smAuthInfo.getRedirectUrl(), smAuthInfo.getAuthStatus()); + } else { + //run it sync + var authProcessor = new WebSessionAuthProcessor(webSession, smAuthInfo, linkWithActiveUser); + return new WebAuthStatus(smAuthInfo.getAuthStatus(), authProcessor.authenticateSession()); + } + } catch (SMTooManySessionsException e) { + throw new DBWebException("User authentication failed", e.getErrorType(), e); + } catch (Exception e) { + throw new DBWebException("User authentication failed", e); + } + } + + @Override + public WebAsyncAuthStatus federatedLogin( + @NotNull WebSession webSession, + @NotNull String providerId, + @Nullable String providerConfigurationId, + boolean linkWithActiveUser, + boolean forceSessionsLogout + ) throws DBWebException { + WebAuthProviderDescriptor providerDescriptor = WebAuthProviderRegistry.getInstance().getAuthProvider(providerId); + if (providerDescriptor == null) { + throw new DBWebException("Provider '" + providerId + "' not found"); + } + if (!providerDescriptor.isFederated()) { + throw new DBWebException("Provider '" + providerId + "' is not federated"); + } + try { + var smAuthInfo = initiateAuthentication(webSession, providerId, providerConfigurationId, Map.of(), forceSessionsLogout); + if (smAuthInfo.getAuthStatus() != SMAuthStatus.IN_PROGRESS) { + throw new DBWebException("Unexpected auth status: " + smAuthInfo.getAuthStatus()); + } + if (CommonUtils.isEmpty(smAuthInfo.getRedirectUrl())) { + throw new DBWebException("Missing redirect URL"); + } + WebAsyncTaskInfo authTask = webSession.createAsyncTask(providerId + " authentication"); + authTask.setRunning(true); + authTask.setJob( + new WebAsyncAuthJob(providerId + " authentication job", smAuthInfo.getAuthAttemptId(), linkWithActiveUser) + ); + return new WebAsyncAuthStatus(smAuthInfo.getRedirectUrl(), authTask); + } catch (SMTooManySessionsException e) { + throw new DBWebException("User authentication failed", e.getErrorType(), e); + } catch (Exception e) { + throw new DBWebException("User authentication failed", e); + } + } + + @Override + public WebAsyncAuthTaskResult federatedAuthTaskResult(@NotNull WebSession webSession, @NotNull String taskId) throws DBWebException { + WebAsyncTaskInfo taskInfo = webSession.asyncTaskStatus(taskId, true); + if (taskInfo == null) { + throw new DBWebException("Task '" + taskId + "' not found"); + } + if (taskInfo.isRunning()) { + throw new DBWebException("Task '" + taskId + "' is running"); + } + if (taskInfo.getJob() == null || !WebAsyncAuthJob.class.isAssignableFrom(taskInfo.getJob().getClass())) { + throw new DBWebException("Task '" + taskId + "' is not async auth task"); + } + WebAsyncAuthJob job = (WebAsyncAuthJob) taskInfo.getJob(); + List userTokens = job.getAuthResult(); + if (CommonUtils.isEmpty(userTokens)) { + userTokens = List.of(); + } + return new WebAsyncAuthTaskResult(userTokens); + } + + private static SMAuthInfo initiateAuthentication( + @NotNull WebSession webSession, + @NotNull String providerId, + @Nullable String providerConfigurationId, + @Nullable Map authParameters, + boolean forceSessionsLogout + ) throws DBException { if (CommonUtils.isEmpty(providerId)) { throw new DBWebException("Missing auth provider parameter"); } @@ -87,34 +168,17 @@ public class WebServiceAuthImpl implements DBWServiceAuth { String currentSmSessionId = (webSession.getUser() == null || CBApplication.getInstance().isConfigurationMode()) ? null : webSession.getUserContext().getSmSessionId(); - - try { - var smAuthInfo = securityController.authenticate( - webSession.getSessionId(), - currentSmSessionId, - webSession.getSessionParameters(), - WebSession.CB_SESSION_TYPE, - providerId, - providerConfigurationId, - authParameters, - forceSessionsLogout - ); - - linkWithActiveUser = linkWithActiveUser && CBApplication.getInstance().getAppConfiguration().isLinkExternalCredentialsWithUser(); - if (smAuthInfo.getAuthStatus() == SMAuthStatus.IN_PROGRESS) { - //run async auth process - return new WebAuthStatus(smAuthInfo.getAuthAttemptId(), smAuthInfo.getRedirectUrl(), smAuthInfo.getAuthStatus()); - } else { - //run it sync - var authProcessor = new WebSessionAuthProcessor(webSession, smAuthInfo, linkWithActiveUser); - return new WebAuthStatus(smAuthInfo.getAuthStatus(), authProcessor.authenticateSession()); - } - } catch (SMTooManySessionsException e) { - throw new DBWebException("User authentication failed", e.getErrorType(), e); - } catch (Exception e) { - throw new DBWebException("User authentication failed", e); - } - + var smAuthInfo = securityController.authenticate( + webSession.getSessionId(), + currentSmSessionId, + webSession.getSessionParameters(), + WebSession.CB_SESSION_TYPE, + providerId, + providerConfigurationId, + authParameters, + forceSessionsLogout + ); + return smAuthInfo; } @Override diff --git a/webapp/packages/core-authentication/src/AuthInfoService.ts b/webapp/packages/core-authentication/src/AuthInfoService.ts index aadf7b8a4c..7e14526d78 100644 --- a/webapp/packages/core-authentication/src/AuthInfoService.ts +++ b/webapp/packages/core-authentication/src/AuthInfoService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,11 +8,12 @@ import { injectable } from '@cloudbeaver/core-di'; import { AutoRunningTask, type ITask } from '@cloudbeaver/core-executor'; import { WindowsService } from '@cloudbeaver/core-routing'; -import { type AuthInfo, AuthStatus, type UserInfo } from '@cloudbeaver/core-sdk'; +import { type UserInfo } from '@cloudbeaver/core-sdk'; import { uuid } from '@cloudbeaver/core-utils'; +import { AsyncTaskInfoService } from '@cloudbeaver/core-root'; -import { type AuthProviderConfiguration, AuthProvidersResource } from './AuthProvidersResource.js'; -import { type ILoginOptions, UserInfoResource } from './UserInfoResource.js'; +import { type AuthProviderConfiguration } from './AuthProvidersResource.js'; +import { type IFederatedLoginOptions, type ILoginOptions, UserInfoResource } from './UserInfoResource.js'; export interface IUserAuthConfiguration { providerId: string; @@ -27,59 +28,50 @@ export class AuthInfoService { constructor( private readonly userInfoResource: UserInfoResource, - private readonly authProvidersResource: AuthProvidersResource, private readonly windowsService: WindowsService, + private readonly asyncTaskInfoService: AsyncTaskInfoService, ) {} - login(providerId: string, options: ILoginOptions): ITask { - return new AutoRunningTask(async () => await this.userInfoResource.login(providerId, options)).then(authInfo => - this.federatedAuthentication(providerId, options, authInfo), - ); + async login(providerId: string, options: ILoginOptions): Promise { + await this.userInfoResource.login(providerId, options); + return this.userInfoResource.data; } - private federatedAuthentication( - providerId: string, - options: ILoginOptions, - { redirectLink, authId, authStatus }: AuthInfo, - ): ITask { - let window: Window | null = null; - let id = providerId; + federatedLogin(providerId: string, options: IFederatedLoginOptions): ITask { + let redirectWindow: Window | null = null; - if (options.configurationId) { - const configuration = this.authProvidersResource.getConfiguration(providerId, options.configurationId); + const task = this.asyncTaskInfoService.create(async () => { + const result = await this.userInfoResource.requestFederatedLogin(providerId, options); - if (configuration) { - id = configuration.id; + if (result.redirectLink) { + const id = uuid(); + redirectWindow = this.windowsService.open(id, { + url: result.redirectLink, + target: id, + width: 600, + height: 700, + }); + + if (redirectWindow) { + redirectWindow.focus(); + } } - } - if (redirectLink) { - id = uuid(); - window = this.windowsService.open(id, { - url: redirectLink, - target: id, - width: 600, - height: 700, - }); - - if (window) { - window.focus(); - } - } + return result.taskInfo; + }); return new AutoRunningTask( - () => { - if (authId && authStatus === AuthStatus.InProgress) { - return this.userInfoResource.finishFederatedAuthentication(authId, options.linkUser); + async () => { + await this.asyncTaskInfoService.run(task); + await this.userInfoResource.syncData(); + + if (redirectWindow) { + this.windowsService.close(redirectWindow); } - return AutoRunningTask.resolve(this.userInfoResource.data); - }, - () => { - if (window) { - this.windowsService.close(window); - } + return this.userInfoResource.data; }, + () => this.asyncTaskInfoService.cancel(task.id), ); } } diff --git a/webapp/packages/core-authentication/src/UserInfoResource.ts b/webapp/packages/core-authentication/src/UserInfoResource.ts index 8f502ec51c..59c797e346 100644 --- a/webapp/packages/core-authentication/src/UserInfoResource.ts +++ b/webapp/packages/core-authentication/src/UserInfoResource.ts @@ -12,6 +12,7 @@ import { AutoRunningTask, type ISyncExecutor, type ITask, SyncExecutor, whileTas import { CachedDataResource, type ResourceKeySimple, ResourceKeyUtils } from '@cloudbeaver/core-resource'; import { SessionResource } from '@cloudbeaver/core-root'; import { + type FederatedAuthInfo, type AuthInfo, type AuthLogoutQuery, AuthStatus, @@ -36,6 +37,8 @@ export interface ILoginOptions { forceSessionsLogout?: boolean; } +export type IFederatedLoginOptions = Omit; + export const ANONYMOUS_USER_ID = 'anonymous'; @injectable() @@ -126,15 +129,27 @@ export class UserInfoResource extends CachedDataResource { + async requestFederatedLogin( + provider: string, + { configurationId, linkUser, forceSessionsLogout }: IFederatedLoginOptions, + ): Promise { + const { result } = await this.graphQLService.sdk.federatedLogin({ + provider, + configuration: configurationId, + linkUser, + forceSessionsLogout, + }); + + return result; + } + + autoLogin(authId: string, linkUser?: boolean): ITask { let activeTask: ITask | undefined; return new AutoRunningTask( @@ -164,9 +179,7 @@ export class UserInfoResource extends CachedDataResource { + this.resetIncludes(); + this.setData(await this.loader()); + this.sessionResource.markOutdated(); + } + protected async loader(key: void, includes?: ReadonlyArray): Promise { try { const { user } = await this.graphQLService.sdk.getActiveUser({ diff --git a/webapp/packages/core-sdk/src/queries/authentication/federatedLogin.gql b/webapp/packages/core-sdk/src/queries/authentication/federatedLogin.gql new file mode 100644 index 0000000000..3e4a3ef5ce --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/authentication/federatedLogin.gql @@ -0,0 +1,8 @@ +mutation federatedLogin($provider: ID!, $configuration: ID, $linkUser: Boolean, $forceSessionsLogout: Boolean) { + result: federatedLogin(provider: $provider, configuration: $configuration, linkUser: $linkUser, forceSessionsLogout: $forceSessionsLogout) { + redirectLink + taskInfo { + ...AsyncTaskInfo + } + } +} diff --git a/webapp/packages/plugin-authentication/src/AuthenticationService.ts b/webapp/packages/plugin-authentication/src/AuthenticationService.ts index 7609b0b381..ff3d888914 100644 --- a/webapp/packages/plugin-authentication/src/AuthenticationService.ts +++ b/webapp/packages/plugin-authentication/src/AuthenticationService.ts @@ -219,7 +219,7 @@ export class AuthenticationService extends Bootstrap { const action = contexts.getContext(sessionActionContext); if (isAutoLoginSessionAction(data)) { - const user = await this.userInfoResource.finishFederatedAuthentication(data['auth-id'], false); + const user = await this.userInfoResource.autoLogin(data['auth-id'], false); if (user) { //we request this method/request bc login form can be opened automatically. diff --git a/webapp/packages/plugin-authentication/src/Dialog/useAuthDialogState.ts b/webapp/packages/plugin-authentication/src/Dialog/useAuthDialogState.ts index 77f842dac6..f6c085e6e3 100644 --- a/webapp/packages/plugin-authentication/src/Dialog/useAuthDialogState.ts +++ b/webapp/packages/plugin-authentication/src/Dialog/useAuthDialogState.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -40,7 +40,7 @@ interface IData { tabIds: string[]; login: (linkUser: boolean, provider?: AuthProvider, configuration?: AuthProviderConfiguration) => Promise; - loginFederated: (provider: AuthProvider, configuration: AuthProviderConfiguration, onClose?: () => void) => Promise; + federatedLogin: (provider: AuthProvider, configuration: AuthProviderConfiguration) => Promise; } interface IState { @@ -223,22 +223,23 @@ export function useAuthDialogState(accessRequest: boolean, providerId: string | try { this.state.setActiveProvider(provider, configuration ?? null); - const loginTask = authInfoService.login(provider.id, { - configurationId: configuration?.id, - credentials: { - ...state.credentials, + if (provider.federated && configuration) { + await this.federatedLogin(provider, configuration); + } else { + await authInfoService.login(provider.id, { + configurationId: configuration?.id, credentials: { - ...state.credentials.credentials, - user: state.credentials.credentials['user']?.trim(), - password: state.credentials.credentials['password']?.trim(), + ...state.credentials, + credentials: { + ...state.credentials.credentials, + user: state.credentials.credentials['user']?.trim(), + password: state.credentials.credentials['password']?.trim(), + }, }, - }, - forceSessionsLogout: state.forceSessionsLogout, - linkUser, - }); - this.authTask = loginTask; - - await loginTask; + forceSessionsLogout: state.forceSessionsLogout, + linkUser, + }); + } } catch (exception: any) { const gqlError = errorOf(exception, GQLError); @@ -265,6 +266,15 @@ export function useAuthDialogState(accessRequest: boolean, providerId: string | return; }, + async federatedLogin(provider: AuthProvider, configuration: AuthProviderConfiguration): Promise { + this.authTask = authInfoService.federatedLogin(provider.id, { + configurationId: configuration.id, + forceSessionsLogout: state.forceSessionsLogout, + linkUser: false, + }); + + await this.authTask; + }, }), { state: observable.ref,