@open-everywhere/adapters 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +43 -0
- package/agent/aws/cli.ts +121 -0
- package/agent/aws/config.ts +71 -0
- package/agent/aws/register.ts +65 -0
- package/agent/aws/sso.ts +567 -0
- package/agent/aws/sts.ts +191 -0
- package/agent/blob/hosting.ts +24 -0
- package/agent/browser/config.ts +62 -0
- package/agent/browser/register.ts +66 -0
- package/agent/browser/sandbox.ts +39 -0
- package/agent/contributions.ts +12 -0
- package/agent/directory/config.ts +138 -0
- package/agent/directory/okta.ts +226 -0
- package/agent/directory/register.ts +16 -0
- package/agent/instrumentation.ts +42 -0
- package/agent/model/approval-judge.ts +57 -0
- package/agent/model/dream-judge.ts +105 -0
- package/agent/model/gateway-vision.ts +162 -0
- package/agent/model/gateway.ts +85 -0
- package/agent/oe-oauth/config.ts +246 -0
- package/agent/oe-oauth/errors.ts +77 -0
- package/agent/oe-oauth/oauth-client.ts +376 -0
- package/agent/oe-oauth/pkce.ts +21 -0
- package/agent/oe-oauth/register.ts +32 -0
- package/agent/oe-oauth/token-vault.ts +652 -0
- package/agent/oe-oauth/user-auth-flow.ts +76 -0
- package/agent/postgres/neon.ts +108 -0
- package/agent/sandbox/backend-stop.ts +259 -0
- package/agent/sandbox/created-sandbox-registry.ts +27 -0
- package/agent/sandbox/extend-workspace-time.ts +77 -0
- package/agent/sandbox/vercel-lifecycle.ts +252 -0
- package/dist/agent/aws/cli.d.ts +24 -0
- package/dist/agent/aws/cli.d.ts.map +1 -0
- package/dist/agent/aws/cli.js +89 -0
- package/dist/agent/aws/cli.js.map +1 -0
- package/dist/agent/aws/config.d.ts +27 -0
- package/dist/agent/aws/config.d.ts.map +1 -0
- package/dist/agent/aws/config.js +46 -0
- package/dist/agent/aws/config.js.map +1 -0
- package/dist/agent/aws/register.d.ts +23 -0
- package/dist/agent/aws/register.d.ts.map +1 -0
- package/dist/agent/aws/register.js +39 -0
- package/dist/agent/aws/register.js.map +1 -0
- package/dist/agent/aws/sso.d.ts +109 -0
- package/dist/agent/aws/sso.d.ts.map +1 -0
- package/dist/agent/aws/sso.js +421 -0
- package/dist/agent/aws/sso.js.map +1 -0
- package/dist/agent/aws/sts.d.ts +27 -0
- package/dist/agent/aws/sts.d.ts.map +1 -0
- package/dist/agent/aws/sts.js +135 -0
- package/dist/agent/aws/sts.js.map +1 -0
- package/dist/agent/blob/hosting.d.ts +4 -0
- package/dist/agent/blob/hosting.d.ts.map +1 -0
- package/dist/agent/blob/hosting.js +20 -0
- package/dist/agent/blob/hosting.js.map +1 -0
- package/dist/agent/browser/config.d.ts +24 -0
- package/dist/agent/browser/config.d.ts.map +1 -0
- package/dist/agent/browser/config.js +50 -0
- package/dist/agent/browser/config.js.map +1 -0
- package/dist/agent/browser/register.d.ts +7 -0
- package/dist/agent/browser/register.d.ts.map +1 -0
- package/dist/agent/browser/register.js +47 -0
- package/dist/agent/browser/register.js.map +1 -0
- package/dist/agent/browser/sandbox.d.ts +23 -0
- package/dist/agent/browser/sandbox.d.ts.map +1 -0
- package/dist/agent/browser/sandbox.js +27 -0
- package/dist/agent/browser/sandbox.js.map +1 -0
- package/dist/agent/contributions.d.ts +16 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +11 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/directory/config.d.ts +39 -0
- package/dist/agent/directory/config.d.ts.map +1 -0
- package/dist/agent/directory/config.js +101 -0
- package/dist/agent/directory/config.js.map +1 -0
- package/dist/agent/directory/okta.d.ts +19 -0
- package/dist/agent/directory/okta.d.ts.map +1 -0
- package/dist/agent/directory/okta.js +178 -0
- package/dist/agent/directory/okta.js.map +1 -0
- package/dist/agent/directory/register.d.ts +6 -0
- package/dist/agent/directory/register.d.ts.map +1 -0
- package/dist/agent/directory/register.js +16 -0
- package/dist/agent/directory/register.js.map +1 -0
- package/dist/agent/instrumentation.d.ts +5 -0
- package/dist/agent/instrumentation.d.ts.map +1 -0
- package/dist/agent/instrumentation.js +39 -0
- package/dist/agent/instrumentation.js.map +1 -0
- package/dist/agent/model/approval-judge.d.ts +15 -0
- package/dist/agent/model/approval-judge.d.ts.map +1 -0
- package/dist/agent/model/approval-judge.js +46 -0
- package/dist/agent/model/approval-judge.js.map +1 -0
- package/dist/agent/model/dream-judge.d.ts +40 -0
- package/dist/agent/model/dream-judge.d.ts.map +1 -0
- package/dist/agent/model/dream-judge.js +80 -0
- package/dist/agent/model/dream-judge.js.map +1 -0
- package/dist/agent/model/gateway-vision.d.ts +53 -0
- package/dist/agent/model/gateway-vision.d.ts.map +1 -0
- package/dist/agent/model/gateway-vision.js +129 -0
- package/dist/agent/model/gateway-vision.js.map +1 -0
- package/dist/agent/model/gateway.d.ts +30 -0
- package/dist/agent/model/gateway.d.ts.map +1 -0
- package/dist/agent/model/gateway.js +55 -0
- package/dist/agent/model/gateway.js.map +1 -0
- package/dist/agent/oe-oauth/config.d.ts +69 -0
- package/dist/agent/oe-oauth/config.d.ts.map +1 -0
- package/dist/agent/oe-oauth/config.js +182 -0
- package/dist/agent/oe-oauth/config.js.map +1 -0
- package/dist/agent/oe-oauth/errors.d.ts +63 -0
- package/dist/agent/oe-oauth/errors.d.ts.map +1 -0
- package/dist/agent/oe-oauth/errors.js +73 -0
- package/dist/agent/oe-oauth/errors.js.map +1 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts +124 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -0
- package/dist/agent/oe-oauth/oauth-client.js +262 -0
- package/dist/agent/oe-oauth/oauth-client.js.map +1 -0
- package/dist/agent/oe-oauth/pkce.d.ts +8 -0
- package/dist/agent/oe-oauth/pkce.d.ts.map +1 -0
- package/dist/agent/oe-oauth/pkce.js +18 -0
- package/dist/agent/oe-oauth/pkce.js.map +1 -0
- package/dist/agent/oe-oauth/register.d.ts +17 -0
- package/dist/agent/oe-oauth/register.d.ts.map +1 -0
- package/dist/agent/oe-oauth/register.js +24 -0
- package/dist/agent/oe-oauth/register.js.map +1 -0
- package/dist/agent/oe-oauth/token-vault.d.ts +107 -0
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -0
- package/dist/agent/oe-oauth/token-vault.js +498 -0
- package/dist/agent/oe-oauth/token-vault.js.map +1 -0
- package/dist/agent/oe-oauth/user-auth-flow.d.ts +10 -0
- package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -0
- package/dist/agent/oe-oauth/user-auth-flow.js +66 -0
- package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -0
- package/dist/agent/postgres/neon.d.ts +29 -0
- package/dist/agent/postgres/neon.d.ts.map +1 -0
- package/dist/agent/postgres/neon.js +76 -0
- package/dist/agent/postgres/neon.js.map +1 -0
- package/dist/agent/sandbox/backend-stop.d.ts +98 -0
- package/dist/agent/sandbox/backend-stop.d.ts.map +1 -0
- package/dist/agent/sandbox/backend-stop.js +188 -0
- package/dist/agent/sandbox/backend-stop.js.map +1 -0
- package/dist/agent/sandbox/created-sandbox-registry.d.ts +15 -0
- package/dist/agent/sandbox/created-sandbox-registry.d.ts.map +1 -0
- package/dist/agent/sandbox/created-sandbox-registry.js +26 -0
- package/dist/agent/sandbox/created-sandbox-registry.js.map +1 -0
- package/dist/agent/sandbox/extend-workspace-time.d.ts +31 -0
- package/dist/agent/sandbox/extend-workspace-time.d.ts.map +1 -0
- package/dist/agent/sandbox/extend-workspace-time.js +63 -0
- package/dist/agent/sandbox/extend-workspace-time.js.map +1 -0
- package/dist/agent/sandbox/vercel-lifecycle.d.ts +38 -0
- package/dist/agent/sandbox/vercel-lifecycle.d.ts.map +1 -0
- package/dist/agent/sandbox/vercel-lifecycle.js +244 -0
- package/dist/agent/sandbox/vercel-lifecycle.js.map +1 -0
- package/package.json +204 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +22 -0
package/agent/aws/sso.ts
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// The per-user AWS Identity Center (SSO) adapter: LINK once with auth-code + PKCE, then mint through
|
|
2
|
+
// `sso:GetRoleCredentials`, so the portal bounds the call by that person's own entitlement. Zero npm deps.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
warmCredential,
|
|
6
|
+
encryptedKvVault,
|
|
7
|
+
requireAwsSsoLinkStore,
|
|
8
|
+
checkAwsSsoIdentityBinding,
|
|
9
|
+
OE_AWS_SSO_CALLBACK_PATH,
|
|
10
|
+
type AwsCredentials,
|
|
11
|
+
type AwsIdentityRequest,
|
|
12
|
+
type AwsSsoLinkStore,
|
|
13
|
+
type CredentialPurgeHook,
|
|
14
|
+
type EncryptedKvVault,
|
|
15
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
16
|
+
import { makePkce, makeState } from "../oe-oauth/pkce.js";
|
|
17
|
+
|
|
18
|
+
/** A `fetch`-shaped transport — the DI seam the offline test injects (defaults to global fetch). */
|
|
19
|
+
export type FetchLike = typeof fetch;
|
|
20
|
+
|
|
21
|
+
// Core owns the canonical const; re-exported here so this module's existing consumers keep resolving it.
|
|
22
|
+
export { OE_AWS_SSO_CALLBACK_PATH };
|
|
23
|
+
|
|
24
|
+
/** Default OAuth scopes for the Identity Center link. `openid` is LOAD-BEARING, not decoration: it is
|
|
25
|
+
* what makes the token response carry an id-token, and the id-token's `sub` is the only statement of
|
|
26
|
+
* WHO completed the sign-in the callback can observe. Without it {@link AwsSsoMinter.completeLink}
|
|
27
|
+
* refuses every completion, because it cannot name the signer (OPE-1955). An operator who overrides
|
|
28
|
+
* `OE_AWS_SSO_SCOPES` must keep `openid` in the list. */
|
|
29
|
+
const DEFAULT_SSO_SCOPES = ["sso:account:access", "openid"] as const;
|
|
30
|
+
/** Consent window for a pending link (15 min, matching the own-rolled OAuth pending TTL). */
|
|
31
|
+
const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000;
|
|
32
|
+
/** Refresh the SSO access token when within this margin of its expiry. */
|
|
33
|
+
const DEFAULT_EXPIRY_SKEW_MS = 60 * 1000;
|
|
34
|
+
/** Fallback creds TTL requested from the warm cache when the portal states no expiry (1h SSO floor). */
|
|
35
|
+
const DEFAULT_CRED_TTL_SECONDS = 3600;
|
|
36
|
+
|
|
37
|
+
/** The stored SSO token record. Serialized to the opaque string the core {@link AwsSsoLinkStore} holds
|
|
38
|
+
* per principal — core never parses this; only this adapter reads/writes it. */
|
|
39
|
+
interface AwsSsoTokenRecord {
|
|
40
|
+
readonly accessToken: string;
|
|
41
|
+
readonly refreshToken?: string;
|
|
42
|
+
/** Absolute ms expiry of the ACCESS token, if the OIDC server stated `expires_in`. */
|
|
43
|
+
readonly expiresAtMs?: number;
|
|
44
|
+
readonly region: string;
|
|
45
|
+
readonly obtainedAtMs: number;
|
|
46
|
+
/** The Identity Center `sub` of the human whose sign-in minted this token — the account the link
|
|
47
|
+
* completion MATCHED against the requester's confirmed `aws` link (OPE-1955). It is the durable
|
|
48
|
+
* record of which AWS identity this row actually holds, so "whose access is this?" is answerable
|
|
49
|
+
* from the row rather than inferred from the key it is stored under. Absent on a row written
|
|
50
|
+
* before the binding check landed; {@link freshAccessToken} treats such a row as UNPROVEN and makes
|
|
51
|
+
* the person link again, because a row minted under the old rule may hold a stranger's access. */
|
|
52
|
+
readonly identityCenterSubject?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A persisted pending link (keyed by the opaque state), holding the PKCE verifier server-side. */
|
|
56
|
+
interface AwsSsoPendingRecord {
|
|
57
|
+
/** The QUALIFIED vault subject the completed link will be stored under (never a bare native id). */
|
|
58
|
+
readonly principalSubject: string;
|
|
59
|
+
readonly verifier: string;
|
|
60
|
+
readonly region: string;
|
|
61
|
+
readonly createdAt: number;
|
|
62
|
+
readonly expiresAt: number;
|
|
63
|
+
readonly consumed?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The acting principal has not linked their Identity Center identity (or their token is dead and must
|
|
67
|
+
* be re-linked). Carries the authorization URL so the caller can relay a "connect your AWS account"
|
|
68
|
+
* link. Stable `code` so it is duck-typed (cross-module) as authorization-required, mirroring the
|
|
69
|
+
* own-rolled OAuth needs-consent error. Carries NO token material. */
|
|
70
|
+
export class AwsSsoLinkRequiredError extends Error {
|
|
71
|
+
readonly code = "aws_sso_link_required";
|
|
72
|
+
readonly authorizationUrl: string;
|
|
73
|
+
constructor(authorizationUrl: string, why: string) {
|
|
74
|
+
super(
|
|
75
|
+
`your AWS account isn't linked yet — ${why}. Open this personal link to connect it (expires soon): ` +
|
|
76
|
+
`${authorizationUrl} — complete the AWS sign-in, then ask again. The link completes ONLY for the ` +
|
|
77
|
+
`Identity Center account already linked to you; a sign-in by anyone else is refused and links ` +
|
|
78
|
+
`nothing, so forwarding it gains nobody access. Once linked, the agent acts as YOU with YOUR own ` +
|
|
79
|
+
`Identity Center access, bounded by the role granted to this channel. If you later say you've ` +
|
|
80
|
+
`signed in, RETRY the operation to re-check.`,
|
|
81
|
+
);
|
|
82
|
+
this.name = "AwsSsoLinkRequiredError";
|
|
83
|
+
this.authorizationUrl = authorizationUrl;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True iff `e` is the "must link your AWS SSO account" signal — duck-typed on the stable `code` so a
|
|
88
|
+
* cross-module instance still matches (mirrors errors.ts isUserAuthRequiredError). */
|
|
89
|
+
export function isAwsSsoLinkRequired(e: unknown): boolean {
|
|
90
|
+
if (!(e instanceof Error)) return false;
|
|
91
|
+
const code = (e as { code?: unknown }).code;
|
|
92
|
+
return code === "aws_sso_link_required" || e.name === "AwsSsoLinkRequiredError";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The per-principal SSO link store over core's encrypted KV vault. The ref interpolates the caller's
|
|
97
|
+
* QUALIFIED subject verbatim, and the older bare form cannot alias it — so the migration needs no fallback.
|
|
98
|
+
*/
|
|
99
|
+
export function awsSsoTokenRef(principalSubject: string): string {
|
|
100
|
+
return `aws-sso:tok:${principalSubject}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function makeAwsSsoLinkStore(vault: EncryptedKvVault = encryptedKvVault()): AwsSsoLinkStore {
|
|
104
|
+
return {
|
|
105
|
+
async read(principalSubject: string): Promise<string | null> {
|
|
106
|
+
return vault.read(awsSsoTokenRef(principalSubject));
|
|
107
|
+
},
|
|
108
|
+
async write(principalSubject: string, token: string): Promise<void> {
|
|
109
|
+
await vault.write(awsSsoTokenRef(principalSubject), token, { connector: "aws-sso" });
|
|
110
|
+
},
|
|
111
|
+
async clear(principalSubject: string): Promise<void> {
|
|
112
|
+
await vault.write(awsSsoTokenRef(principalSubject), "", { connector: "aws-sso" });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The offboarding half of the SSO link: a REAL delete, not `clear()`, which leaves the row and its envelope.
|
|
119
|
+
* No vendor-side revocation — Identity Center publishes none per token, and its `Logout` ends every session.
|
|
120
|
+
*/
|
|
121
|
+
export function makeAwsSsoPurgeHook(vault: EncryptedKvVault = encryptedKvVault()): CredentialPurgeHook {
|
|
122
|
+
return {
|
|
123
|
+
async purgeSubject(subjectId: string): Promise<number> {
|
|
124
|
+
return (await vault.delete(awsSsoTokenRef(subjectId))) ? 1 : 0;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A minimal pending-link store (state → serialized {@link AwsSsoPendingRecord}). Default backs onto the
|
|
130
|
+
* encrypted KV vault; the offline test injects a Map-backed fake. */
|
|
131
|
+
export interface AwsSsoPendingStore {
|
|
132
|
+
read(state: string): Promise<string | null>;
|
|
133
|
+
write(state: string, value: string): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Construction options — every seam has a real default; the offline test injects fetch + the stores. */
|
|
137
|
+
export interface AwsSsoMinterOptions {
|
|
138
|
+
/** Identity Center region (oidc.<region> + portal.sso.<region>). Required for any link/mint. */
|
|
139
|
+
readonly region?: string;
|
|
140
|
+
/** The public PKCE OIDC client id for the link flow. Required for {@link startLink}/{@link completeLink}. */
|
|
141
|
+
readonly clientId?: string;
|
|
142
|
+
/** OAuth scopes requested on the link (default {@link DEFAULT_SSO_SCOPES}). `openid` MUST stay in any
|
|
143
|
+
* override — {@link startLink} refuses without it, because no completion could then name its signer. */
|
|
144
|
+
readonly scopes?: readonly string[];
|
|
145
|
+
/** Transport for OIDC + portal calls. Default: global `fetch`. */
|
|
146
|
+
readonly fetch?: FetchLike;
|
|
147
|
+
/** The per-principal SSO token store. Default: the registered core {@link AwsSsoLinkStore}. */
|
|
148
|
+
readonly store?: AwsSsoLinkStore;
|
|
149
|
+
/** The pending-link store. Default: encrypted KV vault under `aws-sso:pend:<state>`. */
|
|
150
|
+
readonly pending?: AwsSsoPendingStore;
|
|
151
|
+
/** The completion's identity check — does the Identity Center account that signed in belong to the
|
|
152
|
+
* requester? Default: core's {@link checkAwsSsoIdentityBinding}, which reads the identity-link plane
|
|
153
|
+
* and writes the refusal's audit row. The seam exists because that read needs a live operational
|
|
154
|
+
* store and this adapter's tier has none; it is the same TEST seam shape as `store` and `pending`
|
|
155
|
+
* above, and the boot path (register.ts) passes none, so production always takes the real default. */
|
|
156
|
+
readonly bindIdentity?: typeof checkAwsSsoIdentityBinding;
|
|
157
|
+
/** The OAuth redirect URI. Default: OE_PUBLIC_BASE_URL + {@link OE_AWS_SSO_CALLBACK_PATH}. */
|
|
158
|
+
readonly redirectUri?: () => string | undefined;
|
|
159
|
+
/** Clock. Default `Date.now`. */
|
|
160
|
+
readonly now?: () => number;
|
|
161
|
+
/** PKCE pair. Default {@link makePkce}. */
|
|
162
|
+
readonly newPkce?: () => { readonly verifier: string; readonly challenge: string };
|
|
163
|
+
/** Opaque state. Default {@link makeState}. */
|
|
164
|
+
readonly newState?: () => string;
|
|
165
|
+
readonly pendingTtlMs?: number;
|
|
166
|
+
readonly expirySkewMs?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What {@link makeAwsSsoMinter} returns: the user-sso `mint`, plus the link start/complete steps. */
|
|
170
|
+
export interface AwsSsoMinter {
|
|
171
|
+
/** Mint short-lived creds for a user-sso request (the {@link AwsIdentityRequest} planAwsIdentity built).
|
|
172
|
+
* Throws {@link AwsSsoLinkRequiredError} (carrying the authorization URL) when unlinked / token dead;
|
|
173
|
+
* throws a fail-closed error when the user is not entitled to the granted role. */
|
|
174
|
+
mint(req: AwsIdentityRequest): Promise<AwsCredentials>;
|
|
175
|
+
/** Begin an Identity Center link for the acting principal — returns the authorization URL to open.
|
|
176
|
+
* Takes the QUALIFIED subject, not a bare native id (the pending row and the eventual token row are
|
|
177
|
+
* both keyed by it). */
|
|
178
|
+
startLink(principalSubject: string): Promise<{ url: string }>;
|
|
179
|
+
/** Complete the callback: exchange the code for a token, prove the Identity Center account that signed
|
|
180
|
+
* in is one the pending principal has CONFIRMED as their own, and only then store the token for them.
|
|
181
|
+
* Throws on a mismatch, on an unnameable signer, and on an unknown/expired/consumed state. */
|
|
182
|
+
completeLink(state: string, code: string): Promise<{ principalSubject: string }>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function defaultRedirectUri(): string | undefined {
|
|
186
|
+
const base = process.env.OE_PUBLIC_BASE_URL?.trim().replace(/\/+$/u, "");
|
|
187
|
+
if (!base) return undefined;
|
|
188
|
+
const withScheme = /^https?:\/\//iu.test(base) ? base : `https://${base}`;
|
|
189
|
+
return `${withScheme}${OE_AWS_SSO_CALLBACK_PATH}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function defaultPendingStore(vault: EncryptedKvVault): AwsSsoPendingStore {
|
|
193
|
+
const pendRef = (state: string) => `aws-sso:pend:${state}`;
|
|
194
|
+
return {
|
|
195
|
+
read: (state) => vault.read(pendRef(state)),
|
|
196
|
+
write: async (state, value) => {
|
|
197
|
+
await vault.write(pendRef(state), value, { connector: "aws-sso" });
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Parse an IAM role ARN into its account id + role NAME. `arn:aws:iam::<ACCOUNT>:role/<NAME>` (the
|
|
203
|
+
* role name may contain a path, e.g. `role/team/name` → name "team/name"). Throws on a non-role ARN. */
|
|
204
|
+
export function parseRoleArn(roleArn: string): { accountId: string; roleName: string } {
|
|
205
|
+
const m = /^arn:aws[\w-]*:iam::(\d{12}):role\/(.+)$/u.exec(roleArn);
|
|
206
|
+
if (!m) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`AWS SSO mint: granted roleArn "${roleArn}" is not an IAM role ARN (expected ` +
|
|
209
|
+
`arn:aws:iam::<account>:role/<name>). Fail-closed: no AWS access.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return { accountId: m[1]!, roleName: m[2]! };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The `sub` claim of an OIDC id-token — the Identity Center account that completed the sign-in — or
|
|
217
|
+
* undefined when the value is not an id-token carrying one.
|
|
218
|
+
*
|
|
219
|
+
* 🔴 IT DOES NOT VERIFY THE SIGNATURE, AND DOES NOT NEED TO. This id-token is the body of the response
|
|
220
|
+
* to OUR OWN back-channel `authorization_code` exchange: we posted the code and the PKCE verifier to the
|
|
221
|
+
* pinned Identity Center token endpoint over TLS and read the reply on that same socket. OIDC Core
|
|
222
|
+
* §3.1.3.7 states exactly this case — a token received by direct communication with the token endpoint
|
|
223
|
+
* may rest on TLS server validation instead of signature checking. Nothing an attacker controls reaches
|
|
224
|
+
* this parser without first breaking that TLS session, at which point the access token is theirs anyway.
|
|
225
|
+
* A caller that ever reads an id-token from a FRONT channel (a redirect fragment, a posted form) must
|
|
226
|
+
* verify the signature and must not use this function.
|
|
227
|
+
*/
|
|
228
|
+
export function idTokenSubject(idToken: string | undefined): string | undefined {
|
|
229
|
+
if (typeof idToken !== "string") return undefined;
|
|
230
|
+
const parts = idToken.split(".");
|
|
231
|
+
if (parts.length !== 3) return undefined;
|
|
232
|
+
let claims: unknown;
|
|
233
|
+
try {
|
|
234
|
+
claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
if (typeof claims !== "object" || claims === null) return undefined;
|
|
239
|
+
const sub = (claims as { sub?: unknown }).sub;
|
|
240
|
+
return typeof sub === "string" && sub.length > 0 ? sub : undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function makeAwsSsoMinter(opts: AwsSsoMinterOptions = {}): AwsSsoMinter {
|
|
244
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
245
|
+
const region = opts.region;
|
|
246
|
+
const clientId = opts.clientId;
|
|
247
|
+
const scopes = opts.scopes ?? DEFAULT_SSO_SCOPES;
|
|
248
|
+
const vault = encryptedKvVault();
|
|
249
|
+
const pendingStore = opts.pending ?? defaultPendingStore(vault);
|
|
250
|
+
const bindIdentity = opts.bindIdentity ?? checkAwsSsoIdentityBinding;
|
|
251
|
+
const store = () => opts.store ?? requireAwsSsoLinkStore();
|
|
252
|
+
const redirectUriOf = opts.redirectUri ?? defaultRedirectUri;
|
|
253
|
+
const now = opts.now ?? Date.now;
|
|
254
|
+
const newPkce = opts.newPkce ?? makePkce;
|
|
255
|
+
const newState = opts.newState ?? makeState;
|
|
256
|
+
const pendingTtl = opts.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
257
|
+
const skew = opts.expirySkewMs ?? DEFAULT_EXPIRY_SKEW_MS;
|
|
258
|
+
|
|
259
|
+
function requireRegion(): string {
|
|
260
|
+
if (!region) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
"AWS SSO is not configured — no Identity Center region (set OE_AWS_SSO_REGION or OE_AWS_REGION). " +
|
|
263
|
+
"Fail-closed: no per-user AWS access.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return region;
|
|
267
|
+
}
|
|
268
|
+
function requireClientId(): string {
|
|
269
|
+
if (!clientId) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"AWS SSO link is not configured — no OIDC client id (set OE_AWS_SSO_CLIENT_ID). Register a public " +
|
|
272
|
+
"PKCE client in Identity Center. Fail-closed: no per-user AWS link.",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return clientId;
|
|
276
|
+
}
|
|
277
|
+
/** 🔴 REFUSE THE LINK AT ITS START, not after a human has signed in. Without `openid` the token
|
|
278
|
+
* response carries no id-token, so `completeLink` cannot name the signer and refuses every
|
|
279
|
+
* completion — a config error that would otherwise surface only once somebody had already handed
|
|
280
|
+
* their AWS credentials to the portal. */
|
|
281
|
+
function requireOpenIdScope(): void {
|
|
282
|
+
if (scopes.includes("openid")) return;
|
|
283
|
+
throw new Error(
|
|
284
|
+
"AWS SSO link: the configured scopes omit \"openid\" (OE_AWS_SSO_SCOPES). Without it the Identity " +
|
|
285
|
+
"Center token response carries no id-token, so the account that signs in cannot be named and the " +
|
|
286
|
+
"link can never complete. Fail-closed: no link was started.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const authorizeEndpoint = () => `https://oidc.${requireRegion()}.amazonaws.com/authorize`;
|
|
290
|
+
const tokenEndpoint = () => `https://oidc.${requireRegion()}.amazonaws.com/token`;
|
|
291
|
+
const portalEndpoint = () => `https://portal.sso.${requireRegion()}.amazonaws.com/federation/credentials`;
|
|
292
|
+
|
|
293
|
+
/** Build the authorization URL a human opens (auth-code + PKCE + RFC 8707-style resource omitted;
|
|
294
|
+
* Identity Center uses plain scopes). */
|
|
295
|
+
async function startLink(principalSubject: string): Promise<{ url: string }> {
|
|
296
|
+
const reg = requireRegion();
|
|
297
|
+
const cid = requireClientId();
|
|
298
|
+
requireOpenIdScope();
|
|
299
|
+
const redirectUri = redirectUriOf();
|
|
300
|
+
if (!redirectUri) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"AWS SSO link: OE_PUBLIC_BASE_URL is unset — cannot build a redirect_uri for the Identity Center " +
|
|
303
|
+
"callback. Fail-closed.",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const pkce = newPkce();
|
|
307
|
+
const state = newState();
|
|
308
|
+
const pending: AwsSsoPendingRecord = {
|
|
309
|
+
principalSubject,
|
|
310
|
+
verifier: pkce.verifier,
|
|
311
|
+
region: reg,
|
|
312
|
+
createdAt: now(),
|
|
313
|
+
expiresAt: now() + pendingTtl,
|
|
314
|
+
};
|
|
315
|
+
await pendingStore.write(state, JSON.stringify(pending));
|
|
316
|
+
const u = new URL(authorizeEndpoint());
|
|
317
|
+
u.searchParams.set("response_type", "code");
|
|
318
|
+
u.searchParams.set("client_id", cid);
|
|
319
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
320
|
+
u.searchParams.set("state", state);
|
|
321
|
+
u.searchParams.set("code_challenge", pkce.challenge);
|
|
322
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
323
|
+
if (scopes.length) u.searchParams.set("scope", scopes.join(" "));
|
|
324
|
+
return { url: u.toString() };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Complete the callback: look up the pending record, exchange the code, BIND the result to the human
|
|
328
|
+
* who actually signed in, store the token for the pending principal, consume the pending row. Throws
|
|
329
|
+
* on unknown/expired/consumed state, on exchange failure, and on an identity the pending principal
|
|
330
|
+
* has not confirmed as their own. */
|
|
331
|
+
async function completeLink(state: string, code: string): Promise<{ principalSubject: string }> {
|
|
332
|
+
const cid = requireClientId();
|
|
333
|
+
const redirectUri = redirectUriOf();
|
|
334
|
+
if (!redirectUri) throw new Error("AWS SSO link: OE_PUBLIC_BASE_URL is unset — cannot complete without a redirect_uri.");
|
|
335
|
+
const raw = await pendingStore.read(state);
|
|
336
|
+
if (!raw) throw new Error("AWS SSO link: unknown or expired authorization state.");
|
|
337
|
+
const pending = JSON.parse(raw) as AwsSsoPendingRecord;
|
|
338
|
+
if (pending.consumed) throw new Error("AWS SSO link: authorization state already used.");
|
|
339
|
+
if (now() > pending.expiresAt) throw new Error("AWS SSO link: authorization state expired.");
|
|
340
|
+
const tok = await exchangeToken({
|
|
341
|
+
grant_type: "authorization_code",
|
|
342
|
+
code,
|
|
343
|
+
redirect_uri: redirectUri,
|
|
344
|
+
client_id: cid,
|
|
345
|
+
code_verifier: pending.verifier,
|
|
346
|
+
});
|
|
347
|
+
// 🔴 BIND TO THE SIGNER, NOT THE REQUESTER (OPE-1955). The link is a capability URL, so the human who
|
|
348
|
+
// completed this sign-in need not be the human who asked for it. Everything below runs BEFORE the
|
|
349
|
+
// token is stored: a refused completion must leave no token behind at all.
|
|
350
|
+
const signerSubject = idTokenSubject(tok.id_token);
|
|
351
|
+
// Consume the pending row whatever the verdict. A link that has been opened is spent — leaving a
|
|
352
|
+
// refused one live would hand the forwarder a second attempt at the same capability.
|
|
353
|
+
const consume = () => pendingStore.write(state, JSON.stringify({ ...pending, consumed: true, verifier: "" }));
|
|
354
|
+
// An UNNAMEABLE signer takes the same road as a wrong one, so every refusal leaves an audit row. An
|
|
355
|
+
// OIDC client that silently stops issuing id-tokens refuses every person's link, and a trail that
|
|
356
|
+
// held nothing at all would hide exactly that outage from the reader these rows exist for.
|
|
357
|
+
const verdict = await bindIdentity({ principalSubject: pending.principalSubject, signerSubject });
|
|
358
|
+
if (!verdict.ok) {
|
|
359
|
+
await consume();
|
|
360
|
+
throw new Error(
|
|
361
|
+
signerSubject === undefined
|
|
362
|
+
? "AWS SSO link: the Identity Center sign-in returned no id-token subject, so the account that " +
|
|
363
|
+
"signed in cannot be named. The link requested `openid`, so the provider answered without one — " +
|
|
364
|
+
"check that the OIDC client may issue id-tokens. Fail-closed: nothing was linked."
|
|
365
|
+
: `AWS SSO link: refused — ${verdict.reason}. Fail-closed: nothing was linked.`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const record: AwsSsoTokenRecord = {
|
|
370
|
+
accessToken: tok.access_token,
|
|
371
|
+
...(tok.refresh_token ? { refreshToken: tok.refresh_token } : {}),
|
|
372
|
+
...(typeof tok.expires_in === "number" ? { expiresAtMs: now() + tok.expires_in * 1000 } : {}),
|
|
373
|
+
region: pending.region,
|
|
374
|
+
obtainedAtMs: now(),
|
|
375
|
+
identityCenterSubject: verdict.awsNativeId,
|
|
376
|
+
};
|
|
377
|
+
await store().write(pending.principalSubject, JSON.stringify(record));
|
|
378
|
+
// Consume single-use + scrub the verifier (defeats replay).
|
|
379
|
+
await consume();
|
|
380
|
+
return { principalSubject: pending.principalSubject };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface OidcTokenResponse {
|
|
384
|
+
readonly access_token: string;
|
|
385
|
+
readonly refresh_token?: string;
|
|
386
|
+
readonly expires_in?: number;
|
|
387
|
+
readonly token_type?: string;
|
|
388
|
+
/** The OIDC id-token, returned because the link requests the `openid` scope. Its `sub` names the
|
|
389
|
+
* human who just signed in — see {@link idTokenSubject}. */
|
|
390
|
+
readonly id_token?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** POST the Identity Center OIDC token endpoint (auth-code exchange or refresh). Public PKCE client —
|
|
394
|
+
* no secret. Throws (never returns empty) on a non-2xx or a body missing access_token. */
|
|
395
|
+
async function exchangeToken(form: Record<string, string>): Promise<OidcTokenResponse> {
|
|
396
|
+
const endpoint = tokenEndpoint();
|
|
397
|
+
let res: Response;
|
|
398
|
+
try {
|
|
399
|
+
res = await fetchImpl(endpoint, {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
402
|
+
body: new URLSearchParams(form).toString(),
|
|
403
|
+
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`AWS SSO OIDC token transport error against ${endpoint}: ${err instanceof Error ? err.message : String(err)}. Fail-closed.`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
// Read only the RFC 6749 `error` code — never the raw body (may echo a submitted value).
|
|
411
|
+
let code: string | undefined;
|
|
412
|
+
try {
|
|
413
|
+
const body = (await res.json()) as { error?: unknown };
|
|
414
|
+
if (typeof body?.error === "string") code = body.error;
|
|
415
|
+
} catch {
|
|
416
|
+
/* non-JSON error body */
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`AWS SSO OIDC token endpoint returned ${res.status}${code ? ` (${code})` : ""}. Fail-closed.`);
|
|
419
|
+
}
|
|
420
|
+
const body = (await res.json().catch(() => ({}))) as OidcTokenResponse;
|
|
421
|
+
if (typeof body.access_token !== "string" || !body.access_token) {
|
|
422
|
+
throw new Error(`AWS SSO OIDC token endpoint returned no access_token. Fail-closed.`);
|
|
423
|
+
}
|
|
424
|
+
return body;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Read the principal's stored token, refreshing (and re-storing) it if within the expiry skew. Throws
|
|
428
|
+
* {@link AwsSsoLinkRequiredError} when there is no token or the refresh is rejected. */
|
|
429
|
+
async function freshAccessToken(principalSubject: string): Promise<string> {
|
|
430
|
+
const raw = await store().read(principalSubject);
|
|
431
|
+
if (!raw) {
|
|
432
|
+
const { url } = await startLink(principalSubject);
|
|
433
|
+
throw new AwsSsoLinkRequiredError(url, "no linked AWS Identity Center identity");
|
|
434
|
+
}
|
|
435
|
+
let rec: AwsSsoTokenRecord;
|
|
436
|
+
try {
|
|
437
|
+
rec = JSON.parse(raw) as AwsSsoTokenRecord;
|
|
438
|
+
} catch {
|
|
439
|
+
const { url } = await startLink(principalSubject);
|
|
440
|
+
throw new AwsSsoLinkRequiredError(url, "your stored AWS link is unreadable");
|
|
441
|
+
}
|
|
442
|
+
if (!rec.identityCenterSubject) {
|
|
443
|
+
// A row written before the binding check landed (OPE-1955). Its token was stored under this
|
|
444
|
+
// requester's subject whatever identity signed in, so it may hold a stranger's AWS access and
|
|
445
|
+
// nothing recorded whose it is. Unproven is not usable: the person links again, once.
|
|
446
|
+
// 🔴 DESTROY IT, do not merely decline to read it. The row holds a live, still-REFRESHABLE
|
|
447
|
+
// Identity Center session that this code has just judged may be another human's. Leaving it at
|
|
448
|
+
// rest makes that judgement depend on one reader staying the only reader.
|
|
449
|
+
await store().clear(principalSubject);
|
|
450
|
+
const { url } = await startLink(principalSubject);
|
|
451
|
+
throw new AwsSsoLinkRequiredError(url, "your AWS link predates the identity check and must be made again");
|
|
452
|
+
}
|
|
453
|
+
const expired = rec.expiresAtMs !== undefined && now() >= rec.expiresAtMs - skew;
|
|
454
|
+
if (!expired) return rec.accessToken;
|
|
455
|
+
if (!rec.refreshToken) {
|
|
456
|
+
const { url } = await startLink(principalSubject);
|
|
457
|
+
throw new AwsSsoLinkRequiredError(url, "your AWS session expired and cannot be refreshed");
|
|
458
|
+
}
|
|
459
|
+
let tok: OidcTokenResponse;
|
|
460
|
+
try {
|
|
461
|
+
tok = await exchangeToken({ grant_type: "refresh_token", refresh_token: rec.refreshToken, client_id: requireClientId() });
|
|
462
|
+
} catch {
|
|
463
|
+
const { url } = await startLink(principalSubject);
|
|
464
|
+
throw new AwsSsoLinkRequiredError(url, "your AWS session expired and the refresh was rejected");
|
|
465
|
+
}
|
|
466
|
+
const updated: AwsSsoTokenRecord = {
|
|
467
|
+
accessToken: tok.access_token,
|
|
468
|
+
refreshToken: tok.refresh_token ?? rec.refreshToken,
|
|
469
|
+
...(typeof tok.expires_in === "number" ? { expiresAtMs: now() + tok.expires_in * 1000 } : {}),
|
|
470
|
+
region: rec.region,
|
|
471
|
+
obtainedAtMs: now(),
|
|
472
|
+
// A refresh re-mints the SAME person's token, so the binding carries forward unchanged. Dropping
|
|
473
|
+
// it here would silently demote a proven row to the unproven shape above on the first refresh.
|
|
474
|
+
identityCenterSubject: rec.identityCenterSubject,
|
|
475
|
+
};
|
|
476
|
+
await store().write(principalSubject, JSON.stringify(updated));
|
|
477
|
+
return updated.accessToken;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
interface PortalRoleCredentials {
|
|
481
|
+
readonly roleCredentials?: {
|
|
482
|
+
readonly accessKeyId?: string;
|
|
483
|
+
readonly secretAccessKey?: string;
|
|
484
|
+
readonly sessionToken?: string;
|
|
485
|
+
readonly expiration?: number;
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Call sso:GetRoleCredentials with the user's bearer token — creds iff they are ENTITLED to the role.
|
|
490
|
+
* 401 (bad/expired token) → re-link; 403 (not entitled) → fail closed; other non-2xx → fail closed. */
|
|
491
|
+
async function getRoleCredentials(
|
|
492
|
+
accessToken: string,
|
|
493
|
+
principalSubject: string,
|
|
494
|
+
accountId: string,
|
|
495
|
+
roleName: string,
|
|
496
|
+
): Promise<AwsCredentials> {
|
|
497
|
+
const u = new URL(portalEndpoint());
|
|
498
|
+
u.searchParams.set("account_id", accountId);
|
|
499
|
+
u.searchParams.set("role_name", roleName);
|
|
500
|
+
let res: Response;
|
|
501
|
+
try {
|
|
502
|
+
res = await fetchImpl(u.toString(), { method: "GET", headers: { "x-amz-sso_bearer_token": accessToken, accept: "application/json" } });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`AWS SSO GetRoleCredentials transport error against ${u.origin}: ${err instanceof Error ? err.message : String(err)}. Fail-closed.`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (res.status === 401) {
|
|
509
|
+
const { url } = await startLink(principalSubject);
|
|
510
|
+
throw new AwsSsoLinkRequiredError(url, "your AWS session token was rejected");
|
|
511
|
+
}
|
|
512
|
+
if (!res.ok) {
|
|
513
|
+
// 403 = not entitled to this role in Identity Center; any other non-2xx = fail closed loudly.
|
|
514
|
+
throw new Error(
|
|
515
|
+
`AWS SSO GetRoleCredentials failed (HTTP ${res.status}) for role "${roleName}" in account ${accountId} — ` +
|
|
516
|
+
`you are not entitled to that role in Identity Center, or the portal refused. Fail-closed: no AWS access.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const body = (await res.json().catch(() => ({}))) as PortalRoleCredentials;
|
|
520
|
+
const rc = body.roleCredentials;
|
|
521
|
+
if (!rc?.accessKeyId || !rc.secretAccessKey || !rc.sessionToken) {
|
|
522
|
+
throw new Error(`AWS SSO GetRoleCredentials returned no role credentials for "${roleName}". Fail-closed.`);
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
accessKeyId: rc.accessKeyId,
|
|
526
|
+
secretAccessKey: rc.secretAccessKey,
|
|
527
|
+
sessionToken: rc.sessionToken,
|
|
528
|
+
...(typeof rc.expiration === "number" && Number.isFinite(rc.expiration) ? { expiresAtMs: rc.expiration } : {}),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Warm-cache the portal creds per subject:role, so a second mint inside the TTL never re-calls the portal.
|
|
533
|
+
// 🔴 The key's person half is the QUALIFIED subject: a key two humans could compose hands over a session.
|
|
534
|
+
const warm = warmCredential<{ req: AwsIdentityRequest; principalSubject: string }>({
|
|
535
|
+
name: "aws.sso-role-credentials",
|
|
536
|
+
platform: "aws",
|
|
537
|
+
auditAction: "aws.sso.mint_failed",
|
|
538
|
+
refresh: "re-mint",
|
|
539
|
+
fallbackTtlSeconds: DEFAULT_CRED_TTL_SECONDS,
|
|
540
|
+
key: ({ req, principalSubject }) => `user-sso:${req.roleArn}:${principalSubject}`,
|
|
541
|
+
mint: async ({ req, principalSubject }) => {
|
|
542
|
+
const { accountId, roleName } = parseRoleArn(req.roleArn);
|
|
543
|
+
const accessToken = await freshAccessToken(principalSubject);
|
|
544
|
+
const creds = await getRoleCredentials(accessToken, principalSubject, accountId, roleName);
|
|
545
|
+
return { value: JSON.stringify(creds), ...(creds.expiresAtMs !== undefined ? { expiresAtMs: creds.expiresAtMs } : {}) };
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
async function mint(req: AwsIdentityRequest): Promise<AwsCredentials> {
|
|
550
|
+
const principalSubject = req.principalSubject;
|
|
551
|
+
if (!principalSubject) {
|
|
552
|
+
// Defence in depth: planAwsIdentity only builds a user-sso request WITH a verified acting sender.
|
|
553
|
+
throw new Error("AWS SSO mint: a user-sso request arrived with no principalSubject — refusing. Fail-closed.");
|
|
554
|
+
}
|
|
555
|
+
// Cheap existence check BEFORE the warm cache: the common "not linked yet" path throws the
|
|
556
|
+
// link-required relay without an audited mint-failure row.
|
|
557
|
+
const existing = await store().read(principalSubject);
|
|
558
|
+
if (!existing) {
|
|
559
|
+
const { url } = await startLink(principalSubject);
|
|
560
|
+
throw new AwsSsoLinkRequiredError(url, "no linked AWS Identity Center identity");
|
|
561
|
+
}
|
|
562
|
+
const serialised = await warm.get({ req, principalSubject });
|
|
563
|
+
return JSON.parse(serialised) as AwsCredentials;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { mint, startLink, completeLink };
|
|
567
|
+
}
|