add custom oidc connector#2708
Conversation
# Conflicts: # docker-compose.yml # plugins/ui/apps/portal/src/containers/auth/oidc/OidcLoginSilent.tsx
There was a problem hiding this comment.
Pull request overview
This PR adds PhysioNet OIDC federation support by introducing a custom Logto connector that surfaces upstream access/refresh tokens into Logto-issued JWT claims, and by adding backend auto-provisioning + entitlements-based role reconciliation to reduce first-login races and enable PhysioNet-driven authorization.
Changes:
- Add a new
@d2e/connector-physionet-oidcpackage (fork of Logto OIDC connector) plus a patched OIDC connector build artifact, both exposing upstream tokens viaglobalThis.*tokenMap. - Add alp-usermgmt auto-provisioning and PhysioNet entitlements sync (with new env vars), plus retry/backoff logic to reduce first-login race failures.
- Update Logto post-init and Portal UI routing/login flows to support social sign-in targets and avoid UX delays/retry failures.
Reviewed changes
Copilot reviewed 31 out of 36 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| services/alp-logto/post-init/src/main.ts | Adjust Logto sign-in experience + connector target selection and add env override for social sign-in targets. |
| services/alp-logto/connector-physionet-oidc/tsup.config.ts | New tsup build config for the PhysioNet connector package. |
| services/alp-logto/connector-physionet-oidc/tsconfig.json | New TS config for the PhysioNet connector package. |
| services/alp-logto/connector-physionet-oidc/src/utils.ts | Token endpoint exchange helper for the connector. |
| services/alp-logto/connector-physionet-oidc/src/types.ts | Zod guards + config types and scope post-processing (openid). |
| services/alp-logto/connector-physionet-oidc/src/types.test.ts | Unit tests for scope post-processing. |
| services/alp-logto/connector-physionet-oidc/src/mock.ts | Test mock config for connector tests. |
| services/alp-logto/connector-physionet-oidc/src/index.ts | Connector implementation; exposes upstream tokens via globalThis maps. |
| services/alp-logto/connector-physionet-oidc/src/index.test.ts | Tests for authorization URI creation and userinfo flow. |
| services/alp-logto/connector-physionet-oidc/src/constant.ts | Connector metadata (id/target/form items/readme). |
| services/alp-logto/connector-physionet-oidc/README.md | Rationale and usage notes for the fork + token surfacing. |
| services/alp-logto/connector-physionet-oidc/package.json | New workspace package definition and scripts. |
| services/alp-logto/connector-physionet-oidc/logo.svg | Connector logo asset. |
| services/alp-logto/connector-physionet-oidc/lib/index.js | Checked-in build artifact for runtime mounting (currently contains a critical syntax issue). |
| services/alp-logto/connector-physionet-oidc/lib/index.js.map | Source map for the checked-in build artifact. |
| services/alp-logto/connector-physionet-oidc/CHANGELOG.md | Imported upstream changelog for the fork base. |
| services/alp-logto/connector-physionet-oidc/.gitignore | Ignore rules; explicitly keeps lib/ tracked. |
| services/alp-logto/connector-oidc-patched/lib/index.js | Patched OIDC build artifact used to override bundled connector at runtime. |
| services/alp-logto/connector-oidc-patched/lib/index.js.map | Source map for patched OIDC artifact. |
| plugins/ui/apps/portal/src/containers/shared/NoAccess/NoAccess.tsx | Add one-time delayed reload to mitigate first-login provisioning race. |
| plugins/ui/apps/portal/src/containers/public/PublicRoot.tsx | Skip public overview and route directly to login when no OIDC session exists. |
| plugins/ui/apps/portal/src/containers/auth/oidc/OidcLoginSilent.tsx | Add retry/backoff for first-login group fetch + role sync. |
| plugins/functions/package.json | Pass through new alp-usermgmt auto-provision + entitlements env vars (missing one key). |
| plugins/functions/alp-usermgmt/src/services/index.ts | Export new services (AutoProvision + EntitlementsSync). |
| plugins/functions/alp-usermgmt/src/services/AutoProvisionService.ts | New auto-provisioning service, role hook integration, entitlements sync trigger. |
| plugins/functions/alp-usermgmt/src/services/EntitlementsSyncService.ts | New PhysioNet entitlements-driven role reconciliation service. |
| plugins/functions/alp-usermgmt/src/middlewares/add-user-object-to-req.ts | Add M2M token handling and minimal req.user population (needs safer tagging). |
| plugins/functions/alp-usermgmt/src/middlewares/permitted-user-check.ts | Add bypass behavior for “service tokens” (currently unsafe as implemented). |
| plugins/functions/alp-usermgmt/src/middlewares/permitted-tenant-check.ts | Add bypass behavior for “service tokens” (currently unsafe as implemented). |
| plugins/functions/alp-usermgmt/src/middlewares/grant-roles-by-scopes.ts | Integrate auto-provisioning, retry behavior, and entitlements sync; skip M2M provisioning. |
| plugins/functions/alp-usermgmt/src/env.ts | Add new env vars + parsing helper for auto-provision connector allowlist. |
| plugins/functions/alp-usermgmt/src/api/LogtoAPI.ts | Add helpers to fetch user social + SSO identities for connector gating. |
| plugins/functions/alp-usermgmt/deno.json | Add import map entries for new services. |
| env-vars.md | Document new USERMGMT/LOGTO env vars (missing dataset mapping key). |
| docker-compose.yml | Wire new env vars + Logto post-init settings and expose PhysioNet token claims in custom JWT. |
| docker-compose-local.yml | Local dev wiring: physionet host mapping, warmup job, connector override, and sample entitlements env config. |
Files not reviewed (2)
- services/alp-logto/connector-oidc-patched/lib/index.js: Generated file
- services/alp-logto/connector-physionet-oidc/lib/index.js: Generated file
| name: { | ||
| en: "PhysioNet (OIDC)", | ||
| "zh-CN": "OIDC" | ||
| }, | ||
| logo: "./logo.svg", | ||
| logoDark: null, | ||
| description: { | ||
| en: OpenID Connect 1.0 federation to PhysioNet, with upstream access/refresh tokens exposed via globalThis.tokenMap so d2e can call PhysioNet APIs on behalf of the user. Originally a fork of the standard Logto OIDC connector. (en) is a simple identity layer on top of the OAuth 2.0 protocol.", | ||
| "zh-CN": "OpenID Connect 1.0 \u662F\u57FA\u4E8E OAuth 2.0 \u534F\u8BAE\u7684\u4E00\u4E2A\u7B80\u5355\u8EAB\u4EFD\u5C42\u3002" | ||
| }, |
| private async runEntitlementsSync(userId: string, idpUserId: string, bearerToken: string): Promise<void> { | ||
| try { | ||
| const entitlementsSync = Container.get(EntitlementsSyncService) | ||
| const token = jwt.decode(bearerToken) as jwt.JwtPayload | ||
| if (token) { | ||
| await entitlementsSync.sync(userId, idpUserId, token) | ||
| } | ||
| } catch (err) { | ||
| this.logger.warn(`[AutoProvision] entitlements sync failed for ${idpUserId}: ${err}; continuing`) | ||
| } | ||
| } |
| // M2M tokens have sub === client_id; skip user lookup but still | ||
| // set a minimal req.user so downstream middleware doesn't crash. | ||
| if (sub === token.client_id) { | ||
| req.user = { userId: '', idpUserId: sub } as ITokenUser | ||
| return next() | ||
| } |
| // Service tokens (e.g. WebAPI internal calls) have empty ctxUserId. | ||
| // Permission checks require a resolved user to enforce; without one | ||
| // the caller is an already-authenticated service, so pass through. | ||
| if (!ctxUserId) { | ||
| return next() | ||
| } |
| if (!ctxUserId) { | ||
| return next() | ||
| } |
| | `USERMGMT__AUTO_PROVISION_ENABLED` | bool | Auto-create a usermgmt.user row on first federated OIDC login (default `false`). | | ||
| | `USERMGMT__AUTO_PROVISION_CONNECTORS` | csv | Logto social-connector targets allowed to auto-provision (e.g. `physionet,oidc`). | | ||
| | `USERMGMT__AUTO_PROVISION_DEFAULT_TENANT_ID` | uuid | Tenant for the default TENANT_VIEWER group; falls back to `APP__TENANT_ID`. | | ||
| | `USERMGMT__AUTO_PROVISION_ROLE_HOOK_URL` | url | Optional. POSTs `{idpUserId,email,connectorId,accessToken}` and merges `{roles:[]}`.| | ||
| | `USERMGMT__AUTO_PROVISION_ROLE_HOOK_SECRET` | password | Optional bearer token sent to the role hook. | | ||
| | `USERMGMT__AUTO_PROVISION_ROLE_HOOK_TIMEOUT_MS` | number | Role hook abort timeout in ms (default `5000`). | | ||
| | `USERMGMT__ENTITLEMENTS_SYNC_ENABLED` | bool | Reconcile STUDY_RESEARCHER groups against the upstream IdP's entitlements view on every login (default `false`). | | ||
| | `USERMGMT__ENTITLEMENTS_PHYSIONET_BASE_URL` | url | PhysioNet base URL the entitlements sync calls (e.g. `https://physionet.org`). | | ||
| | `USERMGMT__ENTITLEMENTS_TIMEOUT_MS` | number | Entitlements fetch abort timeout in ms (default `10000`). | | ||
| | `USERMGMT__ENTITLEMENTS_TOKEN_CLAIM` | string | JWT claim name carrying the upstream access token (default `physionet_access_token`). | | ||
| | `LOGTO__SOCIAL_SIGNIN_TARGETS` | csv | Logto social-connector targets to enable on the sign-in screen. Defaults to the target of `LOGTO__CONNECTOR_CONFIG`. | |
| "USERMGMT__AUTO_PROVISION_ENABLED": "${USERMGMT__AUTO_PROVISION_ENABLED:-false}", | ||
| "USERMGMT__AUTO_PROVISION_CONNECTORS": "${USERMGMT__AUTO_PROVISION_CONNECTORS:-}", | ||
| "USERMGMT__AUTO_PROVISION_DEFAULT_TENANT_ID": "${USERMGMT__AUTO_PROVISION_DEFAULT_TENANT_ID:-}", | ||
| "USERMGMT__AUTO_PROVISION_ROLE_HOOK_URL": "${USERMGMT__AUTO_PROVISION_ROLE_HOOK_URL:-}", | ||
| "USERMGMT__AUTO_PROVISION_ROLE_HOOK_SECRET": "${USERMGMT__AUTO_PROVISION_ROLE_HOOK_SECRET:-}", | ||
| "USERMGMT__AUTO_PROVISION_ROLE_HOOK_TIMEOUT_MS": "${USERMGMT__AUTO_PROVISION_ROLE_HOOK_TIMEOUT_MS:-5000}", | ||
| "USERMGMT__ENTITLEMENTS_SYNC_ENABLED": "${USERMGMT__ENTITLEMENTS_SYNC_ENABLED:-false}", | ||
| "USERMGMT__ENTITLEMENTS_PHYSIONET_BASE_URL": "${USERMGMT__ENTITLEMENTS_PHYSIONET_BASE_URL:-}", | ||
| "USERMGMT__ENTITLEMENTS_TIMEOUT_MS": "${USERMGMT__ENTITLEMENTS_TIMEOUT_MS:-10000}", | ||
| "USERMGMT__ENTITLEMENTS_TOKEN_CLAIM": "${USERMGMT__ENTITLEMENTS_TOKEN_CLAIM:-physionet_access_token}", | ||
| "NIFI_MGMT__BASE_URL": "http://${PROJECT_NAME:-d2e}-minerva-nifi-mgmt-svc-1:4444/alp-nifi-api/", |
| signInMode: "SignInAndRegister", | ||
| socialSignIn: { automaticAccountLinking: true }, |
There was a problem hiding this comment.
Would this enable the "Register" button for all?
There was a problem hiding this comment.
It did. Registration is now opt-in behind a new LOGTO__ENABLE_REGISTRATION flag (default false), applied to both the base and social-connector sign-in experiences — so SignInAndRegister only kicks in when a deployment explicitly enables it. Fixed in d0848c0.
| USERMGMT__AUTO_PROVISION_ENABLED: "true" | ||
| USERMGMT__AUTO_PROVISION_CONNECTORS: "physionet" | ||
| USERMGMT__AUTO_PROVISION_DEFAULT_TENANT_ID: "e0348e4d-2e17-43f2-a3c6-efd752d17c23" | ||
| USERMGMT__ENTITLEMENTS_SYNC_ENABLED: "true" | ||
| USERMGMT__ENTITLEMENTS_PHYSIONET_BASE_URL: "http://physionet-host:8000" | ||
| USERMGMT__ENTITLEMENTS_DATASET_MAPPING: '{"demo":"demoeicu/1.0"}' |
There was a problem hiding this comment.
- Is this for your local or all developer will have to enable this by default?
- These ENV does not seems to be all. Missing HOOK_URL, HOOK_SECRET?
There was a problem hiding this comment.
- Made it opt-in — both master switches (
USERMGMT__AUTO_PROVISION_ENABLED,USERMGMT__ENTITLEMENTS_SYNC_ENABLED) now default tofalse, so the PhysioNet flow is off for local dev unless a dev sets them totrue(the rest stays pre-wired as env-overridable defaults). Fixed in f7ae567. - Intentional — the role hook is optional (
callRoleHookreturns early when the URL is unset), and local dev assigns roles via the entitlements sync, not the hook. TheHOOK_URL/HOOK_SECRETvars are documented inenv-vars.mdfor deployments that use a hook.
| img[alt="app logo"] { height: 40px; margin-bottom: 20px; } | ||
| button[name="submit"]{ background: #000080 !important; }`, | ||
| signInMode: "SignIn", //Disable user registration At Login screen | ||
| signInMode: process.env.LOGTO__CONNECTOR_CONFIG ? "SignInAndRegister" : "SignIn", |
There was a problem hiding this comment.
LOGTO__CONNECTOR_CONFIG used by Entra & Entra External ID. These 2 connector does not need "Register" button
There was a problem hiding this comment.
Agreed. Decoupled the Register button from LOGTO__CONNECTOR_CONFIG — it is now gated on the new LOGTO__ENABLE_REGISTRATION flag (default false), so Entra / Entra External ID keep a pure sign-in screen. Documented in env-vars.md and wired into docker-compose.yml. Fixed in d0848c0.
| signal: controller.signal, | ||
| }) | ||
| if (!response.ok) { | ||
| if (response.status === 404) return false |
There was a problem hiding this comment.
is 404 assumed as "no-access"?
There was a problem hiding this comment.
Yes, intentionally. A 404 from /oauth/dataset-access/ means PhysioNet has no access record for this user/dataset (or the slug/version is unknown), so we treat it as "no access" and withhold/revoke the researcher role. Other non-OK statuses throw and are treated as transient, so existing roles are kept rather than revoked on a blip. Added a clarifying comment in d0848c0.
| const entitlementsSync = Container.get(EntitlementsSyncService) | ||
| await entitlementsSync.sync(userId!, sub, token).catch(err => { | ||
| logger.warn(`[Entitlements] sync threw for ${sub}: ${err}; keeping existing roles`) | ||
| }) |
There was a problem hiding this comment.
This would be called on every calls. Is this fine?
There was a problem hiding this comment.
Gated and fail-soft: sync() returns immediately when USERMGMT__ENTITLEMENTS_SYNC_ENABLED is off, the base URL is unset, or the token has no PhysioNet claim — a no-op for non-PhysioNet deployments. When enabled it makes one upstream call per request to reconcile dataset access, with errors caught so existing roles are kept. We can add short-lived caching later if the per-request cost matters.
- connector-physionet-oidc/lib: fix invalid build artifact (unquoted description.en, mismatched zh-CN name) to match src/constant.ts - AutoProvisionService: strip "Bearer " prefix before jwt.decode so entitlements sync and the role-hook payload get the bare token - usermgmt authz: tag M2M/service tokens with SERVICE_USER_ID sentinel and bypass checks only for it; deny (403) unprovisioned end-users with empty userId instead of bypassing (privilege-escalation fix) - post-init: gate the Register button on new LOGTO__ENABLE_REGISTRATION flag so Entra / Entra External ID keep a pure sign-in screen - add USERMGMT__ENTITLEMENTS_DATASET_MAPPING to env-vars.md, docker-compose.yml and plugins/functions/package.json - EntitlementsSyncService: document 404 = no-access handling
# Conflicts: # docker-compose-local.yml
pg@8.7.3 depends on pg-protocol ^1.5.0; the 1.15.0 release (2026-06-19) ships .d.ts using generic Buffer<...>, which @types/node@^18 does not define, breaking the postinstall tsc with TS2315 'Type Buffer is not generic'. This broke the d2e Docker Build (d2e-pg-mgmt-init) across all PRs in the merge queue. Pin via yarn resolutions to 1.14.0 (plain Buffer).
Merge Checklist
Please cross check this list if additions / modifications needs to be done on top of your core changes and tick them off. Reviewer can as well glance through and help the developer if something is missed out.
developbranch)