@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.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/chunk-KLXEPP4S.js +80 -0
- package/dist/chunk-VJSMXIZB.js +320 -0
- package/dist/index.cjs +357 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +28 -0
- package/dist/next.cjs +461 -0
- package/dist/next.d.cts +85 -0
- package/dist/next.d.ts +85 -0
- package/dist/next.js +129 -0
- package/dist/sealed.cjs +101 -0
- package/dist/sealed.d.cts +95 -0
- package/dist/sealed.d.ts +95 -0
- package/dist/sealed.js +10 -0
- package/dist/verify-BiGhwaIz.d.cts +306 -0
- package/dist/verify-BiGhwaIz.d.ts +306 -0
- package/package.json +80 -0
package/dist/next.d.cts
ADDED
|
@@ -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
|
+
};
|
package/dist/sealed.cjs
ADDED
|
@@ -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 };
|
package/dist/sealed.d.ts
ADDED
|
@@ -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 };
|