---
title: "Staff SSO (Entra and OIDC) Configuration"
slug: staff-sso-configuration
section: Governance & Operations
status: stable
description: "How Globus back-office staff sign in through Microsoft Entra, a generic OIDC provider, or Google Workspace, and how that sign-in is configured and behaves."
last-updated: 2026-10-07
---

# Staff SSO (Entra and OIDC) Configuration


How Globus back-office staff sign in through Microsoft Entra, a generic OIDC provider, or Google Workspace, and how that sign-in is configured and behaves.

## Overview

Staff SSO is a browser redirect flow (authorization code with PKCE, state and nonce) implemented in `BackOfficePrototype server/entraAuth.ts` using the `openid-client` v6 functional API. It covers human staff sign-in to the back office; see [Roles & Governance](/docs/rbac) for the RBAC model that decides what a signed-in staff user can do. The rbac page lists the SSO error codes briefly; this page is the full reference.

A successful callback mints the same session as local email and password login (session regenerated, `userId` and the session user set via `buildSessionUser`), then redirects to a sanitised return path. Identities that cannot be matched to an active account never get a session; they are redirected to `/auth/pending`.

The module reuses the single `express-session` middleware mounted by local auth. `setupEntraAuth` is called from `server/routes.ts` after local auth setup.

## Routes

All SSO routes are registered in `setupEntraAuth` as plain `GET` handlers with no `isAuthenticated` or `requirePermission` wrapper: they are the sign-in path itself. The routes live under `/auth/*` and `/api/auth/me` (the `humanGate` middleware treats paths under `/auth/` as bypassed, `server/middleware/humanGate.ts`).

### Start: GET /auth/login

| Item | Behaviour |
|---|---|
| Auth | None |
| Query `provider` | `google` or `entra`. Honoured only if that provider is configured; otherwise the first configured provider is used (falling back to `entra`) |
| Query `returnTo` | Passed through `safeReturnTo`: must be a path starting with a single `/`; protocol-relative, absolute and backslash forms become `/` |
| Side effects | Generates state, nonce and a PKCE S256 verifier, stores them in `req.session.entraAuth` together with `returnTo` and the provider, saves the session |
| Success | 302 to the IdP authorization URL (scope `openid profile email`, redirect URI is the request origin plus `/auth/callback`) |
| Failure | 503 `SSO_NOT_CONFIGURED`, or 500 `SSO_INIT_FAILED` (for example discovery failed) |

### Callback: GET /auth/callback

| Item | Behaviour |
|---|---|
| Auth | None; relies on the in-flight `entraAuth` session material |
| Steps | Check configured, check session material, `authorizationCodeGrant` with the stored verifier, state and nonce, read ID-token claims, run tenant check then Google domain check, delete the session material (one-shot, so a replayed callback gets `SSO_STATE_MISSING`), then run account matching |
| Success | 302 to the stored `returnTo`, or 302 to `/auth/pending` (see below) |
| Failure | JSON error envelope with the codes in the error table |

Failures use the standard envelope `success: false` with `error.code`, `error.message`, `error.details`, `error.traceId`. These SSO codes are top-level `error.code` values, not `details.code` under a generic code.

### Logout, probe and admin routes

| Verb and path | Auth | Behaviour |
|---|---|---|
| `GET /auth/logout` | None | If SSO is configured, builds the IdP end-session URL (post-logout redirect to the site root) using the primary provider config; falls back to `/` if unconfigured or discovery fails. Writes `auth.logout` audit when a session user exists, destroys the session, clears the `connect.sid` cookie, redirects |
| `GET /api/auth/me` | None | Returns `configured`, `oidcProvider`, `oidcProviders`, `authenticated` and, when authenticated, a user object with `id`, `email`, `firstName`, `lastName`, `role`. Responds 200 with JSON (no 401 for anonymous callers) |
| `POST /api/admin/users/:id/link-sso` | `isAuthenticated` plus `users.manage` | Binds an Entra subject to an existing user (see Activating or linking a pending account) |
| `PATCH /api/admin/users/:id` | `isAuthenticated` plus `users.manage` | Accepts `status` of `active` or `pending` (other values: 400 `VALIDATION_ERROR`) |

## Configuration

Variables are read from `process.env` at request time. The k8s manifests under `BackOfficePrototype k8s/<env>/` inject them from ExternalSecrets in development, QA and prod.

| Variable | Required | Meaning |
|---|---|---|
| `OIDC_DISCOVERY_URL` | Optional (all three `OIDC_*` needed for the primary provider) | Full OpenID Connect discovery URL. If it contains `accounts.google.com` the provider is `google`, otherwise `entra` |
| `OIDC_CLIENT_ID` | With the above | OAuth2 client id |
| `OIDC_CLIENT_SECRET` | With the above | OAuth2 client secret |
| `ENTRA_TENANT_ID` | Optional (all three `ENTRA_*` needed) | Legacy Entra mode. The discovery URL is derived as `https://login.microsoftonline.com/{tenant}/v2.0`. Also drives the tenant check |
| `ENTRA_CLIENT_ID` | With the above | Entra client id; used as the client id when `OIDC_CLIENT_ID` is unset |
| `ENTRA_CLIENT_SECRET` | With the above | Entra client secret; used when `OIDC_CLIENT_SECRET` is unset |
| `GOOGLE_WORKSPACE_DOMAIN` | Optional | When set, Google logins must carry a matching `hd` claim. No effect when unset or for non-Google logins |

SSO is considered configured (`isEntraConfigured`) when all three `OIDC_*` variables or all three `ENTRA_*` variables are set. No defaults exist for any of them. Discovery results are memoized for one hour per process.

The `k8s` manifests wire `ENTRA_*`, `OIDC_*` and `GOOGLE_WORKSPACE_DOMAIN` in development and prod; in QA only the `ENTRA_*` and `OIDC_*` entries were found.

### Provider selection

| Environment | Providers offered | Discovery used |
|---|---|---|
| Only `ENTRA_*` set | `entra` | Derived Entra v2.0 URL |
| Only `OIDC_*` set | `google` if URL contains `accounts.google.com`, else `entra` | `OIDC_DISCOVERY_URL` |
| Both set, `OIDC_*` pointing at Google | `google` and `entra` (dual provider) | Google via `OIDC_*`, Entra via `ENTRA_*` |

Tenant check (`SSO_TENANT_FORBIDDEN`) only runs when `OIDC_DISCOVERY_URL` is unset and `ENTRA_TENANT_ID` is set, so it does not run in dual-provider mode or for generic OIDC. In dual-provider mode the logout handler builds its end-session URL from the primary (`OIDC_*`) configuration.

## SSO error codes

| Code | HTTP | Raised by | Cause |
|---|---|---|---|
| `SSO_NOT_CONFIGURED` | 503 | login, callback | Neither the `OIDC_*` nor the `ENTRA_*` variable set is complete |
| `SSO_INIT_FAILED` | 500 | login | Discovery or authorization request construction threw; `details` carries the error message |
| `SSO_STATE_MISSING` | 400 | callback | No `entraAuth` material in the session (expired session, replayed callback, cookie lost) |
| `SSO_TENANT_FORBIDDEN` | 403 | callback | Legacy Entra mode with `ENTRA_TENANT_ID`: `tid` claim missing or different |
| `SSO_DOMAIN_FORBIDDEN` | 403 | callback | Google login with `GOOGLE_WORKSPACE_DOMAIN` set: `hd` claim missing or different (case-insensitive compare). Personal Google accounts have no `hd` and are rejected |
| `SSO_CALLBACK_FAILED` | 401 | callback | Any other error in the try block: code exchange, state or nonce mismatch, token validation, or an exception from account matching (such as a database error); `details` carries the message |
| `SSO_SESSION_FAILED` | 500 | callback, after matching | `session.regenerate` or `session.save` failed |

## Account matching and pending accounts

`completeEntraLogin` resolves the verified identity in this order.

1. Durable match: if the identity has a subject and a provider, look up `users.provider_subjects->>provider`. For `entra` it also falls back to the legacy `users.oid` column. The subject is `oid` (Entra) or `sub`.
2. Email handshake: case-insensitive match on `users.email` (email from `email`, `preferred_username` or `upn`). If the same provider already holds a different subject for that user, the login is a conflict. Otherwise the subject is stamped onto the user (first link, including a first link from a second provider).
3. No match: with no email, redirect with `reason=no_email`. With an email, insert a zero-permission row (`role` null, `is_active` false, `status` `pending`) and redirect to `/auth/pending`.
4. Matched but `status` is `pending` or `is_active` is false: redirect to `/auth/pending`; no session.
5. Otherwise update `last_login_at`, write the audit entry and mint the session.

### The /auth/pending redirects

| Redirect | Trigger |
|---|---|
| `/auth/pending` | New pending record created, or matched user is pending or inactive |
| `/auth/pending?reason=conflict` | Same provider already bound to a different subject for that email |
| `/auth/pending?reason=no_email` | Identity carried no usable email and no durable match |

`/auth/pending` is a client route rendered by `client/src/pages/auth-pending.tsx` before the landing page. It shows "Awaiting administrator approval" (default and `no_email`) or "Account needs administrator review" (`conflict`). The copy refers to a Microsoft sign-in even for Google logins.

### Activating or linking a pending account

An administrator with `users.manage` can activate a pending user with `PATCH /api/admin/users/:id` (`status: "active"`), or move a pending identity onto an existing user with `POST /api/admin/users/:id/link-sso`. Link request body is `pendingUserId` (a user whose `status` is `pending`) or `oid` directly.

| Status | Code | Condition |
|---|---|---|
| 404 | `NOT_FOUND` | Target user missing |
| 400 | `VALIDATION_ERROR` | Pending account not found or not pending; linking to itself; no oid available |
| 409 | `OID_ALREADY_BOUND` | The Entra subject is already held by a different user (top-level code, not under `CONFLICT`) |

On success the pending row is deleted, the target gets the `oid` and `auth_provider` `entra`, and `user.sso_link` is audited. The handler does not write `provider_subjects`; later Entra logins still match through the legacy `oid` fallback. The handler only handles Entra identities: it checks and stamps `oid`, and uses `findByProviderSubject("entra", oid)`.

## Data model

Table `users` (`@shared/schema`) columns used by SSO: `email`, `oid` (legacy Entra object id), `provider_subjects` (JSONB map such as entra to uuid, google to sub), `auth_provider` (`entra`, or `oidc` for Google and generic OIDC), `status` (`pending` or active), `is_active`, `role`, `last_login_at`. Audit rows go to the audit log with entity type `session`.

## MFA and local login interplay

The SSO callback path in `entraAuth.ts` contains no MFA check: after matching, it mints the session directly. MFA (`isMfaEnabled`, `isMfaRequiredForRole`, `mfaService`) is evaluated only in `POST /api/auth/login` (`server/auth/loginRoutes.ts`). Whether the IdP enforces MFA is outside the code.

The same local login route enforces the SSO link: when SSO is configured and the user has an `oid` or `auth_provider` of `entra`, a password login that passes credential verification returns 403 `ENTRA_SSO_REQUIRED`, unless the user holds `system.admin` (god mode), which stays allowed as break-glass. Unlinked accounts can still use passwords. The check happens after the password is verified.

## Audit events

`auth.oidc_login`, `auth.oidc_login_blocked`, `auth.oidc_login_denied` (no email), `auth.oidc_pending_created`, `auth.oidc_subject_conflict`, `auth.logout`, `user.sso_link`. Audit writes in the bridge are best-effort and never block the login.

## Known gaps

- No code reviewed sets `is_active` to true when an admin sets `status` to `active`; both are accepted as separate fields by the PATCH handler, so confirm in the user-admin UI that both are sent.
- The link-sso endpoint handles Entra subjects only; no equivalent for a pending Google identity was found.
- The rbac page and the in-repo comments call the module "Entra" although it serves Google and generic OIDC too.
- Whether QA sets `GOOGLE_WORKSPACE_DOMAIN` was not found in the k8s manifests.
