@open-everywhere/adapters 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,14 @@
1
1
  // THE OKTA DIRECTORY ADAPTER — everything it returns is a PROPOSAL, and nothing here confirms a link. The
2
2
  // grade follows the field: `externalId` is attested (the SCIM write-back), `credentials.userName` asserted.
3
3
 
4
- import { getSecretVia, type DirectoryAccount, type DirectoryPerson, type DirectoryProvider } from "@open-everywhere/core/agent/adapter-api.js";
4
+ import {
5
+ declaredConnectionSignIns,
6
+ getSecretVia,
7
+ OKTA_LINK_PLATFORM,
8
+ type DirectoryAccount,
9
+ type DirectoryPerson,
10
+ type DirectoryProvider,
11
+ } from "@open-everywhere/core/agent/adapter-api.js";
5
12
  import type { OktaAppMapping, OktaDirectoryConfig } from "./config.js";
6
13
 
7
14
  /** A `fetch`-shaped transport — the DI seam the offline test injects (defaults to global fetch). */
@@ -63,6 +70,34 @@ export interface OktaDirectoryDeps {
63
70
  readonly fetch?: FetchLike;
64
71
  /** Resolve the API token. Default: core's broker seam — see config.ts's header. */
65
72
  readonly resolveToken?: (broker: string, ref: string) => Promise<string>;
73
+ /** Does a connection sign people in through THIS Okta org for Cross App Access? Default: a declared `xaa`
74
+ * sign-in whose `idpIssuer` has this org's origin. See {@link xaaNamesOrg}. */
75
+ readonly proposeOwnAccount?: () => boolean;
76
+ }
77
+
78
+ /**
79
+ * (OPE-2453) True when a declared `xaa` sign-in names this Okta org as its IdP. Only then does the arm propose each
80
+ * person's OWN Okta account (`okta`, the Okta user id) beside the app accounts: the Cross App Access completion
81
+ * admits a sign-in only for a confirmed `okta` account, and nothing else writes one. With no `xaa` declaration
82
+ * the arm proposes exactly what it did before, so a deployment that does not use Cross App Access sees no change.
83
+ */
84
+ export function xaaNamesOrg(orgUrl: string, declared: ReadonlyMap<string, unknown> = declaredConnectionSignIns()): boolean {
85
+ let origin: string;
86
+ try {
87
+ origin = new URL(orgUrl).origin;
88
+ } catch {
89
+ return false;
90
+ }
91
+ for (const v of declared.values()) {
92
+ const rec = v as { mode?: unknown; idpIssuer?: unknown };
93
+ if (rec.mode !== "xaa" || typeof rec.idpIssuer !== "string") continue;
94
+ try {
95
+ if (new URL(rec.idpIssuer).origin === origin) return true;
96
+ } catch {
97
+ continue;
98
+ }
99
+ }
100
+ return false;
66
101
  }
67
102
 
68
103
  /** The display name to show a human, from whichever profile fields Okta actually filled in. DISPLAY
@@ -91,6 +126,7 @@ export function makeOktaDirectoryProvider(deps: OktaDirectoryDeps): DirectoryPro
91
126
  const { config } = deps;
92
127
  const fetchImpl = deps.fetch ?? fetch;
93
128
  const resolveToken = deps.resolveToken ?? ((broker: string, ref: string) => getSecretVia(broker, ref));
129
+ const proposeOwnAccount = deps.proposeOwnAccount ?? (() => xaaNamesOrg(config.orgUrl));
94
130
 
95
131
  // The operator's OWN Okta origin — the ONLY origin the SSWS token may ever be sent to. Computed
96
132
  // once; every request in getAll (the first AND every `Link: rel="next"` hop) is checked against it.
@@ -196,6 +232,8 @@ export function makeOktaDirectoryProvider(deps: OktaDirectoryDeps): DirectoryPro
196
232
  const users = await getAll<OktaUser>(`/api/v1/users?limit=${PAGE_LIMIT}`, token);
197
233
  const groups = await groupsByUser(token);
198
234
  const accounts = await accountsByUser(token);
235
+ // Read once per sync, like the token: a declaration change takes effect at the next tick.
236
+ const ownAccount = proposeOwnAccount();
199
237
 
200
238
  const people: DirectoryPerson[] = [];
201
239
  for (const user of users) {
@@ -205,7 +243,8 @@ export function makeOktaDirectoryProvider(deps: OktaDirectoryDeps): DirectoryPro
205
243
  externalId: user.id,
206
244
  displayName: displayNameOf(user),
207
245
  ...(email ? { email } : {}),
208
- accounts: accounts.get(user.id) ?? [],
246
+ // The Okta user id IS Okta's own answer for its own account, so the grade is "attested".
247
+ accounts: ownAccount ? [...(accounts.get(user.id) ?? []), { platform: OKTA_LINK_PLATFORM, nativeId: user.id, grade: "attested" }] : (accounts.get(user.id) ?? []),
209
248
  groups: (groups.get(user.id) ?? []).slice().sort(),
210
249
  deprovisioned: DEPROVISIONED_STATUSES.has((user.status ?? "").toUpperCase()),
211
250
  });
@@ -14,6 +14,9 @@ export const OE_OAUTH_MODE_DISCOVERY = "discovery-dcr-pkce";
14
14
  /** The static confidential-client dialect (OPE-1086) — no discovery, no registration, HTTP Basic. */
15
15
  export const OE_OAUTH_MODE_STATIC = "static-confidential";
16
16
 
17
+ /** The Cross App Access dialect (OPE-2453) — an IdP sign-in once, then an ID-JAG mint per app token. */
18
+ export const OE_OAUTH_MODE_XAA = "xaa";
19
+
17
20
  /** How a static provider encodes its token-request body. Declared, never inferred: Notion's REST
18
21
  * token endpoint takes JSON, the RFC 6749 default is form-encoded, and sending the wrong one reads
19
22
  * as an authentication failure rather than as a shape error. */
@@ -66,8 +69,36 @@ export interface OeOauthStaticProviderConfig extends OeOauthProviderConfigBase {
66
69
  readonly bodyEncoding: OeOauthBodyEncoding;
67
70
  }
68
71
 
69
- /** One provider's OAuth target, in one of the two declared modes. */
70
- export type OeOauthProviderConfig = OeOauthDiscoveryProviderConfig | OeOauthStaticProviderConfig;
72
+ /**
73
+ * A Cross App Access provider (OPE-2453). The person signs in to the IdP (`idpIssuer`) once; each mint then trades
74
+ * the person's IdP refresh token for an ID-JAG (RFC 8693, at the IdP) and the ID-JAG for the app's access token
75
+ * (RFC 7523, at `resourceTokenEndpoint`). Two confidential clients, two secret REFS, no literal secret.
76
+ */
77
+ export interface OeOauthXaaProviderConfig extends OeOauthProviderConfigBase {
78
+ readonly mode: typeof OE_OAUTH_MODE_XAA;
79
+ /** The IdP's OIDC issuer. Its `/.well-known/openid-configuration` names the authorize, token, JWKS and
80
+ * revocation endpoints. HTTPS only. */
81
+ readonly idpIssuer: string;
82
+ readonly idpClientId: string;
83
+ readonly idpClientSecretRef: string;
84
+ readonly idpClientSecretBroker: string;
85
+ /** The app's authorization server issuer — the ID-JAG `audience`. */
86
+ readonly audience: string;
87
+ /** The RFC 8707 resource both legs name, when the app asks for one. */
88
+ readonly resource?: string;
89
+ readonly resourceTokenEndpoint: string;
90
+ readonly resourceClientId: string;
91
+ readonly resourceClientSecretRef: string;
92
+ readonly resourceClientSecretBroker: string;
93
+ }
94
+
95
+ /** One provider's OAuth target, in one of the three declared modes. */
96
+ export type OeOauthProviderConfig = OeOauthDiscoveryProviderConfig | OeOauthStaticProviderConfig | OeOauthXaaProviderConfig;
97
+
98
+ /** Narrow a provider config to the Cross App Access mode. Reads the DECLARED `mode` only. */
99
+ export function isXaa(cfg: OeOauthProviderConfig): cfg is OeOauthXaaProviderConfig {
100
+ return cfg.mode === OE_OAUTH_MODE_XAA;
101
+ }
71
102
 
72
103
  /** Narrow a provider config to the static confidential-client mode. Reads the DECLARED `mode` and
73
104
  * nothing else — never the presence of a field. */
@@ -162,6 +193,39 @@ function parseStaticProvider(ref: string, rec: Record<string, unknown>): OeOauth
162
193
  };
163
194
  }
164
195
 
196
+ /** Parse ONE `xaa` entry, with every refusal stated. */
197
+ function parseXaaProvider(ref: string, rec: Record<string, unknown>): OeOauthXaaProviderConfig {
198
+ const where = `sign-in "${ref}"`;
199
+ // 🔴 A LITERAL SECRET IS REFUSED, as in the static dialect: a declaration is committed source.
200
+ for (const literal of ["clientSecret", "idpClientSecret", "resourceClientSecret"]) {
201
+ if (literal in rec) {
202
+ throw new Error(
203
+ `${where} carries a literal "${literal}". A client secret must NEVER live in a sign-in declaration — store it in the ` +
204
+ `credential store and name it with "${literal === "clientSecret" ? "idpClientSecretRef" : `${literal}Ref`}".`,
205
+ );
206
+ }
207
+ }
208
+ const resource = rec.resource === undefined ? undefined : requireString(rec.resource, `${where}.resource`);
209
+ const scopes = parseScopes(rec.scopes);
210
+ return {
211
+ mode: OE_OAUTH_MODE_XAA,
212
+ ref,
213
+ // The issuer is compared byte-for-byte with the id token's `iss`, so it is kept exactly as declared.
214
+ idpIssuer: requireHttpsUrl(rec.idpIssuer, `${where}.idpIssuer`),
215
+ idpClientId: requireString(rec.idpClientId, `${where}.idpClientId`),
216
+ idpClientSecretRef: requireString(rec.idpClientSecretRef, `${where}.idpClientSecretRef`),
217
+ idpClientSecretBroker: rec.idpClientSecretBroker === undefined ? "db" : requireString(rec.idpClientSecretBroker, `${where}.idpClientSecretBroker`),
218
+ audience: requireString(rec.audience, `${where}.audience`),
219
+ ...(resource ? { resource } : {}),
220
+ resourceTokenEndpoint: requireHttpsUrl(rec.resourceTokenEndpoint, `${where}.resourceTokenEndpoint`),
221
+ resourceClientId: requireString(rec.resourceClientId, `${where}.resourceClientId`),
222
+ resourceClientSecretRef: requireString(rec.resourceClientSecretRef, `${where}.resourceClientSecretRef`),
223
+ resourceClientSecretBroker:
224
+ rec.resourceClientSecretBroker === undefined ? "db" : requireString(rec.resourceClientSecretBroker, `${where}.resourceClientSecretBroker`),
225
+ ...(scopes ? { scopes } : {}),
226
+ };
227
+ }
228
+
165
229
  /** Parse ONE `discovery-dcr-pkce` entry. */
166
230
  function parseDiscoveryProvider(ref: string, rec: Record<string, unknown>): OeOauthDiscoveryProviderConfig {
167
231
  const where = `sign-in "${ref}"`;
@@ -192,9 +256,13 @@ function parseDeclarations(declared: ReadonlyMap<string, unknown>): Map<string,
192
256
  out.set(ref, parseStaticProvider(ref, rec));
193
257
  continue;
194
258
  }
259
+ if (mode === OE_OAUTH_MODE_XAA) {
260
+ out.set(ref, parseXaaProvider(ref, rec));
261
+ continue;
262
+ }
195
263
  if (mode !== OE_OAUTH_MODE_DISCOVERY) {
196
264
  throw new Error(
197
- `sign-in "${ref}".mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}" or "${OE_OAUTH_MODE_STATIC}".`,
265
+ `sign-in "${ref}".mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}", "${OE_OAUTH_MODE_STATIC}" or "${OE_OAUTH_MODE_XAA}".`,
198
266
  );
199
267
  }
200
268
  const parsed = parseDiscoveryProvider(ref, rec);
@@ -40,6 +40,23 @@ export class OeOauthRefreshContendedError extends Error {
40
40
  }
41
41
  }
42
42
 
43
+ /** Cross App Access (OPE-2453): the IdP or the app REFUSED the mint for a person who is signed in — the IdP admin
44
+ * removed the pairing, unassigned the person, or never granted this app. A new sign-in link cannot fix that, so
45
+ * this error is DELIBERATELY not classified as user-auth-required: the person gets no "Connect my account" link,
46
+ * only the reason. Carries the RFC 6749 error code, never token material. */
47
+ export class OeOauthAccessRemovedError extends Error {
48
+ readonly code = "oe_oauth_access_removed";
49
+ readonly oauthError?: string;
50
+ constructor(providerRef: string, subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }, where: "idp" | "app", oauthError?: string) {
51
+ super(
52
+ `connection "${providerRef}": ${subjectLabel(subject)} — the ${where === "idp" ? "identity provider" : "app"} refused access` +
53
+ `${oauthError ? ` (${oauthError})` : ""}. Your identity-provider admin manages this access; a new sign-in will not restore it.`,
54
+ );
55
+ this.name = "OeOauthAccessRemovedError";
56
+ if (oauthError) this.oauthError = oauthError;
57
+ }
58
+ }
59
+
43
60
  /** A generic upstream OAuth failure (token endpoint / discovery / DCR returned an error). `oauthError`
44
61
  * is the RFC 6749 `error` code when the body carried one (e.g. "invalid_grant", "invalid_client") —
45
62
  * a fixed-vocabulary string, never a secret. */
@@ -21,6 +21,8 @@ export interface DiscoveredEndpoints {
21
21
  /** A token endpoint response (authorization-code exchange or refresh). Only the fields we persist. */
22
22
  export interface OAuthTokenResponse {
23
23
  readonly access_token: string;
24
+ /** OIDC: present when the request asked for `openid` (the Cross App Access sign-in, OPE-2453). */
25
+ readonly id_token?: string;
24
26
  readonly refresh_token?: string;
25
27
  readonly expires_in?: number;
26
28
  readonly scope?: string;
@@ -301,11 +303,16 @@ async function postTokenConfidential(
301
303
  */
302
304
  export async function exchangeCodeConfidential(
303
305
  tokenEndpoint: string,
304
- input: { readonly code: string; readonly redirectUri: string },
306
+ input: { readonly code: string; readonly redirectUri: string; readonly codeVerifier?: string },
305
307
  client: ConfidentialClient,
306
308
  fetchImpl: OAuthFetch,
307
309
  ): Promise<OAuthTokenResponse> {
308
- const res = await postTokenConfidential(tokenEndpoint, client, { grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri }, fetchImpl);
310
+ const res = await postTokenConfidential(
311
+ tokenEndpoint,
312
+ client,
313
+ { grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri, ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}) },
314
+ fetchImpl,
315
+ );
309
316
  return readTokenResponse(tokenEndpoint, res);
310
317
  }
311
318
 
@@ -374,3 +381,112 @@ export function refreshToken(
374
381
  fetchImpl,
375
382
  );
376
383
  }
384
+
385
+ // ── Cross App Access (OPE-2453): OIDC sign-in at the IdP, then the two ID-JAG legs ─────────────────────────────
386
+
387
+ /** RFC 8693 / ID-JAG wire constants. The draft is `draft-ietf-oauth-identity-assertion-authz-grant-04`, so these
388
+ * names can still move before an RFC; each lives here once. */
389
+ export const GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
390
+ export const GRANT_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";
391
+ export const TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag";
392
+ export const TOKEN_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token";
393
+
394
+ /** The IdP endpoints an OIDC discovery document names. */
395
+ export interface OidcEndpoints {
396
+ readonly authorizationEndpoint: string;
397
+ readonly tokenEndpoint: string;
398
+ readonly jwksUri: string;
399
+ readonly revocationEndpoint?: string;
400
+ }
401
+
402
+ /**
403
+ * OIDC discovery at `issuer` (OpenID Connect Discovery §4). The document's `issuer` MUST equal the configured one
404
+ * (§4.3): a mismatch means the document is not this IdP's, so it is refused. Every endpoint must be https.
405
+ */
406
+ export async function discoverOidc(issuer: string, fetchImpl: OAuthFetch): Promise<OidcEndpoints> {
407
+ const url = `${trimSlash(issuer)}/.well-known/openid-configuration`;
408
+ const res = await fetchImpl(url, { headers: { accept: "application/json" }, redirect: "error" });
409
+ if (!res.ok) throw new OeOauthError(`OIDC discovery failed for ${issuer}`, { httpStatus: res.status, ...(await readError(res)) });
410
+ const doc = (await res.json().catch(() => ({}))) as Record<string, unknown>;
411
+ if (doc["issuer"] !== issuer) throw new OeOauthError(`OIDC discovery for ${issuer} names a different issuer — refused`);
412
+ const https = (k: string): string | undefined => {
413
+ const v = doc[k];
414
+ if (typeof v !== "string" || v === "") return undefined;
415
+ if (!v.startsWith("https://")) throw new OeOauthError(`OIDC discovery for ${issuer}: ${k} is not https — refused`);
416
+ return v;
417
+ };
418
+ const authorizationEndpoint = https("authorization_endpoint");
419
+ const tokenEndpoint = https("token_endpoint");
420
+ const jwksUri = https("jwks_uri");
421
+ const revocationEndpoint = https("revocation_endpoint");
422
+ if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
423
+ throw new OeOauthError(`OIDC discovery for ${issuer} is missing authorization_endpoint, token_endpoint or jwks_uri`);
424
+ }
425
+ return { authorizationEndpoint, tokenEndpoint, jwksUri, ...(revocationEndpoint ? { revocationEndpoint } : {}) };
426
+ }
427
+
428
+ /** Fetch the IdP's JWKS document — the keys an id token must verify against. */
429
+ export async function fetchJwks(jwksUri: string, fetchImpl: OAuthFetch): Promise<{ keys: Record<string, unknown>[] }> {
430
+ const res = await fetchImpl(jwksUri, { headers: { accept: "application/json" }, redirect: "error" });
431
+ if (!res.ok) throw new OeOauthError(`JWKS fetch failed at ${jwksUri}`, { httpStatus: res.status });
432
+ const doc = (await res.json().catch(() => ({}))) as { keys?: unknown };
433
+ if (!Array.isArray(doc.keys)) throw new OeOauthError(`JWKS at ${jwksUri} carries no keys array`);
434
+ return { keys: doc.keys as Record<string, unknown>[] };
435
+ }
436
+
437
+ /**
438
+ * LEG 1 — RFC 8693 token exchange at the IdP: the person's IdP refresh token for an ID-JAG naming `audience`. OE
439
+ * authenticates as its IdP client (HTTP Basic). The ID-JAG comes back in `access_token`, and the response must
440
+ * say `issued_token_type` is an ID-JAG: anything else is not the grant leg 2 expects.
441
+ */
442
+ export async function requestIdJag(
443
+ tokenEndpoint: string,
444
+ input: { readonly subjectToken: string; readonly audience: string; readonly resource?: string; readonly scopes?: readonly string[] },
445
+ client: ConfidentialClient,
446
+ fetchImpl: OAuthFetch,
447
+ ): Promise<string> {
448
+ const res = await postTokenConfidential(
449
+ tokenEndpoint,
450
+ client,
451
+ {
452
+ grant_type: GRANT_TOKEN_EXCHANGE,
453
+ requested_token_type: TOKEN_TYPE_ID_JAG,
454
+ subject_token: input.subjectToken,
455
+ subject_token_type: TOKEN_TYPE_REFRESH_TOKEN,
456
+ audience: input.audience,
457
+ ...(input.resource ? { resource: input.resource } : {}),
458
+ ...(input.scopes && input.scopes.length ? { scope: input.scopes.join(" ") } : {}),
459
+ },
460
+ fetchImpl,
461
+ );
462
+ const body = (await readTokenResponse(tokenEndpoint, res)) as OAuthTokenResponse & { issued_token_type?: unknown };
463
+ if (body.issued_token_type !== TOKEN_TYPE_ID_JAG) {
464
+ throw new OeOauthError(`token endpoint ${tokenEndpoint} answered the ID-JAG exchange with another token type — refused`);
465
+ }
466
+ return body.access_token;
467
+ }
468
+
469
+ /**
470
+ * LEG 2 — RFC 7523 JWT bearer grant at the APP's authorization server: the ID-JAG for the app's access token. OE
471
+ * authenticates with the client the APP issued it (HTTP Basic); the app checks the ID-JAG's `client_id` against
472
+ * it. The draft says the app SHOULD NOT return a refresh token, so the caller re-mints at expiry.
473
+ */
474
+ export async function jwtBearerGrant(
475
+ tokenEndpoint: string,
476
+ input: { readonly assertion: string; readonly resource?: string; readonly scopes?: readonly string[] },
477
+ client: ConfidentialClient,
478
+ fetchImpl: OAuthFetch,
479
+ ): Promise<OAuthTokenResponse> {
480
+ const res = await postTokenConfidential(
481
+ tokenEndpoint,
482
+ client,
483
+ {
484
+ grant_type: GRANT_JWT_BEARER,
485
+ assertion: input.assertion,
486
+ ...(input.resource ? { resource: input.resource } : {}),
487
+ ...(input.scopes && input.scopes.length ? { scope: input.scopes.join(" ") } : {}),
488
+ },
489
+ fetchImpl,
490
+ );
491
+ return readTokenResponse(tokenEndpoint, res);
492
+ }