Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
6819261
migration sql script
Neilk1021 Jul 28, 2026
ae02630
refactor(sql): swap sql tables to add auth_provider table
Neilk1021 Jul 28, 2026
8cf34ce
refactor(sql): migrate local and google auth to use new table def
Neilk1021 Jul 28, 2026
87adc7d
refactor(auth): Migrate admin UserResource to new jOOq def.
Neilk1021 Jul 29, 2026
913daaf
refactor(auth): Refactor `AdminUserResource.scala` to match jOOq tables.
Neilk1021 Jul 29, 2026
b3682dd
refactor(auth): Refactor `AdminUserResource.scala` to match jOOq tables.
Neilk1021 Jul 29, 2026
5bad80c
Merge branch 'apache:main' into task/migrateBackend
Neilk1021 Jul 29, 2026
94d2fa9
refactor(auth): Refactor `AdminUserResource.scala` to match jOOq tables.
Neilk1021 Jul 29, 2026
2766d9c
Merge branch 'task/migrateBackend' of https://github.com/Neilk1021/te…
Neilk1021 Jul 29, 2026
5ec16e0
fix(auth): fix spec tests for branch
Neilk1021 Jul 29, 2026
ebb758b
Merge branch 'main' into task/migrateBackend
Neilk1021 Jul 30, 2026
53ca02f
refactor(auth): readd migration after git merge
Neilk1021 Jul 30, 2026
33d70b6
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Jul 30, 2026
70fb360
refactor(auth): update `AuthResource` with main.
Neilk1021 Jul 30, 2026
e417d9c
refactor(auth): update `AuthResource` with main.
Neilk1021 Jul 30, 2026
f8c67d7
fix(auth): fix spec tests
Neilk1021 Jul 30, 2026
a36b9db
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Jul 30, 2026
784f6a4
fix(auth): fix coverage
Neilk1021 Jul 30, 2026
8edb710
fix(auth): fix coverage
Neilk1021 Jul 30, 2026
0f5e89d
Merge branch 'main' into task/migrateBackend
Neilk1021 Jul 30, 2026
27461b6
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Aug 3, 2026
dc83194
fix(tests): fix spec tests from merging main
Neilk1021 Aug 3, 2026
a455ef4
fix(sql): migration cleanup
Neilk1021 Aug 3, 2026
ae68cb2
refactor(auth): move creating a local account to its own helper object.
Neilk1021 Aug 3, 2026
f4e2c71
refactor(auth): move creating a local account to its own helper object.
Neilk1021 Aug 3, 2026
6102a00
fix(auth): verify email for external users and readd google id
Neilk1021 Aug 3, 2026
52c9e16
Merge branch 'main' into task/migrateBackend
Neilk1021 Aug 3, 2026
2ae76c6
fix(auth): verify email for external users and readd google id
Neilk1021 Aug 4, 2026
aae354e
fix(auth): guard against double insert
Neilk1021 Aug 4, 2026
031abc0
fix(auth): hoist verifier and split `verifiedPayload` s.t. it can ret…
Neilk1021 Aug 4, 2026
53475f8
Merge branch 'task/migrateBackend' into task/migrateAvatarLogic
Neilk1021 Aug 4, 2026
659c2a7
fix(auth): finish avatar migration
Neilk1021 Aug 4, 2026
5950435
fix(auth): fix avatar migration
Neilk1021 Aug 4, 2026
1abdc70
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Aug 4, 2026
f50aabf
refactor(auth): revert behavior changes and fix email casing
Neilk1021 Aug 5, 2026
ac8b90b
refactor(auth): drop unread localHandle and fix stale docs
Neilk1021 Aug 5, 2026
1cf7f92
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Aug 5, 2026
b0b411f
refactor(auth): port the new admin computing-unit spec to auth_provider
Neilk1021 Aug 5, 2026
38db65d
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Aug 7, 2026
6456fdb
fix(auth): fix jwtClaims signature
Neilk1021 Aug 9, 2026
f988ee4
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Aug 9, 2026
32faa38
fix(auth): fix jwtClaims signature
Neilk1021 Aug 9, 2026
e30adc9
fix(auth): update changelog desc
Neilk1021 Aug 9, 2026
8273c1d
fix(auth): ran scalafixAll
Neilk1021 Aug 10, 2026
6020d0d
fix(auth): hoist provisioning into its own method to allow proper try…
Neilk1021 Aug 10, 2026
658d85c
fix(auth): fix naming mistake
Neilk1021 Aug 10, 2026
a27ddec
fix(auth): drop unneeded comments
Neilk1021 Aug 10, 2026
2bb5ce1
fix(auth): run sbtfmtAll
Neilk1021 Aug 10, 2026
8099703
Update amber/src/main/scala/org/apache/texera/web/resource/auth/Local…
Neilk1021 Aug 10, 2026
ae14225
Update common/auth/src/main/scala/org/apache/texera/auth/JwtAuth.scala
Neilk1021 Aug 10, 2026
379215c
Update amber/src/main/scala/org/apache/texera/web/resource/auth/Exter…
Neilk1021 Aug 10, 2026
f655a5e
Update amber/src/main/scala/org/apache/texera/web/resource/auth/Exter…
Neilk1021 Aug 10, 2026
33c958b
Update amber/src/main/scala/org/apache/texera/web/resource/auth/Googl…
Neilk1021 Aug 10, 2026
0c3d0f0
Update amber/src/main/scala/org/apache/texera/web/resource/auth/Local…
Neilk1021 Aug 10, 2026
ac1f408
refactor(auth): migrate UNIQUE_VIOLATION constraint to AuthResource t…
Neilk1021 Aug 10, 2026
88ba36b
fix(auth): cleanup dead JwtAuthSpec test to stop using dead parameter.
Neilk1021 Aug 10, 2026
7f057ea
refactor(amber): read the unique-violation SQLSTATE from a shared con…
Yicong-Huang Aug 10, 2026
8be10a1
test(amber): pin LocalAuthProvisioner's unique-violation messages
Yicong-Huang Aug 10, 2026
75a8af1
fix(amber): normalize login handles in migration 33 instead of refusing
Yicong-Huang Aug 10, 2026
a5a83d4
fix(sql): apply uq_provider constraint at the end of migration
Neilk1021 Aug 10, 2026
d1dbfbe
fix(auth): verify google email and migrate verifier to shared compani…
Neilk1021 Aug 10, 2026
027bfff
refactor(auth): collapse the createLocalAccount seam into its one caller
Neilk1021 Aug 10, 2026
997e16d
fix(auth): stop deriving an admin-created account's password from its…
Neilk1021 Aug 10, 2026
0fbeb5e
refactor(auth): route both email lookups through one query
Neilk1021 Aug 10, 2026
7d8b5be
refactor(auth): tidy provision's returning-user path and naming
Neilk1021 Aug 10, 2026
0e77000
docs(auth): record that the googleId claim is now absent, not null
Neilk1021 Aug 10, 2026
0656b02
fix(auth): lint
Neilk1021 Aug 10, 2026
63628cf
Merge remote-tracking branch 'upstream/main' into task/migrateBackend
Neilk1021 Aug 10, 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 @@ -66,7 +66,6 @@ class AccessControlResourceSpec
user.setName("testuser")
user.setEmail("test@example.com")
user.setRole(UserRoleEnum.REGULAR)
user.setPassword("password")
user
}

Expand All @@ -76,7 +75,6 @@ class AccessControlResourceSpec
user.setName("testuser2")
user.setEmail("test2@example.com")
user.setRole(UserRoleEnum.REGULAR)
user.setPassword("password")
user
}

Expand Down Expand Up @@ -139,7 +137,7 @@ class AccessControlResourceSpec
computingUnitOfUserDao.insert(cuAccess)
}

val claims = JwtAuth.jwtClaims(testUser1, 1)
val claims = JwtAuth.jwtClaims(testUser1)
token = JwtAuth.jwtToken(claims)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,8 @@ class LiteLLMProxyAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAfter
u.setUid(1)
u.setName("test")
u.setEmail("test@example.com")
u.setGoogleId(null)
u.setRole(role)
JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
JwtAuth.jwtToken(JwtAuth.jwtClaims(u))
}

private val chatBody = """{"model":"gpt-4o-mini","messages":[]}"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,73 +19,88 @@

package org.apache.texera.web.resource.auth

import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims, jwtToken}
import com.typesafe.scalalogging.Logger
import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken}
import org.apache.texera.common.config.UserSystemConfig
import org.apache.texera.common.util.EmailUtil
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.jooq.generated.Tables.USER
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.{AUTH_PROVIDER, USER}
import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum}
import org.apache.texera.dao.jooq.generated.tables.pojos.User
import org.apache.texera.web.model.http.request.auth.{UserLoginRequest, UserRegistrationRequest}
import org.apache.texera.web.model.http.response.TokenIssueResponse
import org.apache.texera.web.resource.auth.AuthResource._
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.jasypt.util.password.StrongPasswordEncryptor

import java.time.Instant
import java.time.temporal.ChronoUnit
import javax.ws.rs._
import javax.ws.rs.core.MediaType

object AuthResource {
private val logger: Logger = Logger(classOf[AuthResource])

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

/**
* Retrieve exactly one User from databases with the given username and password.
* The password is used to validate against the hashed password stored in the db.
*
* @param name String
* @param username the LOCAL login handle to authenticate
* @param password String, plain text password
* @return
*/
def retrieveUserByUsernameAndPassword(name: String, password: String): Option[User] = {
if (password == null) return None
if (name == null) return None
Option(
SqlServer
.getInstance()
.createDSLContext()
.select()
.from(USER)
.where(USER.NAME.eq(name))
.fetchOneInto(classOf[User])
).filter(user => new StrongPasswordEncryptor().checkPassword(password, user.getPassword))
def retrieveUserByUsernameAndPassword(username: String, password: String): Option[User] = {
if (password == null || username == null) return None

val record = context
.select()
.from(AUTH_PROVIDER)
.join(USER)
.on(USER.UID.eq(AUTH_PROVIDER.UID))
.where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.LOCAL))
.and(AUTH_PROVIDER.PROVIDER_ID.eq(username))
.fetchOne()

Option(record).flatMap(r => {
val encryptedPassword = r.get(AUTH_PROVIDER.PASSWORD)
if (LocalAuthProvisioner.checkPassword(password, encryptedPassword)) {
Some(r.into(USER).into(classOf[User]))
} else {
None
}
})
}

/**
* Marks a placeholder account (auto-created for a dataset contributor) as
* claimed, leaving persistence to the caller.
*/
/**
* Email identity is matched case-insensitively (backed by idx_user_email_lower),
* while stored emails keep their original casing.
*
* Case-insensitivity is required, not a nicety: `"user".email` is a plain case-sensitive
* UNIQUE and `idx_user_email_lower` is not unique, so `Alice@x.com` and `alice@x.com` can
* coexist. Registration stores the address as the user typed it while contributor
* placeholders are stored lower-cased, so the casings provably differ in practice. An
* exact-match lookup would miss, insert a second account without violating any constraint,
* and silently strand the original account's data.
*/
def fetchUserByEmailIgnoreCase(email: String): User =
SqlServer
.getInstance()
.createDSLContext()
fetchUserByEmailIgnoreCase(SqlServer.getInstance().createDSLContext(), email)

/**
* As above, against a caller-supplied context. [[ExternalAuthProvisioner]] passes its
* transaction's context so the lookup reads that transaction's own writes.
*/
def fetchUserByEmailIgnoreCase(ctx: DSLContext, email: String): User =
ctx
.selectFrom(USER)
.where(DSL.lower(USER.EMAIL).eq(EmailUtil.normalize(email)))
.fetchOneInto(classOf[User])

/**
* Marks a placeholder account (auto-created for a dataset contributor) as
* claimed, leaving persistence to the caller.
*/
def claimPlaceholder(user: User): Unit = {
user.setIsPlaceholder(false)
val claimedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS)
Expand All @@ -95,21 +110,33 @@ object AuthResource {
)
}

def createAdminUser(): Unit = {
val adminUsername = UserSystemConfig.adminUsername
val adminPassword = UserSystemConfig.adminPassword
def createAdminUser(): Unit =
createAdminUser(UserSystemConfig.adminUsername.trim, UserSystemConfig.adminPassword.trim)

if (adminUsername.trim.nonEmpty && adminPassword.trim.nonEmpty) {
val existingUser = userDao.fetchByName(adminUsername)
if (existingUser.isEmpty) {
val user = new User
user.setName(adminUsername)
user.setEmail(adminUsername)
user.setRole(UserRoleEnum.ADMIN)
user.setPassword(new StrongPasswordEncryptor().encryptPassword(adminPassword))
userDao.insert(user)
}
/**
* Bootstrap the configured admin account, doing nothing if it already exists. The credentials
* are parameters rather than reads of [[UserSystemConfig]] because those are object vals
* resolved once per JVM, which leaves the unconfigured case unreachable from a test.
*/
private[auth] def createAdminUser(adminUsername: String, adminPassword: String): Unit = {
Comment thread
Neilk1021 marked this conversation as resolved.
if (adminUsername.isEmpty || adminPassword.isEmpty) return

if (LocalAuthProvisioner.handleExists(adminUsername)) return

if (fetchUserByEmailIgnoreCase(adminUsername) != null) {
logger.warn(
s"Not creating the admin account: '$adminUsername' is already used as an email address " +
"by an account with no local credential. Grant that account the ADMIN role instead."
)
return
}

val user = new User
user.setName(adminUsername)
user.setEmail(adminUsername)
user.setRole(UserRoleEnum.ADMIN)

LocalAuthProvisioner.createLocalAccount(user, adminUsername, adminPassword)
}
}

Expand All @@ -123,7 +150,11 @@ class AuthResource {
def login(request: UserLoginRequest): TokenIssueResponse = {
retrieveUserByUsernameAndPassword(request.username, request.password) match {
case Some(user) =>
TokenIssueResponse(jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES)))
// An account can hold both a LOCAL and a GOOGLE credential, and the frontend expects
// `googleId` in the token regardless of which one was used to sign in.
val googleId =
ExternalAuthProvisioner.providerIdOf(user.getUid, ProviderTypeEnum.GOOGLE)
TokenIssueResponse(jwtToken(jwtClaims(user, googleId)))
case None => throw new NotAuthorizedException("Login credentials are incorrect.")
}
}
Expand All @@ -143,20 +174,27 @@ class AuthResource {
if (userpassword == null || userpassword.isEmpty)
throw new NotAcceptableException("Password cannot be empty")

val usernameExists = !userDao.fetchByName(username).isEmpty
// The username being registered becomes a LOCAL login handle, so the handle is what has to
// be free, not the display name. Asking `"user".name` instead both missed genuinely taken
// handles (letting the insert die on uq_provider_identity as a 500) and rejected free ones,
// because an external login rewrites the display name but never the handle.
val usernameExists = LocalAuthProvisioner.handleExists(username)
val existingByEmail = fetchUserByEmailIgnoreCase(useremail)
val emailExists = existingByEmail != null

// A placeholder account (created for a dataset contributor, never had any
// credential) is claimed by the first registration with its email. The
// account keeps its uid, so existing contributor links stay valid, and it
// stays INACTIVE until an admin approves it.
//
// The credential is written to auth_provider rather than onto the user row, in the same
// transaction as the claim, so the account cannot end up marked claimed with nothing to
// log in with.
if (!usernameExists && emailExists && existingByEmail.getIsPlaceholder) {
existingByEmail.setName(username)
existingByEmail.setPassword(new StrongPasswordEncryptor().encryptPassword(userpassword))
claimPlaceholder(existingByEmail)
userDao.update(existingByEmail)
return TokenIssueResponse(jwtToken(jwtClaims(existingByEmail, TOKEN_EXPIRE_TIME_IN_MINUTES)))
LocalAuthProvisioner.claimWithLocalCredential(existingByEmail, username, userpassword)
return TokenIssueResponse(jwtToken(jwtClaims(existingByEmail)))
}

(usernameExists, emailExists) match {
Expand All @@ -169,10 +207,9 @@ class AuthResource {
user.setName(username)
user.setEmail(useremail)
user.setRole(UserRoleEnum.INACTIVE)
// hash the plain text password
user.setPassword(new StrongPasswordEncryptor().encryptPassword(userpassword))
userDao.insert(user)
TokenIssueResponse(jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES)))
// Reports losing the race to a concurrent registration of the same handle as a 409.
LocalAuthProvisioner.createLocalAccount(user, username, userpassword)
TokenIssueResponse(jwtToken(jwtClaims(user)))
}
}

Expand Down
Loading
Loading