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-8314-dcec7f3601e61436857a619365f7a2c5963c7589
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 48e6dad8d9c70695e9125e49ce858753f9c7675a
Author: Neil Ketteringham <[email protected]>
AuthorDate: Tue Sep 15 05:17:32 2026 +0000

    feat(auth): add Sign in with Apple (#8314)
    
    ### What changes were proposed in this PR?
    
    
    
https://github.com/user-attachments/assets/4a3434d3-4413-4115-bf7b-4a37e345f377
    
    Adds Sign in with Apple as a third identity provider alongside local and
    Google sign-in, following the shape #7664 established for ORCID: one
    more button in the login card's `social-buttons` block, one more
    provider resource on the backend, and no new login surface.
    
    Off by default. `gui.login.apple-login` gates the button, and the flow
    needs a Services ID (`user-sys.apple.clientId`) that only an operator
    can supply, so a plain checkout is unaffected.
    
    **Backend.** `AppleAuthResource` under `/auth/apple`:
    
    - `GET /clientid` mirrors `GoogleAuthResource`, so the Services ID never
    lands in a config payload.
    - `POST /login` takes the raw identity token as `text/plain` and
    verifies it against Apple's published JWKS, pinning the issuer, the
    audience to our Services ID, and RS256. A malformed or unverifiable
    credential becomes a 401 rather than escaping as a 500.
    
    Provisioning reuses `ExternalAuthProvisioner`, extended here to accept
    an identity carrying no email address. Three Apple-specific details
    drove that:
    
    - **`sub` is the provider id.** It is stable per user but scoped to the
    Apple developer *team* — transferring the app rotates it for every user,
    and `transfer_sub` is available for only 60 days after such a move.
    Worth knowing before any team transfer.
    - **An unverified address is refused; an absent one is not.** Mapping an
    address Apple did not verify onto an existing account would be a
    takeover. But Apple omits `email` entirely for Sign in with Apple at
    Work & School accounts, so refusing on absence would lock those users
    out of a provider the deployment has enabled. They are provisioned
    identity-only and asked for an address once inside.
    - **`email_verified` arrives as either a JSON boolean or a quoted
    string**, and Apple documents both shapes. Reading only one silently
    yields `false`, which rejects legitimate logins, so `booleanClaim`
    handles both.
    
    Apple sends the display name only on a user's first authorization,
    outside the identity token, so it never reaches this endpoint and would
    be unsigned and untrusted anyway. The address stands in for it, as it
    already does for a Google account with no name, and `sub` stands in when
    there is no address either. Apple supplies no avatar.
    
    **Frontend.** `AppleAuthService` injects `appleid.auth.js` from Apple's
    CDN on first use and runs the popup flow with `usePopup: true`, which
    keeps the identity token in the page and stops Apple posting a form to
    `redirectURI` and navigating away from the SPA. The script is fetched on
    click, so a visitor who only uses the password form never calls Apple at
    all. `appleLogin` joins the anonymous `/config/pre-login` payload, since
    the login page has to decide whether to draw the button before anyone is
    signed in.
    
    The button itself is a plain `<button>` carrying Apple's logo asset and
    a "Continue with Apple" label, sized and aligned to sit flush with the
    Google button (328px wide, 14px label, a height no smaller than
    Google's, and the label centred as in `.orcid-login`). Apple's SDK can
    render its own button, but it derives both the type size and the logo
    width from the button height, so it cannot be matched to the Google
    button beside it; Apple permits a custom button provided it uses their
    artwork and one of their three approved titles.
    
    **Database.** `sql/updates/43.sql` adds `APPLE` to
    `auth_provider.provider_type`. Nothing inserts an `APPLE` row in the
    same transaction, because Postgres forbids using a new enum value in the
    transaction that adds it — the value is only declared, and the first
    Apple login writes it.
    
    Three things a reviewer should weigh in on:
    
    - `frontend/angular.json` carries my ngrok host in `allowedHosts`. It
    affects only `ng serve`, but it is developer-local config in a shared
    file and should move behind a local override before this merges.
    - `frontend/.well-known/.gitkeep` is a placeholder; a deployment
    enabling Apple has to supply Apple's real domain-association file there.
    - Committing Apple's logo artwork may warrant an entry under `licenses/`
    or `licenses-3rd-party-code/`. #7664 committed its brand PNG without
    one, so there may be no established practice here.
    
    ### Any related issues, documentation, discussions?
    
    - #7664 — ORCID login, the sibling provider whose structure this
    follows.
    - Apple's [Sign in with Apple
    JS](https://developer.apple.com/documentation/signinwithapplejs)
    documentation, and the [Human Interface Guidelines for Sign in with
    
Apple](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple)
    for the button's requirements.
    
    Closes #7514
    
    ### How was this PR tested?
    
    Unit tests:
    
    - `AppleAuthResourceSpec` — 11 cases over token verification and claim
    mapping, with the network seam overridden rather than signing real
    tokens: verified, unverified and absent email; both `email_verified`
    encodings; malformed credentials; and the 401 paths.
    - `apple-auth.service.spec.ts` — 9 cases over the SDK mechanics: the
    client id fetched before the script is injected, the pinned CDN url,
    memoization across calls, a retry after a failed load,
    `usePopup`/`redirectURI` in the init argument, and a dismissed popup
    resolving as "no token".
    - `texera-login.component.spec.ts` — the Apple button and its click
    flow, including two cases that pin the compliance-sensitive bits: the
    label is one of Apple's permitted titles, and the logo `src` is Apple's
    asset rather than an icon-set glyph.
    
    Also fixes a pre-existing failure in that spec: its `render()` helper
    set only `localLogin` and `googleLogin`, while `MockGuiConfigService`
    defaults `appleLogin: true` and the divider condition reads all three,
    so the "only local login" divider assertion failed. The helper now takes
    all three flags, plus new cases for the Apple-only divider path that
    nothing covered.
    
    Full frontend suite passes: 210 files, 5472 tests.
    
    Manual, against a real Apple app and Services ID. Apple will not accept
    `http://localhost` as a redirect URI and requires HTTPS on a domain
    registered against the Services ID, so this cannot be exercised on a
    plain local checkout. I routed a local dev server through an ngrok
    tunnel to get a stable HTTPS domain Apple would accept:
    
    1. Registered the ngrok domain against the Services ID in the Apple
    developer console, with the return URL pointing at the tunnel origin,
    and served Apple's domain-association file from `/.well-known/` — hence
    the `angular.json` asset glob.
    2. Set `USER_SYS_APPLE_CLIENT_ID` to the Services ID and
    `GUI_LOGIN_APPLE_LOGIN=true`.
    3. Ran the Angular dev server with the tunnel host allowed, and opened
    the tunnel URL rather than localhost — `redirectURI` derives from
    `window.location.origin`, so it has to be the tunnel origin for Apple to
    match its registration.
    4. Signed in through Apple's popup and confirmed the identity token
    reaches `POST /auth/apple/login`, verifies against Apple's JWKS,
    provisions the user, and returns a session that lands on the workflow
    page.
    5. Re-ran with the popup dismissed to confirm nothing is surfaced to the
    user, and with an account whose address Apple had not verified to
    confirm the 401.
    
    Both a first sign-in and a subsequent one were exercised, which matters
    because Apple only sends the display name on the first authorization.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
    Co-authored-by: Xinyuan Lin <[email protected]>
---
 .licenserc.yaml                                    |   4 +
 LICENSE                                            |  13 ++
 .../apache/texera/web/TexeraWebApplication.scala   |   8 +-
 .../web/resource/auth/AppleAuthResource.scala      | 161 +++++++++++++++
 .../resource/auth/ExternalAuthProvisioner.scala    |   3 +-
 .../web/resource/auth/AppleAuthResourceSpec.scala  | 229 +++++++++++++++++++++
 bin/k8s/values-development.yaml                    |   6 +
 bin/k8s/values.yaml                                |   6 +
 .../scala/org/apache/texera/auth/JwtParser.scala   |   8 +-
 common/config/src/main/resources/gui.conf          |   6 +
 common/config/src/main/resources/user-system.conf  |   5 +
 .../apache/texera/common/config/GuiConfig.scala    |   2 +
 .../texera/common/config/UserSystemConfig.scala    |   1 +
 .../texera/common/config/GuiConfigSpec.scala       |   3 +
 .../texera/service/resource/ConfigResource.scala   |   1 +
 .../service/resource/ConfigResourceSpec.scala      |   2 +
 frontend/.well-known/.gitkeep                      |   1 +
 frontend/angular.json                              |   5 +
 .../app/common/service/gui-config.service.mock.ts  |   1 +
 .../src/app/common/service/gui-config.service.ts   |   8 +-
 .../common/service/user/apple-auth.service.spec.ts | 191 +++++++++++++++++
 .../app/common/service/user/apple-auth.service.ts  | 132 ++++++++++++
 .../src/app/common/service/user/auth.service.ts    |  15 ++
 .../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    |   6 +
 frontend/src/app/common/type/gui-config.ts         |   1 +
 .../component/login/texera-login.component.html    |  20 +-
 .../component/login/texera-login.component.scss    |  50 +++++
 .../component/login/texera-login.component.spec.ts | 134 ++++++++++--
 .../hub/component/login/texera-login.component.ts  |  38 +++-
 frontend/src/assets/logos/apple-logo-white.svg     |   1 +
 sql/changelog.xml                                  |   5 +
 sql/texera_ddl.sql                                 |   2 +-
 sql/updates/49.sql                                 |  37 ++++
 35 files changed, 1086 insertions(+), 27 deletions(-)

diff --git a/.licenserc.yaml b/.licenserc.yaml
index fc97bf09f1..dc33ea0a54 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -47,6 +47,10 @@ header:
     - 'frontend/src/app/common/formly/object.type.ts'
     - 'frontend/src/app/common/formly/multischema.type.ts'
     - 'frontend/src/app/common/formly/null.type.ts'
+    # Apple trademark artwork, required by Apple for the Sign in with Apple 
button. An ASF header
+    # here would assert Apache-2.0 over Apple's mark, which is why it is 
ignored rather than
+    # headed - see LICENSE file for attribution.
+    - 'frontend/src/assets/logos/apple-logo-white.svg'
     # Third-party SVG assets - see LICENSE file for attribution
     - 'frontend/src/assets/svg/operator-view-result.svg'
     - 'frontend/src/assets/svg/operator-reuse-cache-invalid.svg'
diff --git a/LICENSE b/LICENSE
index 5d1d9f04cb..f3c0d6aaeb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -253,3 +253,16 @@ This product includes SVG icons from SVGRepo:
   - frontend/src/assets/svg/operator-reuse-cache-invalid.svg
   Source: https://www.svgrepo.com
   License: MIT License (licenses/LICENSE-MIT.txt)
+
+Trademarks
+--------------------------------------
+
+This product includes the Apple logo, a trademark of Apple Inc.:
+  - frontend/src/assets/logos/apple-logo-white.svg
+  Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S.
+  and other countries. The mark is included solely to render the "Continue with
+  Apple" button, which Apple's guidelines require to carry their own artwork.
+  Source: 
https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple
+  Use governed by Apple's Sign in with Apple usage guidelines, not by an
+  open-source license:
+  
https://developer.apple.com/sign-in-with-apple/usage-guidelines-for-websites-and-other-platforms/
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 34531e8c6b..4a1d33f62e 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,12 @@ 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, 
OrcidAuthResource}
+import org.apache.texera.web.resource.auth.{
+  AppleAuthResource,
+  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
@@ -139,6 +144,7 @@ class TexeraWebApplication
     environment.jersey.register(classOf[AuthResource])
     environment.jersey.register(classOf[GoogleAuthResource])
     environment.jersey.register(classOf[OrcidAuthResource])
+    environment.jersey.register(classOf[AppleAuthResource])
     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/AppleAuthResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/auth/AppleAuthResource.scala
new file mode 100644
index 0000000000..e3ed8aa26e
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/auth/AppleAuthResource.scala
@@ -0,0 +1,161 @@
+/*
+ * 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.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken}
+import org.apache.texera.common.config.UserSystemConfig
+import org.apache.texera.dao.jooq.generated.enums.ProviderTypeEnum
+import org.apache.texera.web.model.http.response.TokenIssueResponse
+import org.jose4j.jwa.AlgorithmConstraints
+import org.jose4j.jwk.HttpsJwks
+import org.jose4j.jws.AlgorithmIdentifiers
+import org.jose4j.jwt.JwtClaims
+import org.jose4j.jwt.consumer.{InvalidJwtException, JwtConsumer, 
JwtConsumerBuilder}
+import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver
+
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.{Consumes, GET, NotAuthorizedException, POST, Path, 
Produces}
+
+object AppleAuthResource extends LazyLogging {
+
+  /** Where Apple publishes the public keys its identity tokens are signed 
with. */
+  private[auth] val APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";
+  private[auth] val APPLE_ISSUER = "https://appleid.apple.com";
+
+  private lazy val appleJwks = new HttpsJwks(APPLE_JWKS_URL)
+
+  /**
+    * Read a claim Apple types inconsistently. `email_verified` and 
`is_private_email` arrive as
+    * either a JSON boolean or a quoted string, and Apple documents both 
shapes. Reading only one
+    * silently yields `false`, which for `email_verified` means 
[[ExternalAuthProvisioner]]
+    * refuses a legitimate login.
+    */
+  private[auth] def booleanClaim(claims: JwtClaims, name: String): Boolean =
+    claims.getClaimValue(name) match {
+      case b: java.lang.Boolean => b.booleanValue()
+      case s: String            => s.trim.equalsIgnoreCase("true")
+      case _                    => false
+    }
+
+  /**
+    * Reduce a verified Apple identity token to what we persist: an 
[[ExternalProfile]] when Apple
+    * asserted a verified address, an [[ExternalIdentity]] when it asserted 
none.
+    *
+    * `sub` is the stable per-user identifier and becomes the provider id. It 
is scoped to the Apple
+    * developer *team*: transferring the app changes it for every user, and 
`transfer_sub` is
+    * available for only 60 days after such a move.
+    *
+    * Apple sends the display name only on a user's first ever authorization, 
outside the identity
+    * token — it rides in the JS response body, so it never reaches this 
endpoint, and unsigned it
+    * would be untrusted anyway. The address stands in for it, as it does for 
a Google account with
+    * no name, and the `sub` stands in when there is no address either. Apple 
supplies no avatar.
+    *
+    * An address Apple did not verify is refused rather than mapped — see 
[[ExternalProfile]] for
+    * why that is a takeover. An absent address is not: Apple omits `email` 
for Sign in with Apple
+    * at Work & School accounts, so refusing would lock those users out of a 
provider the deployment
+    * has enabled. They are provisioned identity-only and asked for an address 
once in.
+    */
+  private[auth] def identityOf(claims: JwtClaims): Either[ExternalIdentity, 
ExternalProfile] = {
+    val email = 
Option(claims.getClaimValueAsString("email")).map(_.trim).getOrElse("")
+    if (email.isEmpty) {
+      return Left(ExternalIdentity(ProviderTypeEnum.APPLE, claims.getSubject, 
claims.getSubject))
+    }
+    if (!booleanClaim(claims, "email_verified")) {
+      logger.warn(
+        s"Refusing Apple identity ${claims.getSubject}: Apple did not verify 
its email address."
+      )
+      throw new NotAuthorizedException("Login credentials are incorrect.")
+    }
+    Right(
+      ExternalProfile(
+        ProviderTypeEnum.APPLE,
+        claims.getSubject,
+        name = email,
+        email = email,
+        avatar = None
+      )
+    )
+  }
+}
+
+@Path("/auth/apple")
+class AppleAuthResource extends LazyLogging {
+  final private lazy val clientId = UserSystemConfig.appleClientId
+
+  @GET
+  @Path("/clientid")
+  def getClientId: String = clientId
+
+  /**
+    * Rejects anything not signed by a current Apple key, not issued by Apple, 
not addressed to
+    * this Services ID, or expired. The algorithm is pinned to RS256 so a 
token cannot talk the
+    * verifier into a weaker one.
+    */
+  private lazy val jwtConsumer: JwtConsumer =
+    new JwtConsumerBuilder()
+      .setRequireExpirationTime()
+      .setRequireSubject()
+      .setExpectedIssuer(AppleAuthResource.APPLE_ISSUER)
+      .setExpectedAudience(clientId)
+      .setVerificationKeyResolver(
+        new HttpsJwksVerificationKeyResolver(AppleAuthResource.appleJwks)
+      )
+      .setJwsAlgorithmConstraints(
+        new AlgorithmConstraints(
+          AlgorithmConstraints.ConstraintType.PERMIT,
+          AlgorithmIdentifiers.RSA_USING_SHA256
+        )
+      )
+      .build()
+
+  /**
+    * Verify `credential` against Apple's published keys, yielding its claims, 
or None if it is
+    * not a valid token for this client. The only seam that reaches the 
network, so tests
+    * override it rather than sign a token; kept a method rather than a 
constructor parameter
+    * because Jersey instantiates this resource from 
`classOf[AppleAuthResource]`.
+    *
+    * A malformed or unverifiable credential becomes None, and so a 401, 
instead of escaping as
+    * a 500.
+    */
+  protected def verifiedClaims(credential: String): Option[JwtClaims] =
+    try Option(jwtConsumer.processToClaims(credential))
+    catch {
+      case e: InvalidJwtException =>
+        logger.warn(s"Rejecting Apple credential: ${e.getMessage}")
+        None
+    }
+
+  @POST
+  @Consumes(Array(MediaType.TEXT_PLAIN))
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  @Path("/login")
+  def login(credential: String): TokenIssueResponse =
+    verifiedClaims(credential) match {
+      case Some(claims) =>
+        val user = AppleAuthResource.identityOf(claims) match {
+          case Right(profile) => 
ExternalAuthProvisioner.loginOrProvision(profile)
+          case Left(identity) => 
ExternalAuthProvisioner.loginOrProvisionIdentityOnly(identity)
+        }
+        // No `googleId` claim: that one names a Google identity, and this 
login has none.
+        TokenIssueResponse(jwtToken(jwtClaims(user)))
+      case None => throw new NotAuthorizedException("Login credentials are 
incorrect.")
+    }
+}
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 a3530aede1..a627e80a33 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
@@ -52,7 +52,8 @@ final case class ExternalProfile(
 
 /**
   * An identity a provider authenticates without asserting any address — 
ORCID, whose
-  * `/authenticate` scope yields an iD and a name and nothing else.
+  * `/authenticate` scope yields an iD and a name and nothing else, and Apple, 
which omits `email`
+  * for Sign in with Apple at Work & School accounts.
   *
   * 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
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/auth/AppleAuthResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/auth/AppleAuthResourceSpec.scala
new file mode 100644
index 0000000000..43e39b5d3a
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/auth/AppleAuthResourceSpec.scala
@@ -0,0 +1,229 @@
+/*
+ * 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.common.config.UserSystemConfig
+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.jose4j.jwt.JwtClaims
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import javax.ws.rs.NotAuthorizedException
+
+/**
+  * Integration spec for [[AppleAuthResource]] against embedded Postgres.
+  *
+  * Signature verification is the one part that cannot run here — it needs an 
Apple-signed JWT and a
+  * network round trip to Apple's JWKS endpoint — so the suite overrides 
`verifiedClaims` and drives
+  * the resource with claim sets built by hand. What it pins down is the 
mapping downstream, and the
+  * two cases Apple's own documentation warns about: a string-typed 
`email_verified`, and a token
+  * with no `email` at all.
+  */
+class AppleAuthResourceSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with BeforeAndAfterEach
+    with MockTexeraDB {
+
+  private val emailDomain = "@apple-auth-test.com"
+
+  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()
+
+  // Identity-only accounts have a NULL email, so they are matched by the name 
they are given
+  // instead — the Apple `sub`. AUTH_PROVIDER cascades on delete.
+  private def cleanup(): Unit =
+    getDSLContext
+      .deleteFrom(USER)
+      .where(USER.EMAIL.like("%" + 
emailDomain).or(USER.NAME.like("apple-sub-%")))
+      .execute()
+
+  // ---- helpers -------------------------------------------------------------
+
+  /** A resource whose verification step always yields `claims`, standing in 
for Apple. */
+  private class StubbedAppleAuthResource(claims: Option[JwtClaims]) extends 
AppleAuthResource {
+    override protected def verifiedClaims(credential: String): 
Option[JwtClaims] = claims
+  }
+
+  /**
+    * A claim set shaped like Apple's. `emailVerified` is typed `Any` on 
purpose: Apple sends it as
+    * a JSON boolean or as a quoted string, and both have to work.
+    */
+  private def claims(subject: String, email: String, emailVerified: Any = 
true): JwtClaims = {
+    val c = new JwtClaims
+    c.setSubject(subject)
+    if (email != null) c.setClaim("email", email)
+    if (emailVerified != null) c.setClaim("email_verified", emailVerified)
+    c
+  }
+
+  private def loginWith(c: JwtClaims): Unit =
+    new StubbedAppleAuthResource(Some(c))
+      .login("stubbed-credential")
+      .accessToken should not be empty
+
+  private def userByEmail(localPart: String): User =
+    userDao.fetchOneByEmail(localPart + emailDomain)
+
+  private def userByName(name: String): User =
+    userDao.fetchByName(name).stream().findFirst().orElse(null)
+
+  private def appleIdOf(uid: Integer): String =
+    getDSLContext
+      .select(AUTH_PROVIDER.PROVIDER_ID)
+      .from(AUTH_PROVIDER)
+      .where(AUTH_PROVIDER.UID.eq(uid))
+      .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.APPLE))
+      .fetchOne(AUTH_PROVIDER.PROVIDER_ID)
+
+  // ---- login ---------------------------------------------------------------
+
+  behavior of "login"
+
+  it should "provision an INACTIVE user and an APPLE provider row on a first 
login" in {
+    loginWith(claims("apple-sub-new", "newcomer" + emailDomain))
+
+    val user = userByEmail("newcomer")
+    user should not be null
+    user.getRole shouldBe UserRoleEnum.INACTIVE
+    appleIdOf(user.getUid) shouldBe "apple-sub-new"
+  }
+
+  it should "return the same account on a second login rather than 
provisioning again" in {
+    loginWith(claims("apple-sub-repeat", "repeat" + emailDomain))
+    val first = userByEmail("repeat").getUid
+
+    loginWith(claims("apple-sub-repeat", "repeat" + emailDomain))
+
+    getDSLContext.fetchCount(USER, USER.EMAIL.eq("repeat" + emailDomain)) 
shouldBe 1
+    userByEmail("repeat").getUid shouldBe first
+  }
+
+  // Apple only ever sends a display name on the very first authorization, and 
outside the token,
+  // so there is nothing else to fall back to.
+  it should "use the email address as the display name" in {
+    loginWith(claims("apple-sub-name", "named" + emailDomain))
+
+    userByEmail("named").getName shouldBe "named" + emailDomain
+  }
+
+  // ---- a token with no email -----------------------------------------------
+
+  // Apple omits `email` for Sign in with Apple at Work & School accounts. 
Refusing would lock
+  // those users out of a provider the deployment has enabled, so they are 
provisioned
+  // identity-only: NULL email, `sub` as the name, and an address collected 
later.
+  it should "provision an account with no email when Apple asserts none" in {
+    loginWith(claims("apple-sub-noemail", null))
+
+    val user = userByName("apple-sub-noemail")
+    user should not be null
+    user.getEmail shouldBe null
+    appleIdOf(user.getUid) shouldBe "apple-sub-noemail"
+  }
+
+  // Repeated NULL emails do not collide on the UNIQUE index, but the identity 
still has to match
+  // the existing row rather than pile up new ones.
+  it should "return the same email-less account on a second login" in {
+    loginWith(claims("apple-sub-noemail", null))
+    val first = userByName("apple-sub-noemail").getUid
+
+    loginWith(claims("apple-sub-noemail", null))
+
+    getDSLContext.fetchCount(USER, USER.NAME.eq("apple-sub-noemail")) shouldBe 
1
+    userByName("apple-sub-noemail").getUid shouldBe first
+  }
+
+  // ---- email_verified 
-------------------------------------------------------
+
+  // Apple documents this claim as "either a string ("true" or "false") or a 
Boolean". Reading only
+  // the boolean shape yields false for the string case, which would refuse a 
legitimate login.
+  // `booleanClaim` below covers the shapes exhaustively; this pins the wiring 
through the resource.
+  it should "accept email_verified sent as the string \"true\"" in {
+    loginWith(claims("apple-sub-strtrue", "strtrue" + emailDomain, 
emailVerified = "true"))
+
+    userByEmail("strtrue") should not be null
+  }
+
+  it should "refuse an address Apple has not verified" in {
+    val resource = new StubbedAppleAuthResource(
+      Some(claims("apple-sub-unverified", "unverified" + emailDomain, 
emailVerified = false))
+    )
+
+    assertThrows[NotAuthorizedException](resource.login("stubbed-credential"))
+    userByEmail("unverified") shouldBe null
+  }
+
+  // ---- verification failure 
-------------------------------------------------
+
+  it should "reject a credential Apple does not verify with a 401" in {
+    a[NotAuthorizedException] should be thrownBy new 
StubbedAppleAuthResource(None)
+      .login("not-a-real-credential")
+  }
+
+  // The one case that runs the real `verifiedClaims` rather than the stub. A 
string that is not
+  // three dot-separated parts fails the local parse, so no request to Apple 
is made.
+  it should "reject a malformed credential with a 401 before reaching Apple" 
in {
+    a[NotAuthorizedException] should be thrownBy new 
AppleAuthResource().login("not-a-jwt")
+  }
+
+  // ---- booleanClaim 
---------------------------------------------------------
+
+  behavior of "booleanClaim"
+
+  it should "read both the boolean and string shapes Apple uses" in {
+    def read(value: Any): Boolean = {
+      val c = new JwtClaims
+      if (value != null) c.setClaim("flag", value)
+      AppleAuthResource.booleanClaim(c, "flag")
+    }
+
+    read(true) shouldBe true
+    read("true") shouldBe true
+    read("TRUE") shouldBe true
+    read(" true ") shouldBe true
+    read(false) shouldBe false
+    read("false") shouldBe false
+    read(null) shouldBe false
+    read(42) shouldBe false
+  }
+
+  // ---- client id 
------------------------------------------------------------
+
+  behavior of "getClientId"
+
+  it should "expose the configured Apple client id" in {
+    new AppleAuthResource().getClientId shouldBe UserSystemConfig.appleClientId
+  }
+}
diff --git a/bin/k8s/values-development.yaml b/bin/k8s/values-development.yaml
index d7b323246d..0216a44239 100644
--- a/bin/k8s/values-development.yaml
+++ b/bin/k8s/values-development.yaml
@@ -374,6 +374,10 @@ texeraEnvVars:
   # button renders disabled and /auth/orcid/config reports the provider 
unavailable on every visit.
   - name: GUI_LOGIN_ORCID_LOGIN
     value: "false"
+  # Turn on together with USER_SYS_APPLE_CLIENT_ID below. Apple also rejects 
an unregistered or
+  # non-HTTPS redirect, so the flow cannot complete until the Services ID 
names this deployment.
+  - name: GUI_LOGIN_APPLE_LOGIN
+    value: "false"
   - name: GUI_DATASET_SINGLE_FILE_UPLOAD_MAXIMUM_SIZE_MB
     value: "1024"
   - name: GUI_WORKFLOW_WORKSPACE_EXPORT_EXECUTION_RESULT_ENABLED
@@ -395,6 +399,8 @@ texeraEnvVars:
     value: "true"
   - name: USER_SYS_GOOGLE_CLIENT_ID
     value: ""
+  - name: USER_SYS_APPLE_CLIENT_ID
+    value: ""
   - name: USER_SYS_GOOGLE_SMTP_GMAIL
     value: ""
   - name: USER_SYS_GOOGLE_SMTP_PASSWORD
diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml
index 5659db5dcf..67ba2307b3 100644
--- a/bin/k8s/values.yaml
+++ b/bin/k8s/values.yaml
@@ -454,6 +454,10 @@ texeraEnvVars:
   # button renders disabled and /auth/orcid/config reports the provider 
unavailable on every visit.
   - name: GUI_LOGIN_ORCID_LOGIN
     value: "false"
+  # Turn on together with USER_SYS_APPLE_CLIENT_ID below. Apple also rejects 
an unregistered or
+  # non-HTTPS redirect, so the flow cannot complete until the Services ID 
names this deployment.
+  - name: GUI_LOGIN_APPLE_LOGIN
+    value: "false"
   - name: GUI_DATASET_SINGLE_FILE_UPLOAD_MAXIMUM_SIZE_MB
     value: "1024"
   - name: GUI_WORKFLOW_WORKSPACE_EXPORT_EXECUTION_RESULT_ENABLED
@@ -475,6 +479,8 @@ texeraEnvVars:
     value: "true"
   - name: USER_SYS_GOOGLE_CLIENT_ID
     value: ""
+  - name: USER_SYS_APPLE_CLIENT_ID
+    value: ""
   - name: USER_SYS_GOOGLE_SMTP_GMAIL
     value: ""
   - name: USER_SYS_GOOGLE_SMTP_PASSWORD
diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala 
b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
index 157dfb9bf9..024748eb8a 100644
--- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
+++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
@@ -62,15 +62,9 @@ object JwtParser extends LazyLogging {
     // call writes Integer; widen via Number to handle both cases.
     val userId = claims.getClaimValue("userId", classOf[Number]).intValue()
     val role = 
UserRoleEnum.valueOf(claims.getClaimValue("role").asInstanceOf[String])
-    // This claim was named `googleAvatar` until the column and the value 
stopped being
-    // Google-specific. Tokens live for `auth.jwt.expiration-in-minutes` (a 
week by default), so
-    // the old name is still read; the fallback can go once every token 
predating the rename has
-    // expired.
+
     val avatar = Option(claims.getClaimValue("avatar", classOf[String]))
       .getOrElse(claims.getClaimValue("googleAvatar", classOf[String]))
-    // The `googleId` claim is deliberately written but not read back: nothing 
server-side
-    // needs it (credentials live in auth_provider), and the only consumer is 
the frontend,
-    // which reads it straight off the raw token.
 
     new SessionUser(
       new User().tap { user =>
diff --git a/common/config/src/main/resources/gui.conf 
b/common/config/src/main/resources/gui.conf
index b49cc348dd..cccfe4a7d2 100644
--- a/common/config/src/main/resources/gui.conf
+++ b/common/config/src/main/resources/gui.conf
@@ -41,6 +41,12 @@ gui {
     orcid-login = false
     orcid-login = ${?GUI_LOGIN_ORCID_LOGIN}
 
+    # whether Sign in with Apple is enabled. Off by default because it needs a 
Services ID that
+    # only an operator can supply (user-sys.apple.clientId), and Apple rejects 
an unregistered or
+    # non-HTTPS redirect, so a local checkout cannot complete the flow.
+    apple-login = false
+    apple-login = ${?GUI_LOGIN_APPLE_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 b6b3e2a166..312aaa6313 100644
--- a/common/config/src/main/resources/user-system.conf
+++ b/common/config/src/main/resources/user-system.conf
@@ -60,6 +60,11 @@ user-sys {
     redirectUri = ${?USER_SYS_ORCID_REDIRECT_URI}
   }
 
+  apple {
+    clientId = ""
+    clientId = ${?USER_SYS_APPLE_CLIENT_ID}
+  }
+
   domain = ""
   domain = ${?USER_SYS_DOMAIN}
 
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 f6b6e1c434..d5295024dc 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
@@ -31,6 +31,8 @@ object GuiConfig {
     conf.getBoolean("gui.login.google-login")
   val guiLoginOrcidLogin: Boolean =
     conf.getBoolean("gui.login.orcid-login")
+  val guiLoginAppleLogin: Boolean =
+    conf.getBoolean("gui.login.apple-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 fc4756297b..18ee4dea67 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
@@ -34,6 +34,7 @@ object UserSystemConfig {
   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 appleClientId: String = conf.getString("user-sys.apple.clientId")
   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 45e4adecd4..e3d98d261a 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
@@ -38,6 +38,9 @@ class GuiConfigSpec extends AnyFlatSpec with Matchers {
     // 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)
+    // Apple ships off too: it needs a Services ID only an operator can 
supply, and Apple rejects
+    // an unregistered or non-HTTPS redirect, so a local checkout cannot 
complete the flow.
+    ifUnset("GUI_LOGIN_APPLE_LOGIN")(GuiConfig.guiLoginAppleLogin shouldBe 
false)
     ifUnset("GUI_WORKFLOW_WORKSPACE_USER_PRESET_ENABLED")(
       GuiConfig.guiWorkflowWorkspaceUserPresetEnabled 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 ecbfcfa135..5bdba890c2 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
@@ -64,6 +64,7 @@ class ConfigResource {
       "localLogin" -> GuiConfig.guiLoginLocalLogin,
       "googleLogin" -> GuiConfig.guiLoginGoogleLogin,
       "orcidLogin" -> GuiConfig.guiLoginOrcidLogin,
+      "appleLogin" -> GuiConfig.guiLoginAppleLogin,
       "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 b170e222ac..ce3819d8be 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
@@ -135,6 +135,7 @@ class ConfigResourceSpec
       // 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",
+      "appleLogin",
       "defaultLocalUser",
       "attributionEnabled",
       "deploymentVersionCheckEnabled",
@@ -171,6 +172,7 @@ class ConfigResourceSpec
       "localLogin",
       "googleLogin",
       "orcidLogin",
+      "appleLogin",
       "defaultLocalUser",
       "attributionEnabled"
     )
diff --git a/frontend/.well-known/.gitkeep b/frontend/.well-known/.gitkeep
new file mode 100644
index 0000000000..b71bcbb238
--- /dev/null
+++ b/frontend/.well-known/.gitkeep
@@ -0,0 +1 @@
+placeholder — replace with the file Apple gives you
diff --git a/frontend/angular.json b/frontend/angular.json
index 014f8e36d8..1beef6d8c3 100644
--- a/frontend/angular.json
+++ b/frontend/angular.json
@@ -21,6 +21,11 @@
                 "glob": "**/*",
                 "input": 
"./node_modules/@ant-design/icons-angular/src/inline-svg/",
                 "output": "/assets/"
+              },
+              {
+                "glob": "**/*",
+                "input": "./.well-known",
+                "output": "/.well-known/"
               }
             ],
             "styles": [
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 dbf21ff37c..c4441540cb 100644
--- a/frontend/src/app/common/service/gui-config.service.mock.ts
+++ b/frontend/src/app/common/service/gui-config.service.mock.ts
@@ -34,6 +34,7 @@ export class MockGuiConfigService {
     localLogin: true,
     googleLogin: true,
     orcidLogin: true,
+    appleLogin: 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 d00a310383..edd22c5177 100644
--- a/frontend/src/app/common/service/gui-config.service.ts
+++ b/frontend/src/app/common/service/gui-config.service.ts
@@ -30,7 +30,13 @@ const ACCESS_TOKEN_KEY = "access_token";
 
 type PreLoginConfig = Pick<
   GuiConfig,
-  "localLogin" | "googleLogin" | "orcidLogin" | "defaultLocalUser" | 
"attributionEnabled" | "emailVerification"
+  | "localLogin"
+  | "googleLogin"
+  | "orcidLogin"
+  | "appleLogin"
+  | "defaultLocalUser"
+  | "attributionEnabled"
+  | "emailVerification"
 >;
 // Fields served by /config/amber.
 type AmberConfig = Pick<GuiConfig, "defaultDataTransferBatchSize">;
diff --git a/frontend/src/app/common/service/user/apple-auth.service.spec.ts 
b/frontend/src/app/common/service/user/apple-auth.service.spec.ts
new file mode 100644
index 0000000000..9fb38ca2a0
--- /dev/null
+++ b/frontend/src/app/common/service/user/apple-auth.service.spec.ts
@@ -0,0 +1,191 @@
+/*
+ * 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 { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
+import { firstValueFrom } from "rxjs";
+import { vi } from "vitest";
+
+import { AppleAuthService } from "./apple-auth.service";
+import { AppSettings } from "../../app-setting";
+
+const SDK_URL = 
"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";;
+
+/**
+ * This is where Apple's real surface is exercised — the injected script tag 
and the `AppleID`
+ * global. The login component's spec stubs this service wholesale, so keeping 
the SDK's mechanics
+ * here is what stops those concerns leaking into a component test.
+ */
+describe("AppleAuthService", () => {
+  let service: AppleAuthService;
+  let httpTestingController: HttpTestingController;
+  let appleId: { auth: { init: ReturnType<typeof vi.fn>; signIn: 
ReturnType<typeof vi.fn> } };
+
+  const expectedUrl = `${AppSettings.getApiEndpoint()}/auth/apple/clientid`;
+  const sdkScripts = () => 
Array.from(document.querySelectorAll<HTMLScriptElement>(`script[src="${SDK_URL}"]`));
+
+  /**
+   * Answer the client-id request the service makes before it touches the SDK, 
then let the awaiting
+   * continuation inside `configure()` run so the script tag exists by the 
time the caller looks.
+   */
+  const flushClientId = async (clientId = "apple.client.id") => {
+    httpTestingController
+      .expectOne(r => r.method === "GET" && r.url === expectedUrl && 
r.responseType === "text")
+      .flush(clientId);
+    for (let i = 0; i < 5; i++) {
+      await Promise.resolve();
+    }
+  };
+
+  const setGlobal = (value: unknown) => {
+    (globalThis as unknown as { AppleID?: unknown }).AppleID = value;
+  };
+
+  beforeEach(() => {
+    appleId = {
+      auth: { init: vi.fn(), signIn: vi.fn().mockResolvedValue({ 
authorization: { id_token: "apple-id-token" } }) },
+    };
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [AppleAuthService],
+    });
+    service = TestBed.inject(AppleAuthService);
+    httpTestingController = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    delete (globalThis as unknown as { AppleID?: unknown }).AppleID;
+    // jsdom shares one document across a file, so an appended script would 
leak between tests.
+    sdkScripts().forEach(script => script.remove());
+    httpTestingController.verify();
+  });
+
+  it("issues a GET to the client-id endpoint and emits the returned id", async 
() => {
+    const clientId$ = firstValueFrom(service.getClientId());
+
+    await flushClientId("apple-client-id-abc");
+
+    expect(await clientId$).toBe("apple-client-id-abc");
+  });
+
+  describe("signIn", () => {
+    it("configures Apple with the fetched client id, the page origin and a 
popup", async () => {
+      setGlobal(appleId);
+
+      const ready = service.signIn();
+      await flushClientId("apple.services.id");
+      await ready;
+
+      // usePopup is load-bearing twice over: it keeps the identity token in 
the page, and it stops
+      // Apple posting a form to redirectURI, which would navigate away from 
the SPA.
+      expect(appleId.auth.init).toHaveBeenCalledWith({
+        clientId: "apple.services.id",
+        scope: "email",
+        redirectURI: window.location.origin,
+        usePopup: true,
+      });
+    });
+
+    it("appends Apple's SDK script when the global is not already present", 
async () => {
+      const ready = service.signIn();
+      await flushClientId();
+
+      const script = sdkScripts()[0];
+      expect(script).toBeTruthy();
+      expect(script.async).toBe(true);
+
+      setGlobal(appleId);
+      script.onload!(new Event("load"));
+      await ready;
+
+      expect(appleId.auth.init).toHaveBeenCalled();
+    });
+
+    it("skips the script when Apple's SDK is already on the page", async () => 
{
+      setGlobal(appleId);
+
+      const ready = service.signIn();
+      await flushClientId();
+      await ready;
+
+      expect(sdkScripts()).toHaveLength(0);
+    });
+
+    it("neither refetches the client id nor re-appends the script on a second 
call", async () => {
+      setGlobal(appleId);
+
+      const first = service.signIn();
+      await flushClientId();
+      await first;
+
+      await service.signIn();
+
+      // expectNone would pass trivially; verify() in afterEach catches an 
unanswered second request.
+      expect(appleId.auth.init).toHaveBeenCalledTimes(1);
+      expect(sdkScripts()).toHaveLength(0);
+    });
+
+    it("lets a later call retry after the script fails to load", async () => {
+      const failing = service.signIn();
+      await flushClientId();
+      sdkScripts()[0].onerror!(new Event("error"));
+
+      await expect(failing).rejects.toThrow("Failed to load Apple's sign-in 
SDK");
+
+      // The memo must have been cleared, or a transient CDN blip would be 
cached forever.
+      setGlobal(appleId);
+      const retry = service.signIn();
+      await flushClientId();
+      await retry;
+
+      expect(appleId.auth.init).toHaveBeenCalled();
+    });
+  });
+
+  it("resolves with the identity token from Apple's popup", async () => {
+    setGlobal(appleId);
+
+    const token = service.signIn();
+    await flushClientId();
+
+    expect(await token).toBe("apple-id-token");
+  });
+
+  // Apple rejects with the same shape for a dismissal and a real failure, and 
neither detail is
+  // worth surfacing, so both resolve as "no token" rather than throwing.
+  it("resolves undefined when Apple's popup is dismissed or fails", async () 
=> {
+    setGlobal(appleId);
+    appleId.auth.signIn.mockRejectedValue({ error: "popup_closed_by_user" });
+
+    const token = service.signIn();
+    await flushClientId();
+
+    expect(await token).toBeUndefined();
+  });
+
+  it("resolves undefined when Apple returns no identity token", async () => {
+    setGlobal(appleId);
+    appleId.auth.signIn.mockResolvedValue({ authorization: {} });
+
+    const token = service.signIn();
+    await flushClientId();
+
+    expect(await token).toBeUndefined();
+  });
+});
diff --git a/frontend/src/app/common/service/user/apple-auth.service.ts 
b/frontend/src/app/common/service/user/apple-auth.service.ts
new file mode 100644
index 0000000000..866b341dd6
--- /dev/null
+++ b/frontend/src/app/common/service/user/apple-auth.service.ts
@@ -0,0 +1,132 @@
+/*
+ * 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 { HttpClient } from "@angular/common/http";
+import { firstValueFrom, Observable } from "rxjs";
+import { AppSettings } from "../../app-setting";
+
+/** The subset of Apple's JS SDK this service uses. */
+interface AppleIdSignInResponse {
+  authorization?: { id_token?: string };
+}
+
+declare const AppleID: {
+  auth: {
+    init(config: { clientId: string; scope: string; redirectURI: string; 
usePopup: boolean }): void;
+    signIn(): Promise<AppleIdSignInResponse>;
+  };
+};
+
+const APPLE_SDK_URL = 
"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";;
+
+/**
+ * Sign in with Apple, driven through Apple's own SDK because
+ * `@abacritt/angularx-social-login` ships no Apple provider. The script is 
fetched on first use, so
+ * a deployment with `appleLogin` off never calls Apple at all.
+ *
+ * The button is ours rather than `AppleID.auth.renderButton()`'s. Apple's 
rendered button derives
+ * its type size from its height (a 13px label inside a viewBox scaled by 
`height / 30`) and offers
+ * only three logo widths, so it cannot be matched to the Google button beside 
it. Apple permits a
+ * custom button provided it uses their logo artwork and one of their three 
titles, which is what
+ * `assets/logos/apple-logo-white.svg` and the template's label are for.
+ *
+ * `usePopup` keeps the identity token in the page — Apple posts a form to 
`redirectURI` otherwise,
+ * which would navigate away from the SPA — and that is what lets the token go 
straight to
+ * `/auth/apple/login`. Apple still requires `redirectURI` to be registered 
against the Services ID
+ * and HTTPS on a verified domain; it rejects `http://localhost`, so a local 
click-through needs a
+ * tunnel.
+ */
+@Injectable({
+  providedIn: "root",
+})
+export class AppleAuthService {
+  private sdkLoading?: Promise<void>;
+  private ready?: Promise<void>;
+
+  constructor(private http: HttpClient) {}
+
+  getClientId(): Observable<string> {
+    return 
this.http.get(`${AppSettings.getApiEndpoint()}/auth/apple/clientid`, { 
responseType: "text" });
+  }
+
+  /**
+   * Run Apple's popup flow and resolve with the identity token to hand to the 
backend, or
+   * `undefined` when the user dismisses it — a cancelled sign-in is not an 
error to report.
+   */
+  async signIn(): Promise<string | undefined> {
+    await this.init();
+
+    try {
+      const response = await AppleID.auth.signIn();
+      return response?.authorization?.id_token;
+    } catch {
+      // Apple rejects with `{ error: "popup_closed_by_user" }` on dismissal, 
and with the same
+      // shape for a genuine failure; neither carries anything worth surfacing 
to the user.
+      return undefined;
+    }
+  }
+
+  /**
+   * Fetch the client id, load the SDK and configure it. Memoized, so a 
visitor who clicks twice
+   * calls Apple once; a failure clears the memo so a later click re-attempts 
rather than caching
+   * the failure forever. Lazy: nothing here runs until the button is actually 
clicked, so a visitor
+   * who only ever uses the password form never fetches Apple's script.
+   */
+  private init(): Promise<void> {
+    if (!this.ready) {
+      this.ready = this.configure().catch((e: unknown) => {
+        this.ready = undefined;
+        throw e;
+      });
+    }
+    return this.ready;
+  }
+
+  private async configure(): Promise<void> {
+    const clientId = await firstValueFrom(this.getClientId());
+    await this.loadSdk();
+
+    AppleID.auth.init({
+      clientId,
+      scope: "email",
+      redirectURI: window.location.origin,
+      usePopup: true,
+    });
+  }
+
+  private loadSdk(): Promise<void> {
+    if (typeof AppleID !== "undefined") return Promise.resolve();
+    if (this.sdkLoading) return this.sdkLoading;
+
+    this.sdkLoading = new Promise<void>((resolve, reject) => {
+      const script = document.createElement("script");
+      script.src = APPLE_SDK_URL;
+      script.async = true;
+      script.onload = () => resolve();
+      script.onerror = () => {
+        // Let a later attempt retry rather than caching the failure forever.
+        this.sdkLoading = undefined;
+        reject(new Error("Failed to load Apple's sign-in SDK"));
+      };
+      document.head.appendChild(script);
+    });
+    return this.sdkLoading;
+  }
+}
diff --git a/frontend/src/app/common/service/user/auth.service.ts 
b/frontend/src/app/common/service/user/auth.service.ts
index f94dad7ce4..6eb3849c6f 100644
--- a/frontend/src/app/common/service/user/auth.service.ts
+++ b/frontend/src/app/common/service/user/auth.service.ts
@@ -60,6 +60,7 @@ export class AuthService {
   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 APPLE_LOGIN_ENDPOINT = "auth/apple/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";
@@ -146,6 +147,20 @@ export class AuthService {
     );
   }
 
+  /** Exchanges an Apple identity token for a Texera access token. */
+  public appleAuth(credential: string): Observable<Readonly<{ accessToken: 
string }>> {
+    return this.http.post<Readonly<{ accessToken: string }>>(
+      `${AppSettings.getApiEndpoint()}/${AuthService.APPLE_LOGIN_ENDPOINT}`,
+      credential,
+      {
+        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/stub-auth.service.ts 
b/frontend/src/app/common/service/user/stub-auth.service.ts
index 2f290ea79e..b1d8cc661e 100644
--- a/frontend/src/app/common/service/user/stub-auth.service.ts
+++ b/frontend/src/app/common/service/user/stub-auth.service.ts
@@ -78,6 +78,10 @@ export class StubAuthService implements 
PublicInterfaceOf<AuthService> {
     return of(MOCK_TOKEN);
   }
 
+  appleAuth(): Observable<Readonly<{ accessToken: string }>> {
+    return of(MOCK_TOKEN);
+  }
+
   loginWithExistingToken(): User | undefined {
     if (AuthService.getAccessToken() === MOCK_TOKEN.accessToken) {
       return MOCK_USER;
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 d63c2f70dc..ab2296877e 100644
--- a/frontend/src/app/common/service/user/stub-user.service.ts
+++ b/frontend/src/app/common/service/user/stub-user.service.ts
@@ -61,6 +61,10 @@ export class StubUserService implements 
PublicInterfaceOf<UserService> {
     throw new Error("Method not implemented.");
   }
 
+  appleLogin(): 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 0654208035..0e7a272769 100644
--- a/frontend/src/app/common/service/user/user.service.ts
+++ b/frontend/src/app/common/service/user/user.service.ts
@@ -71,6 +71,12 @@ export class UserService {
     return this.authService.orcidAuth(code).pipe(switchMap(({ accessToken }) 
=> this.handleAccessToken(accessToken)));
   }
 
+  public appleLogin(credential: string): Observable<void> {
+    return this.authService
+      .appleAuth(credential)
+      .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 68b1d9564c..27dff5d084 100644
--- a/frontend/src/app/common/type/gui-config.ts
+++ b/frontend/src/app/common/type/gui-config.ts
@@ -25,6 +25,7 @@ export interface GuiConfig {
   localLogin: boolean;
   googleLogin: boolean;
   orcidLogin: boolean;
+  appleLogin: boolean;
   inviteOnly: boolean;
   emailVerification: boolean;
   userPresetEnabled: boolean;
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 1db45f6525..7afcbc0056 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.html
+++ b/frontend/src/app/hub/component/login/texera-login.component.html
@@ -59,10 +59,28 @@
         class="orcid-icon" />
       <span class="orcid-label">Continue with ORCID</span>
     </button>
+    } @if (config.env.appleLogin) {
+    <!-- Apple's own button is not used here: its SDK ties the label's size to 
the button height (a
+         13px label in a viewBox scaled by height/30) and offers only three 
logo widths, so it
+         cannot be matched to the Google button above. Apple permits a custom 
button provided it
+         carries their logo artwork and one of their three titles — hence the 
committed asset and
+         the "Continue with Apple" label. The mark is 16x44 with the glyph's 
own clear space, so it
+         is neither cropped nor given extra vertical padding, as Apple 
requires. -->
+    <button
+      class="apple-button"
+      type="button"
+      [disabled]="appleSignInPending"
+      (click)="signInWithApple()">
+      <img
+        class="apple-logo"
+        src="assets/logos/apple-logo-white.svg"
+        alt="" />
+      <span class="apple-label">Continue with Apple</span>
+    </button>
     }
   </div>
 
-  @if (config.env.localLogin && (config.env.googleLogin || 
config.env.orcidLogin)) {
+  @if (config.env.localLogin && (config.env.googleLogin || 
config.env.orcidLogin || config.env.appleLogin)) {
   <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 b82535e133..e8558adfb2 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.scss
+++ b/frontend/src/app/hub/component/login/texera-login.component.scss
@@ -69,6 +69,56 @@ $text-secondary: rgba(0, 0, 0, 0.45);
   justify-content: center;
 }
 
+// Apple's button, drawn here rather than by their SDK so that the logo, the 
type and the height are
+// independent — the SDK derives all three from one another. Every value is 
chosen against the Google
+// button above: same 328px width, 14px label, and a height no smaller than 
Google's, which is what
+// Apple's "no smaller than other sign-in buttons" rule requires.
+.apple-button {
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+  width: 328px;
+  height: 44px;
+  padding-inline: 12px;
+  // 16px logo + 10px gap == ORCID's 20px icon + 6px gap, so the label box 
starts at the same 38px
+  // and the text lands exactly where `.orcid-login`'s does — which is what 
matches Google's.
+  gap: 10px;
+  border: none;
+  border-radius: 4px;
+  background: #000;
+  color: #fff;
+  cursor: pointer;
+  font-family: inherit;
+  font-size: 14px;
+  font-weight: 500;
+
+  &:hover:not(:disabled) {
+    background: #1a1a1a;
+  }
+
+  &:disabled {
+    cursor: default;
+    opacity: 0.6;
+  }
+}
+
+// 16x44 with the glyph's own clear space inside the asset's viewBox, so 
Apple's rules hold: the mark
+// spans the button's full height, uncropped, with no vertical padding added 
on top of it.
+.apple-logo {
+  flex: none;
+  width: 16px;
+  height: 44px;
+}
+
+// Centres the label in the space to the RIGHT of the logo, not across the 
whole button — which is
+// what Google's button does, and what `.orcid-login` on the ORCID branch does 
for the same reason.
+// Compensating for the logo's width here (padding-right, or a margin) would 
centre the text on the
+// button instead and leave this button's label sitting a few px left of 
Google's.
+.apple-label {
+  flex: 1;
+  text-align: center;
+}
+
 .form {
   display: flex;
   flex-direction: column;
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 ff461ca6ea..6a25580bb2 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
@@ -26,6 +26,7 @@ import { vi } from "vitest";
 
 import { TexeraLoginComponent } from "./texera-login.component";
 import { UserService } from "../../../common/service/user/user.service";
+import { AppleAuthService } from 
"../../../common/service/user/apple-auth.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 import { MockGuiConfigService } from 
"../../../common/service/gui-config.service.mock";
@@ -44,6 +45,7 @@ describe("TexeraLoginComponent", () => {
   let notificationServiceMock: Partial<NotificationService>;
   let routerMock: Partial<Router>;
   let socialAuthServiceMock: Partial<SocialAuthService>;
+  let appleAuthServiceMock: { signIn: ReturnType<typeof vi.fn> };
   // Typed to allow null so the replayed-logout case can be exercised.
   let authState$: Subject<SocialUser | null>;
 
@@ -58,8 +60,11 @@ describe("TexeraLoginComponent", () => {
       register: vi.fn().mockReturnValue(of({ verificationRequired: false })),
       registerVerify: vi.fn().mockReturnValue(of(undefined)),
       googleLogin: vi.fn().mockReturnValue(of(undefined)),
+      appleLogin: vi.fn().mockReturnValue(of(undefined)),
     };
-    notificationServiceMock = { error: vi.fn(), success: vi.fn() };
+    // The button is ours, so Apple's popup is reached through a click rather 
than a stream.
+    appleAuthServiceMock = { signIn: 
vi.fn().mockResolvedValue("apple-id-token") };
+    notificationServiceMock = { error: vi.fn(), success: vi.fn(), warning: 
vi.fn() };
     routerMock = { navigateByUrl: vi.fn() };
     socialAuthServiceMock = {
       authState: authState$.asObservable() as SocialAuthService["authState"],
@@ -76,6 +81,7 @@ describe("TexeraLoginComponent", () => {
         { provide: Router, useValue: routerMock },
         { provide: ActivatedRoute, useValue: { snapshot: { queryParams } as 
Partial<ActivatedRouteSnapshot> } },
         { provide: SocialAuthService, useValue: socialAuthServiceMock },
+        { provide: AppleAuthService, useValue: appleAuthServiceMock },
         ...commonTestProviders,
       ],
     }).compileComponents();
@@ -161,6 +167,74 @@ describe("TexeraLoginComponent", () => {
     });
   });
 
+  describe("signInWithApple", () => {
+    const host = (): HTMLElement => fixture.nativeElement as HTMLElement;
+
+    it("renders our own button carrying Apple's logo and a permitted title", 
() => {
+      fixture.detectChanges();
+
+      const button = 
host().querySelector<HTMLButtonElement>("button.apple-button");
+      expect(button).toBeTruthy();
+      // Apple permits only "Sign in with Apple", "Sign up with Apple" or 
"Continue with Apple".
+      
expect(button!.querySelector(".apple-label")!.textContent!.trim()).toBe("Continue
 with Apple");
+      // The mark must be Apple's own artwork, not a lookalike glyph from an 
icon set.
+      
expect(button!.querySelector("img.apple-logo")!.getAttribute("src")).toBe("assets/logos/apple-logo-white.svg");
+    });
+
+    it("drops the button when the provider is disabled", () => {
+      (TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService).setConfig({ appleLogin: false });
+      fixture.detectChanges();
+
+      expect(host().querySelector("button.apple-button")).toBeNull();
+    });
+
+    it("exchanges Apple's token for a session and redirects", async () => {
+      await component.signInWithApple();
+
+      
expect(userServiceMock.appleLogin).toHaveBeenCalledWith("apple-id-token");
+      expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW);
+      expect(component.appleSignInPending).toBe(false);
+    });
+
+    // Dismissing Apple's popup yields no token; that is not a failure to 
report.
+    it("does nothing when the popup yields no token", async () => {
+      appleAuthServiceMock.signIn.mockResolvedValue(undefined);
+
+      await component.signInWithApple();
+
+      expect(userServiceMock.appleLogin).not.toHaveBeenCalled();
+      expect(notificationServiceMock.error).not.toHaveBeenCalled();
+      expect(component.appleSignInPending).toBe(false);
+    });
+
+    it("surfaces a failed exchange and clears the pending flag", async () => {
+      (userServiceMock.appleLogin as ReturnType<typeof vi.fn>).mockReturnValue(
+        throwError(() => new Error("Apple sign-in failed"))
+      );
+
+      await component.signInWithApple();
+
+      expect(notificationServiceMock.error).toHaveBeenCalledWith("Apple 
sign-in failed");
+      expect(component.appleSignInPending).toBe(false);
+    });
+
+    // Apple's SDK is only reached on click, so a visitor using the password 
form never calls Apple.
+    it("does not touch Apple until the button is clicked", () => {
+      fixture.detectChanges();
+
+      expect(appleAuthServiceMock.signIn).not.toHaveBeenCalled();
+    });
+
+    it("surfaces a failure to load Apple's SDK", async () => {
+      appleAuthServiceMock.signIn.mockRejectedValue(new Error("Failed to load 
Apple's sign-in SDK"));
+
+      await component.signInWithApple();
+
+      expect(notificationServiceMock.error).toHaveBeenCalledWith("Failed to 
load Apple's sign-in SDK");
+      expect(component.appleSignInPending).toBe(false);
+    });
+  });
+
   describe("togglePasswordVisibility", () => {
     it("flips the passwordVisible flag", () => {
       expect(component.passwordVisible).toBe(false);
@@ -531,10 +605,16 @@ describe("TexeraLoginComponent", () => {
   // Template rendering
   //
   // 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.
+  // provider flag explicitly so nothing is inherited from the mock's defaults 
— which matters
+  // because MockGuiConfigService.setConfig merges over them rather than 
replacing them.
   // ──────────────────────────────────────────────────────────────────────────
   describe("template", () => {
-    function render(flags: { localLogin: boolean; googleLogin: boolean; 
orcidLogin: boolean }): void {
+    function render(flags: {
+      localLogin: boolean;
+      googleLogin: boolean;
+      orcidLogin: boolean;
+      appleLogin: boolean;
+    }): void {
       (TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService).setConfig(flags);
       fixture.detectChanges();
     }
@@ -550,43 +630,47 @@ describe("TexeraLoginComponent", () => {
       passwordIcons().map(icon => (icon.injector.get(NzIconDirective) as 
unknown as { type: string }).type);
 
     const orcidButton = (): HTMLElement | null => 
host().querySelector("button.orcid-login");
+    const appleButton = (): HTMLElement | null => 
host().querySelector("button.apple-button");
 
     describe("provider flags", () => {
-      it("renders the local form and both social buttons when all are 
enabled", () => {
-        render({ localLogin: true, googleLogin: true, orcidLogin: true });
+      it("renders the local form and every social button when all are 
enabled", () => {
+        render({ localLogin: true, googleLogin: true, orcidLogin: true, 
appleLogin: 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(appleButton()).toBeTruthy();
+        // The "or continue with" divider only makes sense when the local form 
has company.
         expect(host().querySelector("nz-divider")).toBeTruthy();
       });
 
-      it("drops both social buttons but keeps the form when only local login 
is enabled", () => {
-        render({ localLogin: true, googleLogin: false, orcidLogin: false });
+      it("drops every social button but keeps the form when only local login 
is enabled", () => {
+        render({ localLogin: true, googleLogin: false, orcidLogin: false, 
appleLogin: false });
 
         expect(host().querySelector("nz-tabs")).toBeTruthy();
         expect(host().querySelector("form")).toBeTruthy();
         expect(host().querySelector("asl-google-signin-button")).toBeNull();
         expect(orcidButton()).toBeNull();
+        expect(appleButton()).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, orcidLogin: false });
+        render({ localLogin: false, googleLogin: true, orcidLogin: false, 
appleLogin: false });
 
         expect(host().querySelector("nz-tabs")).toBeNull();
         expect(host().querySelector("form")).toBeNull();
         expect(host().querySelector("asl-google-signin-button")).toBeTruthy();
         expect(orcidButton()).toBeNull();
+        expect(appleButton()).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 });
+        render({ localLogin: true, googleLogin: false, orcidLogin: true, 
appleLogin: false });
 
         expect(host().querySelector("form")).toBeTruthy();
         expect(host().querySelector("asl-google-signin-button")).toBeNull();
@@ -595,31 +679,53 @@ describe("TexeraLoginComponent", () => {
       });
 
       it("drops the tabs and the form but keeps the orcid button when only 
orcid is enabled", () => {
-        render({ localLogin: false, googleLogin: false, orcidLogin: true });
+        render({ localLogin: false, googleLogin: false, orcidLogin: true, 
appleLogin: false });
 
         expect(host().querySelector("nz-tabs")).toBeNull();
         expect(host().querySelector("form")).toBeNull();
         expect(host().querySelector("asl-google-signin-button")).toBeNull();
         expect(orcidButton()).toBeTruthy();
+        expect(appleButton()).toBeNull();
         expect(host().querySelector("nz-divider")).toBeNull();
       });
 
       it("renders no sign-in path when every provider is disabled", () => {
-        render({ localLogin: false, googleLogin: false, orcidLogin: false });
+        render({ localLogin: false, googleLogin: false, orcidLogin: false, 
appleLogin: false });
 
         expect(host().querySelector("nz-tabs")).toBeNull();
         expect(host().querySelector("form")).toBeNull();
         expect(host().querySelector("asl-google-signin-button")).toBeNull();
         expect(orcidButton()).toBeNull();
+        expect(appleButton()).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();
         expect(host().querySelector("p.foot")).toBeTruthy();
       });
+
+      // Apple alone alongside the form still earns the divider. Nothing 
covered this before,
+      // which is what let the divider condition and the render helper drift 
apart.
+      it("keeps the divider when Apple is the only social provider", () => {
+        render({ localLogin: true, googleLogin: false, orcidLogin: false, 
appleLogin: true });
+
+        expect(host().querySelector("form")).toBeTruthy();
+        expect(host().querySelector("asl-google-signin-button")).toBeNull();
+        expect(appleButton()).toBeTruthy();
+        expect(host().querySelector("nz-divider")).toBeTruthy();
+      });
+
+      it("drops the tabs and the form but keeps Apple's slot when only Apple 
is enabled", () => {
+        render({ localLogin: false, googleLogin: false, orcidLogin: false, 
appleLogin: true });
+
+        expect(host().querySelector("nz-tabs")).toBeNull();
+        expect(host().querySelector("form")).toBeNull();
+        expect(appleButton()).toBeTruthy();
+        expect(host().querySelector("nz-divider")).toBeNull();
+      });
     });
 
     describe("sign-in / sign-up mode", () => {
-      beforeEach(() => render({ localLogin: true, googleLogin: true, 
orcidLogin: true }));
+      beforeEach(() => render({ localLogin: true, googleLogin: true, 
orcidLogin: true, appleLogin: true }));
 
       it("shows only the sign-in fields by default", () => {
         expect(component.mode).toBe("signin");
@@ -679,7 +785,7 @@ describe("TexeraLoginComponent", () => {
 
     describe("password visibility", () => {
       beforeEach(() => {
-        render({ localLogin: true, googleLogin: true, orcidLogin: true });
+        render({ localLogin: true, googleLogin: true, orcidLogin: true, 
appleLogin: 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 5d5af50c97..088fb6accf 100644
--- a/frontend/src/app/hub/component/login/texera-login.component.ts
+++ b/frontend/src/app/hub/component/login/texera-login.component.ts
@@ -34,6 +34,7 @@ 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";
+import { AppleAuthService } from 
"../../../common/service/user/apple-auth.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 import { USER_WORKFLOW } from "../../../app-routing.constant";
@@ -64,7 +65,7 @@ const ACCOUNT_CREATED =
 type LoginMode = "signin" | "signup";
 
 /**
- * Full-page login card: tabbed local sign-in / sign-up plus Google sign-in.
+ * Full-page login card: tabbed local sign-in / sign-up plus Google and Apple 
sign-in.
  *
  * This is the single login surface. It replaces the `texera-local-login` form 
that used to be
  * embedded in the About page and the standalone Google button that sat on the 
dashboard shell,
@@ -93,6 +94,8 @@ type LoginMode = "signin" | "signup";
 export class TexeraLoginComponent implements OnInit {
   public mode: LoginMode = "signin";
   public passwordVisible = false;
+  // Guards against a second click while Apple's popup is already open.
+  public appleSignInPending = false;
   public errorMessage: string | undefined;
   public form: FormGroup;
 
@@ -109,6 +112,7 @@ export class TexeraLoginComponent implements OnInit {
     private ngZone: NgZone,
     private socialAuthService: SocialAuthService,
     private orcidAuthService: OrcidAuthService,
+    private appleAuthService: AppleAuthService,
     protected config: GuiConfigService
   ) {
     this.form = this.formBuilder.group({
@@ -181,6 +185,38 @@ export class TexeraLoginComponent implements OnInit {
       });
   }
 
+  /**
+   * Apple has no Angular button component (`@abacritt/angularx-social-login` 
ships no Apple
+   * provider) and its SDK-rendered button cannot be sized to match the Google 
button beside it, so
+   * the button is ours and the flow is started by a click rather than pushed 
through
+   * `socialAuthService.authState`. A dismissed popup yields no token and is 
not an error.
+   *
+   * Apple's script is fetched inside `signIn()`, on this click, so a visitor 
who only uses the
+   * password form never calls Apple at all.
+   */
+  public async signInWithApple(): Promise<void> {
+    this.appleSignInPending = true;
+    try {
+      const idToken = await this.appleAuthService.signIn();
+      if (!idToken) return;
+
+      this.userService
+        .appleLogin(idToken)
+        .pipe(
+          catchError((e: unknown) => {
+            this.notificationService.error((e as Error)?.message || "Apple 
sign-in failed");
+            return throwError(() => e);
+          }),
+          untilDestroyed(this)
+        )
+        .subscribe(() => this.ngZone.run(() => this.navigateAfterLogin()));
+    } catch (e) {
+      this.notificationService.error((e as Error)?.message || "Apple sign-in 
failed");
+    } finally {
+      this.appleSignInPending = false;
+    }
+  }
+
   public setMode(mode: LoginMode): void {
     this.mode = mode;
     this.errorMessage = undefined;
diff --git a/frontend/src/assets/logos/apple-logo-white.svg 
b/frontend/src/assets/logos/apple-logo-white.svg
new file mode 100644
index 0000000000..433c1d9ae4
--- /dev/null
+++ b/frontend/src/assets/logos/apple-logo-white.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="7 0 17 44"><path fill="#fff" 
fill-rule="nonzero" d="M15.7099491,14.8846154 C16.5675461,14.8846154 
17.642562,14.3048315 18.28274,13.5317864 C18.8625238,12.8312142 
19.2852829,11.852829 19.2852829,10.8744437 C19.2852829,10.7415766 
19.2732041,10.6087095 19.2490464,10.5 C18.2948188,10.5362365 
17.1473299,11.140178 16.4588366,11.9494596 C15.9152893,12.56548 
15.4200572,13.5317864 15.4200572,14.5222505 C15.4200572,14.6671964 
15.4442149,14.8121424 1 [...]
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 677af284f0..c0a515d163 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -154,6 +154,11 @@
         <sqlFile path="sql/updates/48.sql"/>
     </changeSet>
 
+    <!-- Allow Sign in with Apple identities in auth_provider.provider_type -->
+    <changeSet id="49" author="Neilk1021">
+        <sqlFile path="sql/updates/49.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 f1890e6f78..c95ff99c18 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -98,7 +98,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', 'ORCID');
+CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE', 'ORCID', 'APPLE');
 CREATE TYPE user_warehouse_flavor_enum AS ENUM ('local', 'aws');
 CREATE TYPE default_view_enum AS ENUM ('CANVAS', 'FORM');
 
diff --git a/sql/updates/49.sql b/sql/updates/49.sql
new file mode 100644
index 0000000000..c9813f71c0
--- /dev/null
+++ b/sql/updates/49.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 Sign in with Apple as an identity provider in 
auth_provider.provider_type.
+--
+-- Postgres forbids *using* a new enum value in the transaction that adds it. 
Nothing here
+-- inserts an APPLE row, so the value is only declared; the first Apple login 
writes it.
+--
+-- 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 'APPLE';
+
+COMMIT;

Reply via email to