@willyim/idp 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 +21 -0
- package/README.md +278 -0
- package/dist/src/api.d.ts +77 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +50 -0
- package/dist/src/claims.d.ts +46 -0
- package/dist/src/claims.d.ts.map +1 -0
- package/dist/src/claims.js +61 -0
- package/dist/src/client.d.ts +112 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +155 -0
- package/dist/src/cookie.d.ts +19 -0
- package/dist/src/cookie.d.ts.map +1 -0
- package/dist/src/cookie.js +49 -0
- package/dist/src/crypto.d.ts +32 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +76 -0
- package/dist/src/drizzle/index.d.ts +873 -0
- package/dist/src/drizzle/index.d.ts.map +1 -0
- package/dist/src/drizzle/index.js +130 -0
- package/dist/src/duration.d.ts +8 -0
- package/dist/src/duration.d.ts.map +1 -0
- package/dist/src/duration.js +24 -0
- package/dist/src/generated/idp-api.d.ts +1022 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +20 -0
- package/dist/src/react-router/index.d.ts +75 -0
- package/dist/src/react-router/index.d.ts.map +1 -0
- package/dist/src/react-router/index.js +110 -0
- package/dist/src/session.d.ts +141 -0
- package/dist/src/session.d.ts.map +1 -0
- package/dist/src/session.js +369 -0
- package/dist/src/store.d.ts +44 -0
- package/dist/src/store.d.ts.map +1 -0
- package/dist/src/store.js +41 -0
- package/openapi/idp-api.json +1344 -0
- package/package.json +78 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 0 — the OIDC relying party. Everything on the wire lives here: the
|
|
3
|
+
* discovery document, the authorization-code flow with PKCE, refresh, userinfo,
|
|
4
|
+
* and RP-initiated logout. No sessions, no cookies, no storage.
|
|
5
|
+
*
|
|
6
|
+
* `fetch` + WebCrypto only, so it runs anywhere the platform is web-standard.
|
|
7
|
+
*/
|
|
8
|
+
import { normalizeClaims } from "./claims.js";
|
|
9
|
+
import { randomToken, sha256Base64url } from "./crypto.js";
|
|
10
|
+
export const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"];
|
|
11
|
+
export class IdpError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
body;
|
|
14
|
+
constructor(message, status, body) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "IdpError";
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.body = body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A fresh PKCE verifier and its S256 challenge. */
|
|
22
|
+
export async function createPkce() {
|
|
23
|
+
const codeVerifier = randomToken(32);
|
|
24
|
+
return { codeVerifier, codeChallenge: await sha256Base64url(codeVerifier) };
|
|
25
|
+
}
|
|
26
|
+
export function createIdpClient(options) {
|
|
27
|
+
const issuer = options.issuer.replace(/\/+$/, "");
|
|
28
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
29
|
+
const defaultScopes = options.scopes ?? DEFAULT_SCOPES;
|
|
30
|
+
let cached = null;
|
|
31
|
+
/** The discovery document, fetched at most once per client instance. */
|
|
32
|
+
function discover() {
|
|
33
|
+
cached ??= (async () => {
|
|
34
|
+
const url = `${issuer}/.well-known/openid-configuration`;
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await doFetch(url);
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
cached = null;
|
|
41
|
+
throw new IdpError(`discovery request to ${url} failed`, 0, cause);
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
cached = null;
|
|
45
|
+
throw new IdpError(`discovery failed (${response.status})`, response.status, await text(response));
|
|
46
|
+
}
|
|
47
|
+
return (await response.json());
|
|
48
|
+
})();
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
async function token(body, what) {
|
|
52
|
+
const { token_endpoint } = await discover();
|
|
53
|
+
body.set("client_id", options.clientId);
|
|
54
|
+
body.set("client_secret", options.clientSecret);
|
|
55
|
+
const response = await doFetch(token_endpoint, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
58
|
+
body,
|
|
59
|
+
});
|
|
60
|
+
const json = (await response.json().catch(() => null));
|
|
61
|
+
if (!response.ok || !json) {
|
|
62
|
+
throw new IdpError(`${what} failed (${response.status})`, response.status, json);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
accessToken: String(json.access_token ?? ""),
|
|
66
|
+
tokenType: String(json.token_type ?? "Bearer"),
|
|
67
|
+
expiresIn: typeof json.expires_in === "number" ? json.expires_in : null,
|
|
68
|
+
refreshToken: typeof json.refresh_token === "string" ? json.refresh_token : null,
|
|
69
|
+
idToken: typeof json.id_token === "string" ? json.id_token : null,
|
|
70
|
+
scope: typeof json.scope === "string" ? json.scope : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
discover,
|
|
75
|
+
/** Where to send the browser to log in. */
|
|
76
|
+
async authorizationUrl(input) {
|
|
77
|
+
const { authorization_endpoint } = await discover();
|
|
78
|
+
const url = new URL(authorization_endpoint);
|
|
79
|
+
url.searchParams.set("response_type", "code");
|
|
80
|
+
url.searchParams.set("client_id", options.clientId);
|
|
81
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
82
|
+
url.searchParams.set("scope", (input.scopes ?? defaultScopes).join(" "));
|
|
83
|
+
url.searchParams.set("state", input.state);
|
|
84
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
85
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
86
|
+
if (input.prompt)
|
|
87
|
+
url.searchParams.set("prompt", input.prompt);
|
|
88
|
+
if (input.loginHint)
|
|
89
|
+
url.searchParams.set("login_hint", input.loginHint);
|
|
90
|
+
return url.toString();
|
|
91
|
+
},
|
|
92
|
+
/** Swap the callback `code` for tokens. The verifier proves we started the flow. */
|
|
93
|
+
exchangeCode(input) {
|
|
94
|
+
return token(new URLSearchParams({
|
|
95
|
+
grant_type: "authorization_code",
|
|
96
|
+
code: input.code,
|
|
97
|
+
redirect_uri: input.redirectUri,
|
|
98
|
+
code_verifier: input.codeVerifier,
|
|
99
|
+
}), "token exchange");
|
|
100
|
+
},
|
|
101
|
+
/** Requires `offline_access` at login. Throws `IdpError` once the grant is gone. */
|
|
102
|
+
refresh(refreshToken) {
|
|
103
|
+
return token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }), "refresh");
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* Live claims for an access token. Unlike the id_token — a login-time
|
|
107
|
+
* snapshot — this reflects permissions and workspaces as they are *now*,
|
|
108
|
+
* which is how a revocation at the IdP reaches the app.
|
|
109
|
+
*/
|
|
110
|
+
async userinfo(accessToken) {
|
|
111
|
+
const { userinfo_endpoint } = await discover();
|
|
112
|
+
const response = await doFetch(userinfo_endpoint, {
|
|
113
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
114
|
+
});
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new IdpError(`userinfo failed (${response.status})`, response.status, await text(response));
|
|
117
|
+
}
|
|
118
|
+
return normalizeClaims((await response.json()));
|
|
119
|
+
},
|
|
120
|
+
/**
|
|
121
|
+
* RP-initiated logout, built from the `end_session_endpoint` in discovery —
|
|
122
|
+
* never a hardcoded path. Returns null when there is nothing usable to
|
|
123
|
+
* redirect to, which is either of:
|
|
124
|
+
*
|
|
125
|
+
* - the IdP advertises no `end_session_endpoint`, or
|
|
126
|
+
* - we have no `id_token` to hint with. The spec makes `id_token_hint`
|
|
127
|
+
* optional; this IdP does not — it identifies the client from the hint,
|
|
128
|
+
* verifies its signature, and reads `sid` out of it to pick the SSO
|
|
129
|
+
* session to kill. Without one the request is rejected.
|
|
130
|
+
*
|
|
131
|
+
* Callers must treat null as "log out locally and move on" — see
|
|
132
|
+
* `createIdp().logout`, which does exactly that.
|
|
133
|
+
*
|
|
134
|
+
* Note the IdP-side registration this depends on: the OAuth client needs
|
|
135
|
+
* `enable_end_session` set (better-auth answers 401 `invalid_client`
|
|
136
|
+
* otherwise) and `redirectTo` must be listed in its
|
|
137
|
+
* `post_logout_redirect_uris` (an unlisted URI is silently not redirected
|
|
138
|
+
* to, leaving the visitor at the IdP).
|
|
139
|
+
*/
|
|
140
|
+
async logoutUrl(input) {
|
|
141
|
+
const { end_session_endpoint } = await discover();
|
|
142
|
+
if (!end_session_endpoint || !input.idToken)
|
|
143
|
+
return null;
|
|
144
|
+
const url = new URL(end_session_endpoint);
|
|
145
|
+
url.searchParams.set("id_token_hint", input.idToken);
|
|
146
|
+
url.searchParams.set("client_id", options.clientId);
|
|
147
|
+
if (input.redirectTo)
|
|
148
|
+
url.searchParams.set("post_logout_redirect_uri", input.redirectTo);
|
|
149
|
+
return url.toString();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function text(response) {
|
|
154
|
+
return response.text().catch(() => null);
|
|
155
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal cookie serialize/parse. `Request`/`Response` give us the headers but
|
|
3
|
+
* not the codec, and pulling in a cookie library would break the zero-runtime-
|
|
4
|
+
* dependency rule for a dozen lines.
|
|
5
|
+
*/
|
|
6
|
+
export type CookieOptions = {
|
|
7
|
+
path?: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
maxAge?: number;
|
|
10
|
+
httpOnly?: boolean;
|
|
11
|
+
secure?: boolean;
|
|
12
|
+
sameSite?: "lax" | "strict" | "none";
|
|
13
|
+
};
|
|
14
|
+
export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
|
|
15
|
+
/** A `Set-Cookie` that expires the cookie immediately. */
|
|
16
|
+
export declare function clearCookie(name: string, options?: CookieOptions): string;
|
|
17
|
+
export declare function parseCookies(header: string | null): Record<string, string>;
|
|
18
|
+
export declare function readCookie(request: Request, name: string): string | null;
|
|
19
|
+
//# sourceMappingURL=cookie.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie.d.ts","sourceRoot":"","sources":["../../src/cookie.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAA;CACrC,CAAA;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAShG;AAED,0DAA0D;AAC1D,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAE7E;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAe1E;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAExE"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal cookie serialize/parse. `Request`/`Response` give us the headers but
|
|
3
|
+
* not the codec, and pulling in a cookie library would break the zero-runtime-
|
|
4
|
+
* dependency rule for a dozen lines.
|
|
5
|
+
*/
|
|
6
|
+
export function serializeCookie(name, value, options = {}) {
|
|
7
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
8
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
9
|
+
if (options.domain)
|
|
10
|
+
parts.push(`Domain=${options.domain}`);
|
|
11
|
+
if (options.maxAge !== undefined)
|
|
12
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
13
|
+
if (options.httpOnly !== false)
|
|
14
|
+
parts.push("HttpOnly");
|
|
15
|
+
if (options.secure !== false)
|
|
16
|
+
parts.push("Secure");
|
|
17
|
+
parts.push(`SameSite=${sameSiteLabel(options.sameSite ?? "lax")}`);
|
|
18
|
+
return parts.join("; ");
|
|
19
|
+
}
|
|
20
|
+
/** A `Set-Cookie` that expires the cookie immediately. */
|
|
21
|
+
export function clearCookie(name, options = {}) {
|
|
22
|
+
return serializeCookie(name, "", { ...options, maxAge: 0 });
|
|
23
|
+
}
|
|
24
|
+
export function parseCookies(header) {
|
|
25
|
+
const out = {};
|
|
26
|
+
if (!header)
|
|
27
|
+
return out;
|
|
28
|
+
for (const pair of header.split(";")) {
|
|
29
|
+
const eq = pair.indexOf("=");
|
|
30
|
+
if (eq < 0)
|
|
31
|
+
continue;
|
|
32
|
+
const name = pair.slice(0, eq).trim();
|
|
33
|
+
if (!name || name in out)
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
out[name] = decodeURIComponent(pair.slice(eq + 1).trim());
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// A cookie we can't decode is a cookie we didn't write.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export function readCookie(request, name) {
|
|
45
|
+
return parseCookies(request.headers.get("cookie"))[name] ?? null;
|
|
46
|
+
}
|
|
47
|
+
function sameSiteLabel(value) {
|
|
48
|
+
return value === "lax" ? "Lax" : value === "strict" ? "Strict" : "None";
|
|
49
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebCrypto bits the package needs: random ids, the PKCE challenge, and the
|
|
3
|
+
* HMAC that signs the session cookie. Everything here is web-standard so the
|
|
4
|
+
* same code runs on Workers, Node ≥20, Bun and the browser.
|
|
5
|
+
*/
|
|
6
|
+
export declare function base64url(bytes: Uint8Array): string;
|
|
7
|
+
export declare function base64urlEncodeString(value: string): string;
|
|
8
|
+
export declare function base64urlDecodeString(value: string): string;
|
|
9
|
+
/** Cryptographically random base64url string — session ids, state, PKCE verifiers. */
|
|
10
|
+
export declare function randomToken(bytes?: number): string;
|
|
11
|
+
/** The S256 PKCE challenge for a verifier. */
|
|
12
|
+
export declare function sha256Base64url(input: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Length-independent, content-constant-time string comparison. Both arguments
|
|
15
|
+
* are base64url HMACs of a fixed length in practice, so the early length exit
|
|
16
|
+
* leaks nothing an attacker doesn't already know.
|
|
17
|
+
*/
|
|
18
|
+
export declare function timingSafeEqual(a: string, b: string): boolean;
|
|
19
|
+
export type Signer = {
|
|
20
|
+
sign(value: string): Promise<string>;
|
|
21
|
+
/** `value.signature` if the signature checks out, else null. */
|
|
22
|
+
unsign(signed: string): Promise<string | null>;
|
|
23
|
+
/** `${value}.${signature}` */
|
|
24
|
+
pack(value: string): Promise<string>;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* HMAC-SHA256 over a string, with the imported key memoized per signer. Used to
|
|
28
|
+
* sign the opaque session id so a forged or tampered cookie is rejected before
|
|
29
|
+
* the session store is ever touched.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createSigner(secret: string): Signer;
|
|
32
|
+
//# sourceMappingURL=crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAInD;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED,sFAAsF;AACtF,wBAAgB,WAAW,CAAC,KAAK,SAAK,GAAG,MAAM,CAI9C;AAED,8CAA8C;AAC9C,wBAAsB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGpE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAK7D;AAED,MAAM,MAAM,MAAM,GAAG;IACnB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACpC,gEAAgE;IAChE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC9C,8BAA8B;IAC9B,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACrC,CAAA;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CA6BnD"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebCrypto bits the package needs: random ids, the PKCE challenge, and the
|
|
3
|
+
* HMAC that signs the session cookie. Everything here is web-standard so the
|
|
4
|
+
* same code runs on Workers, Node ≥20, Bun and the browser.
|
|
5
|
+
*/
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
export function base64url(bytes) {
|
|
8
|
+
let binary = "";
|
|
9
|
+
for (const byte of bytes)
|
|
10
|
+
binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
12
|
+
}
|
|
13
|
+
export function base64urlEncodeString(value) {
|
|
14
|
+
return base64url(encoder.encode(value));
|
|
15
|
+
}
|
|
16
|
+
export function base64urlDecodeString(value) {
|
|
17
|
+
const padding = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
|
18
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding);
|
|
19
|
+
const bytes = new Uint8Array(binary.length);
|
|
20
|
+
for (let i = 0; i < binary.length; i++)
|
|
21
|
+
bytes[i] = binary.charCodeAt(i);
|
|
22
|
+
return new TextDecoder().decode(bytes);
|
|
23
|
+
}
|
|
24
|
+
/** Cryptographically random base64url string — session ids, state, PKCE verifiers. */
|
|
25
|
+
export function randomToken(bytes = 32) {
|
|
26
|
+
const buffer = new Uint8Array(bytes);
|
|
27
|
+
crypto.getRandomValues(buffer);
|
|
28
|
+
return base64url(buffer);
|
|
29
|
+
}
|
|
30
|
+
/** The S256 PKCE challenge for a verifier. */
|
|
31
|
+
export async function sha256Base64url(input) {
|
|
32
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(input));
|
|
33
|
+
return base64url(new Uint8Array(digest));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Length-independent, content-constant-time string comparison. Both arguments
|
|
37
|
+
* are base64url HMACs of a fixed length in practice, so the early length exit
|
|
38
|
+
* leaks nothing an attacker doesn't already know.
|
|
39
|
+
*/
|
|
40
|
+
export function timingSafeEqual(a, b) {
|
|
41
|
+
if (a.length !== b.length)
|
|
42
|
+
return false;
|
|
43
|
+
let diff = 0;
|
|
44
|
+
for (let i = 0; i < a.length; i++)
|
|
45
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
46
|
+
return diff === 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* HMAC-SHA256 over a string, with the imported key memoized per signer. Used to
|
|
50
|
+
* sign the opaque session id so a forged or tampered cookie is rejected before
|
|
51
|
+
* the session store is ever touched.
|
|
52
|
+
*/
|
|
53
|
+
export function createSigner(secret) {
|
|
54
|
+
if (!secret)
|
|
55
|
+
throw new Error("session.secret is required");
|
|
56
|
+
let key = null;
|
|
57
|
+
const getKey = () => (key ??= crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]));
|
|
58
|
+
async function sign(value) {
|
|
59
|
+
const signature = await crypto.subtle.sign("HMAC", await getKey(), encoder.encode(value));
|
|
60
|
+
return base64url(new Uint8Array(signature));
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
sign,
|
|
64
|
+
pack: async (value) => `${value}.${await sign(value)}`,
|
|
65
|
+
async unsign(signed) {
|
|
66
|
+
const dot = signed.lastIndexOf(".");
|
|
67
|
+
if (dot <= 0)
|
|
68
|
+
return null;
|
|
69
|
+
const value = signed.slice(0, dot);
|
|
70
|
+
const signature = signed.slice(dot + 1);
|
|
71
|
+
if (!signature)
|
|
72
|
+
return null;
|
|
73
|
+
return timingSafeEqual(await sign(value), signature) ? value : null;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|