@blamejs/core 0.8.59 → 0.8.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +530 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +307 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/auth/oauth.js
CHANGED
|
@@ -371,9 +371,13 @@ function create(opts) {
|
|
|
371
371
|
revocationEndpoint: opts.revocationEndpoint || (preset && preset.revocationEndpoint) || null,
|
|
372
372
|
jwksUri: opts.jwksUri || (preset && preset.jwksUri) || null,
|
|
373
373
|
endSessionEndpoint: opts.endSessionEndpoint || (preset && preset.endSessionEndpoint) || null,
|
|
374
|
+
checkSessionIframe: opts.checkSessionIframe || (preset && preset.checkSessionIframe) || null,
|
|
374
375
|
pushedAuthorizationRequestEndpoint:
|
|
375
376
|
opts.pushedAuthorizationRequestEndpoint ||
|
|
376
377
|
(preset && preset.pushedAuthorizationRequestEndpoint) || null,
|
|
378
|
+
backchannelAuthenticationEndpoint:
|
|
379
|
+
opts.backchannelAuthenticationEndpoint ||
|
|
380
|
+
(preset && preset.backchannelAuthenticationEndpoint) || null,
|
|
377
381
|
};
|
|
378
382
|
|
|
379
383
|
// Discovery + JWKS caches use b.cache.create + .wrap so concurrent
|
|
@@ -448,7 +452,9 @@ function create(opts) {
|
|
|
448
452
|
revocationEndpoint: "revocation_endpoint",
|
|
449
453
|
jwksUri: "jwks_uri",
|
|
450
454
|
endSessionEndpoint: "end_session_endpoint",
|
|
455
|
+
checkSessionIframe: "check_session_iframe",
|
|
451
456
|
pushedAuthorizationRequestEndpoint: "pushed_authorization_request_endpoint",
|
|
457
|
+
backchannelAuthenticationEndpoint: "backchannel_authentication_endpoint",
|
|
452
458
|
})[name];
|
|
453
459
|
var endpoint = config[snake];
|
|
454
460
|
if (!endpoint) {
|
|
@@ -744,8 +750,14 @@ function create(opts) {
|
|
|
744
750
|
// Claim validation.
|
|
745
751
|
var now = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
746
752
|
var skewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
|
|
747
|
-
|
|
748
|
-
|
|
753
|
+
// OIDC Back-Channel Logout 1.0 §2.4 — logout tokens have no `exp`
|
|
754
|
+
// claim; freshness comes from `iat` + jti-replay window. Operators
|
|
755
|
+
// verifying logout tokens pass `skipExpCheck: true`. ID tokens
|
|
756
|
+
// never set this and continue to require `exp`.
|
|
757
|
+
if (!vopts.skipExpCheck) {
|
|
758
|
+
if (typeof payload.exp !== "number" || payload.exp + skewSec < now) {
|
|
759
|
+
throw new OAuthError("auth-oauth/expired", "ID token expired (exp=" + payload.exp + ", now=" + now + ")");
|
|
760
|
+
}
|
|
749
761
|
}
|
|
750
762
|
if (typeof payload.iat === "number" && payload.iat - skewSec > now) {
|
|
751
763
|
throw new OAuthError("auth-oauth/iat-future", "ID token iat is in the future");
|
|
@@ -870,16 +882,192 @@ function create(opts) {
|
|
|
870
882
|
};
|
|
871
883
|
}
|
|
872
884
|
|
|
885
|
+
// ---- OIDC Front-Channel Logout 1.0 ----
|
|
886
|
+
//
|
|
887
|
+
// The IdP renders an iframe pointing at the RP's
|
|
888
|
+
// frontchannel_logout_uri with `iss` + `sid` query params; the RP's
|
|
889
|
+
// iframe-served endpoint clears the local session for that sid and
|
|
890
|
+
// returns a no-content / blank page. Operators stand up a single
|
|
891
|
+
// /oidc/frontchannel-logout route, parse the request, and call
|
|
892
|
+
// `parseFrontchannelLogoutRequest(req)` to extract the validated
|
|
893
|
+
// (iss, sid) tuple to feed their session-store deletion.
|
|
894
|
+
//
|
|
895
|
+
// The IdP advertises support via `frontchannel_logout_supported`
|
|
896
|
+
// and `frontchannel_logout_session_required` in discovery; the RP
|
|
897
|
+
// registers `frontchannel_logout_uri` + `frontchannel_logout_session_required`
|
|
898
|
+
// at client-registration time. We don't auto-register here — the
|
|
899
|
+
// RP's registration step is operator-side; this surface only
|
|
900
|
+
// handles the runtime parse.
|
|
901
|
+
function parseFrontchannelLogoutRequest(req) {
|
|
902
|
+
if (!req || !req.url) {
|
|
903
|
+
throw new OAuthError("auth-oauth/bad-frontchannel-logout-req",
|
|
904
|
+
"parseFrontchannelLogoutRequest: req with url required");
|
|
905
|
+
}
|
|
906
|
+
var u;
|
|
907
|
+
try { u = new URL(req.url, "http://placeholder.invalid"); } // allow:raw-new-url — req.url is the framework-normalized path; placeholder base provides a synthetic origin for relative-path parse
|
|
908
|
+
catch (_e) {
|
|
909
|
+
throw new OAuthError("auth-oauth/bad-frontchannel-logout-url",
|
|
910
|
+
"parseFrontchannelLogoutRequest: malformed request URL");
|
|
911
|
+
}
|
|
912
|
+
var iss = u.searchParams.get("iss");
|
|
913
|
+
var sid = u.searchParams.get("sid");
|
|
914
|
+
// RFC 0 invariant: `iss` MUST match the configured issuer when
|
|
915
|
+
// present (defends against an attacker-controlled IdP forging a
|
|
916
|
+
// logout for a session at a different IdP). `sid` is required
|
|
917
|
+
// when the RP registered with frontchannel_logout_session_required=true;
|
|
918
|
+
// we surface it either way and let the operator decide.
|
|
919
|
+
if (iss && iss !== issuer) {
|
|
920
|
+
throw new OAuthError("auth-oauth/frontchannel-logout-iss-mismatch",
|
|
921
|
+
"parseFrontchannelLogoutRequest: iss \"" + iss +
|
|
922
|
+
"\" does not match configured issuer \"" + issuer + "\"");
|
|
923
|
+
}
|
|
924
|
+
return { iss: iss || issuer, sid: sid || null };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ---- OIDC Back-Channel Logout 1.0 ----
|
|
928
|
+
//
|
|
929
|
+
// The IdP POSTs an `application/x-www-form-urlencoded` body with
|
|
930
|
+
// `logout_token=<jwt>` to the RP's backchannel_logout_uri. The
|
|
931
|
+
// logout token is a JWT with:
|
|
932
|
+
// header.typ = "logout+jwt"
|
|
933
|
+
// payload.iss = the IdP issuer
|
|
934
|
+
// payload.aud = the RP's client_id
|
|
935
|
+
// payload.iat = recent timestamp
|
|
936
|
+
// payload.jti = unique id (replay-cache key)
|
|
937
|
+
// payload.events = { "http://schemas.openid.net/event/backchannel-logout": {} }
|
|
938
|
+
// payload.sub OR payload.sid (one of)
|
|
939
|
+
// MUST NOT contain `nonce`
|
|
940
|
+
//
|
|
941
|
+
// The RP verifies the JWS using the IdP's JWKS, validates each
|
|
942
|
+
// claim, and destroys every session for the matching sub or sid.
|
|
943
|
+
//
|
|
944
|
+
// Replay defense: operators provide a `seen({jti, iat}) -> Promise<bool>`
|
|
945
|
+
// callback that returns true the FIRST time it sees a (jti, iss)
|
|
946
|
+
// pair within the operator's chosen window (typical: 5 minutes).
|
|
947
|
+
// Subsequent calls with the same (jti, iss) return false and the
|
|
948
|
+
// RP rejects the duplicate. The framework does not maintain the
|
|
949
|
+
// store — operators wire b.cache or b.db.
|
|
950
|
+
async function verifyBackchannelLogoutToken(logoutToken, vopts) {
|
|
951
|
+
vopts = vopts || {};
|
|
952
|
+
if (typeof logoutToken !== "string" || logoutToken.length === 0) {
|
|
953
|
+
throw new OAuthError("auth-oauth/bad-logout-token",
|
|
954
|
+
"verifyBackchannelLogoutToken: logoutToken must be a non-empty string");
|
|
955
|
+
}
|
|
956
|
+
var parts = logoutToken.split(".");
|
|
957
|
+
if (parts.length !== 3) {
|
|
958
|
+
throw new OAuthError("auth-oauth/malformed-logout-token",
|
|
959
|
+
"verifyBackchannelLogoutToken: logout_token must be a 3-segment JWS");
|
|
960
|
+
}
|
|
961
|
+
var headerObj;
|
|
962
|
+
try { headerObj = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")); } // allow:bare-json-parse — pre-verify header parse to look up the typ; the JWS signature is verified by verifyIdToken below
|
|
963
|
+
catch (_e) {
|
|
964
|
+
throw new OAuthError("auth-oauth/bad-logout-header",
|
|
965
|
+
"verifyBackchannelLogoutToken: malformed header");
|
|
966
|
+
}
|
|
967
|
+
if (headerObj.typ !== "logout+jwt") {
|
|
968
|
+
throw new OAuthError("auth-oauth/wrong-typ",
|
|
969
|
+
"verifyBackchannelLogoutToken: header.typ must be \"logout+jwt\" (got \"" +
|
|
970
|
+
headerObj.typ + "\")");
|
|
971
|
+
}
|
|
972
|
+
// Reuse verifyIdToken's signature-verification path. It looks up
|
|
973
|
+
// the IdP JWKS and checks the JWS — same trust anchor.
|
|
974
|
+
var verified = await verifyIdToken(logoutToken, {
|
|
975
|
+
issuer: issuer,
|
|
976
|
+
clientId: clientId,
|
|
977
|
+
acceptedAlgs: vopts.acceptedAlgs,
|
|
978
|
+
jwksUri: vopts.jwksUri,
|
|
979
|
+
maxClockSkewMs: vopts.maxClockSkewMs,
|
|
980
|
+
// Logout tokens have no nonce — disable the nonce check that
|
|
981
|
+
// verifyIdToken would otherwise enforce on id_tokens.
|
|
982
|
+
skipNonceCheck: true,
|
|
983
|
+
// Logout tokens have no exp claim per OIDC Back-Channel Logout
|
|
984
|
+
// §2.4 — the freshness gate is iat + jti-replay window.
|
|
985
|
+
skipExpCheck: true,
|
|
986
|
+
});
|
|
987
|
+
var claims = verified.claims;
|
|
988
|
+
|
|
989
|
+
// §2.6 — events claim presence + correct shape
|
|
990
|
+
if (!claims.events || typeof claims.events !== "object" ||
|
|
991
|
+
!claims.events["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
992
|
+
throw new OAuthError("auth-oauth/missing-logout-event",
|
|
993
|
+
"verifyBackchannelLogoutToken: payload.events missing http://schemas.openid.net/event/backchannel-logout");
|
|
994
|
+
}
|
|
995
|
+
// §2.6 — nonce MUST NOT be present (nonce is for ID tokens only)
|
|
996
|
+
if (Object.prototype.hasOwnProperty.call(claims, "nonce")) {
|
|
997
|
+
throw new OAuthError("auth-oauth/forbidden-nonce",
|
|
998
|
+
"verifyBackchannelLogoutToken: payload.nonce is forbidden in logout tokens (§2.6)");
|
|
999
|
+
}
|
|
1000
|
+
// §2.4 — sub OR sid REQUIRED (at least one)
|
|
1001
|
+
if (!claims.sub && !claims.sid) {
|
|
1002
|
+
throw new OAuthError("auth-oauth/no-sub-or-sid",
|
|
1003
|
+
"verifyBackchannelLogoutToken: payload must include sub or sid");
|
|
1004
|
+
}
|
|
1005
|
+
// Replay defense — operator-supplied jti store
|
|
1006
|
+
if (typeof vopts.seen === "function") {
|
|
1007
|
+
if (typeof claims.jti !== "string" || claims.jti.length === 0) {
|
|
1008
|
+
throw new OAuthError("auth-oauth/no-jti",
|
|
1009
|
+
"verifyBackchannelLogoutToken: jti required when a seen() callback is configured");
|
|
1010
|
+
}
|
|
1011
|
+
var first;
|
|
1012
|
+
try { first = await vopts.seen({ jti: claims.jti, iss: claims.iss, iat: claims.iat }); }
|
|
1013
|
+
catch (e) {
|
|
1014
|
+
throw new OAuthError("auth-oauth/seen-callback-failed",
|
|
1015
|
+
"verifyBackchannelLogoutToken: seen() callback threw: " + ((e && e.message) || String(e)));
|
|
1016
|
+
}
|
|
1017
|
+
if (first === false) {
|
|
1018
|
+
throw new OAuthError("auth-oauth/logout-token-replay",
|
|
1019
|
+
"verifyBackchannelLogoutToken: jti already seen — replay refused");
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
iss: claims.iss,
|
|
1024
|
+
aud: claims.aud,
|
|
1025
|
+
sub: claims.sub || null,
|
|
1026
|
+
sid: claims.sid || null,
|
|
1027
|
+
jti: claims.jti || null,
|
|
1028
|
+
iat: claims.iat || null,
|
|
1029
|
+
events: claims.events,
|
|
1030
|
+
claims: claims,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ---- OIDC Session Management 1.0 — check_session_iframe ----
|
|
1035
|
+
//
|
|
1036
|
+
// The IdP advertises a `check_session_iframe` URL in discovery.
|
|
1037
|
+
// The RP loads it inside an iframe and posts `<client_id>
|
|
1038
|
+
// <session_state>` messages to it; the iframe responds with
|
|
1039
|
+
// "changed" / "unchanged" / "error" so the RP can periodically
|
|
1040
|
+
// poll without a full network round-trip.
|
|
1041
|
+
//
|
|
1042
|
+
// This builder returns the iframe URL plus a small client-side
|
|
1043
|
+
// helper string operators embed in their HTML to drive the
|
|
1044
|
+
// postMessage handshake. The framework does not host the iframe —
|
|
1045
|
+
// the IdP does. Operators that want CSP-compliant inline scripts
|
|
1046
|
+
// emit the helper through the framework's nonce middleware.
|
|
1047
|
+
async function checkSessionIframeUrl() {
|
|
1048
|
+
var url;
|
|
1049
|
+
try { url = await _resolveEndpoint("checkSessionIframe"); }
|
|
1050
|
+
catch (_e) {
|
|
1051
|
+
throw new OAuthError("auth-oauth/no-check-session-iframe",
|
|
1052
|
+
"checkSessionIframeUrl: IdP discovery doc has no check_session_iframe " +
|
|
1053
|
+
"(set opts.checkSessionIframe on create() if the IdP doesn't publish it)");
|
|
1054
|
+
}
|
|
1055
|
+
return url;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
873
1058
|
return {
|
|
874
|
-
authorizationUrl:
|
|
875
|
-
exchangeCode:
|
|
876
|
-
refreshAccessToken:
|
|
877
|
-
fetchUserInfo:
|
|
878
|
-
revokeToken:
|
|
879
|
-
verifyIdToken:
|
|
880
|
-
discover:
|
|
881
|
-
endSessionUrl:
|
|
882
|
-
pushAuthorizationRequest:
|
|
1059
|
+
authorizationUrl: authorizationUrl,
|
|
1060
|
+
exchangeCode: exchangeCode,
|
|
1061
|
+
refreshAccessToken: refreshAccessToken,
|
|
1062
|
+
fetchUserInfo: fetchUserInfo,
|
|
1063
|
+
revokeToken: revokeToken,
|
|
1064
|
+
verifyIdToken: verifyIdToken,
|
|
1065
|
+
discover: _discover,
|
|
1066
|
+
endSessionUrl: endSessionUrl,
|
|
1067
|
+
pushAuthorizationRequest: pushAuthorizationRequest,
|
|
1068
|
+
parseFrontchannelLogoutRequest: parseFrontchannelLogoutRequest,
|
|
1069
|
+
verifyBackchannelLogoutToken: verifyBackchannelLogoutToken,
|
|
1070
|
+
checkSessionIframeUrl: checkSessionIframeUrl,
|
|
883
1071
|
// Diagnostic / power-user surface
|
|
884
1072
|
issuer: issuer,
|
|
885
1073
|
clientId: clientId,
|