This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 48e6dad8d9 feat(auth): add Sign in with Apple (#8314)
48e6dad8d9 is described below
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;