@foir/sdk 0.3.0 → 0.5.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/README.md +36 -2
- package/dist/auth.d.ts +78 -1
- package/dist/auth.js +136 -0
- package/dist/client.d.ts +53 -1
- package/dist/client.js +14 -0
- package/dist/react.d.ts +79 -4
- package/dist/react.js +220 -16
- package/dist/server.d.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ pnpm add yjs y-websocket # for @foir/sdk/realtime
|
|
|
24
24
|
| --- | --- | --- |
|
|
25
25
|
| `@foir/sdk` | Editor iframe client — `EditorProvider`, `useEditor`, `useAutoResize`, `createPlatformClient`, host↔editor message protocol | `react` |
|
|
26
26
|
| `@foir/sdk/client` | Client-agnostic GraphQL transport for SSR/edge loaders — `foirRequest`, `isFoirPreview`, `absolutiseFoirMediaUrl` | `graphql` |
|
|
27
|
-
| `@foir/sdk/auth` | Framework-agnostic customer auth — login/register/OTP/provider flows, token lifecycle (`SessionAdapter`), `authedRequest`, guards | `graphql` |
|
|
28
|
-
| `@foir/sdk/react` | Drop-in React auth — `<FoirProvider
|
|
27
|
+
| `@foir/sdk/auth` | Framework-agnostic customer auth — login/register/OTP/provider flows, token lifecycle (`SessionAdapter`), `createFoirSessionClient` (Foir-hosted cookie sessions), `authedRequest`, guards | `graphql` |
|
|
28
|
+
| `@foir/sdk/react` | Drop-in React auth — `<FoirProvider>` (token or `hostedSession` mode), `useFoirSession`/`useFoirUser`/`useFoirAuth`, `<SignIn>`, `<OAuthButtons>`, `<UserButton>` | `react`, `graphql` |
|
|
29
29
|
| `@foir/sdk/next` | Next.js App Router BFF auth — `createFoirCookieAdapter`, `getFoirSession`, `foirSignIn*`, `completeFoirProviderLogin`, `createFoirMiddleware` | `graphql` |
|
|
30
30
|
| `@foir/sdk/server` | Server-side helpers for extension/config API backends — `createServerClient`, `verifyWebhookSignature`, `verifyScopedToken`, callback client | — |
|
|
31
31
|
| `@foir/sdk/media` | Progressive media — `ProgressiveMedia`, `BlurhashCanvas`, `useProgressiveLoad`, variant-tier adapters | `react` (`hls.js`, `blurhash` optional) |
|
|
@@ -108,6 +108,40 @@ function Header() {
|
|
|
108
108
|
For a custom form, use `useFoirAuth()` (`signIn`, `register`, `signInWithOtp`,
|
|
109
109
|
`requestOtp`, `signInWithProvider`, `signOut`, `getToken`) and `useFoirUser()`.
|
|
110
110
|
|
|
111
|
+
### Backend-less SPA (Foir-hosted sessions)
|
|
112
|
+
|
|
113
|
+
By default `<FoirProvider>` stores tokens in `localStorage`. If your SPA has no
|
|
114
|
+
backend but you have a **verified custom login domain** on the same registrable
|
|
115
|
+
domain (`app.acme.com` + `login.acme.com`), prefer `hostedSession`: the durable
|
|
116
|
+
credential becomes an HttpOnly cookie Foir sets on your domain, and the 15-minute
|
|
117
|
+
access token lives only in memory. Nothing authentication-related is reachable
|
|
118
|
+
from JS, so XSS has nothing to exfiltrate — the BFF security posture without a
|
|
119
|
+
backend.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<FoirProvider
|
|
123
|
+
env={{ PLATFORM_API_URL: 'https://api.foir.dev/graphql', PLATFORM_API_KEY: 'pk_…' }}
|
|
124
|
+
hostedSession={{ loginOrigin: 'https://login.acme.com' }}
|
|
125
|
+
>
|
|
126
|
+
<App />
|
|
127
|
+
</FoirProvider>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Sign-in is a redirect, not a form — `useFoirAuth().hostedSignInUrl()` gives you
|
|
131
|
+
the URL (the password/OTP actions throw in this mode). `getToken()` exchanges the
|
|
132
|
+
cookie and re-exchanges it silently before expiry; `signOut()` revokes the session
|
|
133
|
+
server-side. Everything else — `useFoirSession`, `useFoirUser`, `<UserButton>` —
|
|
134
|
+
behaves the same.
|
|
135
|
+
|
|
136
|
+
Framework-agnostic equivalent:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { createFoirSessionClient } from '@foir/sdk/auth';
|
|
140
|
+
|
|
141
|
+
const session = createFoirSessionClient({ loginOrigin: 'https://login.acme.com' });
|
|
142
|
+
const token = await session.getToken(); // null when signed out
|
|
143
|
+
```
|
|
144
|
+
|
|
111
145
|
### Next.js (App Router, BFF)
|
|
112
146
|
|
|
113
147
|
`@foir/sdk/next` keeps the tokens in HttpOnly cookies server-side. Protect
|
package/dist/auth.d.ts
CHANGED
|
@@ -302,4 +302,81 @@ interface HostedSession {
|
|
|
302
302
|
*/
|
|
303
303
|
declare function fetchHostedSession(request: Request, config: HostedLoginConfig): Promise<HostedSession | null>;
|
|
304
304
|
|
|
305
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Foir-hosted first-party sessions, from the browser — the model for a
|
|
307
|
+
* backend-less SPA (Vite, CRA, a static site) that wants no tokens in JS.
|
|
308
|
+
*
|
|
309
|
+
* The sibling `fetchHostedSession` in ./hosted does the same exchange from a
|
|
310
|
+
* SERVER: it reads the cookie off an incoming request and forwards it by hand.
|
|
311
|
+
* A browser cannot set a `Cookie` header, so that function is unusable from an
|
|
312
|
+
* SPA by construction. This one lets the browser send the cookie the only way it
|
|
313
|
+
* can — `credentials: 'include'` — and keeps the resulting bearer in memory.
|
|
314
|
+
*
|
|
315
|
+
* How the pieces divide:
|
|
316
|
+
*
|
|
317
|
+
* cookie — long-lived (30d), HttpOnly, first-party on your registrable
|
|
318
|
+
* domain, set by Foir when hosted login finalizes. JS cannot read
|
|
319
|
+
* it, so XSS cannot exfiltrate it.
|
|
320
|
+
* bearer — 15 minutes, held in a closure here. Never written to
|
|
321
|
+
* localStorage/sessionStorage/IndexedDB, never in a cookie of ours.
|
|
322
|
+
*
|
|
323
|
+
* There is no refresh token in this flow. Re-exchanging the cookie IS the
|
|
324
|
+
* refresh, which is why nothing here needs rotation or reuse-detection: the
|
|
325
|
+
* durable credential is an opaque server-side session handle, revocable at any
|
|
326
|
+
* time from the admin panel or `signOut()`.
|
|
327
|
+
*
|
|
328
|
+
* REQUIRES a verified custom login domain that shares a registrable domain with
|
|
329
|
+
* your app (app.acme.com + login.acme.com). That is what makes the cookie
|
|
330
|
+
* first-party and lets `SameSite=Lax` send it. The same endpoints exist on
|
|
331
|
+
* api.foir.dev but a browser cannot reach them there — the cookie would be
|
|
332
|
+
* cross-site, and that CORS surface deliberately refuses credentials.
|
|
333
|
+
*/
|
|
334
|
+
interface FoirSessionConfig {
|
|
335
|
+
/**
|
|
336
|
+
* Your verified custom login domain, e.g. `https://login.acme.com`. Must share
|
|
337
|
+
* a registrable domain with the app calling it.
|
|
338
|
+
*/
|
|
339
|
+
loginOrigin: string;
|
|
340
|
+
/** Session-exchange path. Default `/session/token`. */
|
|
341
|
+
sessionPath?: string;
|
|
342
|
+
/** Logout path. Default `/session/logout`. */
|
|
343
|
+
logoutPath?: string;
|
|
344
|
+
/** Seconds before expiry to re-exchange. Default 60. */
|
|
345
|
+
refreshSkewSeconds?: number;
|
|
346
|
+
/**
|
|
347
|
+
* Called when the session goes away — the cookie expired, was revoked, or
|
|
348
|
+
* `signOut()` ran. Use it to redirect to `signInUrl()`.
|
|
349
|
+
*/
|
|
350
|
+
onSignOut?: () => void;
|
|
351
|
+
}
|
|
352
|
+
/** The identity behind the current session. */
|
|
353
|
+
interface FoirSessionUser {
|
|
354
|
+
customerId: string;
|
|
355
|
+
email: string;
|
|
356
|
+
}
|
|
357
|
+
interface FoirSessionClient {
|
|
358
|
+
/**
|
|
359
|
+
* The current access token, exchanging or re-exchanging the cookie as needed.
|
|
360
|
+
* Null when there is no session. Concurrent callers share one in-flight
|
|
361
|
+
* exchange. Call this before each API request — do not cache the result.
|
|
362
|
+
*/
|
|
363
|
+
getToken(): Promise<string | null>;
|
|
364
|
+
/** The signed-in customer, or null. Exchanges if the session isn't loaded yet. */
|
|
365
|
+
getUser(): Promise<FoirSessionUser | null>;
|
|
366
|
+
/** Revoke the session server-side and clear the cookie. Idempotent. */
|
|
367
|
+
signOut(): Promise<void>;
|
|
368
|
+
/** Where to send the browser to sign in. */
|
|
369
|
+
signInUrl(returnTo?: string): string;
|
|
370
|
+
/** Observe session changes. Returns an unsubscribe function. */
|
|
371
|
+
subscribe(listener: (user: FoirSessionUser | null) => void): () => void;
|
|
372
|
+
/** Cancel the refresh timer. Call on unmount. */
|
|
373
|
+
destroy(): void;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Create a session client. Safe to call during SSR — it does nothing until a
|
|
377
|
+
* method is invoked, and `getToken()` on the server returns null rather than
|
|
378
|
+
* throwing (there is no cookie jar to draw on).
|
|
379
|
+
*/
|
|
380
|
+
declare function createFoirSessionClient(config: FoirSessionConfig): FoirSessionClient;
|
|
381
|
+
|
|
382
|
+
export { type AuthProviderPublic, AuthProvidersDocument, type CustomerAuthResult, CustomerLoginDocument, CustomerLoginOTPDocument, CustomerLoginWithProviderDocument, CustomerProviderCallbackDocument, CustomerProviderVerifyOTPDocument, CustomerRefreshTokenDocument, CustomerRegisterDocument, CustomerRequestOTPDocument, type FoirAuthTokens, type FoirCustomer, type FoirSessionClient, type FoirSessionConfig, type FoirSessionUser, type FoirUser, type HostedLoginConfig, type HostedSession, type ProviderCallbackInput, type ProviderLoginInput, type ProviderLoginResult, type ProviderOTPVerifyInput, type RequireAdminOptions, type RequireAuthOptions, type SessionAdapter, authedRequest, clearSession, createFoirSessionClient, customerLogin, customerLoginOTP, customerLoginWithProvider, customerProviderCallback, customerProviderVerifyOTP, customerRefreshToken, customerRegister, customerRequestOTP, fetchHostedSession, getAuthProviders, getSessionCustomer, getSessionToken, hostedLoginUrl, loginWithOTP, loginWithPassword, refreshSession, registerWithPassword, requireAdmin, requireAuth, saveSession };
|
package/dist/auth.js
CHANGED
|
@@ -274,6 +274,141 @@ async function fetchHostedSession(request, config) {
|
|
|
274
274
|
function escapeRegExp(s) {
|
|
275
275
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
276
276
|
}
|
|
277
|
+
|
|
278
|
+
// src/auth/session-client.ts
|
|
279
|
+
var DEFAULT_SESSION_PATH2 = "/session/token";
|
|
280
|
+
var DEFAULT_LOGOUT_PATH = "/session/logout";
|
|
281
|
+
var DEFAULT_REFRESH_SKEW_SECONDS = 60;
|
|
282
|
+
var MIN_REFRESH_DELAY_MS = 5e3;
|
|
283
|
+
var CSRF_HEADER = { "X-Foir-Session": "1" };
|
|
284
|
+
function createFoirSessionClient(config) {
|
|
285
|
+
const sessionUrl = `${trimSlash(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH2}`;
|
|
286
|
+
const logoutUrl = `${trimSlash(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
|
|
287
|
+
const skewMs = (config.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
|
|
288
|
+
let cached = null;
|
|
289
|
+
let inflight = null;
|
|
290
|
+
let timer = null;
|
|
291
|
+
let destroyed = false;
|
|
292
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
293
|
+
function emit(user) {
|
|
294
|
+
for (const l of listeners) l(user);
|
|
295
|
+
}
|
|
296
|
+
function clearTimer() {
|
|
297
|
+
if (timer !== null) {
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
timer = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function scheduleRefresh(expiresAt) {
|
|
303
|
+
clearTimer();
|
|
304
|
+
if (destroyed || typeof setTimeout === "undefined") return;
|
|
305
|
+
const delay = Math.max(expiresAt - Date.now() - skewMs, MIN_REFRESH_DELAY_MS);
|
|
306
|
+
timer = setTimeout(() => {
|
|
307
|
+
void exchange();
|
|
308
|
+
}, delay);
|
|
309
|
+
timer.unref?.();
|
|
310
|
+
}
|
|
311
|
+
function forget() {
|
|
312
|
+
const had = cached !== null;
|
|
313
|
+
cached = null;
|
|
314
|
+
clearTimer();
|
|
315
|
+
if (had) emit(null);
|
|
316
|
+
}
|
|
317
|
+
function exchange() {
|
|
318
|
+
if (inflight) return inflight;
|
|
319
|
+
const pending = (async () => {
|
|
320
|
+
let res;
|
|
321
|
+
try {
|
|
322
|
+
res = await fetch(sessionUrl, {
|
|
323
|
+
method: "GET",
|
|
324
|
+
// The whole point: send the first-party cookie cross-origin (but
|
|
325
|
+
// same-site) to the login domain. Without this the cookie is not sent.
|
|
326
|
+
credentials: "include",
|
|
327
|
+
headers: { ...CSRF_HEADER }
|
|
328
|
+
});
|
|
329
|
+
} catch {
|
|
330
|
+
return cached;
|
|
331
|
+
}
|
|
332
|
+
if (res.status === 401) {
|
|
333
|
+
forget();
|
|
334
|
+
config.onSignOut?.();
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
if (!res.ok) return cached;
|
|
338
|
+
let json;
|
|
339
|
+
try {
|
|
340
|
+
json = await res.json();
|
|
341
|
+
} catch {
|
|
342
|
+
return cached;
|
|
343
|
+
}
|
|
344
|
+
if (!json.access_token || !json.customer_id) return cached;
|
|
345
|
+
const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 0;
|
|
346
|
+
const next = {
|
|
347
|
+
token: json.access_token,
|
|
348
|
+
expiresAt: Date.now() + expiresIn * 1e3,
|
|
349
|
+
user: { customerId: json.customer_id, email: json.email ?? "" }
|
|
350
|
+
};
|
|
351
|
+
const changed = cached?.user.customerId !== next.user.customerId;
|
|
352
|
+
cached = next;
|
|
353
|
+
scheduleRefresh(next.expiresAt);
|
|
354
|
+
if (changed) emit(next.user);
|
|
355
|
+
return next;
|
|
356
|
+
})();
|
|
357
|
+
inflight = pending;
|
|
358
|
+
void pending.finally(() => {
|
|
359
|
+
if (inflight === pending) inflight = null;
|
|
360
|
+
});
|
|
361
|
+
return pending;
|
|
362
|
+
}
|
|
363
|
+
function fresh() {
|
|
364
|
+
if (!cached) return null;
|
|
365
|
+
return Date.now() < cached.expiresAt - skewMs ? cached : null;
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
async getToken() {
|
|
369
|
+
const hit = fresh();
|
|
370
|
+
if (hit) return hit.token;
|
|
371
|
+
const got = await exchange();
|
|
372
|
+
return got?.token ?? null;
|
|
373
|
+
},
|
|
374
|
+
async getUser() {
|
|
375
|
+
const hit = fresh();
|
|
376
|
+
if (hit) return hit.user;
|
|
377
|
+
const got = await exchange();
|
|
378
|
+
return got?.user ?? null;
|
|
379
|
+
},
|
|
380
|
+
async signOut() {
|
|
381
|
+
try {
|
|
382
|
+
await fetch(logoutUrl, {
|
|
383
|
+
method: "POST",
|
|
384
|
+
credentials: "include",
|
|
385
|
+
headers: { ...CSRF_HEADER }
|
|
386
|
+
});
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
forget();
|
|
390
|
+
config.onSignOut?.();
|
|
391
|
+
},
|
|
392
|
+
signInUrl(returnTo) {
|
|
393
|
+
const dest = new URL(trimSlash(config.loginOrigin));
|
|
394
|
+
const target = returnTo ?? (typeof window !== "undefined" ? window.location.href : void 0);
|
|
395
|
+
if (target && target !== "/") dest.searchParams.set("returnTo", target);
|
|
396
|
+
return dest.toString();
|
|
397
|
+
},
|
|
398
|
+
subscribe(listener) {
|
|
399
|
+
listeners.add(listener);
|
|
400
|
+
return () => listeners.delete(listener);
|
|
401
|
+
},
|
|
402
|
+
destroy() {
|
|
403
|
+
destroyed = true;
|
|
404
|
+
clearTimer();
|
|
405
|
+
listeners.clear();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function trimSlash(s) {
|
|
410
|
+
return s.replace(/\/+$/, "");
|
|
411
|
+
}
|
|
277
412
|
export {
|
|
278
413
|
AuthProvidersDocument,
|
|
279
414
|
CustomerLoginDocument,
|
|
@@ -286,6 +421,7 @@ export {
|
|
|
286
421
|
CustomerRequestOTPDocument,
|
|
287
422
|
authedRequest,
|
|
288
423
|
clearSession,
|
|
424
|
+
createFoirSessionClient,
|
|
289
425
|
customerLogin,
|
|
290
426
|
customerLoginOTP,
|
|
291
427
|
customerLoginWithProvider,
|
package/dist/client.d.ts
CHANGED
|
@@ -80,4 +80,56 @@ interface FoirRequestOptions {
|
|
|
80
80
|
}
|
|
81
81
|
declare function foirRequest<TData, TVariables>(env: FoirEnv, document: TypedDocumentNode<TData, TVariables>, variables?: TVariables, options?: FoirRequestOptions): Promise<TData>;
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Session-bound content client — forwards the customer bearer on every read
|
|
85
|
+
* when one is available, so auth/segment-targeted variants and customer-scoped
|
|
86
|
+
* records resolve out of the box, with no per-query wiring to forget.
|
|
87
|
+
*
|
|
88
|
+
* Resolution keys off the request's principal (see the platform's two-gate
|
|
89
|
+
* model): the `x-api-key` alone resolves the *anonymous baseline*
|
|
90
|
+
* (`context.authStatus = ""`); adding the customer bearer resolves the request
|
|
91
|
+
* as that customer (`context.authStatus = "authenticated"`, `customer_id` set).
|
|
92
|
+
*
|
|
93
|
+
* Caching: an api-key-only read is identical for every anonymous visitor and is
|
|
94
|
+
* shared-cacheable; a bearer-bearing read resolves per-customer and is not.
|
|
95
|
+
* Because the bearer only exists once a customer signs in, anonymous traffic
|
|
96
|
+
* keeps hitting the shared-cacheable baseline — only signed-in reads become
|
|
97
|
+
* per-customer. Bind this client wherever content is personalized (for most
|
|
98
|
+
* apps, everywhere the customer may be signed in) and use plain `foirRequest`
|
|
99
|
+
* for reads that must stay anonymous regardless of session.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Supplies the current customer bearer. Sync or async so consumers can read a
|
|
104
|
+
* cookie session (Hydrogen/Remix), a KV store, an in-memory value, etc. Returns
|
|
105
|
+
* the token when signed in, or `null`/`undefined` when signed out.
|
|
106
|
+
*/
|
|
107
|
+
type FoirTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
|
|
108
|
+
interface FoirContentClientConfig {
|
|
109
|
+
env: FoirEnv;
|
|
110
|
+
/**
|
|
111
|
+
* Resolves the current customer bearer before every request. When it yields a
|
|
112
|
+
* token the token is forwarded, so `authStatus`/segment targeting and
|
|
113
|
+
* customer-scoped records resolve as that customer; when it yields
|
|
114
|
+
* null/undefined the read hits the anonymous baseline. Omit for a client that
|
|
115
|
+
* is always anonymous (equivalent to calling `foirRequest` directly).
|
|
116
|
+
*/
|
|
117
|
+
getToken?: FoirTokenProvider;
|
|
118
|
+
}
|
|
119
|
+
interface FoirContentClient {
|
|
120
|
+
/**
|
|
121
|
+
* Run a typed document, forwarding the bound customer bearer when available.
|
|
122
|
+
* An explicit `options.bearerToken` still wins, so a single call can override
|
|
123
|
+
* the bound token (or force anonymous by passing `undefined` — the bound
|
|
124
|
+
* token only fills in when `options.bearerToken` is not supplied).
|
|
125
|
+
*/
|
|
126
|
+
request<TData, TVariables>(document: TypedDocumentNode<TData, TVariables>, variables?: TVariables, options?: FoirRequestOptions): Promise<TData>;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create a {@link FoirContentClient} bound to an env + token source. Every
|
|
130
|
+
* `request` forwards the customer bearer when `getToken` yields one, upgrading
|
|
131
|
+
* that read from the anonymous baseline to the signed-in customer.
|
|
132
|
+
*/
|
|
133
|
+
declare function createFoirContentClient(config: FoirContentClientConfig): FoirContentClient;
|
|
134
|
+
|
|
135
|
+
export { FOIR_CDN_BASE, type FoirContentClient, type FoirContentClientConfig, type FoirEnv, FoirRequestError, type FoirRequestOptions, type FoirTokenProvider, absolutiseFoirMediaUrl, createFoirContentClient, foirRequest, isFoirPreview };
|
package/dist/client.js
CHANGED
|
@@ -72,10 +72,24 @@ async function foirRequest(env, document, variables, options) {
|
|
|
72
72
|
}
|
|
73
73
|
return json.data;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
// src/graphql/content-client.ts
|
|
77
|
+
function createFoirContentClient(config) {
|
|
78
|
+
return {
|
|
79
|
+
async request(document, variables, options) {
|
|
80
|
+
const bound = config.getToken ? await config.getToken() : null;
|
|
81
|
+
return foirRequest(config.env, document, variables, {
|
|
82
|
+
...options,
|
|
83
|
+
bearerToken: options?.bearerToken ?? bound ?? void 0
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
75
88
|
export {
|
|
76
89
|
FOIR_CDN_BASE,
|
|
77
90
|
FoirRequestError,
|
|
78
91
|
absolutiseFoirMediaUrl,
|
|
92
|
+
createFoirContentClient,
|
|
79
93
|
foirRequest,
|
|
80
94
|
isFoirPreview
|
|
81
95
|
};
|
package/dist/react.d.ts
CHANGED
|
@@ -90,6 +90,54 @@ interface SessionAdapter {
|
|
|
90
90
|
unset(key: string): void | Promise<void>;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Foir-hosted first-party sessions, from the browser — the model for a
|
|
95
|
+
* backend-less SPA (Vite, CRA, a static site) that wants no tokens in JS.
|
|
96
|
+
*
|
|
97
|
+
* The sibling `fetchHostedSession` in ./hosted does the same exchange from a
|
|
98
|
+
* SERVER: it reads the cookie off an incoming request and forwards it by hand.
|
|
99
|
+
* A browser cannot set a `Cookie` header, so that function is unusable from an
|
|
100
|
+
* SPA by construction. This one lets the browser send the cookie the only way it
|
|
101
|
+
* can — `credentials: 'include'` — and keeps the resulting bearer in memory.
|
|
102
|
+
*
|
|
103
|
+
* How the pieces divide:
|
|
104
|
+
*
|
|
105
|
+
* cookie — long-lived (30d), HttpOnly, first-party on your registrable
|
|
106
|
+
* domain, set by Foir when hosted login finalizes. JS cannot read
|
|
107
|
+
* it, so XSS cannot exfiltrate it.
|
|
108
|
+
* bearer — 15 minutes, held in a closure here. Never written to
|
|
109
|
+
* localStorage/sessionStorage/IndexedDB, never in a cookie of ours.
|
|
110
|
+
*
|
|
111
|
+
* There is no refresh token in this flow. Re-exchanging the cookie IS the
|
|
112
|
+
* refresh, which is why nothing here needs rotation or reuse-detection: the
|
|
113
|
+
* durable credential is an opaque server-side session handle, revocable at any
|
|
114
|
+
* time from the admin panel or `signOut()`.
|
|
115
|
+
*
|
|
116
|
+
* REQUIRES a verified custom login domain that shares a registrable domain with
|
|
117
|
+
* your app (app.acme.com + login.acme.com). That is what makes the cookie
|
|
118
|
+
* first-party and lets `SameSite=Lax` send it. The same endpoints exist on
|
|
119
|
+
* api.foir.dev but a browser cannot reach them there — the cookie would be
|
|
120
|
+
* cross-site, and that CORS surface deliberately refuses credentials.
|
|
121
|
+
*/
|
|
122
|
+
interface FoirSessionConfig {
|
|
123
|
+
/**
|
|
124
|
+
* Your verified custom login domain, e.g. `https://login.acme.com`. Must share
|
|
125
|
+
* a registrable domain with the app calling it.
|
|
126
|
+
*/
|
|
127
|
+
loginOrigin: string;
|
|
128
|
+
/** Session-exchange path. Default `/session/token`. */
|
|
129
|
+
sessionPath?: string;
|
|
130
|
+
/** Logout path. Default `/session/logout`. */
|
|
131
|
+
logoutPath?: string;
|
|
132
|
+
/** Seconds before expiry to re-exchange. Default 60. */
|
|
133
|
+
refreshSkewSeconds?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Called when the session goes away — the cookie expired, was revoked, or
|
|
136
|
+
* `signOut()` ran. Use it to redirect to `signInUrl()`.
|
|
137
|
+
*/
|
|
138
|
+
onSignOut?: () => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
93
141
|
/** Where the session hydration stands. */
|
|
94
142
|
type FoirStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
|
95
143
|
/** The full context value. Read it through the hooks below, not directly. */
|
|
@@ -126,20 +174,46 @@ interface FoirContextValue {
|
|
|
126
174
|
getToken(): Promise<string | null>;
|
|
127
175
|
/** The project's configured auth providers (for OAuth buttons). */
|
|
128
176
|
getProviders(): Promise<AuthProviderPublic[]>;
|
|
177
|
+
/**
|
|
178
|
+
* In `hostedSession` mode, where to send the browser to sign in — sign-in is a
|
|
179
|
+
* redirect to your Foir login domain, not an in-page form. Null in token mode,
|
|
180
|
+
* where you call {@link FoirContextValue.signIn} instead.
|
|
181
|
+
*/
|
|
182
|
+
hostedSignInUrl: ((returnTo?: string) => string) | null;
|
|
129
183
|
}
|
|
130
184
|
interface FoirProviderProps {
|
|
131
185
|
/** API config: `{ PLATFORM_API_URL, PLATFORM_API_KEY }` (a publishable `pk_` key). */
|
|
132
186
|
env: FoirEnv;
|
|
133
|
-
/** Session store. Defaults to a browser localStorage adapter. */
|
|
187
|
+
/** Session store for token mode. Defaults to a browser localStorage adapter. */
|
|
134
188
|
adapter?: SessionAdapter;
|
|
189
|
+
/**
|
|
190
|
+
* Opt into Foir-hosted first-party sessions: the durable credential is an
|
|
191
|
+
* HttpOnly cookie on your own domain and the access token lives only in
|
|
192
|
+
* memory, so nothing authentication-related is reachable from JS. This is the
|
|
193
|
+
* secure default for a backend-less SPA, and it REPLACES `adapter` — pass one
|
|
194
|
+
* or the other, not both.
|
|
195
|
+
*
|
|
196
|
+
* Requires a verified custom login domain sharing a registrable domain with
|
|
197
|
+
* your app (`app.acme.com` + `login.acme.com`). Sign-in becomes a redirect to
|
|
198
|
+
* {@link FoirContextValue.hostedSignInUrl}; the password/OTP/provider actions
|
|
199
|
+
* do not apply and will throw.
|
|
200
|
+
*/
|
|
201
|
+
hostedSession?: Omit<FoirSessionConfig, 'onSignOut'>;
|
|
135
202
|
children: ReactNode;
|
|
136
203
|
}
|
|
137
204
|
/**
|
|
138
|
-
* Provides Foir customer auth to a React tree. Hydrates the
|
|
139
|
-
*
|
|
205
|
+
* Provides Foir customer auth to a React tree. Hydrates the session on mount,
|
|
206
|
+
* then exposes the current customer and sign-in/out actions through
|
|
140
207
|
* {@link useFoirSession}, {@link useFoirUser}, and {@link useFoirAuth}.
|
|
208
|
+
*
|
|
209
|
+
* Two session models:
|
|
210
|
+
*
|
|
211
|
+
* default tokens in a `SessionAdapter` (localStorage unless you pass
|
|
212
|
+
* your own). Simple, but a refresh token sits in JS.
|
|
213
|
+
* `hostedSession` Foir-hosted first-party cookie; the bearer never leaves
|
|
214
|
+
* memory. Needs a custom login domain. Prefer this.
|
|
141
215
|
*/
|
|
142
|
-
declare function FoirProvider({ env, adapter, children }: FoirProviderProps): react_jsx_runtime.JSX.Element;
|
|
216
|
+
declare function FoirProvider({ env, adapter, hostedSession, children, }: FoirProviderProps): react_jsx_runtime.JSX.Element;
|
|
143
217
|
/** The current session state. */
|
|
144
218
|
interface FoirSession {
|
|
145
219
|
status: FoirStatus;
|
|
@@ -174,6 +248,7 @@ declare function useFoirAuth(): {
|
|
|
174
248
|
refresh: () => Promise<boolean>;
|
|
175
249
|
getToken: () => Promise<string | null>;
|
|
176
250
|
getProviders: () => Promise<AuthProviderPublic[]>;
|
|
251
|
+
hostedSignInUrl: ((returnTo?: string) => string) | null;
|
|
177
252
|
};
|
|
178
253
|
|
|
179
254
|
interface SignInProps {
|
package/dist/react.js
CHANGED
|
@@ -202,6 +202,141 @@ async function refreshSession(adapter, env) {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// src/auth/session-client.ts
|
|
206
|
+
var DEFAULT_SESSION_PATH = "/session/token";
|
|
207
|
+
var DEFAULT_LOGOUT_PATH = "/session/logout";
|
|
208
|
+
var DEFAULT_REFRESH_SKEW_SECONDS = 60;
|
|
209
|
+
var MIN_REFRESH_DELAY_MS = 5e3;
|
|
210
|
+
var CSRF_HEADER = { "X-Foir-Session": "1" };
|
|
211
|
+
function createFoirSessionClient(config) {
|
|
212
|
+
const sessionUrl = `${trimSlash(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH}`;
|
|
213
|
+
const logoutUrl = `${trimSlash(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}`;
|
|
214
|
+
const skewMs = (config.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
|
|
215
|
+
let cached = null;
|
|
216
|
+
let inflight = null;
|
|
217
|
+
let timer = null;
|
|
218
|
+
let destroyed = false;
|
|
219
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
220
|
+
function emit(user) {
|
|
221
|
+
for (const l of listeners) l(user);
|
|
222
|
+
}
|
|
223
|
+
function clearTimer() {
|
|
224
|
+
if (timer !== null) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
timer = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function scheduleRefresh(expiresAt) {
|
|
230
|
+
clearTimer();
|
|
231
|
+
if (destroyed || typeof setTimeout === "undefined") return;
|
|
232
|
+
const delay = Math.max(expiresAt - Date.now() - skewMs, MIN_REFRESH_DELAY_MS);
|
|
233
|
+
timer = setTimeout(() => {
|
|
234
|
+
void exchange();
|
|
235
|
+
}, delay);
|
|
236
|
+
timer.unref?.();
|
|
237
|
+
}
|
|
238
|
+
function forget() {
|
|
239
|
+
const had = cached !== null;
|
|
240
|
+
cached = null;
|
|
241
|
+
clearTimer();
|
|
242
|
+
if (had) emit(null);
|
|
243
|
+
}
|
|
244
|
+
function exchange() {
|
|
245
|
+
if (inflight) return inflight;
|
|
246
|
+
const pending = (async () => {
|
|
247
|
+
let res;
|
|
248
|
+
try {
|
|
249
|
+
res = await fetch(sessionUrl, {
|
|
250
|
+
method: "GET",
|
|
251
|
+
// The whole point: send the first-party cookie cross-origin (but
|
|
252
|
+
// same-site) to the login domain. Without this the cookie is not sent.
|
|
253
|
+
credentials: "include",
|
|
254
|
+
headers: { ...CSRF_HEADER }
|
|
255
|
+
});
|
|
256
|
+
} catch {
|
|
257
|
+
return cached;
|
|
258
|
+
}
|
|
259
|
+
if (res.status === 401) {
|
|
260
|
+
forget();
|
|
261
|
+
config.onSignOut?.();
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (!res.ok) return cached;
|
|
265
|
+
let json;
|
|
266
|
+
try {
|
|
267
|
+
json = await res.json();
|
|
268
|
+
} catch {
|
|
269
|
+
return cached;
|
|
270
|
+
}
|
|
271
|
+
if (!json.access_token || !json.customer_id) return cached;
|
|
272
|
+
const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 0;
|
|
273
|
+
const next = {
|
|
274
|
+
token: json.access_token,
|
|
275
|
+
expiresAt: Date.now() + expiresIn * 1e3,
|
|
276
|
+
user: { customerId: json.customer_id, email: json.email ?? "" }
|
|
277
|
+
};
|
|
278
|
+
const changed = cached?.user.customerId !== next.user.customerId;
|
|
279
|
+
cached = next;
|
|
280
|
+
scheduleRefresh(next.expiresAt);
|
|
281
|
+
if (changed) emit(next.user);
|
|
282
|
+
return next;
|
|
283
|
+
})();
|
|
284
|
+
inflight = pending;
|
|
285
|
+
void pending.finally(() => {
|
|
286
|
+
if (inflight === pending) inflight = null;
|
|
287
|
+
});
|
|
288
|
+
return pending;
|
|
289
|
+
}
|
|
290
|
+
function fresh() {
|
|
291
|
+
if (!cached) return null;
|
|
292
|
+
return Date.now() < cached.expiresAt - skewMs ? cached : null;
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
async getToken() {
|
|
296
|
+
const hit = fresh();
|
|
297
|
+
if (hit) return hit.token;
|
|
298
|
+
const got = await exchange();
|
|
299
|
+
return got?.token ?? null;
|
|
300
|
+
},
|
|
301
|
+
async getUser() {
|
|
302
|
+
const hit = fresh();
|
|
303
|
+
if (hit) return hit.user;
|
|
304
|
+
const got = await exchange();
|
|
305
|
+
return got?.user ?? null;
|
|
306
|
+
},
|
|
307
|
+
async signOut() {
|
|
308
|
+
try {
|
|
309
|
+
await fetch(logoutUrl, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
credentials: "include",
|
|
312
|
+
headers: { ...CSRF_HEADER }
|
|
313
|
+
});
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
forget();
|
|
317
|
+
config.onSignOut?.();
|
|
318
|
+
},
|
|
319
|
+
signInUrl(returnTo) {
|
|
320
|
+
const dest = new URL(trimSlash(config.loginOrigin));
|
|
321
|
+
const target = returnTo ?? (typeof window !== "undefined" ? window.location.href : void 0);
|
|
322
|
+
if (target && target !== "/") dest.searchParams.set("returnTo", target);
|
|
323
|
+
return dest.toString();
|
|
324
|
+
},
|
|
325
|
+
subscribe(listener) {
|
|
326
|
+
listeners.add(listener);
|
|
327
|
+
return () => listeners.delete(listener);
|
|
328
|
+
},
|
|
329
|
+
destroy() {
|
|
330
|
+
destroyed = true;
|
|
331
|
+
clearTimer();
|
|
332
|
+
listeners.clear();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function trimSlash(s) {
|
|
337
|
+
return s.replace(/\/+$/, "");
|
|
338
|
+
}
|
|
339
|
+
|
|
205
340
|
// src/react/localStorageAdapter.ts
|
|
206
341
|
function createLocalStorageAdapter() {
|
|
207
342
|
const store = () => {
|
|
@@ -225,7 +360,17 @@ function createLocalStorageAdapter() {
|
|
|
225
360
|
// src/react/FoirProvider.tsx
|
|
226
361
|
import { jsx } from "react/jsx-runtime";
|
|
227
362
|
var FoirContext = createContext(null);
|
|
228
|
-
function FoirProvider({
|
|
363
|
+
function FoirProvider({
|
|
364
|
+
env,
|
|
365
|
+
adapter,
|
|
366
|
+
hostedSession,
|
|
367
|
+
children
|
|
368
|
+
}) {
|
|
369
|
+
if (process.env.NODE_ENV !== "production" && adapter && hostedSession) {
|
|
370
|
+
console.warn(
|
|
371
|
+
"[foir] FoirProvider: `adapter` is ignored when `hostedSession` is set \u2014 the hosted session keeps the access token in memory and never persists it."
|
|
372
|
+
);
|
|
373
|
+
}
|
|
229
374
|
const envRef = useRef(env);
|
|
230
375
|
envRef.current = env;
|
|
231
376
|
const adapterRef = useRef(adapter ?? createLocalStorageAdapter());
|
|
@@ -236,46 +381,86 @@ function FoirProvider({ env, adapter, children }) {
|
|
|
236
381
|
setCustomer(c);
|
|
237
382
|
setStatus(c ? "authenticated" : "unauthenticated");
|
|
238
383
|
}, []);
|
|
384
|
+
const loginOrigin = hostedSession?.loginOrigin;
|
|
385
|
+
const sessionPath = hostedSession?.sessionPath;
|
|
386
|
+
const logoutPath = hostedSession?.logoutPath;
|
|
387
|
+
const refreshSkewSeconds = hostedSession?.refreshSkewSeconds;
|
|
388
|
+
const hosted = useMemo(() => {
|
|
389
|
+
if (!loginOrigin) return null;
|
|
390
|
+
return createFoirSessionClient({
|
|
391
|
+
loginOrigin,
|
|
392
|
+
sessionPath,
|
|
393
|
+
logoutPath,
|
|
394
|
+
refreshSkewSeconds,
|
|
395
|
+
onSignOut: () => apply(null)
|
|
396
|
+
});
|
|
397
|
+
}, [loginOrigin, sessionPath, logoutPath, refreshSkewSeconds, apply]);
|
|
239
398
|
useEffect(() => {
|
|
240
399
|
let cancelled = false;
|
|
241
400
|
void (async () => {
|
|
401
|
+
if (hosted) {
|
|
402
|
+
const u = await hosted.getUser();
|
|
403
|
+
if (!cancelled) apply(u ? { id: u.customerId, email: u.email } : null);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
242
406
|
const c = await getSessionCustomer(adapterRef.current);
|
|
243
407
|
if (!cancelled) apply(c);
|
|
244
408
|
})();
|
|
245
409
|
return () => {
|
|
246
410
|
cancelled = true;
|
|
247
411
|
};
|
|
248
|
-
}, [apply]);
|
|
412
|
+
}, [apply, hosted]);
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (!hosted) return;
|
|
415
|
+
return () => hosted.destroy();
|
|
416
|
+
}, [hosted]);
|
|
417
|
+
const assertTokenMode = useCallback(
|
|
418
|
+
(action) => {
|
|
419
|
+
if (hosted) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`[foir] ${action}() is not available in hostedSession mode \u2014 the customer signs in at your Foir login domain. Redirect to hostedSignInUrl() instead.`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
[hosted]
|
|
426
|
+
);
|
|
249
427
|
const signIn = useCallback(
|
|
250
428
|
async (vars) => {
|
|
429
|
+
assertTokenMode("signIn");
|
|
251
430
|
const c = await loginWithPassword(adapterRef.current, envRef.current, vars);
|
|
252
431
|
apply(c);
|
|
253
432
|
return c;
|
|
254
433
|
},
|
|
255
|
-
[apply]
|
|
434
|
+
[apply, assertTokenMode]
|
|
256
435
|
);
|
|
257
436
|
const register = useCallback(
|
|
258
437
|
async (vars) => {
|
|
438
|
+
assertTokenMode("register");
|
|
259
439
|
const c = await registerWithPassword(adapterRef.current, envRef.current, vars);
|
|
260
440
|
apply(c);
|
|
261
441
|
return c;
|
|
262
442
|
},
|
|
263
|
-
[apply]
|
|
443
|
+
[apply, assertTokenMode]
|
|
264
444
|
);
|
|
265
445
|
const requestOtp = useCallback(
|
|
266
|
-
(email) =>
|
|
267
|
-
|
|
446
|
+
(email) => {
|
|
447
|
+
assertTokenMode("requestOtp");
|
|
448
|
+
return customerRequestOTP(envRef.current, email);
|
|
449
|
+
},
|
|
450
|
+
[assertTokenMode]
|
|
268
451
|
);
|
|
269
452
|
const signInWithOtp = useCallback(
|
|
270
453
|
async (vars) => {
|
|
454
|
+
assertTokenMode("signInWithOtp");
|
|
271
455
|
const c = await loginWithOTP(adapterRef.current, envRef.current, vars);
|
|
272
456
|
apply(c);
|
|
273
457
|
return c;
|
|
274
458
|
},
|
|
275
|
-
[apply]
|
|
459
|
+
[apply, assertTokenMode]
|
|
276
460
|
);
|
|
277
461
|
const signInWithProvider = useCallback(
|
|
278
462
|
async (providerKey, opts) => {
|
|
463
|
+
assertTokenMode("signInWithProvider");
|
|
279
464
|
const res = await customerLoginWithProvider(envRef.current, {
|
|
280
465
|
providerKey,
|
|
281
466
|
...opts
|
|
@@ -284,31 +469,46 @@ function FoirProvider({ env, adapter, children }) {
|
|
|
284
469
|
window.location.assign(res.redirectUrl);
|
|
285
470
|
}
|
|
286
471
|
},
|
|
287
|
-
[]
|
|
472
|
+
[assertTokenMode]
|
|
288
473
|
);
|
|
289
474
|
const completeProviderLogin = useCallback(
|
|
290
475
|
async (input) => {
|
|
476
|
+
assertTokenMode("completeProviderLogin");
|
|
291
477
|
const result = await customerProviderCallback(envRef.current, input);
|
|
292
478
|
const c = await saveSession(adapterRef.current, result);
|
|
293
479
|
apply(c);
|
|
294
480
|
return c;
|
|
295
481
|
},
|
|
296
|
-
[apply]
|
|
482
|
+
[apply, assertTokenMode]
|
|
297
483
|
);
|
|
298
484
|
const signOut = useCallback(async () => {
|
|
485
|
+
if (hosted) {
|
|
486
|
+
await hosted.signOut();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
299
489
|
await clearSession(adapterRef.current);
|
|
300
490
|
apply(null);
|
|
301
|
-
}, [apply]);
|
|
491
|
+
}, [apply, hosted]);
|
|
302
492
|
const refresh = useCallback(async () => {
|
|
493
|
+
if (hosted) {
|
|
494
|
+
return await hosted.getToken() !== null;
|
|
495
|
+
}
|
|
303
496
|
const tokens = await refreshSession(adapterRef.current, envRef.current);
|
|
304
497
|
if (!tokens) {
|
|
305
498
|
apply(null);
|
|
306
499
|
return false;
|
|
307
500
|
}
|
|
308
501
|
return true;
|
|
309
|
-
}, [apply]);
|
|
310
|
-
const getToken = useCallback(
|
|
502
|
+
}, [apply, hosted]);
|
|
503
|
+
const getToken = useCallback(
|
|
504
|
+
() => hosted ? hosted.getToken() : getSessionToken(adapterRef.current),
|
|
505
|
+
[hosted]
|
|
506
|
+
);
|
|
311
507
|
const getProviders = useCallback(() => getAuthProviders(envRef.current), []);
|
|
508
|
+
const hostedSignInUrl = useMemo(
|
|
509
|
+
() => hosted ? (returnTo) => hosted.signInUrl(returnTo) : null,
|
|
510
|
+
[hosted]
|
|
511
|
+
);
|
|
312
512
|
const value = useMemo(
|
|
313
513
|
() => ({
|
|
314
514
|
status,
|
|
@@ -324,7 +524,8 @@ function FoirProvider({ env, adapter, children }) {
|
|
|
324
524
|
signOut,
|
|
325
525
|
refresh,
|
|
326
526
|
getToken,
|
|
327
|
-
getProviders
|
|
527
|
+
getProviders,
|
|
528
|
+
hostedSignInUrl
|
|
328
529
|
}),
|
|
329
530
|
[
|
|
330
531
|
status,
|
|
@@ -339,7 +540,8 @@ function FoirProvider({ env, adapter, children }) {
|
|
|
339
540
|
signOut,
|
|
340
541
|
refresh,
|
|
341
542
|
getToken,
|
|
342
|
-
getProviders
|
|
543
|
+
getProviders,
|
|
544
|
+
hostedSignInUrl
|
|
343
545
|
]
|
|
344
546
|
);
|
|
345
547
|
return /* @__PURE__ */ jsx(FoirContext.Provider, { value, children });
|
|
@@ -374,7 +576,8 @@ function useFoirAuth() {
|
|
|
374
576
|
signOut,
|
|
375
577
|
refresh,
|
|
376
578
|
getToken,
|
|
377
|
-
getProviders
|
|
579
|
+
getProviders,
|
|
580
|
+
hostedSignInUrl
|
|
378
581
|
} = useFoirContext();
|
|
379
582
|
return {
|
|
380
583
|
signIn,
|
|
@@ -386,7 +589,8 @@ function useFoirAuth() {
|
|
|
386
589
|
signOut,
|
|
387
590
|
refresh,
|
|
388
591
|
getToken,
|
|
389
|
-
getProviders
|
|
592
|
+
getProviders,
|
|
593
|
+
hostedSignInUrl
|
|
390
594
|
};
|
|
391
595
|
}
|
|
392
596
|
|
package/dist/server.d.ts
CHANGED
|
@@ -519,8 +519,8 @@ interface CallbackClient<TResult = unknown> {
|
|
|
519
519
|
* payload structure on every call.
|
|
520
520
|
*
|
|
521
521
|
* `signingSecret` lives on the extension (set as `FOIR_SIGNING_SECRET` or
|
|
522
|
-
* equivalent env var).
|
|
523
|
-
* available before any callback runs
|
|
522
|
+
* equivalent env var). `foir push` writes it there from the project's
|
|
523
|
+
* signing secret, so it's available before any callback runs.
|
|
524
524
|
*
|
|
525
525
|
* `fetch` is overridable for testability and to support runtimes (CF
|
|
526
526
|
* Workers, Node) where `fetch` may not be the global.
|