@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/src/errors.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { OAuthErrorCode } from "@smartcrab/contracts-public";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error model for `@smartcrab/browser` (design.md §26.3, §32).
|
|
5
|
+
*
|
|
6
|
+
* Every client method resolves to `Outcome<T, BrowserAuthError>`; nothing
|
|
7
|
+
* throws for expected failure modes. The union mirrors what the public API
|
|
8
|
+
* can actually put on the wire:
|
|
9
|
+
*
|
|
10
|
+
* - `network_error` — fetch itself failed (DNS, TLS, abort, CORS). Retryable.
|
|
11
|
+
* - `invalid_response` — a 2xx body failed the contracts-public zod schema,
|
|
12
|
+
* an error body was not Problem Details / OAuth error shaped, or the body
|
|
13
|
+
* was not JSON at all. Never trust the wire (IMPL_NOTES §1).
|
|
14
|
+
* - `rate_limited` — HTTP 429; `retryAfterSeconds` reflects the `Retry-After`
|
|
15
|
+
* header when present (design.md §22's rate limit surface).
|
|
16
|
+
* - `problem_details` — RFC 9457 body from the API (`code` is the snake_case
|
|
17
|
+
* platform error code, e.g. `invalid_request`, `not_found`).
|
|
18
|
+
* - `oauth_error` — RFC 6749 §5.2 error from the token endpoint (the
|
|
19
|
+
* `OAUTH_ERROR_CODES` set from contracts-public).
|
|
20
|
+
* - `state_mismatch` — an authorization/complete callback carried a `state`
|
|
21
|
+
* the client never issued (CSRF/replay protection, design.md §32.1).
|
|
22
|
+
* - `not_authenticated` — a token was required but none is held and no
|
|
23
|
+
* refresh path exists.
|
|
24
|
+
* - `webauthn_error` — the platform authenticator ceremony failed or is
|
|
25
|
+
* unavailable (design.md §17, §21.2 normalization rule).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface NetworkFailureError {
|
|
29
|
+
readonly type: "network_error";
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly retryable: true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InvalidResponseError {
|
|
35
|
+
readonly type: "invalid_response";
|
|
36
|
+
readonly message: string;
|
|
37
|
+
/** HTTP status of the offending response, when a response was received at all. */
|
|
38
|
+
readonly status?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RateLimitedError {
|
|
42
|
+
readonly type: "rate_limited";
|
|
43
|
+
readonly retryable: true;
|
|
44
|
+
/** Whole-second delay from the `Retry-After` header; absent when the server sent none. */
|
|
45
|
+
readonly retryAfterSeconds?: number;
|
|
46
|
+
readonly requestId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ProblemDetailsError {
|
|
50
|
+
readonly type: "problem_details";
|
|
51
|
+
/** `type` URI from the Problem Details body. */
|
|
52
|
+
readonly problemType: string;
|
|
53
|
+
/** Platform snake_case error code (`code` extension member). */
|
|
54
|
+
readonly code: string;
|
|
55
|
+
readonly title: string;
|
|
56
|
+
readonly status: number;
|
|
57
|
+
readonly detail?: string;
|
|
58
|
+
readonly requestId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface OAuthFailureError {
|
|
62
|
+
readonly type: "oauth_error";
|
|
63
|
+
readonly code: OAuthErrorCode;
|
|
64
|
+
readonly description?: string;
|
|
65
|
+
/** HTTP status for token-endpoint errors; absent for front-channel (redirect) errors. */
|
|
66
|
+
readonly status?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StateMismatchError {
|
|
70
|
+
readonly type: "state_mismatch";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface NotAuthenticatedError {
|
|
74
|
+
readonly type: "not_authenticated";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type WebAuthnFailureReason = "not_supported" | "cancelled" | "failed";
|
|
78
|
+
|
|
79
|
+
export interface WebAuthnFailureError {
|
|
80
|
+
readonly type: "webauthn_error";
|
|
81
|
+
readonly reason: WebAuthnFailureReason;
|
|
82
|
+
readonly message: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type BrowserAuthError =
|
|
86
|
+
| NetworkFailureError
|
|
87
|
+
| InvalidResponseError
|
|
88
|
+
| RateLimitedError
|
|
89
|
+
| ProblemDetailsError
|
|
90
|
+
| OAuthFailureError
|
|
91
|
+
| StateMismatchError
|
|
92
|
+
| NotAuthenticatedError
|
|
93
|
+
| WebAuthnFailureError;
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Wire guards (this package has no zod dependency — see package.json — so the
|
|
97
|
+
// two error body shapes are validated with hand-rolled narrowing instead of a
|
|
98
|
+
// schema; the success path always goes through contracts-public schemas).
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Validated RFC 9457 Problem Details body as sent by the platform (design.md §26.3). */
|
|
102
|
+
export interface ProblemDetailsWire {
|
|
103
|
+
readonly type: string;
|
|
104
|
+
readonly title: string;
|
|
105
|
+
readonly status: number;
|
|
106
|
+
readonly detail?: string;
|
|
107
|
+
readonly code?: string;
|
|
108
|
+
readonly requestId?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
112
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Narrows an already-JSON-parsed body to the Problem Details shape. Field
|
|
116
|
+
* types are checked one by one; extension members are ignored (carried by the
|
|
117
|
+
* server for its own diagnostics, not part of the client contract).
|
|
118
|
+
*/
|
|
119
|
+
export const parseProblemDetailsWire = (value: unknown): ProblemDetailsWire | null => {
|
|
120
|
+
if (!isPlainObject(value)) return null;
|
|
121
|
+
if (typeof value["type"] !== "string" || typeof value["title"] !== "string") return null;
|
|
122
|
+
if (typeof value["status"] !== "number" || !Number.isInteger(value["status"])) return null;
|
|
123
|
+
const detail = value["detail"];
|
|
124
|
+
const code = value["code"];
|
|
125
|
+
const requestId = value["request_id"];
|
|
126
|
+
if (detail !== undefined && typeof detail !== "string") return null;
|
|
127
|
+
if (code !== undefined && typeof code !== "string") return null;
|
|
128
|
+
if (requestId !== undefined && typeof requestId !== "string") return null;
|
|
129
|
+
return {
|
|
130
|
+
type: value["type"],
|
|
131
|
+
title: value["title"],
|
|
132
|
+
status: value["status"],
|
|
133
|
+
...(detail !== undefined ? { detail } : {}),
|
|
134
|
+
...(code !== undefined ? { code } : {}),
|
|
135
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Validated RFC 6749 §5.2 error body (token endpoint). */
|
|
140
|
+
export interface OAuthErrorWire {
|
|
141
|
+
readonly error: string;
|
|
142
|
+
readonly description?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const parseOAuthErrorWire = (value: unknown): OAuthErrorWire | null => {
|
|
146
|
+
if (!isPlainObject(value)) return null;
|
|
147
|
+
if (typeof value["error"] !== "string" || value["error"].length === 0) return null;
|
|
148
|
+
const description = value["error_description"];
|
|
149
|
+
if (description !== undefined && typeof description !== "string") return null;
|
|
150
|
+
return {
|
|
151
|
+
error: value["error"],
|
|
152
|
+
...(description !== undefined ? { description } : {}),
|
|
153
|
+
};
|
|
154
|
+
};
|
package/src/http.test.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { buildUrl, requestJson } from "./http.js";
|
|
3
|
+
import type { FetchPort, WireSchema } from "./http.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transport-level error normalization (design.md §26.3 Problem Details,
|
|
7
|
+
* RFC 6749 §5.2 OAuth errors, rate limiting with Retry-After).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A trivial schema standing in for the contracts-public zod schemas. */
|
|
11
|
+
const stringSchema: WireSchema<{ value: string }> = {
|
|
12
|
+
safeParse(input) {
|
|
13
|
+
if (
|
|
14
|
+
typeof input === "object" &&
|
|
15
|
+
input !== null &&
|
|
16
|
+
"value" in input &&
|
|
17
|
+
typeof input.value === "string"
|
|
18
|
+
) {
|
|
19
|
+
return { success: true, data: { value: input.value } };
|
|
20
|
+
}
|
|
21
|
+
return { success: false, error: new Error("bad shape") };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const jsonResponse = (status: number, body: unknown, headers?: Record<string, string>): Response =>
|
|
26
|
+
new Response(JSON.stringify(body), { status, ...(headers !== undefined ? { headers } : {}) });
|
|
27
|
+
|
|
28
|
+
const fetchReturning =
|
|
29
|
+
(response: Response): FetchPort =>
|
|
30
|
+
() =>
|
|
31
|
+
Promise.resolve(response);
|
|
32
|
+
|
|
33
|
+
const fetchRejecting =
|
|
34
|
+
(cause: unknown): FetchPort =>
|
|
35
|
+
() =>
|
|
36
|
+
Promise.reject(cause);
|
|
37
|
+
|
|
38
|
+
describe("requestJson — success path", () => {
|
|
39
|
+
test("serializes method, headers and JSON body exactly", async () => {
|
|
40
|
+
const seen: { url?: string; init?: RequestInit } = {};
|
|
41
|
+
const fetchPort: FetchPort = (url, init) => {
|
|
42
|
+
seen.url = url;
|
|
43
|
+
seen.init = init;
|
|
44
|
+
return Promise.resolve(jsonResponse(200, { value: "ok" }));
|
|
45
|
+
};
|
|
46
|
+
const outcome = await requestJson(
|
|
47
|
+
fetchPort,
|
|
48
|
+
{ method: "POST", url: "https://id.example.com/v1/x", body: { a: 1 }, accessToken: "at_1" },
|
|
49
|
+
stringSchema,
|
|
50
|
+
);
|
|
51
|
+
expect(outcome).toEqual({ ok: true, value: { value: "ok" } });
|
|
52
|
+
expect(seen.init?.method).toBe("POST");
|
|
53
|
+
expect(new Headers(seen.init?.headers).get("content-type")).toBe("application/json");
|
|
54
|
+
expect(new Headers(seen.init?.headers).get("authorization")).toBe("Bearer at_1");
|
|
55
|
+
expect(new Headers(seen.init?.headers).get("accept")).toBe("application/json");
|
|
56
|
+
expect(seen.init?.body).toBe(JSON.stringify({ a: 1 }));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("GET without body sends no content-type and no body", async () => {
|
|
60
|
+
const seen: { init?: RequestInit } = {};
|
|
61
|
+
const fetchPort: FetchPort = (_url, init) => {
|
|
62
|
+
seen.init = init;
|
|
63
|
+
return Promise.resolve(jsonResponse(200, { value: "ok" }));
|
|
64
|
+
};
|
|
65
|
+
await requestJson(
|
|
66
|
+
fetchPort,
|
|
67
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
68
|
+
stringSchema,
|
|
69
|
+
);
|
|
70
|
+
expect(new Headers(seen.init?.headers).get("content-type")).toBeNull();
|
|
71
|
+
expect(seen.init?.body).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("2xx body failing the schema is invalid_response", async () => {
|
|
75
|
+
const outcome = await requestJson(
|
|
76
|
+
fetchReturning(jsonResponse(200, { unexpected: true })),
|
|
77
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
78
|
+
stringSchema,
|
|
79
|
+
);
|
|
80
|
+
expect(outcome.ok).toBe(false);
|
|
81
|
+
if (!outcome.ok) {
|
|
82
|
+
expect(outcome.error.type).toBe("invalid_response");
|
|
83
|
+
if (outcome.error.type === "invalid_response") {
|
|
84
|
+
expect(outcome.error.status).toBe(200);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("requestJson — error normalization", () => {
|
|
91
|
+
test("fetch rejection maps to retryable network_error", async () => {
|
|
92
|
+
const outcome = await requestJson(
|
|
93
|
+
fetchRejecting(new TypeError("Failed to fetch")),
|
|
94
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
95
|
+
stringSchema,
|
|
96
|
+
);
|
|
97
|
+
expect(outcome).toEqual({
|
|
98
|
+
ok: false,
|
|
99
|
+
error: { type: "network_error", message: "Failed to fetch", retryable: true },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("429 with Retry-After maps to rate_limited with retryAfterSeconds", async () => {
|
|
104
|
+
const outcome = await requestJson(
|
|
105
|
+
fetchReturning(jsonResponse(429, {}, { "retry-after": "17" })),
|
|
106
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
107
|
+
stringSchema,
|
|
108
|
+
);
|
|
109
|
+
expect(outcome.ok).toBe(false);
|
|
110
|
+
if (!outcome.ok) {
|
|
111
|
+
expect(outcome.error).toMatchObject({
|
|
112
|
+
type: "rate_limited",
|
|
113
|
+
retryable: true,
|
|
114
|
+
retryAfterSeconds: 17,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("429 without Retry-After omits retryAfterSeconds", async () => {
|
|
120
|
+
const outcome = await requestJson(
|
|
121
|
+
fetchReturning(jsonResponse(429, {})),
|
|
122
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
123
|
+
stringSchema,
|
|
124
|
+
);
|
|
125
|
+
expect(outcome.ok).toBe(false);
|
|
126
|
+
if (!outcome.ok && outcome.error.type === "rate_limited") {
|
|
127
|
+
expect(outcome.error.retryAfterSeconds).toBeUndefined();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("4xx Problem Details body maps to problem_details with code/title/status/requestId", async () => {
|
|
132
|
+
const problem = {
|
|
133
|
+
type: "https://docs.example-auth.com/errors/not-found",
|
|
134
|
+
title: "Not Found",
|
|
135
|
+
status: 404,
|
|
136
|
+
detail: "transaction does not exist",
|
|
137
|
+
code: "not_found",
|
|
138
|
+
request_id: "req_123",
|
|
139
|
+
};
|
|
140
|
+
const outcome = await requestJson(
|
|
141
|
+
fetchReturning(jsonResponse(404, problem)),
|
|
142
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
143
|
+
stringSchema,
|
|
144
|
+
);
|
|
145
|
+
expect(outcome.ok).toBe(false);
|
|
146
|
+
if (!outcome.ok) {
|
|
147
|
+
expect(outcome.error).toEqual({
|
|
148
|
+
type: "problem_details",
|
|
149
|
+
problemType: problem.type,
|
|
150
|
+
code: "not_found",
|
|
151
|
+
title: "Not Found",
|
|
152
|
+
status: 404,
|
|
153
|
+
detail: "transaction does not exist",
|
|
154
|
+
requestId: "req_123",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("OAuth error body maps to oauth_error with a known code", async () => {
|
|
160
|
+
const outcome = await requestJson(
|
|
161
|
+
fetchReturning(
|
|
162
|
+
jsonResponse(400, { error: "invalid_grant", error_description: "code expired" }),
|
|
163
|
+
),
|
|
164
|
+
{ method: "POST", url: "https://id.example.com/e/env/oauth2/token" },
|
|
165
|
+
stringSchema,
|
|
166
|
+
);
|
|
167
|
+
expect(outcome.ok).toBe(false);
|
|
168
|
+
if (!outcome.ok) {
|
|
169
|
+
expect(outcome.error).toEqual({
|
|
170
|
+
type: "oauth_error",
|
|
171
|
+
code: "invalid_grant",
|
|
172
|
+
description: "code expired",
|
|
173
|
+
status: 400,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("an unknown OAuth error code is not trusted → invalid_response", async () => {
|
|
179
|
+
const outcome = await requestJson(
|
|
180
|
+
fetchReturning(jsonResponse(400, { error: "made_up_code" })),
|
|
181
|
+
{ method: "POST", url: "https://id.example.com/e/env/oauth2/token" },
|
|
182
|
+
stringSchema,
|
|
183
|
+
);
|
|
184
|
+
expect(outcome.ok).toBe(false);
|
|
185
|
+
if (!outcome.ok) {
|
|
186
|
+
expect(outcome.error.type).toBe("invalid_response");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("malformed JSON body maps to invalid_response", async () => {
|
|
191
|
+
const outcome = await requestJson(
|
|
192
|
+
fetchReturning(new Response("not json at all{", { status: 200 })),
|
|
193
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
194
|
+
stringSchema,
|
|
195
|
+
);
|
|
196
|
+
expect(outcome.ok).toBe(false);
|
|
197
|
+
if (!outcome.ok) {
|
|
198
|
+
expect(outcome.error.type).toBe("invalid_response");
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("error status with a non-Problem, non-OAuth body maps to invalid_response", async () => {
|
|
203
|
+
const outcome = await requestJson(
|
|
204
|
+
fetchReturning(jsonResponse(500, { oops: true })),
|
|
205
|
+
{ method: "GET", url: "https://id.example.com/v1/x" },
|
|
206
|
+
stringSchema,
|
|
207
|
+
);
|
|
208
|
+
expect(outcome.ok).toBe(false);
|
|
209
|
+
if (!outcome.ok) {
|
|
210
|
+
expect(outcome.error.type).toBe("invalid_response");
|
|
211
|
+
if (outcome.error.type === "invalid_response") {
|
|
212
|
+
expect(outcome.error.status).toBe(500);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("buildUrl", () => {
|
|
219
|
+
test("joins base and path, appending an encoded query string", () => {
|
|
220
|
+
expect(buildUrl("https://id.example.com", "/v1/config", { client_id: "cli_x y" })).toBe(
|
|
221
|
+
"https://id.example.com/v1/config?client_id=cli_x+y",
|
|
222
|
+
);
|
|
223
|
+
expect(buildUrl("https://id.example.com", "/v1/me")).toBe("https://id.example.com/v1/me");
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Outcome } from "@smartcrab/contracts-public";
|
|
2
|
+
import { err, ok } from "@smartcrab/contracts-public";
|
|
3
|
+
import { OAUTH_ERROR_CODES } from "@smartcrab/contracts-public";
|
|
4
|
+
import type { OAuthErrorCode } from "@smartcrab/contracts-public";
|
|
5
|
+
import { parseOAuthErrorWire, parseProblemDetailsWire } from "./errors.js";
|
|
6
|
+
import type { BrowserAuthError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HTTP transport for the public API (design.md §16.2) and the token endpoint
|
|
10
|
+
* (design.md §15.1).
|
|
11
|
+
*
|
|
12
|
+
* Responsibilities:
|
|
13
|
+
* - exact request serialization (JSON bodies, Bearer header, query strings)
|
|
14
|
+
* - response normalization into `BrowserAuthError` (network failure, 429 with
|
|
15
|
+
* `Retry-After`, RFC 9457 Problem Details, RFC 6749 OAuth errors, and
|
|
16
|
+
* `invalid_response` for anything that does not match the wire contract)
|
|
17
|
+
* - success-path validation through the contracts-public zod schemas — the
|
|
18
|
+
* wire is never trusted (IMPL_NOTES §1)
|
|
19
|
+
*
|
|
20
|
+
* Exception boundaries are promise rejection handlers (`.then(onOk, onErr)`),
|
|
21
|
+
* never `try`/`catch` statements: `scripts/check-backend-error-style.ts`
|
|
22
|
+
* (design.md §5.2) scans this package and rejects direct try-catch.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Minimal structural view of a zod schema's `safeParse` (this package has no zod dependency). */
|
|
26
|
+
export interface WireSchema<T> {
|
|
27
|
+
safeParse(input: unknown): { success: true; data: T } | { success: false; error: unknown };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type FetchPort = (url: string, init: RequestInit) => Promise<Response>;
|
|
31
|
+
|
|
32
|
+
export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
|
33
|
+
|
|
34
|
+
export interface JsonRequest {
|
|
35
|
+
readonly method: HttpMethod;
|
|
36
|
+
/** Fully-built URL, including query string. */
|
|
37
|
+
readonly url: string;
|
|
38
|
+
readonly body?: unknown;
|
|
39
|
+
readonly accessToken?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const OAUTH_ERROR_CODE_SET: ReadonlySet<string> = new Set(OAUTH_ERROR_CODES);
|
|
43
|
+
|
|
44
|
+
export const isOAuthErrorCode = (value: string): value is OAuthErrorCode =>
|
|
45
|
+
OAUTH_ERROR_CODE_SET.has(value);
|
|
46
|
+
|
|
47
|
+
const parseRetryAfterSeconds = (response: Response): number | undefined => {
|
|
48
|
+
const raw = response.headers.get("retry-after");
|
|
49
|
+
if (raw === null) return undefined;
|
|
50
|
+
// Only the delta-seconds form is honored; HTTP-date form is intentionally
|
|
51
|
+
// unsupported (the platform always emits delta-seconds).
|
|
52
|
+
if (!/^[0-9]+$/.test(raw.trim())) return undefined;
|
|
53
|
+
return Number.parseInt(raw.trim(), 10);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const invalidResponse = (message: string, status?: number): Outcome<never, BrowserAuthError> =>
|
|
57
|
+
err({
|
|
58
|
+
type: "invalid_response",
|
|
59
|
+
message,
|
|
60
|
+
...(status !== undefined ? { status } : {}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const toNetworkError = (cause: unknown): Outcome<never, BrowserAuthError> =>
|
|
64
|
+
err({
|
|
65
|
+
type: "network_error",
|
|
66
|
+
message: cause instanceof Error ? cause.message : "Network request failed",
|
|
67
|
+
retryable: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** `JSON.parse` without a try statement: the throw becomes a rejection inside the promise chain. */
|
|
71
|
+
const parseJsonBody = (text: string, status: number): Promise<Outcome<unknown, BrowserAuthError>> =>
|
|
72
|
+
Promise.resolve()
|
|
73
|
+
.then((): unknown => JSON.parse(text))
|
|
74
|
+
.then(
|
|
75
|
+
(value) => ok(value),
|
|
76
|
+
() => invalidResponse("Response body was not valid JSON", status),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Executes one JSON request and validates the response against `schema`.
|
|
81
|
+
*
|
|
82
|
+
* Error precedence on non-2xx: 429 → `rate_limited`; a valid Problem Details
|
|
83
|
+
* body → `problem_details`; a valid OAuth error body → `oauth_error`;
|
|
84
|
+
* anything else → `invalid_response`.
|
|
85
|
+
*/
|
|
86
|
+
export const requestJson = async <T>(
|
|
87
|
+
fetchPort: FetchPort,
|
|
88
|
+
request: JsonRequest,
|
|
89
|
+
schema: WireSchema<T>,
|
|
90
|
+
): Promise<Outcome<T, BrowserAuthError>> => {
|
|
91
|
+
const headers: Record<string, string> = {
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
...(request.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
94
|
+
...(request.accessToken !== undefined
|
|
95
|
+
? { authorization: `Bearer ${request.accessToken}` }
|
|
96
|
+
: {}),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const responseOutcome = await fetchPort(request.url, {
|
|
100
|
+
method: request.method,
|
|
101
|
+
headers,
|
|
102
|
+
...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}),
|
|
103
|
+
}).then((response) => ok<Response, BrowserAuthError>(response), toNetworkError);
|
|
104
|
+
if (!responseOutcome.ok) return responseOutcome;
|
|
105
|
+
const response = responseOutcome.value;
|
|
106
|
+
|
|
107
|
+
// A rejection while streaming the body is still a transport failure.
|
|
108
|
+
const textOutcome = await response
|
|
109
|
+
.text()
|
|
110
|
+
.then((text) => ok<string, BrowserAuthError>(text), toNetworkError);
|
|
111
|
+
if (!textOutcome.ok) return textOutcome;
|
|
112
|
+
const text = textOutcome.value;
|
|
113
|
+
|
|
114
|
+
const jsonOutcome: Outcome<unknown, BrowserAuthError> =
|
|
115
|
+
text.length === 0 ? ok(undefined) : await parseJsonBody(text, response.status);
|
|
116
|
+
if (!jsonOutcome.ok) return jsonOutcome;
|
|
117
|
+
const json = jsonOutcome.value;
|
|
118
|
+
|
|
119
|
+
if (response.status === 429) {
|
|
120
|
+
const retryAfterSeconds = parseRetryAfterSeconds(response);
|
|
121
|
+
const problem = parseProblemDetailsWire(json);
|
|
122
|
+
return err({
|
|
123
|
+
type: "rate_limited",
|
|
124
|
+
retryable: true,
|
|
125
|
+
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
|
|
126
|
+
...(problem !== null && problem.requestId !== undefined
|
|
127
|
+
? { requestId: problem.requestId }
|
|
128
|
+
: {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const problem = parseProblemDetailsWire(json);
|
|
134
|
+
if (problem !== null) {
|
|
135
|
+
return err({
|
|
136
|
+
type: "problem_details",
|
|
137
|
+
problemType: problem.type,
|
|
138
|
+
code: problem.code ?? "unknown_error",
|
|
139
|
+
title: problem.title,
|
|
140
|
+
status: problem.status,
|
|
141
|
+
...(problem.detail !== undefined ? { detail: problem.detail } : {}),
|
|
142
|
+
...(problem.requestId !== undefined ? { requestId: problem.requestId } : {}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const oauthError = parseOAuthErrorWire(json);
|
|
146
|
+
if (oauthError !== null && isOAuthErrorCode(oauthError.error)) {
|
|
147
|
+
return err({
|
|
148
|
+
type: "oauth_error",
|
|
149
|
+
code: oauthError.error,
|
|
150
|
+
status: response.status,
|
|
151
|
+
...(oauthError.description !== undefined ? { description: oauthError.description } : {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return invalidResponse(
|
|
155
|
+
`HTTP ${response.status} error body was neither Problem Details nor an OAuth error`,
|
|
156
|
+
response.status,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parsed = schema.safeParse(json);
|
|
161
|
+
if (!parsed.success) {
|
|
162
|
+
return invalidResponse("Response body did not match the public API contract", response.status);
|
|
163
|
+
}
|
|
164
|
+
return ok(parsed.data);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Builds `base + path` plus an optional query string. */
|
|
168
|
+
export const buildUrl = (baseUrl: string, path: string, query?: Record<string, string>): string => {
|
|
169
|
+
const url = `${baseUrl}${path}`;
|
|
170
|
+
if (query === undefined || Object.keys(query).length === 0) return url;
|
|
171
|
+
const params = new URLSearchParams(query);
|
|
172
|
+
return `${url}?${params.toString()}`;
|
|
173
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @smartcrab/browser — dependency-free SPA client for the passwordless
|
|
3
|
+
* auth platform's public API (design.md §16 transaction API, §15 token
|
|
4
|
+
* endpoint, §22.3 SPA rules).
|
|
5
|
+
*
|
|
6
|
+
* - Every request/response goes through the `@smartcrab/contracts-public`
|
|
7
|
+
* zod schemas; malformed wire data surfaces as typed `invalid_response`
|
|
8
|
+
* failures, never as trusted objects.
|
|
9
|
+
* - Access tokens live in memory only (design.md §22.3); refresh-token
|
|
10
|
+
* persistence is an explicit, documented opt-in.
|
|
11
|
+
* - Fallible methods return `Outcome<T, BrowserAuthError>` — the local
|
|
12
|
+
* discriminated-union channel from contracts-public, because
|
|
13
|
+
* `@praha/byethrow` is not resolvable from this package (not a declared
|
|
14
|
+
* dependency).
|
|
15
|
+
*/
|
|
16
|
+
export { createBrowserAuthClient } from "./client.js";
|
|
17
|
+
export type {
|
|
18
|
+
AuthenticatedSession,
|
|
19
|
+
BrowserAuthClient,
|
|
20
|
+
BrowserAuthClientConfig,
|
|
21
|
+
BrowserAuthListener,
|
|
22
|
+
BrowserAuthSnapshot,
|
|
23
|
+
BrowserTransaction,
|
|
24
|
+
CreateTransactionInput,
|
|
25
|
+
HostedAuthorization,
|
|
26
|
+
RedirectPort,
|
|
27
|
+
RefreshTokenStoragePort,
|
|
28
|
+
} from "./client.js";
|
|
29
|
+
export { parseOAuthErrorWire, parseProblemDetailsWire } from "./errors.js";
|
|
30
|
+
export type {
|
|
31
|
+
BrowserAuthError,
|
|
32
|
+
InvalidResponseError,
|
|
33
|
+
NetworkFailureError,
|
|
34
|
+
NotAuthenticatedError,
|
|
35
|
+
OAuthErrorWire,
|
|
36
|
+
OAuthFailureError,
|
|
37
|
+
ProblemDetailsError,
|
|
38
|
+
ProblemDetailsWire,
|
|
39
|
+
RateLimitedError,
|
|
40
|
+
StateMismatchError,
|
|
41
|
+
WebAuthnFailureError,
|
|
42
|
+
WebAuthnFailureReason,
|
|
43
|
+
} from "./errors.js";
|
|
44
|
+
export { buildUrl, requestJson } from "./http.js";
|
|
45
|
+
export type { FetchPort, HttpMethod, JsonRequest, WireSchema } from "./http.js";
|
|
46
|
+
export { generateNonce, generatePkcePair, generateState } from "./pkce.js";
|
|
47
|
+
export type { PkcePair } from "./pkce.js";
|
|
48
|
+
export { base64UrlDecodeToBytes, base64UrlEncodeBytes, bufferToBase64Url } from "./base64url.js";
|
|
49
|
+
export { navigatorWebAuthnPort } from "./webauthn.js";
|
|
50
|
+
export type { WebAuthnBrowserPort } from "./webauthn.js";
|
|
51
|
+
|
|
52
|
+
// Selected contract types re-exported so downstream SDKs (e.g.
|
|
53
|
+
// `@smartcrab/react`, which cannot depend on contracts-public directly)
|
|
54
|
+
// can type their own surfaces against the exact wire shapes. `ok`/`err` are
|
|
55
|
+
// re-exported as values so consumers can construct `Outcome`s themselves.
|
|
56
|
+
export { err, ok } from "@smartcrab/contracts-public";
|
|
57
|
+
export type {
|
|
58
|
+
EmailChallengeResponse,
|
|
59
|
+
MeIdentitySummary,
|
|
60
|
+
MeSessionSummary,
|
|
61
|
+
MeUpdateRequest,
|
|
62
|
+
MeUser,
|
|
63
|
+
Outcome,
|
|
64
|
+
PasskeySummary,
|
|
65
|
+
PublicConfigResponse,
|
|
66
|
+
SocialProvider,
|
|
67
|
+
TransactionStatus,
|
|
68
|
+
} from "@smartcrab/contracts-public";
|
package/src/pkce.test.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { base64UrlEncodeBytes } from "./base64url.js";
|
|
3
|
+
import { generateNonce, generatePkcePair, generateState } from "./pkce.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PKCE/state generation (design.md §32.1: S256 only, state/nonce 必須;
|
|
7
|
+
* RFC 7636 §4.1/§4.2 shape rules).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
11
|
+
const RFC7636_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
12
|
+
|
|
13
|
+
describe("generatePkcePair", () => {
|
|
14
|
+
test("verifier is 43 chars in the RFC 7636 unreserved charset", () => {
|
|
15
|
+
return generatePkcePair().then((pair) => {
|
|
16
|
+
expect(pair.verifier).toHaveLength(43);
|
|
17
|
+
expect(pair.verifier).toMatch(RFC7636_VERIFIER_PATTERN);
|
|
18
|
+
expect(pair.method).toBe("S256");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("challenge is 43-char base64url and equals base64url(SHA-256(verifier))", async () => {
|
|
23
|
+
const pair = await generatePkcePair();
|
|
24
|
+
expect(pair.challenge).toHaveLength(43);
|
|
25
|
+
expect(pair.challenge).toMatch(BASE64URL_PATTERN);
|
|
26
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pair.verifier));
|
|
27
|
+
expect(pair.challenge).toBe(base64UrlEncodeBytes(new Uint8Array(digest)));
|
|
28
|
+
// S256 must actually transform the verifier.
|
|
29
|
+
expect(pair.challenge).not.toBe(pair.verifier);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("pairs are unique across generations", async () => {
|
|
33
|
+
const [a, b] = await Promise.all([generatePkcePair(), generatePkcePair()]);
|
|
34
|
+
expect(a.verifier).not.toBe(b.verifier);
|
|
35
|
+
expect(a.challenge).not.toBe(b.challenge);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("generateState / generateNonce", () => {
|
|
40
|
+
test("are 43-char base64url values (256 bits of entropy)", () => {
|
|
41
|
+
for (const value of [generateState(), generateNonce()]) {
|
|
42
|
+
expect(value).toHaveLength(43);
|
|
43
|
+
expect(value).toMatch(BASE64URL_PATTERN);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("never repeat across a batch", () => {
|
|
48
|
+
const values = new Set(Array.from({ length: 100 }, () => generateState()));
|
|
49
|
+
expect(values.size).toBe(100);
|
|
50
|
+
});
|
|
51
|
+
});
|