@synqauth/js 0.0.0 → 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/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-y9-9AIc-.js";
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 } from "./management-CWdCiiEv.js";
2
2
  import { JWTPayload, JWTVerifyGetKey } from "jose";
3
3
  //#region src/api.d.ts
4
4
  type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
@@ -129,6 +129,61 @@ export type AccessTokenVerifier = (token: string, options?: VerifyAccessTokenOpt
129
129
  * request. The issuer and its JWKS must be https (http only on this machine).
130
130
  */
131
131
  export declare function createAccessTokenVerifier(options: AccessTokenVerifierOptions): AccessTokenVerifier;
132
+ /**
133
+ * Whether `credential` is one of your users' API keys (`ak_live_…` or
134
+ * `ak_test_…`, AERO-1693) rather than an access token: what tells an API
135
+ * which of `createApiKeyVerifier` and `createAccessTokenVerifier` to check
136
+ * a bearer credential with.
137
+ */
138
+ export declare function isApiKey(credential: unknown): credential is string;
139
+ /** What an API checks its users' API keys with: the brand's issuer, and the API's own app. */
140
+ export interface ApiKeyVerifierOptions {
141
+ /** The brand's issuer URL, as the dashboard shows it. */
142
+ issuer: string;
143
+ /**
144
+ * The API's own app (its `client_id` in Synq), the one linked to the API:
145
+ * only it may check the API's keys.
146
+ */
147
+ clientId: string;
148
+ clientSecret: string;
149
+ fetch?: typeof fetch;
150
+ }
151
+ /** What one route wants of a key, on top of the verifier's checks. */
152
+ export interface VerifyApiKeyOptions {
153
+ /** Scopes the key must carry, every one. */
154
+ scopes?: readonly string[];
155
+ }
156
+ /** One of your users' API keys, once Synq said it works. */
157
+ export interface ApiKeyClaims {
158
+ readonly iss: string;
159
+ /** The person who made it (`usr_…`), as in their access tokens' `sub`. */
160
+ readonly sub: string;
161
+ /** Your API's identifier: the API it is for. */
162
+ readonly aud: string;
163
+ readonly scopes: readonly string[];
164
+ /** The key's id (`uak_…`), never the key. */
165
+ readonly keyId: string;
166
+ /** The name the person gave it. */
167
+ readonly name: string;
168
+ /** When it was made, in seconds. */
169
+ readonly iat: number;
170
+ /** When it stops working, in seconds; undefined for a key that doesn't expire. */
171
+ readonly exp: number | undefined;
172
+ }
173
+ /**
174
+ * Checks one of your users' API keys, as `createApiKeyVerifier` makes it:
175
+ * the claims when the key works, else a `SynqError` (`invalid_token` for a
176
+ * key that doesn't, `insufficient_scope` for one without a scope asked for).
177
+ */
178
+ export type ApiKeyVerifier = (key: string, options?: VerifyApiKeyOptions) => Promise<ApiKeyClaims>;
179
+ /**
180
+ * A verifier for the API keys your users make for your API (AERO-1693). It
181
+ * asks the brand's issuer about every key (`POST /api-keys/verify`, as the
182
+ * API's own app), and keeps nothing: revoking a key works from its next
183
+ * check. A key Synq says doesn't work is `invalid_token` (401), never why;
184
+ * your app's credentials refused are `invalid_client`.
185
+ */
186
+ export declare function createApiKeyVerifier(options: ApiKeyVerifierOptions): ApiKeyVerifier;
132
187
  /** A Synq webhook's event: one row of the org's audit log (the management API's `WebhookEvent`). */
133
188
  export type WebhookEvent = components['schemas']['WebhookEvent'];
134
189
  /** A delivery that passed its check. */
@@ -171,5 +226,56 @@ export type ManagementClient = ApiClient<paths>;
171
226
  * with the problem's `code`, such as `insufficient_permissions`.
172
227
  */
173
228
  export declare function createManagementClient(options: ManagementClientOptions): ManagementClient;
229
+ /** What a server needs to sign people in on its app's own screens (AERO-1411). */
230
+ export interface OwnUiSignInOptions {
231
+ /** The brand's issuer URL, as the dashboard shows it. */
232
+ issuer: string;
233
+ /** The app's client_id: one of the brand's own web apps, with own-UI sign-in on. */
234
+ clientId: string;
235
+ /** The app's secret, sent with HTTP Basic, as at the token endpoint. */
236
+ clientSecret?: string;
237
+ /** Or the key it signs its client assertions with (`private_key_jwt`). */
238
+ privateKey?: CryptoKey | {
239
+ key: CryptoKey;
240
+ kid?: string;
241
+ };
242
+ fetch?: typeof fetch;
243
+ }
244
+ /** What a server's own-UI sign-in ends with: the app keeps them as its session. */
245
+ export interface OwnUiTokens {
246
+ readonly accessToken: string;
247
+ readonly tokenType: string;
248
+ /** When the access token expires, in seconds since the epoch, when Synq said. */
249
+ readonly expiresAt: number | undefined;
250
+ readonly scope: string | undefined;
251
+ readonly idToken: string;
252
+ readonly refreshToken: string | undefined;
253
+ /** The ID token's claims, validated: who signed in, and how. */
254
+ readonly claims: IdTokenClaims;
255
+ }
256
+ /** A server's own-UI sign-ins: one flow per sign-in, kept between requests with `toJSON`. */
257
+ export interface OwnUiSignIn {
258
+ /**
259
+ * Starts a sign-in. `endUserIp` is the address the person is at, as your
260
+ * server saw it: Synq counts per-address limits for your app with it, and
261
+ * without it counts none.
262
+ */
263
+ start(options?: StartSignInOptions & {
264
+ endUserIp?: string;
265
+ }): SignInFlow<OwnUiTokens>;
266
+ /** Picks up the flow `toJSON()` saved, for the next request of the same sign-in. */
267
+ resume(saved: SavedSignIn | string, options?: {
268
+ endUserIp?: string;
269
+ }): SignInFlow<OwnUiTokens>;
270
+ }
271
+ /**
272
+ * Own-UI sign-in for a server (AERO-1411): its forms post to it, it calls
273
+ * Synq's Authorization Challenge Endpoint as its app, and a sign-in ends
274
+ * with tokens, not a cookie at Synq. The app keeps them, and the person's
275
+ * password or code never reaches a browser but the form they typed it in.
276
+ * `hosted` results can't `continue()` here: send the browser to an
277
+ * ordinary authorization request with the `method` and `loginHint` given.
278
+ */
279
+ export declare function createOwnUiSignIn(options: OwnUiSignInOptions): OwnUiSignIn;
174
280
  //#endregion
175
- export { type components as ManagementComponents, type paths as ManagementPaths, SynqError, isSynqError };
281
+ export { 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 SynqError, t as createApiClient } from "./api-Cf9K1Rc9.js";
1
+ import { a as readSavedSignIn, i as newSignIn, n as secureUrl, o as SynqError, r as SignInFlow, s as isSynqError, t as createApiClient } from "./api-CeYAjkvX.js";
2
+ import * as oauth from "oauth4webapi";
2
3
  import { createRemoteJWKSet, customFetch, errors, jwtVerify } from "jose";
3
4
  //#region src/server.ts
4
5
  /**
@@ -123,6 +124,74 @@ function tokenError(error) {
123
124
  });
124
125
  }
125
126
  /**
127
+ * Whether `credential` is one of your users' API keys (`ak_live_…` or
128
+ * `ak_test_…`, AERO-1693) rather than an access token: what tells an API
129
+ * which of `createApiKeyVerifier` and `createAccessTokenVerifier` to check
130
+ * a bearer credential with.
131
+ */
132
+ function isApiKey(credential) {
133
+ return typeof credential === "string" && /^ak_(live|test)_[A-Za-z0-9_-]{43}$/.test(credential);
134
+ }
135
+ /**
136
+ * A verifier for the API keys your users make for your API (AERO-1693). It
137
+ * asks the brand's issuer about every key (`POST /api-keys/verify`, as the
138
+ * API's own app), and keeps nothing: revoking a key works from its next
139
+ * check. A key Synq says doesn't work is `invalid_token` (401), never why;
140
+ * your app's credentials refused are `invalid_client`.
141
+ */
142
+ function createApiKeyVerifier(options) {
143
+ const issuer = secureUrl(options.issuer, "The issuer").href.replace(/\/+$/, "");
144
+ if (!options.clientId || !options.clientSecret) throw new SynqError("invalid_options", "Give the API key verifier your API's own app: its clientId and clientSecret");
145
+ const form = (value) => encodeURIComponent(value).replace(/%20/g, "+");
146
+ const basic = btoa(`${form(options.clientId)}:${form(options.clientSecret)}`);
147
+ const fetcher = options.fetch ?? fetch;
148
+ return async (key, verify = {}) => {
149
+ if (!isApiKey(key)) throw new SynqError("invalid_token", "The API key is not valid for this API", { status: 401 });
150
+ let response;
151
+ try {
152
+ response = await fetcher(`${issuer}/api-keys/verify`, {
153
+ method: "POST",
154
+ headers: {
155
+ authorization: `Basic ${basic}`,
156
+ "content-type": "application/x-www-form-urlencoded"
157
+ },
158
+ body: new URLSearchParams({ token: key }).toString()
159
+ });
160
+ } catch (error) {
161
+ throw new SynqError("network_error", "Synq couldn't be reached to check the API key", {
162
+ status: 503,
163
+ cause: error
164
+ });
165
+ }
166
+ const body = await response.json().catch(() => void 0);
167
+ if (!response.ok) {
168
+ const error = typeof body?.error === "string" ? body.error : void 0;
169
+ const retryAfter = Number(response.headers.get("retry-after"));
170
+ throw new SynqError(error === "invalid_client" ? "invalid_client" : response.status === 429 ? "rate_limited" : "server_error", `Synq refused to check the API key (${response.status})`, {
171
+ status: response.status,
172
+ description: typeof body?.error_description === "string" ? body.error_description : void 0,
173
+ retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0
174
+ });
175
+ }
176
+ if (body?.active !== true) throw new SynqError("invalid_token", "The API key is not valid for this API", { status: 401 });
177
+ const string = (value) => typeof value === "string" ? value : "";
178
+ const claims = {
179
+ iss: string(body.iss),
180
+ sub: string(body.sub),
181
+ aud: string(body.aud),
182
+ scopes: string(body.scope).split(" ").filter(Boolean),
183
+ keyId: string(body.key_id),
184
+ name: string(body.name),
185
+ iat: typeof body.iat === "number" ? body.iat : 0,
186
+ exp: typeof body.exp === "number" ? body.exp : void 0
187
+ };
188
+ if (claims.iss !== issuer) throw new SynqError("invalid_response", "Synq's answer names another issuer", { status: 502 });
189
+ const missing = (verify.scopes ?? []).filter((scope) => !claims.scopes.includes(scope));
190
+ if (missing.length > 0) throw new SynqError("insufficient_scope", `The API key lacks ${missing.join(", ")}`, { status: 403 });
191
+ return claims;
192
+ };
193
+ }
194
+ /**
126
195
  * Checks a delivery's Standard Webhooks signature (`webhook-id`,
127
196
  * `webhook-timestamp`, `webhook-signature`, HMAC-SHA256 over
128
197
  * `<id>.<timestamp>.<body>`) against the subscription's `whsec_` secret, and
@@ -193,5 +262,56 @@ function createManagementClient(options) {
193
262
  fetch: options.fetch
194
263
  });
195
264
  }
265
+ /**
266
+ * Own-UI sign-in for a server (AERO-1411): its forms post to it, it calls
267
+ * Synq's Authorization Challenge Endpoint as its app, and a sign-in ends
268
+ * with tokens, not a cookie at Synq. The app keeps them, and the person's
269
+ * password or code never reaches a browser but the form they typed it in.
270
+ * `hosted` results can't `continue()` here: send the browser to an
271
+ * ordinary authorization request with the `method` and `loginHint` given.
272
+ */
273
+ function createOwnUiSignIn(options) {
274
+ const issuer = secureUrl(options.issuer, "The issuer");
275
+ if (!options.clientId) throw new SynqError("invalid_options", "The clientId is missing");
276
+ if (!options.clientSecret === !options.privateKey) throw new SynqError("invalid_options", "Give createOwnUiSignIn a clientSecret or a privateKey");
277
+ const auth = options.clientSecret ? oauth.ClientSecretBasic(options.clientSecret) : oauth.PrivateKeyJwt(options.privateKey);
278
+ const http = {};
279
+ if (options.fetch) http[oauth.customFetch] = options.fetch;
280
+ if (issuer.protocol === "http:") http[oauth.allowInsecureRequests] = true;
281
+ let server;
282
+ const discover = () => server ??= (async () => {
283
+ try {
284
+ const response = await oauth.discoveryRequest(issuer, {
285
+ ...http,
286
+ algorithm: "oidc"
287
+ });
288
+ return await oauth.processDiscoveryResponse(issuer, response);
289
+ } catch (error) {
290
+ server = void 0;
291
+ throw new SynqError("discovery_failed", `Synq's discovery document at ${issuer.href} couldn't be read`, { cause: error });
292
+ }
293
+ })();
294
+ const port = (endUserIp) => ({
295
+ server: discover,
296
+ client: { client_id: options.clientId },
297
+ auth,
298
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
299
+ ...endUserIp ? { headers: { "synq-end-user-ip": endUserIp } } : {},
300
+ finish: (result, claims) => Promise.resolve({
301
+ accessToken: result.access_token,
302
+ tokenType: result.token_type,
303
+ expiresAt: result.expires_in === void 0 ? void 0 : Math.floor(Date.now() / 1e3) + result.expires_in,
304
+ scope: result.scope,
305
+ idToken: result.id_token,
306
+ refreshToken: result.refresh_token,
307
+ claims
308
+ }),
309
+ 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"))
310
+ });
311
+ return {
312
+ start: ({ endUserIp, ...rest } = {}) => new SignInFlow(port(endUserIp), newSignIn(rest.scope ?? "openid", rest)),
313
+ resume: (saved, { endUserIp } = {}) => new SignInFlow(port(endUserIp), readSavedSignIn(saved))
314
+ };
315
+ }
196
316
  //#endregion
197
- export { SynqError, createAccessTokenVerifier, createManagementClient, isSynqError, verifyWebhook };
317
+ export { 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.0.0",
3
+ "version": "0.2.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": {
@@ -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 };