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 <[email protected]>
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 
<[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
    Co-authored-by: Yicong Huang 
<[email protected]>
---
 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("[email protected]")
+    userDao.update(user)
+
+    new StubbedOrcidAuthResource(tokenBody()).login("c")
+
+    userBehind(orcidId).getEmail shouldBe "[email protected]"
+  }
+
+  // ---- 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

Reply via email to