Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bf2de22
feat: Added gdrive refresh token to user table
Sentiaus May 8, 2026
7edf913
feat: Added google clientSecret and apiKey
Sentiaus May 8, 2026
aba3887
feat: Added clientSecret and apiKey to conf
Sentiaus May 8, 2026
fdf53bb
feat: Add refresh_token field to user code and token endpoint
Sentiaus May 12, 2026
45b38d0
feat: implemented connect and callback methods for getting refresh an…
Sentiaus May 19, 2026
042412a
feat: add google access and refresh tokens
Sentiaus May 19, 2026
e75569f
fix(amber): improve logging in GoogleDriveAuthResource
Sentiaus May 20, 2026
d5efcc9
feat: add Google Drive export to list-item component
Sentiaus May 20, 2026
5d05131
test: add unit tests for DriveService and GoogleDriveConnectComponent
Sentiaus May 20, 2026
ee8877c
feat: keep list-item highlighted while export dropdown is open
Sentiaus May 21, 2026
03db898
feat: show toast on Google Drive connection and successful export
Sentiaus May 21, 2026
afe7f1b
feat: add Drive export to dataset detail page download buttons
Sentiaus May 21, 2026
efe0a6d
feat: add Drive export to workspace menu download button
Sentiaus May 21, 2026
80ec674
test: add Drive integration tests for list-item, menu, and dataset-de…
Sentiaus May 21, 2026
f703b09
feat: add RolesAllowed to Drive token/connect endpoints and handle in…
Sentiaus May 21, 2026
8b0762f
style: apply scalafmt formatting to Drive-related Scala files
Sentiaus May 21, 2026
8c65b88
style: add ASF license headers to new Scala response files
Sentiaus May 21, 2026
8c9ad52
fix: restore yarn.lock to Yarn Berry format
Sentiaus May 21, 2026
62915f9
style: run prettier-eslint format:fix on Drive integration files
Sentiaus May 21, 2026
3088f13
fix: annotate error callback as unknown in drive.service.spec.ts
Sentiaus May 21, 2026
71e3f6c
chore: updated values yaml
Sentiaus May 24, 2026
b0cfd6c
feat: Added user_oauth_tokens table
Sentiaus May 27, 2026
093c689
feat: add TokenEncryptionService and migrate Drive tokens to user_oau…
Sentiaus May 27, 2026
b9bcd7a
fix: use java.util.HashMap for auth blob serialization in GoogleDrive…
Sentiaus May 27, 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 @@ -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,19 @@ 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
)
}

@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,205 @@
/*
* 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.fasterxml.jackson.databind.ObjectMapper
import com.typesafe.scalalogging.LazyLogging
import org.apache.texera.auth.{JwtParser, SessionUser, TokenEncryptionService}
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.UserOauthTokenDao
import org.apache.texera.dao.jooq.generated.tables.pojos.UserOauthToken
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 {
private val STATUS_OK = "ok"
private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
private val STATUS_INVALID_GRANT = "invalid_grant"
private val PROVIDER_GOOGLE_DRIVE = "google_drive"

private val mapper = new ObjectMapper()

private def oauthTokenDao =
new UserOauthTokenDao(
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 uid = sessionUser.getUid
val record = oauthTokenDao.fetchByUid(uid).stream()
.filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
.findFirst()
.orElse(null)

if (record == null) {
return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN, None)).build()
}

try {
val blob = mapper.readTree(TokenEncryptionService.decrypt(record.getAuthBlob))
val refreshToken = blob.get("refreshToken").asText()

val tokenResponse = new GoogleRefreshTokenRequest(
new NetHttpTransport(),
GsonFactory.getDefaultInstance,
refreshToken,
clientId,
clientSecret
).execute()

Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(tokenResponse.getAccessToken))).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 uid = sessionUserOpt.get().getUid

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

val blobMap = new java.util.HashMap[String, String]()
blobMap.put("refreshToken", tokenResponse.getRefreshToken)
blobMap.put("scopes", tokenResponse.getScope)
val blobJson = mapper.writeValueAsString(blobMap)
val encryptedBlob = TokenEncryptionService.encrypt(blobJson)

val existing = oauthTokenDao.fetchByUid(uid).stream()
.filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
.findFirst()

if (existing.isPresent) {
existing.get().setAuthBlob(encryptedBlob)
oauthTokenDao.update(existing.get())
} else {
val record = new UserOauthToken()
record.setUid(uid)
record.setProvider(PROVIDER_GOOGLE_DRIVE)
record.setAuthBlob(encryptedBlob)
oauthTokenDao.insert(record)
}

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()
}
}
4 changes: 4 additions & 0 deletions bin/k8s/values-development.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ texeraEnvVars:
value: "true"
- name: USER_SYS_GOOGLE_CLIENT_ID
value: ""
- name: USER_SYS_GOOGLE_CLIENT_SECRET
value: ""
- name: USER_SYS_GOOGLE_API_KEY
value: ""
- name: USER_SYS_GOOGLE_SMTP_GMAIL
value: ""
- name: USER_SYS_GOOGLE_SMTP_PASSWORD
Expand Down
4 changes: 4 additions & 0 deletions bin/k8s/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ texeraEnvVars:
value: "true"
- name: USER_SYS_GOOGLE_CLIENT_ID
value: ""
- name: USER_SYS_GOOGLE_CLIENT_SECRET
value: ""
- name: USER_SYS_GOOGLE_API_KEY
value: ""
- name: USER_SYS_GOOGLE_SMTP_GMAIL
value: ""
- name: USER_SYS_GOOGLE_SMTP_PASSWORD
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.auth

import org.apache.texera.config.AuthConfig
import org.jose4j.jwe.{ContentEncryptionAlgorithmIdentifiers, JsonWebEncryption, KeyManagementAlgorithmIdentifiers}
import org.jose4j.keys.AesKey

import java.nio.charset.StandardCharsets

object TokenEncryptionService {
private val key = new AesKey(AuthConfig.encryptionSecretKey.getBytes(StandardCharsets.UTF_8))

def encrypt(plaintext: String): String = {
val jwe = new JsonWebEncryption()
jwe.setAlgorithmHeaderValue(KeyManagementAlgorithmIdentifiers.DIRECT)
jwe.setEncryptionMethodHeaderParameter(ContentEncryptionAlgorithmIdentifiers.AES_256_GCM)
jwe.setKey(key)
jwe.setPayload(plaintext)
jwe.getCompactSerialization
}

def decrypt(ciphertext: String): String = {
val jwe = new JsonWebEncryption()
jwe.setKey(key)
jwe.setCompactSerialization(ciphertext)
jwe.getPayload
}
}
Loading
Loading