Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7229237
feat: Added gdrive refresh token to user table
Sentiaus May 8, 2026
fe5d866
feat: Added google clientSecret and apiKey
Sentiaus May 8, 2026
2fea1c6
feat: Added clientSecret and apiKey to conf
Sentiaus May 8, 2026
54975b8
feat: Add refresh_token field to user code and token endpoint
Sentiaus May 12, 2026
24039d6
feat: implemented connect and callback methods for getting refresh an…
Sentiaus May 19, 2026
02af780
feat: add google access and refresh tokens
Sentiaus May 19, 2026
52e3080
fix(amber): improve logging in GoogleDriveAuthResource
Sentiaus May 20, 2026
08e5c1b
feat: add Google Drive export to list-item component
Sentiaus May 20, 2026
98e3c42
test: add unit tests for DriveService and GoogleDriveConnectComponent
Sentiaus May 20, 2026
5561467
feat: keep list-item highlighted while export dropdown is open
Sentiaus May 21, 2026
07a6bad
feat: show toast on Google Drive connection and successful export
Sentiaus May 21, 2026
6ebab5c
feat: add Drive export to dataset detail page download buttons
Sentiaus May 21, 2026
3c3fd56
feat: add Drive export to workspace menu download button
Sentiaus May 21, 2026
9d70bb9
test: add Drive integration tests for list-item, menu, and dataset-de…
Sentiaus May 21, 2026
c1b74f8
feat: add RolesAllowed to Drive token/connect endpoints and handle in…
Sentiaus May 21, 2026
e8487ff
style: apply scalafmt formatting to Drive-related Scala files
Sentiaus May 21, 2026
a28039d
style: add ASF license headers to new Scala response files
Sentiaus May 21, 2026
893661e
fix: restore yarn.lock to Yarn Berry format
Sentiaus May 21, 2026
6e4ce86
style: run prettier-eslint format:fix on Drive integration files
Sentiaus May 21, 2026
eec2822
fix: annotate error callback as unknown in drive.service.spec.ts
Sentiaus May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La
null,
null,
null,
null,
null
)
)
Expand Down Expand Up @@ -108,6 +109,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La
null,
null,
null,
null,
null
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import org.apache.texera.auth.SessionUser
import org.apache.texera.dao.SqlServer
import org.apache.texera.web.auth.JwtAuth.setupJwtAuth
import org.apache.texera.web.resource._
import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource}
import org.apache.texera.web.resource.auth.{
AuthResource,
GoogleAuthResource,
GoogleDriveAuthResource
}
import org.apache.texera.web.resource.dashboard.DashboardResource
import org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionResource
import org.apache.texera.web.resource.dashboard.admin.settings.AdminSettingsResource
Expand Down Expand Up @@ -160,6 +164,7 @@ class TexeraWebApplication
environment.jersey.register(classOf[UserQuotaResource])
environment.jersey.register(classOf[AdminSettingsResource])
environment.jersey.register(classOf[AIAssistantResource])
environment.jersey.register(classOf[GoogleDriveAuthResource])

AuthResource.createAdminUser()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,20 @@ import javax.ws.rs.core.SecurityContext
}

val GUEST: User =
new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null, null, null)
new User(
null,
"guest",
null,
null,
null,
null,
UserRoleEnum.REGULAR,
null,
null,
null,
null,
null
)
}

@PreMatching
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.texera.web.model.http.response

case class DriveTokenIssueResponse(
status: String,
accessToken: Option[String]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.texera.web.model.http.response

case class GoogleAuthConfigResponse(clientId: String, apiKey: String)
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
import org.apache.texera.dao.jooq.generated.tables.pojos.User
import org.apache.texera.web.model.http.response.TokenIssueResponse
import org.apache.texera.web.model.http.response.GoogleAuthConfigResponse
import org.apache.texera.web.resource.auth.GoogleAuthResource.userDao

import java.util.Collections
Expand All @@ -48,11 +49,21 @@ object GoogleAuthResource {
@Path("/auth/google")
class GoogleAuthResource {
final private lazy val clientId = UserSystemConfig.googleClientId
final private lazy val apiKey = UserSystemConfig.googleApiKey

@GET
@Path("/clientid")
def getClientId: String = clientId

@GET
@Path("/config")
def getConfig: GoogleAuthConfigResponse = {
GoogleAuthConfigResponse(clientId, apiKey)
}

@Path("/drive")
def getDriveResource: GoogleDriveAuthResource = new GoogleDriveAuthResource()

@POST
@Consumes(Array(MediaType.TEXT_PLAIN))
@Produces(Array(MediaType.APPLICATION_JSON))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.texera.web.resource.auth

import io.dropwizard.auth.Auth
import com.typesafe.scalalogging.LazyLogging
import org.apache.texera.auth.{JwtParser, SessionUser}
import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
import org.apache.texera.dao.SqlServer
import org.apache.texera.config.UserSystemConfig
import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims}
import org.apache.texera.auth.JwtAuth
import com.google.api.client.googleapis.auth.oauth2.{
GoogleAuthorizationCodeRequestUrl,
GoogleAuthorizationCodeTokenRequest,
GoogleRefreshTokenRequest,
GoogleTokenResponse
}
import com.google.api.client.auth.oauth2.TokenResponseException
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory

import javax.annotation.security.RolesAllowed
import javax.ws.rs._
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.Response

object GoogleDriveAuthResource {
// Status codes for token
private val STATUS_OK = "ok"
private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
private val STATUS_INVALID_GRANT = "invalid_grant"

private def userDao =
new UserDao(
SqlServer
.getInstance()
.createDSLContext()
.configuration
)
}

@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class GoogleDriveAuthResource extends LazyLogging {
final private lazy val clientId = UserSystemConfig.googleClientId
final private lazy val clientSecret = UserSystemConfig.googleClientSecret
final private lazy val redirectUri = UserSystemConfig.appDomain
.map(domain => s"https://$domain/api/auth/google/drive/callback")
.getOrElse("http://localhost:4200/api/auth/google/drive/callback")

@GET
@Path("/token")
@RolesAllowed(Array("REGULAR", "ADMIN"))
def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
val user = userDao.fetchOneByUid(sessionUser.getUid)
val refreshToken = user.getGoogleDriveRefreshToken
if (refreshToken == null) {
return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN, None)).build()
}
try {
val tokenResponse = new GoogleRefreshTokenRequest(
new NetHttpTransport(),
GsonFactory.getDefaultInstance,
refreshToken,
clientId,
clientSecret
).execute()
val accessToken = tokenResponse.getAccessToken
Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(accessToken))).build()
} catch {
case e: TokenResponseException =>
if (e.getDetails != null && e.getDetails.getError == STATUS_INVALID_GRANT) {
Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT, None)).build()
} else {
logger.error("Failed to refresh access token", e)
Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
}
case e: Exception =>
logger.error("Unexpected error refreshing access token", e)
Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
}
}

@GET
@Path("/callback")
@Produces(Array(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))
def getCallback(
@QueryParam("code") @DefaultValue("") code: String,
@QueryParam("state") @DefaultValue("") state: String
): Response = {
if (code.isEmpty || state.isEmpty) {
return Response.status(Response.Status.BAD_REQUEST).build()
}
try {
val sessionUserOpt = JwtParser.parseToken(state)
if (!sessionUserOpt.isPresent) {
return Response
.status(Response.Status.UNAUTHORIZED)
.entity("User is not authenticated")
.build()
}

val userId = sessionUserOpt.get().getUid
val user = userDao.fetchOneByUid(userId)

val response: GoogleTokenResponse = new GoogleAuthorizationCodeTokenRequest(
new NetHttpTransport(),
GsonFactory.getDefaultInstance,
clientId,
clientSecret,
code,
redirectUri
).execute()

user.setGoogleDriveRefreshToken(response.getRefreshToken)
userDao.update(user)

val html =
"""<html><body><script>
|window.opener.postMessage('gdrive-connected', window.location.origin);
|window.close();
|</script></body></html>""".stripMargin
Response.ok(html).build()
} catch {
case e: TokenResponseException =>
logger.error("Google token exchange failed in callback", e)
Response.status(Response.Status.BAD_GATEWAY).build()
case e: Exception =>
logger.error("Unexpected error in OAuth callback", e)
Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
}
}

@GET
@Path("/connect")
@RolesAllowed(Array("REGULAR", "ADMIN"))
def getOAuth(
@Auth sessionUser: SessionUser,
@QueryParam("reauth") @DefaultValue("false") reauth: Boolean
): Response = {
val user = sessionUser.getUser
val state = JwtAuth.jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))

val url = new GoogleAuthorizationCodeRequestUrl(
clientId,
redirectUri,
java.util.Arrays.asList("https://www.googleapis.com/auth/drive")
)
.setState(state)
.setAccessType("offline")
.set("prompt", if (reauth) "consent" else null)
.set("include_granted_scopes", true)
.build()

Response.ok(url).build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ object JwtParser extends LazyLogging {
null,
null,
null,
null,
null
)
new SessionUser(user)
Expand Down
6 changes: 6 additions & 0 deletions common/config/src/main/resources/user-system.conf
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ user-sys {
google {
clientId = ""
clientId = ${?USER_SYS_GOOGLE_CLIENT_ID}

clientSecret = ""
clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET}

apiKey = ""
apiKey = ${?USER_SYS_GOOGLE_API_KEY}

smtp {
gmail = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ object UserSystemConfig {
val adminUsername: String = conf.getString("user-sys.admin-username")
val adminPassword: String = conf.getString("user-sys.admin-password")
val googleClientId: String = conf.getString("user-sys.google.clientId")
val googleClientSecret: String = conf.getString("user-sys.google.clientSecret")
val googleApiKey: String = conf.getString("user-sys.google.apiKey")
val gmail: String = conf.getString("user-sys.google.smtp.gmail")
val smtpPassword: String = conf.getString("user-sys.google.smtp.password")
val inviteOnly: Boolean = conf.getBoolean("user-sys.invite-only")
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/app-routing.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ export const DASHBOARD_ADMIN_EXECUTION = `${DASHBOARD_ADMIN}/execution`;
export const DASHBOARD_ADMIN_SETTINGS = `${DASHBOARD_ADMIN}/settings`;

export const DASHBOARD_SEARCH = `${DASHBOARD}/search`;

export const DASHBOARD_USER_GOOGLE_DRIVE = `${DASHBOARD_USER}/google-drive`;
5 changes: 5 additions & 0 deletions frontend/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/us
import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component";
import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component";
import { DASHBOARD_ABOUT, DASHBOARD_USER_WORKFLOW } from "./app-routing.constant";
import { GoogleDriveConnectComponent } from "./dashboard/component/user/google-drive-connect/google-drive-connect.component";
import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component";
import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component";
import { GuiConfigService } from "./common/service/gui-config.service";
Expand Down Expand Up @@ -143,6 +144,10 @@ routes.push({
path: "discussion",
component: FlarumComponent,
},
{
path: "google-drive",
component: GoogleDriveConnectComponent,
},
],
},
{
Expand Down
Loading
Loading