@synqauth/js 0.0.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 +7 -1
- package/dist/api-BGeXhXo6.js +913 -0
- package/dist/index.d.ts +3326 -1504
- package/dist/index.js +544 -11
- package/dist/management-C4LYIC3S.d.ts +20745 -0
- package/dist/server.d.ts +164 -3
- package/dist/server.js +140 -3
- package/package.json +1 -1
- package/dist/api-Cf9K1Rc9.js +0 -163
- package/dist/management-y9-9AIc-.d.ts +0 -11198
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as SynqError, l as isSynqError, r as paths, t as components } from "./management-
|
|
1
|
+
import { A as SignInFlowStep, M as StartSignInOptions, O as SavedSignIn, c as SynqError, j as SignInStepResult, k as SignInFlow, l as isSynqError, m as IdTokenClaims, r as paths, t as components, z as LinkedAccount } from "./management-C4LYIC3S.js";
|
|
2
2
|
import { JWTPayload, JWTVerifyGetKey } from "jose";
|
|
3
3
|
//#region src/api.d.ts
|
|
4
4
|
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
@@ -38,7 +38,12 @@ type Success<O> = O extends {
|
|
|
38
38
|
};
|
|
39
39
|
} ? J : never : S extends 204 ? undefined : never; }[keyof R] : never;
|
|
40
40
|
type Common = {
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Sent as `Idempotency-Key`: a POST with the same key runs once (the
|
|
43
|
+
* management API keeps the answer 24 hours). The management client makes
|
|
44
|
+
* one for every POST you don't give one, unless `maxRetries` is 0, and
|
|
45
|
+
* sends the same key on each retry.
|
|
46
|
+
*/
|
|
42
47
|
idempotencyKey?: string;
|
|
43
48
|
signal?: AbortSignal;
|
|
44
49
|
};
|
|
@@ -60,6 +65,14 @@ type RequestOptions<O> = ([PathParams<O>] extends [never] ? {
|
|
|
60
65
|
}) & Common;
|
|
61
66
|
type Args<O> = object extends RequestOptions<O> ? [options?: RequestOptions<O>] : [options: RequestOptions<O>];
|
|
62
67
|
type Call<Paths, M extends HttpMethod> = <P extends PathsWith<Paths, M>>(path: P, ...args: Args<Operation<Paths, P, M>>) => Promise<Success<Operation<Paths, P, M>>>;
|
|
68
|
+
/** One item of a list's page: what `paginate` yields. */
|
|
69
|
+
type PageItem<O> = Success<O> extends {
|
|
70
|
+
readonly data: readonly (infer T)[];
|
|
71
|
+
readonly next_cursor: string | null;
|
|
72
|
+
} ? T : never;
|
|
73
|
+
/** The paths of a contract whose GET answers a page (`{ data, next_cursor }`). */
|
|
74
|
+
type PagedPaths<Paths> = { [P in PathsWith<Paths, 'get'>]: [PageItem<Operation<Paths, P, 'get'>>] extends [never] ? never : P; }[PathsWith<Paths, 'get'>] & string;
|
|
75
|
+
type Paginate<Paths> = <P extends PagedPaths<Paths>>(path: P, ...args: Args<Operation<Paths, P, 'get'>>) => AsyncIterable<PageItem<Operation<Paths, P, 'get'>>>;
|
|
63
76
|
/** A client for one Synq contract: one method per HTTP method, each typed by the contract. */
|
|
64
77
|
interface ApiClient<Paths> {
|
|
65
78
|
get: Call<Paths, 'get'>;
|
|
@@ -67,6 +80,14 @@ interface ApiClient<Paths> {
|
|
|
67
80
|
put: Call<Paths, 'put'>;
|
|
68
81
|
patch: Call<Paths, 'patch'>;
|
|
69
82
|
delete: Call<Paths, 'delete'>;
|
|
83
|
+
/**
|
|
84
|
+
* Every item of a list, page by page: `for await (const user of
|
|
85
|
+
* client.paginate('/v1/orgs/{org}/brands/{brand}/users', { path }))`. It
|
|
86
|
+
* asks for the next page (`cursor`, from the last page's `next_cursor`)
|
|
87
|
+
* only when the loop reaches it, so `break` stops asking. `query.limit`
|
|
88
|
+
* is the size of each page.
|
|
89
|
+
*/
|
|
90
|
+
paginate: Paginate<Paths>;
|
|
70
91
|
}
|
|
71
92
|
//#endregion
|
|
72
93
|
//#region src/server.d.ts
|
|
@@ -112,6 +133,12 @@ export interface AccessTokenClaims {
|
|
|
112
133
|
readonly type?: string;
|
|
113
134
|
readonly client_id?: string;
|
|
114
135
|
} | undefined;
|
|
136
|
+
/** The person's email, when your API asks for it (`identity_claims`) and they have one. */
|
|
137
|
+
readonly email: string | undefined;
|
|
138
|
+
/** Whether Synq verified it, when your API asks for it. */
|
|
139
|
+
readonly email_verified: boolean | undefined;
|
|
140
|
+
/** Their accounts at upstream providers (GitHub, Google…), when your API asks for them. */
|
|
141
|
+
readonly linked_accounts: readonly LinkedAccount[] | undefined;
|
|
115
142
|
/** Every claim, as the token carries it. */
|
|
116
143
|
readonly payload: JWTPayload;
|
|
117
144
|
}
|
|
@@ -129,6 +156,67 @@ export type AccessTokenVerifier = (token: string, options?: VerifyAccessTokenOpt
|
|
|
129
156
|
* request. The issuer and its JWKS must be https (http only on this machine).
|
|
130
157
|
*/
|
|
131
158
|
export declare function createAccessTokenVerifier(options: AccessTokenVerifierOptions): AccessTokenVerifier;
|
|
159
|
+
/**
|
|
160
|
+
* Whether `credential` is one of your users' API keys (`ak_live_…` or
|
|
161
|
+
* `ak_test_…`, AERO-1693) rather than an access token: what tells an API
|
|
162
|
+
* which of `createApiKeyVerifier` and `createAccessTokenVerifier` to check
|
|
163
|
+
* a bearer credential with.
|
|
164
|
+
*/
|
|
165
|
+
export declare function isApiKey(credential: unknown): credential is string;
|
|
166
|
+
/** What an API checks its users' API keys with: the brand's issuer, and the API's own app. */
|
|
167
|
+
export interface ApiKeyVerifierOptions {
|
|
168
|
+
/** The brand's issuer URL, as the dashboard shows it. */
|
|
169
|
+
issuer: string;
|
|
170
|
+
/**
|
|
171
|
+
* The API's own app (its `client_id` in Synq), the one linked to the API:
|
|
172
|
+
* only it may check the API's keys.
|
|
173
|
+
*/
|
|
174
|
+
clientId: string;
|
|
175
|
+
clientSecret: string;
|
|
176
|
+
fetch?: typeof fetch;
|
|
177
|
+
}
|
|
178
|
+
/** What one route wants of a key, on top of the verifier's checks. */
|
|
179
|
+
export interface VerifyApiKeyOptions {
|
|
180
|
+
/** Scopes the key must carry, every one. */
|
|
181
|
+
scopes?: readonly string[];
|
|
182
|
+
}
|
|
183
|
+
/** One of your users' API keys, once Synq said it works. */
|
|
184
|
+
export interface ApiKeyClaims {
|
|
185
|
+
readonly iss: string;
|
|
186
|
+
/** The person who made it (`usr_…`), as in their access tokens' `sub`. */
|
|
187
|
+
readonly sub: string;
|
|
188
|
+
/** Your API's identifier: the API it is for. */
|
|
189
|
+
readonly aud: string;
|
|
190
|
+
readonly scopes: readonly string[];
|
|
191
|
+
/** The key's id (`uak_…`), never the key. */
|
|
192
|
+
readonly keyId: string;
|
|
193
|
+
/** The name the person gave it. */
|
|
194
|
+
readonly name: string;
|
|
195
|
+
/** When it was made, in seconds. */
|
|
196
|
+
readonly iat: number;
|
|
197
|
+
/** When it stops working, in seconds; undefined for a key that doesn't expire. */
|
|
198
|
+
readonly exp: number | undefined;
|
|
199
|
+
/** The person's email, when your API asks for it (`identity_claims`) and they have one. */
|
|
200
|
+
readonly email: string | undefined;
|
|
201
|
+
/** Whether Synq verified it, when your API asks for it. */
|
|
202
|
+
readonly email_verified: boolean | undefined;
|
|
203
|
+
/** Their accounts at upstream providers (GitHub, Google…), when your API asks for them. */
|
|
204
|
+
readonly linked_accounts: readonly LinkedAccount[] | undefined;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Checks one of your users' API keys, as `createApiKeyVerifier` makes it:
|
|
208
|
+
* the claims when the key works, else a `SynqError` (`invalid_token` for a
|
|
209
|
+
* key that doesn't, `insufficient_scope` for one without a scope asked for).
|
|
210
|
+
*/
|
|
211
|
+
export type ApiKeyVerifier = (key: string, options?: VerifyApiKeyOptions) => Promise<ApiKeyClaims>;
|
|
212
|
+
/**
|
|
213
|
+
* A verifier for the API keys your users make for your API (AERO-1693). It
|
|
214
|
+
* asks the brand's issuer about every key (`POST /api-keys/verify`, as the
|
|
215
|
+
* API's own app), and keeps nothing: revoking a key works from its next
|
|
216
|
+
* check. A key Synq says doesn't work is `invalid_token` (401), never why;
|
|
217
|
+
* your app's credentials refused are `invalid_client`.
|
|
218
|
+
*/
|
|
219
|
+
export declare function createApiKeyVerifier(options: ApiKeyVerifierOptions): ApiKeyVerifier;
|
|
132
220
|
/** A Synq webhook's event: one row of the org's audit log (the management API's `WebhookEvent`). */
|
|
133
221
|
export type WebhookEvent = components['schemas']['WebhookEvent'];
|
|
134
222
|
/** A delivery that passed its check. */
|
|
@@ -162,14 +250,87 @@ export interface ManagementClientOptions {
|
|
|
162
250
|
/** Or a Synq access token, acting as that person: a function so it can be refreshed. */
|
|
163
251
|
accessToken?: () => string | Promise<string>;
|
|
164
252
|
fetch?: typeof fetch;
|
|
253
|
+
/**
|
|
254
|
+
* How many times a call is tried again when Synq's answer says it may
|
|
255
|
+
* work later: 2 by default, 0 for never (and then a POST carries only
|
|
256
|
+
* the `idempotencyKey` you give it). Every method retries a 429, a
|
|
257
|
+
* 503 and `idempotency_key_in_flight`; a GET, PUT, PATCH or POST (with
|
|
258
|
+
* its key) also retries a 502, a 504 and a dropped connection; a GET, PUT
|
|
259
|
+
* or PATCH a 500. A DELETE retries only refusals, since a second try
|
|
260
|
+
* could answer 404 for a delete that worked. Each try waits the
|
|
261
|
+
* `Retry-After` Synq names, up to 60 seconds (a longer one is thrown at
|
|
262
|
+
* once, with `retryAfter`), or else backs off from half a second, and an
|
|
263
|
+
* aborted `signal` stops the waiting.
|
|
264
|
+
*/
|
|
265
|
+
maxRetries?: number;
|
|
165
266
|
}
|
|
267
|
+
/** How many times the management client tries a call again by default. */
|
|
268
|
+
export declare const DEFAULT_MAX_RETRIES = 2;
|
|
166
269
|
/** The management API's typed client: every path, parameter, body and answer from its contract. */
|
|
167
270
|
export type ManagementClient = ApiClient<paths>;
|
|
168
271
|
/**
|
|
169
272
|
* A client for Synq's management API (`/v1`), typed by its contract. Server
|
|
170
273
|
* only: an API key never reaches a browser. Failures throw a `SynqError`
|
|
171
274
|
* with the problem's `code`, such as `insufficient_permissions`.
|
|
275
|
+
*
|
|
276
|
+
* Every POST carries an `Idempotency-Key` (yours, or a new one unless
|
|
277
|
+
* `maxRetries` is 0), the same on each try, so a retry never makes two of
|
|
278
|
+
* anything. An answer that holds a secret or a link only its holder may use
|
|
279
|
+
* isn't kept with the key: a retry of it is `conflict`. A call is tried
|
|
280
|
+
* again, up to `maxRetries` times, when Synq's answer says it may work
|
|
281
|
+
* later, and `paginate` walks a list's pages with `for await`.
|
|
172
282
|
*/
|
|
173
283
|
export declare function createManagementClient(options: ManagementClientOptions): ManagementClient;
|
|
284
|
+
/** What a server needs to sign people in on its app's own screens (AERO-1411). */
|
|
285
|
+
export interface OwnUiSignInOptions {
|
|
286
|
+
/** The brand's issuer URL, as the dashboard shows it. */
|
|
287
|
+
issuer: string;
|
|
288
|
+
/** The app's client_id: one of the brand's own web apps, with own-UI sign-in on. */
|
|
289
|
+
clientId: string;
|
|
290
|
+
/** The app's secret, sent with HTTP Basic, as at the token endpoint. */
|
|
291
|
+
clientSecret?: string;
|
|
292
|
+
/** Or the key it signs its client assertions with (`private_key_jwt`). */
|
|
293
|
+
privateKey?: CryptoKey | {
|
|
294
|
+
key: CryptoKey;
|
|
295
|
+
kid?: string;
|
|
296
|
+
};
|
|
297
|
+
fetch?: typeof fetch;
|
|
298
|
+
}
|
|
299
|
+
/** What a server's own-UI sign-in ends with: the app keeps them as its session. */
|
|
300
|
+
export interface OwnUiTokens {
|
|
301
|
+
readonly accessToken: string;
|
|
302
|
+
readonly tokenType: string;
|
|
303
|
+
/** When the access token expires, in seconds since the epoch, when Synq said. */
|
|
304
|
+
readonly expiresAt: number | undefined;
|
|
305
|
+
readonly scope: string | undefined;
|
|
306
|
+
readonly idToken: string;
|
|
307
|
+
readonly refreshToken: string | undefined;
|
|
308
|
+
/** The ID token's claims, validated: who signed in, and how. */
|
|
309
|
+
readonly claims: IdTokenClaims;
|
|
310
|
+
}
|
|
311
|
+
/** A server's own-UI sign-ins: one flow per sign-in, kept between requests with `toJSON`. */
|
|
312
|
+
export interface OwnUiSignIn {
|
|
313
|
+
/**
|
|
314
|
+
* Starts a sign-in. `endUserIp` is the address the person is at, as your
|
|
315
|
+
* server saw it: Synq counts per-address limits for your app with it, and
|
|
316
|
+
* without it counts none.
|
|
317
|
+
*/
|
|
318
|
+
start(options?: StartSignInOptions & {
|
|
319
|
+
endUserIp?: string;
|
|
320
|
+
}): SignInFlow<OwnUiTokens>;
|
|
321
|
+
/** Picks up the flow `toJSON()` saved, for the next request of the same sign-in. */
|
|
322
|
+
resume(saved: SavedSignIn | string, options?: {
|
|
323
|
+
endUserIp?: string;
|
|
324
|
+
}): SignInFlow<OwnUiTokens>;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Own-UI sign-in for a server (AERO-1411): its forms post to it, it calls
|
|
328
|
+
* Synq's Authorization Challenge Endpoint as its app, and a sign-in ends
|
|
329
|
+
* with tokens, not a cookie at Synq. The app keeps them, and the person's
|
|
330
|
+
* password or code never reaches a browser but the form they typed it in.
|
|
331
|
+
* `hosted` results can't `continue()` here: send the browser to an
|
|
332
|
+
* ordinary authorization request with the `method` and `loginHint` given.
|
|
333
|
+
*/
|
|
334
|
+
export declare function createOwnUiSignIn(options: OwnUiSignInOptions): OwnUiSignIn;
|
|
174
335
|
//#endregion
|
|
175
|
-
export { type components as ManagementComponents, type paths as ManagementPaths, SynqError, isSynqError };
|
|
336
|
+
export { type LinkedAccount, type components as ManagementComponents, type paths as ManagementPaths, type SavedSignIn, SignInFlow, type SignInFlowStep, type SignInStepResult, type StartSignInOptions, SynqError, isSynqError };
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { i as isSynqError, n as secureUrl, r as
|
|
1
|
+
import { a as readSavedSignIn, c as SynqError, i as newSignIn, l as isSynqError, n as secureUrl, o as linkedAccountsOf, r as SignInFlow, t as createApiClient } from "./api-BGeXhXo6.js";
|
|
2
|
+
import * as oauth from "oauth4webapi";
|
|
2
3
|
import { createRemoteJWKSet, customFetch, errors, jwtVerify } from "jose";
|
|
3
4
|
//#region src/server.ts
|
|
4
5
|
/**
|
|
@@ -104,6 +105,9 @@ function accessTokenClaims(payload) {
|
|
|
104
105
|
acr: string(payload.acr),
|
|
105
106
|
amr,
|
|
106
107
|
act,
|
|
108
|
+
email: string(payload.email),
|
|
109
|
+
email_verified: typeof payload.email_verified === "boolean" ? payload.email_verified : void 0,
|
|
110
|
+
linked_accounts: linkedAccountsOf(payload.linked_accounts),
|
|
107
111
|
payload
|
|
108
112
|
};
|
|
109
113
|
}
|
|
@@ -123,6 +127,77 @@ function tokenError(error) {
|
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
129
|
/**
|
|
130
|
+
* Whether `credential` is one of your users' API keys (`ak_live_…` or
|
|
131
|
+
* `ak_test_…`, AERO-1693) rather than an access token: what tells an API
|
|
132
|
+
* which of `createApiKeyVerifier` and `createAccessTokenVerifier` to check
|
|
133
|
+
* a bearer credential with.
|
|
134
|
+
*/
|
|
135
|
+
function isApiKey(credential) {
|
|
136
|
+
return typeof credential === "string" && /^ak_(live|test)_[A-Za-z0-9_-]{43}$/.test(credential);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A verifier for the API keys your users make for your API (AERO-1693). It
|
|
140
|
+
* asks the brand's issuer about every key (`POST /api-keys/verify`, as the
|
|
141
|
+
* API's own app), and keeps nothing: revoking a key works from its next
|
|
142
|
+
* check. A key Synq says doesn't work is `invalid_token` (401), never why;
|
|
143
|
+
* your app's credentials refused are `invalid_client`.
|
|
144
|
+
*/
|
|
145
|
+
function createApiKeyVerifier(options) {
|
|
146
|
+
const issuer = secureUrl(options.issuer, "The issuer").href.replace(/\/+$/, "");
|
|
147
|
+
if (!options.clientId || !options.clientSecret) throw new SynqError("invalid_options", "Give the API key verifier your API's own app: its clientId and clientSecret");
|
|
148
|
+
const form = (value) => encodeURIComponent(value).replace(/%20/g, "+");
|
|
149
|
+
const basic = btoa(`${form(options.clientId)}:${form(options.clientSecret)}`);
|
|
150
|
+
const fetcher = options.fetch ?? fetch;
|
|
151
|
+
return async (key, verify = {}) => {
|
|
152
|
+
if (!isApiKey(key)) throw new SynqError("invalid_token", "The API key is not valid for this API", { status: 401 });
|
|
153
|
+
let response;
|
|
154
|
+
try {
|
|
155
|
+
response = await fetcher(`${issuer}/api-keys/verify`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: {
|
|
158
|
+
authorization: `Basic ${basic}`,
|
|
159
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
160
|
+
},
|
|
161
|
+
body: new URLSearchParams({ token: key }).toString()
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new SynqError("network_error", "Synq couldn't be reached to check the API key", {
|
|
165
|
+
status: 503,
|
|
166
|
+
cause: error
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const body = await response.json().catch(() => void 0);
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const error = typeof body?.error === "string" ? body.error : void 0;
|
|
172
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
173
|
+
throw new SynqError(error === "invalid_client" ? "invalid_client" : response.status === 429 ? "rate_limited" : "server_error", `Synq refused to check the API key (${response.status})`, {
|
|
174
|
+
status: response.status,
|
|
175
|
+
description: typeof body?.error_description === "string" ? body.error_description : void 0,
|
|
176
|
+
retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (body?.active !== true) throw new SynqError("invalid_token", "The API key is not valid for this API", { status: 401 });
|
|
180
|
+
const string = (value) => typeof value === "string" ? value : "";
|
|
181
|
+
const claims = {
|
|
182
|
+
iss: string(body.iss),
|
|
183
|
+
sub: string(body.sub),
|
|
184
|
+
aud: string(body.aud),
|
|
185
|
+
scopes: string(body.scope).split(" ").filter(Boolean),
|
|
186
|
+
keyId: string(body.key_id),
|
|
187
|
+
name: string(body.name),
|
|
188
|
+
iat: typeof body.iat === "number" ? body.iat : 0,
|
|
189
|
+
exp: typeof body.exp === "number" ? body.exp : void 0,
|
|
190
|
+
email: typeof body.email === "string" ? body.email : void 0,
|
|
191
|
+
email_verified: typeof body.email_verified === "boolean" ? body.email_verified : void 0,
|
|
192
|
+
linked_accounts: linkedAccountsOf(body.linked_accounts)
|
|
193
|
+
};
|
|
194
|
+
if (claims.iss !== issuer) throw new SynqError("invalid_response", "Synq's answer names another issuer", { status: 502 });
|
|
195
|
+
const missing = (verify.scopes ?? []).filter((scope) => !claims.scopes.includes(scope));
|
|
196
|
+
if (missing.length > 0) throw new SynqError("insufficient_scope", `The API key lacks ${missing.join(", ")}`, { status: 403 });
|
|
197
|
+
return claims;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
126
201
|
* Checks a delivery's Standard Webhooks signature (`webhook-id`,
|
|
127
202
|
* `webhook-timestamp`, `webhook-signature`, HMAC-SHA256 over
|
|
128
203
|
* `<id>.<timestamp>.<body>`) against the subscription's `whsec_` secret, and
|
|
@@ -179,10 +254,19 @@ function fromBase64(value) {
|
|
|
179
254
|
return;
|
|
180
255
|
}
|
|
181
256
|
}
|
|
257
|
+
/** How many times the management client tries a call again by default. */
|
|
258
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
182
259
|
/**
|
|
183
260
|
* A client for Synq's management API (`/v1`), typed by its contract. Server
|
|
184
261
|
* only: an API key never reaches a browser. Failures throw a `SynqError`
|
|
185
262
|
* with the problem's `code`, such as `insufficient_permissions`.
|
|
263
|
+
*
|
|
264
|
+
* Every POST carries an `Idempotency-Key` (yours, or a new one unless
|
|
265
|
+
* `maxRetries` is 0), the same on each try, so a retry never makes two of
|
|
266
|
+
* anything. An answer that holds a secret or a link only its holder may use
|
|
267
|
+
* isn't kept with the key: a retry of it is `conflict`. A call is tried
|
|
268
|
+
* again, up to `maxRetries` times, when Synq's answer says it may work
|
|
269
|
+
* later, and `paginate` walks a list's pages with `for await`.
|
|
186
270
|
*/
|
|
187
271
|
function createManagementClient(options) {
|
|
188
272
|
const { apiKey, accessToken } = options;
|
|
@@ -190,8 +274,61 @@ function createManagementClient(options) {
|
|
|
190
274
|
return createApiClient({
|
|
191
275
|
baseUrl: options.baseUrl,
|
|
192
276
|
credential: apiKey ? () => apiKey : accessToken,
|
|
193
|
-
fetch: options.fetch
|
|
277
|
+
fetch: options.fetch,
|
|
278
|
+
maxRetries: options.maxRetries ?? 2,
|
|
279
|
+
idempotencyKeys: true
|
|
194
280
|
});
|
|
195
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Own-UI sign-in for a server (AERO-1411): its forms post to it, it calls
|
|
284
|
+
* Synq's Authorization Challenge Endpoint as its app, and a sign-in ends
|
|
285
|
+
* with tokens, not a cookie at Synq. The app keeps them, and the person's
|
|
286
|
+
* password or code never reaches a browser but the form they typed it in.
|
|
287
|
+
* `hosted` results can't `continue()` here: send the browser to an
|
|
288
|
+
* ordinary authorization request with the `method` and `loginHint` given.
|
|
289
|
+
*/
|
|
290
|
+
function createOwnUiSignIn(options) {
|
|
291
|
+
const issuer = secureUrl(options.issuer, "The issuer");
|
|
292
|
+
if (!options.clientId) throw new SynqError("invalid_options", "The clientId is missing");
|
|
293
|
+
if (!options.clientSecret === !options.privateKey) throw new SynqError("invalid_options", "Give createOwnUiSignIn a clientSecret or a privateKey");
|
|
294
|
+
const auth = options.clientSecret ? oauth.ClientSecretBasic(options.clientSecret) : oauth.PrivateKeyJwt(options.privateKey);
|
|
295
|
+
const http = {};
|
|
296
|
+
if (options.fetch) http[oauth.customFetch] = options.fetch;
|
|
297
|
+
if (issuer.protocol === "http:") http[oauth.allowInsecureRequests] = true;
|
|
298
|
+
let server;
|
|
299
|
+
const discover = () => server ??= (async () => {
|
|
300
|
+
try {
|
|
301
|
+
const response = await oauth.discoveryRequest(issuer, {
|
|
302
|
+
...http,
|
|
303
|
+
algorithm: "oidc"
|
|
304
|
+
});
|
|
305
|
+
return await oauth.processDiscoveryResponse(issuer, response);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
server = void 0;
|
|
308
|
+
throw new SynqError("discovery_failed", `Synq's discovery document at ${issuer.href} couldn't be read`, { cause: error });
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
const port = (endUserIp) => ({
|
|
312
|
+
server: discover,
|
|
313
|
+
client: { client_id: options.clientId },
|
|
314
|
+
auth,
|
|
315
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
316
|
+
...endUserIp ? { headers: { "synq-end-user-ip": endUserIp } } : {},
|
|
317
|
+
finish: (result, claims) => Promise.resolve({
|
|
318
|
+
accessToken: result.access_token,
|
|
319
|
+
tokenType: result.token_type,
|
|
320
|
+
expiresAt: result.expires_in === void 0 ? void 0 : Math.floor(Date.now() / 1e3) + result.expires_in,
|
|
321
|
+
scope: result.scope,
|
|
322
|
+
idToken: result.id_token,
|
|
323
|
+
refreshToken: result.refresh_token,
|
|
324
|
+
claims
|
|
325
|
+
}),
|
|
326
|
+
hosted: () => Promise.reject(new SynqError("redirect_to_web", "Only the hosted page can go on: send the browser to an authorization request with this result's method and loginHint"))
|
|
327
|
+
});
|
|
328
|
+
return {
|
|
329
|
+
start: ({ endUserIp, ...rest } = {}) => new SignInFlow(port(endUserIp), newSignIn(rest.scope ?? "openid", rest)),
|
|
330
|
+
resume: (saved, { endUserIp } = {}) => new SignInFlow(port(endUserIp), readSavedSignIn(saved))
|
|
331
|
+
};
|
|
332
|
+
}
|
|
196
333
|
//#endregion
|
|
197
|
-
export { SynqError, createAccessTokenVerifier, createManagementClient, isSynqError, verifyWebhook };
|
|
334
|
+
export { DEFAULT_MAX_RETRIES, SignInFlow, SynqError, createAccessTokenVerifier, createApiKeyVerifier, createManagementClient, createOwnUiSignIn, isApiKey, isSynqError, verifyWebhook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synqauth/js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Synq's framework-agnostic client: sign-in and sessions in the browser, the account API, and token and webhook verification on your server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/dist/api-Cf9K1Rc9.js
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { AuthorizationResponseError, OperationProcessingError, ResponseBodyError, WWWAuthenticateChallengeError } from "oauth4webapi";
|
|
2
|
-
//#region src/errors.ts
|
|
3
|
-
/**
|
|
4
|
-
* Every error the SDK throws. Branch on `code`, which is stable: the API's
|
|
5
|
-
* problem `code`, OAuth's `error`, or one of the SDK's own; never on the
|
|
6
|
-
* message.
|
|
7
|
-
*/
|
|
8
|
-
var SynqError = class extends Error {
|
|
9
|
-
name = "SynqError";
|
|
10
|
-
code;
|
|
11
|
-
/** The HTTP status Synq answered with, when it answered. */
|
|
12
|
-
status;
|
|
13
|
-
/** What Synq said about it: OAuth's `error_description` or a problem's `detail`. */
|
|
14
|
-
description;
|
|
15
|
-
/**
|
|
16
|
-
* How many seconds to wait before trying again, from Synq's `Retry-After`:
|
|
17
|
-
* set on `rate_limited` (429), and wherever else Synq sends one.
|
|
18
|
-
*/
|
|
19
|
-
retryAfter;
|
|
20
|
-
constructor(code, message, options = {}) {
|
|
21
|
-
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
22
|
-
this.code = code;
|
|
23
|
-
this.status = options.status;
|
|
24
|
-
this.description = options.description;
|
|
25
|
-
this.retryAfter = options.retryAfter;
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
/** Whether `error` is a `SynqError`, with `code` when given. */
|
|
29
|
-
function isSynqError(error, code) {
|
|
30
|
-
return error instanceof SynqError && (code === void 0 || error.code === code);
|
|
31
|
-
}
|
|
32
|
-
/** A `SynqError` for a problem document a Synq API answered with. */
|
|
33
|
-
function problemError(status, problem, retryAfter) {
|
|
34
|
-
const code = typeof problem.code === "string" ? problem.code : `http_${status}`;
|
|
35
|
-
const description = typeof problem.detail === "string" ? problem.detail : void 0;
|
|
36
|
-
return new SynqError(code, description ?? problem.title ?? `Synq answered ${status}`, {
|
|
37
|
-
status,
|
|
38
|
-
description,
|
|
39
|
-
retryAfter,
|
|
40
|
-
cause: problem
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* The seconds a `Retry-After` header asks for: a number of seconds, or an
|
|
45
|
-
* HTTP date, counted from `now`. Undefined when there is none, or it can't
|
|
46
|
-
* be read.
|
|
47
|
-
*/
|
|
48
|
-
function retryAfterSeconds(header, now = Date.now()) {
|
|
49
|
-
const value = header?.trim();
|
|
50
|
-
if (!value) return void 0;
|
|
51
|
-
if (/^[0-9]+$/.test(value)) return Number(value);
|
|
52
|
-
if (!/[A-Za-z]/.test(value)) return void 0;
|
|
53
|
-
const at = Date.parse(value);
|
|
54
|
-
return Number.isNaN(at) ? void 0 : Math.max(0, Math.ceil((at - now) / 1e3));
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* A `SynqError` for whatever went wrong talking to Synq's issuer: OAuth's
|
|
58
|
-
* error responses keep their `error`, a failed check of Synq's answer is
|
|
59
|
-
* `invalid_response`, and a request that never got an answer is
|
|
60
|
-
* `network_error`.
|
|
61
|
-
*/
|
|
62
|
-
function toSynqError(error) {
|
|
63
|
-
if (error instanceof SynqError) return error;
|
|
64
|
-
if (error instanceof ResponseBodyError) return new SynqError(error.error, error.error_description ?? error.error, {
|
|
65
|
-
status: error.status,
|
|
66
|
-
description: error.error_description,
|
|
67
|
-
cause: error
|
|
68
|
-
});
|
|
69
|
-
if (error instanceof AuthorizationResponseError) return new SynqError(error.error, error.error_description ?? error.error, {
|
|
70
|
-
description: error.error_description,
|
|
71
|
-
cause: error
|
|
72
|
-
});
|
|
73
|
-
if (error instanceof WWWAuthenticateChallengeError) {
|
|
74
|
-
const challenge = error.cause[0];
|
|
75
|
-
const code = challenge?.parameters.error ?? "invalid_token";
|
|
76
|
-
const description = challenge?.parameters.error_description;
|
|
77
|
-
return new SynqError(code, description ?? code, {
|
|
78
|
-
status: error.status,
|
|
79
|
-
description,
|
|
80
|
-
cause: error
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
if (error instanceof OperationProcessingError) return new SynqError("invalid_response", error.message, { cause: error });
|
|
84
|
-
if (error instanceof TypeError) return new SynqError("network_error", `Synq couldn't be reached: ${error.message}`, { cause: error });
|
|
85
|
-
return new SynqError("invalid_response", error instanceof Error ? error.message : "Synq answered with something unexpected", { cause: error });
|
|
86
|
-
}
|
|
87
|
-
//#endregion
|
|
88
|
-
//#region src/urls.ts
|
|
89
|
-
/** Whether a host is this machine: a Synq stack under `localhost`, or a loopback address. */
|
|
90
|
-
function isLoopback(hostname) {
|
|
91
|
-
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* `value` as a URL, which must be https: Synq's hosts always are. Plain http
|
|
95
|
-
* only on this machine, for a local Synq stack. `what` names it in the error.
|
|
96
|
-
*/
|
|
97
|
-
function secureUrl(value, what) {
|
|
98
|
-
let url;
|
|
99
|
-
try {
|
|
100
|
-
url = new URL(value);
|
|
101
|
-
} catch {
|
|
102
|
-
throw new SynqError("invalid_options", `${what} isn't a URL: ${String(value)}`);
|
|
103
|
-
}
|
|
104
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) throw new SynqError("invalid_options", `${what} must be https: (http: only for a Synq stack on this machine, under 'localhost')`);
|
|
105
|
-
return url;
|
|
106
|
-
}
|
|
107
|
-
//#endregion
|
|
108
|
-
//#region src/api.ts
|
|
109
|
-
function createApiClient(options) {
|
|
110
|
-
const base = secureUrl(options.baseUrl, "The API base URL").href.replace(/\/+$/, "");
|
|
111
|
-
const call = (method) => ((path, request = {}) => send(method, path, request));
|
|
112
|
-
async function send(method, path, request) {
|
|
113
|
-
const url = new URL(base + fill(path, request.path));
|
|
114
|
-
for (const [name, value] of Object.entries(request.query ?? {})) {
|
|
115
|
-
if (value === void 0) continue;
|
|
116
|
-
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(name, String(item));
|
|
117
|
-
}
|
|
118
|
-
const headers = new Headers({
|
|
119
|
-
accept: "application/json, application/problem+json",
|
|
120
|
-
authorization: `Bearer ${await options.credential()}`
|
|
121
|
-
});
|
|
122
|
-
if (request.body !== void 0) headers.set("content-type", "application/json");
|
|
123
|
-
if (request.idempotencyKey) headers.set("idempotency-key", request.idempotencyKey);
|
|
124
|
-
let response;
|
|
125
|
-
try {
|
|
126
|
-
response = await (options.fetch ?? fetch)(url, {
|
|
127
|
-
method: method.toUpperCase(),
|
|
128
|
-
headers,
|
|
129
|
-
body: request.body === void 0 ? void 0 : JSON.stringify(request.body),
|
|
130
|
-
signal: request.signal
|
|
131
|
-
});
|
|
132
|
-
} catch (error) {
|
|
133
|
-
throw new SynqError("network_error", `Synq couldn't be reached: ${String(error)}`, { cause: error });
|
|
134
|
-
}
|
|
135
|
-
const text = await response.text();
|
|
136
|
-
let json;
|
|
137
|
-
try {
|
|
138
|
-
json = text ? JSON.parse(text) : void 0;
|
|
139
|
-
} catch {
|
|
140
|
-
json = void 0;
|
|
141
|
-
}
|
|
142
|
-
if (!response.ok) throw problemError(response.status, json !== null && typeof json === "object" ? json : {}, retryAfterSeconds(response.headers.get("retry-after")));
|
|
143
|
-
return json;
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
get: call("get"),
|
|
147
|
-
post: call("post"),
|
|
148
|
-
put: call("put"),
|
|
149
|
-
patch: call("patch"),
|
|
150
|
-
delete: call("delete")
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
/** The path with each `{name}` replaced by its parameter, encoded. */
|
|
154
|
-
function fill(path, params = {}) {
|
|
155
|
-
return path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
156
|
-
const value = params[name];
|
|
157
|
-
if (value === void 0 || value === "") throw new SynqError("invalid_options", `The path ${path} needs ${name}`);
|
|
158
|
-
if (/^\.+$/.test(String(value))) throw new SynqError("invalid_options", `${name} can't be ${String(value)}`);
|
|
159
|
-
return encodeURIComponent(String(value));
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
//#endregion
|
|
163
|
-
export { problemError as a, isSynqError as i, secureUrl as n, toSynqError as o, SynqError as r, createApiClient as t };
|