@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.
- package/agent/directory/okta.ts +41 -2
- package/agent/oe-oauth/config.ts +71 -3
- package/agent/oe-oauth/errors.ts +17 -0
- package/agent/oe-oauth/oauth-client.ts +118 -2
- package/agent/oe-oauth/token-vault.ts +359 -8
- package/dist/agent/directory/okta.d.ts +10 -0
- package/dist/agent/directory/okta.d.ts.map +1 -1
- package/dist/agent/directory/okta.js +34 -2
- package/dist/agent/directory/okta.js.map +1 -1
- package/dist/agent/oe-oauth/config.d.ts +28 -2
- package/dist/agent/oe-oauth/config.d.ts.map +1 -1
- package/dist/agent/oe-oauth/config.js +40 -1
- package/dist/agent/oe-oauth/config.js.map +1 -1
- package/dist/agent/oe-oauth/errors.d.ts +14 -0
- package/dist/agent/oe-oauth/errors.d.ts.map +1 -1
- package/dist/agent/oe-oauth/errors.js +15 -0
- package/dist/agent/oe-oauth/errors.js.map +1 -1
- package/dist/agent/oe-oauth/oauth-client.d.ts +46 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -1
- package/dist/agent/oe-oauth/oauth-client.js +82 -1
- package/dist/agent/oe-oauth/oauth-client.js.map +1 -1
- package/dist/agent/oe-oauth/token-vault.d.ts +15 -0
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
- package/dist/agent/oe-oauth/token-vault.js +317 -7
- package/dist/agent/oe-oauth/token-vault.js.map +1 -1
- package/package.json +3 -2
package/agent/directory/okta.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
});
|
package/agent/oe-oauth/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
70
|
-
|
|
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 "${
|
|
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);
|
package/agent/oe-oauth/errors.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|