@synqauth/nextjs 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/dist/client.d.ts +13 -0
- package/dist/client.js +24 -0
- package/dist/config-CpEbAGEs.d.ts +215 -0
- package/dist/define.d.ts +6 -0
- package/dist/define.js +7 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +106 -0
- package/dist/proxy.d.ts +28 -0
- package/dist/proxy.js +59 -0
- package/dist/sign-in-error-kd56GHt_.js +1349 -0
- package/package.json +59 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aerosol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synqauth/nextjs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Synq for Next.js: a backend-for-frontend session, so the browser never holds a Synq token.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
This package is at 0.x and hasn't been published yet: its API can change in any release until Synq's first customer depends on it.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
Register the app in the Synq dashboard as a web app, with `https://<your app>/api/auth/callback` as its redirect URI and `https://<your app>/` as its post-logout URI. Then set five environment variables:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
SYNQ_ISSUER=https://<your brand>.auth.synq.build
|
|
15
|
+
SYNQ_CLIENT_ID=app_...
|
|
16
|
+
SYNQ_CLIENT_SECRET=...
|
|
17
|
+
SYNQ_APP_URL=https://app.example.com
|
|
18
|
+
SYNQ_SESSION_SECRET=$(openssl rand -base64 32)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
One configuration, which the server and the proxy share:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// synq.config.ts
|
|
25
|
+
import { defineSynqConfig } from '@synqauth/nextjs/config'
|
|
26
|
+
|
|
27
|
+
export const synqConfig = defineSynqConfig({})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// lib/synq.ts
|
|
32
|
+
import { synqAuth } from '@synqauth/nextjs'
|
|
33
|
+
import { synqConfig } from '../synq.config'
|
|
34
|
+
|
|
35
|
+
export const synq = synqAuth(synqConfig)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// app/api/auth/[...synq]/route.ts
|
|
40
|
+
import { synq } from '@/lib/synq'
|
|
41
|
+
|
|
42
|
+
export const { GET, POST, PATCH, DELETE } = synq.handlers
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// proxy.ts
|
|
47
|
+
import { synqProxy } from '@synqauth/nextjs/proxy'
|
|
48
|
+
import { synqConfig } from './synq.config'
|
|
49
|
+
|
|
50
|
+
export const proxy = synqProxy(synqConfig, { protect: ['/dashboard'] })
|
|
51
|
+
// Pages only: not Next's files, nor anything with an extension.
|
|
52
|
+
export const config = { matcher: ['/((?!_next/|.*\\..*).*)'] }
|
|
53
|
+
```
|
|
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.
|
|
56
|
+
|
|
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
|
+
|
|
59
|
+
```ts
|
|
60
|
+
export const synqConfig = defineSynqConfig({
|
|
61
|
+
// Behind Cloudflare
|
|
62
|
+
clientAddress: (request) => request.headers.get('cf-connecting-ip') ?? undefined,
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
In a server component, `await synq.me()` is the signed-in person or null, and `await synq.getAccessToken()` is a token for your API (`resource`). Link `synq.signInPath({ returnTo: '/dashboard' })` to sign in, and sign out with a form that POSTs to `synq.signOutPath`.
|
|
67
|
+
|
|
68
|
+
## When a sign-in doesn't finish
|
|
69
|
+
|
|
70
|
+
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
|
+
|
|
72
|
+
```ts
|
|
73
|
+
export const proxy = synqProxy(synqConfig, { protect: () => true, signInError: '/sign-in-error' })
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// app/sign-in-error/page.tsx
|
|
78
|
+
export default async function SignInError({ searchParams }: PageProps<'/sign-in-error'>) {
|
|
79
|
+
const failed = synq.signInError(await searchParams)
|
|
80
|
+
if (!failed) redirect('/')
|
|
81
|
+
return (
|
|
82
|
+
<main>
|
|
83
|
+
<h1>Sign-in didn’t finish</h1>
|
|
84
|
+
<p>{failed.message}</p>
|
|
85
|
+
<a href={failed.retryPath}>Try again</a> <a href={failed.anotherAccountPath}>Use another account</a>
|
|
86
|
+
</main>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A page that isn't protected gets `synq_error` as it is: `synq.signInError(await searchParams, '/where-to-come-back')` reads it there too.
|
|
92
|
+
|
|
93
|
+
Next 16 runs `proxy.ts` on Node. The proxy imports nothing only Node has, so it works on the Edge runtime too.
|
|
94
|
+
|
|
95
|
+
## More than one server process: use a store
|
|
96
|
+
|
|
97
|
+
By default a session lives in a sealed, HttpOnly cookie, and only one refresh runs at a time within one server process. Synq rotates refresh tokens and ends a session whose old one comes back, so an app on several processes (Vercel and other serverless hosts, replicas) must keep sessions in a store they all share, which gives each session one lock:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { keyValueSessionStore } from '@synqauth/nextjs'
|
|
101
|
+
|
|
102
|
+
export const synqConfig = defineSynqConfig({
|
|
103
|
+
store: keyValueSessionStore({
|
|
104
|
+
get: (key) => redis.get(key),
|
|
105
|
+
set: async (key, value, { ttlMs, onlyIfAbsent }) =>
|
|
106
|
+
(await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
|
|
107
|
+
delete: async (key) => void (await redis.del(key)),
|
|
108
|
+
}),
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`synqAuth()` refuses cookie sessions where `VERCEL`, `AWS_LAMBDA_FUNCTION_NAME`, `NETLIFY`, `K_SERVICE` or `KUBERNETES_SERVICE_HOST` is set, unless `allowSingleProcessCookieMode` says the app truly runs one process. A store also lets your server end a session; a cookie can't be taken back.
|
|
113
|
+
|
|
114
|
+
To rotate `SYNQ_SESSION_SECRET`, move the old secret to `SYNQ_SESSION_SECRET_PREVIOUS` (comma-separated), which opens cookies but never seals them.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/client.d.ts
|
|
2
|
+
/** Calls the app's route mount: `request('session')`, `request('account/v1/profile', { method: 'PATCH', ... })`. */
|
|
3
|
+
export type SynqMountRequest = (path: string, init?: RequestInit) => Promise<Response>;
|
|
4
|
+
/** A browser plugin: actions over its server plugin's routes (`defineSynqPlugin`), which @synqauth/react exposes on `useAuth().plugins`. */
|
|
5
|
+
export interface SynqClientPlugin<Id extends string = string, Actions = unknown> {
|
|
6
|
+
readonly id: Id;
|
|
7
|
+
actions(request: SynqMountRequest): Actions;
|
|
8
|
+
}
|
|
9
|
+
/** Declares a browser plugin, its id the same as its server plugin's. */
|
|
10
|
+
export declare function defineSynqClientPlugin<const Id extends string, Actions>(plugin: SynqClientPlugin<Id, Actions>): SynqClientPlugin<Id, Actions>;
|
|
11
|
+
/** A request to `basePath`'s routes from the app's own page: same origin, with the cookie, never cached. */
|
|
12
|
+
export declare function mountRequest(basePath?: string, fetcher?: typeof fetch): SynqMountRequest;
|
|
13
|
+
//#endregion
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/client.ts
|
|
2
|
+
/** Declares a browser plugin, its id the same as its server plugin's. */
|
|
3
|
+
function defineSynqClientPlugin(plugin) {
|
|
4
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(plugin.id)) throw new TypeError(`A Synq plugin's id must be lowercase letters, digits and dashes: ${plugin.id}`);
|
|
5
|
+
return plugin;
|
|
6
|
+
}
|
|
7
|
+
/** A request to `basePath`'s routes from the app's own page: same origin, with the cookie, never cached. */
|
|
8
|
+
function mountRequest(basePath = "/api/auth", fetcher = fetch) {
|
|
9
|
+
const base = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
|
|
10
|
+
return (path, init = {}) => fetcher(`${base}/${path.replace(/^\/+/, "")}`, {
|
|
11
|
+
...init,
|
|
12
|
+
credentials: "same-origin",
|
|
13
|
+
cache: "no-store",
|
|
14
|
+
headers: {
|
|
15
|
+
accept: "application/json",
|
|
16
|
+
...headersOf(init.headers)
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function headersOf(headers) {
|
|
21
|
+
return Object.fromEntries(new Headers(headers).entries());
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { defineSynqClientPlugin, mountRequest };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
//#region src/plugins.d.ts
|
|
2
|
+
/** A signed-in person, from the ID token Synq issued at sign-in. */
|
|
3
|
+
interface SynqUser {
|
|
4
|
+
/** The user's id at the brand (`usr_…`). */
|
|
5
|
+
readonly sub: string;
|
|
6
|
+
readonly email?: string;
|
|
7
|
+
readonly email_verified?: boolean;
|
|
8
|
+
readonly name?: string;
|
|
9
|
+
readonly preferred_username?: string;
|
|
10
|
+
readonly picture?: string;
|
|
11
|
+
/** When they signed in, in seconds since the epoch. */
|
|
12
|
+
readonly auth_time?: number;
|
|
13
|
+
/** How they signed in: `pwd`, `otp`, `wallet`, `hwk`, `fed`... */
|
|
14
|
+
readonly amr?: readonly string[];
|
|
15
|
+
readonly [claim: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** What a plugin's route is handed. */
|
|
18
|
+
interface SynqPluginContext {
|
|
19
|
+
/** The signed-in person, or null. */
|
|
20
|
+
user(): Promise<SynqUser | null>;
|
|
21
|
+
/** A current access token for `resource` (the session's main API by default), refreshed when it must be. */
|
|
22
|
+
getAccessToken(options?: {
|
|
23
|
+
resource?: string;
|
|
24
|
+
}): Promise<string>;
|
|
25
|
+
readonly issuer: string;
|
|
26
|
+
readonly clientId: string;
|
|
27
|
+
readonly basePath: string;
|
|
28
|
+
}
|
|
29
|
+
/** A route: `GET /status`, `POST /verify`... relative to the plugin's own path. */
|
|
30
|
+
type SynqPluginRoute = (context: SynqPluginContext, request: Request) => Response | Promise<Response>;
|
|
31
|
+
/** A server plugin: routes under the route mount, at `<basePath>/<id>/…`. Declare one with `defineSynqPlugin`. */
|
|
32
|
+
interface SynqPlugin {
|
|
33
|
+
/** Where its routes are, under the route mount: lowercase letters, digits and dashes. */
|
|
34
|
+
readonly id: string;
|
|
35
|
+
/** Its routes, keyed `"<METHOD> /<path>"`: GET, POST, PATCH or DELETE, the methods the route mount exports. */
|
|
36
|
+
readonly routes?: Readonly<Record<string, SynqPluginRoute>>;
|
|
37
|
+
}
|
|
38
|
+
/** Declares a server plugin, checking its id and routes once, when the module loads. */
|
|
39
|
+
declare function defineSynqPlugin<const T extends SynqPlugin>(plugin: T): T;
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/store.d.ts
|
|
42
|
+
/** What a session keeps: tokens per API and the refresh token, as strings. */
|
|
43
|
+
type SessionValues = Record<string, string>;
|
|
44
|
+
/**
|
|
45
|
+
* Where sessions live server-side when the cookie holds only the session's
|
|
46
|
+
* id (`synqAuth({ store })`): needed where several processes serve the app.
|
|
47
|
+
* `keyValueSessionStore` makes one from any key-value store.
|
|
48
|
+
*/
|
|
49
|
+
interface SynqSessionStore {
|
|
50
|
+
get(id: string): Promise<SessionValues | null>;
|
|
51
|
+
set(id: string, values: SessionValues, maxAgeSeconds: number): Promise<void>;
|
|
52
|
+
delete(id: string): Promise<void>;
|
|
53
|
+
/** Runs `fn` while no other holder of `id`'s lock does, in any process sharing the store. */
|
|
54
|
+
lock<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A store in this process's memory: for tests and a single server process,
|
|
58
|
+
* where the default cookie does as well.
|
|
59
|
+
*/
|
|
60
|
+
declare function memorySessionStore(): SynqSessionStore;
|
|
61
|
+
/** The few operations a key-value store (Redis, Valkey, Upstash...) gives `keyValueSessionStore`. */
|
|
62
|
+
interface KeyValue {
|
|
63
|
+
get(key: string): Promise<string | null | undefined>;
|
|
64
|
+
/** Sets `key`, expiring after `ttlMs`; with `onlyIfAbsent`, only when it isn't set (Redis `SET … NX PX`), and says whether it did. */
|
|
65
|
+
set(key: string, value: string, options: {
|
|
66
|
+
ttlMs: number;
|
|
67
|
+
onlyIfAbsent?: boolean;
|
|
68
|
+
}): Promise<boolean>;
|
|
69
|
+
delete(key: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Deletes `key` only while it holds `value`, in one step: how a lock's
|
|
72
|
+
* holder releases it and never another's. In Redis, a script:
|
|
73
|
+
*
|
|
74
|
+
* ```lua
|
|
75
|
+
* if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end
|
|
76
|
+
* return 0
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Without it the lock reads then deletes, and a lock that expired between
|
|
80
|
+
* the two, and was taken by another request, could be deleted.
|
|
81
|
+
*/
|
|
82
|
+
deleteIfEquals?(key: string, value: string): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
/** What `keyValueSessionStore` takes besides the store itself. */
|
|
85
|
+
interface KeyValueSessionStoreOptions {
|
|
86
|
+
/** Prefix for every key: `synq:` by default. */
|
|
87
|
+
prefix?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Milliseconds a lock is held at most, should its holder die: 30 seconds by
|
|
90
|
+
* default, and always longer than a request to Synq may take (10 seconds),
|
|
91
|
+
* so a slow refresh never outlives its lock.
|
|
92
|
+
*/
|
|
93
|
+
lockTtlMs?: number;
|
|
94
|
+
/** Milliseconds to wait for a lock before giving up with `lock_timeout`: 35 seconds by default, past a dead holder's lock. */
|
|
95
|
+
lockWaitMs?: number;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Sessions in a key-value store every server process shares, such as Redis
|
|
99
|
+
* or Valkey: the store an app on several processes (replicas, serverless
|
|
100
|
+
* functions) needs. Its lock is `SET … NX PX` with a token, polled until
|
|
101
|
+
* free, and released only by its holder; it expires on its own, so a dead
|
|
102
|
+
* process never holds a session for ever.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* keyValueSessionStore({
|
|
106
|
+
* get: (key) => redis.get(key),
|
|
107
|
+
* set: async (key, value, { ttlMs, onlyIfAbsent }) =>
|
|
108
|
+
* (await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
|
|
109
|
+
* delete: async (key) => void (await redis.del(key)),
|
|
110
|
+
* })
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function keyValueSessionStore(kv: KeyValue, options?: KeyValueSessionStoreOptions): SynqSessionStore;
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/config.d.ts
|
|
116
|
+
/**
|
|
117
|
+
* What `synqAuth()` and `defineSynqConfig()` take. Every option the
|
|
118
|
+
* environment can give (`SYNQ_*`) is optional here; an option given wins.
|
|
119
|
+
*/
|
|
120
|
+
interface SynqAuthOptions {
|
|
121
|
+
/** The brand's issuer URL (`SYNQ_ISSUER`): `https://<issuer-slug>.auth.<Synq's domain>`. */
|
|
122
|
+
issuer?: string;
|
|
123
|
+
/** The app's client_id (`SYNQ_CLIENT_ID`): a web app, the brand's own. */
|
|
124
|
+
clientId?: string;
|
|
125
|
+
/** The app's client secret (`SYNQ_CLIENT_SECRET`). Never reaches the browser. */
|
|
126
|
+
clientSecret?: string;
|
|
127
|
+
/** Where the app is served (`SYNQ_APP_URL`), such as `https://app.example.com`: the callback and post-logout URIs are made from it. */
|
|
128
|
+
appUrl?: string;
|
|
129
|
+
/** At least 32 random bytes (`SYNQ_SESSION_SECRET`) the session's cookies are sealed with. */
|
|
130
|
+
sessionSecret?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Secrets cookies may still be sealed with (`SYNQ_SESSION_SECRET_PREVIOUS`,
|
|
133
|
+
* comma-separated): to rotate the secret, move the old one here, and drop
|
|
134
|
+
* it once every session it sealed has ended. Nothing is sealed with them.
|
|
135
|
+
*/
|
|
136
|
+
previousSessionSecrets?: readonly string[];
|
|
137
|
+
/** Where the route mount is: `/api/auth` by default, for `app/api/auth/[...synq]/route.ts`. */
|
|
138
|
+
basePath?: string;
|
|
139
|
+
/** The app's own API, a registered API's identifier: the session's access token is for it. */
|
|
140
|
+
resource?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Whether the session also holds a token for the brand's account API
|
|
143
|
+
* (`<issuer>/me`), which `<basePath>/account/*` calls with, for
|
|
144
|
+
* @synqauth/react's profile, wallets and sessions: true by default. Only
|
|
145
|
+
* the brand's own apps may have one.
|
|
146
|
+
*/
|
|
147
|
+
account?: boolean;
|
|
148
|
+
/** Scopes beyond `openid profile email offline_access` (and the account API's, when `account`): the app's API's scopes, say. */
|
|
149
|
+
scope?: string;
|
|
150
|
+
/** Where the browser lands after signing out: the app's root by default. Register it as a post-logout URI, exactly. */
|
|
151
|
+
postLogoutRedirectUri?: string;
|
|
152
|
+
/**
|
|
153
|
+
* Where tokens are kept: in the session's sealed cookie by default, or in
|
|
154
|
+
* this store, with only the session's sealed id in the cookie. An app on
|
|
155
|
+
* more than one server process (replicas, serverless) needs a store with
|
|
156
|
+
* a lock shared by all of them, or two requests may refresh at once and
|
|
157
|
+
* Synq ends the session (see `keyValueSessionStore`). A store also lets
|
|
158
|
+
* the app end a session on its server; a cookie can't be taken back.
|
|
159
|
+
*/
|
|
160
|
+
store?: SynqSessionStore;
|
|
161
|
+
/**
|
|
162
|
+
* Keep sessions in the cookie although the environment looks like several
|
|
163
|
+
* server processes (Vercel, Lambda, Netlify, Cloud Run, Kubernetes): only
|
|
164
|
+
* for an app that truly runs one. Without a store there, two processes
|
|
165
|
+
* refresh one session at once and Synq signs the person out.
|
|
166
|
+
*/
|
|
167
|
+
allowSingleProcessCookieMode?: boolean;
|
|
168
|
+
/** Seconds a session lasts at most, however it's refreshed: 30 days by default. */
|
|
169
|
+
sessionMaxAge?: number;
|
|
170
|
+
/** Seconds before an access token expires it's refreshed: 30 by default. */
|
|
171
|
+
refreshLeewaySeconds?: number;
|
|
172
|
+
/** Routes and hooks other @synqauth packages add under the route mount. */
|
|
173
|
+
plugins?: readonly SynqPlugin[];
|
|
174
|
+
/** The `fetch` to call Synq with. */
|
|
175
|
+
fetch?: typeof fetch;
|
|
176
|
+
/**
|
|
177
|
+
* Where the person signing in on the app's own screens is
|
|
178
|
+
* (`<basePath>/sign-in/flow`, AERO-1411), as this server saw them: Synq
|
|
179
|
+
* counts its per-address limits for the app with it (`Synq-End-User-IP`).
|
|
180
|
+
* `x-real-ip` by default, which Vercel sets; give one that reads your
|
|
181
|
+
* proxy's header otherwise. Return undefined, and per-address protection
|
|
182
|
+
* is off for the app.
|
|
183
|
+
*/
|
|
184
|
+
clientAddress?: (request: Request) => string | undefined;
|
|
185
|
+
}
|
|
186
|
+
interface SynqConfig {
|
|
187
|
+
readonly issuer: string;
|
|
188
|
+
readonly clientId: string;
|
|
189
|
+
readonly clientSecret: string;
|
|
190
|
+
readonly appUrl: URL;
|
|
191
|
+
readonly sessionSecret: string;
|
|
192
|
+
readonly previousSessionSecrets: readonly string[];
|
|
193
|
+
readonly basePath: string;
|
|
194
|
+
/** The resource the session's main token is for: the app's API, else the account API, else none. */
|
|
195
|
+
readonly primary: string | undefined;
|
|
196
|
+
/** The account API's identifier, when the session has a token for it. */
|
|
197
|
+
readonly accountApi: string | undefined;
|
|
198
|
+
readonly scope: string;
|
|
199
|
+
readonly redirectUri: string;
|
|
200
|
+
readonly postLogoutRedirectUri: string;
|
|
201
|
+
readonly store: SynqSessionStore | undefined;
|
|
202
|
+
readonly sessionMaxAge: number;
|
|
203
|
+
readonly refreshLeewaySeconds: number;
|
|
204
|
+
readonly plugins: readonly SynqPlugin[];
|
|
205
|
+
readonly fetch: typeof fetch | undefined;
|
|
206
|
+
/** Where a person signing in on the app's own screens is: `x-real-ip` by default. */
|
|
207
|
+
readonly clientAddress: (request: Request) => string | undefined;
|
|
208
|
+
/** Over https: `Secure` cookies with the `__Host-` prefix. */
|
|
209
|
+
readonly secure: boolean;
|
|
210
|
+
}
|
|
211
|
+
type Env = Record<string, string | undefined>;
|
|
212
|
+
/** The configuration from `options` and the environment; throws `invalid_options` for anything missing or unsafe. */
|
|
213
|
+
declare function resolveConfig(options?: SynqAuthOptions, env?: Env): SynqConfig;
|
|
214
|
+
//#endregion
|
|
215
|
+
export { KeyValueSessionStoreOptions as a, keyValueSessionStore as c, SynqPluginContext as d, SynqPluginRoute as f, KeyValue as i, memorySessionStore as l, defineSynqPlugin as m, SynqConfig as n, SessionValues as o, SynqUser as p, resolveConfig as r, SynqSessionStore as s, SynqAuthOptions as t, SynqPlugin as u };
|
package/dist/define.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { t as SynqAuthOptions } from "./config-CpEbAGEs.js";
|
|
2
|
+
//#region src/define.d.ts
|
|
3
|
+
/** Declares the app's Synq configuration: `SYNQ_*` environment variables fill in whatever it leaves out. */
|
|
4
|
+
export declare function defineSynqConfig<const T extends SynqAuthOptions>(options: T): T;
|
|
5
|
+
//#endregion
|
|
6
|
+
export type { SynqAuthOptions };
|
package/dist/define.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { a as KeyValueSessionStoreOptions, c as keyValueSessionStore, d as SynqPluginContext, f as SynqPluginRoute, i as KeyValue, l as memorySessionStore, m as defineSynqPlugin, n as SynqConfig, o as SessionValues, p as SynqUser, r as resolveConfig, s as SynqSessionStore, t as SynqAuthOptions, u as SynqPlugin } from "./config-CpEbAGEs.js";
|
|
2
|
+
import { defineSynqConfig } from "./define.js";
|
|
3
|
+
import { cookies } from "next/headers.js";
|
|
4
|
+
//#region src/cookies.d.ts
|
|
5
|
+
/** The cookies a request carries and the changes a response makes: what a route handler, Next's `cookies()` or a test gives. */
|
|
6
|
+
interface CookieJar {
|
|
7
|
+
get(name: string): string | undefined;
|
|
8
|
+
/** Every cookie name the request carries. */
|
|
9
|
+
names(): Iterable<string>;
|
|
10
|
+
set(name: string, value: string, options: CookieOptions): void;
|
|
11
|
+
delete(name: string, options: Pick<CookieOptions, 'secure'>): void;
|
|
12
|
+
/** Whether a change reaches the browser: false in a server component, where cookies are read-only. Writable when absent. */
|
|
13
|
+
writable?(): boolean;
|
|
14
|
+
}
|
|
15
|
+
interface CookieOptions {
|
|
16
|
+
/** Seconds the browser keeps it; a session cookie when undefined. */
|
|
17
|
+
maxAge?: number;
|
|
18
|
+
/** Over https: `Secure`, and the name carries `__Host-`. */
|
|
19
|
+
secure: boolean;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/core.d.ts
|
|
23
|
+
/** Who is signed in, as the browser may see it: never a token. */
|
|
24
|
+
interface SynqSessionInfo {
|
|
25
|
+
readonly user: SynqUser | null;
|
|
26
|
+
/** When the main access token expires, in seconds since the epoch. */
|
|
27
|
+
readonly expiresAt: number | null;
|
|
28
|
+
}
|
|
29
|
+
interface SynqCore {
|
|
30
|
+
/** Answers a request under the route mount. */
|
|
31
|
+
handle(request: Request): Promise<Response>;
|
|
32
|
+
/** The signed-in person, from the session's ID token: no request to Synq. */
|
|
33
|
+
user(jar: CookieJar): Promise<SynqUser | null>;
|
|
34
|
+
session(jar: CookieJar): Promise<SynqSessionInfo>;
|
|
35
|
+
/** A current access token for `resource` (the session's main API by default), refreshed when it must be. */
|
|
36
|
+
getAccessToken(jar: CookieJar, options?: {
|
|
37
|
+
resource?: string;
|
|
38
|
+
minValiditySeconds?: number;
|
|
39
|
+
}): Promise<string>;
|
|
40
|
+
/**
|
|
41
|
+
* Refreshes the session's main token when it's due, where the cookie can
|
|
42
|
+
* be written (Next's proxy), so the server components after it never
|
|
43
|
+
* need to; says whether someone is signed in.
|
|
44
|
+
*/
|
|
45
|
+
refreshIfDue(jar: CookieJar): Promise<boolean>;
|
|
46
|
+
readonly config: SynqConfig;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The framework-neutral core `synqAuth()` runs on, over any cookie jar: for
|
|
50
|
+
* a server that answers the same routes without Next. Most apps use
|
|
51
|
+
* `synqAuth()` instead.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createSynqCore(config: SynqConfig): SynqCore;
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/sign-in-error.d.ts
|
|
56
|
+
/** The query parameter the route mount adds to `returnTo` when a sign-in fails. */
|
|
57
|
+
export declare const SIGN_IN_ERROR_PARAM = "synq_error";
|
|
58
|
+
/** Why the last sign-in didn't finish, and the ways on. */
|
|
59
|
+
interface SynqSignInError {
|
|
60
|
+
/** The OAuth or SDK error code (`access_denied`, `unknown_state`…). */
|
|
61
|
+
readonly code: string;
|
|
62
|
+
/** What happened, in plain words for the person. */
|
|
63
|
+
readonly message: string;
|
|
64
|
+
/** Signs in again, back to the same page. */
|
|
65
|
+
readonly retryPath: string;
|
|
66
|
+
/** Signs in as someone else (`prompt=select_account`), back to the same page. */
|
|
67
|
+
readonly anotherAccountPath: string;
|
|
68
|
+
}
|
|
69
|
+
/** What a sign-in error code means, in plain words. */
|
|
70
|
+
export declare function signInErrorMessage(code: string): string;
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/index.d.ts
|
|
73
|
+
/** What `synqAuth()` returns: the route mount's handlers and the server's view of the session. */
|
|
74
|
+
export interface SynqAuth {
|
|
75
|
+
/** The route mount's handlers, for `app/api/auth/[...synq]/route.ts`. */
|
|
76
|
+
readonly handlers: {
|
|
77
|
+
readonly GET: (request: Request) => Promise<Response>;
|
|
78
|
+
readonly POST: (request: Request) => Promise<Response>;
|
|
79
|
+
readonly PATCH: (request: Request) => Promise<Response>;
|
|
80
|
+
readonly DELETE: (request: Request) => Promise<Response>;
|
|
81
|
+
};
|
|
82
|
+
/** The signed-in person, or null: read from the session's ID token, once per request, with no request to Synq. */
|
|
83
|
+
me(): Promise<SynqUser | null>;
|
|
84
|
+
/** Who is signed in and until when, as the browser may see it. */
|
|
85
|
+
getSession(): Promise<SynqSessionInfo>;
|
|
86
|
+
/**
|
|
87
|
+
* A current access token for the app's API (or `resource`), refreshed
|
|
88
|
+
* when it must be: for the app's server to call its API with. Never hand
|
|
89
|
+
* it to the browser.
|
|
90
|
+
*/
|
|
91
|
+
getAccessToken(options?: {
|
|
92
|
+
resource?: string;
|
|
93
|
+
minValiditySeconds?: number;
|
|
94
|
+
}): Promise<string>;
|
|
95
|
+
/** Where to send the browser to sign in, coming back to `returnTo`. */
|
|
96
|
+
signInPath(options?: {
|
|
97
|
+
returnTo?: string;
|
|
98
|
+
method?: string;
|
|
99
|
+
loginHint?: string;
|
|
100
|
+
prompt?: 'login' | 'none' | 'consent' | 'select_account';
|
|
101
|
+
maxAge?: number;
|
|
102
|
+
}): string;
|
|
103
|
+
/** Where a form POSTs to sign out. */
|
|
104
|
+
readonly signOutPath: string;
|
|
105
|
+
/**
|
|
106
|
+
* Why the last sign-in didn't finish, from a page's search params: the
|
|
107
|
+
* `synq_error` the route mount adds to `returnTo` when Synq refused or the
|
|
108
|
+
* callback failed, and the `returnTo` the proxy adds when it rewrites to
|
|
109
|
+
* its `signInError` page (else `returnTo` here). Its code, plain words
|
|
110
|
+
* for the person, and the ways on: signing in again, and as someone else.
|
|
111
|
+
* Null when the page has none.
|
|
112
|
+
*/
|
|
113
|
+
signInError(searchParams: URLSearchParams | Record<string, string | string[] | undefined>, returnTo?: string): SynqSignInError | null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Synq for this app, configured from SYNQ_ISSUER, SYNQ_CLIENT_ID,
|
|
117
|
+
* SYNQ_CLIENT_SECRET, SYNQ_APP_URL and SYNQ_SESSION_SECRET unless `options`
|
|
118
|
+
* (`defineSynqConfig`, shared with the proxy) says.
|
|
119
|
+
*/
|
|
120
|
+
export declare function synqAuth(options?: SynqAuthOptions): SynqAuth;
|
|
121
|
+
type NextCookieStore = Awaited<ReturnType<typeof cookies>>;
|
|
122
|
+
/** Next's cookie store as the cookie jar `createSynqCore` reads and writes. */
|
|
123
|
+
export declare function jarOf(store: NextCookieStore): CookieJar;
|
|
124
|
+
//#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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { c as memorySessionStore, d as normalizePath, f as resolveConfig, l as defineSynqPlugin, n as signInErrorMessage, o as createSynqCore, r as signInErrorOf, s as keyValueSessionStore, t as SIGN_IN_ERROR_PARAM } from "./sign-in-error-kd56GHt_.js";
|
|
2
|
+
import { defineSynqConfig } from "./define.js";
|
|
3
|
+
import { cookies } from "next/headers.js";
|
|
4
|
+
import { cache } from "react";
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* Synq for this app, configured from SYNQ_ISSUER, SYNQ_CLIENT_ID,
|
|
8
|
+
* SYNQ_CLIENT_SECRET, SYNQ_APP_URL and SYNQ_SESSION_SECRET unless `options`
|
|
9
|
+
* (`defineSynqConfig`, shared with the proxy) says.
|
|
10
|
+
*/
|
|
11
|
+
function synqAuth(options = {}) {
|
|
12
|
+
let core;
|
|
13
|
+
const get = () => core ??= createSynqCore(resolveConfig(options));
|
|
14
|
+
const handler = (request) => get().handle(request);
|
|
15
|
+
const basePath = () => normalizePath(options.basePath ?? "/api/auth");
|
|
16
|
+
const me = cache(async () => {
|
|
17
|
+
const jar = await nextJar();
|
|
18
|
+
return get().user(jar);
|
|
19
|
+
});
|
|
20
|
+
const getSession = cache(async () => {
|
|
21
|
+
const jar = await nextJar();
|
|
22
|
+
return get().session(jar);
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
handlers: {
|
|
26
|
+
GET: handler,
|
|
27
|
+
POST: handler,
|
|
28
|
+
PATCH: handler,
|
|
29
|
+
DELETE: handler
|
|
30
|
+
},
|
|
31
|
+
me,
|
|
32
|
+
getSession,
|
|
33
|
+
async getAccessToken(opts) {
|
|
34
|
+
const jar = await nextJar();
|
|
35
|
+
return get().getAccessToken(jar, opts);
|
|
36
|
+
},
|
|
37
|
+
signInPath(opts = {}) {
|
|
38
|
+
const params = new URLSearchParams();
|
|
39
|
+
if (opts.returnTo) params.set("returnTo", opts.returnTo);
|
|
40
|
+
if (opts.method) params.set("method", opts.method);
|
|
41
|
+
if (opts.loginHint) params.set("login_hint", opts.loginHint);
|
|
42
|
+
if (opts.prompt) params.set("prompt", opts.prompt);
|
|
43
|
+
if (opts.maxAge !== void 0) params.set("max_age", String(opts.maxAge));
|
|
44
|
+
const query = params.toString();
|
|
45
|
+
return `${basePath()}/sign-in${query ? `?${query}` : ""}`;
|
|
46
|
+
},
|
|
47
|
+
get signOutPath() {
|
|
48
|
+
return `${basePath()}/sign-out`;
|
|
49
|
+
},
|
|
50
|
+
signInError(searchParams, returnTo = "/") {
|
|
51
|
+
const read = (name) => {
|
|
52
|
+
if (searchParams instanceof URLSearchParams) return searchParams.get(name);
|
|
53
|
+
const value = searchParams[name];
|
|
54
|
+
return Array.isArray(value) ? value[0] : value;
|
|
55
|
+
};
|
|
56
|
+
return signInErrorOf(read(SIGN_IN_ERROR_PARAM), basePath(), read("returnTo") ?? returnTo);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Next's cookies as a jar. A server component can't set cookies, so it says
|
|
62
|
+
* it isn't writable there, and the session never refreshes in one: the
|
|
63
|
+
* proxy refreshes it before the page renders.
|
|
64
|
+
*/
|
|
65
|
+
async function nextJar() {
|
|
66
|
+
return jarOf(await cookies());
|
|
67
|
+
}
|
|
68
|
+
/** Next's cookie store as the cookie jar `createSynqCore` reads and writes. */
|
|
69
|
+
function jarOf(store) {
|
|
70
|
+
const write = (name, value, secure, maxAge) => {
|
|
71
|
+
try {
|
|
72
|
+
store.set({
|
|
73
|
+
name,
|
|
74
|
+
value,
|
|
75
|
+
httpOnly: true,
|
|
76
|
+
sameSite: "lax",
|
|
77
|
+
path: "/",
|
|
78
|
+
secure,
|
|
79
|
+
maxAge
|
|
80
|
+
});
|
|
81
|
+
} catch {}
|
|
82
|
+
};
|
|
83
|
+
let writable;
|
|
84
|
+
return {
|
|
85
|
+
get: (name) => store.get(name)?.value,
|
|
86
|
+
names: () => store.getAll().map((cookie) => cookie.name),
|
|
87
|
+
set: (name, value, opts) => write(name, value, opts.secure, opts.maxAge),
|
|
88
|
+
delete: (name, opts) => write(name, "", opts.secure, 0),
|
|
89
|
+
writable() {
|
|
90
|
+
if (writable === void 0) try {
|
|
91
|
+
store.set({
|
|
92
|
+
name: "synq.probe",
|
|
93
|
+
value: "",
|
|
94
|
+
maxAge: 0,
|
|
95
|
+
path: "/"
|
|
96
|
+
});
|
|
97
|
+
writable = true;
|
|
98
|
+
} catch {
|
|
99
|
+
writable = false;
|
|
100
|
+
}
|
|
101
|
+
return writable;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
export { SIGN_IN_ERROR_PARAM, createSynqCore, defineSynqConfig, defineSynqPlugin, jarOf, keyValueSessionStore, memorySessionStore, resolveConfig, signInErrorMessage, synqAuth };
|