@synqauth/nextjs 0.2.0 → 0.3.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 +34 -2
- package/dist/{config-CpEbAGEs.d.ts → config-a7tT3KtP.d.ts} +96 -1
- package/dist/define.d.ts +2 -2
- package/dist/index.d.ts +55 -5
- package/dist/index.js +22 -7
- package/dist/proxy.d.ts +29 -1
- package/dist/proxy.js +19 -4
- package/dist/{sign-in-error-kd56GHt_.js → sites-JUnMBDbp.js} +799 -54
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Synq for Next.js: a backend-for-frontend session, so the browser never holds a S
|
|
|
4
4
|
|
|
5
5
|
Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with Solana wallet, social, email and passkey sign-in. The documentation is at [docs.synq.build](https://docs.synq.build).
|
|
6
6
|
|
|
7
|
-
This package is at 0.x
|
|
7
|
+
This package is at 0.x, so a minor release can change its API: see its CHANGELOG.md.
|
|
8
8
|
|
|
9
9
|
## Quick start
|
|
10
10
|
|
|
@@ -52,7 +52,7 @@ export const proxy = synqProxy(synqConfig, { protect: ['/dashboard'] })
|
|
|
52
52
|
export const config = { matcher: ['/((?!_next/|.*\\..*).*)'] }
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
To add a way to sign in to someone's account (Google, a wallet, anything the brand offers), POST `{ method, returnTo }` as JSON to `/api/auth/link` from your own pages and send the browser to the `redirect` it answers, or call `useAuth().link('google')` from `@synqauth/react`: Synq asks them to sign in again, then adds it.
|
|
55
|
+
To add a way to sign in to someone's account (Google, a wallet, a passkey, anything the brand offers), POST `{ method, returnTo }` as JSON to `/api/auth/link` from your own pages and send the browser to the `redirect` it answers, or call `useAuth().link('google')` from `@synqauth/react`: Synq asks them to sign in again, then adds it.
|
|
56
56
|
|
|
57
57
|
To sign people in on your own page instead of the brand's (AERO-1411), turn on own-UI sign-in for the app in the dashboard and use `<SignInForm>` or `useSignIn()` from `@synqauth/react`. They post each step to `/api/auth/sign-in/flow`, which takes it to Synq as your app. Pass `clientAddress` to `defineSynqConfig` when your host doesn't set `x-real-ip`, so Synq can limit tries per address. Read a header your own proxy sets, never one the browser could send:
|
|
58
58
|
|
|
@@ -67,6 +67,16 @@ In a server component, `await synq.me()` is the signed-in person or null, and `a
|
|
|
67
67
|
|
|
68
68
|
## When a sign-in doesn't finish
|
|
69
69
|
|
|
70
|
+
Synq's own dashboard can resume an unverified email address with
|
|
71
|
+
`/auth/sign-in?intent=verify-email&returnTo=/onboard`. The intent defaults to
|
|
72
|
+
`prompt=select_account`, so an existing GitHub-only account can prove its email
|
|
73
|
+
without signing in again or creating another account. An explicit `prompt` and
|
|
74
|
+
`max_age` are preserved: verification never replaces a request for fresh
|
|
75
|
+
authentication. A maximum-age request takes the normal sign-in flow; an
|
|
76
|
+
incompatible explicit prompt is refused rather than replaced. This verification
|
|
77
|
+
flow applies only where the issuer requires unproved email addresses to be
|
|
78
|
+
verified; tenants keep their existing behavior.
|
|
79
|
+
|
|
70
80
|
When Synq refuses a sign-in (the person cancelled, their account can't use the app, a token gate they went back from) or the callback fails, the route mount sends the browser back to `returnTo` with `synq_error=<code>`. The proxy never sends such a page to sign in again: with the same session at the brand, that would land on the same refusal, round and round. Without a session, it shows why instead, with "Try again" and "Use another account" (`prompt=select_account`). Give it a page of your own with `signInError`, which it never protects:
|
|
71
81
|
|
|
72
82
|
```ts
|
|
@@ -113,6 +123,28 @@ export const synqConfig = defineSynqConfig({
|
|
|
113
123
|
|
|
114
124
|
To rotate `SYNQ_SESSION_SECRET`, move the old secret to `SYNQ_SESSION_SECRET_PREVIOUS` (comma-separated), which opens cookies but never seals them.
|
|
115
125
|
|
|
126
|
+
## Several sites from one deployment
|
|
127
|
+
|
|
128
|
+
One app can sign people in on many hosts, each with its own app, at its own brand or a shared one: a site per chapter, say. Pass `hosts`, every host you serve (`*.` and a domain for any one label under it), and `resolve`, which answers each request's site from its host. It may be async and read the client secret from your own store:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
export const synqConfig = defineSynqConfig({
|
|
132
|
+
hosts: ['*.acmeguild.com'],
|
|
133
|
+
async resolve({ host }) {
|
|
134
|
+
const chapter = await chapterByHost(host)
|
|
135
|
+
if (!chapter) return null
|
|
136
|
+
return {
|
|
137
|
+
issuer: chapter.issuer,
|
|
138
|
+
clientId: chapter.clientId,
|
|
139
|
+
clientSecret: await chapter.clientSecret(),
|
|
140
|
+
storePrefix: `chapter-${chapter.slug}:`,
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The handlers, the proxy, `me()`, `getSession()` and `getAccessToken()` then pick the request's site per request. The host comes from `X-Forwarded-Host`, else `Host`, and the site's app URL is `https://<host>`. A host off `hosts`, or one `resolve` answers `null` for, is refused with `unknown_host`: never given another site's configuration. Each site's core is kept, and its cookies are sealed for it alone, so sites never share a session. The guide is at [docs.synq.build/guides/several-sites](https://docs.synq.build/guides/several-sites).
|
|
147
|
+
|
|
116
148
|
## License
|
|
117
149
|
|
|
118
150
|
MIT
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LinkedAccount } from "@synqauth/js";
|
|
1
2
|
//#region src/plugins.d.ts
|
|
2
3
|
/** A signed-in person, from the ID token Synq issued at sign-in. */
|
|
3
4
|
interface SynqUser {
|
|
@@ -7,11 +8,40 @@ interface SynqUser {
|
|
|
7
8
|
readonly email_verified?: boolean;
|
|
8
9
|
readonly name?: string;
|
|
9
10
|
readonly preferred_username?: string;
|
|
11
|
+
/** The picture they chose: none, and your app shows its own default, from `avatar_seed`. */
|
|
10
12
|
readonly picture?: string;
|
|
13
|
+
/** Where `picture` came from (AERO-2700): `none`, `upload`, `url` or `linked:<provider>`. */
|
|
14
|
+
readonly picture_source?: string;
|
|
15
|
+
/**
|
|
16
|
+
* What to draw their default avatar from (AERO-2704): theirs in every app
|
|
17
|
+
* of the brand. Absent from a token issued before seeds: use `sub`.
|
|
18
|
+
*/
|
|
19
|
+
readonly avatar_seed?: string;
|
|
20
|
+
/** The brand's own default avatar for them, where it uploaded a set. */
|
|
21
|
+
readonly default_picture?: string;
|
|
11
22
|
/** When they signed in, in seconds since the epoch. */
|
|
12
23
|
readonly auth_time?: number;
|
|
13
24
|
/** How they signed in: `pwd`, `otp`, `wallet`, `hwk`, `fed`... */
|
|
14
25
|
readonly amr?: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Their accounts at upstream providers (GitHub, Google…), with what each
|
|
28
|
+
* says of the account, when the app asks for the `linked_accounts` scope
|
|
29
|
+
* (AERO-2332).
|
|
30
|
+
*/
|
|
31
|
+
readonly linked_accounts?: readonly LinkedAccount[];
|
|
32
|
+
/**
|
|
33
|
+
* How the sign-in this session came from went, when the app turned its
|
|
34
|
+
* sign-in claims on (`sign_in_claims`, AERO-2691): the way in (`google`,
|
|
35
|
+
* `wallet`, `email`, `password`, `passkey`…).
|
|
36
|
+
*/
|
|
37
|
+
readonly sign_in_method?: string;
|
|
38
|
+
/** For a wallet sign-in, the address that signed and the wallet app's name, when it said. */
|
|
39
|
+
readonly sign_in_wallet?: {
|
|
40
|
+
readonly address: string;
|
|
41
|
+
readonly app: string | null;
|
|
42
|
+
};
|
|
43
|
+
/** Whether that sign-in made the account: true in its first ID token only. */
|
|
44
|
+
readonly new_user?: boolean;
|
|
15
45
|
readonly [claim: string]: unknown;
|
|
16
46
|
}
|
|
17
47
|
/** What a plugin's route is handed. */
|
|
@@ -182,7 +212,66 @@ interface SynqAuthOptions {
|
|
|
182
212
|
* is off for the app.
|
|
183
213
|
*/
|
|
184
214
|
clientAddress?: (request: Request) => string | undefined;
|
|
215
|
+
/**
|
|
216
|
+
* For an app that serves several sites from one deployment (AERO-2655):
|
|
217
|
+
* a brand per site, an app per host, or both. Called per request with the
|
|
218
|
+
* host the browser asked for, once it is on `hosts`, it answers that
|
|
219
|
+
* site's issuer, app and secret, which it may read from the app's own
|
|
220
|
+
* store (it may be async). Null for a host the app doesn't serve, which is
|
|
221
|
+
* refused as one off `hosts` is. Each site's core is made once and kept,
|
|
222
|
+
* keyed by everything that tells sites apart, so two never share a
|
|
223
|
+
* session. With `resolve`, the site's options come from it alone: never
|
|
224
|
+
* from `issuer`, `clientId`, `clientSecret`, `appUrl` or
|
|
225
|
+
* `postLogoutRedirectUri` here, nor from `SYNQ_*`.
|
|
226
|
+
*/
|
|
227
|
+
resolve?: SynqResolve;
|
|
228
|
+
/**
|
|
229
|
+
* With `resolve`: every host the app serves, as the browser names it
|
|
230
|
+
* (`club.example.com`, `localhost:3000`), or `*.example.com` for any one
|
|
231
|
+
* label under a domain. A request whose host (`X-Forwarded-Host`, else
|
|
232
|
+
* `Host`) isn't one is refused with `unknown_host`: never answered with
|
|
233
|
+
* another site's configuration.
|
|
234
|
+
*/
|
|
235
|
+
hosts?: readonly string[];
|
|
185
236
|
}
|
|
237
|
+
/** What `resolve` is asked about: the request's host, already on `hosts`. */
|
|
238
|
+
interface SynqSiteRequest {
|
|
239
|
+
/** The host the browser asked for, lowercase, with its port when it named one. */
|
|
240
|
+
readonly host: string;
|
|
241
|
+
/**
|
|
242
|
+
* `https://<host>`, or `http://` for a host on this machine when the
|
|
243
|
+
* request came over http: the site's `appUrl` unless `resolve` says.
|
|
244
|
+
*/
|
|
245
|
+
readonly origin: string;
|
|
246
|
+
/** The request's headers, cookies included: for a choice the app keeps in one. */
|
|
247
|
+
readonly headers: Headers;
|
|
248
|
+
}
|
|
249
|
+
/** One site's Synq, as `resolve` answers it. */
|
|
250
|
+
interface SynqSite {
|
|
251
|
+
/** The brand's issuer URL: `https://<issuer-slug>.auth.<Synq's domain>`. */
|
|
252
|
+
readonly issuer: string;
|
|
253
|
+
/** The site's app: a web app of the brand. */
|
|
254
|
+
readonly clientId: string;
|
|
255
|
+
/** The app's client secret. Never logged, never in a cookie, never in an error. */
|
|
256
|
+
readonly clientSecret: string;
|
|
257
|
+
/**
|
|
258
|
+
* Where the site is served: the request's own `origin` by default, and
|
|
259
|
+
* nothing else, since the session's cookies are on that host alone.
|
|
260
|
+
*/
|
|
261
|
+
readonly appUrl?: string;
|
|
262
|
+
/**
|
|
263
|
+
* The site's sessions' prefix in `store`, so one store keeps every
|
|
264
|
+
* site's apart, and a site's can be found or cleared on their own:
|
|
265
|
+
* `club-dragons:`, say. Sessions never open on another site either way.
|
|
266
|
+
*/
|
|
267
|
+
readonly storePrefix?: string;
|
|
268
|
+
/** Whether the site's sessions hold a token for the brand's account API: the top-level `account` by default. */
|
|
269
|
+
readonly account?: boolean;
|
|
270
|
+
/** Where the browser lands after signing out: the site's root by default. */
|
|
271
|
+
readonly postLogoutRedirectUri?: string;
|
|
272
|
+
}
|
|
273
|
+
/** Picks a request's site: null (or undefined) for a host the app doesn't serve. */
|
|
274
|
+
type SynqResolve = (request: SynqSiteRequest) => SynqSite | null | undefined | Promise<SynqSite | null | undefined>;
|
|
186
275
|
interface SynqConfig {
|
|
187
276
|
readonly issuer: string;
|
|
188
277
|
readonly clientId: string;
|
|
@@ -207,9 +296,15 @@ interface SynqConfig {
|
|
|
207
296
|
readonly clientAddress: (request: Request) => string | undefined;
|
|
208
297
|
/** Over https: `Secure` cookies with the `__Host-` prefix. */
|
|
209
298
|
readonly secure: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* The site the session's cookies are sealed for (`resolve`): its issuer,
|
|
301
|
+
* app and origin, so a cookie never opens on another site. Empty for an
|
|
302
|
+
* app with one configuration.
|
|
303
|
+
*/
|
|
304
|
+
readonly site: string;
|
|
210
305
|
}
|
|
211
306
|
type Env = Record<string, string | undefined>;
|
|
212
307
|
/** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
|
|
213
308
|
declare function resolveConfig(options?: SynqAuthOptions, env?: Env): SynqConfig;
|
|
214
309
|
//#endregion
|
|
215
|
-
export {
|
|
310
|
+
export { defineSynqPlugin as _, SynqSiteRequest as a, KeyValueSessionStoreOptions as c, keyValueSessionStore as d, memorySessionStore as f, SynqUser as g, SynqPluginRoute as h, SynqSite as i, SessionValues as l, SynqPluginContext as m, SynqConfig as n, resolveConfig as o, SynqPlugin as p, SynqResolve as r, KeyValue as s, SynqAuthOptions as t, SynqSessionStore as u };
|
package/dist/define.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { t as SynqAuthOptions } from "./config-
|
|
1
|
+
import { a as SynqSiteRequest, i as SynqSite, r as SynqResolve, t as SynqAuthOptions } from "./config-a7tT3KtP.js";
|
|
2
2
|
//#region src/define.d.ts
|
|
3
3
|
/** Declares the app's Synq configuration: `SYNQ_*` environment variables fill in whatever it leaves out. */
|
|
4
4
|
export declare function defineSynqConfig<const T extends SynqAuthOptions>(options: T): T;
|
|
5
5
|
//#endregion
|
|
6
|
-
export type { SynqAuthOptions };
|
|
6
|
+
export type { SynqAuthOptions, SynqResolve, SynqSite, SynqSiteRequest };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { _ as defineSynqPlugin, a as SynqSiteRequest, c as KeyValueSessionStoreOptions, d as keyValueSessionStore, f as memorySessionStore, g as SynqUser, h as SynqPluginRoute, i as SynqSite, l as SessionValues, m as SynqPluginContext, n as SynqConfig, o as resolveConfig, p as SynqPlugin, r as SynqResolve, s as KeyValue, t as SynqAuthOptions, u as SynqSessionStore } from "./config-a7tT3KtP.js";
|
|
2
2
|
import { defineSynqConfig } from "./define.js";
|
|
3
3
|
import { cookies } from "next/headers.js";
|
|
4
|
+
import "@synqauth/js";
|
|
4
5
|
//#region src/cookies.d.ts
|
|
5
6
|
/** The cookies a request carries and the changes a response makes: what a route handler, Next's `cookies()` or a test gives. */
|
|
6
7
|
interface CookieJar {
|
|
@@ -19,12 +20,54 @@ interface CookieOptions {
|
|
|
19
20
|
secure: boolean;
|
|
20
21
|
}
|
|
21
22
|
//#endregion
|
|
23
|
+
//#region src/sign-in-context.d.ts
|
|
24
|
+
/** The way this browser last signed in to the app: never who, never an address. */
|
|
25
|
+
interface LastUsedMethod {
|
|
26
|
+
/** The method's id: `google`, `wallet`, `email`… */
|
|
27
|
+
readonly method: string;
|
|
28
|
+
/** For a wallet, the wallet app's name as it announced itself (`Phantom`). */
|
|
29
|
+
readonly walletApp?: string;
|
|
30
|
+
}
|
|
31
|
+
/** A sign-in that just finished, told to the page once (`useSignInEvent()`). */
|
|
32
|
+
interface SignInEvent {
|
|
33
|
+
/**
|
|
34
|
+
* This sign-in's own id, random: two sign-ins the same way can share
|
|
35
|
+
* `at` (a provider's own sign-in time), never this.
|
|
36
|
+
*/
|
|
37
|
+
readonly id: string;
|
|
38
|
+
/** Whether it made the account: their first sign-in. */
|
|
39
|
+
readonly isNewUser: boolean;
|
|
40
|
+
/** The way in: `google`, `wallet`, `email`, `password`, `passkey`… */
|
|
41
|
+
readonly method: string;
|
|
42
|
+
/** For a wallet sign-in, the address that signed and its wallet app, when it said. */
|
|
43
|
+
readonly wallet: {
|
|
44
|
+
readonly address: string;
|
|
45
|
+
readonly app: string | null;
|
|
46
|
+
} | null;
|
|
47
|
+
/** When they signed in, in seconds since the epoch (the ID token's `auth_time`). */
|
|
48
|
+
readonly at: number;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
22
51
|
//#region src/core.d.ts
|
|
23
52
|
/** Who is signed in, as the browser may see it: never a token. */
|
|
24
53
|
interface SynqSessionInfo {
|
|
25
54
|
readonly user: SynqUser | null;
|
|
26
55
|
/** When the main access token expires, in seconds since the epoch. */
|
|
27
56
|
readonly expiresAt: number | null;
|
|
57
|
+
/**
|
|
58
|
+
* The way this browser last signed in to the app, for a "Last used" mark
|
|
59
|
+
* (AERO-2691): kept on the app's own host from the app's sign-in claims,
|
|
60
|
+
* signed in or out, and never where the brand turned "Last used" off.
|
|
61
|
+
* Null without the claims, or before any sign-in here.
|
|
62
|
+
*/
|
|
63
|
+
readonly lastUsed: LastUsedMethod | null;
|
|
64
|
+
/**
|
|
65
|
+
* The sign-in that just finished, until the page says it heard of it
|
|
66
|
+
* (`DELETE <base>/session/event`, `useSignInEvent().consume()`) or five
|
|
67
|
+
* minutes pass: whether it made the account, how, and with which wallet.
|
|
68
|
+
* Needs the app's sign-in claims on.
|
|
69
|
+
*/
|
|
70
|
+
readonly event: SignInEvent | null;
|
|
28
71
|
}
|
|
29
72
|
interface SynqCore {
|
|
30
73
|
/** Answers a request under the route mount. */
|
|
@@ -81,7 +124,11 @@ export interface SynqAuth {
|
|
|
81
124
|
};
|
|
82
125
|
/** The signed-in person, or null: read from the session's ID token, once per request, with no request to Synq. */
|
|
83
126
|
me(): Promise<SynqUser | null>;
|
|
84
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Who is signed in and until when, as the browser may see it, with the way
|
|
129
|
+
* this browser last signed in (`lastUsed`) and the sign-in that just
|
|
130
|
+
* finished (`event`), once the app's sign-in claims are on (AERO-2691).
|
|
131
|
+
*/
|
|
85
132
|
getSession(): Promise<SynqSessionInfo>;
|
|
86
133
|
/**
|
|
87
134
|
* A current access token for the app's API (or `resource`), refreshed
|
|
@@ -97,7 +144,7 @@ export interface SynqAuth {
|
|
|
97
144
|
returnTo?: string;
|
|
98
145
|
method?: string;
|
|
99
146
|
loginHint?: string;
|
|
100
|
-
prompt?: 'login' | 'none' | 'consent' | 'select_account';
|
|
147
|
+
prompt?: 'login' | 'none' | 'consent' | 'select_account' | 'create';
|
|
101
148
|
maxAge?: number;
|
|
102
149
|
}): string;
|
|
103
150
|
/** Where a form POSTs to sign out. */
|
|
@@ -115,11 +162,14 @@ export interface SynqAuth {
|
|
|
115
162
|
/**
|
|
116
163
|
* Synq for this app, configured from SYNQ_ISSUER, SYNQ_CLIENT_ID,
|
|
117
164
|
* SYNQ_CLIENT_SECRET, SYNQ_APP_URL and SYNQ_SESSION_SECRET unless `options`
|
|
118
|
-
* (`defineSynqConfig`, shared with the proxy) says.
|
|
165
|
+
* (`defineSynqConfig`, shared with the proxy) says. With `resolve` and
|
|
166
|
+
* `hosts`, each request's site is its own (AERO-2655): its handlers answer a
|
|
167
|
+
* host off `hosts` with 421 `unknown_host`, and `me()`, `getSession()` and
|
|
168
|
+
* `getAccessToken()` throw that error.
|
|
119
169
|
*/
|
|
120
170
|
export declare function synqAuth(options?: SynqAuthOptions): SynqAuth;
|
|
121
171
|
type NextCookieStore = Awaited<ReturnType<typeof cookies>>;
|
|
122
172
|
/** Next's cookie store as the cookie jar `createSynqCore` reads and writes. */
|
|
123
173
|
export declare function jarOf(store: NextCookieStore): CookieJar;
|
|
124
174
|
//#endregion
|
|
125
|
-
export { type KeyValue, type KeyValueSessionStoreOptions, type SessionValues, type SynqAuthOptions, type SynqPlugin, type SynqPluginContext, type SynqPluginRoute, type SynqSessionInfo, type SynqSessionStore, type SynqSignInError, type SynqUser, defineSynqConfig, defineSynqPlugin, keyValueSessionStore, memorySessionStore, resolveConfig };
|
|
175
|
+
export { type KeyValue, type KeyValueSessionStoreOptions, type LastUsedMethod, type SessionValues, type SignInEvent, type SynqAuthOptions, type SynqPlugin, type SynqPluginContext, type SynqPluginRoute, type SynqResolve, type SynqSessionInfo, type SynqSessionStore, type SynqSignInError, type SynqSite, type SynqSiteRequest, type SynqUser, defineSynqConfig, defineSynqPlugin, keyValueSessionStore, memorySessionStore, resolveConfig };
|
package/dist/index.js
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as signInErrorMessage, d as memorySessionStore, f as defineSynqPlugin, h as resolveConfig, i as SIGN_IN_ERROR_PARAM, l as createSynqCore, m as normalizePath, n as unknownHostError, o as signInErrorOf, r as unknownHostResponse, t as siteCores, u as keyValueSessionStore } from "./sites-JUnMBDbp.js";
|
|
2
2
|
import { defineSynqConfig } from "./define.js";
|
|
3
|
-
import { cookies } from "next/headers.js";
|
|
3
|
+
import { cookies, headers } from "next/headers.js";
|
|
4
4
|
import { cache } from "react";
|
|
5
5
|
//#region src/index.ts
|
|
6
6
|
/**
|
|
7
7
|
* Synq for this app, configured from SYNQ_ISSUER, SYNQ_CLIENT_ID,
|
|
8
8
|
* SYNQ_CLIENT_SECRET, SYNQ_APP_URL and SYNQ_SESSION_SECRET unless `options`
|
|
9
|
-
* (`defineSynqConfig`, shared with the proxy) says.
|
|
9
|
+
* (`defineSynqConfig`, shared with the proxy) says. With `resolve` and
|
|
10
|
+
* `hosts`, each request's site is its own (AERO-2655): its handlers answer a
|
|
11
|
+
* host off `hosts` with 421 `unknown_host`, and `me()`, `getSession()` and
|
|
12
|
+
* `getAccessToken()` throw that error.
|
|
10
13
|
*/
|
|
11
14
|
function synqAuth(options = {}) {
|
|
12
15
|
let core;
|
|
16
|
+
const sites = options.resolve ? siteCores(options) : void 0;
|
|
13
17
|
const get = () => core ??= createSynqCore(resolveConfig(options));
|
|
14
|
-
|
|
18
|
+
/** The core for the page's request: the one, or its site's, read from Next's headers. */
|
|
19
|
+
const forPage = async () => {
|
|
20
|
+
if (!sites) return get();
|
|
21
|
+
const found = await sites.coreFor(await headers());
|
|
22
|
+
if (!found) throw unknownHostError();
|
|
23
|
+
return found;
|
|
24
|
+
};
|
|
25
|
+
const handler = async (request) => {
|
|
26
|
+
if (!sites) return get().handle(request);
|
|
27
|
+
const found = await sites.coreFor(request.headers, request.url);
|
|
28
|
+
return found ? found.handle(request) : unknownHostResponse();
|
|
29
|
+
};
|
|
15
30
|
const basePath = () => normalizePath(options.basePath ?? "/api/auth");
|
|
16
31
|
const me = cache(async () => {
|
|
17
32
|
const jar = await nextJar();
|
|
18
|
-
return
|
|
33
|
+
return (await forPage()).user(jar);
|
|
19
34
|
});
|
|
20
35
|
const getSession = cache(async () => {
|
|
21
36
|
const jar = await nextJar();
|
|
22
|
-
return
|
|
37
|
+
return (await forPage()).session(jar);
|
|
23
38
|
});
|
|
24
39
|
return {
|
|
25
40
|
handlers: {
|
|
@@ -32,7 +47,7 @@ function synqAuth(options = {}) {
|
|
|
32
47
|
getSession,
|
|
33
48
|
async getAccessToken(opts) {
|
|
34
49
|
const jar = await nextJar();
|
|
35
|
-
return
|
|
50
|
+
return (await forPage()).getAccessToken(jar, opts);
|
|
36
51
|
},
|
|
37
52
|
signInPath(opts = {}) {
|
|
38
53
|
const params = new URLSearchParams();
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as SynqAuthOptions } from "./config-
|
|
1
|
+
import { t as SynqAuthOptions } from "./config-a7tT3KtP.js";
|
|
2
2
|
//#region src/proxy.d.ts
|
|
3
3
|
/** What `synqProxy()` takes: which paths need a session. */
|
|
4
4
|
export interface SynqProxyOptions {
|
|
@@ -18,6 +18,34 @@ export interface SynqProxyOptions {
|
|
|
18
18
|
* of its own. Either way, it never sends that request to sign in again.
|
|
19
19
|
*/
|
|
20
20
|
readonly signInError?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Paths whose visitors without a session are sent to sign up rather than
|
|
23
|
+
* to sign in (`prompt=create`): where a "Get started" or pricing link
|
|
24
|
+
* lands. Path prefixes, or a test. Synq's page opens on its sign-up screen
|
|
25
|
+
* where the brand lets someone new sign up, with "Sign in" a link away,
|
|
26
|
+
* and someone already signed in at Synq is simply signed in.
|
|
27
|
+
*/
|
|
28
|
+
readonly signUp?: readonly string[] | ((pathname: string) => boolean);
|
|
29
|
+
/**
|
|
30
|
+
* What the visitor picked on a pricing page, read from the URL of the page
|
|
31
|
+
* they are sent to sign in (or up) from: a plan's code and its billing
|
|
32
|
+
* interval, and the onboarding visit's id. Synq's own sign-up page says
|
|
33
|
+
* the plan back, and its funnel ties the account made to the visit
|
|
34
|
+
* (AERO-2160); a tenant's page ignores them. Return undefined when the
|
|
35
|
+
* page names neither.
|
|
36
|
+
*/
|
|
37
|
+
readonly choice?: (url: URL) => SignUpChoice | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** What a visitor picked: a plan's code and its interval, and the onboarding visit. */
|
|
40
|
+
export interface SignUpChoice {
|
|
41
|
+
readonly plan?: string;
|
|
42
|
+
/** Monthly or yearly; only with a plan. */
|
|
43
|
+
readonly interval?: 'month' | 'year';
|
|
44
|
+
/**
|
|
45
|
+
* The onboarding visit's id (22 base62 characters), which Synq's funnel
|
|
46
|
+
* ties the account a sign-up makes to.
|
|
47
|
+
*/
|
|
48
|
+
readonly onboarding?: string;
|
|
21
49
|
}
|
|
22
50
|
/**
|
|
23
51
|
* Next's proxy for Synq: refreshes a session that is due, and sends a
|
package/dist/proxy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as withoutSignInError, h as resolveConfig, i as SIGN_IN_ERROR_PARAM, l as createSynqCore, m as normalizePath, o as signInErrorOf, p as requestJar, r as unknownHostResponse, s as signInErrorPage, t as siteCores } from "./sites-JUnMBDbp.js";
|
|
2
2
|
import { NextResponse } from "next/server.js";
|
|
3
3
|
//#region src/proxy.ts
|
|
4
4
|
/**
|
|
@@ -7,14 +7,22 @@ import { NextResponse } from "next/server.js";
|
|
|
7
7
|
* just failed, to the page that says why.
|
|
8
8
|
*/
|
|
9
9
|
function synqProxy(config, options) {
|
|
10
|
-
const { protect, signInError } = options;
|
|
10
|
+
const { protect, signInError, signUp, choice } = options;
|
|
11
11
|
if (signInError !== void 0 && !/^\/(?![/\\])/.test(signInError)) throw new TypeError("synqProxy: signInError is a path on the app, such as /sign-in-error");
|
|
12
12
|
const protectsPath = typeof protect === "function" ? protect : (path) => protect.some((prefix) => under(path, prefix));
|
|
13
|
+
const signsUp = signUp === void 0 ? () => false : typeof signUp === "function" ? signUp : (path) => signUp.some((prefix) => under(path, prefix));
|
|
13
14
|
const protects = (path) => !(signInError !== void 0 && under(path, signInError)) && protectsPath(path);
|
|
14
|
-
let
|
|
15
|
+
let single;
|
|
16
|
+
const sites = config.resolve ? siteCores(config) : void 0;
|
|
15
17
|
return async function proxy(request) {
|
|
16
|
-
core ??= createSynqCore(resolveConfig(config));
|
|
17
18
|
const url = new URL(request.url);
|
|
19
|
+
let core;
|
|
20
|
+
if (sites) {
|
|
21
|
+
if (under(url.pathname, normalizePath(config.basePath ?? "/api/auth"))) return void 0;
|
|
22
|
+
const found = await sites.coreFor(request.headers, request.url);
|
|
23
|
+
if (!found) return protects(url.pathname) ? unknownHostResponse() : void 0;
|
|
24
|
+
core = found;
|
|
25
|
+
} else core = single ??= createSynqCore(resolveConfig(config));
|
|
18
26
|
if (under(url.pathname, core.config.basePath)) return void 0;
|
|
19
27
|
const jar = requestJar(request);
|
|
20
28
|
const signedIn = await core.refreshIfDue(jar);
|
|
@@ -29,6 +37,13 @@ function synqProxy(config, options) {
|
|
|
29
37
|
}
|
|
30
38
|
const signIn = new URL(`${core.config.basePath}/sign-in`, core.config.appUrl);
|
|
31
39
|
signIn.searchParams.set("returnTo", returnTo);
|
|
40
|
+
if (signsUp(url.pathname)) signIn.searchParams.set("prompt", "create");
|
|
41
|
+
const picked = choice?.(url);
|
|
42
|
+
if (picked?.plan) {
|
|
43
|
+
signIn.searchParams.set("plan", picked.plan);
|
|
44
|
+
if (picked.interval) signIn.searchParams.set("interval", picked.interval);
|
|
45
|
+
}
|
|
46
|
+
if (picked?.onboarding) signIn.searchParams.set("onboarding", picked.onboarding);
|
|
32
47
|
const response = new Response(null, {
|
|
33
48
|
status: 307,
|
|
34
49
|
headers: { location: signIn.href }
|