@honua/react 0.0.20-alpha.0 → 0.1.0-beta.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/contract/index.d.ts +2 -0
- package/contract/index.d.ts.map +1 -1
- package/contract/index.js +1 -0
- package/contract/pmtiles.d.ts +162 -0
- package/contract/pmtiles.d.ts.map +1 -0
- package/contract/pmtiles.js +155 -0
- package/contract/source.d.ts +24 -0
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +115 -8
- package/contract/types.d.ts +31 -2
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +8 -0
- package/core/auth/client-credentials-provider.d.ts +51 -0
- package/core/auth/client-credentials-provider.d.ts.map +1 -0
- package/core/auth/client-credentials-provider.js +115 -0
- package/core/auth/credential-store.d.ts +52 -0
- package/core/auth/credential-store.d.ts.map +1 -0
- package/core/auth/credential-store.js +82 -0
- package/core/auth/index.d.ts +23 -0
- package/core/auth/index.d.ts.map +1 -0
- package/core/auth/index.js +20 -0
- package/core/auth/oauth2-provider.d.ts +114 -0
- package/core/auth/oauth2-provider.d.ts.map +1 -0
- package/core/auth/oauth2-provider.js +408 -0
- package/core/auth/pkce.d.ts +25 -0
- package/core/auth/pkce.d.ts.map +1 -0
- package/core/auth/pkce.js +46 -0
- package/core/auth/static-providers.d.ts +20 -0
- package/core/auth/static-providers.d.ts.map +1 -0
- package/core/auth/static-providers.js +26 -0
- package/core/auth/token-endpoint.d.ts +40 -0
- package/core/auth/token-endpoint.d.ts.map +1 -0
- package/core/auth/token-endpoint.js +133 -0
- package/core/auth/types.d.ts +144 -0
- package/core/auth/types.d.ts.map +1 -0
- package/core/auth/types.js +8 -0
- package/core/client.d.ts +31 -1
- package/core/client.d.ts.map +1 -1
- package/core/client.js +52 -0
- package/core/errors.d.ts +33 -1
- package/core/errors.d.ts.map +1 -1
- package/core/errors.js +20 -0
- package/core/geoparquet-sql.d.ts +162 -0
- package/core/geoparquet-sql.d.ts.map +1 -0
- package/core/geoparquet-sql.js +333 -0
- package/core/ogc-endpoint-layout.d.ts +70 -0
- package/core/ogc-endpoint-layout.d.ts.map +1 -0
- package/core/ogc-endpoint-layout.js +173 -0
- package/core/ogc-features.d.ts.map +1 -1
- package/core/ogc-features.js +32 -17
- package/core/stac-static.d.ts +51 -0
- package/core/stac-static.d.ts.map +1 -0
- package/core/stac-static.js +195 -0
- package/core/stac.d.ts +12 -0
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +45 -15
- package/core/surfaces.d.ts +13 -0
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +58 -11
- package/core/types.d.ts +49 -0
- package/core/types.d.ts.map +1 -1
- package/core/wfs-capabilities.d.ts +10 -0
- package/core/wfs-capabilities.d.ts.map +1 -1
- package/core/wfs-capabilities.js +14 -2
- package/core/wfs.d.ts +13 -0
- package/core/wfs.d.ts.map +1 -1
- package/core/wfs.js +25 -5
- package/esri-compat/identity-manager.d.ts +43 -0
- package/esri-compat/identity-manager.d.ts.map +1 -1
- package/esri-compat/identity-manager.js +131 -1
- package/esri-compat/oauth-info.d.ts +15 -0
- package/esri-compat/oauth-info.d.ts.map +1 -1
- package/esri-compat/oauth-info.js +27 -0
- package/package.json +6 -1
- package/runtime/index.d.ts +2 -2
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +1 -4
- package/runtime/load-package.d.ts.map +1 -1
- package/runtime/load-package.js +8 -0
- package/runtime/map-package-validation.d.ts +0 -13
- package/runtime/map-package-validation.d.ts.map +1 -1
- package/runtime/map-package-validation.js +0 -12
- package/runtime/map-package.d.ts +1 -1
- package/runtime/map-package.d.ts.map +1 -1
- package/runtime/pmtiles-protocol.d.ts +77 -0
- package/runtime/pmtiles-protocol.d.ts.map +1 -0
- package/runtime/pmtiles-protocol.js +120 -0
- package/runtime/source-bridge.d.ts.map +1 -1
- package/runtime/source-bridge.js +27 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 authorization-code + PKCE provider.
|
|
3
|
+
*
|
|
4
|
+
* Implements {@link HonuaAuthProvider} (so it drops straight into
|
|
5
|
+
* `new HonuaClient({ auth })` and is attached/refreshed on every fetch and
|
|
6
|
+
* gRPC-web call) *and* exposes the interactive surface a browser app drives:
|
|
7
|
+
* `signIn()` (redirect or popup), `handleRedirectCallback()`, `signOut()`.
|
|
8
|
+
*
|
|
9
|
+
* Design highlights:
|
|
10
|
+
* - S256 PKCE + unguessable `state` (CSRF), validated on the redirect.
|
|
11
|
+
* - Silent refresh via `refresh_token` with a documented fallback
|
|
12
|
+
* (`interaction_required`) when no refresh token is available.
|
|
13
|
+
* - Clock-skew-tolerant expiry so a refresh happens *before* requests 401.
|
|
14
|
+
* - **Single-flight refresh**: N concurrent `getCredentials()` calls that all
|
|
15
|
+
* observe an expiring token share exactly one token-endpoint round-trip.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
import { HonuaAuthError } from "../errors.js";
|
|
20
|
+
import { InMemoryCredentialStore } from "./credential-store.js";
|
|
21
|
+
import { createPkcePair, createStateToken } from "./pkce.js";
|
|
22
|
+
import { DEFAULT_CLOCK_SKEW_MS, isCredentialExpiring, requestToken, resolveServiceKey, toHonuaCredentials, } from "./token-endpoint.js";
|
|
23
|
+
const TRANSACTION_NAMESPACE = "honua.auth.txn";
|
|
24
|
+
/**
|
|
25
|
+
* OAuth2 authorization-code + PKCE provider. Construct via {@link oauth2}.
|
|
26
|
+
*/
|
|
27
|
+
export class OAuth2Provider {
|
|
28
|
+
config;
|
|
29
|
+
store;
|
|
30
|
+
serviceKey;
|
|
31
|
+
clockSkewMs;
|
|
32
|
+
mode;
|
|
33
|
+
fetchFn;
|
|
34
|
+
listeners = new Set();
|
|
35
|
+
/** Single-flight guard: one in-flight refresh shared by all callers. */
|
|
36
|
+
refreshInFlight;
|
|
37
|
+
/** In-memory transaction fallback when no Web Storage is available. */
|
|
38
|
+
memoryTransaction;
|
|
39
|
+
constructor(config) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.store = config.store ?? new InMemoryCredentialStore();
|
|
42
|
+
this.serviceKey = resolveServiceKey(config.serviceKey, config.clientId, config.tokenEndpoint);
|
|
43
|
+
this.clockSkewMs = config.clockSkewMs ?? DEFAULT_CLOCK_SKEW_MS;
|
|
44
|
+
this.mode = config.mode ?? "redirect";
|
|
45
|
+
this.fetchFn = (config.fetchFn ?? fetch).bind(globalThis);
|
|
46
|
+
if (config.onEvent) {
|
|
47
|
+
this.listeners.add(config.onEvent);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ── HonuaAuthProvider surface ────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* Resolve credentials for an outgoing request. Returns the current access
|
|
53
|
+
* token, silently refreshing it first when it is expiring (or when
|
|
54
|
+
* `context.forceRefresh` is set, e.g. after a 401). Throws
|
|
55
|
+
* {@link HonuaAuthError} `interaction_required` when there is no token and no
|
|
56
|
+
* refresh token — the app must call {@link OAuth2Provider.signIn}.
|
|
57
|
+
*/
|
|
58
|
+
async getCredentials(context = { reason: "manual", forceRefresh: false }) {
|
|
59
|
+
const current = await this.store.get(this.serviceKey);
|
|
60
|
+
const needsRefresh = context.forceRefresh || isCredentialExpiring(current, this.clockSkewMs);
|
|
61
|
+
if (!needsRefresh && current) {
|
|
62
|
+
return toHonuaCredentials(current);
|
|
63
|
+
}
|
|
64
|
+
if (current?.refreshToken) {
|
|
65
|
+
const refreshed = await this.refreshSingleFlight(current);
|
|
66
|
+
return toHonuaCredentials(refreshed);
|
|
67
|
+
}
|
|
68
|
+
throw new HonuaAuthError("interaction_required", current
|
|
69
|
+
? "Access token expired and no refresh token is available; call signIn() to re-authenticate."
|
|
70
|
+
: "No credential available; call signIn() to start the OAuth2 authorization-code flow.");
|
|
71
|
+
}
|
|
72
|
+
/** {@link HonuaAuthProvider.revokeCredentials} — clears + optionally revokes. */
|
|
73
|
+
async revokeCredentials(_credentials, _context) {
|
|
74
|
+
await this.signOut();
|
|
75
|
+
}
|
|
76
|
+
// ── Interactive flow ─────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Start interactive sign-in. In `redirect` mode this navigates the current
|
|
79
|
+
* window to the authorization endpoint and returns a promise that never
|
|
80
|
+
* resolves (the page unloads); complete the flow by calling
|
|
81
|
+
* {@link OAuth2Provider.handleRedirectCallback} when the app reloads at the
|
|
82
|
+
* redirect URI. In `popup` mode it opens a popup, waits for the redirect back,
|
|
83
|
+
* exchanges the code, and resolves with the stored credential.
|
|
84
|
+
*/
|
|
85
|
+
async signIn() {
|
|
86
|
+
const pkce = await createPkcePair();
|
|
87
|
+
const state = createStateToken();
|
|
88
|
+
const transaction = {
|
|
89
|
+
codeVerifier: pkce.codeVerifier,
|
|
90
|
+
state,
|
|
91
|
+
redirectUri: this.config.redirectUri,
|
|
92
|
+
createdAt: Date.now(),
|
|
93
|
+
};
|
|
94
|
+
const authorizeUrl = this.buildAuthorizeUrl(pkce, state);
|
|
95
|
+
if (this.mode === "popup") {
|
|
96
|
+
return this.signInPopup(authorizeUrl, transaction);
|
|
97
|
+
}
|
|
98
|
+
this.saveTransaction(transaction);
|
|
99
|
+
const win = this.requireWindow();
|
|
100
|
+
win.location.assign(authorizeUrl);
|
|
101
|
+
// The redirect unloads the page; never resolves.
|
|
102
|
+
return new Promise(() => { });
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether `url` (or the current window location) looks like an OAuth2 redirect
|
|
106
|
+
* callback (carries `code`+`state` or an `error`). Call this on app load to
|
|
107
|
+
* decide whether to invoke {@link OAuth2Provider.handleRedirectCallback}.
|
|
108
|
+
*/
|
|
109
|
+
isRedirectCallback(url) {
|
|
110
|
+
const target = this.parseUrl(url);
|
|
111
|
+
if (!target) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return target.searchParams.has("error") || (target.searchParams.has("code") && target.searchParams.has("state"));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Complete the redirect leg: validate `state`, exchange the authorization
|
|
118
|
+
* `code` (+ PKCE verifier) for tokens, persist them, and emit `signed-in`.
|
|
119
|
+
*/
|
|
120
|
+
async handleRedirectCallback(url) {
|
|
121
|
+
const target = this.parseUrl(url);
|
|
122
|
+
if (!target) {
|
|
123
|
+
throw new HonuaAuthError("interaction_required", "No redirect callback URL is available to process.");
|
|
124
|
+
}
|
|
125
|
+
const params = target.searchParams;
|
|
126
|
+
const authError = params.get("error");
|
|
127
|
+
if (authError) {
|
|
128
|
+
const description = params.get("error_description");
|
|
129
|
+
this.clearTransaction();
|
|
130
|
+
throw new HonuaAuthError("invalid_grant", `Authorization server returned an error: ${authError}${description ? ` (${description})` : ""}`);
|
|
131
|
+
}
|
|
132
|
+
const code = params.get("code");
|
|
133
|
+
const state = params.get("state");
|
|
134
|
+
if (!code) {
|
|
135
|
+
throw new HonuaAuthError("interaction_required", "Redirect callback is missing the authorization code.");
|
|
136
|
+
}
|
|
137
|
+
const transaction = this.loadTransaction();
|
|
138
|
+
if (!transaction) {
|
|
139
|
+
throw new HonuaAuthError("interaction_required", "No PKCE transaction is in progress; start sign-in with signIn() first.");
|
|
140
|
+
}
|
|
141
|
+
if (!state || state !== transaction.state) {
|
|
142
|
+
this.clearTransaction();
|
|
143
|
+
throw new HonuaAuthError("invalid_grant", "OAuth2 state mismatch on redirect callback (possible CSRF).");
|
|
144
|
+
}
|
|
145
|
+
const credential = await this.exchangeCode(code, transaction);
|
|
146
|
+
await this.store.set(this.serviceKey, credential);
|
|
147
|
+
this.clearTransaction();
|
|
148
|
+
this.emit({ type: "signed-in", credential });
|
|
149
|
+
return credential;
|
|
150
|
+
}
|
|
151
|
+
/** Whether a (non-expired) credential is currently stored. */
|
|
152
|
+
async isSignedIn() {
|
|
153
|
+
const current = await this.store.get(this.serviceKey);
|
|
154
|
+
return current !== undefined && !isCredentialExpiring(current, 0);
|
|
155
|
+
}
|
|
156
|
+
/** Read the raw stored credential, if any. */
|
|
157
|
+
getStoredCredential() {
|
|
158
|
+
return this.store.get(this.serviceKey);
|
|
159
|
+
}
|
|
160
|
+
/** Clear stored credentials, optionally revoke them, and emit `signed-out`. */
|
|
161
|
+
async signOut() {
|
|
162
|
+
const current = await this.store.get(this.serviceKey);
|
|
163
|
+
await this.store.delete(this.serviceKey);
|
|
164
|
+
this.clearTransaction();
|
|
165
|
+
this.refreshInFlight = undefined;
|
|
166
|
+
if (current && this.config.revocationEndpoint) {
|
|
167
|
+
await this.revokeAtEndpoint(current).catch(() => {
|
|
168
|
+
// Best-effort revocation; local sign-out already succeeded.
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
this.emit({ type: "signed-out" });
|
|
172
|
+
}
|
|
173
|
+
/** Register an auth lifecycle listener. */
|
|
174
|
+
on(listener) {
|
|
175
|
+
this.listeners.add(listener);
|
|
176
|
+
return {
|
|
177
|
+
remove: () => {
|
|
178
|
+
this.listeners.delete(listener);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// ── internals ────────────────────────────────────────────────
|
|
183
|
+
refreshSingleFlight(current) {
|
|
184
|
+
if (this.refreshInFlight) {
|
|
185
|
+
return this.refreshInFlight;
|
|
186
|
+
}
|
|
187
|
+
const inFlight = this.doRefresh(current).finally(() => {
|
|
188
|
+
if (this.refreshInFlight === inFlight) {
|
|
189
|
+
this.refreshInFlight = undefined;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
this.refreshInFlight = inFlight;
|
|
193
|
+
return inFlight;
|
|
194
|
+
}
|
|
195
|
+
async doRefresh(current) {
|
|
196
|
+
const refreshToken = current.refreshToken;
|
|
197
|
+
if (!refreshToken) {
|
|
198
|
+
throw new HonuaAuthError("interaction_required", "No refresh token is available; call signIn().");
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const refreshed = await requestToken({
|
|
202
|
+
fetchFn: this.fetchFn,
|
|
203
|
+
tokenEndpoint: this.config.tokenEndpoint,
|
|
204
|
+
params: {
|
|
205
|
+
grant_type: "refresh_token",
|
|
206
|
+
refresh_token: refreshToken,
|
|
207
|
+
client_id: this.config.clientId,
|
|
208
|
+
...(this.config.scopes && this.config.scopes.length > 0 ? { scope: this.config.scopes.join(" ") } : {}),
|
|
209
|
+
...this.config.extraTokenParams,
|
|
210
|
+
},
|
|
211
|
+
previous: current,
|
|
212
|
+
});
|
|
213
|
+
await this.store.set(this.serviceKey, refreshed);
|
|
214
|
+
this.emit({ type: "token-refreshed", credential: refreshed });
|
|
215
|
+
return refreshed;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error instanceof HonuaAuthError) {
|
|
219
|
+
this.emit({ type: "refresh-failed", error });
|
|
220
|
+
if (error.code === "invalid_grant") {
|
|
221
|
+
// The refresh token is dead; drop it so the next call surfaces
|
|
222
|
+
// interaction_required instead of looping on a doomed refresh.
|
|
223
|
+
await this.store.delete(this.serviceKey);
|
|
224
|
+
this.emit({ type: "signed-out" });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async exchangeCode(code, transaction) {
|
|
231
|
+
return requestToken({
|
|
232
|
+
fetchFn: this.fetchFn,
|
|
233
|
+
tokenEndpoint: this.config.tokenEndpoint,
|
|
234
|
+
params: {
|
|
235
|
+
grant_type: "authorization_code",
|
|
236
|
+
code,
|
|
237
|
+
redirect_uri: transaction.redirectUri,
|
|
238
|
+
client_id: this.config.clientId,
|
|
239
|
+
code_verifier: transaction.codeVerifier,
|
|
240
|
+
...this.config.extraTokenParams,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async revokeAtEndpoint(credential) {
|
|
245
|
+
const endpoint = this.config.revocationEndpoint;
|
|
246
|
+
if (!endpoint) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const token = credential.refreshToken ?? credential.accessToken;
|
|
250
|
+
await this.fetchFn(endpoint, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
253
|
+
body: new URLSearchParams({ token, client_id: this.config.clientId }).toString(),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
buildAuthorizeUrl(pkce, state) {
|
|
257
|
+
const url = new URL(this.config.authorizationEndpoint);
|
|
258
|
+
const params = url.searchParams;
|
|
259
|
+
params.set("response_type", "code");
|
|
260
|
+
params.set("client_id", this.config.clientId);
|
|
261
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
262
|
+
params.set("state", state);
|
|
263
|
+
params.set("code_challenge", pkce.codeChallenge);
|
|
264
|
+
params.set("code_challenge_method", pkce.codeChallengeMethod);
|
|
265
|
+
if (this.config.scopes && this.config.scopes.length > 0) {
|
|
266
|
+
params.set("scope", this.config.scopes.join(" "));
|
|
267
|
+
}
|
|
268
|
+
for (const [key, value] of Object.entries(this.config.extraAuthorizationParams ?? {})) {
|
|
269
|
+
params.set(key, value);
|
|
270
|
+
}
|
|
271
|
+
return url.toString();
|
|
272
|
+
}
|
|
273
|
+
async signInPopup(authorizeUrl, transaction) {
|
|
274
|
+
const win = this.requireWindow();
|
|
275
|
+
const popup = win.open(authorizeUrl, "honua-oauth", this.config.popupFeatures ?? "width=480,height=680");
|
|
276
|
+
if (!popup) {
|
|
277
|
+
throw new HonuaAuthError("interaction_required", "The sign-in popup was blocked by the browser.");
|
|
278
|
+
}
|
|
279
|
+
const redirectOrigin = new URL(transaction.redirectUri, win.location?.href ?? transaction.redirectUri).origin;
|
|
280
|
+
const callback = await new Promise((resolve, reject) => {
|
|
281
|
+
const timer = setInterval(() => {
|
|
282
|
+
try {
|
|
283
|
+
if (popup.closed) {
|
|
284
|
+
clearInterval(timer);
|
|
285
|
+
reject(new HonuaAuthError("interaction_required", "The sign-in popup was closed before completing sign-in."));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
let popupUrl;
|
|
289
|
+
try {
|
|
290
|
+
popupUrl = new URL(popup.location.href);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Cross-origin while on the authorization server — not readable yet.
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (popupUrl.origin === redirectOrigin && popupUrl.searchParams.has("code")) {
|
|
297
|
+
clearInterval(timer);
|
|
298
|
+
const code = popupUrl.searchParams.get("code") ?? "";
|
|
299
|
+
const state = popupUrl.searchParams.get("state") ?? "";
|
|
300
|
+
popup.close();
|
|
301
|
+
resolve({ code, state });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// Ignore transient cross-origin access errors during the auth leg.
|
|
306
|
+
}
|
|
307
|
+
}, 200);
|
|
308
|
+
});
|
|
309
|
+
if (!callback.state || callback.state !== transaction.state) {
|
|
310
|
+
throw new HonuaAuthError("invalid_grant", "OAuth2 state mismatch on popup callback (possible CSRF).");
|
|
311
|
+
}
|
|
312
|
+
const credential = await this.exchangeCode(callback.code, transaction);
|
|
313
|
+
await this.store.set(this.serviceKey, credential);
|
|
314
|
+
this.emit({ type: "signed-in", credential });
|
|
315
|
+
return credential;
|
|
316
|
+
}
|
|
317
|
+
parseUrl(url) {
|
|
318
|
+
const href = url ?? this.getWindow()?.location?.href;
|
|
319
|
+
if (!href) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
return new URL(href);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
getWindow() {
|
|
330
|
+
return this.config.windowRef ?? globalThis.window;
|
|
331
|
+
}
|
|
332
|
+
requireWindow() {
|
|
333
|
+
const win = this.getWindow();
|
|
334
|
+
if (!win) {
|
|
335
|
+
throw new HonuaAuthError("interaction_required", "Interactive OAuth2 sign-in requires a browser window; none is available in this environment.");
|
|
336
|
+
}
|
|
337
|
+
return win;
|
|
338
|
+
}
|
|
339
|
+
transactionKey() {
|
|
340
|
+
return `${TRANSACTION_NAMESPACE}:${this.serviceKey}`;
|
|
341
|
+
}
|
|
342
|
+
sessionStorage() {
|
|
343
|
+
return this.getWindow()?.sessionStorage;
|
|
344
|
+
}
|
|
345
|
+
saveTransaction(transaction) {
|
|
346
|
+
const storage = this.sessionStorage();
|
|
347
|
+
if (storage) {
|
|
348
|
+
storage.setItem(this.transactionKey(), JSON.stringify(transaction));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.memoryTransaction = transaction;
|
|
352
|
+
}
|
|
353
|
+
loadTransaction() {
|
|
354
|
+
const storage = this.sessionStorage();
|
|
355
|
+
if (storage) {
|
|
356
|
+
const raw = storage.getItem(this.transactionKey());
|
|
357
|
+
if (!raw) {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
return JSON.parse(raw);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return this.memoryTransaction;
|
|
368
|
+
}
|
|
369
|
+
clearTransaction() {
|
|
370
|
+
this.sessionStorage()?.removeItem(this.transactionKey());
|
|
371
|
+
this.memoryTransaction = undefined;
|
|
372
|
+
}
|
|
373
|
+
emit(event) {
|
|
374
|
+
for (const listener of this.listeners) {
|
|
375
|
+
try {
|
|
376
|
+
listener(event);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Listener errors must not break the auth flow.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Create an OAuth2 authorization-code + PKCE {@link HonuaAuthProvider}.
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```ts
|
|
389
|
+
* import { HonuaClient } from "@honua/sdk-js/honua";
|
|
390
|
+
* import { oauth2 } from "@honua/sdk-js/auth";
|
|
391
|
+
*
|
|
392
|
+
* const auth = oauth2({
|
|
393
|
+
* authorizationEndpoint: "https://idp.example/authorize",
|
|
394
|
+
* tokenEndpoint: "https://idp.example/token",
|
|
395
|
+
* clientId: "my-app",
|
|
396
|
+
* redirectUri: `${location.origin}/callback`,
|
|
397
|
+
* scopes: ["openid", "profile"],
|
|
398
|
+
* });
|
|
399
|
+
* const client = new HonuaClient({ baseUrl: "https://api.example", auth });
|
|
400
|
+
*
|
|
401
|
+
* // On the callback page:
|
|
402
|
+
* if (auth.isRedirectCallback()) await auth.handleRedirectCallback();
|
|
403
|
+
* else if (!(await auth.isSignedIn())) await auth.signIn();
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
export function oauth2(config) {
|
|
407
|
+
return new OAuth2Provider(config);
|
|
408
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) primitives built exclusively on WebCrypto.
|
|
3
|
+
*
|
|
4
|
+
* `globalThis.crypto` (the WebCrypto API) is available in browsers and in
|
|
5
|
+
* Node ≥20, so no `node:crypto` import — and therefore no bundler/runtime
|
|
6
|
+
* branch — is needed. This keeps the auth flow dependency-free and identical
|
|
7
|
+
* across environments.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A PKCE code verifier + its S256 challenge. The verifier is high-entropy
|
|
13
|
+
* (32 random bytes → 43-char base64url string, within the RFC's 43–128 range);
|
|
14
|
+
* the challenge is `BASE64URL(SHA-256(verifier))`.
|
|
15
|
+
*/
|
|
16
|
+
export interface PkcePair {
|
|
17
|
+
codeVerifier: string;
|
|
18
|
+
codeChallenge: string;
|
|
19
|
+
codeChallengeMethod: "S256";
|
|
20
|
+
}
|
|
21
|
+
/** Create a fresh PKCE verifier/challenge pair using SHA-256 (S256). */
|
|
22
|
+
export declare function createPkcePair(): Promise<PkcePair>;
|
|
23
|
+
/** Random, unguessable `state` value for CSRF protection on the redirect. */
|
|
24
|
+
export declare function createStateToken(): string;
|
|
25
|
+
//# sourceMappingURL=pkce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../../../../src/core/auth/pkce.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AA6BH;;;;GAIG;AACH,MAAM,WAAW,QAAQ;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,wEAAwE;AACxE,wBAAsB,cAAc,IAAI,OAAO,CAAC,QAAQ,CAAC,CAQxD;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) primitives built exclusively on WebCrypto.
|
|
3
|
+
*
|
|
4
|
+
* `globalThis.crypto` (the WebCrypto API) is available in browsers and in
|
|
5
|
+
* Node ≥20, so no `node:crypto` import — and therefore no bundler/runtime
|
|
6
|
+
* branch — is needed. This keeps the auth flow dependency-free and identical
|
|
7
|
+
* across environments.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
function requireWebCrypto() {
|
|
12
|
+
const cryptoRef = globalThis.crypto;
|
|
13
|
+
if (!cryptoRef?.subtle) {
|
|
14
|
+
throw new Error("WebCrypto (globalThis.crypto.subtle) is unavailable. OAuth2 PKCE requires a browser or Node.js >= 20.");
|
|
15
|
+
}
|
|
16
|
+
return cryptoRef;
|
|
17
|
+
}
|
|
18
|
+
/** Base64url-encode raw bytes (no padding), per RFC 7636 §A. */
|
|
19
|
+
function base64UrlEncode(bytes) {
|
|
20
|
+
let binary = "";
|
|
21
|
+
for (const byte of bytes) {
|
|
22
|
+
binary += String.fromCharCode(byte);
|
|
23
|
+
}
|
|
24
|
+
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
|
|
25
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
26
|
+
}
|
|
27
|
+
/** Cryptographically-random base64url string derived from `byteLength` bytes. */
|
|
28
|
+
function randomBase64Url(byteLength) {
|
|
29
|
+
const bytes = new Uint8Array(byteLength);
|
|
30
|
+
requireWebCrypto().getRandomValues(bytes);
|
|
31
|
+
return base64UrlEncode(bytes);
|
|
32
|
+
}
|
|
33
|
+
/** Create a fresh PKCE verifier/challenge pair using SHA-256 (S256). */
|
|
34
|
+
export async function createPkcePair() {
|
|
35
|
+
const codeVerifier = randomBase64Url(32);
|
|
36
|
+
const digest = await requireWebCrypto().subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
37
|
+
return {
|
|
38
|
+
codeVerifier,
|
|
39
|
+
codeChallenge: base64UrlEncode(new Uint8Array(digest)),
|
|
40
|
+
codeChallengeMethod: "S256",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Random, unguessable `state` value for CSRF protection on the redirect. */
|
|
44
|
+
export function createStateToken() {
|
|
45
|
+
return randomBase64Url(16);
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static (non-interactive) {@link HonuaAuthProvider} built-ins for callers that
|
|
3
|
+
* already hold a long-lived credential.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
import type { HonuaAuthProvider } from "../types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Provider that attaches a static API key as `X-API-Key` on every request.
|
|
10
|
+
* Equivalent to `new HonuaClient({ apiKey })` but composable as an
|
|
11
|
+
* {@link HonuaAuthProvider}.
|
|
12
|
+
*/
|
|
13
|
+
export declare function apiKeyAuth(apiKey: string): HonuaAuthProvider;
|
|
14
|
+
/**
|
|
15
|
+
* Provider that attaches a static bearer token as `Authorization: Bearer …`.
|
|
16
|
+
* Equivalent to `new HonuaClient({ bearerToken })` but composable as an
|
|
17
|
+
* {@link HonuaAuthProvider}.
|
|
18
|
+
*/
|
|
19
|
+
export declare function bearerTokenAuth(token: string): HonuaAuthProvider;
|
|
20
|
+
//# sourceMappingURL=static-providers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-providers.d.ts","sourceRoot":"","sources":["../../../../src/core/auth/static-providers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,CAI5D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,iBAAiB,CAIhE"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static (non-interactive) {@link HonuaAuthProvider} built-ins for callers that
|
|
3
|
+
* already hold a long-lived credential.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Provider that attaches a static API key as `X-API-Key` on every request.
|
|
9
|
+
* Equivalent to `new HonuaClient({ apiKey })` but composable as an
|
|
10
|
+
* {@link HonuaAuthProvider}.
|
|
11
|
+
*/
|
|
12
|
+
export function apiKeyAuth(apiKey) {
|
|
13
|
+
return {
|
|
14
|
+
getCredentials: () => ({ apiKey }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Provider that attaches a static bearer token as `Authorization: Bearer …`.
|
|
19
|
+
* Equivalent to `new HonuaClient({ bearerToken })` but composable as an
|
|
20
|
+
* {@link HonuaAuthProvider}.
|
|
21
|
+
*/
|
|
22
|
+
export function bearerTokenAuth(token) {
|
|
23
|
+
return {
|
|
24
|
+
getCredentials: () => ({ bearerToken: token }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for talking to an OAuth2 token endpoint and turning responses
|
|
3
|
+
* into {@link StoredCredential}s. Used by both the PKCE and client-credentials
|
|
4
|
+
* providers.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
import type { HonuaAuthCredentials } from "../types.js";
|
|
9
|
+
import type { OAuth2TokenResponse, StoredCredential } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Project a {@link StoredCredential} into the {@link HonuaAuthCredentials} shape
|
|
12
|
+
* the client attaches to requests. Non-`Bearer` token types are emitted as a
|
|
13
|
+
* full `Authorization` value; the expiry drives the client's own refresh skew.
|
|
14
|
+
*/
|
|
15
|
+
export declare function toHonuaCredentials(credential: StoredCredential): HonuaAuthCredentials;
|
|
16
|
+
/** Resolve `serviceKey`, defaulting to `${clientId}@${endpoint origin}`. */
|
|
17
|
+
export declare function resolveServiceKey(serviceKey: string | undefined, clientId: string, endpoint: string): string;
|
|
18
|
+
/** Default clock-skew tolerance (ms) applied to credential expiry checks. */
|
|
19
|
+
export declare const DEFAULT_CLOCK_SKEW_MS = 60000;
|
|
20
|
+
/** True when a credential is missing or within `skewMs` of (or past) expiry. */
|
|
21
|
+
export declare function isCredentialExpiring(credential: StoredCredential | undefined, skewMs: number): boolean;
|
|
22
|
+
/** Map a token response into a {@link StoredCredential} (clock-skew tolerant). */
|
|
23
|
+
export declare function credentialFromTokenResponse(response: OAuth2TokenResponse, previous?: StoredCredential): StoredCredential;
|
|
24
|
+
/**
|
|
25
|
+
* POST an `application/x-www-form-urlencoded` grant to the token endpoint and
|
|
26
|
+
* return the parsed {@link StoredCredential}.
|
|
27
|
+
*
|
|
28
|
+
* Errors are normalized to {@link HonuaAuthError}: an `invalid_grant` OAuth
|
|
29
|
+
* error (expired/revoked/invalid grant → re-auth needed) maps to code
|
|
30
|
+
* `invalid_grant`; every other transport/HTTP/parse failure maps to
|
|
31
|
+
* `refresh_failed` (transient, retry may succeed).
|
|
32
|
+
*/
|
|
33
|
+
export declare function requestToken(options: {
|
|
34
|
+
fetchFn: typeof fetch;
|
|
35
|
+
tokenEndpoint: string;
|
|
36
|
+
params: Record<string, string>;
|
|
37
|
+
previous?: StoredCredential;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}): Promise<StoredCredential>;
|
|
40
|
+
//# sourceMappingURL=token-endpoint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-endpoint.d.ts","sourceRoot":"","sources":["../../../../src/core/auth/token-endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,gBAAgB,GAAG,oBAAoB,CAWrF;AAED,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAW5G;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C,gFAAgF;AAChF,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAQtG;AAED,kFAAkF;AAClF,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,mBAAmB,EAC7B,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,gBAAgB,CAwBlB;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE;IAC1C,OAAO,EAAE,OAAO,KAAK,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAmD5B"}
|