@blamejs/core 0.8.60 → 0.8.66

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/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
- if (typeof payload.exp !== "number" || payload.exp + skewSec < now) {
748
- throw new OAuthError("auth-oauth/expired", "ID token expired (exp=" + payload.exp + ", now=" + now + ")");
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: authorizationUrl,
875
- exchangeCode: exchangeCode,
876
- refreshAccessToken: refreshAccessToken,
877
- fetchUserInfo: fetchUserInfo,
878
- revokeToken: revokeToken,
879
- verifyIdToken: verifyIdToken,
880
- discover: _discover,
881
- endSessionUrl: endSessionUrl,
882
- pushAuthorizationRequest: 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,