Blame · Line-by-line history
cross-product-auth.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 055ebc4 | 1 | /** |
| 2 | * Block K11 — Cross-product identity. | |
| 3 | * | |
| 4 | * gluecron is the identity provider (IdP) for its sibling products: | |
| 5 | * * Crontech — runtime/hosting | |
| 6 | * * Gatetest — testing platform | |
| 7 | * | |
| 8 | * A single gluecron credential (session cookie, `glc_` PAT, or `glct_` OAuth | |
| 9 | * access token) can be exchanged at `POST /api/v1/cross-product/token` for a | |
| 10 | * short-lived HS256 JWT bound to a specific audience. The sibling products | |
| 11 | * verify these tokens either by calling `GET /api/v1/cross-product/verify` or | |
| 12 | * by verifying the HMAC signature themselves with the shared secret. | |
| 13 | * | |
| 14 | * This module is the pure crypto + persistence layer. It: | |
| 15 | * * Mints signed JWTs using HMAC-SHA256 (crypto.subtle, no new deps). | |
| 16 | * * Records every mint in `cross_product_tokens` for revocation + audit. | |
| 17 | * * Verifies tokens (signature, exp, and the revocation list). | |
| 18 | * | |
| 19 | * Rules: | |
| 20 | * * Secret is `process.env.CROSS_PRODUCT_SIGNING_SECRET`. In non-prod we fall | |
| 21 | * back to a deterministic dev secret so tests run offline; in prod we | |
| 22 | * refuse to boot. | |
| 23 | * * Tokens are 15 minutes by default. Callers may NOT extend this. | |
| 24 | * * Scopes are a fixed allowlist — anything unknown is silently dropped. | |
| 25 | * * Audiences are a fixed allowlist — signing for an unknown audience throws. | |
| 26 | * * JTI is a v4 UUID. The `cross_product_tokens` row is the source of truth | |
| 27 | * for revocation; the JWT itself is not self-revoking. | |
| 28 | */ | |
| 29 | ||
| 30 | import { sql } from "drizzle-orm"; | |
| 31 | import { db } from "../db"; | |
| 32 | ||
| 33 | // --------------------------------------------------------------------------- | |
| 34 | // Config: audiences, scopes, TTL. | |
| 35 | // --------------------------------------------------------------------------- | |
| 36 | ||
| 37 | export type Audience = "crontech" | "gatetest"; | |
| 38 | ||
| 39 | export const ALLOWED_AUDIENCES: readonly Audience[] = [ | |
| 40 | "crontech", | |
| 41 | "gatetest", | |
| 42 | ] as const; | |
| 43 | ||
| 44 | /** | |
| 45 | * The universe of scopes gluecron will sign across all sibling products. | |
| 46 | * Keep this tight — unknown scopes get dropped, not rejected, so new sibling | |
| 47 | * products must add their scope here before clients can request it. | |
| 48 | */ | |
| 49 | export const ALLOWED_SCOPES: readonly string[] = [ | |
| 50 | "deploy:read", | |
| 51 | "deploy:write", | |
| 52 | "test:run", | |
| 53 | "test:heal", | |
| 54 | "signals:write", | |
| 55 | "signals:read", | |
| 56 | "identity:read", | |
| 57 | ] as const; | |
| 58 | ||
| 59 | export const DEFAULT_TTL_SECONDS: number = 15 * 60; | |
| 60 | ||
| 61 | /** Identity claim issuer — sibling products check this. */ | |
| 62 | export const ISSUER = "gluecron" as const; | |
| 63 | ||
| 64 | // --------------------------------------------------------------------------- | |
| 65 | // Types. | |
| 66 | // --------------------------------------------------------------------------- | |
| 67 | ||
| 68 | export interface CrossProductClaims { | |
| 69 | /** Subject — gluecron user id. */ | |
| 70 | sub: string; | |
| 71 | /** User's gluecron email (for sibling audit logs). */ | |
| 72 | email: string; | |
| 73 | /** Always `"gluecron"`. */ | |
| 74 | iss: typeof ISSUER; | |
| 75 | /** `"crontech"` or `"gatetest"`. */ | |
| 76 | aud: Audience; | |
| 77 | /** Expiry, seconds since epoch. */ | |
| 78 | exp: number; | |
| 79 | /** Issued-at, seconds since epoch. */ | |
| 80 | iat: number; | |
| 81 | /** JWT ID (UUID v4). */ | |
| 82 | jti: string; | |
| 83 | /** Filtered to ALLOWED_SCOPES. */ | |
| 84 | scopes: string[]; | |
| 85 | } | |
| 86 | ||
| 87 | export interface SignInput { | |
| 88 | userId: string; | |
| 89 | email: string; | |
| 90 | audience: Audience; | |
| 91 | scopes?: string[]; | |
| 92 | ttlSeconds?: number; | |
| 93 | } | |
| 94 | ||
| 95 | export interface SignResult { | |
| 96 | token: string; | |
| 97 | jti: string; | |
| 98 | expiresAt: Date; | |
| 99 | scopes: string[]; | |
| 100 | } | |
| 101 | ||
| 102 | export type VerifyResult = | |
| 103 | | { | |
| 104 | valid: true; | |
| 105 | sub: string; | |
| 106 | email: string; | |
| 107 | audience: Audience; | |
| 108 | scopes: string[]; | |
| 109 | jti: string; | |
| 110 | expiresAt: Date; | |
| 111 | } | |
| 112 | | { valid: false; reason: VerifyFailureReason }; | |
| 113 | ||
| 114 | export type VerifyFailureReason = | |
| 115 | | "malformed" | |
| 116 | | "bad_algorithm" | |
| 117 | | "bad_signature" | |
| 118 | | "expired" | |
| 119 | | "unknown_audience" | |
| 120 | | "revoked" | |
| 121 | | "unknown_jti"; | |
| 122 | ||
| 123 | export interface ActiveCrossProductToken { | |
| 124 | jti: string; | |
| 125 | userId: string; | |
| 126 | audience: Audience; | |
| 127 | scopes: string[]; | |
| 128 | issuedAt: Date; | |
| 129 | expiresAt: Date; | |
| 130 | revokedAt: Date | null; | |
| 131 | } | |
| 132 | ||
| 133 | // --------------------------------------------------------------------------- | |
| 134 | // Secret loading. | |
| 135 | // --------------------------------------------------------------------------- | |
| 136 | ||
| 137 | const DEV_FALLBACK_SEED = "gluecron-dev-secret-do-not-use-in-prod"; | |
| 138 | ||
| 139 | let cachedKey: Promise<CryptoKey> | null = null; | |
| 140 | ||
| 141 | function resolveSecret(): string { | |
| 142 | const envVar = process.env.CROSS_PRODUCT_SIGNING_SECRET; | |
| 143 | if (envVar && envVar.length >= 16) return envVar; | |
| 144 | if (process.env.NODE_ENV === "production") { | |
| 145 | throw new Error( | |
| 146 | "CROSS_PRODUCT_SIGNING_SECRET must be set (>=16 chars) in production" | |
| 147 | ); | |
| 148 | } | |
| 149 | return DEV_FALLBACK_SEED; | |
| 150 | } | |
| 151 | ||
| 152 | async function getSigningKey(): Promise<CryptoKey> { | |
| 153 | if (!cachedKey) { | |
| 154 | cachedKey = (async () => { | |
| 155 | const secret = resolveSecret(); | |
| 156 | const raw = new TextEncoder().encode(secret); | |
| 157 | return await crypto.subtle.importKey( | |
| 158 | "raw", | |
| 159 | raw, | |
| 160 | { name: "HMAC", hash: "SHA-256" }, | |
| 161 | false, | |
| 162 | ["sign", "verify"] | |
| 163 | ); | |
| 164 | })(); | |
| 165 | } | |
| 166 | return cachedKey; | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Test-only: forget the cached key so a test can rotate the secret mid-run. | |
| 171 | * Not exported from the package root — call via `__test`. | |
| 172 | */ | |
| 173 | function resetSigningKeyCache(): void { | |
| 174 | cachedKey = null; | |
| 175 | } | |
| 176 | ||
| 177 | // --------------------------------------------------------------------------- | |
| 178 | // Base64url (JWT-safe). | |
| 179 | // --------------------------------------------------------------------------- | |
| 180 | ||
| 181 | function b64urlEncode(bytes: Uint8Array): string { | |
| 182 | let bin = ""; | |
| 183 | for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); | |
| 184 | return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 185 | } | |
| 186 | ||
| 187 | function b64urlEncodeString(str: string): string { | |
| 188 | return b64urlEncode(new TextEncoder().encode(str)); | |
| 189 | } | |
| 190 | ||
| 191 | function b64urlDecode(input: string): Uint8Array { | |
| 192 | // Re-pad to a multiple of 4. | |
| 193 | const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4)); | |
| 194 | const b64 = input.replace(/-/g, "+").replace(/_/g, "/") + pad; | |
| 195 | const bin = atob(b64); | |
| 196 | const out = new Uint8Array(bin.length); | |
| 197 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 198 | return out; | |
| 199 | } | |
| 200 | ||
| 201 | function b64urlDecodeString(input: string): string { | |
| 202 | return new TextDecoder().decode(b64urlDecode(input)); | |
| 203 | } | |
| 204 | ||
| 205 | // --------------------------------------------------------------------------- | |
| 206 | // UUID v4 (no deps). | |
| 207 | // --------------------------------------------------------------------------- | |
| 208 | ||
| 209 | function uuidV4(): string { | |
| 210 | const b = crypto.getRandomValues(new Uint8Array(16)); | |
| 211 | b[6] = (b[6] & 0x0f) | 0x40; | |
| 212 | b[8] = (b[8] & 0x3f) | 0x80; | |
| 213 | const hex = Array.from(b).map((x) => x.toString(16).padStart(2, "0")); | |
| 214 | return ( | |
| 215 | hex.slice(0, 4).join("") + | |
| 216 | "-" + | |
| 217 | hex.slice(4, 6).join("") + | |
| 218 | "-" + | |
| 219 | hex.slice(6, 8).join("") + | |
| 220 | "-" + | |
| 221 | hex.slice(8, 10).join("") + | |
| 222 | "-" + | |
| 223 | hex.slice(10, 16).join("") | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | // --------------------------------------------------------------------------- | |
| 228 | // Validation helpers. | |
| 229 | // --------------------------------------------------------------------------- | |
| 230 | ||
| 231 | export function validateScopes(requested: readonly string[] | undefined): string[] { | |
| 232 | if (!requested || !Array.isArray(requested)) return []; | |
| 233 | const seen = new Set<string>(); | |
| 234 | const out: string[] = []; | |
| 235 | const allow = new Set<string>(ALLOWED_SCOPES); | |
| 236 | for (const raw of requested) { | |
| 237 | if (typeof raw !== "string") continue; | |
| 238 | const s = raw.trim(); | |
| 239 | if (!s || seen.has(s)) continue; | |
| 240 | if (allow.has(s)) { | |
| 241 | out.push(s); | |
| 242 | seen.add(s); | |
| 243 | } | |
| 244 | } | |
| 245 | return out; | |
| 246 | } | |
| 247 | ||
| 248 | export function isAllowedAudience(value: unknown): value is Audience { | |
| 249 | return ( | |
| 250 | typeof value === "string" && | |
| 251 | (ALLOWED_AUDIENCES as readonly string[]).includes(value) | |
| 252 | ); | |
| 253 | } | |
| 254 | ||
| 255 | // --------------------------------------------------------------------------- | |
| 256 | // Sign / verify. | |
| 257 | // --------------------------------------------------------------------------- | |
| 258 | ||
| 259 | async function hmacSign(signingInput: string): Promise<string> { | |
| 260 | const key = await getSigningKey(); | |
| 261 | const sig = await crypto.subtle.sign( | |
| 262 | "HMAC", | |
| 263 | key, | |
| 264 | new TextEncoder().encode(signingInput) | |
| 265 | ); | |
| 266 | return b64urlEncode(new Uint8Array(sig)); | |
| 267 | } | |
| 268 | ||
| 269 | async function hmacVerify( | |
| 270 | signingInput: string, | |
| 271 | signatureB64: string | |
| 272 | ): Promise<boolean> { | |
| 273 | const key = await getSigningKey(); | |
| 274 | const sig = b64urlDecode(signatureB64); | |
| 275 | return await crypto.subtle.verify( | |
| 276 | "HMAC", | |
| 277 | key, | |
| 278 | sig, | |
| 279 | new TextEncoder().encode(signingInput) | |
| 280 | ); | |
| 281 | } | |
| 282 | ||
| 283 | /** | |
| 284 | * Mint a cross-product JWT and persist the jti row. | |
| 285 | * | |
| 286 | * Throws on programmer errors (unknown audience, missing user id). Swallows | |
| 287 | * only DB failures on the insert — the token is still returned, but downstream | |
| 288 | * revocation will fail open. Deployed code should monitor the log. | |
| 289 | */ | |
| 290 | export async function signCrossProductToken( | |
| 291 | input: SignInput | |
| 292 | ): Promise<SignResult> { | |
| 293 | if (!input.userId || typeof input.userId !== "string") { | |
| 294 | throw new Error("userId is required"); | |
| 295 | } | |
| 296 | if (!isAllowedAudience(input.audience)) { | |
| 297 | throw new Error(`unknown audience: ${String(input.audience)}`); | |
| 298 | } | |
| 299 | const ttl = Math.max( | |
| 300 | 60, | |
| 301 | Math.min(input.ttlSeconds ?? DEFAULT_TTL_SECONDS, DEFAULT_TTL_SECONDS) | |
| 302 | ); | |
| 303 | const scopes = validateScopes(input.scopes ?? []); | |
| 304 | const jti = uuidV4(); | |
| 305 | const iat = Math.floor(Date.now() / 1000); | |
| 306 | const exp = iat + ttl; | |
| 307 | const expiresAt = new Date(exp * 1000); | |
| 308 | ||
| 309 | const header = { alg: "HS256", typ: "JWT" } as const; | |
| 310 | const payload: CrossProductClaims = { | |
| 311 | sub: input.userId, | |
| 312 | email: input.email, | |
| 313 | iss: ISSUER, | |
| 314 | aud: input.audience, | |
| 315 | exp, | |
| 316 | iat, | |
| 317 | jti, | |
| 318 | scopes, | |
| 319 | }; | |
| 320 | ||
| 321 | const headerB = b64urlEncodeString(JSON.stringify(header)); | |
| 322 | const payloadB = b64urlEncodeString(JSON.stringify(payload)); | |
| 323 | const signingInput = `${headerB}.${payloadB}`; | |
| 324 | const sigB = await hmacSign(signingInput); | |
| 325 | const token = `${signingInput}.${sigB}`; | |
| 326 | ||
| 327 | try { | |
| 328 | await db.execute(sql` | |
| 329 | INSERT INTO cross_product_tokens (jti, user_id, audience, scopes, issued_at, expires_at) | |
| 330 | VALUES ( | |
| 331 | ${jti}, | |
| 332 | ${input.userId}, | |
| 333 | ${input.audience}, | |
| 334 | ${JSON.stringify(scopes)}, | |
| 335 | to_timestamp(${iat}), | |
| 336 | to_timestamp(${exp}) | |
| 337 | ) | |
| 338 | `); | |
| 339 | } catch (err) { | |
| 340 | console.error("[cross-product-auth] failed to persist jti:", err); | |
| 341 | } | |
| 342 | ||
| 343 | return { token, jti, expiresAt, scopes }; | |
| 344 | } | |
| 345 | ||
| 346 | /** | |
| 347 | * Verify a cross-product JWT. | |
| 348 | * | |
| 349 | * * Checks the structural shape: 3 base64url parts. | |
| 350 | * * Rejects anything that isn't `alg: "HS256", typ: "JWT"`. | |
| 351 | * * Verifies HMAC. | |
| 352 | * * Checks `exp` vs. now. | |
| 353 | * * Checks the `cross_product_tokens` row for revocation. | |
| 354 | * | |
| 355 | * If the DB lookup fails (connection error), we fail open on the revocation | |
| 356 | * check — the signature + exp + audience checks have already passed, so | |
| 357 | * callers still get a well-formed identity; we log the DB error for ops. | |
| 358 | */ | |
| 359 | export async function verifyCrossProductToken( | |
| 360 | token: string | |
| 361 | ): Promise<VerifyResult> { | |
| 362 | if (!token || typeof token !== "string") { | |
| 363 | return { valid: false, reason: "malformed" }; | |
| 364 | } | |
| 365 | const parts = token.split("."); | |
| 366 | if (parts.length !== 3) return { valid: false, reason: "malformed" }; | |
| 367 | const [headerB, payloadB, sigB] = parts; | |
| 368 | ||
| 369 | let header: { alg?: unknown; typ?: unknown }; | |
| 370 | let payload: Partial<CrossProductClaims> & Record<string, unknown>; | |
| 371 | try { | |
| 372 | header = JSON.parse(b64urlDecodeString(headerB)); | |
| 373 | payload = JSON.parse(b64urlDecodeString(payloadB)); | |
| 374 | } catch { | |
| 375 | return { valid: false, reason: "malformed" }; | |
| 376 | } | |
| 377 | ||
| 378 | if (header.alg !== "HS256" || header.typ !== "JWT") { | |
| 379 | return { valid: false, reason: "bad_algorithm" }; | |
| 380 | } | |
| 381 | ||
| 382 | const signingInput = `${headerB}.${payloadB}`; | |
| 383 | let sigOk = false; | |
| 384 | try { | |
| 385 | sigOk = await hmacVerify(signingInput, sigB); | |
| 386 | } catch { | |
| 387 | sigOk = false; | |
| 388 | } | |
| 389 | if (!sigOk) return { valid: false, reason: "bad_signature" }; | |
| 390 | ||
| 391 | if (typeof payload.exp !== "number") { | |
| 392 | return { valid: false, reason: "malformed" }; | |
| 393 | } | |
| 394 | const now = Math.floor(Date.now() / 1000); | |
| 395 | if (payload.exp <= now) return { valid: false, reason: "expired" }; | |
| 396 | ||
| 397 | if (!isAllowedAudience(payload.aud)) { | |
| 398 | return { valid: false, reason: "unknown_audience" }; | |
| 399 | } | |
| 400 | if (typeof payload.sub !== "string" || !payload.sub) { | |
| 401 | return { valid: false, reason: "malformed" }; | |
| 402 | } | |
| 403 | if (typeof payload.jti !== "string" || !payload.jti) { | |
| 404 | return { valid: false, reason: "malformed" }; | |
| 405 | } | |
| 406 | ||
| 407 | // Revocation + jti existence check. | |
| 408 | try { | |
| 409 | const rows = (await db.execute(sql` | |
| 410 | SELECT revoked_at FROM cross_product_tokens | |
| 411 | WHERE jti = ${payload.jti} | |
| 412 | LIMIT 1 | |
| 413 | `)) as unknown as Array<{ revoked_at: string | null }>; | |
| 414 | const row = Array.isArray(rows) ? rows[0] : undefined; | |
| 415 | if (row && row.revoked_at) { | |
| 416 | return { valid: false, reason: "revoked" }; | |
| 417 | } | |
| 418 | // Note: a missing row is not a hard fail; dev/test paths where the insert | |
| 419 | // was swallowed still surface the token. Ops can flip this to strict by | |
| 420 | // setting CROSS_PRODUCT_STRICT_JTI=1. | |
| 421 | if (!row && process.env.CROSS_PRODUCT_STRICT_JTI === "1") { | |
| 422 | return { valid: false, reason: "unknown_jti" }; | |
| 423 | } | |
| 424 | } catch (err) { | |
| 425 | console.error("[cross-product-auth] revocation lookup failed:", err); | |
| 426 | } | |
| 427 | ||
| 428 | const scopes = Array.isArray(payload.scopes) | |
| 429 | ? (payload.scopes as unknown[]).filter( | |
| 430 | (s): s is string => typeof s === "string" | |
| 431 | ) | |
| 432 | : []; | |
| 433 | ||
| 434 | return { | |
| 435 | valid: true, | |
| 436 | sub: payload.sub, | |
| 437 | email: typeof payload.email === "string" ? payload.email : "", | |
| 438 | audience: payload.aud, | |
| 439 | scopes, | |
| 440 | jti: payload.jti, | |
| 441 | expiresAt: new Date(payload.exp * 1000), | |
| 442 | }; | |
| 443 | } | |
| 444 | ||
| 445 | /** | |
| 446 | * Revoke a previously-minted cross-product token. Only the owning user's id | |
| 447 | * is accepted — callers must enforce this from the session / PAT / OAuth. | |
| 448 | * Returns `true` if a row was updated. | |
| 449 | */ | |
| 450 | export async function revokeCrossProductToken( | |
| 451 | jti: string, | |
| 452 | userId: string | |
| 453 | ): Promise<boolean> { | |
| 454 | if (!jti || !userId) return false; | |
| 455 | try { | |
| 456 | const rows = (await db.execute(sql` | |
| 457 | UPDATE cross_product_tokens | |
| 458 | SET revoked_at = now() | |
| 459 | WHERE jti = ${jti} | |
| 460 | AND user_id = ${userId} | |
| 461 | AND revoked_at IS NULL | |
| 462 | RETURNING jti | |
| 463 | `)) as unknown as Array<{ jti: string }>; | |
| 464 | return Array.isArray(rows) && rows.length > 0; | |
| 465 | } catch (err) { | |
| 466 | console.error("[cross-product-auth] revoke failed:", err); | |
| 467 | return false; | |
| 468 | } | |
| 469 | } | |
| 470 | ||
| 471 | /** | |
| 472 | * List the user's currently-usable tokens (non-revoked, non-expired). | |
| 473 | * Ordered newest first. Cap at 50 to keep the settings page responsive. | |
| 474 | */ | |
| 475 | export async function listActiveCrossProductTokens( | |
| 476 | userId: string | |
| 477 | ): Promise<ActiveCrossProductToken[]> { | |
| 478 | if (!userId) return []; | |
| 479 | try { | |
| 480 | const rows = (await db.execute(sql` | |
| 481 | SELECT jti, user_id, audience, scopes, issued_at, expires_at, revoked_at | |
| 482 | FROM cross_product_tokens | |
| 483 | WHERE user_id = ${userId} | |
| 484 | AND revoked_at IS NULL | |
| 485 | AND expires_at > now() | |
| 486 | ORDER BY issued_at DESC | |
| 487 | LIMIT 50 | |
| 488 | `)) as unknown as Array<Record<string, unknown>>; | |
| 489 | return (rows || []).map(rowToActive).filter( | |
| 490 | (r): r is ActiveCrossProductToken => r !== null | |
| 491 | ); | |
| 492 | } catch (err) { | |
| 493 | console.error("[cross-product-auth] list failed:", err); | |
| 494 | return []; | |
| 495 | } | |
| 496 | } | |
| 497 | ||
| 498 | function rowToActive(row: Record<string, unknown>): ActiveCrossProductToken | null { | |
| 499 | if (!row) return null; | |
| 500 | const jti = row.jti; | |
| 501 | const userId = row.user_id; | |
| 502 | const audience = row.audience; | |
| 503 | if (typeof jti !== "string" || typeof userId !== "string") return null; | |
| 504 | if (!isAllowedAudience(audience)) return null; | |
| 505 | let scopes: string[] = []; | |
| 506 | if (typeof row.scopes === "string") { | |
| 507 | try { | |
| 508 | const parsed = JSON.parse(row.scopes); | |
| 509 | if (Array.isArray(parsed)) { | |
| 510 | scopes = parsed.filter((s): s is string => typeof s === "string"); | |
| 511 | } | |
| 512 | } catch { | |
| 513 | scopes = []; | |
| 514 | } | |
| 515 | } | |
| 516 | const issuedAt = row.issued_at ? new Date(String(row.issued_at)) : new Date(); | |
| 517 | const expiresAt = row.expires_at | |
| 518 | ? new Date(String(row.expires_at)) | |
| 519 | : new Date(); | |
| 520 | const revokedAt = row.revoked_at ? new Date(String(row.revoked_at)) : null; | |
| 521 | return { jti, userId, audience, scopes, issuedAt, expiresAt, revokedAt }; | |
| 522 | } | |
| 523 | ||
| 524 | // --------------------------------------------------------------------------- | |
| 525 | // Test hooks — do not import from production code. | |
| 526 | // --------------------------------------------------------------------------- | |
| 527 | ||
| 528 | export const __test = { | |
| 529 | resetSigningKeyCache, | |
| 530 | b64urlEncode, | |
| 531 | b64urlEncodeString, | |
| 532 | b64urlDecode, | |
| 533 | b64urlDecodeString, | |
| 534 | uuidV4, | |
| 535 | resolveSecret, | |
| 536 | }; |