Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f812c86
feat: derive the ID-token issuer from presets and OIDC discovery (#264)
heskew Oct 2, 2026
343a8ae
test: cover OIDC discovery, Azure issuer binding, and the Okta auth-s…
heskew Oct 2, 2026
f6f3c06
test: cover real-signature JWKS verification, discovery-await orderin…
heskew Oct 2, 2026
b0c6a65
test: cover discovery kickoff lifecycle through a real reload (#264)
heskew Oct 2, 2026
aeb52fc
test: cover the Graph profile-scope warning and the TenantManager aut…
heskew Oct 2, 2026
509bcda
docs: document OIDC discovery, Okta auth-server, and Azure multi-tena…
heskew Oct 2, 2026
8349435
fix: resolve pre-push review findings on the issuer-discovery branch
heskew Oct 2, 2026
210bc37
Merge origin/main into feat/issuer-discovery
heskew Oct 2, 2026
3a177f9
Merge origin/main into feat/issuer-discovery
heskew Oct 2, 2026
33ce64b
fix: pass raw error objects to the logger on three discovery error paths
heskew Oct 2, 2026
877f18c
fix: resolve post-ready review blockers (DNS capacity retry, Azure pi…
heskew Oct 2, 2026
df56ad5
fix: validate Azure issuer-pin form against the authorize endpoint; n…
heskew Oct 2, 2026
8d08b45
fix: close the tenant-domain JWKS exemption gap, stop per-cached-hit …
heskew Oct 2, 2026
98dbebf
fix: derive the unpinned GUID issuer form from authorizationUrl; docu…
heskew Oct 2, 2026
cc03aee
fix: checkGraphProfileScope must not throw on a non-string scope
heskew Oct 2, 2026
f3d4972
fix: warn when authorizationUrl and jwksUri name different Azure tenants
heskew Oct 2, 2026
f7a487f
fix: recognize the v1 shared-alias JWKS shape to avoid a regression a…
heskew Oct 2, 2026
db51cf5
Merge origin/main into feat/issuer-discovery
heskew Oct 2, 2026
cf8ea43
fix: Azure tenant-mismatch warning false positive and missing logger …
heskew Oct 2, 2026
ac87802
fix: tenant-mismatch warning false positive on the consumers/AZURE_CO…
heskew Oct 2, 2026
3d02d9b
fix: dynamic-resolution logger wrapper dropped prototype methods
heskew Oct 2, 2026
1365802
docs: qualify the Azure tenant-mismatch warning's dynamic-resolution …
heskew Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ No central `errors.ts` — error classes are defined locally near where they're

## Where design decisions live

Non-trivial issues carry their design and status in the issue body itself, kept current as work lands — read the issue before re-deciding something it already settled (current examples: #231, #244, #212, #213, #264). `docs/mcp-oauth-conformance.md` maps every MCP requirement to its implementing code and test and must be updated in the same PR as any behavior change. See **[docs/maintaining.md](docs/maintaining.md)** for the release process, CI/review automation, and the Harper peer-dependency relationship.
Non-trivial issues carry their design and status in the issue body itself, kept current as work lands — read the issue before re-deciding something it already settled (current examples: #231, #244, #212, #213, #264). `docs/mcp-oauth-conformance.md` maps every MCP requirement to its implementing code and test and must be updated in the same PR as any behavior change. See **[docs/maintaining.md](docs/maintaining.md)** for the release process, CI/review automation, and the Harper peer-dependency relationship. See `src/lib/DESIGN.md` for invariants around issuer derivation (OIDC discovery, Azure multi-tenant adoption, #264) that aren't obvious from the code alone.

## Dependencies

Expand Down
23 changes: 21 additions & 2 deletions CHANGELOG.md

Large diffs are not rendered by default.

58 changes: 33 additions & 25 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,9 @@ There are exactly **two** trusted email sources. A login may adopt an existing a
**Source 1 — JWKS-signed OIDC id token (`signed-oidc`)**:

- The email is a claim in a JWKS-signature-verified id token.
- The token's `iss` was validated against the provider's **expected issuer** (`config.issuer`). Providers without a known issuer — Azure `/common` (multi-tenant) — are not trusted for adoption.
- The token's `iss` was validated against the provider's **expected issuer** (`config.issuer`). An Azure `/common`/`/organizations`/`consumers` provider with no `issuer` pinned is not trusted for adoption — see [Multi-Tenant Apps and Account Adoption](./providers.md#multi-tenant-apps-and-account-adoption) for how to make one specific tenant adoption-eligible.

Any other JWKS-enabled provider (Okta, Auth0, or a `generic` OIDC config) that sets `jwksUri` but has no usable `issuer` fails at startup instead, naming the provider and the `issuer` key — this is almost always a bypassed shortcut (Okta/Auth0's `domain` option, which derives `issuer` for you) rather than an intentionally issuer-less provider. Set `issuer` explicitly, or use `domain`/`tenantId`.
**You almost never need to set `issuer` yourself.** Presets derive it from `domain`/`tenantId`/`authServer` whenever it's knowable, and any other JWKS-enabled provider (Okta, Auth0, or a `generic` OIDC config) that sets `jwksUri` with explicit endpoints but no usable `issuer` derives it automatically in the background via [OIDC discovery](./providers.md#issuer-is-usually-unnecessary) — this never blocks startup, and the first login that needs it waits only briefly for an in-flight attempt; every other login proceeds without waiting. Only a config that's wrong _independent_ of the network — no `authorizationUrl`, or a non-`https` one — still fails fast at startup, naming the provider and the `issuer` key.

- `email_verified === true` for the **standard `email` claim**. Providers that use a custom `emailClaim` cannot earn adoption trust because `email_verified` only attests the standard claim.
- The verified email equals the resolved username (`usernameClaim === 'email'`).
Expand Down Expand Up @@ -434,21 +434,23 @@ Only VERIFIED addresses from GitHub's authenticated fetch are ever candidates fo

**Denied by default:**

| Scenario | Reason |
| ------------------------------------------------------------------- | ---------------------------------------------------- |
| GitHub `login` claim → existing account | `login` ≠ email; username and email differ |
| Okta `preferred_username` ≠ email → existing account | username claim differs from email |
| `email_verified` absent or false | Provider has not attested the email |
| Custom `emailClaim` (non-`email` claim used as identity) | `email_verified` does not attest the custom claim |
| Unsigned / unverified id token | Signature not verified |
| JWKS-signed token from issuer-less Azure `/common` (multi-tenant) | Issuer cannot be validated; nOAuth class attack |
| Azure id token without `email_verified` | No verified-email attestation available |
| Plain UserInfo response (no id token) | No authenticated binding to the identity |
| GitHub `/user/emails` fetch failed or returned non-OK | Email not confirmed by an authenticated call |
| GitHub `/user/emails` returned `verified: false` | Email not verified by GitHub |
| Non-GitHub provider with custom `getUserInfo` adapter | `github-authenticated` requires `provider=github` |
| Custom adapter returning `_emailProvenance: 'github-authenticated'` | Provenance is plugin-assigned; adapter value ignored |
| `fetchEmail: true` resolved email from UserInfo (no signed token) | Email is present but provenance is unauthenticated |
| Scenario | Reason |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| GitHub `login` claim → existing account | `login` ≠ email; username and email differ |
| Okta `preferred_username` ≠ email → existing account | username claim differs from email |
| `email_verified` absent or false | Provider has not attested the email |
| Custom `emailClaim` (non-`email` claim used as identity) | `email_verified` does not attest the custom claim |
| Unsigned / unverified id token | Signature not verified |
| JWKS-signed token from an unpinned Azure `/common` (multi-tenant) | Issuer cannot be validated; nOAuth class attack |
| Azure id token from a tenant other than a pinned multi-tenant provider's | ID token verification fails outright (not just denied adoption) — pinning makes the provider single-tenant |
| Login arriving before background OIDC discovery resolves | No validated issuer yet for this attempt |
| Azure id token without `email_verified` | No verified-email attestation available |
| Plain UserInfo response (no id token) | No authenticated binding to the identity |
| GitHub `/user/emails` fetch failed or returned non-OK | Email not confirmed by an authenticated call |
| GitHub `/user/emails` returned `verified: false` | Email not verified by GitHub |
| Non-GitHub provider with custom `getUserInfo` adapter | `github-authenticated` requires `provider=github` |
| Custom adapter returning `_emailProvenance: 'github-authenticated'` | Provenance is plugin-assigned; adapter value ignored |
| `fetchEmail: true` resolved email from UserInfo (no signed token) | Email is present but provenance is unauthenticated |

Two further denials happen earlier, during [email resolution itself](#github-default-email-when-there-are-several-verified-addresses) (`usernameClaim: 'email'` only) — not from the trust-source table above, but from which address gets picked in the first place:

Expand All @@ -459,14 +461,16 @@ Two further denials happen earlier, during [email resolution itself](#github-def

**Allowed:**

| Scenario | Reason |
| -------------------------------------------------------------- | ----------------------------------------------------------- |
| Google (JWKS-signed token, `email_verified=true`) | `signed-oidc`: issuer validated, email verified |
| Auth0 (JWKS-signed token, `email_verified=true`) | `signed-oidc`: issuer derived from domain |
| Okta (JWKS-signed token, `preferred_username` equals email) | `signed-oidc`: issuer derived from domain |
| Azure single-tenant (JWKS-signed, `email_verified=true`) | `signed-oidc`: issuer derived from tenant ID |
| GitHub (`provider=github`) with verified email, username=email | `github-authenticated`: successful `/user/emails`, verified |
| `onLogin` hook returns `{ user }` | Hook path, gate skipped |
| Scenario | Reason |
| --------------------------------------------------------------------- | ----------------------------------------------------------- |
| Google (JWKS-signed token, `email_verified=true`) | `signed-oidc`: issuer validated, email verified |
| Auth0 (JWKS-signed token, `email_verified=true`) | `signed-oidc`: issuer derived from domain |
| Okta (JWKS-signed token, `preferred_username` equals email) | `signed-oidc`: issuer derived from domain |
| Azure single-tenant (JWKS-signed, `email_verified=true`) | `signed-oidc`: issuer derived from tenant ID |
| Azure multi-tenant with `issuer` pinned to the matching tenant | `signed-oidc`: verified against that tenant's own keys |
| Okta custom authorization server, explicit endpoints, no `issuer` set | `signed-oidc`: issuer derived via OIDC discovery |
| GitHub (`provider=github`) with verified email, username=email | `github-authenticated`: successful `/user/emails`, verified |
| `onLogin` hook returns `{ user }` | Hook path, gate skipped |

### Escape hatch: `allowUnverifiedClaimInheritance`

Expand Down Expand Up @@ -498,6 +502,10 @@ Default: `false` (off). Junk values, unresolved `${VAR}` placeholders (environme

For a roleless login with no matching account and an untrusted claim, the plugin sets the session identity to a **quarantine principal** of the form `unverified:<claim>#<random>`. The random suffix makes the value unpredictable, so it can never be created as an `hdb_user` and can never resolve to a role. Unlike a fixed reserved prefix, it requires no naming convention and no fail-closed collision check — its security comes from unpredictability, not from a reserved namespace. The claim is embedded before the `#` purely for log readability and is never parsed back out.

### OIDC discovery and startup behavior (upgrade note)

Before this release, a statically configured JWKS-enabled provider with explicit endpoints and no usable `issuer` failed to start (see the `[Unreleased]`/2.9.0 CHANGELOG entry for #231 §4). As of this release, that config now boots normally whenever `authorizationUrl` is `https://` — the issuer is instead derived in the background via OIDC discovery (non-`https` is unchanged: it still fails fast, since no amount of waiting fixes that). While discovery is pending, or if it never resolves (e.g. the IdP doesn't publish a discovery document, or it doesn't validate against your configured endpoints), logins through that provider proceed exactly as any other "signed but not issuer-validated" login already does: a login that would adopt an **existing** account is **denied**, and a login with **no** existing account match proceeds as a quarantined, roleless session — the same behavior Azure `/common` has always had. A session already created while discovery was pending does not retroactively gain trust once discovery later succeeds; only a subsequent login benefits. A failed discovery attempt is retried only on a live config reload (or a restart) at least 5 minutes after the failure — never automatically, and never from a login path. A reload inside that 5-minute window is a no-op, so a transient DNS/network blip does **not** recover "on its own": it requires an operator-initiated reload after the cooldown, or a restart. Each Harper worker thread also holds its own discovery cache (module state, not shared), so the same blip can affect only some of a multi-worker deployment's threads — whether a given login is denied adoption depends on which worker served it.

### Pre-existing sessions (upgrade note)

Harper sessions do not expire by default. A session established **before this release** still holds a raw claim as its identity, which Harper resolves by name against `hdb_user` — and the login-time gate governs only _new_ logins, so it cannot retract an already-persisted session. Every OAuth login from this release forward records a provenance stamp (`authTrust`) on the session; a session whose `oauth` metadata carries no stamp is a **legacy** (pre-gate) session.
Expand Down
48 changes: 48 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ export OAUTH_REDIRECT_URI="https://yourdomain.com/oauth/callback"

[Microsoft Graph Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference)

### Multi-Tenant Apps and Account Adoption

`tenantId` set to a real tenant GUID derives a fixed issuer, same as any other provider. Leaving `tenantId` unset (or set to `common`/`organizations`/`consumers`) accepts sign-ins from any tenant, but — because there's no single fixed issuer for those, and no key set exclusive to one tenant — ID tokens aren't trusted for [account adoption](./configuration.md#account-adoption-gate): logins work, but every login is denied adoption (or quarantined), exactly as for any other unverified claim.

Pinning `issuer` alongside a multi-tenant `tenantId` does **not** keep the provider multi-tenant with per-login adoption gating. It turns the provider into a single-tenant one, for exactly the one tenant named by the pin — the plugin redirects verification to that tenant's own, non-shared signing keys and enforces that tenant's issuer, exactly as if you had set `tenantId` to that tenant's GUID directly. Sign-ins from every other tenant stop working outright: ID token verification fails, and the Microsoft Graph `/v1.0/me` fallback can't rescue the login either, since Graph returns `mail`, not `email` — the preset's default `usernameClaim`.

```yaml
'@harperfast/oauth':
providers:
azure:
tenantId: 'common' # only used to resolve the pin below to a real tenant
issuer: 'https://login.microsoftonline.com/<your-tenant-guid>/v2.0' # provider becomes single-tenant: only this tenant signs in
clientId: ${OAUTH_AZURE_CLIENT_ID}
clientSecret: ${OAUTH_AZURE_CLIENT_SECRET}
```

If you need more than one tenant to sign in, configure a separate provider entry per tenant (each pinned to its own tenant GUID) rather than pinning one multi-tenant provider to an array of issuers — an array would let those tenants adopt each other's Harper accounts if they ever shared an email, so it's rejected outright.

### `userInfoUrl` fetchEmail and the `profile` scope

If you override `userInfoUrl` away from Microsoft Graph, or set a custom `scope`, keep `profile` in `scope` whenever `fetchEmail: true` is set — the Graph UserInfo correlation needs the ID token's `oid` claim, which Azure only includes when `profile` was requested. The plugin warns at startup if this combination can't work.

---

## Auth0 (OIDC)
Expand Down Expand Up @@ -313,6 +335,22 @@ To include groups in the ID token:

[Okta OAuth Documentation](https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/)

### Using a Custom Authorization Server

`domain` alone derives the **org authorization server** (`/oauth2/v1/*`), whose issuer is just `https://{domain}`. If you're using a [custom authorization server](https://developer.okta.com/docs/concepts/auth-servers/) instead (e.g. `default`, or a generated ID), set `authServer` alongside `domain` — the plugin derives both the path-inclusive endpoints and the issuer for you:

```yaml
'@harperfast/oauth':
providers:
okta:
domain: ${OAUTH_OKTA_DOMAIN}
authServer: 'default' # or your custom auth server's ID
clientId: ${OAUTH_OKTA_CLIENT_ID}
clientSecret: ${OAUTH_OKTA_CLIENT_SECRET}
```

Without `authServer`, pointing `authorizationUrl`/`tokenUrl`/`userInfoUrl`/`jwksUri` at a custom authorization server directly (bypassing `domain`) no longer requires setting `issuer` explicitly — OIDC discovery derives it for you in the background instead. See [`issuer` is usually unnecessary](#issuer-is-usually-unnecessary) below.

---

## Custom OIDC Provider
Expand Down Expand Up @@ -347,6 +385,16 @@ export OAUTH_CUSTOM_USERINFO_URL="https://provider.com/oauth/userinfo"
export OAUTH_CUSTOM_JWKS_URL="https://provider.com/.well-known/jwks.json"
```

### `issuer` is usually unnecessary

When `jwksUri` is set but `issuer` isn't, the plugin derives it in the background via [OIDC discovery](https://openid.net/specs/openid-connect-discovery-1_0.html): it probes `.well-known/openid-configuration` at your `authorizationUrl`'s own origin and validates the result strictly against your configured endpoints before trusting it. This never blocks startup, and the first login that needs it waits only briefly for an in-flight attempt. On success, the server log names the exact value — copy it into `issuer` to pin it and skip discovery on future logins:

```
OIDC discovery for provider 'custom': issuer derived via discovery: https://provider.com/ — add issuer: https://provider.com/ to pin it.
```

Discovery only runs for an `https://` `authorizationUrl` — a non-`https` one (and, separately, `jwksUri` with no usable `issuer` and no way to derive one) still fails fast at startup, since no amount of waiting fixes that. See [Understanding `issuer`](./configuration.md#account-adoption-gate) for what a missing/undiscoverable issuer means for account adoption.

---

## Testing Your Configuration
Expand Down
Loading
Loading