@tdacorp/identity-client 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.
@@ -0,0 +1,85 @@
1
+ import { NextResponse, NextRequest } from 'next/server';
2
+ import { c as TokenSet, I as IdTokenClaims } from './verify-BiGhwaIz.cjs';
3
+ import { SealSecret } from './sealed.cjs';
4
+ import '@tdacorp/identity-authz';
5
+ import 'jose';
6
+
7
+ interface CreateLoginRouteConfig {
8
+ issuer: string;
9
+ clientId: string;
10
+ redirectUri: string;
11
+ /** Space-separated OAuth scopes. Defaults to `"openid profile email
12
+ * roles"` -- `roles` is included in the default because without it the
13
+ * minted token carries no `roles` claim, and every `permits()` call
14
+ * (`@tdacorp/identity-authz`) against that token then permanently
15
+ * returns `indeterminate` with `reason: 'absent-claim'`, regardless of
16
+ * what the caller is actually authorized to do. Override this down to
17
+ * whatever scope is actually needed -- e.g. `"openid"` alone -- for a
18
+ * consuming app that genuinely does not use RBAC. */
19
+ scope?: string;
20
+ /** Encrypts the transaction cookie; see `./sealed`'s `SealSecret`. Must be
21
+ * the same value `createCallbackRoute` is configured with. */
22
+ cookieSecret: SealSecret;
23
+ transactionCookieName?: string;
24
+ /** Where to send the user after login completes. Defaults to reading a
25
+ * `returnTo` search param off the incoming request. */
26
+ getReturnTo?: (request: NextRequest) => string | undefined;
27
+ }
28
+ /** Builds a GET Route Handler that starts the OIDC login: generates PKCE and
29
+ * `state`, seals them (plus `returnTo`) into a short-lived cookie, and
30
+ * redirects to the identity server's `authorization_endpoint`. */
31
+ declare function createLoginRoute(config: CreateLoginRouteConfig): (request: NextRequest) => Promise<NextResponse>;
32
+ /**
33
+ * Why `createCallbackRoute`'s handler could not complete the login, passed
34
+ * to `config.onError` (or rendered by the default handler as a 400 JSON
35
+ * body naming `code`).
36
+ *
37
+ * - `'provider_error'`: the identity server's redirect itself carried an
38
+ * `error` param (the user denied consent, an invalid request, etc).
39
+ * - `'missing_code'` / `'missing_state'`: the callback request is missing a
40
+ * `code` or `state` query param.
41
+ * - `'missing_transaction'`: the transaction cookie `createLoginRoute` set
42
+ * was not presented, failed to decrypt, or has expired.
43
+ * - `'state_mismatch'`: the `state` param does not match the value stored
44
+ * in the transaction cookie -- a CSRF check failure.
45
+ * - `'token_exchange_failed'`: the authorization code could not be
46
+ * exchanged for a token set (see `TokenRequestError`).
47
+ * - `'token_verification_failed'`: the returned id_token failed
48
+ * `verifyIdToken` (see `TokenVerificationError`).
49
+ */
50
+ interface CallbackError {
51
+ code: 'provider_error' | 'missing_code' | 'missing_state' | 'missing_transaction' | 'state_mismatch' | 'token_exchange_failed' | 'token_verification_failed';
52
+ message: string;
53
+ }
54
+ interface CreateCallbackRouteConfig {
55
+ issuer: string;
56
+ clientId: string;
57
+ /** Omit for a public client authenticating via PKCE alone. */
58
+ clientSecret?: string;
59
+ redirectUri: string;
60
+ cookieSecret: SealSecret;
61
+ transactionCookieName?: string;
62
+ /** Defaults to `clientId`, correct for a standard login where this app is
63
+ * its own audience. */
64
+ audience?: string;
65
+ authorizedParties?: string[];
66
+ /**
67
+ * Called once the code has been exchanged and the id_token verified. This
68
+ * package does not decide what happens next -- session creation, cookie
69
+ * choice, redirect target are the consuming app's own concerns -- so this
70
+ * callback's return value IS the route handler's response.
71
+ */
72
+ onSuccess: (tokens: TokenSet, claims: IdTokenClaims, ctx: {
73
+ returnTo?: string;
74
+ }) => NextResponse | Promise<NextResponse>;
75
+ /** Defaults to a bare 400 JSON error body naming `error.code`. */
76
+ onError?: (error: CallbackError) => NextResponse | Promise<NextResponse>;
77
+ }
78
+ /** Builds a GET Route Handler that finishes the OIDC login: unseals the
79
+ * transaction cookie, checks `state`, exchanges the code, verifies the
80
+ * returned id_token, then hands the verified identity to
81
+ * `config.onSuccess` -- see that option's own docblock for why this
82
+ * package stops there. */
83
+ declare function createCallbackRoute(config: CreateCallbackRouteConfig): (request: NextRequest) => Promise<NextResponse>;
84
+
85
+ export { type CallbackError, type CreateCallbackRouteConfig, type CreateLoginRouteConfig, createCallbackRoute, createLoginRoute };
package/dist/next.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { NextResponse, NextRequest } from 'next/server';
2
+ import { c as TokenSet, I as IdTokenClaims } from './verify-BiGhwaIz.js';
3
+ import { SealSecret } from './sealed.js';
4
+ import '@tdacorp/identity-authz';
5
+ import 'jose';
6
+
7
+ interface CreateLoginRouteConfig {
8
+ issuer: string;
9
+ clientId: string;
10
+ redirectUri: string;
11
+ /** Space-separated OAuth scopes. Defaults to `"openid profile email
12
+ * roles"` -- `roles` is included in the default because without it the
13
+ * minted token carries no `roles` claim, and every `permits()` call
14
+ * (`@tdacorp/identity-authz`) against that token then permanently
15
+ * returns `indeterminate` with `reason: 'absent-claim'`, regardless of
16
+ * what the caller is actually authorized to do. Override this down to
17
+ * whatever scope is actually needed -- e.g. `"openid"` alone -- for a
18
+ * consuming app that genuinely does not use RBAC. */
19
+ scope?: string;
20
+ /** Encrypts the transaction cookie; see `./sealed`'s `SealSecret`. Must be
21
+ * the same value `createCallbackRoute` is configured with. */
22
+ cookieSecret: SealSecret;
23
+ transactionCookieName?: string;
24
+ /** Where to send the user after login completes. Defaults to reading a
25
+ * `returnTo` search param off the incoming request. */
26
+ getReturnTo?: (request: NextRequest) => string | undefined;
27
+ }
28
+ /** Builds a GET Route Handler that starts the OIDC login: generates PKCE and
29
+ * `state`, seals them (plus `returnTo`) into a short-lived cookie, and
30
+ * redirects to the identity server's `authorization_endpoint`. */
31
+ declare function createLoginRoute(config: CreateLoginRouteConfig): (request: NextRequest) => Promise<NextResponse>;
32
+ /**
33
+ * Why `createCallbackRoute`'s handler could not complete the login, passed
34
+ * to `config.onError` (or rendered by the default handler as a 400 JSON
35
+ * body naming `code`).
36
+ *
37
+ * - `'provider_error'`: the identity server's redirect itself carried an
38
+ * `error` param (the user denied consent, an invalid request, etc).
39
+ * - `'missing_code'` / `'missing_state'`: the callback request is missing a
40
+ * `code` or `state` query param.
41
+ * - `'missing_transaction'`: the transaction cookie `createLoginRoute` set
42
+ * was not presented, failed to decrypt, or has expired.
43
+ * - `'state_mismatch'`: the `state` param does not match the value stored
44
+ * in the transaction cookie -- a CSRF check failure.
45
+ * - `'token_exchange_failed'`: the authorization code could not be
46
+ * exchanged for a token set (see `TokenRequestError`).
47
+ * - `'token_verification_failed'`: the returned id_token failed
48
+ * `verifyIdToken` (see `TokenVerificationError`).
49
+ */
50
+ interface CallbackError {
51
+ code: 'provider_error' | 'missing_code' | 'missing_state' | 'missing_transaction' | 'state_mismatch' | 'token_exchange_failed' | 'token_verification_failed';
52
+ message: string;
53
+ }
54
+ interface CreateCallbackRouteConfig {
55
+ issuer: string;
56
+ clientId: string;
57
+ /** Omit for a public client authenticating via PKCE alone. */
58
+ clientSecret?: string;
59
+ redirectUri: string;
60
+ cookieSecret: SealSecret;
61
+ transactionCookieName?: string;
62
+ /** Defaults to `clientId`, correct for a standard login where this app is
63
+ * its own audience. */
64
+ audience?: string;
65
+ authorizedParties?: string[];
66
+ /**
67
+ * Called once the code has been exchanged and the id_token verified. This
68
+ * package does not decide what happens next -- session creation, cookie
69
+ * choice, redirect target are the consuming app's own concerns -- so this
70
+ * callback's return value IS the route handler's response.
71
+ */
72
+ onSuccess: (tokens: TokenSet, claims: IdTokenClaims, ctx: {
73
+ returnTo?: string;
74
+ }) => NextResponse | Promise<NextResponse>;
75
+ /** Defaults to a bare 400 JSON error body naming `error.code`. */
76
+ onError?: (error: CallbackError) => NextResponse | Promise<NextResponse>;
77
+ }
78
+ /** Builds a GET Route Handler that finishes the OIDC login: unseals the
79
+ * transaction cookie, checks `state`, exchanges the code, verifies the
80
+ * returned id_token, then hands the verified identity to
81
+ * `config.onSuccess` -- see that option's own docblock for why this
82
+ * package stops there. */
83
+ declare function createCallbackRoute(config: CreateCallbackRouteConfig): (request: NextRequest) => Promise<NextResponse>;
84
+
85
+ export { type CallbackError, type CreateCallbackRouteConfig, type CreateLoginRouteConfig, createCallbackRoute, createLoginRoute };
package/dist/next.js ADDED
@@ -0,0 +1,129 @@
1
+ import {
2
+ exchangeAuthorizationCode,
3
+ fetchDiscovery,
4
+ generateCodeChallenge,
5
+ generateCodeVerifier,
6
+ generateState,
7
+ verifyIdToken
8
+ } from "./chunk-VJSMXIZB.js";
9
+ import {
10
+ seal,
11
+ unseal
12
+ } from "./chunk-KLXEPP4S.js";
13
+
14
+ // src/next.ts
15
+ import { NextResponse } from "next/server";
16
+ var TRANSACTION_TTL_SECONDS = 600;
17
+ var DEFAULT_TRANSACTION_COOKIE = "identity_oauth_txn";
18
+ var TRANSACTION_PURPOSE = "identity-client:oauth-transaction";
19
+ function transactionCookieOptions(maxAge) {
20
+ return {
21
+ httpOnly: true,
22
+ secure: process.env.NODE_ENV === "production",
23
+ sameSite: "lax",
24
+ path: "/",
25
+ maxAge
26
+ };
27
+ }
28
+ function createLoginRoute(config) {
29
+ return async function loginRoute(request) {
30
+ const state = generateState();
31
+ const codeVerifier = generateCodeVerifier();
32
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
33
+ const returnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
34
+ const transaction = { state, codeVerifier, returnTo };
35
+ const sealedTransaction = await seal(transaction, {
36
+ secret: config.cookieSecret,
37
+ ttlSeconds: TRANSACTION_TTL_SECONDS,
38
+ purpose: TRANSACTION_PURPOSE
39
+ });
40
+ const discovery = await fetchDiscovery(config.issuer);
41
+ const authorizeUrl = new URL(discovery.authorization_endpoint);
42
+ authorizeUrl.searchParams.set("client_id", config.clientId);
43
+ authorizeUrl.searchParams.set("redirect_uri", config.redirectUri);
44
+ authorizeUrl.searchParams.set("response_type", "code");
45
+ authorizeUrl.searchParams.set("scope", config.scope ?? "openid profile email roles");
46
+ authorizeUrl.searchParams.set("state", state);
47
+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
48
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
49
+ const response = NextResponse.redirect(authorizeUrl);
50
+ response.cookies.set(
51
+ config.transactionCookieName ?? DEFAULT_TRANSACTION_COOKIE,
52
+ sealedTransaction,
53
+ transactionCookieOptions(TRANSACTION_TTL_SECONDS)
54
+ );
55
+ return response;
56
+ };
57
+ }
58
+ function defaultOnError(error) {
59
+ return NextResponse.json({ error: error.code, error_description: error.message }, { status: 400 });
60
+ }
61
+ function createCallbackRoute(config) {
62
+ return async function callbackRoute(request) {
63
+ const handleError = config.onError ?? defaultOnError;
64
+ const cookieName = config.transactionCookieName ?? DEFAULT_TRANSACTION_COOKIE;
65
+ const params = request.nextUrl.searchParams;
66
+ const providerError = params.get("error");
67
+ if (providerError) {
68
+ return handleError({ code: "provider_error", message: params.get("error_description") ?? providerError });
69
+ }
70
+ const code = params.get("code");
71
+ const state = params.get("state");
72
+ if (!code) return handleError({ code: "missing_code", message: "Callback request has no code parameter" });
73
+ if (!state) return handleError({ code: "missing_state", message: "Callback request has no state parameter" });
74
+ const sealedTransaction = request.cookies.get(cookieName)?.value;
75
+ if (!sealedTransaction) {
76
+ return handleError({
77
+ code: "missing_transaction",
78
+ message: `No ${cookieName} cookie was presented; it may have expired, or this is a replayed callback`
79
+ });
80
+ }
81
+ const transaction = await unseal(sealedTransaction, {
82
+ secret: config.cookieSecret,
83
+ purpose: TRANSACTION_PURPOSE
84
+ });
85
+ if (!transaction) {
86
+ return handleError({ code: "missing_transaction", message: "The transaction cookie failed to decrypt, or has expired" });
87
+ }
88
+ if (transaction.state !== state) {
89
+ return handleError({ code: "state_mismatch", message: "The state parameter does not match the value stored at login" });
90
+ }
91
+ let tokens;
92
+ try {
93
+ tokens = await exchangeAuthorizationCode({
94
+ issuer: config.issuer,
95
+ clientId: config.clientId,
96
+ clientSecret: config.clientSecret,
97
+ code,
98
+ redirectUri: config.redirectUri,
99
+ codeVerifier: transaction.codeVerifier
100
+ });
101
+ } catch (error) {
102
+ return handleError({
103
+ code: "token_exchange_failed",
104
+ message: error instanceof Error ? error.message : "Token exchange failed"
105
+ });
106
+ }
107
+ if (!tokens.idToken) {
108
+ return handleError({ code: "token_verification_failed", message: "Token response carried no id_token to verify" });
109
+ }
110
+ const verification = await verifyIdToken(tokens.idToken, {
111
+ issuer: config.issuer,
112
+ audience: config.audience ?? config.clientId,
113
+ authorizedParties: config.authorizedParties
114
+ });
115
+ if (!verification.success) {
116
+ return handleError({
117
+ code: "token_verification_failed",
118
+ message: verification.errors.map((e) => `${e.code}: ${e.message}`).join("; ")
119
+ });
120
+ }
121
+ const response = await config.onSuccess(tokens, verification.data, { returnTo: transaction.returnTo });
122
+ response.cookies.delete(cookieName);
123
+ return response;
124
+ };
125
+ }
126
+ export {
127
+ createCallbackRoute,
128
+ createLoginRoute
129
+ };
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/sealed.ts
21
+ var sealed_exports = {};
22
+ __export(sealed_exports, {
23
+ MAX_SEALED_BYTES: () => MAX_SEALED_BYTES,
24
+ seal: () => seal,
25
+ unseal: () => unseal
26
+ });
27
+ module.exports = __toCommonJS(sealed_exports);
28
+ var import_jose = require("jose");
29
+ async function deriveContentEncryptionKey(secret, purpose) {
30
+ const encoder = new TextEncoder();
31
+ const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveBits"]);
32
+ const bits = await crypto.subtle.deriveBits(
33
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(purpose) },
34
+ keyMaterial,
35
+ 256
36
+ );
37
+ return new Uint8Array(bits);
38
+ }
39
+ function resolveSealingKey(secret) {
40
+ if (typeof secret === "string") return { secretValue: secret };
41
+ const keyNumbers = Object.keys(secret).map(Number);
42
+ if (keyNumbers.length === 0) throw new Error("seal: secret key-rotation map is empty");
43
+ const highest = Math.max(...keyNumbers);
44
+ return { kid: String(highest), secretValue: secret[highest] };
45
+ }
46
+ var MAX_SEALED_BYTES = 4096;
47
+ async function seal(payload, options) {
48
+ const { kid, secretValue } = resolveSealingKey(options.secret);
49
+ const cek = await deriveContentEncryptionKey(secretValue, options.purpose);
50
+ const header = {
51
+ alg: "dir",
52
+ enc: "A256GCM",
53
+ ...kid !== void 0 ? { kid } : {}
54
+ };
55
+ const sealed = await new import_jose.EncryptJWT(payload).setProtectedHeader(header).setIssuedAt().setExpirationTime(`${options.ttlSeconds}s`).encrypt(cek);
56
+ const byteLength = new TextEncoder().encode(sealed).length;
57
+ if (byteLength > MAX_SEALED_BYTES) {
58
+ throw new Error(
59
+ `seal: sealed payload is ${byteLength} bytes, over the ${MAX_SEALED_BYTES}-byte limit (MAX_SEALED_BYTES). Shrink the payload -- do not chunk it across cookies; see MAX_SEALED_BYTES's own docblock for why.`
60
+ );
61
+ }
62
+ return sealed;
63
+ }
64
+ function resolveUnsealingKey(secret, kid) {
65
+ if (typeof secret === "string") return { secretValue: secret };
66
+ if (kid === void 0 || !(Number(kid) in secret)) return { reason: "unknown-key" };
67
+ return { secretValue: secret[Number(kid)] };
68
+ }
69
+ function classifyUnsealError(error) {
70
+ const code = error instanceof import_jose.errors.JOSEError ? error.code : void 0;
71
+ return code === "ERR_JWT_EXPIRED" ? "expired" : "invalid";
72
+ }
73
+ async function unseal(sealedValue, options) {
74
+ let kid;
75
+ try {
76
+ kid = (0, import_jose.decodeProtectedHeader)(sealedValue).kid;
77
+ } catch {
78
+ options.onError?.("invalid");
79
+ return null;
80
+ }
81
+ const resolved = resolveUnsealingKey(options.secret, kid);
82
+ if ("reason" in resolved) {
83
+ options.onError?.(resolved.reason);
84
+ return null;
85
+ }
86
+ try {
87
+ const cek = await deriveContentEncryptionKey(resolved.secretValue, options.purpose);
88
+ const { payload } = await (0, import_jose.jwtDecrypt)(sealedValue, cek);
89
+ return payload;
90
+ } catch (error) {
91
+ const reason = classifyUnsealError(error);
92
+ options.onError?.(reason);
93
+ return null;
94
+ }
95
+ }
96
+ // Annotate the CommonJS export names for ESM import in node:
97
+ 0 && (module.exports = {
98
+ MAX_SEALED_BYTES,
99
+ seal,
100
+ unseal
101
+ });
@@ -0,0 +1,95 @@
1
+ import { JWTPayload } from 'jose';
2
+
3
+ /**
4
+ * The key material `seal()`/`unseal()` derive a content-encryption key from.
5
+ *
6
+ * A plain string is the common case: one secret, used for every seal and
7
+ * unseal. A `Record<number, string>` is a rotation map -- `seal()` always
8
+ * encrypts under the highest-numbered key (the active one) and stamps that
9
+ * number into the JWE header as `kid`; `unseal()` reads `kid` back off the
10
+ * header to pick the matching key, so values sealed under a retired key
11
+ * keep decrypting while new ones move to the new key. The same `secret`
12
+ * value must be given to both `seal()` and `unseal()` for a given `purpose`.
13
+ */
14
+ type SealSecret = string | Record<number, string>;
15
+ /** Hard limit on a sealed value's own length, enforced by `seal()`.
16
+ *
17
+ * A sealed value that does not fit in one cookie must never be chunked
18
+ * across multiple `Set-Cookie` headers: the combined `Cookie` header the
19
+ * browser sends back on the NEXT request then risks exceeding common
20
+ * reverse-proxy request-header limits (nginx's default is 8KB total), which
21
+ * fails as an opaque HTTP 431 one layer removed from the actual cause -- a
22
+ * request that never reaches this application's own error handling at all.
23
+ * A hard throw here, at the point of creation, is far easier to debug than a
24
+ * 431 discovered in production weeks later. */
25
+ declare const MAX_SEALED_BYTES = 4096;
26
+ /** Options for `seal()`. */
27
+ interface SealOptions {
28
+ /** The key material to encrypt under; see `SealSecret`'s own docblock for
29
+ * the single-secret vs. rotation-map shapes. */
30
+ secret: SealSecret;
31
+ /** How long the sealed value stays valid, in seconds, before `unseal()`
32
+ * reports it as `'expired'`. */
33
+ ttlSeconds: number;
34
+ /** Mixed into key derivation (see `deriveContentEncryptionKey`) so the
35
+ * same base secret used for two different purposes derives two
36
+ * different keys. Must match the `purpose` passed to `unseal()`. */
37
+ purpose: string;
38
+ }
39
+ /**
40
+ * Encrypts `payload` into a compact JWE (`alg: "dir"`, `enc: "A256GCM"`)
41
+ * that expires after `ttlSeconds`.
42
+ *
43
+ * WHY REAL ENCRYPTION, NOT A SIGNATURE
44
+ *
45
+ * This is NOT justified by Edge-runtime compatibility -- the session
46
+ * boundary this package's `./next` subpath is designed to sit alongside
47
+ * runs the Node runtime, not Edge, so that argument does not hold here and
48
+ * this module makes no such claim.
49
+ *
50
+ * It is justified by wanting real encryption (JWE, not a signed-but-legible
51
+ * JWT) for a payload that might carry something sensitive -- an OAuth
52
+ * transaction's PKCE verifier, a `returnTo` URL, an admin session's extra
53
+ * claim -- so the cookie's contents are opaque to whoever holds the cookie,
54
+ * not just tamper-evident. This is the same caution Next.js's own general
55
+ * cookie guidance gives regardless of encryption: minimize what a cookie
56
+ * payload carries in the first place. Encryption here is defense in depth on
57
+ * top of that, not a reason to stop minimizing.
58
+ *
59
+ * See `deriveContentEncryptionKey` for why `purpose` is mixed into key
60
+ * derivation. Throws synchronously if the result would exceed
61
+ * `MAX_SEALED_BYTES` -- see that constant's docblock for why this is a
62
+ * throw, not a truncation or a chunking scheme.
63
+ *
64
+ * @param payload - The data to encrypt. Any JWT-payload-shaped object.
65
+ * @param options - See `SealOptions`.
66
+ * @returns The compact JWE string to store (e.g. as a cookie value).
67
+ */
68
+ declare function seal(payload: JWTPayload, options: SealOptions): Promise<string>;
69
+ /** Why `unseal` could not produce a payload. `onError` (on `UnsealOptions`)
70
+ * receives exactly this, so a caller can log or alarm on `'invalid'`
71
+ * (tampering, or the wrong secret) differently from routine `'expired'`. */
72
+ type UnsealFailureReason = 'expired' | 'invalid' | 'unknown-key';
73
+ /** Options for `unseal()`. */
74
+ interface UnsealOptions {
75
+ /** Must be the same value `seal()` was called with for this sealed value
76
+ * (or, for a rotation map, still contain the key it was sealed under). */
77
+ secret: SealSecret;
78
+ /** Must match the `purpose` `seal()` was called with; a mismatch fails
79
+ * decryption the same way tampering does. */
80
+ purpose: string;
81
+ /** Called with the specific reason `unseal()` returned `null`, so a
82
+ * caller can log or alarm on `'invalid'` differently from routine
83
+ * `'expired'`. */
84
+ onError?: (reason: UnsealFailureReason) => void;
85
+ }
86
+ /** Decrypts and returns a payload sealed by `seal()`, or `null` on ANY
87
+ * failure -- this never throws for an expected failure. `options.secret`
88
+ * and `options.purpose` must match what `seal()` was called with; `kid` is
89
+ * read from the sealed value's own header to pick the right key out of a
90
+ * rotation map (see `resolveUnsealingKey`). Pass `onError` to be told WHICH
91
+ * kind of failure this was, without having to treat every `null` the same
92
+ * way. */
93
+ declare function unseal<T = JWTPayload>(sealedValue: string, options: UnsealOptions): Promise<T | null>;
94
+
95
+ export { MAX_SEALED_BYTES, type SealOptions, type SealSecret, type UnsealFailureReason, type UnsealOptions, seal, unseal };
@@ -0,0 +1,95 @@
1
+ import { JWTPayload } from 'jose';
2
+
3
+ /**
4
+ * The key material `seal()`/`unseal()` derive a content-encryption key from.
5
+ *
6
+ * A plain string is the common case: one secret, used for every seal and
7
+ * unseal. A `Record<number, string>` is a rotation map -- `seal()` always
8
+ * encrypts under the highest-numbered key (the active one) and stamps that
9
+ * number into the JWE header as `kid`; `unseal()` reads `kid` back off the
10
+ * header to pick the matching key, so values sealed under a retired key
11
+ * keep decrypting while new ones move to the new key. The same `secret`
12
+ * value must be given to both `seal()` and `unseal()` for a given `purpose`.
13
+ */
14
+ type SealSecret = string | Record<number, string>;
15
+ /** Hard limit on a sealed value's own length, enforced by `seal()`.
16
+ *
17
+ * A sealed value that does not fit in one cookie must never be chunked
18
+ * across multiple `Set-Cookie` headers: the combined `Cookie` header the
19
+ * browser sends back on the NEXT request then risks exceeding common
20
+ * reverse-proxy request-header limits (nginx's default is 8KB total), which
21
+ * fails as an opaque HTTP 431 one layer removed from the actual cause -- a
22
+ * request that never reaches this application's own error handling at all.
23
+ * A hard throw here, at the point of creation, is far easier to debug than a
24
+ * 431 discovered in production weeks later. */
25
+ declare const MAX_SEALED_BYTES = 4096;
26
+ /** Options for `seal()`. */
27
+ interface SealOptions {
28
+ /** The key material to encrypt under; see `SealSecret`'s own docblock for
29
+ * the single-secret vs. rotation-map shapes. */
30
+ secret: SealSecret;
31
+ /** How long the sealed value stays valid, in seconds, before `unseal()`
32
+ * reports it as `'expired'`. */
33
+ ttlSeconds: number;
34
+ /** Mixed into key derivation (see `deriveContentEncryptionKey`) so the
35
+ * same base secret used for two different purposes derives two
36
+ * different keys. Must match the `purpose` passed to `unseal()`. */
37
+ purpose: string;
38
+ }
39
+ /**
40
+ * Encrypts `payload` into a compact JWE (`alg: "dir"`, `enc: "A256GCM"`)
41
+ * that expires after `ttlSeconds`.
42
+ *
43
+ * WHY REAL ENCRYPTION, NOT A SIGNATURE
44
+ *
45
+ * This is NOT justified by Edge-runtime compatibility -- the session
46
+ * boundary this package's `./next` subpath is designed to sit alongside
47
+ * runs the Node runtime, not Edge, so that argument does not hold here and
48
+ * this module makes no such claim.
49
+ *
50
+ * It is justified by wanting real encryption (JWE, not a signed-but-legible
51
+ * JWT) for a payload that might carry something sensitive -- an OAuth
52
+ * transaction's PKCE verifier, a `returnTo` URL, an admin session's extra
53
+ * claim -- so the cookie's contents are opaque to whoever holds the cookie,
54
+ * not just tamper-evident. This is the same caution Next.js's own general
55
+ * cookie guidance gives regardless of encryption: minimize what a cookie
56
+ * payload carries in the first place. Encryption here is defense in depth on
57
+ * top of that, not a reason to stop minimizing.
58
+ *
59
+ * See `deriveContentEncryptionKey` for why `purpose` is mixed into key
60
+ * derivation. Throws synchronously if the result would exceed
61
+ * `MAX_SEALED_BYTES` -- see that constant's docblock for why this is a
62
+ * throw, not a truncation or a chunking scheme.
63
+ *
64
+ * @param payload - The data to encrypt. Any JWT-payload-shaped object.
65
+ * @param options - See `SealOptions`.
66
+ * @returns The compact JWE string to store (e.g. as a cookie value).
67
+ */
68
+ declare function seal(payload: JWTPayload, options: SealOptions): Promise<string>;
69
+ /** Why `unseal` could not produce a payload. `onError` (on `UnsealOptions`)
70
+ * receives exactly this, so a caller can log or alarm on `'invalid'`
71
+ * (tampering, or the wrong secret) differently from routine `'expired'`. */
72
+ type UnsealFailureReason = 'expired' | 'invalid' | 'unknown-key';
73
+ /** Options for `unseal()`. */
74
+ interface UnsealOptions {
75
+ /** Must be the same value `seal()` was called with for this sealed value
76
+ * (or, for a rotation map, still contain the key it was sealed under). */
77
+ secret: SealSecret;
78
+ /** Must match the `purpose` `seal()` was called with; a mismatch fails
79
+ * decryption the same way tampering does. */
80
+ purpose: string;
81
+ /** Called with the specific reason `unseal()` returned `null`, so a
82
+ * caller can log or alarm on `'invalid'` differently from routine
83
+ * `'expired'`. */
84
+ onError?: (reason: UnsealFailureReason) => void;
85
+ }
86
+ /** Decrypts and returns a payload sealed by `seal()`, or `null` on ANY
87
+ * failure -- this never throws for an expected failure. `options.secret`
88
+ * and `options.purpose` must match what `seal()` was called with; `kid` is
89
+ * read from the sealed value's own header to pick the right key out of a
90
+ * rotation map (see `resolveUnsealingKey`). Pass `onError` to be told WHICH
91
+ * kind of failure this was, without having to treat every `null` the same
92
+ * way. */
93
+ declare function unseal<T = JWTPayload>(sealedValue: string, options: UnsealOptions): Promise<T | null>;
94
+
95
+ export { MAX_SEALED_BYTES, type SealOptions, type SealSecret, type UnsealFailureReason, type UnsealOptions, seal, unseal };
package/dist/sealed.js ADDED
@@ -0,0 +1,10 @@
1
+ import {
2
+ MAX_SEALED_BYTES,
3
+ seal,
4
+ unseal
5
+ } from "./chunk-KLXEPP4S.js";
6
+ export {
7
+ MAX_SEALED_BYTES,
8
+ seal,
9
+ unseal
10
+ };