@synqauth/js 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 +39 -2
- package/dist/api-CeYAjkvX.js +467 -0
- package/dist/index.d.ts +2215 -0
- package/dist/index.js +690 -0
- package/dist/management-CWdCiiEv.d.ts +11856 -0
- package/dist/server.d.ts +281 -0
- package/dist/server.js +317 -0
- package/package.json +46 -3
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
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
|
+
import { JWTPayload, JWTVerifyGetKey } from "jose";
|
|
3
|
+
//#region src/api.d.ts
|
|
4
|
+
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
5
|
+
/** The paths of a contract that answer `M`. */
|
|
6
|
+
type PathsWith<Paths, M extends HttpMethod> = { [P in keyof Paths]: Paths[P] extends { readonly [K in M]: infer O; } ? [O] extends [never] ? never : P : never; }[keyof Paths] & string;
|
|
7
|
+
type Operation<Paths, P extends keyof Paths, M extends HttpMethod> = Paths[P] extends { readonly [K in M]: infer O; } ? O : never;
|
|
8
|
+
type PathParams<O> = O extends {
|
|
9
|
+
readonly parameters: {
|
|
10
|
+
readonly path: infer X;
|
|
11
|
+
};
|
|
12
|
+
} ? X : never;
|
|
13
|
+
type QueryParams<O> = O extends {
|
|
14
|
+
readonly parameters: {
|
|
15
|
+
readonly query?: infer X;
|
|
16
|
+
};
|
|
17
|
+
} ? Exclude<X, undefined> : never;
|
|
18
|
+
type JsonBody<O> = O extends {
|
|
19
|
+
readonly requestBody: {
|
|
20
|
+
readonly content: {
|
|
21
|
+
readonly 'application/json': infer B;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
} ? B : never;
|
|
25
|
+
type OptionalJsonBody<O> = O extends {
|
|
26
|
+
readonly requestBody?: {
|
|
27
|
+
readonly content: {
|
|
28
|
+
readonly 'application/json': infer B;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
} ? B : never;
|
|
32
|
+
/** What a successful call answers: the JSON of its 2xx responses, or nothing for a 204. */
|
|
33
|
+
type Success<O> = O extends {
|
|
34
|
+
readonly responses: infer R;
|
|
35
|
+
} ? { [S in keyof R]: S extends 200 | 201 | 202 | 203 ? R[S] extends {
|
|
36
|
+
readonly content: {
|
|
37
|
+
readonly 'application/json': infer J;
|
|
38
|
+
};
|
|
39
|
+
} ? J : never : S extends 204 ? undefined : never; }[keyof R] : never;
|
|
40
|
+
type Common = {
|
|
41
|
+
/** Sent as `Idempotency-Key`: a POST with the same key runs once (the management API keeps the answer 24 hours). */
|
|
42
|
+
idempotencyKey?: string;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
};
|
|
45
|
+
/** The options of one call: its path parameters, query and body, as the contract has them. */
|
|
46
|
+
type RequestOptions<O> = ([PathParams<O>] extends [never] ? {
|
|
47
|
+
path?: undefined;
|
|
48
|
+
} : {
|
|
49
|
+
path: PathParams<O>;
|
|
50
|
+
}) & ([QueryParams<O>] extends [never] ? {
|
|
51
|
+
query?: undefined;
|
|
52
|
+
} : {
|
|
53
|
+
query?: QueryParams<O>;
|
|
54
|
+
}) & ([JsonBody<O>] extends [never] ? [OptionalJsonBody<O>] extends [never] ? {
|
|
55
|
+
body?: undefined;
|
|
56
|
+
} : {
|
|
57
|
+
body?: OptionalJsonBody<O>;
|
|
58
|
+
} : {
|
|
59
|
+
body: JsonBody<O>;
|
|
60
|
+
}) & Common;
|
|
61
|
+
type Args<O> = object extends RequestOptions<O> ? [options?: RequestOptions<O>] : [options: RequestOptions<O>];
|
|
62
|
+
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>>>;
|
|
63
|
+
/** A client for one Synq contract: one method per HTTP method, each typed by the contract. */
|
|
64
|
+
interface ApiClient<Paths> {
|
|
65
|
+
get: Call<Paths, 'get'>;
|
|
66
|
+
post: Call<Paths, 'post'>;
|
|
67
|
+
put: Call<Paths, 'put'>;
|
|
68
|
+
patch: Call<Paths, 'patch'>;
|
|
69
|
+
delete: Call<Paths, 'delete'>;
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/server.d.ts
|
|
73
|
+
/** What an API checks every access token against. */
|
|
74
|
+
export interface AccessTokenVerifierOptions {
|
|
75
|
+
/** The brand's issuer URL, as the dashboard shows it. */
|
|
76
|
+
issuer: string;
|
|
77
|
+
/** This API's identifier, as registered with Synq: the token's `aud`. */
|
|
78
|
+
audience: string;
|
|
79
|
+
/** The algorithms accepted: Synq signs API tokens with ES256. */
|
|
80
|
+
algorithms?: string[];
|
|
81
|
+
/** Seconds of clock difference forgiven on `exp`, `iat` and `nbf`: 5 by default. */
|
|
82
|
+
clockToleranceSeconds?: number;
|
|
83
|
+
fetch?: typeof fetch;
|
|
84
|
+
/** The keys to check with, instead of fetching the issuer's JWKS (for tests). */
|
|
85
|
+
keys?: JWTVerifyGetKey;
|
|
86
|
+
}
|
|
87
|
+
/** What one route wants of a token, on top of the verifier's checks. */
|
|
88
|
+
export interface VerifyAccessTokenOptions {
|
|
89
|
+
/** Scopes the token must carry, every one. */
|
|
90
|
+
scopes?: readonly string[];
|
|
91
|
+
/** The apps (client_ids) whose tokens this route takes (`azp`). */
|
|
92
|
+
authorizedParties?: readonly string[];
|
|
93
|
+
/** The longest ago, in seconds, the person may have signed in (`auth_time`): for sensitive actions. */
|
|
94
|
+
maxAuthAgeSeconds?: number;
|
|
95
|
+
}
|
|
96
|
+
/** A Synq access token's claims (RFC 9068), once checked. */
|
|
97
|
+
export interface AccessTokenClaims {
|
|
98
|
+
readonly iss: string;
|
|
99
|
+
/** The person (`usr_…`), or the machine app's client_id for client credentials. */
|
|
100
|
+
readonly sub: string;
|
|
101
|
+
readonly aud: string | string[];
|
|
102
|
+
/** The app the token was issued to. */
|
|
103
|
+
readonly azp: string | undefined;
|
|
104
|
+
readonly scopes: readonly string[];
|
|
105
|
+
readonly iat: number;
|
|
106
|
+
readonly exp: number;
|
|
107
|
+
readonly auth_time: number | undefined;
|
|
108
|
+
readonly acr: string | undefined;
|
|
109
|
+
readonly amr: readonly string[] | undefined;
|
|
110
|
+
/** An agent acting for the person (device flow): its client_id. */
|
|
111
|
+
readonly act: {
|
|
112
|
+
readonly type?: string;
|
|
113
|
+
readonly client_id?: string;
|
|
114
|
+
} | undefined;
|
|
115
|
+
/** Every claim, as the token carries it. */
|
|
116
|
+
readonly payload: JWTPayload;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Checks one access token for your API, as `createAccessTokenVerifier` makes
|
|
120
|
+
* it: the claims when the token is good, else a `SynqError`
|
|
121
|
+
* (`invalid_token`, `insufficient_scope` or `fresh_sign_in_required`).
|
|
122
|
+
*/
|
|
123
|
+
export type AccessTokenVerifier = (token: string, options?: VerifyAccessTokenOptions) => Promise<AccessTokenClaims>;
|
|
124
|
+
/**
|
|
125
|
+
* A verifier for one API's access tokens. It reads the issuer's JWKS once
|
|
126
|
+
* and keeps it, fetching it again when a token names a key it doesn't have
|
|
127
|
+
* (a rotation), at most every 30 seconds. A discovery that failed is retried
|
|
128
|
+
* after 30 seconds, not on every token. Keep one per API, not one per
|
|
129
|
+
* request. The issuer and its JWKS must be https (http only on this machine).
|
|
130
|
+
*/
|
|
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;
|
|
187
|
+
/** A Synq webhook's event: one row of the org's audit log (the management API's `WebhookEvent`). */
|
|
188
|
+
export type WebhookEvent = components['schemas']['WebhookEvent'];
|
|
189
|
+
/** A delivery that passed its check. */
|
|
190
|
+
export interface VerifiedWebhook {
|
|
191
|
+
/** The delivery's `webhook-id`: the same on every retry, so a receiver drops one it has seen. */
|
|
192
|
+
readonly id: string;
|
|
193
|
+
/** When Synq signed it. */
|
|
194
|
+
readonly timestamp: Date;
|
|
195
|
+
readonly event: WebhookEvent;
|
|
196
|
+
}
|
|
197
|
+
type HeadersLike = Headers | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
198
|
+
/**
|
|
199
|
+
* Checks a delivery's Standard Webhooks signature (`webhook-id`,
|
|
200
|
+
* `webhook-timestamp`, `webhook-signature`, HMAC-SHA256 over
|
|
201
|
+
* `<id>.<timestamp>.<body>`) against the subscription's `whsec_` secret, and
|
|
202
|
+
* that Synq signed it within `toleranceSeconds` (5 minutes by default), so a
|
|
203
|
+
* captured delivery can't be replayed later. Pass both secrets while one is
|
|
204
|
+
* being rotated: Synq signs with both for 24 hours. `body` is the request's
|
|
205
|
+
* raw body, exactly as it arrived.
|
|
206
|
+
*/
|
|
207
|
+
export declare function verifyWebhook(body: string, headers: HeadersLike, secrets: string | readonly string[], options?: {
|
|
208
|
+
toleranceSeconds?: number;
|
|
209
|
+
now?: Date;
|
|
210
|
+
}): Promise<VerifiedWebhook>;
|
|
211
|
+
/** Who a management API client acts as. */
|
|
212
|
+
export interface ManagementClientOptions {
|
|
213
|
+
/** The management API's origin, as the dashboard shows it. */
|
|
214
|
+
baseUrl: string;
|
|
215
|
+
/** An org API key (`sk_live_…` or `sk_test_…`): it acts in its own org with the permissions it was given. */
|
|
216
|
+
apiKey?: string;
|
|
217
|
+
/** Or a Synq access token, acting as that person: a function so it can be refreshed. */
|
|
218
|
+
accessToken?: () => string | Promise<string>;
|
|
219
|
+
fetch?: typeof fetch;
|
|
220
|
+
}
|
|
221
|
+
/** The management API's typed client: every path, parameter, body and answer from its contract. */
|
|
222
|
+
export type ManagementClient = ApiClient<paths>;
|
|
223
|
+
/**
|
|
224
|
+
* A client for Synq's management API (`/v1`), typed by its contract. Server
|
|
225
|
+
* only: an API key never reaches a browser. Failures throw a `SynqError`
|
|
226
|
+
* with the problem's `code`, such as `insufficient_permissions`.
|
|
227
|
+
*/
|
|
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;
|
|
280
|
+
//#endregion
|
|
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
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
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";
|
|
3
|
+
import { createRemoteJWKSet, customFetch, errors, jwtVerify } from "jose";
|
|
4
|
+
//#region src/server.ts
|
|
5
|
+
/**
|
|
6
|
+
* A verifier for one API's access tokens. It reads the issuer's JWKS once
|
|
7
|
+
* and keeps it, fetching it again when a token names a key it doesn't have
|
|
8
|
+
* (a rotation), at most every 30 seconds. A discovery that failed is retried
|
|
9
|
+
* after 30 seconds, not on every token. Keep one per API, not one per
|
|
10
|
+
* request. The issuer and its JWKS must be https (http only on this machine).
|
|
11
|
+
*/
|
|
12
|
+
function createAccessTokenVerifier(options) {
|
|
13
|
+
const issuer = secureUrl(options.issuer, "The issuer").href.replace(/\/+$/, "");
|
|
14
|
+
let keys = options.keys ? Promise.resolve(options.keys) : void 0;
|
|
15
|
+
/** When discovery last failed: it isn't asked again for DISCOVERY_RETRY_MS. */
|
|
16
|
+
let failedAt = 0;
|
|
17
|
+
const getKeys = () => {
|
|
18
|
+
if (!keys && Date.now() - failedAt < DISCOVERY_RETRY_MS) return Promise.reject(new SynqError("discovery_failed", `Synq's discovery document at ${issuer} couldn't be read`, { description: "It failed moments ago; it is asked again 30 seconds after." }));
|
|
19
|
+
keys ??= discoverKeys(issuer, options.fetch).catch((error) => {
|
|
20
|
+
keys = void 0;
|
|
21
|
+
failedAt = Date.now();
|
|
22
|
+
throw error;
|
|
23
|
+
});
|
|
24
|
+
return keys;
|
|
25
|
+
};
|
|
26
|
+
return async (token, verify = {}) => {
|
|
27
|
+
let payload;
|
|
28
|
+
try {
|
|
29
|
+
({payload} = await jwtVerify(token, await getKeys(), {
|
|
30
|
+
issuer,
|
|
31
|
+
audience: options.audience,
|
|
32
|
+
algorithms: options.algorithms ?? ["ES256"],
|
|
33
|
+
typ: "at+jwt",
|
|
34
|
+
clockTolerance: options.clockToleranceSeconds ?? 5,
|
|
35
|
+
requiredClaims: [
|
|
36
|
+
"sub",
|
|
37
|
+
"iat",
|
|
38
|
+
"exp"
|
|
39
|
+
]
|
|
40
|
+
}));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw tokenError(error);
|
|
43
|
+
}
|
|
44
|
+
const claims = accessTokenClaims(payload);
|
|
45
|
+
if (verify.authorizedParties && !verify.authorizedParties.includes(claims.azp ?? "")) throw new SynqError("invalid_token", "The token was issued to an app this route takes none from", { status: 401 });
|
|
46
|
+
const missing = (verify.scopes ?? []).filter((scope) => !claims.scopes.includes(scope));
|
|
47
|
+
if (missing.length > 0) throw new SynqError("insufficient_scope", `The token lacks ${missing.join(" ")}`, {
|
|
48
|
+
status: 403,
|
|
49
|
+
description: missing.join(" ")
|
|
50
|
+
});
|
|
51
|
+
if (verify.maxAuthAgeSeconds !== void 0) {
|
|
52
|
+
const signedInAt = provenSignInTime(claims);
|
|
53
|
+
if ((signedInAt === void 0 ? Infinity : Date.now() / 1e3 - signedInAt) > verify.maxAuthAgeSeconds) throw new SynqError("fresh_sign_in_required", `This wants a sign-in from the last ${verify.maxAuthAgeSeconds} seconds: sign in again with max_age`, { status: 403 });
|
|
54
|
+
}
|
|
55
|
+
return claims;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** How long a failed discovery is remembered before it is asked again. */
|
|
59
|
+
const DISCOVERY_RETRY_MS = 3e4;
|
|
60
|
+
/**
|
|
61
|
+
* When the person signed in, for `maxAuthAgeSeconds`: the token's
|
|
62
|
+
* `auth_time`, or `undefined` when it has none, which fails a recency check
|
|
63
|
+
* closed. Synq itself goes further on its own routes: a sign-in whose time it
|
|
64
|
+
* never saw proved (an upstream provider that says nothing of when, such as
|
|
65
|
+
* Discord) is tracked on the issuer and never counts as fresh there. Tokens
|
|
66
|
+
* carry no mark of it, so an API's own check here is `auth_time` alone; an
|
|
67
|
+
* API that needs the stronger rule sends the person through Synq's
|
|
68
|
+
* `max_age` sign-in.
|
|
69
|
+
*/
|
|
70
|
+
function provenSignInTime(claims) {
|
|
71
|
+
return claims.auth_time;
|
|
72
|
+
}
|
|
73
|
+
async function discoverKeys(issuer, fetcher) {
|
|
74
|
+
let jwksUri;
|
|
75
|
+
try {
|
|
76
|
+
const response = await (fetcher ?? fetch)(`${issuer}/.well-known/openid-configuration`);
|
|
77
|
+
if (!response.ok) throw new Error(`discovery answered ${response.status}`);
|
|
78
|
+
const metadata = await response.json();
|
|
79
|
+
if (metadata.issuer !== issuer || typeof metadata.jwks_uri !== "string") throw new Error("the discovery document names another issuer, or no JWKS");
|
|
80
|
+
jwksUri = secureUrl(metadata.jwks_uri, "The issuer's jwks_uri").href;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new SynqError("discovery_failed", `Synq's discovery document at ${issuer} couldn't be read`, { cause: error });
|
|
83
|
+
}
|
|
84
|
+
return createRemoteJWKSet(new URL(jwksUri), {
|
|
85
|
+
cooldownDuration: 3e4,
|
|
86
|
+
cacheMaxAge: 6e5,
|
|
87
|
+
...fetcher ? { [customFetch]: (url, init) => fetcher(url, init) } : {}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function accessTokenClaims(payload) {
|
|
91
|
+
const string = (value) => typeof value === "string" ? value : void 0;
|
|
92
|
+
const number = (value) => typeof value === "number" ? value : void 0;
|
|
93
|
+
const scope = string(payload.scope);
|
|
94
|
+
const amr = Array.isArray(payload.amr) ? payload.amr.filter((v) => typeof v === "string") : void 0;
|
|
95
|
+
const act = payload.act !== null && typeof payload.act === "object" ? payload.act : void 0;
|
|
96
|
+
return {
|
|
97
|
+
iss: payload.iss,
|
|
98
|
+
sub: payload.sub,
|
|
99
|
+
aud: payload.aud,
|
|
100
|
+
azp: string(payload.azp) ?? string(payload.client_id),
|
|
101
|
+
scopes: scope ? scope.split(" ").filter(Boolean) : [],
|
|
102
|
+
iat: payload.iat,
|
|
103
|
+
exp: payload.exp,
|
|
104
|
+
auth_time: number(payload.auth_time),
|
|
105
|
+
acr: string(payload.acr),
|
|
106
|
+
amr,
|
|
107
|
+
act,
|
|
108
|
+
payload
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function tokenError(error) {
|
|
112
|
+
if (error instanceof SynqError) return error;
|
|
113
|
+
if (error instanceof errors.JWTExpired) return new SynqError("token_expired", "The access token has expired", {
|
|
114
|
+
status: 401,
|
|
115
|
+
cause: error
|
|
116
|
+
});
|
|
117
|
+
if (error instanceof errors.JWKSTimeout || error instanceof TypeError) return new SynqError("network_error", "Synq's keys couldn't be fetched", {
|
|
118
|
+
status: 503,
|
|
119
|
+
cause: error
|
|
120
|
+
});
|
|
121
|
+
return new SynqError("invalid_token", "The access token is not valid for this API", {
|
|
122
|
+
status: 401,
|
|
123
|
+
cause: error
|
|
124
|
+
});
|
|
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
|
+
/**
|
|
195
|
+
* Checks a delivery's Standard Webhooks signature (`webhook-id`,
|
|
196
|
+
* `webhook-timestamp`, `webhook-signature`, HMAC-SHA256 over
|
|
197
|
+
* `<id>.<timestamp>.<body>`) against the subscription's `whsec_` secret, and
|
|
198
|
+
* that Synq signed it within `toleranceSeconds` (5 minutes by default), so a
|
|
199
|
+
* captured delivery can't be replayed later. Pass both secrets while one is
|
|
200
|
+
* being rotated: Synq signs with both for 24 hours. `body` is the request's
|
|
201
|
+
* raw body, exactly as it arrived.
|
|
202
|
+
*/
|
|
203
|
+
async function verifyWebhook(body, headers, secrets, options = {}) {
|
|
204
|
+
const id = header(headers, "webhook-id");
|
|
205
|
+
const timestamp = header(headers, "webhook-timestamp");
|
|
206
|
+
const signatures = header(headers, "webhook-signature");
|
|
207
|
+
if (!id || !timestamp || !signatures) throw new SynqError("invalid_signature", "The delivery is missing its webhook-* headers");
|
|
208
|
+
const seconds = Number(timestamp);
|
|
209
|
+
const now = (options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3;
|
|
210
|
+
if (!Number.isInteger(seconds) || Math.abs(now - seconds) > (options.toleranceSeconds ?? 300)) throw new SynqError("stale_webhook", "The delivery was signed too long ago, or in the future");
|
|
211
|
+
const message = new TextEncoder().encode(`${id}.${seconds}.${body}`);
|
|
212
|
+
const given = signatures.split(" ").filter((signature) => signature.startsWith("v1,")).map((signature) => fromBase64(signature.slice(3))).filter((bytes) => bytes !== void 0);
|
|
213
|
+
for (const secret of typeof secrets === "string" ? [secrets] : secrets) {
|
|
214
|
+
const raw = fromBase64(secret.replace(/^whsec_/, ""));
|
|
215
|
+
if (!raw) throw new SynqError("invalid_options", "A webhook secret is not a whsec_ secret");
|
|
216
|
+
const key = await crypto.subtle.importKey("raw", raw, {
|
|
217
|
+
name: "HMAC",
|
|
218
|
+
hash: "SHA-256"
|
|
219
|
+
}, false, ["verify"]);
|
|
220
|
+
for (const signature of given) if (await crypto.subtle.verify("HMAC", key, signature, message)) {
|
|
221
|
+
let event;
|
|
222
|
+
try {
|
|
223
|
+
event = JSON.parse(body);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new SynqError("invalid_signature", "The signed body is not JSON", { cause: error });
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
id,
|
|
229
|
+
timestamp: /* @__PURE__ */ new Date(seconds * 1e3),
|
|
230
|
+
event
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw new SynqError("invalid_signature", "The delivery's signature matches none of the secrets");
|
|
235
|
+
}
|
|
236
|
+
function header(headers, name) {
|
|
237
|
+
if (headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
238
|
+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
239
|
+
return Array.isArray(entry) ? entry.join(" ") : entry;
|
|
240
|
+
}
|
|
241
|
+
function fromBase64(value) {
|
|
242
|
+
try {
|
|
243
|
+
const binary = atob(value);
|
|
244
|
+
const bytes = new Uint8Array(binary.length);
|
|
245
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
246
|
+
return bytes;
|
|
247
|
+
} catch {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* A client for Synq's management API (`/v1`), typed by its contract. Server
|
|
253
|
+
* only: an API key never reaches a browser. Failures throw a `SynqError`
|
|
254
|
+
* with the problem's `code`, such as `insufficient_permissions`.
|
|
255
|
+
*/
|
|
256
|
+
function createManagementClient(options) {
|
|
257
|
+
const { apiKey, accessToken } = options;
|
|
258
|
+
if (!apiKey === !accessToken) throw new SynqError("invalid_options", "Give the management client an apiKey or an accessToken");
|
|
259
|
+
return createApiClient({
|
|
260
|
+
baseUrl: options.baseUrl,
|
|
261
|
+
credential: apiKey ? () => apiKey : accessToken,
|
|
262
|
+
fetch: options.fetch
|
|
263
|
+
});
|
|
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
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
export { SignInFlow, SynqError, createAccessTokenVerifier, createApiKeyVerifier, createManagementClient, createOwnUiSignIn, isApiKey, isSynqError, verifyWebhook };
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synqauth/js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aerosol-build/synq.git",
|
|
9
|
+
"directory": "packages/js"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./server": {
|
|
22
|
+
"types": "./dist/server.d.ts",
|
|
23
|
+
"default": "./dist/server.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
35
|
+
"openapi-typescript": "7.13.0",
|
|
36
|
+
"vitest": "5.0.2"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"oauth4webapi": "3.8.8",
|
|
40
|
+
"jose": "6.2.12"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsdown",
|
|
44
|
+
"generate": "node scripts/generate.ts",
|
|
45
|
+
"lint": "eslint . && node scripts/generate.ts --check",
|
|
46
|
+
"typecheck": "tsc",
|
|
47
|
+
"test": "vitest run --coverage"
|
|
48
|
+
}
|
|
6
49
|
}
|