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