@hearth-auth/sdk 0.0.1 → 1.0.1

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.
Files changed (83) hide show
  1. package/dist/admin.d.ts +43 -0
  2. package/dist/admin.js +126 -0
  3. package/dist/admin.js.map +1 -0
  4. package/dist/browser-auth.d.ts +32 -0
  5. package/dist/browser-auth.js +99 -0
  6. package/dist/browser-auth.js.map +1 -0
  7. package/dist/claims.d.ts +86 -0
  8. package/dist/claims.js +137 -0
  9. package/dist/claims.js.map +1 -0
  10. package/dist/client.d.ts +77 -0
  11. package/dist/client.js +190 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors.d.ts +114 -0
  14. package/{src/errors.ts → dist/errors.js} +83 -97
  15. package/dist/errors.js.map +1 -0
  16. package/dist/hearth-client.d.ts +133 -0
  17. package/dist/hearth-client.js +192 -0
  18. package/dist/hearth-client.js.map +1 -0
  19. package/dist/hearth.d.ts +105 -0
  20. package/dist/hearth.js +109 -0
  21. package/dist/hearth.js.map +1 -0
  22. package/dist/index.d.ts +23 -0
  23. package/dist/index.js +22 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/introspection-client.d.ts +59 -0
  26. package/dist/introspection-client.js +36 -0
  27. package/dist/introspection-client.js.map +1 -0
  28. package/dist/jwks-client.d.ts +28 -0
  29. package/dist/jwks-client.js +28 -0
  30. package/dist/jwks-client.js.map +1 -0
  31. package/dist/middleware.d.ts +38 -0
  32. package/dist/middleware.js +51 -0
  33. package/dist/middleware.js.map +1 -0
  34. package/dist/pkce.d.ts +64 -0
  35. package/dist/pkce.js +64 -0
  36. package/dist/pkce.js.map +1 -0
  37. package/dist/react.d.ts +32 -0
  38. package/dist/react.js +41 -0
  39. package/dist/react.js.map +1 -0
  40. package/dist/session-version-cache.d.ts +50 -0
  41. package/dist/session-version-cache.js +129 -0
  42. package/dist/session-version-cache.js.map +1 -0
  43. package/dist/types.d.ts +168 -0
  44. package/dist/types.js +2 -0
  45. package/dist/types.js.map +1 -0
  46. package/package.json +13 -4
  47. package/CHANGELOG.md +0 -12
  48. package/src/admin.ts +0 -157
  49. package/src/browser-auth.ts +0 -130
  50. package/src/claims.ts +0 -180
  51. package/src/client.ts +0 -251
  52. package/src/generated/google/api/annotations_pb.ts +0 -44
  53. package/src/generated/google/api/http_pb.ts +0 -467
  54. package/src/generated/hearth/authz/v1/authz_pb.ts +0 -593
  55. package/src/generated/hearth/cluster/v1/raft_pb.ts +0 -183
  56. package/src/generated/hearth/events/v1/audit_pb.ts +0 -886
  57. package/src/generated/hearth/identity/v1/identity_pb.ts +0 -1673
  58. package/src/generated/hearth/identity/v1/oauth_pb.ts +0 -1138
  59. package/src/generated/hearth/rbac/v1/rbac_pb.ts +0 -2000
  60. package/src/hearth-client.ts +0 -288
  61. package/src/hearth.ts +0 -224
  62. package/src/index.ts +0 -106
  63. package/src/introspection-client.ts +0 -83
  64. package/src/jwks-client.ts +0 -45
  65. package/src/middleware.ts +0 -82
  66. package/src/pkce.ts +0 -129
  67. package/src/react.tsx +0 -57
  68. package/src/session-version-cache.ts +0 -167
  69. package/src/types.ts +0 -188
  70. package/tests/admin-crud.test.ts +0 -97
  71. package/tests/auth-flow.test.ts +0 -75
  72. package/tests/authorize.test.ts +0 -386
  73. package/tests/claims.test.ts +0 -159
  74. package/tests/hasPermission.test.ts +0 -152
  75. package/tests/hearth-client.test.ts +0 -243
  76. package/tests/helpers.ts +0 -90
  77. package/tests/jwks.test.ts +0 -62
  78. package/tests/pkce.test.ts +0 -210
  79. package/tests/react-useHasPermission.test.tsx +0 -92
  80. package/tests/required-action.test.ts +0 -276
  81. package/tests/session-version.test.ts +0 -391
  82. package/tsconfig.json +0 -16
  83. package/vitest.config.ts +0 -8
@@ -1,62 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import * as jose from "jose";
3
- import { ensureBinary, startServer, stopServer, type TestServer } from "./helpers.js";
4
-
5
- describe("TypeScript SDK: JWKS Validation", () => {
6
- let server: TestServer;
7
-
8
- beforeAll(async () => {
9
- ensureBinary();
10
- server = await startServer();
11
- });
12
-
13
- afterAll(() => {
14
- if (server) stopServer(server);
15
- });
16
-
17
- it("verifies token signatures using JWKS-fetched public keys", async () => {
18
- const { client, bootstrap } = server;
19
-
20
- // 1. Fetch the JWKS document
21
- const jwks = await client.jwks();
22
- expect(jwks.keys).toBeTruthy();
23
- expect(jwks.keys.length).toBeGreaterThan(0);
24
-
25
- // Verify the key is an Ed25519 (OKP) key
26
- const key = jwks.keys[0];
27
- expect(key.kty).toBe("OKP");
28
- expect(key.crv).toBe("Ed25519");
29
- expect(key.x).toBeTruthy();
30
- expect(key.alg).toBe("EdDSA");
31
- expect(key.use).toBe("sig");
32
- expect(key.kid).toBeTruthy();
33
-
34
- // 2. Import the public key for verification
35
- const publicKey = await jose.importJWK(key as jose.JWK, "EdDSA");
36
-
37
- // 3. The bootstrap access token is signed with the global key (same as /jwks)
38
- // Verify it directly against the JWKS-fetched key
39
- const { payload: accessPayload } = await jose.jwtVerify(
40
- bootstrap.access_token,
41
- publicKey,
42
- );
43
- expect(accessPayload.sub).toBeTruthy();
44
- expect(accessPayload.exp).toBeTruthy();
45
-
46
- // 4. Verify the OIDC discovery document references the JWKS endpoint
47
- const discovery = await client.discovery();
48
- expect(discovery.jwks_uri).toBeTruthy();
49
-
50
- // 5. Verify the JWT header contains the correct kid and alg
51
- const header = jose.decodeProtectedHeader(bootstrap.access_token);
52
- expect(header.alg).toBe("EdDSA");
53
- expect(header.kid).toBe(key.kid);
54
- expect(header.typ).toBe("JWT");
55
-
56
- // 6. Verify a tampered token fails verification
57
- const tampered = bootstrap.access_token.slice(0, -4) + "XXXX";
58
- await expect(
59
- jose.jwtVerify(tampered, publicKey),
60
- ).rejects.toThrow();
61
- });
62
- });
@@ -1,210 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import {
3
- generateCodeVerifier,
4
- generateCodeChallenge,
5
- buildAuthorizationUrl,
6
- startLogin,
7
- } from "../src/pkce.js";
8
- import { HearthApiClient } from "../src/client.js";
9
-
10
- // RFC 7636 Appendix B test vector.
11
- const RFC_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
12
- const RFC_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
13
-
14
- const BASE64URL_RE = /^[A-Za-z0-9\-_]+$/;
15
-
16
- // ---------------------------------------------------------------------------
17
- // generateCodeVerifier
18
- // ---------------------------------------------------------------------------
19
-
20
- describe("generateCodeVerifier", () => {
21
- it("returns a 43-character base64url string (32 bytes of entropy)", () => {
22
- const verifier = generateCodeVerifier();
23
- expect(verifier).toHaveLength(43);
24
- expect(verifier).toMatch(BASE64URL_RE);
25
- });
26
-
27
- it("contains no padding characters", () => {
28
- const verifier = generateCodeVerifier();
29
- expect(verifier).not.toContain("=");
30
- expect(verifier).not.toContain("+");
31
- expect(verifier).not.toContain("/");
32
- });
33
-
34
- it("produces unique values (entropy check)", () => {
35
- const values = new Set(Array.from({ length: 20 }, () => generateCodeVerifier()));
36
- expect(values.size).toBe(20);
37
- });
38
- });
39
-
40
- // ---------------------------------------------------------------------------
41
- // generateCodeChallenge
42
- // ---------------------------------------------------------------------------
43
-
44
- describe("generateCodeChallenge", () => {
45
- it("derives the correct S256 challenge from the RFC 7636 Appendix B vector", async () => {
46
- const challenge = await generateCodeChallenge(RFC_VERIFIER);
47
- expect(challenge).toBe(RFC_CHALLENGE);
48
- });
49
-
50
- it("produces a 43-character base64url string (SHA-256 = 32 bytes)", async () => {
51
- const challenge = await generateCodeChallenge(generateCodeVerifier());
52
- expect(challenge).toHaveLength(43);
53
- expect(challenge).toMatch(BASE64URL_RE);
54
- });
55
-
56
- it("produces distinct challenges for distinct verifiers", async () => {
57
- const a = await generateCodeChallenge("verifier-a");
58
- const b = await generateCodeChallenge("verifier-b");
59
- expect(a).not.toBe(b);
60
- });
61
- });
62
-
63
- // ---------------------------------------------------------------------------
64
- // buildAuthorizationUrl
65
- // ---------------------------------------------------------------------------
66
-
67
- describe("buildAuthorizationUrl", () => {
68
- const BASE_OPTS = {
69
- authorizationEndpoint: "https://auth.example.com/oauth/authorize",
70
- clientId: "my-client",
71
- redirectUri: "https://app.example.com/callback",
72
- codeChallenge: RFC_CHALLENGE,
73
- };
74
-
75
- it("includes all required PKCE and OIDC parameters", () => {
76
- const { url } = buildAuthorizationUrl(BASE_OPTS);
77
- const p = new URL(url).searchParams;
78
- expect(p.get("response_type")).toBe("code");
79
- expect(p.get("client_id")).toBe("my-client");
80
- expect(p.get("redirect_uri")).toBe("https://app.example.com/callback");
81
- expect(p.get("code_challenge")).toBe(RFC_CHALLENGE);
82
- expect(p.get("code_challenge_method")).toBe("S256");
83
- });
84
-
85
- it("defaults scope to 'openid profile email' when omitted", () => {
86
- const { url } = buildAuthorizationUrl(BASE_OPTS);
87
- expect(new URL(url).searchParams.get("scope")).toBe("openid profile email");
88
- });
89
-
90
- it("uses the caller-supplied scope", () => {
91
- const { url } = buildAuthorizationUrl({ ...BASE_OPTS, scope: "openid offline_access" });
92
- expect(new URL(url).searchParams.get("scope")).toBe("openid offline_access");
93
- });
94
-
95
- it("auto-generates a non-empty state when not provided", () => {
96
- const { url, state } = buildAuthorizationUrl(BASE_OPTS);
97
- expect(state).toBeTruthy();
98
- expect(state).toMatch(BASE64URL_RE);
99
- expect(new URL(url).searchParams.get("state")).toBe(state);
100
- });
101
-
102
- it("auto-generates unique states on successive calls", () => {
103
- const states = new Set(
104
- Array.from({ length: 10 }, () => buildAuthorizationUrl(BASE_OPTS).state),
105
- );
106
- expect(states.size).toBe(10);
107
- });
108
-
109
- it("echoes back a provided state value", () => {
110
- const { url, state } = buildAuthorizationUrl({ ...BASE_OPTS, state: "csrf-token-xyz" });
111
- expect(state).toBe("csrf-token-xyz");
112
- expect(new URL(url).searchParams.get("state")).toBe("csrf-token-xyz");
113
- });
114
-
115
- it("uses the authorization endpoint as the URL base", () => {
116
- const { url } = buildAuthorizationUrl(BASE_OPTS);
117
- const parsed = new URL(url);
118
- expect(`${parsed.origin}${parsed.pathname}`).toBe(
119
- "https://auth.example.com/oauth/authorize",
120
- );
121
- });
122
- });
123
-
124
- // ---------------------------------------------------------------------------
125
- // startLogin — integration (mocked discovery doc)
126
- // ---------------------------------------------------------------------------
127
-
128
- describe("startLogin", () => {
129
- const DISCOVERY_DOC = {
130
- issuer: "https://auth.example.com",
131
- authorization_endpoint: "https://auth.example.com/oauth/authorize",
132
- jwks_uri: "https://auth.example.com/jwks",
133
- token_endpoint: "https://auth.example.com/token",
134
- };
135
-
136
- function mockFetch(body: unknown, status = 200): void {
137
- vi.mocked(fetch).mockResolvedValueOnce(
138
- new Response(JSON.stringify(body), {
139
- status,
140
- headers: { "Content-Type": "application/json" },
141
- }),
142
- );
143
- }
144
-
145
- beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
146
- afterEach(() => vi.unstubAllGlobals());
147
-
148
- it("returns a valid redirect URL with all required PKCE parameters", async () => {
149
- mockFetch(DISCOVERY_DOC);
150
- const client = new HearthApiClient({
151
- baseUrl: "https://auth.example.com",
152
- realmId: "realm_1",
153
- });
154
- const result = await startLogin(client, {
155
- clientId: "my-spa",
156
- redirectUri: "https://app.example.com/callback",
157
- });
158
-
159
- const p = new URL(result.url).searchParams;
160
- expect(new URL(result.url).origin + new URL(result.url).pathname).toBe(
161
- "https://auth.example.com/oauth/authorize",
162
- );
163
- expect(p.get("response_type")).toBe("code");
164
- expect(p.get("client_id")).toBe("my-spa");
165
- expect(p.get("redirect_uri")).toBe("https://app.example.com/callback");
166
- expect(p.get("code_challenge_method")).toBe("S256");
167
- expect(p.get("code_challenge")).toBeTruthy();
168
- expect(p.get("state")).toBe(result.state);
169
- });
170
-
171
- it("returns a 43-char codeVerifier the caller can store for token exchange", async () => {
172
- mockFetch(DISCOVERY_DOC);
173
- const client = new HearthApiClient({
174
- baseUrl: "https://auth.example.com",
175
- realmId: "realm_1",
176
- });
177
- const result = await startLogin(client, {
178
- clientId: "my-spa",
179
- redirectUri: "https://app.example.com/callback",
180
- });
181
- expect(result.codeVerifier).toHaveLength(43);
182
- expect(result.codeVerifier).toMatch(BASE64URL_RE);
183
- });
184
-
185
- it("propagates a caller-supplied state to the URL", async () => {
186
- mockFetch(DISCOVERY_DOC);
187
- const client = new HearthApiClient({
188
- baseUrl: "https://auth.example.com",
189
- realmId: "realm_1",
190
- });
191
- const result = await startLogin(client, {
192
- clientId: "my-spa",
193
- redirectUri: "https://app.example.com/callback",
194
- state: "custom-csrf-state",
195
- });
196
- expect(result.state).toBe("custom-csrf-state");
197
- expect(new URL(result.url).searchParams.get("state")).toBe("custom-csrf-state");
198
- });
199
-
200
- it("throws when authorization_endpoint is absent from the discovery doc", async () => {
201
- mockFetch({ issuer: "https://auth.example.com", jwks_uri: "https://auth.example.com/jwks" });
202
- const client = new HearthApiClient({
203
- baseUrl: "https://auth.example.com",
204
- realmId: "realm_1",
205
- });
206
- await expect(
207
- startLogin(client, { clientId: "spa", redirectUri: "https://app.example.com/cb" }),
208
- ).rejects.toThrow("authorization_endpoint not found");
209
- });
210
- });
@@ -1,92 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it } from "vitest";
3
- import { cleanup, render, screen } from "@testing-library/react";
4
- import * as React from "react";
5
- import { createHearth } from "../src/hearth.js";
6
- import {
7
- HearthProvider,
8
- useHasPermission,
9
- useHasRole,
10
- useInGroup,
11
- useInOrg,
12
- } from "../src/react.js";
13
-
14
- function forgeJwt(claims: Record<string, unknown>): string {
15
- const header = Buffer.from(
16
- JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
17
- "utf8",
18
- ).toString("base64url");
19
- const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
20
- const sig = Buffer.from("not-a-real-signature").toString("base64url");
21
- return `${header}.${body}.${sig}`;
22
- }
23
-
24
- function Probe(): React.ReactElement {
25
- const canEdit = useHasPermission("docs.edit");
26
- const isAdmin = useHasRole("admin");
27
- const inEng = useInGroup("engineering");
28
- const inOrg42 = useInOrg("org_42");
29
- return (
30
- <div>
31
- <span data-testid="perm">{String(canEdit)}</span>
32
- <span data-testid="role">{String(isAdmin)}</span>
33
- <span data-testid="group">{String(inEng)}</span>
34
- <span data-testid="org">{String(inOrg42)}</span>
35
- </div>
36
- );
37
- }
38
-
39
- describe("react hooks", () => {
40
- afterEach(() => {
41
- cleanup();
42
- });
43
-
44
- it("reads RBAC claims from the provider client", () => {
45
- const token = forgeJwt({
46
- permissions: ["docs.edit"],
47
- roles: ["admin"],
48
- groups: ["engineering"],
49
- oid: "org_42",
50
- });
51
- const hearth = createHearth({
52
- baseUrl: "http://localhost",
53
- realmId: "r1",
54
- getToken: () => token,
55
- });
56
- render(
57
- <HearthProvider client={hearth}>
58
- <Probe />
59
- </HearthProvider>,
60
- );
61
- expect(screen.getByTestId("perm").textContent).toBe("true");
62
- expect(screen.getByTestId("role").textContent).toBe("true");
63
- expect(screen.getByTestId("group").textContent).toBe("true");
64
- expect(screen.getByTestId("org").textContent).toBe("true");
65
- });
66
-
67
- it("returns false when the JWT lacks the requested claims", () => {
68
- const token = forgeJwt({ sub: "user_1" });
69
- const hearth = createHearth({
70
- baseUrl: "http://localhost",
71
- realmId: "r1",
72
- getToken: () => token,
73
- });
74
- render(
75
- <HearthProvider client={hearth}>
76
- <Probe />
77
- </HearthProvider>,
78
- );
79
- expect(screen.getByTestId("perm").textContent).toBe("false");
80
- expect(screen.getByTestId("role").textContent).toBe("false");
81
- expect(screen.getByTestId("group").textContent).toBe("false");
82
- expect(screen.getByTestId("org").textContent).toBe("false");
83
- });
84
-
85
- it("returns false when no HearthProvider is mounted", () => {
86
- render(<Probe />);
87
- expect(screen.getByTestId("perm").textContent).toBe("false");
88
- expect(screen.getByTestId("role").textContent).toBe("false");
89
- expect(screen.getByTestId("group").textContent).toBe("false");
90
- expect(screen.getByTestId("org").textContent).toBe("false");
91
- });
92
- });
@@ -1,276 +0,0 @@
1
- /**
2
- * Unit tests for RequiredActionError (spec §5) and handleCallback
3
- * required-action detection (spec §7).
4
- * Tests are written before implementation (TDD).
5
- */
6
-
7
- import { describe, it, expect, vi, afterEach } from "vitest";
8
- import { RequiredActionError } from "../src/errors.js";
9
- import { HearthApiClient } from "../src/client.js";
10
-
11
- // ── RequiredActionError ────────────────────────────────────────────────────
12
-
13
- describe("RequiredActionError", () => {
14
- it("is a subclass of Error", () => {
15
- const err = new RequiredActionError(["VERIFY_EMAIL"]);
16
- expect(err).toBeInstanceOf(Error);
17
- });
18
-
19
- it("exposes requiredActions array", () => {
20
- const err = new RequiredActionError(["VERIFY_EMAIL", "UPDATE_PASSWORD"]);
21
- expect(err.requiredActions).toEqual(["VERIFY_EMAIL", "UPDATE_PASSWORD"]);
22
- });
23
-
24
- it("has a human-readable message", () => {
25
- const err = new RequiredActionError(["VERIFY_EMAIL"]);
26
- expect(err.message).toBeTruthy();
27
- expect(typeof err.message).toBe("string");
28
- });
29
-
30
- it("has name 'RequiredActionError'", () => {
31
- const err = new RequiredActionError(["VERIFY_EMAIL"]);
32
- expect(err.name).toBe("RequiredActionError");
33
- });
34
-
35
- it("redirectUri is undefined when not provided", () => {
36
- const err = new RequiredActionError(["VERIFY_EMAIL"]);
37
- expect(err.redirectUri).toBeUndefined();
38
- });
39
-
40
- it("accepts an optional redirectUri", () => {
41
- const err = new RequiredActionError(
42
- ["VERIFY_EMAIL"],
43
- "https://auth.example.com/ui/required-actions/verify-email",
44
- );
45
- expect(err.redirectUri).toBe(
46
- "https://auth.example.com/ui/required-actions/verify-email",
47
- );
48
- });
49
-
50
- it("works with empty required actions list", () => {
51
- const err = new RequiredActionError([]);
52
- expect(err.requiredActions).toEqual([]);
53
- });
54
- });
55
-
56
- // ── handleCallback() ───────────────────────────────────────────────────────
57
-
58
- /** Build a minimal JWT with the given payload. */
59
- function forgeJwt(payload: Record<string, unknown>): string {
60
- const header = Buffer.from(
61
- JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
62
- "utf8",
63
- ).toString("base64url");
64
- const body = Buffer.from(JSON.stringify(payload), "utf8").toString(
65
- "base64url",
66
- );
67
- const sig = Buffer.from("fake-sig").toString("base64url");
68
- return `${header}.${body}.${sig}`;
69
- }
70
-
71
- function makeClient(): HearthApiClient {
72
- return new HearthApiClient({
73
- baseUrl: "https://auth.example.com",
74
- realmId: "realm_test",
75
- });
76
- }
77
-
78
- describe("HearthApiClient.handleCallback()", () => {
79
- afterEach(() => {
80
- vi.restoreAllMocks();
81
- });
82
-
83
- it("returns token response when token_type is 'access'", async () => {
84
- const accessJwt = forgeJwt({
85
- sub: "user_1",
86
- token_type: "access",
87
- exp: Math.floor(Date.now() / 1000) + 3600,
88
- });
89
- const mockResponse = {
90
- access_token: accessJwt,
91
- id_token: "id_token_value",
92
- token_type: "Bearer",
93
- expires_in: 3600,
94
- refresh_token: "refresh_token_value",
95
- };
96
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
97
- new Response(JSON.stringify(mockResponse), { status: 200 }),
98
- );
99
-
100
- const client = makeClient();
101
- const result = await client.handleCallback({
102
- callbackUrl: "https://app.example.com/callback?code=abc123&state=xyz",
103
- clientId: "client_1",
104
- redirectUri: "https://app.example.com/callback",
105
- });
106
- expect(result.access_token).toBe(accessJwt);
107
- expect(result.token_type).toBe("Bearer");
108
- });
109
-
110
- it("throws RequiredActionError when JWT token_type is 'required_action'", async () => {
111
- const requiredActionJwt = forgeJwt({
112
- sub: "user_1",
113
- token_type: "required_action",
114
- required_actions: ["VERIFY_EMAIL", "UPDATE_PASSWORD"],
115
- exp: Math.floor(Date.now() / 1000) + 300,
116
- });
117
- const mockResponse = {
118
- access_token: requiredActionJwt,
119
- id_token: "id_token_value",
120
- token_type: "Bearer",
121
- expires_in: 300,
122
- refresh_token: "",
123
- };
124
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
125
- new Response(JSON.stringify(mockResponse), { status: 200 }),
126
- );
127
-
128
- const client = makeClient();
129
- await expect(
130
- client.handleCallback({
131
- callbackUrl: "https://app.example.com/callback?code=abc123",
132
- clientId: "client_1",
133
- redirectUri: "https://app.example.com/callback",
134
- }),
135
- ).rejects.toBeInstanceOf(RequiredActionError);
136
- });
137
-
138
- it("populates requiredActions from JWT required_actions claim", async () => {
139
- const requiredActionJwt = forgeJwt({
140
- sub: "user_1",
141
- token_type: "required_action",
142
- required_actions: ["VERIFY_EMAIL", "UPDATE_PASSWORD"],
143
- exp: Math.floor(Date.now() / 1000) + 300,
144
- });
145
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
146
- new Response(
147
- JSON.stringify({
148
- access_token: requiredActionJwt,
149
- id_token: "",
150
- token_type: "Bearer",
151
- expires_in: 300,
152
- refresh_token: "",
153
- }),
154
- { status: 200 },
155
- ),
156
- );
157
-
158
- const client = makeClient();
159
- try {
160
- await client.handleCallback({
161
- callbackUrl: "https://app.example.com/callback?code=abc123",
162
- clientId: "client_1",
163
- redirectUri: "https://app.example.com/callback",
164
- });
165
- expect.fail("should have thrown");
166
- } catch (err) {
167
- expect(err).toBeInstanceOf(RequiredActionError);
168
- expect((err as RequiredActionError).requiredActions).toEqual([
169
- "VERIFY_EMAIL",
170
- "UPDATE_PASSWORD",
171
- ]);
172
- }
173
- });
174
-
175
- it("throws RequiredActionError with redirectUri from required_action_redirect_uri param", async () => {
176
- const normalJwt = forgeJwt({
177
- sub: "user_1",
178
- token_type: "access",
179
- exp: Math.floor(Date.now() / 1000) + 3600,
180
- });
181
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
182
- new Response(
183
- JSON.stringify({
184
- access_token: normalJwt,
185
- id_token: "",
186
- token_type: "Bearer",
187
- expires_in: 3600,
188
- refresh_token: "rt",
189
- }),
190
- { status: 200 },
191
- ),
192
- );
193
-
194
- const client = makeClient();
195
- const redirectUri = "https://auth.example.com/ui/required-actions/verify-email";
196
- const callbackUrl = `https://app.example.com/callback?code=abc123&required_action_redirect_uri=${encodeURIComponent(redirectUri)}`;
197
-
198
- try {
199
- await client.handleCallback({
200
- callbackUrl,
201
- clientId: "client_1",
202
- redirectUri: "https://app.example.com/callback",
203
- });
204
- expect.fail("should have thrown");
205
- } catch (err) {
206
- expect(err).toBeInstanceOf(RequiredActionError);
207
- expect((err as RequiredActionError).redirectUri).toBe(redirectUri);
208
- }
209
- });
210
-
211
- it("token_type=required_action sets redirectUri from JWT if required_action_redirect_uri also in URL", async () => {
212
- const redirectUri = "https://auth.example.com/ui/actions";
213
- const requiredActionJwt = forgeJwt({
214
- sub: "user_1",
215
- token_type: "required_action",
216
- required_actions: ["VERIFY_EMAIL"],
217
- exp: Math.floor(Date.now() / 1000) + 300,
218
- });
219
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
220
- new Response(
221
- JSON.stringify({
222
- access_token: requiredActionJwt,
223
- id_token: "",
224
- token_type: "Bearer",
225
- expires_in: 300,
226
- refresh_token: "",
227
- }),
228
- { status: 200 },
229
- ),
230
- );
231
-
232
- const callbackUrl = `https://app.example.com/callback?code=abc123&required_action_redirect_uri=${encodeURIComponent(redirectUri)}`;
233
- const client = makeClient();
234
- try {
235
- await client.handleCallback({
236
- callbackUrl,
237
- clientId: "client_1",
238
- redirectUri: "https://app.example.com/callback",
239
- });
240
- expect.fail("should have thrown");
241
- } catch (err) {
242
- expect(err).toBeInstanceOf(RequiredActionError);
243
- expect((err as RequiredActionError).requiredActions).toEqual(["VERIFY_EMAIL"]);
244
- expect((err as RequiredActionError).redirectUri).toBe(redirectUri);
245
- }
246
- });
247
-
248
- it("passes codeVerifier to the token exchange when provided", async () => {
249
- const accessJwt = forgeJwt({ sub: "user_1", token_type: "access" });
250
- const fetchSpy = vi
251
- .spyOn(globalThis, "fetch")
252
- .mockResolvedValue(
253
- new Response(
254
- JSON.stringify({
255
- access_token: accessJwt,
256
- id_token: "",
257
- token_type: "Bearer",
258
- expires_in: 3600,
259
- refresh_token: "rt",
260
- }),
261
- { status: 200 },
262
- ),
263
- );
264
-
265
- const client = makeClient();
266
- await client.handleCallback({
267
- callbackUrl: "https://app.example.com/callback?code=abc123",
268
- clientId: "client_1",
269
- redirectUri: "https://app.example.com/callback",
270
- codeVerifier: "pkce_verifier_value",
271
- });
272
-
273
- const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
274
- expect(body.code_verifier).toBe("pkce_verifier_value");
275
- });
276
- });