@dloizides/auth-client 1.0.0 → 2.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/CHANGELOG.md +176 -0
- package/README.md +138 -42
- package/dist/AuthClient-BGr8L03W.d.mts +460 -0
- package/dist/AuthClient-D95OMajD.d.ts +460 -0
- package/dist/TokenResponse-CY1CaU2l.d.mts +59 -0
- package/dist/TokenResponse-CY1CaU2l.d.ts +59 -0
- package/dist/index.d.mts +207 -134
- package/dist/index.d.ts +207 -134
- package/dist/index.js +799 -52
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +785 -53
- package/dist/index.mjs.map +1 -1
- package/dist/oidc/index.d.mts +127 -0
- package/dist/oidc/index.d.ts +127 -0
- package/dist/oidc/index.js +192 -0
- package/dist/oidc/index.js.map +1 -0
- package/dist/oidc/index.mjs +184 -0
- package/dist/oidc/index.mjs.map +1 -0
- package/dist/react.d.mts +63 -0
- package/dist/react.d.ts +63 -0
- package/dist/react.js +65 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +58 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +53 -5
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/oidc/discovery.ts
|
|
4
|
+
var cache = /* @__PURE__ */ new Map();
|
|
5
|
+
function normalizeIssuer(issuerUrl) {
|
|
6
|
+
return issuerUrl.replace(/\/$/, "");
|
|
7
|
+
}
|
|
8
|
+
function isOidcDiscoveryDocument(data) {
|
|
9
|
+
if (data === null || typeof data !== "object") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const d = data;
|
|
13
|
+
return typeof d.issuer === "string" && d.issuer !== "" && typeof d.authorization_endpoint === "string" && d.authorization_endpoint !== "" && typeof d.token_endpoint === "string" && d.token_endpoint !== "";
|
|
14
|
+
}
|
|
15
|
+
async function fetchDiscoveryDocument(input) {
|
|
16
|
+
const key = normalizeIssuer(input.issuerUrl);
|
|
17
|
+
const cached = cache.get(key);
|
|
18
|
+
if (cached !== void 0) {
|
|
19
|
+
return cached;
|
|
20
|
+
}
|
|
21
|
+
const response = await input.http({
|
|
22
|
+
url: `${key}/.well-known/openid-configuration`,
|
|
23
|
+
method: "GET"
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`OIDC discovery failed: ${String(response.status)} for ${key}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (!isOidcDiscoveryDocument(response.data)) {
|
|
31
|
+
throw new Error(`OIDC discovery returned invalid metadata for ${key}`);
|
|
32
|
+
}
|
|
33
|
+
cache.set(key, response.data);
|
|
34
|
+
return response.data;
|
|
35
|
+
}
|
|
36
|
+
function clearDiscoveryCache() {
|
|
37
|
+
cache.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/oidc/pkce.ts
|
|
41
|
+
var VERIFIER_MIN_LENGTH = 43;
|
|
42
|
+
var VERIFIER_MAX_LENGTH = 128;
|
|
43
|
+
var DEFAULT_VERIFIER_LENGTH = 64;
|
|
44
|
+
var RANDOM_BYTES_PER_CHAR = 1;
|
|
45
|
+
var UNRESERVED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
46
|
+
function getCrypto() {
|
|
47
|
+
const c = globalThis.crypto;
|
|
48
|
+
if (c === void 0 || c.subtle === void 0) {
|
|
49
|
+
throw new Error("pkce: globalThis.crypto.subtle is required (Node 16+ / modern browser)");
|
|
50
|
+
}
|
|
51
|
+
return c;
|
|
52
|
+
}
|
|
53
|
+
function assertVerifierLength(length) {
|
|
54
|
+
if (length < VERIFIER_MIN_LENGTH || length > VERIFIER_MAX_LENGTH) {
|
|
55
|
+
throw new Error(`pkce: code_verifier length must be ${String(VERIFIER_MIN_LENGTH)}-${String(VERIFIER_MAX_LENGTH)} chars (RFC 7636)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function base64UrlEncode(buffer) {
|
|
59
|
+
const bytes = new Uint8Array(buffer);
|
|
60
|
+
let binary = "";
|
|
61
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
62
|
+
binary += String.fromCharCode(bytes[i]);
|
|
63
|
+
}
|
|
64
|
+
const b64 = globalThis.btoa?.(binary) ?? Buffer.from(binary, "binary").toString("base64");
|
|
65
|
+
let end = b64.length;
|
|
66
|
+
while (end > 0 && b64.charCodeAt(end - 1) === "=".charCodeAt(0)) {
|
|
67
|
+
end -= 1;
|
|
68
|
+
}
|
|
69
|
+
return b64.slice(0, end).replace(/\+/g, "-").replace(/\//g, "_");
|
|
70
|
+
}
|
|
71
|
+
function generateCodeVerifier(length = DEFAULT_VERIFIER_LENGTH) {
|
|
72
|
+
assertVerifierLength(length);
|
|
73
|
+
const crypto = getCrypto();
|
|
74
|
+
const bytes = new Uint8Array(length * RANDOM_BYTES_PER_CHAR);
|
|
75
|
+
crypto.getRandomValues(bytes);
|
|
76
|
+
let out = "";
|
|
77
|
+
for (let i = 0; i < length; i++) {
|
|
78
|
+
const byte = bytes[i];
|
|
79
|
+
out += UNRESERVED_CHARS[byte % UNRESERVED_CHARS.length];
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
async function deriveCodeChallenge(verifier) {
|
|
84
|
+
assertVerifierLength(verifier.length);
|
|
85
|
+
const crypto = getCrypto();
|
|
86
|
+
const data = new TextEncoder().encode(verifier);
|
|
87
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
88
|
+
return base64UrlEncode(digest);
|
|
89
|
+
}
|
|
90
|
+
async function generatePkcePair(length) {
|
|
91
|
+
const codeVerifier = generateCodeVerifier(length);
|
|
92
|
+
const codeChallenge = await deriveCodeChallenge(codeVerifier);
|
|
93
|
+
return { codeVerifier, codeChallenge, codeChallengeMethod: "S256" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/utils/buildKeycloakUrls.ts
|
|
97
|
+
var REALM_PATH_PREFIX = "/realms";
|
|
98
|
+
var PROTOCOL_PATH = "/protocol/openid-connect";
|
|
99
|
+
function trimTrailingSlash(value) {
|
|
100
|
+
return value.replace(/\/$/, "");
|
|
101
|
+
}
|
|
102
|
+
function buildIssuerUrl(baseUrl, realm) {
|
|
103
|
+
return `${trimTrailingSlash(baseUrl)}${REALM_PATH_PREFIX}/${encodeURIComponent(realm)}`;
|
|
104
|
+
}
|
|
105
|
+
function buildTokenEndpoint(baseUrl, realm) {
|
|
106
|
+
return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/token`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/utils/buildTokenRequestBody.ts
|
|
110
|
+
function buildAuthorizationCodeBody(input) {
|
|
111
|
+
return new URLSearchParams({
|
|
112
|
+
client_id: input.clientId,
|
|
113
|
+
grant_type: "authorization_code",
|
|
114
|
+
code: input.code,
|
|
115
|
+
redirect_uri: input.redirectUri,
|
|
116
|
+
code_verifier: input.codeVerifier
|
|
117
|
+
}).toString();
|
|
118
|
+
}
|
|
119
|
+
function buildRefreshTokenBody(input) {
|
|
120
|
+
return new URLSearchParams({
|
|
121
|
+
client_id: input.clientId,
|
|
122
|
+
grant_type: "refresh_token",
|
|
123
|
+
refresh_token: input.refreshToken
|
|
124
|
+
}).toString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/utils/normalizeTokenResponse.ts
|
|
128
|
+
function asString(value) {
|
|
129
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
130
|
+
}
|
|
131
|
+
function asNumber(value) {
|
|
132
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
133
|
+
}
|
|
134
|
+
function normalizeTokenResponse(raw) {
|
|
135
|
+
const accessToken = asString(raw.access_token);
|
|
136
|
+
if (accessToken === void 0) {
|
|
137
|
+
throw new Error("Token response missing access_token");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
accessToken,
|
|
141
|
+
refreshToken: asString(raw.refresh_token),
|
|
142
|
+
idToken: asString(raw.id_token),
|
|
143
|
+
expiresIn: asNumber(raw.expires_in),
|
|
144
|
+
tokenType: asString(raw.token_type),
|
|
145
|
+
scope: asString(raw.scope)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/oidc/tokenExchange.ts
|
|
150
|
+
var FORM_HEADERS = {
|
|
151
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
152
|
+
};
|
|
153
|
+
async function postTokenEndpoint(http, url, body) {
|
|
154
|
+
const response = await http({
|
|
155
|
+
url,
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: FORM_HEADERS,
|
|
158
|
+
body
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(`token endpoint POST failed: ${String(response.status)}`);
|
|
162
|
+
}
|
|
163
|
+
return normalizeTokenResponse(response.data);
|
|
164
|
+
}
|
|
165
|
+
async function exchangeAuthorizationCode(input) {
|
|
166
|
+
const url = buildTokenEndpoint(input.baseUrl, input.realm);
|
|
167
|
+
const body = buildAuthorizationCodeBody({
|
|
168
|
+
clientId: input.clientId,
|
|
169
|
+
code: input.code,
|
|
170
|
+
redirectUri: input.redirectUri,
|
|
171
|
+
codeVerifier: input.codeVerifier
|
|
172
|
+
});
|
|
173
|
+
return postTokenEndpoint(input.http, url, body);
|
|
174
|
+
}
|
|
175
|
+
async function refreshAccessToken(input) {
|
|
176
|
+
const url = buildTokenEndpoint(input.baseUrl, input.realm);
|
|
177
|
+
const body = buildRefreshTokenBody({
|
|
178
|
+
clientId: input.clientId,
|
|
179
|
+
refreshToken: input.refreshToken
|
|
180
|
+
});
|
|
181
|
+
return postTokenEndpoint(input.http, url, body);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
exports.clearDiscoveryCache = clearDiscoveryCache;
|
|
185
|
+
exports.deriveCodeChallenge = deriveCodeChallenge;
|
|
186
|
+
exports.exchangeAuthorizationCode = exchangeAuthorizationCode;
|
|
187
|
+
exports.fetchDiscoveryDocument = fetchDiscoveryDocument;
|
|
188
|
+
exports.generateCodeVerifier = generateCodeVerifier;
|
|
189
|
+
exports.generatePkcePair = generatePkcePair;
|
|
190
|
+
exports.refreshAccessToken = refreshAccessToken;
|
|
191
|
+
//# sourceMappingURL=index.js.map
|
|
192
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/oidc/discovery.ts","../../src/oidc/pkce.ts","../../src/utils/buildKeycloakUrls.ts","../../src/utils/buildTokenRequestBody.ts","../../src/utils/normalizeTokenResponse.ts","../../src/oidc/tokenExchange.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * OIDC discovery document fetcher.\n *\n * Fetches `{issuer}/.well-known/openid-configuration` and caches the result\n * per-issuer for the lifetime of the process. Discovery responses are stable\n * for hours; the cache prevents the auth flow from hitting KC on every login.\n *\n * Pure (no React, no hooks). Consumed by app-side hooks that orchestrate the\n * PKCE flow.\n */\n\nimport type { HttpClient } from '../http/HttpClient';\n\n/**\n * Subset of the OIDC Discovery 1.0 metadata the auth-client needs.\n *\n * The KC discovery doc carries many more fields; we type only what the PKCE\n * flow consumes to keep the surface small and to fail loudly when KC ever\n * stops returning one of these.\n */\nexport interface OidcDiscoveryDocument {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n end_session_endpoint?: string;\n userinfo_endpoint?: string;\n jwks_uri?: string;\n}\n\nexport interface FetchDiscoveryDocumentInput {\n /** Issuer URL — `{baseUrl}/realms/{realm}`. Trailing slash tolerated. */\n issuerUrl: string;\n /** Transport. Pass `createFetchHttpClient(fetch)` in browser/Node18+. */\n http: HttpClient;\n}\n\nconst cache = new Map<string, OidcDiscoveryDocument>();\n\nfunction normalizeIssuer(issuerUrl: string): string {\n return issuerUrl.replace(/\\/$/, '');\n}\n\nfunction isOidcDiscoveryDocument(data: unknown): data is OidcDiscoveryDocument {\n if (data === null || typeof data !== 'object') {\n return false;\n }\n const d = data as Record<string, unknown>;\n return (\n typeof d.issuer === 'string'\n && d.issuer !== ''\n && typeof d.authorization_endpoint === 'string'\n && d.authorization_endpoint !== ''\n && typeof d.token_endpoint === 'string'\n && d.token_endpoint !== ''\n );\n}\n\n/**\n * Fetch + cache the OIDC discovery document for an issuer.\n *\n * Cache key = normalized issuer URL (trailing slash stripped).\n *\n * @throws Error when the HTTP call fails, returns non-2xx, or returns a body\n * missing required OIDC metadata fields.\n */\nexport async function fetchDiscoveryDocument(\n input: FetchDiscoveryDocumentInput,\n): Promise<OidcDiscoveryDocument> {\n const key = normalizeIssuer(input.issuerUrl);\n const cached = cache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const response = await input.http({\n url: `${key}/.well-known/openid-configuration`,\n method: 'GET',\n });\n if (!response.ok) {\n throw new Error(\n `OIDC discovery failed: ${String(response.status)} for ${key}`,\n );\n }\n if (!isOidcDiscoveryDocument(response.data)) {\n throw new Error(`OIDC discovery returned invalid metadata for ${key}`);\n }\n cache.set(key, response.data);\n return response.data;\n}\n\n/**\n * Clear the per-issuer discovery cache. Test-only — production code does not\n * call this. Useful when a test mocks different metadata across cases.\n */\nexport function clearDiscoveryCache(): void {\n cache.clear();\n}\n","/**\n * PKCE (RFC 7636) primitives for the OIDC authorization-code flow.\n *\n * Pure (no React). Browser-compatible — uses `crypto.subtle` for SHA-256 and\n * `crypto.getRandomValues` for the verifier. Node 16+ exposes both via\n * `globalThis.crypto`.\n */\n\n/** RFC 7636 §4.1: code_verifier MUST be 43..128 chars from the unreserved set. */\nconst VERIFIER_MIN_LENGTH = 43;\nconst VERIFIER_MAX_LENGTH = 128;\nconst DEFAULT_VERIFIER_LENGTH = 64;\nconst RANDOM_BYTES_PER_CHAR = 1;\n\nconst UNRESERVED_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';\n\nfunction getCrypto(): Crypto {\n const c = (globalThis as { crypto?: Crypto }).crypto;\n // Runtime check: in some Node test environments `crypto.subtle` may not\n // exist even though the TS lib types mark it as non-optional.\n // eslint-disable-next-line sonarjs/different-types-comparison, @typescript-eslint/no-unnecessary-condition\n if (c === undefined || c.subtle === undefined) {\n throw new Error('pkce: globalThis.crypto.subtle is required (Node 16+ / modern browser)');\n }\n return c;\n}\n\nfunction assertVerifierLength(length: number): void {\n if (length < VERIFIER_MIN_LENGTH || length > VERIFIER_MAX_LENGTH) {\n throw new Error(`pkce: code_verifier length must be ${String(VERIFIER_MIN_LENGTH)}-${String(VERIFIER_MAX_LENGTH)} chars (RFC 7636)`);\n }\n}\n\n/**\n * Base64-URL encode an ArrayBuffer (no padding, `-` and `_` substitutions).\n *\n * Required for the S256 challenge — RFC 7636 §4.2.\n */\nfunction base64UrlEncode(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i] as number);\n }\n const b64 = (globalThis as { btoa?: (s: string) => string }).btoa?.(binary)\n ?? Buffer.from(binary, 'binary').toString('base64');\n // Strip trailing '=' padding by slicing — avoids the sonarjs/slow-regex\n // warning on /=+$/ even though base64 padding is bounded to 0..2 chars.\n let end = b64.length;\n while (end > 0 && b64.charCodeAt(end - 1) === '='.charCodeAt(0)) {\n end -= 1;\n }\n return b64.slice(0, end).replace(/\\+/g, '-').replace(/\\//g, '_');\n}\n\n/**\n * Generate a cryptographically random PKCE code_verifier.\n *\n * Default length 64 sits well inside the RFC 7636 43..128 band.\n *\n * @throws Error when `length` falls outside the RFC band.\n */\nexport function generateCodeVerifier(length: number = DEFAULT_VERIFIER_LENGTH): string {\n assertVerifierLength(length);\n const crypto = getCrypto();\n const bytes = new Uint8Array(length * RANDOM_BYTES_PER_CHAR);\n crypto.getRandomValues(bytes);\n let out = '';\n for (let i = 0; i < length; i++) {\n const byte = bytes[i] as number;\n out += UNRESERVED_CHARS[byte % UNRESERVED_CHARS.length];\n }\n return out;\n}\n\n/**\n * Derive the S256 code_challenge from a code_verifier.\n *\n * `code_challenge = BASE64URL(SHA256(code_verifier))` — RFC 7636 §4.2.\n *\n * @throws Error when `verifier` is shorter than 43 or longer than 128 chars.\n */\nexport async function deriveCodeChallenge(verifier: string): Promise<string> {\n assertVerifierLength(verifier.length);\n const crypto = getCrypto();\n const data = new TextEncoder().encode(verifier);\n const digest = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(digest);\n}\n\nexport interface PkcePair {\n codeVerifier: string;\n codeChallenge: string;\n codeChallengeMethod: 'S256';\n}\n\n/**\n * Convenience: produce a fresh verifier + matching challenge in one call.\n */\nexport async function generatePkcePair(length?: number): Promise<PkcePair> {\n const codeVerifier = generateCodeVerifier(length);\n const codeChallenge = await deriveCodeChallenge(codeVerifier);\n return { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' };\n}\n","/**\n * URL builders for the realm-aware Keycloak surface area.\n *\n * Every helper takes `baseUrl` and `realm` explicitly — no hardcoded realm\n * names. This is the contract that Phase 2 of the product split relies on:\n * the same package serves the future Questioner-realm app and OnlineMenu-realm\n * app without code change.\n */\n\nconst REALM_PATH_PREFIX = '/realms';\nconst PROTOCOL_PATH = '/protocol/openid-connect';\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/$/, '');\n}\n\n/**\n * Compute the issuer URL: `{baseUrl}/realms/{realm}`.\n */\nexport function buildIssuerUrl(baseUrl: string, realm: string): string {\n return `${trimTrailingSlash(baseUrl)}${REALM_PATH_PREFIX}/${encodeURIComponent(realm)}`;\n}\n\n/**\n * Compute the authorization endpoint URL.\n */\nexport function buildAuthorizationEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/auth`;\n}\n\n/**\n * Compute the token endpoint URL.\n */\nexport function buildTokenEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/token`;\n}\n\n/**\n * Compute the userinfo endpoint URL.\n */\nexport function buildUserInfoEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/userinfo`;\n}\n\n/**\n * Compute the logout endpoint URL.\n */\nexport function buildLogoutEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/logout`;\n}\n\nexport interface AuthorizationUrlInput {\n baseUrl: string;\n realm: string;\n clientId: string;\n redirectUri: string;\n scope?: string;\n state?: string;\n codeChallenge?: string;\n codeChallengeMethod?: 'S256' | 'plain';\n}\n\n/**\n * Build a complete authorization URL the user agent can navigate to.\n *\n * All PKCE-related fields are optional so this helper also serves\n * non-PKCE flows (e.g. confidential server-side clients) — but PKCE is\n * the recommended path for SPA / native consumers.\n */\nexport function buildAuthorizationUrl(input: AuthorizationUrlInput): string {\n const params = new URLSearchParams({\n client_id: input.clientId,\n redirect_uri: input.redirectUri,\n response_type: 'code',\n });\n if (typeof input.scope === 'string' && input.scope !== '') {\n params.set('scope', input.scope);\n }\n if (typeof input.state === 'string' && input.state !== '') {\n params.set('state', input.state);\n }\n if (typeof input.codeChallenge === 'string' && input.codeChallenge !== '') {\n params.set('code_challenge', input.codeChallenge);\n params.set('code_challenge_method', input.codeChallengeMethod ?? 'S256');\n }\n return `${buildAuthorizationEndpoint(input.baseUrl, input.realm)}?${params.toString()}`;\n}\n","/**\n * Inputs for the OAuth `authorization_code` token request.\n */\nexport interface AuthorizationCodeBodyInput {\n clientId: string;\n code: string;\n redirectUri: string;\n codeVerifier: string;\n}\n\n/**\n * Inputs for the OAuth `refresh_token` token request.\n */\nexport interface RefreshTokenBodyInput {\n clientId: string;\n refreshToken: string;\n}\n\n/**\n * Build the `application/x-www-form-urlencoded` body for the\n * `grant_type=authorization_code` token endpoint call (PKCE flow).\n */\nexport function buildAuthorizationCodeBody(input: AuthorizationCodeBodyInput): string {\n return new URLSearchParams({\n client_id: input.clientId,\n grant_type: 'authorization_code',\n code: input.code,\n redirect_uri: input.redirectUri,\n code_verifier: input.codeVerifier,\n }).toString();\n}\n\n/**\n * Build the `application/x-www-form-urlencoded` body for the\n * `grant_type=refresh_token` token endpoint call.\n */\nexport function buildRefreshTokenBody(input: RefreshTokenBodyInput): string {\n return new URLSearchParams({\n client_id: input.clientId,\n grant_type: 'refresh_token',\n refresh_token: input.refreshToken,\n }).toString();\n}\n","import type { AuthTokens } from '../types/AuthTokens';\nimport type { RawTokenResponse, TokenResponse } from '../types/TokenResponse';\nimport { computeExpiresAt } from './isTokenExpired';\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === 'string' && value !== '' ? value : undefined;\n}\n\nfunction asNumber(value: unknown): number | undefined {\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n}\n\n/**\n * Map a raw OIDC token endpoint response (snake_case) to camelCase.\n *\n * Throws when `access_token` is missing or empty — callers should let this\n * propagate to the auth state machine, which treats it as a login failure.\n */\nexport function normalizeTokenResponse(raw: RawTokenResponse): TokenResponse {\n const accessToken = asString(raw.access_token);\n if (accessToken === undefined) {\n throw new Error('Token response missing access_token');\n }\n return {\n accessToken,\n refreshToken: asString(raw.refresh_token),\n idToken: asString(raw.id_token),\n expiresIn: asNumber(raw.expires_in),\n tokenType: asString(raw.token_type),\n scope: asString(raw.scope),\n };\n}\n\n/**\n * Convert a normalized {@link TokenResponse} into a persistable\n * {@link AuthTokens} bundle by computing `expiresAt` from `expiresIn`.\n */\nexport function tokenResponseToAuthTokens(\n response: TokenResponse,\n now: number = Date.now(),\n): AuthTokens {\n return {\n accessToken: response.accessToken,\n refreshToken: response.refreshToken,\n idToken: response.idToken,\n expiresAt: computeExpiresAt(response.expiresIn, now),\n };\n}\n","/**\n * OIDC token-endpoint helpers.\n *\n * Pure (no React, no hooks). Wraps the realm-aware token endpoint with the\n * PKCE `authorization_code` and `refresh_token` grants. The transport is\n * injected so callers can use the HTTP client of their choice.\n *\n * Use these from app-side hooks (e.g. `useKeycloakExchange`) instead of\n * duplicating the body-builder + POST + normalise dance.\n */\n\nimport { buildTokenEndpoint } from '../utils/buildKeycloakUrls';\nimport {\n buildAuthorizationCodeBody,\n buildRefreshTokenBody,\n} from '../utils/buildTokenRequestBody';\nimport { normalizeTokenResponse } from '../utils/normalizeTokenResponse';\n\nimport type { HttpClient } from '../http/HttpClient';\nimport type { RawTokenResponse, TokenResponse } from '../types/TokenResponse';\n\nconst FORM_HEADERS: Record<string, string> = {\n 'Content-Type': 'application/x-www-form-urlencoded',\n};\n\nexport interface ExchangeAuthorizationCodeInput {\n http: HttpClient;\n baseUrl: string;\n realm: string;\n clientId: string;\n code: string;\n redirectUri: string;\n codeVerifier: string;\n}\n\nexport interface RefreshAccessTokenInput {\n http: HttpClient;\n baseUrl: string;\n realm: string;\n clientId: string;\n refreshToken: string;\n}\n\nasync function postTokenEndpoint(\n http: HttpClient,\n url: string,\n body: string,\n): Promise<TokenResponse> {\n const response = await http({\n url,\n method: 'POST',\n headers: FORM_HEADERS,\n body,\n });\n if (!response.ok) {\n throw new Error(`token endpoint POST failed: ${String(response.status)}`);\n }\n return normalizeTokenResponse(response.data as RawTokenResponse);\n}\n\n/**\n * Exchange a PKCE authorization `code` for tokens via the realm's token\n * endpoint (`grant_type=authorization_code`).\n *\n * @throws Error when the HTTP call returns non-2xx or the body is missing\n * `access_token`.\n */\nexport async function exchangeAuthorizationCode(\n input: ExchangeAuthorizationCodeInput,\n): Promise<TokenResponse> {\n const url = buildTokenEndpoint(input.baseUrl, input.realm);\n const body = buildAuthorizationCodeBody({\n clientId: input.clientId,\n code: input.code,\n redirectUri: input.redirectUri,\n codeVerifier: input.codeVerifier,\n });\n return postTokenEndpoint(input.http, url, body);\n}\n\n/**\n * Swap a refresh token for a fresh access/refresh-token pair via the realm's\n * token endpoint (`grant_type=refresh_token`).\n *\n * @throws Error when the HTTP call returns non-2xx or the body is missing\n * `access_token`.\n */\nexport async function refreshAccessToken(\n input: RefreshAccessTokenInput,\n): Promise<TokenResponse> {\n const url = buildTokenEndpoint(input.baseUrl, input.realm);\n const body = buildRefreshTokenBody({\n clientId: input.clientId,\n refreshToken: input.refreshToken,\n });\n return postTokenEndpoint(input.http, url, body);\n}\n"]}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/oidc/discovery.ts
|
|
2
|
+
var cache = /* @__PURE__ */ new Map();
|
|
3
|
+
function normalizeIssuer(issuerUrl) {
|
|
4
|
+
return issuerUrl.replace(/\/$/, "");
|
|
5
|
+
}
|
|
6
|
+
function isOidcDiscoveryDocument(data) {
|
|
7
|
+
if (data === null || typeof data !== "object") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const d = data;
|
|
11
|
+
return typeof d.issuer === "string" && d.issuer !== "" && typeof d.authorization_endpoint === "string" && d.authorization_endpoint !== "" && typeof d.token_endpoint === "string" && d.token_endpoint !== "";
|
|
12
|
+
}
|
|
13
|
+
async function fetchDiscoveryDocument(input) {
|
|
14
|
+
const key = normalizeIssuer(input.issuerUrl);
|
|
15
|
+
const cached = cache.get(key);
|
|
16
|
+
if (cached !== void 0) {
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
19
|
+
const response = await input.http({
|
|
20
|
+
url: `${key}/.well-known/openid-configuration`,
|
|
21
|
+
method: "GET"
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`OIDC discovery failed: ${String(response.status)} for ${key}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!isOidcDiscoveryDocument(response.data)) {
|
|
29
|
+
throw new Error(`OIDC discovery returned invalid metadata for ${key}`);
|
|
30
|
+
}
|
|
31
|
+
cache.set(key, response.data);
|
|
32
|
+
return response.data;
|
|
33
|
+
}
|
|
34
|
+
function clearDiscoveryCache() {
|
|
35
|
+
cache.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/oidc/pkce.ts
|
|
39
|
+
var VERIFIER_MIN_LENGTH = 43;
|
|
40
|
+
var VERIFIER_MAX_LENGTH = 128;
|
|
41
|
+
var DEFAULT_VERIFIER_LENGTH = 64;
|
|
42
|
+
var RANDOM_BYTES_PER_CHAR = 1;
|
|
43
|
+
var UNRESERVED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
44
|
+
function getCrypto() {
|
|
45
|
+
const c = globalThis.crypto;
|
|
46
|
+
if (c === void 0 || c.subtle === void 0) {
|
|
47
|
+
throw new Error("pkce: globalThis.crypto.subtle is required (Node 16+ / modern browser)");
|
|
48
|
+
}
|
|
49
|
+
return c;
|
|
50
|
+
}
|
|
51
|
+
function assertVerifierLength(length) {
|
|
52
|
+
if (length < VERIFIER_MIN_LENGTH || length > VERIFIER_MAX_LENGTH) {
|
|
53
|
+
throw new Error(`pkce: code_verifier length must be ${String(VERIFIER_MIN_LENGTH)}-${String(VERIFIER_MAX_LENGTH)} chars (RFC 7636)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function base64UrlEncode(buffer) {
|
|
57
|
+
const bytes = new Uint8Array(buffer);
|
|
58
|
+
let binary = "";
|
|
59
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
60
|
+
binary += String.fromCharCode(bytes[i]);
|
|
61
|
+
}
|
|
62
|
+
const b64 = globalThis.btoa?.(binary) ?? Buffer.from(binary, "binary").toString("base64");
|
|
63
|
+
let end = b64.length;
|
|
64
|
+
while (end > 0 && b64.charCodeAt(end - 1) === "=".charCodeAt(0)) {
|
|
65
|
+
end -= 1;
|
|
66
|
+
}
|
|
67
|
+
return b64.slice(0, end).replace(/\+/g, "-").replace(/\//g, "_");
|
|
68
|
+
}
|
|
69
|
+
function generateCodeVerifier(length = DEFAULT_VERIFIER_LENGTH) {
|
|
70
|
+
assertVerifierLength(length);
|
|
71
|
+
const crypto = getCrypto();
|
|
72
|
+
const bytes = new Uint8Array(length * RANDOM_BYTES_PER_CHAR);
|
|
73
|
+
crypto.getRandomValues(bytes);
|
|
74
|
+
let out = "";
|
|
75
|
+
for (let i = 0; i < length; i++) {
|
|
76
|
+
const byte = bytes[i];
|
|
77
|
+
out += UNRESERVED_CHARS[byte % UNRESERVED_CHARS.length];
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
async function deriveCodeChallenge(verifier) {
|
|
82
|
+
assertVerifierLength(verifier.length);
|
|
83
|
+
const crypto = getCrypto();
|
|
84
|
+
const data = new TextEncoder().encode(verifier);
|
|
85
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
86
|
+
return base64UrlEncode(digest);
|
|
87
|
+
}
|
|
88
|
+
async function generatePkcePair(length) {
|
|
89
|
+
const codeVerifier = generateCodeVerifier(length);
|
|
90
|
+
const codeChallenge = await deriveCodeChallenge(codeVerifier);
|
|
91
|
+
return { codeVerifier, codeChallenge, codeChallengeMethod: "S256" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/utils/buildKeycloakUrls.ts
|
|
95
|
+
var REALM_PATH_PREFIX = "/realms";
|
|
96
|
+
var PROTOCOL_PATH = "/protocol/openid-connect";
|
|
97
|
+
function trimTrailingSlash(value) {
|
|
98
|
+
return value.replace(/\/$/, "");
|
|
99
|
+
}
|
|
100
|
+
function buildIssuerUrl(baseUrl, realm) {
|
|
101
|
+
return `${trimTrailingSlash(baseUrl)}${REALM_PATH_PREFIX}/${encodeURIComponent(realm)}`;
|
|
102
|
+
}
|
|
103
|
+
function buildTokenEndpoint(baseUrl, realm) {
|
|
104
|
+
return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/token`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/utils/buildTokenRequestBody.ts
|
|
108
|
+
function buildAuthorizationCodeBody(input) {
|
|
109
|
+
return new URLSearchParams({
|
|
110
|
+
client_id: input.clientId,
|
|
111
|
+
grant_type: "authorization_code",
|
|
112
|
+
code: input.code,
|
|
113
|
+
redirect_uri: input.redirectUri,
|
|
114
|
+
code_verifier: input.codeVerifier
|
|
115
|
+
}).toString();
|
|
116
|
+
}
|
|
117
|
+
function buildRefreshTokenBody(input) {
|
|
118
|
+
return new URLSearchParams({
|
|
119
|
+
client_id: input.clientId,
|
|
120
|
+
grant_type: "refresh_token",
|
|
121
|
+
refresh_token: input.refreshToken
|
|
122
|
+
}).toString();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/utils/normalizeTokenResponse.ts
|
|
126
|
+
function asString(value) {
|
|
127
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
128
|
+
}
|
|
129
|
+
function asNumber(value) {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
131
|
+
}
|
|
132
|
+
function normalizeTokenResponse(raw) {
|
|
133
|
+
const accessToken = asString(raw.access_token);
|
|
134
|
+
if (accessToken === void 0) {
|
|
135
|
+
throw new Error("Token response missing access_token");
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
accessToken,
|
|
139
|
+
refreshToken: asString(raw.refresh_token),
|
|
140
|
+
idToken: asString(raw.id_token),
|
|
141
|
+
expiresIn: asNumber(raw.expires_in),
|
|
142
|
+
tokenType: asString(raw.token_type),
|
|
143
|
+
scope: asString(raw.scope)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/oidc/tokenExchange.ts
|
|
148
|
+
var FORM_HEADERS = {
|
|
149
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
150
|
+
};
|
|
151
|
+
async function postTokenEndpoint(http, url, body) {
|
|
152
|
+
const response = await http({
|
|
153
|
+
url,
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: FORM_HEADERS,
|
|
156
|
+
body
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(`token endpoint POST failed: ${String(response.status)}`);
|
|
160
|
+
}
|
|
161
|
+
return normalizeTokenResponse(response.data);
|
|
162
|
+
}
|
|
163
|
+
async function exchangeAuthorizationCode(input) {
|
|
164
|
+
const url = buildTokenEndpoint(input.baseUrl, input.realm);
|
|
165
|
+
const body = buildAuthorizationCodeBody({
|
|
166
|
+
clientId: input.clientId,
|
|
167
|
+
code: input.code,
|
|
168
|
+
redirectUri: input.redirectUri,
|
|
169
|
+
codeVerifier: input.codeVerifier
|
|
170
|
+
});
|
|
171
|
+
return postTokenEndpoint(input.http, url, body);
|
|
172
|
+
}
|
|
173
|
+
async function refreshAccessToken(input) {
|
|
174
|
+
const url = buildTokenEndpoint(input.baseUrl, input.realm);
|
|
175
|
+
const body = buildRefreshTokenBody({
|
|
176
|
+
clientId: input.clientId,
|
|
177
|
+
refreshToken: input.refreshToken
|
|
178
|
+
});
|
|
179
|
+
return postTokenEndpoint(input.http, url, body);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export { clearDiscoveryCache, deriveCodeChallenge, exchangeAuthorizationCode, fetchDiscoveryDocument, generateCodeVerifier, generatePkcePair, refreshAccessToken };
|
|
183
|
+
//# sourceMappingURL=index.mjs.map
|
|
184
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/oidc/discovery.ts","../../src/oidc/pkce.ts","../../src/utils/buildKeycloakUrls.ts","../../src/utils/buildTokenRequestBody.ts","../../src/utils/normalizeTokenResponse.ts","../../src/oidc/tokenExchange.ts"],"names":[],"mappings":";AAoCA,IAAM,KAAA,uBAAY,GAAA,EAAmC;AAErD,SAAS,gBAAgB,SAAA,EAA2B;AAClD,EAAA,OAAO,SAAA,CAAU,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AACpC;AAEA,SAAS,wBAAwB,IAAA,EAA8C;AAC7E,EAAA,IAAI,IAAA,KAAS,IAAA,IAAQ,OAAO,IAAA,KAAS,QAAA,EAAU;AAC7C,IAAA,OAAO,KAAA;AAAA,EACT;AACA,EAAA,MAAM,CAAA,GAAI,IAAA;AACV,EAAA,OACE,OAAO,CAAA,CAAE,MAAA,KAAW,YACjB,CAAA,CAAE,MAAA,KAAW,MACb,OAAO,CAAA,CAAE,2BAA2B,QAAA,IACpC,CAAA,CAAE,2BAA2B,EAAA,IAC7B,OAAO,EAAE,cAAA,KAAmB,QAAA,IAC5B,EAAE,cAAA,KAAmB,EAAA;AAE5B;AAUA,eAAsB,uBACpB,KAAA,EACgC;AAChC,EAAA,MAAM,GAAA,GAAM,eAAA,CAAgB,KAAA,CAAM,SAAS,CAAA;AAC3C,EAAA,MAAM,MAAA,GAAS,KAAA,CAAM,GAAA,CAAI,GAAG,CAAA;AAC5B,EAAA,IAAI,WAAW,MAAA,EAAW;AACxB,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,IAAA,CAAK;AAAA,IAChC,GAAA,EAAK,GAAG,GAAG,CAAA,iCAAA,CAAA;AAAA,IACX,MAAA,EAAQ;AAAA,GACT,CAAA;AACD,EAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,0BAA0B,MAAA,CAAO,QAAA,CAAS,MAAM,CAAC,QAAQ,GAAG,CAAA;AAAA,KAC9D;AAAA,EACF;AACA,EAAA,IAAI,CAAC,uBAAA,CAAwB,QAAA,CAAS,IAAI,CAAA,EAAG;AAC3C,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,6CAAA,EAAgD,GAAG,CAAA,CAAE,CAAA;AAAA,EACvE;AACA,EAAA,KAAA,CAAM,GAAA,CAAI,GAAA,EAAK,QAAA,CAAS,IAAI,CAAA;AAC5B,EAAA,OAAO,QAAA,CAAS,IAAA;AAClB;AAMO,SAAS,mBAAA,GAA4B;AAC1C,EAAA,KAAA,CAAM,KAAA,EAAM;AACd;;;ACtFA,IAAM,mBAAA,GAAsB,EAAA;AAC5B,IAAM,mBAAA,GAAsB,GAAA;AAC5B,IAAM,uBAAA,GAA0B,EAAA;AAChC,IAAM,qBAAA,GAAwB,CAAA;AAE9B,IAAM,gBAAA,GAAmB,oEAAA;AAEzB,SAAS,SAAA,GAAoB;AAC3B,EAAA,MAAM,IAAK,UAAA,CAAmC,MAAA;AAI9C,EAAA,IAAI,CAAA,KAAM,MAAA,IAAa,CAAA,CAAE,MAAA,KAAW,MAAA,EAAW;AAC7C,IAAA,MAAM,IAAI,MAAM,wEAAwE,CAAA;AAAA,EAC1F;AACA,EAAA,OAAO,CAAA;AACT;AAEA,SAAS,qBAAqB,MAAA,EAAsB;AAClD,EAAA,IAAI,MAAA,GAAS,mBAAA,IAAuB,MAAA,GAAS,mBAAA,EAAqB;AAChE,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mCAAA,EAAsC,MAAA,CAAO,mBAAmB,CAAC,CAAA,CAAA,EAAI,MAAA,CAAO,mBAAmB,CAAC,CAAA,iBAAA,CAAmB,CAAA;AAAA,EACrI;AACF;AAOA,SAAS,gBAAgB,MAAA,EAA6B;AACpD,EAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,MAAM,CAAA;AACnC,EAAA,IAAI,MAAA,GAAS,EAAA;AACb,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,CAAM,QAAQ,CAAA,EAAA,EAAK;AACrC,IAAA,MAAA,IAAU,MAAA,CAAO,YAAA,CAAa,KAAA,CAAM,CAAC,CAAW,CAAA;AAAA,EAClD;AACA,EAAA,MAAM,GAAA,GAAO,UAAA,CAAgD,IAAA,GAAO,MAAM,CAAA,IACrE,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,QAAQ,CAAA,CAAE,QAAA,CAAS,QAAQ,CAAA;AAGpD,EAAA,IAAI,MAAM,GAAA,CAAI,MAAA;AACd,EAAA,OAAO,GAAA,GAAM,CAAA,IAAK,GAAA,CAAI,UAAA,CAAW,GAAA,GAAM,CAAC,CAAA,KAAM,GAAA,CAAI,UAAA,CAAW,CAAC,CAAA,EAAG;AAC/D,IAAA,GAAA,IAAO,CAAA;AAAA,EACT;AACA,EAAA,OAAO,GAAA,CAAI,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA,CAAE,OAAA,CAAQ,KAAA,EAAO,GAAG,CAAA,CAAE,OAAA,CAAQ,KAAA,EAAO,GAAG,CAAA;AACjE;AASO,SAAS,oBAAA,CAAqB,SAAiB,uBAAA,EAAiC;AACrF,EAAA,oBAAA,CAAqB,MAAM,CAAA;AAC3B,EAAA,MAAM,SAAS,SAAA,EAAU;AACzB,EAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,MAAA,GAAS,qBAAqB,CAAA;AAC3D,EAAA,MAAA,CAAO,gBAAgB,KAAK,CAAA;AAC5B,EAAA,IAAI,GAAA,GAAM,EAAA;AACV,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,MAAA,EAAQ,CAAA,EAAA,EAAK;AAC/B,IAAA,MAAM,IAAA,GAAO,MAAM,CAAC,CAAA;AACpB,IAAA,GAAA,IAAO,gBAAA,CAAiB,IAAA,GAAO,gBAAA,CAAiB,MAAM,CAAA;AAAA,EACxD;AACA,EAAA,OAAO,GAAA;AACT;AASA,eAAsB,oBAAoB,QAAA,EAAmC;AAC3E,EAAA,oBAAA,CAAqB,SAAS,MAAM,CAAA;AACpC,EAAA,MAAM,SAAS,SAAA,EAAU;AACzB,EAAA,MAAM,IAAA,GAAO,IAAI,WAAA,EAAY,CAAE,OAAO,QAAQ,CAAA;AAC9C,EAAA,MAAM,SAAS,MAAM,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,WAAW,IAAI,CAAA;AACzD,EAAA,OAAO,gBAAgB,MAAM,CAAA;AAC/B;AAWA,eAAsB,iBAAiB,MAAA,EAAoC;AACzE,EAAA,MAAM,YAAA,GAAe,qBAAqB,MAAM,CAAA;AAChD,EAAA,MAAM,aAAA,GAAgB,MAAM,mBAAA,CAAoB,YAAY,CAAA;AAC5D,EAAA,OAAO,EAAE,YAAA,EAAc,aAAA,EAAe,mBAAA,EAAqB,MAAA,EAAO;AACpE;;;AC9FA,IAAM,iBAAA,GAAoB,SAAA;AAC1B,IAAM,aAAA,GAAgB,0BAAA;AAEtB,SAAS,kBAAkB,KAAA,EAAuB;AAChD,EAAA,OAAO,KAAA,CAAM,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AAChC;AAKO,SAAS,cAAA,CAAe,SAAiB,KAAA,EAAuB;AACrE,EAAA,OAAO,CAAA,EAAG,kBAAkB,OAAO,CAAC,GAAG,iBAAiB,CAAA,CAAA,EAAI,kBAAA,CAAmB,KAAK,CAAC,CAAA,CAAA;AACvF;AAYO,SAAS,kBAAA,CAAmB,SAAiB,KAAA,EAAuB;AACzE,EAAA,OAAO,GAAG,cAAA,CAAe,OAAA,EAAS,KAAK,CAAC,GAAG,aAAa,CAAA,MAAA,CAAA;AAC1D;;;ACbO,SAAS,2BAA2B,KAAA,EAA2C;AACpF,EAAA,OAAO,IAAI,eAAA,CAAgB;AAAA,IACzB,WAAW,KAAA,CAAM,QAAA;AAAA,IACjB,UAAA,EAAY,oBAAA;AAAA,IACZ,MAAM,KAAA,CAAM,IAAA;AAAA,IACZ,cAAc,KAAA,CAAM,WAAA;AAAA,IACpB,eAAe,KAAA,CAAM;AAAA,GACtB,EAAE,QAAA,EAAS;AACd;AAMO,SAAS,sBAAsB,KAAA,EAAsC;AAC1E,EAAA,OAAO,IAAI,eAAA,CAAgB;AAAA,IACzB,WAAW,KAAA,CAAM,QAAA;AAAA,IACjB,UAAA,EAAY,eAAA;AAAA,IACZ,eAAe,KAAA,CAAM;AAAA,GACtB,EAAE,QAAA,EAAS;AACd;;;ACtCA,SAAS,SAAS,KAAA,EAAoC;AACpD,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,KAAU,KAAK,KAAA,GAAQ,MAAA;AAC7D;AAEA,SAAS,SAAS,KAAA,EAAoC;AACpD,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,IAAY,OAAO,QAAA,CAAS,KAAK,IAAI,KAAA,GAAQ,MAAA;AACvE;AAQO,SAAS,uBAAuB,GAAA,EAAsC;AAC3E,EAAA,MAAM,WAAA,GAAc,QAAA,CAAS,GAAA,CAAI,YAAY,CAAA;AAC7C,EAAA,IAAI,gBAAgB,MAAA,EAAW;AAC7B,IAAA,MAAM,IAAI,MAAM,qCAAqC,CAAA;AAAA,EACvD;AACA,EAAA,OAAO;AAAA,IACL,WAAA;AAAA,IACA,YAAA,EAAc,QAAA,CAAS,GAAA,CAAI,aAAa,CAAA;AAAA,IACxC,OAAA,EAAS,QAAA,CAAS,GAAA,CAAI,QAAQ,CAAA;AAAA,IAC9B,SAAA,EAAW,QAAA,CAAS,GAAA,CAAI,UAAU,CAAA;AAAA,IAClC,SAAA,EAAW,QAAA,CAAS,GAAA,CAAI,UAAU,CAAA;AAAA,IAClC,KAAA,EAAO,QAAA,CAAS,GAAA,CAAI,KAAK;AAAA,GAC3B;AACF;;;ACVA,IAAM,YAAA,GAAuC;AAAA,EAC3C,cAAA,EAAgB;AAClB,CAAA;AAoBA,eAAe,iBAAA,CACb,IAAA,EACA,GAAA,EACA,IAAA,EACwB;AACxB,EAAA,MAAM,QAAA,GAAW,MAAM,IAAA,CAAK;AAAA,IAC1B,GAAA;AAAA,IACA,MAAA,EAAQ,MAAA;AAAA,IACR,OAAA,EAAS,YAAA;AAAA,IACT;AAAA,GACD,CAAA;AACD,EAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,4BAAA,EAA+B,OAAO,QAAA,CAAS,MAAM,CAAC,CAAA,CAAE,CAAA;AAAA,EAC1E;AACA,EAAA,OAAO,sBAAA,CAAuB,SAAS,IAAwB,CAAA;AACjE;AASA,eAAsB,0BACpB,KAAA,EACwB;AACxB,EAAA,MAAM,GAAA,GAAM,kBAAA,CAAmB,KAAA,CAAM,OAAA,EAAS,MAAM,KAAK,CAAA;AACzD,EAAA,MAAM,OAAO,0BAAA,CAA2B;AAAA,IACtC,UAAU,KAAA,CAAM,QAAA;AAAA,IAChB,MAAM,KAAA,CAAM,IAAA;AAAA,IACZ,aAAa,KAAA,CAAM,WAAA;AAAA,IACnB,cAAc,KAAA,CAAM;AAAA,GACrB,CAAA;AACD,EAAA,OAAO,iBAAA,CAAkB,KAAA,CAAM,IAAA,EAAM,GAAA,EAAK,IAAI,CAAA;AAChD;AASA,eAAsB,mBACpB,KAAA,EACwB;AACxB,EAAA,MAAM,GAAA,GAAM,kBAAA,CAAmB,KAAA,CAAM,OAAA,EAAS,MAAM,KAAK,CAAA;AACzD,EAAA,MAAM,OAAO,qBAAA,CAAsB;AAAA,IACjC,UAAU,KAAA,CAAM,QAAA;AAAA,IAChB,cAAc,KAAA,CAAM;AAAA,GACrB,CAAA;AACD,EAAA,OAAO,iBAAA,CAAkB,KAAA,CAAM,IAAA,EAAM,GAAA,EAAK,IAAI,CAAA;AAChD","file":"index.mjs","sourcesContent":["/**\n * OIDC discovery document fetcher.\n *\n * Fetches `{issuer}/.well-known/openid-configuration` and caches the result\n * per-issuer for the lifetime of the process. Discovery responses are stable\n * for hours; the cache prevents the auth flow from hitting KC on every login.\n *\n * Pure (no React, no hooks). Consumed by app-side hooks that orchestrate the\n * PKCE flow.\n */\n\nimport type { HttpClient } from '../http/HttpClient';\n\n/**\n * Subset of the OIDC Discovery 1.0 metadata the auth-client needs.\n *\n * The KC discovery doc carries many more fields; we type only what the PKCE\n * flow consumes to keep the surface small and to fail loudly when KC ever\n * stops returning one of these.\n */\nexport interface OidcDiscoveryDocument {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n end_session_endpoint?: string;\n userinfo_endpoint?: string;\n jwks_uri?: string;\n}\n\nexport interface FetchDiscoveryDocumentInput {\n /** Issuer URL — `{baseUrl}/realms/{realm}`. Trailing slash tolerated. */\n issuerUrl: string;\n /** Transport. Pass `createFetchHttpClient(fetch)` in browser/Node18+. */\n http: HttpClient;\n}\n\nconst cache = new Map<string, OidcDiscoveryDocument>();\n\nfunction normalizeIssuer(issuerUrl: string): string {\n return issuerUrl.replace(/\\/$/, '');\n}\n\nfunction isOidcDiscoveryDocument(data: unknown): data is OidcDiscoveryDocument {\n if (data === null || typeof data !== 'object') {\n return false;\n }\n const d = data as Record<string, unknown>;\n return (\n typeof d.issuer === 'string'\n && d.issuer !== ''\n && typeof d.authorization_endpoint === 'string'\n && d.authorization_endpoint !== ''\n && typeof d.token_endpoint === 'string'\n && d.token_endpoint !== ''\n );\n}\n\n/**\n * Fetch + cache the OIDC discovery document for an issuer.\n *\n * Cache key = normalized issuer URL (trailing slash stripped).\n *\n * @throws Error when the HTTP call fails, returns non-2xx, or returns a body\n * missing required OIDC metadata fields.\n */\nexport async function fetchDiscoveryDocument(\n input: FetchDiscoveryDocumentInput,\n): Promise<OidcDiscoveryDocument> {\n const key = normalizeIssuer(input.issuerUrl);\n const cached = cache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const response = await input.http({\n url: `${key}/.well-known/openid-configuration`,\n method: 'GET',\n });\n if (!response.ok) {\n throw new Error(\n `OIDC discovery failed: ${String(response.status)} for ${key}`,\n );\n }\n if (!isOidcDiscoveryDocument(response.data)) {\n throw new Error(`OIDC discovery returned invalid metadata for ${key}`);\n }\n cache.set(key, response.data);\n return response.data;\n}\n\n/**\n * Clear the per-issuer discovery cache. Test-only — production code does not\n * call this. Useful when a test mocks different metadata across cases.\n */\nexport function clearDiscoveryCache(): void {\n cache.clear();\n}\n","/**\n * PKCE (RFC 7636) primitives for the OIDC authorization-code flow.\n *\n * Pure (no React). Browser-compatible — uses `crypto.subtle` for SHA-256 and\n * `crypto.getRandomValues` for the verifier. Node 16+ exposes both via\n * `globalThis.crypto`.\n */\n\n/** RFC 7636 §4.1: code_verifier MUST be 43..128 chars from the unreserved set. */\nconst VERIFIER_MIN_LENGTH = 43;\nconst VERIFIER_MAX_LENGTH = 128;\nconst DEFAULT_VERIFIER_LENGTH = 64;\nconst RANDOM_BYTES_PER_CHAR = 1;\n\nconst UNRESERVED_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';\n\nfunction getCrypto(): Crypto {\n const c = (globalThis as { crypto?: Crypto }).crypto;\n // Runtime check: in some Node test environments `crypto.subtle` may not\n // exist even though the TS lib types mark it as non-optional.\n // eslint-disable-next-line sonarjs/different-types-comparison, @typescript-eslint/no-unnecessary-condition\n if (c === undefined || c.subtle === undefined) {\n throw new Error('pkce: globalThis.crypto.subtle is required (Node 16+ / modern browser)');\n }\n return c;\n}\n\nfunction assertVerifierLength(length: number): void {\n if (length < VERIFIER_MIN_LENGTH || length > VERIFIER_MAX_LENGTH) {\n throw new Error(`pkce: code_verifier length must be ${String(VERIFIER_MIN_LENGTH)}-${String(VERIFIER_MAX_LENGTH)} chars (RFC 7636)`);\n }\n}\n\n/**\n * Base64-URL encode an ArrayBuffer (no padding, `-` and `_` substitutions).\n *\n * Required for the S256 challenge — RFC 7636 §4.2.\n */\nfunction base64UrlEncode(buffer: ArrayBuffer): string {\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i] as number);\n }\n const b64 = (globalThis as { btoa?: (s: string) => string }).btoa?.(binary)\n ?? Buffer.from(binary, 'binary').toString('base64');\n // Strip trailing '=' padding by slicing — avoids the sonarjs/slow-regex\n // warning on /=+$/ even though base64 padding is bounded to 0..2 chars.\n let end = b64.length;\n while (end > 0 && b64.charCodeAt(end - 1) === '='.charCodeAt(0)) {\n end -= 1;\n }\n return b64.slice(0, end).replace(/\\+/g, '-').replace(/\\//g, '_');\n}\n\n/**\n * Generate a cryptographically random PKCE code_verifier.\n *\n * Default length 64 sits well inside the RFC 7636 43..128 band.\n *\n * @throws Error when `length` falls outside the RFC band.\n */\nexport function generateCodeVerifier(length: number = DEFAULT_VERIFIER_LENGTH): string {\n assertVerifierLength(length);\n const crypto = getCrypto();\n const bytes = new Uint8Array(length * RANDOM_BYTES_PER_CHAR);\n crypto.getRandomValues(bytes);\n let out = '';\n for (let i = 0; i < length; i++) {\n const byte = bytes[i] as number;\n out += UNRESERVED_CHARS[byte % UNRESERVED_CHARS.length];\n }\n return out;\n}\n\n/**\n * Derive the S256 code_challenge from a code_verifier.\n *\n * `code_challenge = BASE64URL(SHA256(code_verifier))` — RFC 7636 §4.2.\n *\n * @throws Error when `verifier` is shorter than 43 or longer than 128 chars.\n */\nexport async function deriveCodeChallenge(verifier: string): Promise<string> {\n assertVerifierLength(verifier.length);\n const crypto = getCrypto();\n const data = new TextEncoder().encode(verifier);\n const digest = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(digest);\n}\n\nexport interface PkcePair {\n codeVerifier: string;\n codeChallenge: string;\n codeChallengeMethod: 'S256';\n}\n\n/**\n * Convenience: produce a fresh verifier + matching challenge in one call.\n */\nexport async function generatePkcePair(length?: number): Promise<PkcePair> {\n const codeVerifier = generateCodeVerifier(length);\n const codeChallenge = await deriveCodeChallenge(codeVerifier);\n return { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' };\n}\n","/**\n * URL builders for the realm-aware Keycloak surface area.\n *\n * Every helper takes `baseUrl` and `realm` explicitly — no hardcoded realm\n * names. This is the contract that Phase 2 of the product split relies on:\n * the same package serves the future Questioner-realm app and OnlineMenu-realm\n * app without code change.\n */\n\nconst REALM_PATH_PREFIX = '/realms';\nconst PROTOCOL_PATH = '/protocol/openid-connect';\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/$/, '');\n}\n\n/**\n * Compute the issuer URL: `{baseUrl}/realms/{realm}`.\n */\nexport function buildIssuerUrl(baseUrl: string, realm: string): string {\n return `${trimTrailingSlash(baseUrl)}${REALM_PATH_PREFIX}/${encodeURIComponent(realm)}`;\n}\n\n/**\n * Compute the authorization endpoint URL.\n */\nexport function buildAuthorizationEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/auth`;\n}\n\n/**\n * Compute the token endpoint URL.\n */\nexport function buildTokenEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/token`;\n}\n\n/**\n * Compute the userinfo endpoint URL.\n */\nexport function buildUserInfoEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/userinfo`;\n}\n\n/**\n * Compute the logout endpoint URL.\n */\nexport function buildLogoutEndpoint(baseUrl: string, realm: string): string {\n return `${buildIssuerUrl(baseUrl, realm)}${PROTOCOL_PATH}/logout`;\n}\n\nexport interface AuthorizationUrlInput {\n baseUrl: string;\n realm: string;\n clientId: string;\n redirectUri: string;\n scope?: string;\n state?: string;\n codeChallenge?: string;\n codeChallengeMethod?: 'S256' | 'plain';\n}\n\n/**\n * Build a complete authorization URL the user agent can navigate to.\n *\n * All PKCE-related fields are optional so this helper also serves\n * non-PKCE flows (e.g. confidential server-side clients) — but PKCE is\n * the recommended path for SPA / native consumers.\n */\nexport function buildAuthorizationUrl(input: AuthorizationUrlInput): string {\n const params = new URLSearchParams({\n client_id: input.clientId,\n redirect_uri: input.redirectUri,\n response_type: 'code',\n });\n if (typeof input.scope === 'string' && input.scope !== '') {\n params.set('scope', input.scope);\n }\n if (typeof input.state === 'string' && input.state !== '') {\n params.set('state', input.state);\n }\n if (typeof input.codeChallenge === 'string' && input.codeChallenge !== '') {\n params.set('code_challenge', input.codeChallenge);\n params.set('code_challenge_method', input.codeChallengeMethod ?? 'S256');\n }\n return `${buildAuthorizationEndpoint(input.baseUrl, input.realm)}?${params.toString()}`;\n}\n","/**\n * Inputs for the OAuth `authorization_code` token request.\n */\nexport interface AuthorizationCodeBodyInput {\n clientId: string;\n code: string;\n redirectUri: string;\n codeVerifier: string;\n}\n\n/**\n * Inputs for the OAuth `refresh_token` token request.\n */\nexport interface RefreshTokenBodyInput {\n clientId: string;\n refreshToken: string;\n}\n\n/**\n * Build the `application/x-www-form-urlencoded` body for the\n * `grant_type=authorization_code` token endpoint call (PKCE flow).\n */\nexport function buildAuthorizationCodeBody(input: AuthorizationCodeBodyInput): string {\n return new URLSearchParams({\n client_id: input.clientId,\n grant_type: 'authorization_code',\n code: input.code,\n redirect_uri: input.redirectUri,\n code_verifier: input.codeVerifier,\n }).toString();\n}\n\n/**\n * Build the `application/x-www-form-urlencoded` body for the\n * `grant_type=refresh_token` token endpoint call.\n */\nexport function buildRefreshTokenBody(input: RefreshTokenBodyInput): string {\n return new URLSearchParams({\n client_id: input.clientId,\n grant_type: 'refresh_token',\n refresh_token: input.refreshToken,\n }).toString();\n}\n","import type { AuthTokens } from '../types/AuthTokens';\nimport type { RawTokenResponse, TokenResponse } from '../types/TokenResponse';\nimport { computeExpiresAt } from './isTokenExpired';\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === 'string' && value !== '' ? value : undefined;\n}\n\nfunction asNumber(value: unknown): number | undefined {\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n}\n\n/**\n * Map a raw OIDC token endpoint response (snake_case) to camelCase.\n *\n * Throws when `access_token` is missing or empty — callers should let this\n * propagate to the auth state machine, which treats it as a login failure.\n */\nexport function normalizeTokenResponse(raw: RawTokenResponse): TokenResponse {\n const accessToken = asString(raw.access_token);\n if (accessToken === undefined) {\n throw new Error('Token response missing access_token');\n }\n return {\n accessToken,\n refreshToken: asString(raw.refresh_token),\n idToken: asString(raw.id_token),\n expiresIn: asNumber(raw.expires_in),\n tokenType: asString(raw.token_type),\n scope: asString(raw.scope),\n };\n}\n\n/**\n * Convert a normalized {@link TokenResponse} into a persistable\n * {@link AuthTokens} bundle by computing `expiresAt` from `expiresIn`.\n */\nexport function tokenResponseToAuthTokens(\n response: TokenResponse,\n now: number = Date.now(),\n): AuthTokens {\n return {\n accessToken: response.accessToken,\n refreshToken: response.refreshToken,\n idToken: response.idToken,\n expiresAt: computeExpiresAt(response.expiresIn, now),\n };\n}\n","/**\n * OIDC token-endpoint helpers.\n *\n * Pure (no React, no hooks). Wraps the realm-aware token endpoint with the\n * PKCE `authorization_code` and `refresh_token` grants. The transport is\n * injected so callers can use the HTTP client of their choice.\n *\n * Use these from app-side hooks (e.g. `useKeycloakExchange`) instead of\n * duplicating the body-builder + POST + normalise dance.\n */\n\nimport { buildTokenEndpoint } from '../utils/buildKeycloakUrls';\nimport {\n buildAuthorizationCodeBody,\n buildRefreshTokenBody,\n} from '../utils/buildTokenRequestBody';\nimport { normalizeTokenResponse } from '../utils/normalizeTokenResponse';\n\nimport type { HttpClient } from '../http/HttpClient';\nimport type { RawTokenResponse, TokenResponse } from '../types/TokenResponse';\n\nconst FORM_HEADERS: Record<string, string> = {\n 'Content-Type': 'application/x-www-form-urlencoded',\n};\n\nexport interface ExchangeAuthorizationCodeInput {\n http: HttpClient;\n baseUrl: string;\n realm: string;\n clientId: string;\n code: string;\n redirectUri: string;\n codeVerifier: string;\n}\n\nexport interface RefreshAccessTokenInput {\n http: HttpClient;\n baseUrl: string;\n realm: string;\n clientId: string;\n refreshToken: string;\n}\n\nasync function postTokenEndpoint(\n http: HttpClient,\n url: string,\n body: string,\n): Promise<TokenResponse> {\n const response = await http({\n url,\n method: 'POST',\n headers: FORM_HEADERS,\n body,\n });\n if (!response.ok) {\n throw new Error(`token endpoint POST failed: ${String(response.status)}`);\n }\n return normalizeTokenResponse(response.data as RawTokenResponse);\n}\n\n/**\n * Exchange a PKCE authorization `code` for tokens via the realm's token\n * endpoint (`grant_type=authorization_code`).\n *\n * @throws Error when the HTTP call returns non-2xx or the body is missing\n * `access_token`.\n */\nexport async function exchangeAuthorizationCode(\n input: ExchangeAuthorizationCodeInput,\n): Promise<TokenResponse> {\n const url = buildTokenEndpoint(input.baseUrl, input.realm);\n const body = buildAuthorizationCodeBody({\n clientId: input.clientId,\n code: input.code,\n redirectUri: input.redirectUri,\n codeVerifier: input.codeVerifier,\n });\n return postTokenEndpoint(input.http, url, body);\n}\n\n/**\n * Swap a refresh token for a fresh access/refresh-token pair via the realm's\n * token endpoint (`grant_type=refresh_token`).\n *\n * @throws Error when the HTTP call returns non-2xx or the body is missing\n * `access_token`.\n */\nexport async function refreshAccessToken(\n input: RefreshAccessTokenInput,\n): Promise<TokenResponse> {\n const url = buildTokenEndpoint(input.baseUrl, input.realm);\n const body = buildRefreshTokenBody({\n clientId: input.clientId,\n refreshToken: input.refreshToken,\n });\n return postTokenEndpoint(input.http, url, body);\n}\n"]}
|
package/dist/react.d.mts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
|
2
|
+
import { F as ForgotPasswordRequest, A as AuthApiClient, R as ResetPasswordRequest, a as AuthSessionInfo, b as AuthClient } from './AuthClient-BGr8L03W.mjs';
|
|
3
|
+
import './TokenResponse-CY1CaU2l.mjs';
|
|
4
|
+
|
|
5
|
+
interface UseForgotPasswordOptions extends Omit<UseMutationOptions<void, Error, ForgotPasswordRequest>, 'mutationFn'> {
|
|
6
|
+
api: AuthApiClient;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* React Query mutation that POSTs to `/auth/forgot-password`.
|
|
10
|
+
*
|
|
11
|
+
* The backend returns 200 unconditionally (no email enumeration). UI should
|
|
12
|
+
* always show "if that email exists, we sent a reset link" regardless of
|
|
13
|
+
* whether `onSuccess` or `onError` fires.
|
|
14
|
+
*/
|
|
15
|
+
declare function useForgotPassword(options: UseForgotPasswordOptions): UseMutationResult<void, Error, ForgotPasswordRequest>;
|
|
16
|
+
|
|
17
|
+
interface UseResetPasswordOptions extends Omit<UseMutationOptions<void, Error, ResetPasswordRequest>, 'mutationFn'> {
|
|
18
|
+
api: AuthApiClient;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* React Query mutation that POSTs to `/auth/reset-password`.
|
|
22
|
+
*
|
|
23
|
+
* On success, the user can log in with the new password. The backend
|
|
24
|
+
* also revokes existing sessions, so any other devices stay logged in until
|
|
25
|
+
* their access token expires (mobile) or the cookie is cleared (web).
|
|
26
|
+
*/
|
|
27
|
+
declare function useResetPassword(options: UseResetPasswordOptions): UseMutationResult<void, Error, ResetPasswordRequest>;
|
|
28
|
+
|
|
29
|
+
declare const SESSIONS_QUERY_KEY: readonly ["auth", "sessions"];
|
|
30
|
+
interface UseSessionsOptions extends Omit<UseQueryOptions<AuthSessionInfo[], Error, AuthSessionInfo[]>, 'queryKey' | 'queryFn'> {
|
|
31
|
+
api: AuthApiClient;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* React Query wrapper around `GET /me/sessions`. Returns the active sessions
|
|
35
|
+
* for the current user.
|
|
36
|
+
*
|
|
37
|
+
* Use the exported `SESSIONS_QUERY_KEY` for invalidation from other hooks
|
|
38
|
+
* (e.g., after `useRevokeSession` or `useLogoutEverywhere`).
|
|
39
|
+
*/
|
|
40
|
+
declare function useSessions(options: UseSessionsOptions): UseQueryResult<AuthSessionInfo[], Error>;
|
|
41
|
+
|
|
42
|
+
interface UseRevokeSessionOptions extends Omit<UseMutationOptions<void, Error, string>, 'mutationFn'> {
|
|
43
|
+
api: AuthApiClient;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* React Query mutation that POSTs to `/me/sessions/{id}/revoke`. Pass the
|
|
47
|
+
* session id as the variable. Automatically invalidates the sessions query.
|
|
48
|
+
*/
|
|
49
|
+
declare function useRevokeSession(options: UseRevokeSessionOptions): UseMutationResult<void, Error, string>;
|
|
50
|
+
|
|
51
|
+
interface UseLogoutEverywhereOptions extends Omit<UseMutationOptions<void, Error, void>, 'mutationFn'> {
|
|
52
|
+
client: AuthClient;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* React Query mutation calling `AuthClient.logout({ everywhere: true })`.
|
|
56
|
+
*
|
|
57
|
+
* Routes through `AuthClient` (not `AuthApiClient` directly) so storage and
|
|
58
|
+
* inactivity tracker are cleared as well. Invalidates the sessions query so
|
|
59
|
+
* any open sessions screen reflects the empty list.
|
|
60
|
+
*/
|
|
61
|
+
declare function useLogoutEverywhere(options: UseLogoutEverywhereOptions): UseMutationResult<void, Error, void>;
|
|
62
|
+
|
|
63
|
+
export { SESSIONS_QUERY_KEY, type UseForgotPasswordOptions, type UseLogoutEverywhereOptions, type UseResetPasswordOptions, type UseRevokeSessionOptions, type UseSessionsOptions, useForgotPassword, useLogoutEverywhere, useResetPassword, useRevokeSession, useSessions };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
|
2
|
+
import { F as ForgotPasswordRequest, A as AuthApiClient, R as ResetPasswordRequest, a as AuthSessionInfo, b as AuthClient } from './AuthClient-D95OMajD.js';
|
|
3
|
+
import './TokenResponse-CY1CaU2l.js';
|
|
4
|
+
|
|
5
|
+
interface UseForgotPasswordOptions extends Omit<UseMutationOptions<void, Error, ForgotPasswordRequest>, 'mutationFn'> {
|
|
6
|
+
api: AuthApiClient;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* React Query mutation that POSTs to `/auth/forgot-password`.
|
|
10
|
+
*
|
|
11
|
+
* The backend returns 200 unconditionally (no email enumeration). UI should
|
|
12
|
+
* always show "if that email exists, we sent a reset link" regardless of
|
|
13
|
+
* whether `onSuccess` or `onError` fires.
|
|
14
|
+
*/
|
|
15
|
+
declare function useForgotPassword(options: UseForgotPasswordOptions): UseMutationResult<void, Error, ForgotPasswordRequest>;
|
|
16
|
+
|
|
17
|
+
interface UseResetPasswordOptions extends Omit<UseMutationOptions<void, Error, ResetPasswordRequest>, 'mutationFn'> {
|
|
18
|
+
api: AuthApiClient;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* React Query mutation that POSTs to `/auth/reset-password`.
|
|
22
|
+
*
|
|
23
|
+
* On success, the user can log in with the new password. The backend
|
|
24
|
+
* also revokes existing sessions, so any other devices stay logged in until
|
|
25
|
+
* their access token expires (mobile) or the cookie is cleared (web).
|
|
26
|
+
*/
|
|
27
|
+
declare function useResetPassword(options: UseResetPasswordOptions): UseMutationResult<void, Error, ResetPasswordRequest>;
|
|
28
|
+
|
|
29
|
+
declare const SESSIONS_QUERY_KEY: readonly ["auth", "sessions"];
|
|
30
|
+
interface UseSessionsOptions extends Omit<UseQueryOptions<AuthSessionInfo[], Error, AuthSessionInfo[]>, 'queryKey' | 'queryFn'> {
|
|
31
|
+
api: AuthApiClient;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* React Query wrapper around `GET /me/sessions`. Returns the active sessions
|
|
35
|
+
* for the current user.
|
|
36
|
+
*
|
|
37
|
+
* Use the exported `SESSIONS_QUERY_KEY` for invalidation from other hooks
|
|
38
|
+
* (e.g., after `useRevokeSession` or `useLogoutEverywhere`).
|
|
39
|
+
*/
|
|
40
|
+
declare function useSessions(options: UseSessionsOptions): UseQueryResult<AuthSessionInfo[], Error>;
|
|
41
|
+
|
|
42
|
+
interface UseRevokeSessionOptions extends Omit<UseMutationOptions<void, Error, string>, 'mutationFn'> {
|
|
43
|
+
api: AuthApiClient;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* React Query mutation that POSTs to `/me/sessions/{id}/revoke`. Pass the
|
|
47
|
+
* session id as the variable. Automatically invalidates the sessions query.
|
|
48
|
+
*/
|
|
49
|
+
declare function useRevokeSession(options: UseRevokeSessionOptions): UseMutationResult<void, Error, string>;
|
|
50
|
+
|
|
51
|
+
interface UseLogoutEverywhereOptions extends Omit<UseMutationOptions<void, Error, void>, 'mutationFn'> {
|
|
52
|
+
client: AuthClient;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* React Query mutation calling `AuthClient.logout({ everywhere: true })`.
|
|
56
|
+
*
|
|
57
|
+
* Routes through `AuthClient` (not `AuthApiClient` directly) so storage and
|
|
58
|
+
* inactivity tracker are cleared as well. Invalidates the sessions query so
|
|
59
|
+
* any open sessions screen reflects the empty list.
|
|
60
|
+
*/
|
|
61
|
+
declare function useLogoutEverywhere(options: UseLogoutEverywhereOptions): UseMutationResult<void, Error, void>;
|
|
62
|
+
|
|
63
|
+
export { SESSIONS_QUERY_KEY, type UseForgotPasswordOptions, type UseLogoutEverywhereOptions, type UseResetPasswordOptions, type UseRevokeSessionOptions, type UseSessionsOptions, useForgotPassword, useLogoutEverywhere, useResetPassword, useRevokeSession, useSessions };
|