@smartcrab/browser 0.1.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 +202 -0
- package/dist/base64url.d.ts +12 -0
- package/dist/base64url.js +44 -0
- package/dist/client.d.ts +192 -0
- package/dist/client.js +551 -0
- package/dist/errors.d.ts +95 -0
- package/dist/errors.js +44 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +113 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +26 -0
- package/dist/pkce.d.ts +25 -0
- package/dist/pkce.js +21 -0
- package/dist/webauthn.d.ts +26 -0
- package/dist/webauthn.js +214 -0
- package/package.json +41 -0
- package/src/base64url.ts +47 -0
- package/src/client-me.test.ts +321 -0
- package/src/client-tokens.test.ts +378 -0
- package/src/client.test.ts +714 -0
- package/src/client.ts +1117 -0
- package/src/errors.ts +154 -0
- package/src/http.test.ts +225 -0
- package/src/http.ts +173 -0
- package/src/index.ts +68 -0
- package/src/pkce.test.ts +51 -0
- package/src/pkce.ts +43 -0
- package/src/webauthn.ts +295 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { clientIdSchema, emailChallengeResponseSchema, emailVerifyResponseSchema, meDeleteResponseSchema, meEmailChangeStartResponseSchema, meEmailChangeVerifyNewResponseSchema, meEmailChangeVerifyOldResponseSchema, meIdentitiesListResponseSchema, meIdentityLinkResponseSchema, meIdentityUnlinkResponseSchema, mePasskeyDeleteResponseSchema, mePasskeyRegistrationOptionsResponseSchema, mePasskeyRegistrationVerifyResponseSchema, mePasskeysListResponseSchema, meSessionRevokeResponseSchema, meSessionsListResponseSchema, meSessionsRevokeAllResponseSchema, meUpdateResponseSchema, meUserSchema, passkeyAuthenticationOptionsResponseSchema, passkeyAuthenticationVerifyResponseSchema, passkeyRegistrationOptionsResponseSchema, publicConfigResponseSchema, scopeStringSchema, socialStartResponseSchema, tokenResponseSchema, transactionCancelResponseSchema, transactionCompleteResponseSchema, transactionCreateResponseSchema, transactionGetResponseSchema, transactionPasskeyRegistrationVerifyResponseSchema, } from "@smartcrab/contracts-public";
|
|
2
|
+
import { err, ok } from "@smartcrab/contracts-public";
|
|
3
|
+
import { buildUrl, isOAuthErrorCode, requestJson } from "./http.js";
|
|
4
|
+
import { generateNonce, generatePkcePair, generateState } from "./pkce.js";
|
|
5
|
+
import { navigatorWebAuthnPort } from "./webauthn.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Implementation
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const EXPIRY_SKEW_MS = 30_000;
|
|
10
|
+
/** Matches the transaction lifetime implied by design.md §16.1's example (10 minutes). */
|
|
11
|
+
const PENDING_AUTHORIZATION_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
const defaultFetchPort = (url, init) => fetch(url, init);
|
|
13
|
+
const defaultRedirectPort = {
|
|
14
|
+
assign: (url) => {
|
|
15
|
+
if (typeof window !== "undefined") {
|
|
16
|
+
window.location.assign(url);
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const PENDING_AUTHORIZATION_STORAGE_PREFIX = "@smartcrab/browser:pending:";
|
|
21
|
+
const isPendingAuthorization = (value) => {
|
|
22
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
23
|
+
return false;
|
|
24
|
+
const record = value;
|
|
25
|
+
return (typeof record["state"] === "string" &&
|
|
26
|
+
typeof record["nonce"] === "string" &&
|
|
27
|
+
typeof record["codeVerifier"] === "string" &&
|
|
28
|
+
typeof record["redirectUri"] === "string" &&
|
|
29
|
+
typeof record["createdAt"] === "number" &&
|
|
30
|
+
Number.isFinite(record["createdAt"]));
|
|
31
|
+
};
|
|
32
|
+
const encodeSegment = encodeURIComponent;
|
|
33
|
+
export const createBrowserAuthClient = (config) => {
|
|
34
|
+
// Programmer-error validation: fail fast at factory time (not a runtime
|
|
35
|
+
// failure channel, hence a TypeError rather than an Outcome).
|
|
36
|
+
if (!URL.canParse(config.baseUrl)) {
|
|
37
|
+
throw new TypeError(`baseUrl is not a valid URL: ${config.baseUrl}`);
|
|
38
|
+
}
|
|
39
|
+
if (!clientIdSchema.safeParse(config.clientId).success) {
|
|
40
|
+
throw new TypeError("clientId is not a valid cli_ identifier");
|
|
41
|
+
}
|
|
42
|
+
const scope = config.scope ?? "openid profile email";
|
|
43
|
+
if (!scopeStringSchema.safeParse(scope).success) {
|
|
44
|
+
throw new TypeError(`scope contains unknown tokens: ${scope}`);
|
|
45
|
+
}
|
|
46
|
+
if (config.redirectUri.length === 0) {
|
|
47
|
+
throw new TypeError("redirectUri must not be empty");
|
|
48
|
+
}
|
|
49
|
+
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
50
|
+
const fetchPort = config.fetch ?? defaultFetchPort;
|
|
51
|
+
const webAuthn = config.webAuthn ?? navigatorWebAuthnPort;
|
|
52
|
+
const redirectPort = config.redirect ?? defaultRedirectPort;
|
|
53
|
+
const now = config.now ?? (() => Date.now());
|
|
54
|
+
const storage = config.refreshTokenStorage;
|
|
55
|
+
// Tokens stay in this closure; only short-lived PKCE callback state uses sessionStorage.
|
|
56
|
+
let tokenSet = null;
|
|
57
|
+
let cachedIssuer = config.issuer ?? null;
|
|
58
|
+
let inflightRefresh = null;
|
|
59
|
+
const pendingByState = new Map();
|
|
60
|
+
const listeners = new Set();
|
|
61
|
+
let snapshot = { status: "signed_out", hasRefreshToken: false };
|
|
62
|
+
const notify = () => {
|
|
63
|
+
snapshot =
|
|
64
|
+
tokenSet === null
|
|
65
|
+
? { status: "signed_out", hasRefreshToken: false }
|
|
66
|
+
: {
|
|
67
|
+
status: "signed_in",
|
|
68
|
+
accessTokenExpiresAt: tokenSet.accessTokenExpiresAt,
|
|
69
|
+
scope: tokenSet.scope,
|
|
70
|
+
hasRefreshToken: tokenSet.refreshToken !== null,
|
|
71
|
+
};
|
|
72
|
+
for (const listener of listeners) {
|
|
73
|
+
listener(snapshot);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const prunePending = () => {
|
|
77
|
+
const cutoff = now() - PENDING_AUTHORIZATION_TTL_MS;
|
|
78
|
+
for (const [state, pending] of pendingByState) {
|
|
79
|
+
if (pending.createdAt < cutoff)
|
|
80
|
+
pendingByState.delete(state);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// Storage is a navigation-recovery aid: unavailable storage (Node/SSR, blocked storage,
|
|
84
|
+
// quota) or malformed entries fall back to the in-memory copy. Exception boundaries are
|
|
85
|
+
// promise rejection handlers, never try/catch (scripts/check-backend-error-style.ts).
|
|
86
|
+
const registerPending = async (pending) => {
|
|
87
|
+
prunePending();
|
|
88
|
+
pendingByState.set(pending.state, pending);
|
|
89
|
+
await Promise.resolve()
|
|
90
|
+
.then(() => sessionStorage.setItem(`${PENDING_AUTHORIZATION_STORAGE_PREFIX}${pending.state}`, JSON.stringify(pending)))
|
|
91
|
+
.then(undefined, () => undefined);
|
|
92
|
+
};
|
|
93
|
+
const consumePending = async (state) => {
|
|
94
|
+
const inMemory = pendingByState.get(state) ?? null;
|
|
95
|
+
pendingByState.delete(state);
|
|
96
|
+
const stored = await Promise.resolve()
|
|
97
|
+
.then(() => {
|
|
98
|
+
const key = `${PENDING_AUTHORIZATION_STORAGE_PREFIX}${state}`;
|
|
99
|
+
const serialized = sessionStorage.getItem(key);
|
|
100
|
+
if (serialized === null)
|
|
101
|
+
return null;
|
|
102
|
+
sessionStorage.removeItem(key);
|
|
103
|
+
const parsed = JSON.parse(serialized);
|
|
104
|
+
return isPendingAuthorization(parsed) && parsed.state === state ? parsed : null;
|
|
105
|
+
})
|
|
106
|
+
.then(undefined, () => null);
|
|
107
|
+
const pending = inMemory ?? stored;
|
|
108
|
+
if (pending === null || now() - pending.createdAt > PENDING_AUTHORIZATION_TTL_MS) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return pending;
|
|
112
|
+
};
|
|
113
|
+
const applyTokenResponse = async (tokenResponse) => {
|
|
114
|
+
const refreshToken = tokenResponse.refresh_token ?? tokenSet?.refreshToken ?? null;
|
|
115
|
+
tokenSet = {
|
|
116
|
+
accessToken: tokenResponse.access_token,
|
|
117
|
+
accessTokenExpiresAt: now() + tokenResponse.expires_in * 1000,
|
|
118
|
+
scope: tokenResponse.scope,
|
|
119
|
+
refreshToken,
|
|
120
|
+
};
|
|
121
|
+
const persistToken = tokenResponse.refresh_token;
|
|
122
|
+
if (persistToken !== undefined && storage !== undefined) {
|
|
123
|
+
// Best-effort: a failing storage port must not break authentication.
|
|
124
|
+
await Promise.resolve()
|
|
125
|
+
.then(() => storage.save(persistToken))
|
|
126
|
+
.then(undefined, () => undefined);
|
|
127
|
+
}
|
|
128
|
+
notify();
|
|
129
|
+
return {
|
|
130
|
+
accessTokenExpiresAt: tokenSet.accessTokenExpiresAt,
|
|
131
|
+
scope: tokenSet.scope,
|
|
132
|
+
hasRefreshToken: refreshToken !== null,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
const clearSession = () => {
|
|
136
|
+
tokenSet = null;
|
|
137
|
+
if (storage !== undefined) {
|
|
138
|
+
void Promise.resolve()
|
|
139
|
+
.then(() => storage.clear())
|
|
140
|
+
.then(undefined, () => undefined);
|
|
141
|
+
}
|
|
142
|
+
notify();
|
|
143
|
+
};
|
|
144
|
+
const request = (method, path, schema, options) => requestJson(fetchPort, {
|
|
145
|
+
method,
|
|
146
|
+
url: buildUrl(baseUrl, path, options?.query),
|
|
147
|
+
...(options?.body !== undefined ? { body: options.body } : {}),
|
|
148
|
+
...(options?.accessToken !== undefined ? { accessToken: options.accessToken } : {}),
|
|
149
|
+
}, schema);
|
|
150
|
+
const getPublicConfig = () => request("GET", "/v1/config", publicConfigResponseSchema, {
|
|
151
|
+
query: { client_id: config.clientId },
|
|
152
|
+
});
|
|
153
|
+
const resolveIssuer = async () => {
|
|
154
|
+
if (cachedIssuer !== null)
|
|
155
|
+
return ok(cachedIssuer);
|
|
156
|
+
const configOutcome = await getPublicConfig();
|
|
157
|
+
if (!configOutcome.ok)
|
|
158
|
+
return configOutcome;
|
|
159
|
+
cachedIssuer = configOutcome.value.issuer;
|
|
160
|
+
return ok(cachedIssuer);
|
|
161
|
+
};
|
|
162
|
+
// --- transactions -----------------------------------------------------------
|
|
163
|
+
const createTransaction = async (input) => {
|
|
164
|
+
const pkce = await generatePkcePair();
|
|
165
|
+
const state = input?.state ?? generateState();
|
|
166
|
+
const nonce = input?.nonce ?? generateNonce();
|
|
167
|
+
const resource = input?.resource ?? config.resource;
|
|
168
|
+
const body = {
|
|
169
|
+
client_id: config.clientId,
|
|
170
|
+
redirect_uri: config.redirectUri,
|
|
171
|
+
code_challenge: pkce.challenge,
|
|
172
|
+
code_challenge_method: "S256",
|
|
173
|
+
state,
|
|
174
|
+
nonce,
|
|
175
|
+
scope: input?.scope ?? scope,
|
|
176
|
+
...(resource !== undefined ? { resource: [...resource] } : {}),
|
|
177
|
+
// The SPA drives the ceremony itself and calls `/complete` over XHR
|
|
178
|
+
// (design.md §16.3), which is the `native_return` mode.
|
|
179
|
+
response_mode: "native_return",
|
|
180
|
+
};
|
|
181
|
+
const outcome = await request("POST", "/v1/auth/transactions", transactionCreateResponseSchema, { body });
|
|
182
|
+
if (!outcome.ok)
|
|
183
|
+
return outcome;
|
|
184
|
+
await registerPending({
|
|
185
|
+
state,
|
|
186
|
+
nonce,
|
|
187
|
+
codeVerifier: pkce.verifier,
|
|
188
|
+
redirectUri: config.redirectUri,
|
|
189
|
+
createdAt: now(),
|
|
190
|
+
});
|
|
191
|
+
return ok({
|
|
192
|
+
transactionId: outcome.value.transaction_id,
|
|
193
|
+
state,
|
|
194
|
+
expiresAt: outcome.value.expires_at,
|
|
195
|
+
availableMethods: outcome.value.available_methods,
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const transactionPath = (transactionId) => `/v1/auth/transactions/${encodeSegment(transactionId)}`;
|
|
199
|
+
const getTransaction = (transactionId) => request("GET", transactionPath(transactionId), transactionGetResponseSchema);
|
|
200
|
+
const cancelTransaction = (transactionId) => request("POST", `${transactionPath(transactionId)}/cancel`, transactionCancelResponseSchema, {
|
|
201
|
+
body: {},
|
|
202
|
+
});
|
|
203
|
+
// --- email OTP -----------------------------------------------------------------
|
|
204
|
+
const startEmailOtp = (transactionId, input) => request("POST", `${transactionPath(transactionId)}/email/start`, emailChallengeResponseSchema, {
|
|
205
|
+
body: input,
|
|
206
|
+
});
|
|
207
|
+
const resendEmailOtp = (transactionId, input) => request("POST", `${transactionPath(transactionId)}/email/resend`, emailChallengeResponseSchema, { body: input });
|
|
208
|
+
const verifyEmailOtp = (transactionId, input) => request("POST", `${transactionPath(transactionId)}/email/verify`, emailVerifyResponseSchema, {
|
|
209
|
+
body: input,
|
|
210
|
+
});
|
|
211
|
+
// --- passkeys --------------------------------------------------------------------
|
|
212
|
+
const getPasskeyRegistrationOptions = (transactionId) => request("POST", `${transactionPath(transactionId)}/passkeys/registration/options`, passkeyRegistrationOptionsResponseSchema, {
|
|
213
|
+
body: {},
|
|
214
|
+
});
|
|
215
|
+
const verifyPasskeyRegistration = (transactionId, input) => request("POST", `${transactionPath(transactionId)}/passkeys/registration/verify`, transactionPasskeyRegistrationVerifyResponseSchema, { body: input });
|
|
216
|
+
const startPasskeyRegistration = async (transactionId, input) => {
|
|
217
|
+
if (!webAuthn.isAvailable()) {
|
|
218
|
+
return err({
|
|
219
|
+
type: "webauthn_error",
|
|
220
|
+
reason: "not_supported",
|
|
221
|
+
message: "WebAuthn is not available",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const options = await getPasskeyRegistrationOptions(transactionId);
|
|
225
|
+
if (!options.ok)
|
|
226
|
+
return options;
|
|
227
|
+
const credential = await webAuthn.create(options.value);
|
|
228
|
+
if (!credential.ok)
|
|
229
|
+
return credential;
|
|
230
|
+
return verifyPasskeyRegistration(transactionId, {
|
|
231
|
+
credential: credential.value,
|
|
232
|
+
...(input?.name !== undefined ? { name: input.name } : {}),
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
const getPasskeyAuthenticationOptions = (transactionId, input) => request("POST", `${transactionPath(transactionId)}/passkeys/authentication/options`, passkeyAuthenticationOptionsResponseSchema, { body: input ?? {} });
|
|
236
|
+
const verifyPasskeyAuthentication = (transactionId, input) => request("POST", `${transactionPath(transactionId)}/passkeys/authentication/verify`, passkeyAuthenticationVerifyResponseSchema, { body: input });
|
|
237
|
+
const startPasskeyAuthentication = async (transactionId, input) => {
|
|
238
|
+
if (!webAuthn.isAvailable()) {
|
|
239
|
+
return err({
|
|
240
|
+
type: "webauthn_error",
|
|
241
|
+
reason: "not_supported",
|
|
242
|
+
message: "WebAuthn is not available",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const options = await getPasskeyAuthenticationOptions(transactionId, input);
|
|
246
|
+
if (!options.ok)
|
|
247
|
+
return options;
|
|
248
|
+
const credential = await webAuthn.get(options.value);
|
|
249
|
+
if (!credential.ok)
|
|
250
|
+
return credential;
|
|
251
|
+
return verifyPasskeyAuthentication(transactionId, { credential: credential.value });
|
|
252
|
+
};
|
|
253
|
+
// --- social -----------------------------------------------------------------------
|
|
254
|
+
const startSocialSignIn = async (transactionId, provider) => {
|
|
255
|
+
const outcome = await request("POST", `${transactionPath(transactionId)}/social/${encodeSegment(provider)}/start`, socialStartResponseSchema, { body: {} });
|
|
256
|
+
if (!outcome.ok)
|
|
257
|
+
return outcome;
|
|
258
|
+
// design.md §22.3/§20.6: top-level navigation only; the SPA resumes via
|
|
259
|
+
// the redirect back to `redirectUri`.
|
|
260
|
+
redirectPort.assign(outcome.value.authorization_url);
|
|
261
|
+
return outcome;
|
|
262
|
+
};
|
|
263
|
+
// --- completion + token exchange ------------------------------------------------------
|
|
264
|
+
const completeTransaction = async (transactionId) => {
|
|
265
|
+
const outcome = await request("POST", `${transactionPath(transactionId)}/complete`, transactionCompleteResponseSchema, {
|
|
266
|
+
body: {},
|
|
267
|
+
});
|
|
268
|
+
if (!outcome.ok)
|
|
269
|
+
return outcome;
|
|
270
|
+
if (outcome.value.redirect_uri !== config.redirectUri) {
|
|
271
|
+
// design.md §15.9: exact redirect matching. A mismatch here is a server
|
|
272
|
+
// contract violation (or an attack), never a usable code.
|
|
273
|
+
return err({
|
|
274
|
+
type: "invalid_response",
|
|
275
|
+
message: "complete response redirect_uri does not match the configured redirectUri",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return outcome;
|
|
279
|
+
};
|
|
280
|
+
const exchangeAuthorizationCode = async (input) => {
|
|
281
|
+
const pending = await consumePending(input.state);
|
|
282
|
+
if (pending === null) {
|
|
283
|
+
// Unknown, expired, or already-consumed state: possible CSRF/replay.
|
|
284
|
+
return err({ type: "state_mismatch" });
|
|
285
|
+
}
|
|
286
|
+
const issuerOutcome = await resolveIssuer();
|
|
287
|
+
if (!issuerOutcome.ok)
|
|
288
|
+
return issuerOutcome;
|
|
289
|
+
const body = {
|
|
290
|
+
grant_type: "authorization_code",
|
|
291
|
+
code: input.code,
|
|
292
|
+
redirect_uri: pending.redirectUri,
|
|
293
|
+
code_verifier: pending.codeVerifier,
|
|
294
|
+
client_id: config.clientId,
|
|
295
|
+
...(config.resource !== undefined ? { resource: [...config.resource] } : {}),
|
|
296
|
+
};
|
|
297
|
+
const outcome = await requestJson(fetchPort, { method: "POST", url: `${issuerOutcome.value}/oauth2/token`, body }, tokenResponseSchema);
|
|
298
|
+
if (!outcome.ok)
|
|
299
|
+
return outcome;
|
|
300
|
+
return ok(await applyTokenResponse(outcome.value));
|
|
301
|
+
};
|
|
302
|
+
// --- hosted (redirect) authorization ------------------------------------------------------
|
|
303
|
+
const createHostedAuthorizationUrl = async () => {
|
|
304
|
+
const issuerOutcome = await resolveIssuer();
|
|
305
|
+
if (!issuerOutcome.ok)
|
|
306
|
+
return issuerOutcome;
|
|
307
|
+
const pkce = await generatePkcePair();
|
|
308
|
+
const state = generateState();
|
|
309
|
+
const nonce = generateNonce();
|
|
310
|
+
const params = new URLSearchParams();
|
|
311
|
+
params.set("response_type", "code");
|
|
312
|
+
params.set("client_id", config.clientId);
|
|
313
|
+
params.set("redirect_uri", config.redirectUri);
|
|
314
|
+
params.set("scope", scope);
|
|
315
|
+
params.set("state", state);
|
|
316
|
+
params.set("nonce", nonce);
|
|
317
|
+
params.set("code_challenge", pkce.challenge);
|
|
318
|
+
params.set("code_challenge_method", "S256");
|
|
319
|
+
for (const resource of config.resource ?? []) {
|
|
320
|
+
params.append("resource", resource);
|
|
321
|
+
}
|
|
322
|
+
await registerPending({
|
|
323
|
+
state,
|
|
324
|
+
nonce,
|
|
325
|
+
codeVerifier: pkce.verifier,
|
|
326
|
+
redirectUri: config.redirectUri,
|
|
327
|
+
createdAt: now(),
|
|
328
|
+
});
|
|
329
|
+
return ok({ url: `${issuerOutcome.value}/oauth2/authorize?${params.toString()}`, state });
|
|
330
|
+
};
|
|
331
|
+
const beginHostedAuthorization = async () => {
|
|
332
|
+
const outcome = await createHostedAuthorizationUrl();
|
|
333
|
+
if (!outcome.ok)
|
|
334
|
+
return outcome;
|
|
335
|
+
redirectPort.assign(outcome.value.url);
|
|
336
|
+
return ok({ state: outcome.value.state });
|
|
337
|
+
};
|
|
338
|
+
const handleHostedAuthorizationCallback = async (callbackUrl) => {
|
|
339
|
+
if (!URL.canParse(callbackUrl)) {
|
|
340
|
+
return err({ type: "invalid_response", message: "callback URL is not parseable" });
|
|
341
|
+
}
|
|
342
|
+
const url = new URL(callbackUrl);
|
|
343
|
+
const state = url.searchParams.get("state");
|
|
344
|
+
const errorParam = url.searchParams.get("error");
|
|
345
|
+
if (errorParam !== null) {
|
|
346
|
+
if (state !== null)
|
|
347
|
+
await consumePending(state);
|
|
348
|
+
// RFC 6749 §4.1.2.1 error redirect. The code list is closed; an unknown
|
|
349
|
+
// value means the callback is not contract-shaped.
|
|
350
|
+
if (!isOAuthErrorCode(errorParam)) {
|
|
351
|
+
return err({
|
|
352
|
+
type: "invalid_response",
|
|
353
|
+
message: `callback carried an unknown OAuth error: ${errorParam}`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const description = url.searchParams.get("error_description");
|
|
357
|
+
return err({
|
|
358
|
+
type: "oauth_error",
|
|
359
|
+
code: errorParam,
|
|
360
|
+
...(description !== null ? { description } : {}),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const code = url.searchParams.get("code");
|
|
364
|
+
if (code === null || state === null) {
|
|
365
|
+
if (state !== null)
|
|
366
|
+
await consumePending(state);
|
|
367
|
+
return err({ type: "invalid_response", message: "callback URL is missing code or state" });
|
|
368
|
+
}
|
|
369
|
+
return exchangeAuthorizationCode({ code, state });
|
|
370
|
+
};
|
|
371
|
+
// --- token / session management -------------------------------------------------------------
|
|
372
|
+
const refreshSession = () => {
|
|
373
|
+
if (inflightRefresh !== null)
|
|
374
|
+
return inflightRefresh;
|
|
375
|
+
const pending = (async () => {
|
|
376
|
+
const refreshToken = tokenSet?.refreshToken ?? null;
|
|
377
|
+
if (refreshToken === null) {
|
|
378
|
+
return err({ type: "not_authenticated" });
|
|
379
|
+
}
|
|
380
|
+
const issuerOutcome = await resolveIssuer();
|
|
381
|
+
if (!issuerOutcome.ok)
|
|
382
|
+
return issuerOutcome;
|
|
383
|
+
const body = {
|
|
384
|
+
grant_type: "refresh_token",
|
|
385
|
+
refresh_token: refreshToken,
|
|
386
|
+
client_id: config.clientId,
|
|
387
|
+
...(config.resource !== undefined ? { resource: [...config.resource] } : {}),
|
|
388
|
+
};
|
|
389
|
+
const outcome = await requestJson(fetchPort, { method: "POST", url: `${issuerOutcome.value}/oauth2/token`, body }, tokenResponseSchema);
|
|
390
|
+
if (!outcome.ok) {
|
|
391
|
+
// design.md §21.7: invalid_grant kills the local session entirely;
|
|
392
|
+
// transient failures (network/5xx/429) must not.
|
|
393
|
+
if (outcome.error.type === "oauth_error" && outcome.error.code === "invalid_grant") {
|
|
394
|
+
clearSession();
|
|
395
|
+
}
|
|
396
|
+
return outcome;
|
|
397
|
+
}
|
|
398
|
+
return ok(await applyTokenResponse(outcome.value));
|
|
399
|
+
})();
|
|
400
|
+
inflightRefresh = pending;
|
|
401
|
+
const clearInflight = () => {
|
|
402
|
+
if (inflightRefresh === pending)
|
|
403
|
+
inflightRefresh = null;
|
|
404
|
+
};
|
|
405
|
+
void pending.then(clearInflight, clearInflight);
|
|
406
|
+
return pending;
|
|
407
|
+
};
|
|
408
|
+
const getAccessToken = async () => {
|
|
409
|
+
if (tokenSet !== null && now() < tokenSet.accessTokenExpiresAt - EXPIRY_SKEW_MS) {
|
|
410
|
+
return ok(tokenSet.accessToken);
|
|
411
|
+
}
|
|
412
|
+
if (tokenSet !== null && tokenSet.refreshToken !== null) {
|
|
413
|
+
const refreshed = await refreshSession();
|
|
414
|
+
if (!refreshed.ok)
|
|
415
|
+
return refreshed;
|
|
416
|
+
const current = tokenSet;
|
|
417
|
+
if (current === null)
|
|
418
|
+
return err({ type: "not_authenticated" });
|
|
419
|
+
return ok(current.accessToken);
|
|
420
|
+
}
|
|
421
|
+
return err({ type: "not_authenticated" });
|
|
422
|
+
};
|
|
423
|
+
const restoreSession = async () => {
|
|
424
|
+
if (storage === undefined)
|
|
425
|
+
return err({ type: "not_authenticated" });
|
|
426
|
+
// A rejecting storage port is treated as "nothing persisted".
|
|
427
|
+
const persisted = await Promise.resolve()
|
|
428
|
+
.then(() => storage.load())
|
|
429
|
+
.then((value) => value, () => null);
|
|
430
|
+
if (persisted === null)
|
|
431
|
+
return err({ type: "not_authenticated" });
|
|
432
|
+
tokenSet = {
|
|
433
|
+
accessToken: "",
|
|
434
|
+
accessTokenExpiresAt: 0,
|
|
435
|
+
scope: "",
|
|
436
|
+
refreshToken: persisted,
|
|
437
|
+
};
|
|
438
|
+
return refreshSession();
|
|
439
|
+
};
|
|
440
|
+
const signOut = async () => {
|
|
441
|
+
const refreshToken = tokenSet?.refreshToken ?? null;
|
|
442
|
+
if (refreshToken !== null && cachedIssuer !== null) {
|
|
443
|
+
// Best-effort revocation (RFC 7009; design.md §15.8): the endpoint
|
|
444
|
+
// succeeds even for unknown tokens, and local cleanup proceeds
|
|
445
|
+
// regardless of the outcome.
|
|
446
|
+
const body = {
|
|
447
|
+
token: refreshToken,
|
|
448
|
+
token_type_hint: "refresh_token",
|
|
449
|
+
client_id: config.clientId,
|
|
450
|
+
};
|
|
451
|
+
await requestJson(fetchPort, { method: "POST", url: `${cachedIssuer}/oauth2/revoke`, body }, {
|
|
452
|
+
safeParse: () => ({ success: true, data: {} }),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
clearSession();
|
|
456
|
+
return ok({ signedOut: true });
|
|
457
|
+
};
|
|
458
|
+
// --- /v1/me -------------------------------------------------------------------------
|
|
459
|
+
const authed = async (method, path, schema, body) => {
|
|
460
|
+
const token = await getAccessToken();
|
|
461
|
+
if (!token.ok)
|
|
462
|
+
return token;
|
|
463
|
+
return request(method, path, schema, {
|
|
464
|
+
accessToken: token.value,
|
|
465
|
+
...(body !== undefined ? { body } : {}),
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
const getMe = () => authed("GET", "/v1/me", meUserSchema);
|
|
469
|
+
const updateMe = (input) => authed("PATCH", "/v1/me", meUpdateResponseSchema, input);
|
|
470
|
+
const listMyPasskeys = () => authed("GET", "/v1/me/passkeys", mePasskeysListResponseSchema);
|
|
471
|
+
const getMyPasskeyRegistrationOptions = () => authed("POST", "/v1/me/passkeys/registration/options", mePasskeyRegistrationOptionsResponseSchema, {});
|
|
472
|
+
const verifyMyPasskeyRegistration = (input) => authed("POST", "/v1/me/passkeys/registration/verify", mePasskeyRegistrationVerifyResponseSchema, input);
|
|
473
|
+
const registerMyPasskey = async (input) => {
|
|
474
|
+
if (!webAuthn.isAvailable()) {
|
|
475
|
+
return err({
|
|
476
|
+
type: "webauthn_error",
|
|
477
|
+
reason: "not_supported",
|
|
478
|
+
message: "WebAuthn is not available",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const options = await getMyPasskeyRegistrationOptions();
|
|
482
|
+
if (!options.ok)
|
|
483
|
+
return options;
|
|
484
|
+
const credential = await webAuthn.create(options.value);
|
|
485
|
+
if (!credential.ok)
|
|
486
|
+
return credential;
|
|
487
|
+
return verifyMyPasskeyRegistration({
|
|
488
|
+
credential: credential.value,
|
|
489
|
+
...(input?.name !== undefined ? { name: input.name } : {}),
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
const deleteMyPasskey = (passkeyId) => authed("DELETE", `/v1/me/passkeys/${encodeSegment(passkeyId)}`, mePasskeyDeleteResponseSchema);
|
|
493
|
+
const listMyIdentities = () => authed("GET", "/v1/me/identities", meIdentitiesListResponseSchema);
|
|
494
|
+
const linkMyIdentity = (provider, input) => authed("POST", `/v1/me/identities/${encodeSegment(provider)}/link`, meIdentityLinkResponseSchema, input);
|
|
495
|
+
const unlinkMyIdentity = (identityId) => authed("DELETE", `/v1/me/identities/${encodeSegment(identityId)}`, meIdentityUnlinkResponseSchema);
|
|
496
|
+
const listMySessions = () => authed("GET", "/v1/me/sessions", meSessionsListResponseSchema);
|
|
497
|
+
const revokeMySession = (sessionId) => authed("DELETE", `/v1/me/sessions/${encodeSegment(sessionId)}`, meSessionRevokeResponseSchema);
|
|
498
|
+
const revokeAllMySessions = () => authed("DELETE", "/v1/me/sessions", meSessionsRevokeAllResponseSchema);
|
|
499
|
+
const startMyEmailChange = (input) => authed("POST", "/v1/me/email/change/start", meEmailChangeStartResponseSchema, input);
|
|
500
|
+
const verifyMyEmailChangeOld = (input) => authed("POST", "/v1/me/email/change/verify-old", meEmailChangeVerifyOldResponseSchema, input);
|
|
501
|
+
const verifyMyEmailChangeNew = (input) => authed("POST", "/v1/me/email/change/verify-new", meEmailChangeVerifyNewResponseSchema, input);
|
|
502
|
+
const deleteMyAccount = () => authed("DELETE", "/v1/me", meDeleteResponseSchema);
|
|
503
|
+
return {
|
|
504
|
+
getPublicConfig,
|
|
505
|
+
createTransaction,
|
|
506
|
+
getTransaction,
|
|
507
|
+
cancelTransaction,
|
|
508
|
+
startEmailOtp,
|
|
509
|
+
resendEmailOtp,
|
|
510
|
+
verifyEmailOtp,
|
|
511
|
+
getPasskeyRegistrationOptions,
|
|
512
|
+
verifyPasskeyRegistration,
|
|
513
|
+
startPasskeyRegistration,
|
|
514
|
+
getPasskeyAuthenticationOptions,
|
|
515
|
+
verifyPasskeyAuthentication,
|
|
516
|
+
startPasskeyAuthentication,
|
|
517
|
+
startSocialSignIn,
|
|
518
|
+
completeTransaction,
|
|
519
|
+
exchangeAuthorizationCode,
|
|
520
|
+
createHostedAuthorizationUrl,
|
|
521
|
+
beginHostedAuthorization,
|
|
522
|
+
handleHostedAuthorizationCallback,
|
|
523
|
+
getSnapshot: () => snapshot,
|
|
524
|
+
subscribe: (listener) => {
|
|
525
|
+
listeners.add(listener);
|
|
526
|
+
return () => listeners.delete(listener);
|
|
527
|
+
},
|
|
528
|
+
isPasskeySupported: () => webAuthn.isAvailable(),
|
|
529
|
+
getAccessToken,
|
|
530
|
+
refreshSession,
|
|
531
|
+
restoreSession,
|
|
532
|
+
signOut,
|
|
533
|
+
getMe,
|
|
534
|
+
updateMe,
|
|
535
|
+
listMyPasskeys,
|
|
536
|
+
getMyPasskeyRegistrationOptions,
|
|
537
|
+
verifyMyPasskeyRegistration,
|
|
538
|
+
registerMyPasskey,
|
|
539
|
+
deleteMyPasskey,
|
|
540
|
+
listMyIdentities,
|
|
541
|
+
linkMyIdentity,
|
|
542
|
+
unlinkMyIdentity,
|
|
543
|
+
listMySessions,
|
|
544
|
+
revokeMySession,
|
|
545
|
+
revokeAllMySessions,
|
|
546
|
+
startMyEmailChange,
|
|
547
|
+
verifyMyEmailChangeOld,
|
|
548
|
+
verifyMyEmailChangeNew,
|
|
549
|
+
deleteMyAccount,
|
|
550
|
+
};
|
|
551
|
+
};
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { OAuthErrorCode } from "@smartcrab/contracts-public";
|
|
2
|
+
/**
|
|
3
|
+
* Error model for `@smartcrab/browser` (design.md §26.3, §32).
|
|
4
|
+
*
|
|
5
|
+
* Every client method resolves to `Outcome<T, BrowserAuthError>`; nothing
|
|
6
|
+
* throws for expected failure modes. The union mirrors what the public API
|
|
7
|
+
* can actually put on the wire:
|
|
8
|
+
*
|
|
9
|
+
* - `network_error` — fetch itself failed (DNS, TLS, abort, CORS). Retryable.
|
|
10
|
+
* - `invalid_response` — a 2xx body failed the contracts-public zod schema,
|
|
11
|
+
* an error body was not Problem Details / OAuth error shaped, or the body
|
|
12
|
+
* was not JSON at all. Never trust the wire (IMPL_NOTES §1).
|
|
13
|
+
* - `rate_limited` — HTTP 429; `retryAfterSeconds` reflects the `Retry-After`
|
|
14
|
+
* header when present (design.md §22's rate limit surface).
|
|
15
|
+
* - `problem_details` — RFC 9457 body from the API (`code` is the snake_case
|
|
16
|
+
* platform error code, e.g. `invalid_request`, `not_found`).
|
|
17
|
+
* - `oauth_error` — RFC 6749 §5.2 error from the token endpoint (the
|
|
18
|
+
* `OAUTH_ERROR_CODES` set from contracts-public).
|
|
19
|
+
* - `state_mismatch` — an authorization/complete callback carried a `state`
|
|
20
|
+
* the client never issued (CSRF/replay protection, design.md §32.1).
|
|
21
|
+
* - `not_authenticated` — a token was required but none is held and no
|
|
22
|
+
* refresh path exists.
|
|
23
|
+
* - `webauthn_error` — the platform authenticator ceremony failed or is
|
|
24
|
+
* unavailable (design.md §17, §21.2 normalization rule).
|
|
25
|
+
*/
|
|
26
|
+
export interface NetworkFailureError {
|
|
27
|
+
readonly type: "network_error";
|
|
28
|
+
readonly message: string;
|
|
29
|
+
readonly retryable: true;
|
|
30
|
+
}
|
|
31
|
+
export interface InvalidResponseError {
|
|
32
|
+
readonly type: "invalid_response";
|
|
33
|
+
readonly message: string;
|
|
34
|
+
/** HTTP status of the offending response, when a response was received at all. */
|
|
35
|
+
readonly status?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface RateLimitedError {
|
|
38
|
+
readonly type: "rate_limited";
|
|
39
|
+
readonly retryable: true;
|
|
40
|
+
/** Whole-second delay from the `Retry-After` header; absent when the server sent none. */
|
|
41
|
+
readonly retryAfterSeconds?: number;
|
|
42
|
+
readonly requestId?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ProblemDetailsError {
|
|
45
|
+
readonly type: "problem_details";
|
|
46
|
+
/** `type` URI from the Problem Details body. */
|
|
47
|
+
readonly problemType: string;
|
|
48
|
+
/** Platform snake_case error code (`code` extension member). */
|
|
49
|
+
readonly code: string;
|
|
50
|
+
readonly title: string;
|
|
51
|
+
readonly status: number;
|
|
52
|
+
readonly detail?: string;
|
|
53
|
+
readonly requestId?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface OAuthFailureError {
|
|
56
|
+
readonly type: "oauth_error";
|
|
57
|
+
readonly code: OAuthErrorCode;
|
|
58
|
+
readonly description?: string;
|
|
59
|
+
/** HTTP status for token-endpoint errors; absent for front-channel (redirect) errors. */
|
|
60
|
+
readonly status?: number;
|
|
61
|
+
}
|
|
62
|
+
export interface StateMismatchError {
|
|
63
|
+
readonly type: "state_mismatch";
|
|
64
|
+
}
|
|
65
|
+
export interface NotAuthenticatedError {
|
|
66
|
+
readonly type: "not_authenticated";
|
|
67
|
+
}
|
|
68
|
+
export type WebAuthnFailureReason = "not_supported" | "cancelled" | "failed";
|
|
69
|
+
export interface WebAuthnFailureError {
|
|
70
|
+
readonly type: "webauthn_error";
|
|
71
|
+
readonly reason: WebAuthnFailureReason;
|
|
72
|
+
readonly message: string;
|
|
73
|
+
}
|
|
74
|
+
export type BrowserAuthError = NetworkFailureError | InvalidResponseError | RateLimitedError | ProblemDetailsError | OAuthFailureError | StateMismatchError | NotAuthenticatedError | WebAuthnFailureError;
|
|
75
|
+
/** Validated RFC 9457 Problem Details body as sent by the platform (design.md §26.3). */
|
|
76
|
+
export interface ProblemDetailsWire {
|
|
77
|
+
readonly type: string;
|
|
78
|
+
readonly title: string;
|
|
79
|
+
readonly status: number;
|
|
80
|
+
readonly detail?: string;
|
|
81
|
+
readonly code?: string;
|
|
82
|
+
readonly requestId?: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Narrows an already-JSON-parsed body to the Problem Details shape. Field
|
|
86
|
+
* types are checked one by one; extension members are ignored (carried by the
|
|
87
|
+
* server for its own diagnostics, not part of the client contract).
|
|
88
|
+
*/
|
|
89
|
+
export declare const parseProblemDetailsWire: (value: unknown) => ProblemDetailsWire | null;
|
|
90
|
+
/** Validated RFC 6749 §5.2 error body (token endpoint). */
|
|
91
|
+
export interface OAuthErrorWire {
|
|
92
|
+
readonly error: string;
|
|
93
|
+
readonly description?: string;
|
|
94
|
+
}
|
|
95
|
+
export declare const parseOAuthErrorWire: (value: unknown) => OAuthErrorWire | null;
|