You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/09/03 02:37:00 UTC
(texera) 01/01: feat(auth): add ORCID login (#7664)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7664-4ff0b8b4ac415c4d60bfd73f37da5e3039ade2ec
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 186652ba9b1abb731a520e6ef97b461826803153
Author: Neil Ketteringham <53...@users.noreply.github.com>
AuthorDate: Thu Sep 3 02:36:20 2026 +0000
feat(auth): add ORCID login (#7664)
### What changes were proposed in this PR?
Closes #7516 by adding ORCID login as an optional feature, disabled by
default.
https://github.com/user-attachments/assets/3c1fe0f8-898b-4e22-b5ec-5a775042ed60
ORCID differs from the existing OIDC provider (Google) in two ways, and
those two differences drive
nearly all of this diff.
**1. No email address.** This PR uses ORCID's authorization-code flow
with the `/authenticate`
scope, which returns an iD and a name and no email. (ORCID does support
OpenID Connect — there's an
`openid` scope and an `id_token` — but even under `openid` it doesn't
assert an address.) Many
Texera features require a valid email, so an ORCID account is
provisioned `INACTIVE` with a NULL
email and the address is collected at first sign-in by the prompt that
#7758 already shipped:
`AuthService.loginWithExistingToken` hands out no user while the `email`
claim is null, and the
dialog it opens is not dismissable — cancelling signs out. This PR adds
no part of that flow and
changes none of it; it only produces the account shape the prompt was
built for.
The rules that prompt enforces (all existing `PUT /auth/email`
behaviour, listed here because they
are what makes an ORCID account safe to create without an address):
- an address held by an account that already has a credential
(LOCAL/Google/ORCID) is refused with 409
- an address held by a contributor placeholder is claimed: the ORCID
identity moves onto the
placeholder's uid, and the row created at login is discarded
- an account that already has an address can't replace it
The attach is single-step, following repo precedent:
`AuthResource.register` already claims a
placeholder on an unverified, typed address, and there is no email
verification anywhere in the
codebase today. Adding verification is out of scope here but worth
doing.
**2. Not a single-step handoff.** Because this is a plain OAuth 2.0
authorization-code flow rather
than the OIDC path Google takes, login can't be resolved in one clean
step. The frontend gets a
dedicated callback component that resolves the code and passes it to the
backend before routing to
the homepage. The CSRF `state` parameter is now verified there — the
login page was already writing
it to `sessionStorage`, but nothing read it back.
- **`ExternalAuthProvisioner` gains a sibling entry point**, rather than
the existing one widening.
`ExternalProfile` keeps main's contract (`email: String`,
provider-verified), and a new
`ExternalIdentity(providerType, providerId, name)` covers a provider
that vouches for no address,
provisioned through `loginOrProvisionIdentityOnly`. Such a login is
deliberately never matched to
an existing account: the only address available for matching would be
one the user typed, and
linking on that is the takeover `ExternalProfile` warns about.
`GoogleAuthResource` is unchanged.
- **`refresh` no longer blanks a field the provider didn't assert.** A
returning ORCID login carries
no address, and by then the account may well have one collected through
the prompt.
- **`TexeraWebApplication`** registers the new resource.
- **`ConfigResource` / `GuiConfig`** carry the `orcidLogin` flag to the
login page.
### Config and how to enable
`user-sys.orcid.{clientId,clientSecret,baseUrl,redirectUri}`,
`GUI_LOGIN_ORCID_LOGIN`, and both k8s
values files. `baseUrl` defaults to the ORCID sandbox. `GET
/auth/orcid/config` returns 503 when any
setting is missing, so the button stays disabled and no error toast
appears.
`redirectUri` is served to the login page by `GET /auth/orcid/config`
rather than derived in the
browser, so the authorize leg and the token exchange cannot disagree —
ORCID requires them to match
byte-for-byte.
Serving ORCID locally needs the dev server on the IPv4 loopback (`ng
serve --host 127.0.0.1`),
because ORCID rejects `localhost` as a registered redirect URI and `ng
serve` binds
`localhost`/`::1` by default. This is a per-developer flag, not a
repo-wide default: `angular.json`
is unchanged.
### Any related issues, documentation, discussions?
Closes #7516
### How was this PR tested?
New specs on both sides. The consent screen and token exchange are the
one part that cannot be unit
tested, so `exchangeCode` is a protected seam the specs override — as
`GoogleAuthResourceSpec` does
with `verifiedPayload` — and the real flow was driven by hand against
the ORCID sandbox.
- **`OrcidAuthResourceSpec` (new)**: provisioning from an authenticated
iD — emailless INACTIVE
account plus its `auth_provider` row, idempotent on a second login, the
iD standing in for a
private name. Refusals: a response naming no iD, a blank code, each
missing config setting.
- **`ExternalAuthProvisionerSpec`**: identity-only provisioning, two
emailless accounts staying
separate on a NULL email (`"user".email` is UNIQUE, which in Postgres
doesn't constrain repeated
NULLs), and a later-collected address surviving a refresh.
- **`GoogleAuthResourceSpec` / `AuthResourceSpec`**: unchanged by this
PR, run as regression over
the provisioning refactor.
- **Frontend**: `orcid-callback.component.spec.ts` (new) for the state
check and every refusal path;
`auth.service.spec.ts` for the `orcidAuth` endpoint and its error
propagation; the login page's
redirect and button gating.
```
sbt "WorkflowExecutionService/testOnly org.apache.texera.web.resource.auth.*"
cd frontend && npx ng test --watch=false
sbt scalafmtCheckAll && cd frontend && npx tsc -p tsconfig.json --noEmit && yarn format:ci
```
By hand, against the ORCID sandbox. Register
`http://127.0.0.1:4200/callback/orcid` on a sandbox
application (ORCID rejects `localhost`), then:
```
export USER_SYS_ORCID_CLIENT_ID=APP-XXXXXXXXXXXX
export USER_SYS_ORCID_CLIENT_SECRET=... # read once per JVM, so export before starting
export GUI_LOGIN_ORCID_LOGIN=true
bin/local-dev.sh up # migrations + jOOQ codegen
cd frontend && npx ng serve --host 127.0.0.1
```
Sign in with ORCID at `http://127.0.0.1:4200/login`, consent, supply an
address at the prompt, and
reload to confirm you are not asked again. Refusals: a tampered `state`
on the callback URL returns
you to `/login`; an address belonging to a credentialed account keeps
the dialog open. With the
credentials unset, the button stays disabled and no error toast appears.
**Migration**: applied to a database whose enum lacked `ORCID` under
both runners this repo uses
(`bin/local-dev.sh` keeps `SET search_path`; the Liquibase runner in
`sql/docker-compose.yml` strips
it, which is why the type is schema-qualified), then re-applied to
confirm idempotence.
### Was this PR authored or co-authored using generative AI tooling?
Co-Authored with Claude Opus 5
---------
Signed-off-by: Neil Ketteringham <53...@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <no...@anthropic.com>
Co-authored-by: Yicong Huang <17...@users.noreply.github.com>
---
LICENSE | 9 +
.../apache/texera/web/TexeraWebApplication.scala | 3 +-
.../resource/auth/ExternalAuthProvisioner.scala | 77 +++++-
.../web/resource/auth/OrcidAuthResource.scala | 214 ++++++++++++++++
.../auth/ExternalAuthProvisionerSpec.scala | 74 +++++-
.../web/resource/auth/OrcidAuthResourceSpec.scala | 210 ++++++++++++++++
bin/k8s/values-development.yaml | 18 ++
bin/k8s/values.yaml | 18 ++
common/config/src/main/resources/gui.conf | 7 +
common/config/src/main/resources/user-system.conf | 24 ++
.../common/config/EnvironmentalVariable.scala | 4 +
.../apache/texera/common/config/GuiConfig.scala | 2 +
.../texera/common/config/UserSystemConfig.scala | 4 +
.../texera/common/config/GuiConfigSpec.scala | 3 +
.../common/config/UserSystemConfigSpec.scala | 16 ++
.../texera/service/resource/ConfigResource.scala | 1 +
.../service/resource/ConfigResourceSpec.scala | 4 +
frontend/src/app/app-routing.module.ts | 6 +
.../app/common/service/gui-config.service.mock.ts | 1 +
.../src/app/common/service/gui-config.service.ts | 2 +-
.../app/common/service/user/auth.service.spec.ts | 10 +
.../src/app/common/service/user/auth.service.ts | 21 ++
.../app/common/service/user/orcid-auth.service.ts | 58 +++++
.../app/common/service/user/stub-auth.service.ts | 4 +
.../app/common/service/user/stub-user.service.ts | 4 +
.../src/app/common/service/user/user.service.ts | 4 +
frontend/src/app/common/type/gui-config.ts | 1 +
.../login/orcid-callback.component.spec.ts | 269 +++++++++++++++++++++
.../component/login/orcid-callback.component.ts | 150 ++++++++++++
.../component/login/texera-login.component.html | 14 +-
.../component/login/texera-login.component.scss | 33 +++
.../component/login/texera-login.component.spec.ts | 153 +++++++++++-
.../hub/component/login/texera-login.component.ts | 63 ++++-
frontend/src/assets/logos/ORCID-iD_icon_24x24.png | Bin 0 -> 1358 bytes
licenses/LICENSE-CC0-1.0.txt | 121 +++++++++
sql/changelog.xml | 5 +
sql/texera_ddl.sql | 2 +-
sql/updates/46.sql | 37 +++
38 files changed, 1616 insertions(+), 30 deletions(-)
diff --git a/LICENSE b/LICENSE
index 9e1e228eb2..5d1d9f04cb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -238,6 +238,15 @@ This product includes an icon from Google's Material Symbols:
Source: https://github.com/google/material-design-icons
License: Apache License 2.0 (this LICENSE file)
+This product includes the ORCID iD icon from ORCID, Inc.:
+ - frontend/src/assets/logos/ORCID-iD_icon_24x24.png
+ Source: https://info.orcid.org/brand-guidelines/
+ https://doi.org/10.23640/07243.5008697 (ORCID iD Icons, ORCID Brand Library)
+ License: CC0 1.0 Universal (licenses/LICENSE-CC0-1.0.txt)
+ Note: ORCID(TM), the ORCID logo and the iD logo are trademarks of ORCID, Inc. The icon is
+ bundled unmodified and displayed only to label ORCID sign-in, per ORCID's brand
+ guidelines; CC0 covers the graphic, not the mark.
+
This product includes SVG icons from SVGRepo:
- frontend/src/assets/svg/operator-view-result.svg
- frontend/src/assets/svg/operator-reuse-cache-valid.svg
diff --git a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
index 935242d6ff..34531e8c6b 100644
--- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
+++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
@@ -33,7 +33,7 @@ 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, OrcidAuthResource}
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.user.AdminUserResource
@@ -138,6 +138,7 @@ class TexeraWebApplication
environment.jersey.register(classOf[AuthResource])
environment.jersey.register(classOf[GoogleAuthResource])
+ environment.jersey.register(classOf[OrcidAuthResource])
environment.jersey.register(classOf[UserConfigResource])
environment.jersey.register(classOf[FeedbackResource])
environment.jersey.register(classOf[AdminUserResource])
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
index f5bb43cbf7..a3530aede1 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
@@ -50,8 +50,36 @@ final case class ExternalProfile(
avatar: Option[String]
)
+/**
+ * An identity a provider authenticates without asserting any address — ORCID, whose
+ * `/authenticate` scope yields an iD and a name and nothing else.
+ *
+ * A separate type rather than an optional `email` on [[ExternalProfile]]: the difference is what
+ * the provider vouches for, not how much of it is filled in, and an email-asserting provider's
+ * contract should stay a plain `String`. Provisioned through
+ * [[ExternalAuthProvisioner.loginOrProvisionIdentityOnly]].
+ */
+final case class ExternalIdentity(
+ providerType: ProviderTypeEnum,
+ providerId: String,
+ name: String
+)
+
object ExternalAuthProvisioner extends LazyLogging {
+ /**
+ * What provisioning actually works with: the fields a provider may or may not have asserted.
+ * Private, so the optionality never reaches a caller — each public entry point below states
+ * plainly which kind of provider it serves.
+ */
+ private final case class Asserted(
+ providerType: ProviderTypeEnum,
+ providerId: String,
+ name: String,
+ email: Option[String],
+ avatar: Option[String]
+ )
+
/**
* The account owning `email`, matched case-insensitively and within the caller's transaction
* so it reads that transaction's own writes. See
@@ -66,7 +94,32 @@ object ExternalAuthProvisioner extends LazyLogging {
* transaction. A unique violation is taken to mean a concurrent login won the race, so the
* attempt is re-run once; if the retry violates a constraint too, that exception propagates.
*/
- def loginOrProvision(profile: ExternalProfile): User = {
+ def loginOrProvision(profile: ExternalProfile): User =
+ attempt(
+ Asserted(
+ profile.providerType,
+ profile.providerId,
+ profile.name,
+ Some(profile.email),
+ profile.avatar
+ )
+ )
+
+ /**
+ * As [[loginOrProvision]], for a provider that asserts no address.
+ *
+ * The account is created with a NULL email and is deliberately never matched to an existing
+ * one: the only address available for matching would be one the user typed, and linking on
+ * that is the takeover [[ExternalProfile]] describes. Such an account signs in but is inert
+ * for the email-keyed parts of the product (dataset paths, access grants) until the address is
+ * collected — see `AuthResource.setEmail`.
+ */
+ def loginOrProvisionIdentityOnly(identity: ExternalIdentity): User =
+ attempt(
+ Asserted(identity.providerType, identity.providerId, identity.name, None, None)
+ )
+
+ private def attempt(profile: Asserted): User = {
try {
provision(profile)
} catch {
@@ -75,7 +128,7 @@ object ExternalAuthProvisioner extends LazyLogging {
}
}
- private def provision(profile: ExternalProfile): User = {
+ private def provision(profile: Asserted): User = {
SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { ctx =>
val txUserDao = new UserDao(ctx.configuration())
val txAuthDao = new AuthProviderDao(ctx.configuration())
@@ -98,7 +151,9 @@ object ExternalAuthProvisioner extends LazyLogging {
}
case None =>
- val user = userByEmailIgnoreCase(ctx, profile.email) match {
+ // An identity-only provider skips the lookup entirely rather than matching on nothing,
+ // so it always lands in the insert branch below.
+ val user = profile.email.flatMap(userByEmailIgnoreCase(ctx, _)) match {
case Some(existing) =>
existing.tap { user =>
val wasPlaceholder = user.getIsPlaceholder
@@ -109,7 +164,9 @@ object ExternalAuthProvisioner extends LazyLogging {
case None =>
val created = new User()
created.setName(profile.name)
- created.setEmail(profile.email)
+ // Left NULL for an identity-only provider. The column is nullable and its UNIQUE
+ // index tolerates repeated NULLs, so several such accounts can coexist.
+ profile.email.foreach(created.setEmail)
profile.avatar.foreach(created.setAvatar)
created.setRole(UserRoleEnum.INACTIVE)
txUserDao.insert(created)
@@ -137,15 +194,19 @@ object ExternalAuthProvisioner extends LazyLogging {
/**
* Mutate `user` in place to match `profile`, returning true iff anything changed
* (so the caller only issues an UPDATE when needed).
+ *
+ * A field the provider did not assert is left as it is rather than blanked: an identity-only
+ * provider carries no address, and on a returning login the account may well have one by then
+ * — collected through `AuthResource.setEmail` — which this must not undo.
*/
- private def refresh(user: User, profile: ExternalProfile): Boolean = {
+ private def refresh(user: User, profile: Asserted): Boolean = {
var changed = false
if (user.getName != profile.name) {
user.setName(profile.name)
changed = true
}
- if (user.getEmail != profile.email) {
- user.setEmail(profile.email)
+ profile.email.filter(_ != user.getEmail).foreach { email =>
+ user.setEmail(email)
changed = true
}
profile.avatar.filter(_ != user.getAvatar).foreach { url =>
@@ -159,7 +220,7 @@ object ExternalAuthProvisioner extends LazyLogging {
ctx: DSLContext,
authDao: AuthProviderDao,
user: User,
- profile: ExternalProfile
+ profile: Asserted
): Unit = {
val hasProvider = ctx.fetchExists(
ctx
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/OrcidAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/OrcidAuthResource.scala
new file mode 100644
index 0000000000..f64051a873
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/OrcidAuthResource.scala
@@ -0,0 +1,214 @@
+/*
+ * 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 com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
+import com.typesafe.scalalogging.Logger
+import kong.unirest.Unirest
+import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken}
+import org.apache.texera.common.config.UserSystemConfig
+import org.apache.texera.common.config.UserSystemConfig.orcidBaseUrl
+import org.apache.texera.dao.jooq.generated.enums.ProviderTypeEnum
+import org.apache.texera.web.model.http.response.TokenIssueResponse
+import org.apache.texera.web.resource.auth.OrcidAuthResource._
+
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.{
+ Consumes,
+ GET,
+ NotAuthorizedException,
+ POST,
+ Path,
+ Produces,
+ ServiceUnavailableException
+}
+
+object OrcidAuthResource {
+ private val logger: Logger = Logger(classOf[OrcidAuthResource])
+
+ final private lazy val clientId = UserSystemConfig.orcidClientId
+ final private lazy val clientSecret = UserSystemConfig.orcidClientSecret
+ final private lazy val redirectUri = UserSystemConfig.orcidRedirectUri
+
+ private val CONNECT_TIMEOUT_MS = 5000
+ private val SOCKET_TIMEOUT_MS = 10000
+
+ private val mapper = new ObjectMapper()
+
+ private[auth] final case class OrcidIdentity(orcidId: String, name: Option[String])
+
+ private def textOf(node: JsonNode, field: String): Option[String] =
+ Option(node.path(field).asText(null)).map(_.trim).filter(_.nonEmpty)
+
+ /**
+ * The names of the settings the ORCID flow cannot run without, among those given. Taken as
+ * parameters rather than read from [[UserSystemConfig]] because those are object vals resolved
+ * once per JVM, which leaves both the configured and unconfigured cases at the mercy of the
+ * environment a test happens to run in — the same reason `AuthResource.createAdminUser` takes
+ * its credentials as parameters.
+ */
+ private[auth] def missingSettings(
+ clientId: String,
+ clientSecret: String,
+ redirectUri: String,
+ baseUrl: String
+ ): Seq[String] =
+ Seq(
+ "clientId" -> clientId,
+ "clientSecret" -> clientSecret,
+ "redirectUri" -> redirectUri,
+ "baseUrl" -> baseUrl
+ ).collect { case (name, value) if value == null || value.isBlank => name }
+
+ /**
+ * Read the identity out of a token-endpoint response body.
+ *
+ * A body with no `orcid` is refused rather than defaulted: it means the exchange authenticated
+ * nobody, and provisioning against a synthesized id would hand out an account.
+ */
+ private[auth] def identityOf(body: String): OrcidIdentity = {
+ val tree = mapper.readTree(body)
+ OrcidIdentity(
+ textOf(tree, "orcid").getOrElse(
+ throw new NotAuthorizedException("Login credentials are incorrect.")
+ ),
+ textOf(tree, "name")
+ )
+ }
+
+}
+
+/**
+ * ORCID sign-in. Unlike Google — whose SDK runs the whole handshake in the browser and hands the
+ * frontend a signed id-token to post here — ORCID is plain authorization-code OAuth, so its
+ * second leg happens on this side: the frontend forwards the one-time `code` it was redirected to
+ * `/callback/orcid` with, and this trades it for the identity behind it. That code is useless
+ * without `clientSecret`, which is the only reason it may travel through a browser at all.
+ *
+ * ORCID asserts no email under the `/authenticate` scope the login page requests, so the account
+ * provisioned here has a NULL email and is deliberately not matched against any existing account.
+ * See [[ExternalIdentity]] for why that is the safe reading, and `AuthResource.setEmail` for how an
+ * address is collected once the user is in.
+ */
+@Path("/auth/orcid")
+class OrcidAuthResource {
+
+ /**
+ * What the login page needs to build its authorize redirect.
+ *
+ * A deployment missing any of the four settings the flow needs is reported unavailable rather
+ * than answered with blanks. The login page enables its ORCID button the moment this resolves,
+ * and each blank fails later and worse: an empty `client_id` lands the user on an ORCID error
+ * page, and an empty `redirect_uri` gets the exchange rejected after they have already
+ * consented. Failing here instead leaves the button disabled behind "ORCID sign-in is
+ * unavailable", which is what the page already does with a failed fetch
+ * (`texera-login.component.ts`).
+ *
+ * `redirectUri` is answered rather than left to the browser to derive, because ORCID requires
+ * the authorize call's `redirect_uri` and the token exchange's to match byte-for-byte and
+ * [[exchangeCode]] sends the configured one. Deriving the authorize leg from
+ * `window.location.origin` instead would give that pair two independent owners, and any
+ * disagreement — a deployment reached over a host, port or scheme other than the registered
+ * one — shows up only after the user has consented, as "Login credentials are incorrect."
+ * Serving it here makes this the single owner.
+ *
+ * `clientSecret` is checked here even though only [[exchangeCode]] sends it, and `baseUrl`
+ * because it is easily emptied: the deployment templates ship these for an operator to fill in,
+ * and HOCON treats an env var set to "" as set, so it overrides the config default. An empty
+ * `baseUrl` would otherwise answer with the relative `authorizeUrl` "/oauth/authorize", which
+ * navigates the SPA to itself instead of ORCID.
+ */
+ @GET
+ @Path("/config")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getConfig: Map[String, String] = {
+ val missing = missingSettings(clientId, clientSecret, redirectUri, orcidBaseUrl)
+ if (missing.nonEmpty) {
+ logger.warn(
+ s"ORCID sign-in is enabled but ${missing.map("user-sys.orcid." + _).mkString(", ")} " +
+ "is not configured; reporting it unavailable."
+ )
+ throw new ServiceUnavailableException("ORCID sign-in is not configured.")
+ }
+ Map(
+ "clientId" -> clientId,
+ "authorizeUrl" -> s"$orcidBaseUrl/oauth/authorize",
+ "redirectUri" -> redirectUri
+ )
+ }
+
+ /**
+ * Trade `code` for ORCID's token response, returning the raw body.
+ *
+ * `redirect_uri` is read from configuration rather than the request: ORCID requires it to match
+ * the authorize call byte-for-byte, and honouring a caller-supplied one would let the browser
+ * choose which registered redirect an exchange is attributed to. [[getConfig]] hands the login
+ * page this same value to send on the authorize leg, so the two agree by construction.
+ *
+ * The one seam that reaches the network. Kept as a method rather than a constructor parameter
+ * for the same reason [[GoogleAuthResource.verifiedPayload]] is: Jersey instantiates this
+ * resource from `classOf[OrcidAuthResource]`, so tests override instead of injecting.
+ */
+ protected def exchangeCode(code: String): String = {
+ val response = Unirest
+ .post(s"$orcidBaseUrl/oauth/token")
+ .header("Accept", MediaType.APPLICATION_JSON)
+ .field("client_id", clientId)
+ .field("client_secret", clientSecret)
+ .field("grant_type", "authorization_code")
+ .field("code", code)
+ .field("redirect_uri", redirectUri)
+ .connectTimeout(CONNECT_TIMEOUT_MS)
+ .socketTimeout(SOCKET_TIMEOUT_MS)
+ .asString()
+
+ if (response.getStatus != 200) {
+ logger.warn(s"ORCID token exchange returned ${response.getStatus}")
+ throw new NotAuthorizedException("Login credentials are incorrect.")
+ }
+ response.getBody
+ }
+
+ @POST
+ @Consumes(Array(MediaType.TEXT_PLAIN))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ @Path("/login")
+ def login(code: String): TokenIssueResponse = {
+ val trimmedCode = Option(code).map(_.trim).filter(_.nonEmpty).getOrElse {
+ throw new NotAuthorizedException("Login credentials are incorrect.")
+ }
+
+ val identity = identityOf(exchangeCode(trimmedCode))
+
+ val user = ExternalAuthProvisioner.loginOrProvisionIdentityOnly(
+ ExternalIdentity(
+ ProviderTypeEnum.ORCID,
+ identity.orcidId,
+ identity.name.getOrElse(identity.orcidId)
+ )
+ )
+
+ // No provider id in the claims. `jwtClaims`' second parameter is specifically the GOOGLE one —
+ // it writes a claim named `googleId` — and the frontend spends that claim as a Flarum account
+ // password (`flarum.service.ts`). An ORCID iD is public, so putting it there would set a
+ // guessable password on that account; the iD is in `auth_provider` for anything that needs it.
+ TokenIssueResponse(jwtToken(jwtClaims(user)))
+ }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
index 9db28a2bed..2f35df53f3 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
@@ -60,9 +60,23 @@ class ExternalAuthProvisionerSpec
override protected def beforeEach(): Unit = cleanup()
override protected def afterEach(): Unit = cleanup()
- // Case-insensitive so it also collects rows seeded with a differing casing.
- private def cleanup(): Unit =
+ // Case-insensitive so it also collects rows seeded with a differing casing. The ORCID arm
+ // catches what the email predicate cannot: an identity-only login leaves `email` NULL, so those
+ // accounts are identified by the provider row that cascades from them.
+ private def cleanup(): Unit = {
getDSLContext.deleteFrom(USER).where(DSL.lower(USER.EMAIL).like("%" + emailDomain)).execute()
+ getDSLContext
+ .deleteFrom(USER)
+ .where(
+ USER.UID.in(
+ getDSLContext
+ .select(AUTH_PROVIDER.UID)
+ .from(AUTH_PROVIDER)
+ .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.ORCID))
+ )
+ )
+ .execute()
+ }
// ---- helpers -------------------------------------------------------------
@@ -77,6 +91,10 @@ class ExternalAuthProvisionerSpec
): ExternalProfile =
ExternalProfile(ProviderTypeEnum.GOOGLE, providerId, name, email, avatar)
+ /** An identity-only login: ORCID's `/authenticate` scope asserts an iD and a name, no address. */
+ private def orcidIdentity(providerId: String, name: String): ExternalIdentity =
+ ExternalIdentity(ProviderTypeEnum.ORCID, providerId, name)
+
/** Seed a user row directly; uid is DB-assigned and read back into the pojo. */
private def seedUser(name: String, localPart: String, avatar: String = null): User =
seedUserWithEmail(name, localPart + emailDomain, avatar)
@@ -259,6 +277,58 @@ class ExternalAuthProvisionerSpec
claimed.getComment should include("Claimed contributor placeholder at ")
}
+ // ---- identity-only providers (no email asserted) --------------------------
+
+ it should "provision an emailless INACTIVE account for an identity-only provider" in {
+ val user =
+ ExternalAuthProvisioner.loginOrProvisionIdentityOnly(
+ orcidIdentity("0000-0001-0000-0001", "Researcher")
+ )
+
+ user.getUid should not be null
+ user.getName shouldBe "Researcher"
+ user.getEmail shouldBe null
+ user.getRole shouldBe UserRoleEnum.INACTIVE
+ providerIdOf(user.getUid, ProviderTypeEnum.ORCID) shouldBe "0000-0001-0000-0001"
+ }
+
+ // Two emailless accounts have to be able to coexist: `"user".email` is UNIQUE, which in Postgres
+ // does not constrain repeated NULLs. If that ever changed, the second login would 500 here
+ // rather than silently merging, but this pins the behavior either way.
+ it should "keep two identity-only accounts separate rather than merging them on a null email" in {
+ val first =
+ ExternalAuthProvisioner.loginOrProvisionIdentityOnly(
+ orcidIdentity("0000-0001-0000-0002", "First")
+ )
+ val second =
+ ExternalAuthProvisioner.loginOrProvisionIdentityOnly(
+ orcidIdentity("0000-0001-0000-0003", "Second")
+ )
+
+ second.getUid should not be first.getUid
+ providerRowCount(first.getUid) shouldBe 1
+ providerRowCount(second.getUid) shouldBe 1
+ }
+
+ // The address is collected after the first login (AuthResource.setEmail), so every subsequent
+ // login arrives with a profile that still asserts no email. Refreshing must not blank it.
+ it should "preserve a later-collected email when an identity-only login returns" in {
+ val created =
+ ExternalAuthProvisioner.loginOrProvisionIdentityOnly(
+ orcidIdentity("0000-0001-0000-0004", "Returner")
+ )
+ created.setEmail("collected" + emailDomain)
+ userDao.update(created)
+
+ val returning =
+ ExternalAuthProvisioner.loginOrProvisionIdentityOnly(
+ orcidIdentity("0000-0001-0000-0004", "Returner")
+ )
+
+ returning.getUid shouldBe created.getUid
+ userDao.fetchOneByUid(created.getUid).getEmail shouldBe "collected" + emailDomain
+ }
+
// ---- provider id rotation -------------------------------------------------
it should "update the stored provider id when the same user returns with a new one" in {
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/auth/OrcidAuthResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/auth/OrcidAuthResourceSpec.scala
new file mode 100644
index 0000000000..ed504a8d0d
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/auth/OrcidAuthResourceSpec.scala
@@ -0,0 +1,210 @@
+/*
+ * 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 org.apache.texera.dao.MockTexeraDB
+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.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import javax.ws.rs.NotAuthorizedException
+
+/**
+ * Integration spec for [[OrcidAuthResource]] against embedded Postgres.
+ *
+ * The token exchange is what cannot run here, so the suite overrides that one seam and drives the
+ * resource with bodies shaped like ORCID's. What that leaves under test is everything the exchange
+ * feeds: that an authenticated iD becomes an emailless INACTIVE account with an ORCID provider row,
+ * and that a response authenticating nobody is a 401 rather than an account.
+ */
+class OrcidAuthResourceSpec
+ extends AnyFlatSpec
+ with Matchers
+ with BeforeAndAfterAll
+ with BeforeAndAfterEach
+ with MockTexeraDB {
+
+ private val orcidId = "0000-0002-1825-0097"
+
+ private var userDao: UserDao = _
+
+ override protected def beforeAll(): Unit = {
+ initializeDBAndReplaceDSLContext()
+ userDao = new UserDao(getDSLContext.configuration())
+ }
+
+ override protected def afterAll(): Unit = shutdownDB()
+
+ override protected def beforeEach(): Unit = cleanup()
+ override protected def afterEach(): Unit = cleanup()
+
+ // Accounts provisioned here have no email, so they are identified by the provider row that
+ // cascades from them rather than by an address pattern.
+ private def cleanup(): Unit =
+ getDSLContext
+ .deleteFrom(USER)
+ .where(
+ USER.UID.in(
+ getDSLContext
+ .select(AUTH_PROVIDER.UID)
+ .from(AUTH_PROVIDER)
+ .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.ORCID))
+ )
+ )
+ .execute()
+
+ // ---- helpers -------------------------------------------------------------
+
+ /** A token response shaped like ORCID's. Passing null for `name` omits the member entirely. */
+ private def tokenBody(id: String = orcidId, name: String = "Sofia Garcia"): String = {
+ val nameMember = if (name == null) "" else s""""name":"$name","""
+ s"""{"access_token":"tok-abc","token_type":"bearer","refresh_token":"ref",
+ |"expires_in":631138518,"scope":"/authenticate",$nameMember"orcid":"$id"}""".stripMargin
+ }
+
+ /** A resource whose one network leg is canned: `body` stands in for the token exchange. */
+ private class StubbedOrcidAuthResource(body: String) extends OrcidAuthResource {
+ var exchangedCode: Option[String] = None
+
+ override protected def exchangeCode(code: String): String = {
+ exchangedCode = Some(code)
+ body
+ }
+ }
+
+ private def userBehind(orcidId: String): User =
+ getDSLContext
+ .select(USER.fields(): _*)
+ .from(USER)
+ .join(AUTH_PROVIDER)
+ .on(USER.UID.eq(AUTH_PROVIDER.UID))
+ .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.ORCID))
+ .and(AUTH_PROVIDER.PROVIDER_ID.eq(orcidId))
+ .fetchOneInto(classOf[User])
+
+ // ---- login ---------------------------------------------------------------
+
+ behavior of "login"
+
+ it should "provision an emailless INACTIVE account and an ORCID provider row on a first login" in {
+ val response = new StubbedOrcidAuthResource(tokenBody()).login("auth-code")
+
+ response.accessToken should not be empty
+ val user = userBehind(orcidId)
+ user should not be null
+ user.getName shouldBe "Sofia Garcia"
+ user.getEmail shouldBe null
+ user.getRole shouldBe UserRoleEnum.INACTIVE
+ }
+
+ it should "return the same account on a second login rather than provisioning again" in {
+ val first = new StubbedOrcidAuthResource(tokenBody())
+ first.login("code-1")
+ val uid = userBehind(orcidId).getUid
+
+ new StubbedOrcidAuthResource(tokenBody()).login("code-2")
+
+ userBehind(orcidId).getUid shouldBe uid
+ }
+
+ // `"user".name` is NOT NULL and ORCID omits the member for a record whose owner made it private,
+ // so the iD has to stand in rather than the insert failing.
+ it should "fall back to the ORCID iD when the record publishes no name" in {
+ new StubbedOrcidAuthResource(tokenBody(name = null)).login("auth-code")
+
+ userBehind(orcidId).getName shouldBe orcidId
+ }
+
+ it should "pass the code through to the exchange with surrounding whitespace trimmed" in {
+ val resource = new StubbedOrcidAuthResource(tokenBody())
+ resource.login(" auth-code\n")
+
+ resource.exchangedCode shouldBe Some("auth-code")
+ }
+
+ // An address the user supplied later has to survive: every subsequent ORCID login still asserts
+ // none, and refreshing must not blank what `AuthResource.setEmail` collected.
+ it should "leave a later-collected address alone when the identity returns" in {
+ new StubbedOrcidAuthResource(tokenBody()).login("c")
+ val user = userBehind(orcidId)
+ user.setEmail("collected@example.com")
+ userDao.update(user)
+
+ new StubbedOrcidAuthResource(tokenBody()).login("c")
+
+ userBehind(orcidId).getEmail shouldBe "collected@example.com"
+ }
+
+ // ---- refusals ------------------------------------------------------------
+
+ // A response with no `orcid` authenticated nobody. Provisioning against a synthesized id would
+ // hand out an account, so this must fail rather than default.
+ it should "reject a token response that names no ORCID iD" in {
+ assertThrows[NotAuthorizedException] {
+ new StubbedOrcidAuthResource("""{"access_token":"tok","scope":"/authenticate"}""").login("c")
+ }
+ }
+
+ it should "reject a blank authorization code without reaching the exchange" in {
+ val resource = new StubbedOrcidAuthResource(tokenBody())
+
+ assertThrows[NotAuthorizedException](resource.login(" "))
+ resource.exchangedCode shouldBe None
+ }
+
+ // ---- configuration gating ------------------------------------------------
+
+ // What `getConfig` refuses on. Driven through the pure helper rather than the endpoint, because
+ // the endpoint reads `UserSystemConfig` object vals: a developer with USER_SYS_ORCID_* exported
+ // would see the opposite outcome from CI.
+ //
+ // Each blank matters at a different moment, and both are worse than failing here: an empty
+ // client id lands the user on an ORCID error page, and an empty redirect uri gets the exchange
+ // rejected after they have already consented.
+ behavior of "missingSettings"
+
+ it should "accept a fully configured deployment" in {
+ OrcidAuthResource.missingSettings(
+ "APP-1",
+ "secret",
+ "http://127.0.0.1:4200/callback/orcid",
+ "https://sandbox.orcid.org"
+ ) shouldBe empty
+ }
+
+ it should "name each setting that is empty, blank, or absent" in {
+ OrcidAuthResource.missingSettings("", "secret", "uri", "base") shouldBe Seq("clientId")
+ OrcidAuthResource.missingSettings("APP-1", " ", "uri", "base") shouldBe Seq("clientSecret")
+ OrcidAuthResource.missingSettings("APP-1", "secret", null, "base") shouldBe Seq("redirectUri")
+ // A blank baseUrl would make authorizeUrl the relative "/oauth/authorize", so the button would
+ // navigate the app to itself rather than to ORCID.
+ OrcidAuthResource.missingSettings("APP-1", "secret", "uri", "") shouldBe Seq("baseUrl")
+ OrcidAuthResource.missingSettings("", "", "", "") shouldBe Seq(
+ "clientId",
+ "clientSecret",
+ "redirectUri",
+ "baseUrl"
+ )
+ }
+}
diff --git a/bin/k8s/values-development.yaml b/bin/k8s/values-development.yaml
index 5a8dc899e2..01fdc2c957 100644
--- a/bin/k8s/values-development.yaml
+++ b/bin/k8s/values-development.yaml
@@ -330,6 +330,10 @@ texeraEnvVars:
value: "true"
- name: GUI_LOGIN_GOOGLE_LOGIN
value: "true"
+ # Turn on together with the USER_SYS_ORCID_* credentials below. On with nothing configured, the
+ # button renders disabled and /auth/orcid/config reports the provider unavailable on every visit.
+ - name: GUI_LOGIN_ORCID_LOGIN
+ value: "false"
- name: GUI_DATASET_SINGLE_FILE_UPLOAD_MAXIMUM_SIZE_MB
value: "1024"
- name: GUI_WORKFLOW_WORKSPACE_EXPORT_EXECUTION_RESULT_ENABLED
@@ -355,6 +359,20 @@ texeraEnvVars:
value: ""
- name: USER_SYS_GOOGLE_SMTP_PASSWORD
value: ""
+ # ORCID sign-in. The client id and secret come from an ORCID developer application; leave them
+ # empty to keep the provider switched off. baseUrl selects the deployment (sandbox vs
+ # production), and redirectUri must be this deployment's own /callback/orcid URL, registered on
+ # that ORCID application — ORCID does not accept `localhost`. It drives both legs of the flow:
+ # /auth/orcid/config hands it to the login page and the token exchange sends the same value, so
+ # leaving it empty switches the provider off rather than failing after the user has consented.
+ - name: USER_SYS_ORCID_CLIENT_ID
+ value: ""
+ - name: USER_SYS_ORCID_CLIENT_SECRET
+ value: ""
+ - name: USER_SYS_ORCID_BASE_URL
+ value: "https://sandbox.orcid.org"
+ - name: USER_SYS_ORCID_REDIRECT_URI
+ value: ""
- name: USER_SYS_DOMAIN
value: ""
- name: AUTH_JWT_SECRET
diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml
index ca03235616..597a65ae10 100644
--- a/bin/k8s/values.yaml
+++ b/bin/k8s/values.yaml
@@ -333,6 +333,10 @@ texeraEnvVars:
value: "true"
- name: GUI_LOGIN_GOOGLE_LOGIN
value: "true"
+ # Turn on together with the USER_SYS_ORCID_* credentials below. On with nothing configured, the
+ # button renders disabled and /auth/orcid/config reports the provider unavailable on every visit.
+ - name: GUI_LOGIN_ORCID_LOGIN
+ value: "false"
- name: GUI_DATASET_SINGLE_FILE_UPLOAD_MAXIMUM_SIZE_MB
value: "1024"
- name: GUI_WORKFLOW_WORKSPACE_EXPORT_EXECUTION_RESULT_ENABLED
@@ -358,6 +362,20 @@ texeraEnvVars:
value: ""
- name: USER_SYS_GOOGLE_SMTP_PASSWORD
value: ""
+ # ORCID sign-in. The client id and secret come from an ORCID developer application; leave them
+ # empty to keep the provider switched off. baseUrl selects the deployment (sandbox vs
+ # production), and redirectUri must be this deployment's own /callback/orcid URL, registered on
+ # that ORCID application — ORCID does not accept `localhost`. It drives both legs of the flow:
+ # /auth/orcid/config hands it to the login page and the token exchange sends the same value, so
+ # leaving it empty switches the provider off rather than failing after the user has consented.
+ - name: USER_SYS_ORCID_CLIENT_ID
+ value: ""
+ - name: USER_SYS_ORCID_CLIENT_SECRET
+ value: ""
+ - name: USER_SYS_ORCID_BASE_URL
+ value: "https://orcid.org"
+ - name: USER_SYS_ORCID_REDIRECT_URI
+ value: ""
- name: USER_SYS_DOMAIN
value: ""
- name: AUTH_JWT_SECRET
diff --git a/common/config/src/main/resources/gui.conf b/common/config/src/main/resources/gui.conf
index f136569593..b49cc348dd 100644
--- a/common/config/src/main/resources/gui.conf
+++ b/common/config/src/main/resources/gui.conf
@@ -34,6 +34,13 @@ gui {
google-login = true
google-login = ${?GUI_LOGIN_GOOGLE_LOGIN}
+ # whether orcid login is enabled. Off by default because it needs credentials that only an
+ # operator can supply (user-sys.orcid.clientId/clientSecret): with the button on and nothing
+ # configured, /auth/orcid/config reports the provider unavailable on every visit to the login
+ # page. Turn this on together with those settings.
+ orcid-login = false
+ orcid-login = ${?GUI_LOGIN_ORCID_LOGIN}
+
# Can be configured as { username: "texera", password: "password" }
# If configured, this will be automatically filled into the local login input box
default-local-user {
diff --git a/common/config/src/main/resources/user-system.conf b/common/config/src/main/resources/user-system.conf
index 61b6e9d237..b6b3e2a166 100644
--- a/common/config/src/main/resources/user-system.conf
+++ b/common/config/src/main/resources/user-system.conf
@@ -36,6 +36,30 @@ user-sys {
}
}
+ orcid {
+ clientId = ""
+ clientId = ${?USER_SYS_ORCID_CLIENT_ID}
+
+ clientSecret = ""
+ clientSecret = ${?USER_SYS_ORCID_CLIENT_SECRET}
+
+ # The registry base URL, used for the authorize and token endpoints.
+ baseUrl = "https://sandbox.orcid.org"
+ baseUrl = ${?USER_SYS_ORCID_BASE_URL}
+
+ # Must be a redirect URI registered on the ORCID client, or the token exchange is rejected.
+ # The login page sends this same value on the authorize leg — /auth/orcid/config hands it over
+ # rather than letting the browser derive one — so the two legs agree by construction.
+ #
+ # 127.0.0.1 rather than localhost because ORCID does not accept `localhost` as a registered
+ # redirect URI. The Angular dev server binds `localhost` (::1) by default, so testing ORCID
+ # locally means starting it on the IPv4 loopback instead:
+ # cd frontend && npx ng serve --host 127.0.0.1
+ # Deployments override this with USER_SYS_ORCID_REDIRECT_URI.
+ redirectUri = "http://127.0.0.1:4200/callback/orcid"
+ redirectUri = ${?USER_SYS_ORCID_REDIRECT_URI}
+ }
+
domain = ""
domain = ${?USER_SYS_DOMAIN}
diff --git a/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala b/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala
index b0cc3028f9..46bf207457 100644
--- a/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala
+++ b/common/config/src/main/scala/org/apache/texera/common/config/EnvironmentalVariable.scala
@@ -125,6 +125,10 @@ object EnvironmentalVariable {
val ENV_USER_SYS_GOOGLE_CLIENT_ID = "USER_SYS_GOOGLE_CLIENT_ID"
val ENV_USER_SYS_GOOGLE_SMTP_GMAIL = "USER_SYS_GOOGLE_SMTP_GMAIL"
val ENV_USER_SYS_GOOGLE_SMTP_PASSWORD = "USER_SYS_GOOGLE_SMTP_PASSWORD"
+ val ENV_USER_SYS_ORCID_CLIENT_ID = "USER_SYS_ORCID_CLIENT_ID"
+ val ENV_USER_SYS_ORCID_CLIENT_SECRET = "USER_SYS_ORCID_CLIENT_SECRET"
+ val ENV_USER_SYS_ORCID_BASE_URL = "USER_SYS_ORCID_BASE_URL"
+ val ENV_USER_SYS_ORCID_REDIRECT_URI = "USER_SYS_ORCID_REDIRECT_URI"
val ENV_USER_SYS_VERSION_TIME_LIMIT_IN_MINUTES = "USER_SYS_VERSION_TIME_LIMIT_IN_MINUTES"
// Result Cleanup
diff --git a/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala b/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala
index f4e07d2abf..f6b6e1c434 100644
--- a/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala
+++ b/common/config/src/main/scala/org/apache/texera/common/config/GuiConfig.scala
@@ -29,6 +29,8 @@ object GuiConfig {
conf.getBoolean("gui.login.local-login")
val guiLoginGoogleLogin: Boolean =
conf.getBoolean("gui.login.google-login")
+ val guiLoginOrcidLogin: Boolean =
+ conf.getBoolean("gui.login.orcid-login")
val guiLoginDefaultLocalUserUsername: String =
if (conf.hasPath("gui.login.default-local-user.username"))
conf.getString("gui.login.default-local-user.username")
diff --git a/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala b/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala
index ff28b32ff2..fc4756297b 100644
--- a/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala
+++ b/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala
@@ -30,6 +30,10 @@ 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 orcidClientId: String = conf.getString("user-sys.orcid.clientId")
+ val orcidClientSecret: String = conf.getString("user-sys.orcid.clientSecret")
+ val orcidBaseUrl: String = conf.getString("user-sys.orcid.baseUrl")
+ val orcidRedirectUri: String = conf.getString("user-sys.orcid.redirectUri")
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")
diff --git a/common/config/src/test/scala/org/apache/texera/common/config/GuiConfigSpec.scala b/common/config/src/test/scala/org/apache/texera/common/config/GuiConfigSpec.scala
index 68f47b0c14..45e4adecd4 100644
--- a/common/config/src/test/scala/org/apache/texera/common/config/GuiConfigSpec.scala
+++ b/common/config/src/test/scala/org/apache/texera/common/config/GuiConfigSpec.scala
@@ -35,6 +35,9 @@ class GuiConfigSpec extends AnyFlatSpec with Matchers {
"GuiConfig boolean flags" should "resolve to their gui.conf defaults when env overrides are unset" in {
ifUnset("GUI_LOGIN_LOCAL_LOGIN")(GuiConfig.guiLoginLocalLogin shouldBe true)
ifUnset("GUI_LOGIN_GOOGLE_LOGIN")(GuiConfig.guiLoginGoogleLogin shouldBe true)
+ // ORCID ships off: it needs credentials only an operator can supply, and with the button on
+ // and nothing configured /auth/orcid/config reports it unavailable on every visit.
+ ifUnset("GUI_LOGIN_ORCID_LOGIN")(GuiConfig.guiLoginOrcidLogin shouldBe false)
ifUnset("GUI_WORKFLOW_WORKSPACE_USER_PRESET_ENABLED")(
GuiConfig.guiWorkflowWorkspaceUserPresetEnabled shouldBe false
)
diff --git a/common/config/src/test/scala/org/apache/texera/common/config/UserSystemConfigSpec.scala b/common/config/src/test/scala/org/apache/texera/common/config/UserSystemConfigSpec.scala
index 034036f991..0e42bf3f5c 100644
--- a/common/config/src/test/scala/org/apache/texera/common/config/UserSystemConfigSpec.scala
+++ b/common/config/src/test/scala/org/apache/texera/common/config/UserSystemConfigSpec.scala
@@ -41,6 +41,10 @@ class UserSystemConfigSpec extends AnyFlatSpec with Matchers {
UserSystemConfig.adminUsername should not be null
UserSystemConfig.adminPassword should not be null
UserSystemConfig.googleClientId should not be null
+ UserSystemConfig.orcidClientId should not be null
+ UserSystemConfig.orcidClientSecret should not be null
+ UserSystemConfig.orcidBaseUrl should not be null
+ UserSystemConfig.orcidRedirectUri should not be null
UserSystemConfig.gmail should not be null
UserSystemConfig.smtpPassword should not be null
UserSystemConfig.projectName should not be null
@@ -53,6 +57,18 @@ class UserSystemConfigSpec extends AnyFlatSpec with Matchers {
ifUnset("USER_SYS_GOOGLE_CLIENT_ID")(UserSystemConfig.googleClientId shouldBe "")
ifUnset("USER_SYS_GOOGLE_SMTP_GMAIL")(UserSystemConfig.gmail shouldBe "")
ifUnset("USER_SYS_GOOGLE_SMTP_PASSWORD")(UserSystemConfig.smtpPassword shouldBe "")
+ // The two credentials ship blank so ORCID sign-in cannot be half-configured by accident;
+ // `OrcidAuthResource.getConfig` reports the provider unavailable until an operator fills them
+ // in. baseUrl and redirectUri ship usable dev defaults: the sandbox registry, and the callback
+ // URL of `ng serve --host 127.0.0.1`.
+ ifUnset("USER_SYS_ORCID_CLIENT_ID")(UserSystemConfig.orcidClientId shouldBe "")
+ ifUnset("USER_SYS_ORCID_CLIENT_SECRET")(UserSystemConfig.orcidClientSecret shouldBe "")
+ ifUnset("USER_SYS_ORCID_BASE_URL")(
+ UserSystemConfig.orcidBaseUrl shouldBe "https://sandbox.orcid.org"
+ )
+ ifUnset("USER_SYS_ORCID_REDIRECT_URI")(
+ UserSystemConfig.orcidRedirectUri shouldBe "http://127.0.0.1:4200/callback/orcid"
+ )
ifUnset("USER_SYS_PROJECT_NAME")(UserSystemConfig.projectName shouldBe "Texera")
ifUnset("USER_SYS_INVITE_ONLY")(UserSystemConfig.inviteOnly shouldBe false)
ifUnset("USER_SYS_EMAIL_VERIFICATION")(UserSystemConfig.emailVerification shouldBe false)
diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index 48329e33d9..ecbfcfa135 100644
--- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -63,6 +63,7 @@ class ConfigResource {
Map(
"localLogin" -> GuiConfig.guiLoginLocalLogin,
"googleLogin" -> GuiConfig.guiLoginGoogleLogin,
+ "orcidLogin" -> GuiConfig.guiLoginOrcidLogin,
"defaultLocalUser" -> Map(
"username" -> GuiConfig.guiLoginDefaultLocalUserUsername,
"password" -> GuiConfig.guiLoginDefaultLocalUserPassword
diff --git a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
index 524a966183..b170e222ac 100644
--- a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
+++ b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
@@ -132,6 +132,9 @@ class ConfigResourceSpec
payload.keySet shouldBe Set(
"localLogin",
"googleLogin",
+ // The login page needs this before anyone is signed in, for the same reason as the other two
+ // provider flags: it decides whether the ORCID button is rendered at all.
+ "orcidLogin",
"defaultLocalUser",
"attributionEnabled",
"deploymentVersionCheckEnabled",
@@ -167,6 +170,7 @@ class ConfigResourceSpec
payload.keySet should contain noneOf (
"localLogin",
"googleLogin",
+ "orcidLogin",
"defaultLocalUser",
"attributionEnabled"
)
diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 05be4d2183..f9fd43cac1 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -45,6 +45,7 @@ import { USER_WORKFLOW } from "./app-routing.constant";
import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component";
import { EntityType } from "./hub/service/hub.service";
import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component";
+import { OrcidCallbackComponent } from "./hub/component/login/orcid-callback.component";
const routes: Routes = [];
@@ -56,6 +57,11 @@ routes.push({
component: TexeraLoginComponent,
});
+routes.push({
+ path: "callback",
+ children: [{ path: "orcid", component: OrcidCallbackComponent }],
+});
+
routes.push({
path: "",
component: DashboardComponent,
diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts b/frontend/src/app/common/service/gui-config.service.mock.ts
index 2101144960..dbf21ff37c 100644
--- a/frontend/src/app/common/service/gui-config.service.mock.ts
+++ b/frontend/src/app/common/service/gui-config.service.mock.ts
@@ -33,6 +33,7 @@ export class MockGuiConfigService {
selectingFilesFromDatasetsEnabled: false,
localLogin: true,
googleLogin: true,
+ orcidLogin: true,
inviteOnly: false,
emailVerification: false,
userPresetEnabled: true,
diff --git a/frontend/src/app/common/service/gui-config.service.ts b/frontend/src/app/common/service/gui-config.service.ts
index 6c1cb18b44..d00a310383 100644
--- a/frontend/src/app/common/service/gui-config.service.ts
+++ b/frontend/src/app/common/service/gui-config.service.ts
@@ -30,7 +30,7 @@ const ACCESS_TOKEN_KEY = "access_token";
type PreLoginConfig = Pick<
GuiConfig,
- "localLogin" | "googleLogin" | "defaultLocalUser" | "attributionEnabled" | "emailVerification"
+ "localLogin" | "googleLogin" | "orcidLogin" | "defaultLocalUser" | "attributionEnabled" | "emailVerification"
>;
// Fields served by /config/amber.
type AmberConfig = Pick<GuiConfig, "defaultDataTransferBatchSize">;
diff --git a/frontend/src/app/common/service/user/auth.service.spec.ts b/frontend/src/app/common/service/user/auth.service.spec.ts
index c02c54bb7e..b2d054e806 100644
--- a/frontend/src/app/common/service/user/auth.service.spec.ts
+++ b/frontend/src/app/common/service/user/auth.service.spec.ts
@@ -121,6 +121,15 @@ describe("AuthService", () => {
req.flush({ accessToken: "t" });
});
+ it("orcidAuth() POSTs the raw authorization code with a text/plain content type", () => {
+ service.orcidAuth("auth-code").subscribe();
+ const req = httpMock.expectOne(`${api}/${AuthService.ORCID_LOGIN_ENDPOINT}`);
+ expect(req.request.method).toEqual("POST");
+ expect(req.request.body).toEqual("auth-code");
+ expect(req.request.headers.get("Content-Type")).toEqual("text/plain");
+ req.flush({ accessToken: "t" });
+ });
+
it("googleAuth() POSTs the raw credential with a text/plain content type", () => {
service.googleAuth("cred").subscribe();
const req = httpMock.expectOne(`${api}/${AuthService.GOOGLE_LOGIN_ENDPOINT}`);
@@ -140,6 +149,7 @@ describe("AuthService", () => {
},
{ name: "auth", call: () => service.auth("alice", "pw"), endpoint: AuthService.LOGIN_ENDPOINT },
{ name: "googleAuth", call: () => service.googleAuth("cred"), endpoint: AuthService.GOOGLE_LOGIN_ENDPOINT },
+ { name: "orcidAuth", call: () => service.orcidAuth("code"), endpoint: AuthService.ORCID_LOGIN_ENDPOINT },
];
errorCases.forEach(({ name, call, endpoint }) => {
diff --git a/frontend/src/app/common/service/user/auth.service.ts b/frontend/src/app/common/service/user/auth.service.ts
index 722a887f3d..f94dad7ce4 100644
--- a/frontend/src/app/common/service/user/auth.service.ts
+++ b/frontend/src/app/common/service/user/auth.service.ts
@@ -59,6 +59,7 @@ export class AuthService {
public static readonly REFRESH_TOKEN = "auth/refresh";
public static readonly REGISTER_ENDPOINT = "auth/register";
public static readonly GOOGLE_LOGIN_ENDPOINT = "auth/google/login";
+ public static readonly ORCID_LOGIN_ENDPOINT = "auth/orcid/login";
public static readonly SET_EMAIL_ENDPOINT = "auth/email";
public static readonly SET_EMAIL_CODE_ENDPOINT = "auth/email/code";
public static readonly REGISTER_VERIFY_ENDPOINT = "auth/register/verify";
@@ -125,6 +126,26 @@ export class AuthService {
);
}
+ /**
+ * Trades the authorization code from `/callback/orcid` for a Texera token.
+ *
+ * ORCID authenticates an iD without asserting an email, so the account behind this token may have
+ * none — `loginWithExistingToken` asks for one before the email-keyed parts of the product
+ * (dataset paths, access grants) are reachable.
+ */
+ public orcidAuth(code: string): Observable<Readonly<{ accessToken: string }>> {
+ return this.http.post<Readonly<{ accessToken: string }>>(
+ `${AppSettings.getApiEndpoint()}/${AuthService.ORCID_LOGIN_ENDPOINT}`,
+ code,
+ {
+ headers: {
+ "Content-Type": "text/plain",
+ Accept: "application/json",
+ },
+ }
+ );
+ }
+
/** Emits when this service changed the stored token or cleared it itself (see `promptForEmail`). */
public sessionChanged(): Observable<void> {
return this.sessionChangedSubject.asObservable();
diff --git a/frontend/src/app/common/service/user/orcid-auth.service.ts b/frontend/src/app/common/service/user/orcid-auth.service.ts
new file mode 100644
index 0000000000..701b21b238
--- /dev/null
+++ b/frontend/src/app/common/service/user/orcid-auth.service.ts
@@ -0,0 +1,58 @@
+/**
+ * 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.
+ */
+
+import { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { HttpClient } from "@angular/common/http";
+import { AppSettings } from "../../app-setting";
+
+/**
+ * What the login page needs to send the browser to ORCID: the registered client id, the authorize
+ * endpoint of whichever ORCID deployment this backend is configured against (sandbox or
+ * production), and the redirect URI to come back through.
+ *
+ * All three come from the server so none of them can disagree with what the backend uses. That
+ * matters most for `redirectUri`: ORCID requires the value on the authorize leg to match the one
+ * the token exchange sends byte-for-byte, and the exchange sends
+ * `user-sys.orcid.redirectUri`. Deriving it here from `window.location.origin` would give the pair
+ * two owners, and a mismatch only surfaces after the user has already consented.
+ */
+export interface OrcidConfig {
+ clientId: string;
+ authorizeUrl: string;
+ redirectUri: string;
+}
+
+/**
+ * sessionStorage key holding the CSRF `state` value across the ORCID round trip. Written by the
+ * login page before it redirects, read back by the callback page — shared here so the two sides
+ * cannot drift apart.
+ */
+export const ORCID_STATE_KEY = "orcid_state";
+
+@Injectable({
+ providedIn: "root",
+})
+export class OrcidAuthService {
+ constructor(private http: HttpClient) {}
+
+ getConfig(): Observable<OrcidConfig> {
+ return this.http.get<OrcidConfig>(`${AppSettings.getApiEndpoint()}/auth/orcid/config`);
+ }
+}
diff --git a/frontend/src/app/common/service/user/stub-auth.service.ts b/frontend/src/app/common/service/user/stub-auth.service.ts
index cbab3e97bb..2f290ea79e 100644
--- a/frontend/src/app/common/service/user/stub-auth.service.ts
+++ b/frontend/src/app/common/service/user/stub-auth.service.ts
@@ -43,6 +43,10 @@ export const MOCK_INVALID_TOKEN = {
export class StubAuthService implements PublicInterfaceOf<AuthService> {
private readonly reissued = new Subject<void>();
+ orcidAuth(code: string): Observable<Readonly<{ accessToken: string }>> {
+ return of(MOCK_TOKEN);
+ }
+
setEmail(email: string, code?: string): Observable<Readonly<{ accessToken: string }>> {
return of(MOCK_TOKEN);
}
diff --git a/frontend/src/app/common/service/user/stub-user.service.ts b/frontend/src/app/common/service/user/stub-user.service.ts
index 867e0bb3db..d63c2f70dc 100644
--- a/frontend/src/app/common/service/user/stub-user.service.ts
+++ b/frontend/src/app/common/service/user/stub-user.service.ts
@@ -57,6 +57,10 @@ export class StubUserService implements PublicInterfaceOf<UserService> {
throw new Error("Method not implemented.");
}
+ orcidLogin(code: string): Observable<void> {
+ throw new Error("Method not implemented.");
+ }
+
isLogin(): boolean {
return this.user !== undefined;
}
diff --git a/frontend/src/app/common/service/user/user.service.ts b/frontend/src/app/common/service/user/user.service.ts
index 5489f664b4..0654208035 100644
--- a/frontend/src/app/common/service/user/user.service.ts
+++ b/frontend/src/app/common/service/user/user.service.ts
@@ -67,6 +67,10 @@ export class UserService {
.pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken)));
}
+ public orcidLogin(code: string): Observable<void> {
+ return this.authService.orcidAuth(code).pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken)));
+ }
+
public isLogin(): boolean {
return this.currentUser !== undefined;
}
diff --git a/frontend/src/app/common/type/gui-config.ts b/frontend/src/app/common/type/gui-config.ts
index ae3e99a174..68b1d9564c 100644
--- a/frontend/src/app/common/type/gui-config.ts
+++ b/frontend/src/app/common/type/gui-config.ts
@@ -24,6 +24,7 @@ export interface GuiConfig {
selectingFilesFromDatasetsEnabled: boolean;
localLogin: boolean;
googleLogin: boolean;
+ orcidLogin: boolean;
inviteOnly: boolean;
emailVerification: boolean;
userPresetEnabled: boolean;
diff --git a/frontend/src/app/hub/component/login/orcid-callback.component.spec.ts b/frontend/src/app/hub/component/login/orcid-callback.component.spec.ts
new file mode 100644
index 0000000000..f38afd4cc3
--- /dev/null
+++ b/frontend/src/app/hub/component/login/orcid-callback.component.spec.ts
@@ -0,0 +1,269 @@
+/**
+ * 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.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ActivatedRoute, convertToParamMap, Router } from "@angular/router";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { HttpErrorResponse } from "@angular/common/http";
+import { of, ReplaySubject, throwError } from "rxjs";
+import { vi } from "vitest";
+
+import { OrcidCallbackComponent } from "./orcid-callback.component";
+import { UserService } from "../../../common/service/user/user.service";
+import { User } from "../../../common/type/user";
+import { NotificationService } from "../../../common/service/notification/notification.service";
+import { ORCID_STATE_KEY } from "../../../common/service/user/orcid-auth.service";
+import { commonTestProviders } from "../../../common/testing/test-utils";
+import { LOGIN, USER_WORKFLOW } from "../../../app-routing.constant";
+
+/**
+ * The callback page has no interaction: everything it does happens in ngOnInit, and the only
+ * observable outcomes are which URL it navigates to and whether the code reached the exchange.
+ * The `state` cases are the point of most of this — that value is the flow's CSRF protection, so a
+ * missing or mismatched one must never reach `orcidLogin`.
+ */
+describe("OrcidCallbackComponent", () => {
+ let fixture: ComponentFixture<OrcidCallbackComponent>;
+ let userServiceMock: {
+ orcidLogin: ReturnType<typeof vi.fn>;
+ isLogin: ReturnType<typeof vi.fn>;
+ userChanged: () => ReplaySubject<User | undefined>;
+ };
+ /** Stands in for `UserService`'s own subject, replaying the current user the way it does. */
+ let userChangedSubject: ReplaySubject<User | undefined>;
+ let notificationServiceMock: { error: ReturnType<typeof vi.fn> };
+ let routerMock: { navigateByUrl: ReturnType<typeof vi.fn> };
+
+ const STATE = "state-abc";
+
+ /** Builds the component with `queryParams` in the URL and `storedState` in sessionStorage. */
+ const createComponent = async (
+ queryParams: Record<string, string>,
+ storedState: string | null = STATE,
+ orcidLogin = vi.fn().mockReturnValue(of(undefined)),
+ signedIn = true
+ ) => {
+ TestBed.resetTestingModule();
+ sessionStorage.clear();
+ if (storedState !== null) {
+ sessionStorage.setItem(ORCID_STATE_KEY, storedState);
+ }
+
+ userChangedSubject = new ReplaySubject<User | undefined>(1);
+ // `handleAccessToken` publishes the session state before `orcidLogin` completes, so there is
+ // always a current value to replay: the user for an address-carrying token, nothing while the
+ // address prompt is open.
+ userChangedSubject.next(signedIn ? ({ uid: 1 } as User) : undefined);
+ userServiceMock = {
+ orcidLogin,
+ isLogin: vi.fn().mockReturnValue(signedIn),
+ userChanged: () => userChangedSubject,
+ };
+ notificationServiceMock = { error: vi.fn() };
+ routerMock = { navigateByUrl: vi.fn() };
+
+ await TestBed.configureTestingModule({
+ imports: [OrcidCallbackComponent, HttpClientTestingModule],
+ providers: [
+ { provide: UserService, useValue: userServiceMock },
+ { provide: NotificationService, useValue: notificationServiceMock },
+ { provide: Router, useValue: routerMock },
+ {
+ provide: ActivatedRoute,
+ useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } },
+ },
+ ...commonTestProviders,
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(OrcidCallbackComponent);
+ fixture.detectChanges();
+ };
+
+ afterEach(() => sessionStorage.clear());
+
+ // ─── the happy path ───────────────────────────────────────────────────────
+
+ it("exchanges the code and lands the user in the dashboard", async () => {
+ await createComponent({ code: "auth-code", state: STATE });
+
+ expect(userServiceMock.orcidLogin).toHaveBeenCalledWith("auth-code");
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW);
+ expect(notificationServiceMock.error).not.toHaveBeenCalled();
+ });
+
+ // Good for exactly one round trip: a leftover key would let a later callback verify against a
+ // state nobody is waiting on.
+ it("clears the stored state once it has been read", async () => {
+ await createComponent({ code: "auth-code", state: STATE });
+
+ expect(sessionStorage.getItem(ORCID_STATE_KEY)).toBeNull();
+ });
+
+ // ─── state verification ───────────────────────────────────────────────────
+
+ it("refuses a state that does not match the one it stored", async () => {
+ await createComponent({ code: "auth-code", state: "not-the-one" });
+
+ expect(userServiceMock.orcidLogin).not.toHaveBeenCalled();
+ expect(notificationServiceMock.error).toHaveBeenCalled();
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ });
+
+ it("refuses a callback carrying no state at all", async () => {
+ await createComponent({ code: "auth-code" });
+
+ expect(userServiceMock.orcidLogin).not.toHaveBeenCalled();
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ });
+
+ // Nothing stored means this browser did not start a sign-in: a bookmark, a stale tab, or a code
+ // planted by someone else.
+ it("refuses when it never stored a state to compare against", async () => {
+ await createComponent({ code: "auth-code", state: STATE }, null);
+
+ expect(userServiceMock.orcidLogin).not.toHaveBeenCalled();
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ });
+
+ // ─── what ORCID sends back instead of a code ──────────────────────────────
+
+ it("reports the description when ORCID returns a correlated error", async () => {
+ await createComponent({ error: "access_denied", error_description: "The user denied access", state: STATE });
+
+ expect(userServiceMock.orcidLogin).not.toHaveBeenCalled();
+ expect(notificationServiceMock.error).toHaveBeenCalledWith("The user denied access");
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ });
+
+ it("falls back to a generic message when ORCID's error carries no description", async () => {
+ await createComponent({ error: "access_denied", state: STATE });
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith("ORCID sign-in was not completed");
+ });
+
+ // An error response carries `state` too (RFC 6749 §4.1.2.1), so one that does not correlate is
+ // not ORCID answering this browser — it is a planted link, and its `error_description` must not
+ // be repeated back as Texera's own message.
+ it("refuses an uncorrelated error instead of reporting its description", async () => {
+ await createComponent({
+ error: "access_denied",
+ error_description: "Visit https://evil.example to re-authorize",
+ state: "not-the-one",
+ });
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith(
+ "ORCID sign-in could not be verified. Please try again."
+ );
+ expect(notificationServiceMock.error).not.toHaveBeenCalledWith("Visit https://evil.example to re-authorize");
+ expect(userServiceMock.orcidLogin).not.toHaveBeenCalled();
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ });
+
+ it("refuses an error response carrying no state at all", async () => {
+ await createComponent({ error: "access_denied", error_description: "planted" });
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith(
+ "ORCID sign-in could not be verified. Please try again."
+ );
+ expect(notificationServiceMock.error).not.toHaveBeenCalledWith("planted");
+ });
+
+ it("refuses a verified callback that carries no code", async () => {
+ await createComponent({ state: STATE });
+
+ expect(userServiceMock.orcidLogin).not.toHaveBeenCalled();
+ expect(notificationServiceMock.error).toHaveBeenCalledWith("ORCID sign-in was not completed");
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ });
+
+ // ─── a failed exchange ────────────────────────────────────────────────────
+
+ // The backend's message is in `error.message`; HttpErrorResponse.message is Angular's generated
+ // "Http failure response for …" developer string, which must not reach the visitor.
+ it("sends the user back to the login page with the backend's message when the exchange fails", async () => {
+ const failing = vi.fn().mockReturnValue(
+ throwError(
+ () =>
+ new HttpErrorResponse({
+ status: 401,
+ statusText: "Unauthorized",
+ error: { message: "Login credentials are incorrect." },
+ })
+ )
+ );
+ await createComponent({ code: "auth-code", state: STATE }, STATE, failing);
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith("Login credentials are incorrect.");
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ expect(routerMock.navigateByUrl).not.toHaveBeenCalledWith(USER_WORKFLOW);
+ });
+
+ // An ORCID token carries no email, so the session is not resolved when `orcidLogin` completes:
+ // `loginWithExistingToken` has opened the address prompt and handed back no user. Navigating then
+ // would hit AuthGuardService and land the user on /login behind the still-open dialog.
+ describe("when the address prompt is still open", () => {
+ const openPrompt = (queryParams = { code: "auth-code", state: STATE }) =>
+ createComponent(queryParams, STATE, vi.fn().mockReturnValue(of(undefined)), false);
+
+ it("stays put rather than navigating into the auth guard", async () => {
+ await openPrompt();
+
+ expect(userServiceMock.orcidLogin).toHaveBeenCalledWith("auth-code");
+ expect(routerMock.navigateByUrl).not.toHaveBeenCalled();
+ });
+
+ it("goes to the dashboard once the answered prompt produces a user", async () => {
+ await openPrompt();
+
+ userChangedSubject.next({ uid: 7 } as User);
+
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW);
+ });
+
+ // Cancelling the dialog signs out, which republishes an undefined user.
+ it("returns to the login page if the prompt is cancelled", async () => {
+ await openPrompt();
+
+ userChangedSubject.next(undefined);
+
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(LOGIN, { replaceUrl: true });
+ expect(routerMock.navigateByUrl).not.toHaveBeenCalledWith(USER_WORKFLOW);
+ });
+
+ it("routes on the outcome only once", async () => {
+ await openPrompt();
+
+ userChangedSubject.next({ uid: 7 } as User);
+ userChangedSubject.next(undefined);
+
+ expect(routerMock.navigateByUrl).toHaveBeenCalledTimes(1);
+ expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW);
+ });
+ });
+
+ it("falls back to a generic message when the failure carries none", async () => {
+ const failing = vi
+ .fn()
+ .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500, statusText: "Server Error" })));
+ await createComponent({ code: "auth-code", state: STATE }, STATE, failing);
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith("ORCID sign-in failed");
+ });
+});
diff --git a/frontend/src/app/hub/component/login/orcid-callback.component.ts b/frontend/src/app/hub/component/login/orcid-callback.component.ts
new file mode 100644
index 0000000000..cf8b8b622f
--- /dev/null
+++ b/frontend/src/app/hub/component/login/orcid-callback.component.ts
@@ -0,0 +1,150 @@
+/**
+ * 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.
+ */
+import { HttpErrorResponse } from "@angular/common/http";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { Component, OnInit } from "@angular/core";
+import { ActivatedRoute, Router } from "@angular/router";
+import { catchError, skip, take } from "rxjs/operators";
+import { EMPTY } from "rxjs";
+import { NzSpinComponent } from "ng-zorro-antd/spin";
+import { UserService } from "../../../common/service/user/user.service";
+import { NotificationService } from "../../../common/service/notification/notification.service";
+import { ORCID_STATE_KEY } from "../../../common/service/user/orcid-auth.service";
+import { LOGIN, USER_WORKFLOW } from "../../../app-routing.constant";
+
+/**
+ * Where ORCID sends the browser back to after its consent screen, carrying the one-time `code`
+ * that only the backend can redeem (see `OrcidAuthResource`). Nothing here is interactive: it
+ * checks the round trip was one we started, hands the code over, and leaves.
+ */
+@UntilDestroy()
+@Component({
+ selector: "texera-orcid-callback",
+ template: `
+ <div class="orcid-callback">
+ <nz-spin nzSimple></nz-spin>
+ <p>Signing you in with ORCID…</p>
+ </div>
+ `,
+ styles: [
+ `
+ .orcid-callback {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ height: 100vh;
+ }
+ `,
+ ],
+ imports: [NzSpinComponent],
+})
+export class OrcidCallbackComponent implements OnInit {
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private userService: UserService,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnInit(): void {
+ const params = this.route.snapshot.queryParamMap;
+
+ const expectedState = sessionStorage.getItem(ORCID_STATE_KEY);
+
+ // Good for one round trip only: a leftover value would let an unrelated callback pass the check below.
+ sessionStorage.removeItem(ORCID_STATE_KEY);
+
+ // Verified before anything in the URL is acted on, the provider's error response included.
+ // RFC 6749 §4.1.2.1 has the authorization server echo `state` back on the error response for
+ // exactly this correlation, so an error that cannot be correlated is not ORCID answering this
+ // browser's sign-in. Acting on it first would let a bare link to
+ // `/callback/orcid?error=…&error_description=…` discard whatever state this browsing context
+ // was holding and render an attacker's text as Texera's own error — and ng-zorro's message
+ // service renders through `[innerHTML]`, where Angular's sanitizer keeps `<a href>`, so that
+ // text can carry a live link on the login page.
+ const state = params.get("state");
+ if (expectedState === null || state !== expectedState) {
+ this.failBackToLogin("ORCID sign-in could not be verified. Please try again.");
+ return;
+ }
+
+ const error = params.get("error");
+ if (error !== null) {
+ this.failBackToLogin(params.get("error_description") ?? "ORCID sign-in was not completed");
+ return;
+ }
+
+ const code = params.get("code");
+ if (code === null) {
+ this.failBackToLogin("ORCID sign-in was not completed");
+ return;
+ }
+
+ this.userService
+ .orcidLogin(code)
+ .pipe(
+ catchError((e: unknown) => {
+ const failure = e as HttpErrorResponse;
+ this.failBackToLogin(failure?.error?.message || "ORCID sign-in failed");
+ return EMPTY;
+ }),
+ untilDestroyed(this)
+ )
+ .subscribe(() => this.navigateOnceSignedIn());
+ }
+
+ /**
+ * Leave this page only once the session has actually resolved.
+ *
+ * An ORCID token carries no email, so `loginWithExistingToken` opens the address prompt and hands
+ * back no user. Navigating on the strength of `orcidLogin` merely completing would meet
+ * `AuthGuardService`, which redirects an unauthenticated caller to `/login` — leaving the user
+ * stranded there behind the dialog even after answering it, since nothing navigates again.
+ *
+ * Answering the prompt reissues a token and produces a user; cancelling it signs out. Either way
+ * the next `userChanged` emission is the outcome worth routing on. The replayed current value is
+ * skipped because `UserService.handleAccessToken` has already published it by the time we get
+ * here — it is the unresolved state, not an outcome.
+ *
+ * Google never needed this: its token always carries an address, so the first
+ * `loginWithExistingToken` returns a user.
+ */
+ private navigateOnceSignedIn(): void {
+ if (this.userService.isLogin()) {
+ this.router.navigateByUrl(USER_WORKFLOW);
+ return;
+ }
+
+ this.userService
+ .userChanged()
+ .pipe(skip(1), take(1), untilDestroyed(this))
+ .subscribe(user =>
+ user === undefined
+ ? this.router.navigateByUrl(LOGIN, { replaceUrl: true })
+ : this.router.navigateByUrl(USER_WORKFLOW)
+ );
+ }
+
+ private failBackToLogin(message: string): void {
+ this.notificationService.error(message);
+ this.router.navigateByUrl(LOGIN, { replaceUrl: true });
+ }
+}
diff --git a/frontend/src/app/hub/component/login/texera-login.component.html b/frontend/src/app/hub/component/login/texera-login.component.html
index 7a7a95fce0..1db45f6525 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.html
+++ b/frontend/src/app/hub/component/login/texera-login.component.html
@@ -47,10 +47,22 @@
size="large"
[width]="328"></asl-google-signin-button>
</div>
+ } @if (config.env.orcidLogin){
+ <button
+ class="orcid-login"
+ nz-button
+ [disabled]="!orcidConfig"
+ (click)="orcidLogin()">
+ <img
+ src="assets/logos/ORCID-iD_icon_24x24.png"
+ alt=""
+ class="orcid-icon" />
+ <span class="orcid-label">Continue with ORCID</span>
+ </button>
}
</div>
- @if (config.env.localLogin && config.env.googleLogin) {
+ @if (config.env.localLogin && (config.env.googleLogin || config.env.orcidLogin)) {
<nz-divider
nzPlain
nzText="or continue with"></nz-divider>
diff --git a/frontend/src/app/hub/component/login/texera-login.component.scss b/frontend/src/app/hub/component/login/texera-login.component.scss
index bd9c8ef779..b82535e133 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.scss
+++ b/frontend/src/app/hub/component/login/texera-login.component.scss
@@ -104,6 +104,39 @@ $text-secondary: rgba(0, 0, 0, 0.45);
margin: -6px 2px 0;
}
+.orcid-login {
+ display: flex;
+ align-items: center;
+ padding-inline: 12px;
+ width: 328px;
+ height: 40px;
+ border-radius: 4px;
+ gap: 6px;
+ transition-duration: 0.15s;
+
+ .orcid-icon {
+ width: 20px;
+ height: 20px;
+ flex: none;
+ }
+
+ .orcid-label {
+ flex: 1;
+ text-align: center;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: rgba(217, 217, 250, 0.3);
+ border-color: #d9d9d9;
+ color: rgba(0, 0, 0, 0.88);
+ transition-duration: 0.15s;
+ }
+
+ &:active {
+ background-color: rgba(0, 0, 0, 0.08);
+ }
+}
.foot {
text-align: center;
font-size: 13px;
diff --git a/frontend/src/app/hub/component/login/texera-login.component.spec.ts b/frontend/src/app/hub/component/login/texera-login.component.spec.ts
index 8c1a6c2748..ff461ca6ea 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.spec.ts
+++ b/frontend/src/app/hub/component/login/texera-login.component.spec.ts
@@ -19,7 +19,7 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, ActivatedRouteSnapshot, Router } from "@angular/router";
-import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { EMPTY, Subject, of, throwError } from "rxjs";
import { SocialAuthService, SocialUser } from "@abacritt/angularx-social-login";
import { vi } from "vitest";
@@ -31,6 +31,7 @@ import { GuiConfigService } from "../../../common/service/gui-config.service";
import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock";
import { commonTestProviders } from "../../../common/testing/test-utils";
import { USER_WORKFLOW } from "../../../app-routing.constant";
+import { ORCID_STATE_KEY } from "../../../common/service/user/orcid-auth.service";
import { By } from "@angular/platform-browser";
import { NzIconDirective } from "ng-zorro-antd/icon";
import { NzTabsComponent } from "ng-zorro-antd/tabs";
@@ -427,14 +428,113 @@ describe("TexeraLoginComponent", () => {
});
});
+ // ORCID is authorization-code OAuth, so this page's whole job is the redirect: everything after
+ // it happens on /callback/orcid and in the backend. What matters here is that the redirect is
+ // well formed and that the `state` the callback verifies actually gets stashed first.
+ describe("orcid sign-in", () => {
+ const ORCID_CONFIG = {
+ clientId: "APP-123",
+ authorizeUrl: "https://sandbox.orcid.org/oauth/authorize",
+ redirectUri: "https://texera.example/callback/orcid",
+ };
+
+ /**
+ * Swaps window.location for the duration of `run`, per the pattern in app.component.spec.ts.
+ * `href` is where the redirect lands; the `origin` deliberately differs from
+ * `ORCID_CONFIG.redirectUri`, so deriving `redirect_uri` from the browser again would fail.
+ */
+ const withStubbedLocation = (run: (location: { origin: string; href: string }) => void) => {
+ const original = window.location;
+ const stub = { ...original, origin: "http://127.0.0.1:4200", href: "" } as unknown as {
+ origin: string;
+ href: string;
+ };
+ Object.defineProperty(window, "location", { configurable: true, value: stub });
+ try {
+ run(stub);
+ } finally {
+ Object.defineProperty(window, "location", { configurable: true, value: original });
+ }
+ };
+
+ /** Answers the config fetch ngOnInit issues, which is what enables the button. */
+ const flushOrcidConfig = (
+ body: Record<string, string> | string = ORCID_CONFIG,
+ status?: { status: number; statusText: string }
+ ) => {
+ const httpMock = TestBed.inject(HttpTestingController);
+ const req = httpMock.expectOne(r => r.url.endsWith("/auth/orcid/config"));
+ if (status) {
+ req.flush(body, status);
+ } else {
+ req.flush(body);
+ }
+ };
+
+ beforeEach(() => {
+ sessionStorage.clear();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => sessionStorage.clear());
+
+ it("redirects to ORCID with the server's client id and redirect uri, and a fresh state", () => {
+ flushOrcidConfig();
+
+ withStubbedLocation(location => {
+ (component as any).orcidLogin();
+
+ const url = new URL(location.href);
+ expect(`${url.origin}${url.pathname}`).toBe(ORCID_CONFIG.authorizeUrl);
+ expect(url.searchParams.get("client_id")).toBe("APP-123");
+ expect(url.searchParams.get("response_type")).toBe("code");
+ expect(url.searchParams.get("scope")).toBe("/authenticate");
+ // Served by the backend, which sends the same value on the token exchange — not derived
+ // from `window.location.origin`, which ORCID would reject as a mismatch.
+ expect(url.searchParams.get("redirect_uri")).toBe(ORCID_CONFIG.redirectUri);
+ // The callback compares this against what comes back; it has to be stored before leaving.
+ expect(url.searchParams.get("state")).toBe(sessionStorage.getItem(ORCID_STATE_KEY));
+ expect(sessionStorage.getItem(ORCID_STATE_KEY)).toBeTruthy();
+ });
+ });
+
+ it("uses a different state on each attempt", () => {
+ flushOrcidConfig();
+
+ const states: Array<string | null> = [];
+ withStubbedLocation(() => {
+ (component as any).orcidLogin();
+ states.push(sessionStorage.getItem(ORCID_STATE_KEY));
+ (component as any).orcidLogin();
+ states.push(sessionStorage.getItem(ORCID_STATE_KEY));
+ });
+
+ expect(states[0]).not.toBe(states[1]);
+ });
+
+ // A deployment with no ORCID credentials reports the provider unavailable, which leaves the
+ // button disabled — clicking it anyway must not send the user to a broken authorize URL.
+ it("reports unavailable and does not redirect when the config fetch failed", () => {
+ flushOrcidConfig("nope", { status: 503, statusText: "Service Unavailable" });
+
+ withStubbedLocation(location => {
+ (component as any).orcidLogin();
+
+ expect(notificationServiceMock.error).toHaveBeenCalledWith("ORCID sign-in is unavailable");
+ expect(location.href).toBe("");
+ expect(sessionStorage.getItem(ORCID_STATE_KEY)).toBeNull();
+ });
+ });
+ });
+
// ──────────────────────────────────────────────────────────────────────────
// Template rendering
//
- // The suite above drives the class; these render the card. Each test sets both
- // provider flags explicitly so nothing is inherited from the mock's defaults.
+ // The suite above drives the class; these render the card. Each test sets every
+ // provider flag explicitly so nothing is inherited from the mock's defaults.
// ──────────────────────────────────────────────────────────────────────────
describe("template", () => {
- function render(flags: { localLogin: boolean; googleLogin: boolean }): void {
+ function render(flags: { localLogin: boolean; googleLogin: boolean; orcidLogin: boolean }): void {
(TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig(flags);
fixture.detectChanges();
}
@@ -449,41 +549,68 @@ describe("TexeraLoginComponent", () => {
const iconTypes = (): string[] =>
passwordIcons().map(icon => (icon.injector.get(NzIconDirective) as unknown as { type: string }).type);
+ const orcidButton = (): HTMLElement | null => host().querySelector("button.orcid-login");
+
describe("provider flags", () => {
- it("renders the local form and the google button when both are enabled", () => {
- render({ localLogin: true, googleLogin: true });
+ it("renders the local form and both social buttons when all are enabled", () => {
+ render({ localLogin: true, googleLogin: true, orcidLogin: true });
expect(host().querySelector("nz-tabs")).toBeTruthy();
expect(host().querySelector("form")).toBeTruthy();
expect(host().querySelector("asl-google-signin-button")).toBeTruthy();
+ expect(orcidButton()).toBeTruthy();
// The "or continue with" divider only makes sense when both are offered.
expect(host().querySelector("nz-divider")).toBeTruthy();
});
- it("drops the google button but keeps the form when only local login is enabled", () => {
- render({ localLogin: true, googleLogin: false });
+ it("drops both social buttons but keeps the form when only local login is enabled", () => {
+ render({ localLogin: true, googleLogin: false, orcidLogin: false });
expect(host().querySelector("nz-tabs")).toBeTruthy();
expect(host().querySelector("form")).toBeTruthy();
expect(host().querySelector("asl-google-signin-button")).toBeNull();
+ expect(orcidButton()).toBeNull();
expect(host().querySelector("nz-divider")).toBeNull();
});
it("drops the tabs and the form but keeps the google button when only google is enabled", () => {
- render({ localLogin: false, googleLogin: true });
+ render({ localLogin: false, googleLogin: true, orcidLogin: false });
expect(host().querySelector("nz-tabs")).toBeNull();
expect(host().querySelector("form")).toBeNull();
expect(host().querySelector("asl-google-signin-button")).toBeTruthy();
+ expect(orcidButton()).toBeNull();
+ expect(host().querySelector("nz-divider")).toBeNull();
+ });
+
+ // ORCID carries the divider on its own: it is a second way to "continue with"
+ // something other than the local form, so the label still reads correctly.
+ it("keeps the orcid button and the divider when google is disabled but orcid is not", () => {
+ render({ localLogin: true, googleLogin: false, orcidLogin: true });
+
+ expect(host().querySelector("form")).toBeTruthy();
+ expect(host().querySelector("asl-google-signin-button")).toBeNull();
+ expect(orcidButton()).toBeTruthy();
+ expect(host().querySelector("nz-divider")).toBeTruthy();
+ });
+
+ it("drops the tabs and the form but keeps the orcid button when only orcid is enabled", () => {
+ render({ localLogin: false, googleLogin: false, orcidLogin: true });
+
+ expect(host().querySelector("nz-tabs")).toBeNull();
+ expect(host().querySelector("form")).toBeNull();
+ expect(host().querySelector("asl-google-signin-button")).toBeNull();
+ expect(orcidButton()).toBeTruthy();
expect(host().querySelector("nz-divider")).toBeNull();
});
- it("renders neither sign-in path when both are disabled", () => {
- render({ localLogin: false, googleLogin: false });
+ it("renders no sign-in path when every provider is disabled", () => {
+ render({ localLogin: false, googleLogin: false, orcidLogin: false });
expect(host().querySelector("nz-tabs")).toBeNull();
expect(host().querySelector("form")).toBeNull();
expect(host().querySelector("asl-google-signin-button")).toBeNull();
+ expect(orcidButton()).toBeNull();
expect(host().querySelector("nz-divider")).toBeNull();
// The brand and footer are outside every flag, so the card is never empty.
expect(host().querySelector(".brand")).toBeTruthy();
@@ -492,7 +619,7 @@ describe("TexeraLoginComponent", () => {
});
describe("sign-in / sign-up mode", () => {
- beforeEach(() => render({ localLogin: true, googleLogin: true }));
+ beforeEach(() => render({ localLogin: true, googleLogin: true, orcidLogin: true }));
it("shows only the sign-in fields by default", () => {
expect(component.mode).toBe("signin");
@@ -552,7 +679,7 @@ describe("TexeraLoginComponent", () => {
describe("password visibility", () => {
beforeEach(() => {
- render({ localLogin: true, googleLogin: true });
+ render({ localLogin: true, googleLogin: true, orcidLogin: true });
component.setMode("signup");
fixture.detectChanges();
});
diff --git a/frontend/src/app/hub/component/login/texera-login.component.ts b/frontend/src/app/hub/component/login/texera-login.component.ts
index 581f6f433f..5d5af50c97 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.ts
+++ b/frontend/src/app/hub/component/login/texera-login.component.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { HttpErrorResponse } from "@angular/common/http";
import { Component, NgZone, OnInit } from "@angular/core";
import {
AbstractControl,
@@ -29,8 +30,7 @@ import {
} from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { catchError, filter } from "rxjs/operators";
-import { throwError } from "rxjs";
-import { HttpErrorResponse } from "@angular/common/http";
+import { EMPTY, throwError } from "rxjs";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { SocialAuthService, GoogleSigninButtonModule, SocialUser } from "@abacritt/angularx-social-login";
import { UserService } from "../../../common/service/user/user.service";
@@ -43,6 +43,7 @@ import { NzInputDirective, NzInputGroupComponent, NzInputGroupWhitSuffixOrPrefix
import { NzButtonComponent } from "ng-zorro-antd/button";
import { NzDividerComponent } from "ng-zorro-antd/divider";
import { NzTypographyComponent } from "ng-zorro-antd/typography";
+import { ORCID_STATE_KEY, OrcidAuthService, OrcidConfig } from "../../../common/service/user/orcid-auth.service";
/**
* The reason a failed call carries, preferring the server's own words.
@@ -93,9 +94,12 @@ export class TexeraLoginComponent implements OnInit {
public mode: LoginMode = "signin";
public passwordVisible = false;
public errorMessage: string | undefined;
-
public form: FormGroup;
+ // Undefined until the fetch in ngOnInit lands; the ORCID button stays disabled until then.
+ // Protected rather than private because the template reads it for that disabled binding.
+ protected orcidConfig: OrcidConfig | undefined;
+
constructor(
private formBuilder: FormBuilder,
private userService: UserService,
@@ -104,6 +108,7 @@ export class TexeraLoginComponent implements OnInit {
private router: Router,
private ngZone: NgZone,
private socialAuthService: SocialAuthService,
+ private orcidAuthService: OrcidAuthService,
protected config: GuiConfigService
) {
this.form = this.formBuilder.group({
@@ -134,6 +139,26 @@ export class TexeraLoginComponent implements OnInit {
});
}
+ // Fetched up front rather than on click so the redirect is instant; until it arrives the
+ // button is disabled. EMPTY rather than a rethrow because this is background setup with no
+ // caller to propagate to — a failure leaves the button disabled, which fails safe.
+ if (this.config.env.orcidLogin) {
+ this.orcidAuthService
+ .getConfig()
+ .pipe(
+ catchError((err: unknown) => {
+ if ((err as HttpErrorResponse)?.status !== 503) {
+ this.notificationService.error("ORCID sign-in is unavailable");
+ }
+ return EMPTY;
+ }),
+ untilDestroyed(this)
+ )
+ .subscribe(orcidConfig => {
+ this.orcidConfig = orcidConfig;
+ });
+ }
+
// Google emits the signed-in user here after its own button completes the flow.
// The null filter matters: logging out pushes null through this subject, and it is a
// ReplaySubject, so that stale null is replayed into this subscription the moment it starts.
@@ -319,4 +344,36 @@ export class TexeraLoginComponent implements OnInit {
}
return null;
};
+
+ /**
+ * Hand the browser to ORCID's consent screen. Unlike Google — whose SDK runs the whole
+ * handshake in a popup and emits a token — ORCID is plain authorization-code OAuth, so this
+ * leaves the app entirely and comes back at `/callback/orcid` with a `code` to exchange.
+ *
+ * Every value in the authorize URL comes from `/auth/orcid/config`, `redirect_uri` included:
+ * the backend sends its own configured redirect URI on the token exchange, and ORCID rejects
+ * the exchange unless the two match byte-for-byte. See [[OrcidConfig]].
+ */
+ protected orcidLogin(): void {
+ // Unreachable while the template keeps the button disabled, but the narrowing is needed
+ // regardless, and the guard outlives whoever might drop that binding later.
+ const config = this.orcidConfig;
+ if (!config) {
+ this.notificationService.error("ORCID sign-in is unavailable");
+ return;
+ }
+
+ const state = crypto.randomUUID();
+ sessionStorage.setItem(ORCID_STATE_KEY, state);
+
+ const params = new URLSearchParams({
+ client_id: config.clientId,
+ response_type: "code",
+ scope: "/authenticate",
+ redirect_uri: config.redirectUri,
+ state,
+ });
+
+ window.location.href = `${config.authorizeUrl}?${params}`;
+ }
}
diff --git a/frontend/src/assets/logos/ORCID-iD_icon_24x24.png b/frontend/src/assets/logos/ORCID-iD_icon_24x24.png
new file mode 100644
index 0000000000..4447d46283
Binary files /dev/null and b/frontend/src/assets/logos/ORCID-iD_icon_24x24.png differ
diff --git a/licenses/LICENSE-CC0-1.0.txt b/licenses/LICENSE-CC0-1.0.txt
new file mode 100644
index 0000000000..0e259d42c9
--- /dev/null
+++ b/licenses/LICENSE-CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 340e6190b3..4b59671aa5 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -139,6 +139,11 @@
<sqlFile path="sql/updates/45.sql"/>
</changeSet>
+ <!-- Allow ORCID as an identity provider in auth_provider.provider_type -->
+ <changeSet id="46" author="Neilk1021">
+ <sqlFile path="sql/updates/46.sql"/>
+ </changeSet>
+
<!-- example changeSet
<changeSet id="1" author="author">
<sqlFile path="sql/updates/1.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index fec701db2c..d0e5ad8050 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -97,7 +97,7 @@ CREATE TYPE user_role_enum AS ENUM ('INACTIVE', 'RESTRICTED', 'REGULAR', 'ADMIN'
CREATE TYPE action_enum AS ENUM ('like', 'unlike', 'view', 'clone');
CREATE TYPE privilege_enum AS ENUM ('NONE', 'READ', 'WRITE');
CREATE TYPE workflow_computing_unit_type_enum AS ENUM ('local', 'kubernetes');
-CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE');
+CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE', 'ORCID');
CREATE TYPE user_warehouse_flavor_enum AS ENUM ('local', 'aws');
CREATE TYPE default_view_enum AS ENUM ('CANVAS', 'FORM');
diff --git a/sql/updates/46.sql b/sql/updates/46.sql
new file mode 100644
index 0000000000..45283f20c3
--- /dev/null
+++ b/sql/updates/46.sql
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+-- Allow ORCID as an identity provider in auth_provider.provider_type.
+--
+-- ORCID is authorization-code OAuth rather than Google's id-token flow, but the identity it
+-- yields lands in the same place: one auth_provider row whose provider_id is the ORCID iD.
+--
+-- The type is schema-qualified because the two runners disagree about the search path: the
+-- liquibase runner in sql/docker-compose.yml strips `SET search_path` out of these files before
+-- applying them, while bin/local-dev.sh keeps it.
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+ALTER TYPE texera_db.provider_type_enum ADD VALUE IF NOT EXISTS 'ORCID';
+
+COMMIT;
\ No newline at end of file