@payez/next-mvp 4.4.8 → 4.5.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/README.md +53 -0
- package/dist/api-handlers/account/reset-password.js +3 -1
- package/dist/api-handlers/onboarding/send-email-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/send-email-code.js +11 -0
- package/dist/api-handlers/onboarding/set-password.d.ts +12 -0
- package/dist/api-handlers/onboarding/set-password.js +15 -0
- package/dist/api-handlers/onboarding/verify-code.d.ts +11 -0
- package/dist/api-handlers/onboarding/verify-code.js +14 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.d.ts +10 -0
- package/dist/api-handlers/onboarding/verify-welcome-email.js +11 -0
- package/dist/api-handlers/session/viability.js +32 -0
- package/dist/auth/password-reset-bridge.d.ts +70 -0
- package/dist/auth/password-reset-bridge.js +99 -0
- package/dist/auth/route-config.js +17 -0
- package/dist/components/auth/FederatedAuthSection.js +50 -21
- package/dist/components/auth/PasswordResetBridge.d.ts +17 -0
- package/dist/components/auth/PasswordResetBridge.js +32 -0
- package/dist/hooks/useAvailableProviders.js +39 -17
- package/dist/lib/onboarding-proxy.d.ts +30 -0
- package/dist/lib/onboarding-proxy.js +62 -0
- package/dist/lib/safe-error-response.d.ts +8 -0
- package/dist/lib/safe-error-response.js +27 -0
- package/dist/middleware/create-middleware.d.ts +41 -0
- package/dist/middleware/create-middleware.js +74 -2
- package/dist/pages/login/page.js +1 -1
- package/dist/pages/onboarding/index.d.ts +11 -0
- package/dist/pages/onboarding/index.js +20 -0
- package/dist/pages/onboarding/page.d.ts +1 -0
- package/dist/pages/onboarding/page.js +118 -0
- package/dist/pages/recovery/page.js +20 -7
- package/dist/routes/auth/viability.d.ts +5 -0
- package/dist/routes/auth/viability.js +53 -0
- package/dist/routes/onboarding/index.d.ts +15 -0
- package/dist/routes/onboarding/index.js +22 -0
- package/dist/routes/onboarding/send-email-code.d.ts +2 -0
- package/dist/routes/onboarding/send-email-code.js +6 -0
- package/dist/routes/onboarding/set-password.d.ts +2 -0
- package/dist/routes/onboarding/set-password.js +6 -0
- package/dist/routes/onboarding/verify-code.d.ts +2 -0
- package/dist/routes/onboarding/verify-code.js +6 -0
- package/dist/routes/onboarding/verify-welcome-email.d.ts +2 -0
- package/dist/routes/onboarding/verify-welcome-email.js +6 -0
- package/dist/utils/api.d.ts +6 -0
- package/dist/utils/api.js +10 -2
- package/dist/vibe/sessions.d.ts +88 -1
- package/dist/vibe/sessions.js +260 -19
- package/package.json +58 -2
- package/scripts/check-publish-eol.mjs +135 -0
- package/src/api-handlers/account/reset-password.ts +4 -2
- package/src/api-handlers/onboarding/send-email-code.ts +18 -0
- package/src/api-handlers/onboarding/set-password.ts +21 -0
- package/src/api-handlers/onboarding/verify-code.ts +20 -0
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -0
- package/src/api-handlers/session/viability.ts +31 -0
- package/src/auth/password-reset-bridge.ts +98 -0
- package/src/auth/route-config.ts +17 -0
- package/src/components/auth/FederatedAuthSection.tsx +56 -22
- package/src/components/auth/PasswordResetBridge.tsx +30 -0
- package/src/hooks/useAvailableProviders.ts +41 -18
- package/src/lib/onboarding-proxy.ts +57 -0
- package/src/lib/safe-error-response.ts +23 -0
- package/src/middleware/create-middleware.ts +106 -2
- package/src/pages/login/page.tsx +4 -7
- package/src/pages/onboarding/index.ts +11 -0
- package/src/pages/onboarding/page.tsx +212 -0
- package/src/pages/recovery/page.tsx +24 -8
- package/src/routes/auth/viability.ts +52 -0
- package/src/routes/onboarding/index.ts +15 -0
- package/src/routes/onboarding/send-email-code.ts +2 -0
- package/src/routes/onboarding/set-password.ts +2 -0
- package/src/routes/onboarding/verify-code.ts +2 -0
- package/src/routes/onboarding/verify-welcome-email.ts +2 -0
- package/src/utils/api.ts +10 -1
- package/src/vibe/document-unwrap.ts +103 -103
- package/src/vibe/sessions.ts +291 -24
|
@@ -10,6 +10,7 @@ import { getSession } from '../../server/auth';
|
|
|
10
10
|
import { isInitializationFailed, ensureInitialized } from '../../lib/startup-init';
|
|
11
11
|
import { getIDPClientConfig } from '../../lib/idp-client-config';
|
|
12
12
|
import { tokenLogId } from '../../lib/token-log-id';
|
|
13
|
+
import { getRevocationStatus, warnNonNumericUserId, resolveNumericIdpUserId, type RevocationStatus } from '../../vibe/sessions';
|
|
13
14
|
|
|
14
15
|
export async function GET(req: NextRequest) {
|
|
15
16
|
try {
|
|
@@ -140,12 +141,42 @@ export async function GET(req: NextRequest) {
|
|
|
140
141
|
});
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
// --- Session revocation status (PAY-1882 step 4 producer half) ---
|
|
145
|
+
// The four-state status (BAPert 66320). The middleware fails CLOSED by
|
|
146
|
+
// default on 'unknown' (a failed Vibe read) and always on 'revoked';
|
|
147
|
+
// 'active' and 'not_configured' stay viable. A consumer that does not
|
|
148
|
+
// read revocationStatus is unaffected - absent is not revoked.
|
|
149
|
+
let revocationStatus: RevocationStatus = 'not_configured';
|
|
150
|
+
// BAPert 66480 condition 2 / ruling (ii) 66700: the numeric IdP id comes from
|
|
151
|
+
// the token's own `sub` claim FIRST (session.userId can be an email).
|
|
152
|
+
const revocationUserId = resolveNumericIdpUserId(sessionData, (sessionData as any).idpAccessToken);
|
|
153
|
+
if (revocationUserId > 0) {
|
|
154
|
+
try {
|
|
155
|
+
revocationStatus = await getRevocationStatus(revocationUserId, sessionToken);
|
|
156
|
+
} catch {
|
|
157
|
+
// getRevocationStatus does not throw by contract; fail closed if it ever does.
|
|
158
|
+
revocationStatus = 'unknown';
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// N1 (NightHawk 66392): a session EXISTS but its userId is not a usable
|
|
162
|
+
// numeric IdP id, so the check could not be run. That is 'unknown'
|
|
163
|
+
// (could not determine), NOT 'not_configured' (no revocation source) -
|
|
164
|
+
// the two must not be conflated.
|
|
165
|
+
// BAPert 66480 condition 1: emit ONE WARN (shape only) per session.
|
|
166
|
+
revocationStatus = warnNonNumericUserId(sessionToken, sessionData.userId);
|
|
167
|
+
}
|
|
168
|
+
|
|
143
169
|
const response = {
|
|
144
170
|
authenticated: true,
|
|
145
171
|
sessionTokenId: tokenLogId(sessionToken), // PAY-1481: correlation id for middleware tracking, never the token
|
|
146
172
|
// 2FA fields - critical for middleware redirect logic
|
|
147
173
|
requires2FA, // From cached client config (client-wide setting)
|
|
148
174
|
twoFactorComplete: effectiveTwoFactorComplete, // From session, BUT respects MFA TTL
|
|
175
|
+
// Session revocation (PAY-1882 step 4): the four-state status, plus the
|
|
176
|
+
// boolean the middleware's existing force-invalid branch already reads.
|
|
177
|
+
revocationStatus,
|
|
178
|
+
forceInvalid: revocationStatus === 'revoked' || revocationStatus === 'unknown',
|
|
179
|
+
revoked: revocationStatus === 'revoked',
|
|
149
180
|
// Cards 141688/141741 — the IDP's routing decision, relayed verbatim.
|
|
150
181
|
//
|
|
151
182
|
// THIS IS THE SECOND IMPLEMENTATION OF VIABILITY. The other is
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2121: the fragment bridge on `/`.
|
|
3
|
+
*
|
|
4
|
+
* The IdP's post-verify redirect for a passwordless account lands on
|
|
5
|
+
* ORIGIN/#password_reset_required=true&reset_token=<token>&email=<addr>
|
|
6
|
+
* - a FRAGMENT, never a query (ProgressiveAuthController.cs:1204-1222). A fresh
|
|
7
|
+
* @payez/next-mvp app has no fragment handling at all, so the user lands on `/`
|
|
8
|
+
* and nothing happens; their reset is lost.
|
|
9
|
+
*
|
|
10
|
+
* This is the PURE half (data in, data out) so a test can execute it without a
|
|
11
|
+
* DOM. The client component (PasswordResetBridge) calls it on mount with
|
|
12
|
+
* `window.location.hash`. The kit's reset surface is the existing
|
|
13
|
+
* `/account-auth/recovery` page, which reads `?email=` and a reset token - so the
|
|
14
|
+
* bridge rewrites the fragment into that surface's OWN query shape and navigates
|
|
15
|
+
* once. It invents no new reset flow.
|
|
16
|
+
*
|
|
17
|
+
* CONTRACT:
|
|
18
|
+
* '#password_reset_required=true&reset_token=T&email=a%40b.co' -> '/account-auth/recovery?token=T&email=a%40b.co'
|
|
19
|
+
* any other fragment (no flag, flag != true, no token) -> null (leave `/` alone)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The reset surface the bridge hands off to, by its existing path. */
|
|
23
|
+
export const RECOVERY_PATH = '/account-auth/recovery';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PAY-2121 (BAPert 70576): the recovery page's ENTRY decision, as a pure function.
|
|
27
|
+
*
|
|
28
|
+
* The bridge now sends a VERIFIED passwordless user to
|
|
29
|
+
* /account-auth/recovery?token=<reset_token>&email=<addr>
|
|
30
|
+
* with a REAL reset token in hand. If the page then showed its request-a-code
|
|
31
|
+
* step, it would throw that token away and the flow would still dead-end - just
|
|
32
|
+
* one page later. So when a token is present the page must start at the
|
|
33
|
+
* set-password step and submit straight through resetPasswordWithToken.
|
|
34
|
+
*
|
|
35
|
+
* token present -> { step: 'set-password', resetToken: <token> }
|
|
36
|
+
* no token -> { step: 'initiate', resetToken: null } (today's behaviour)
|
|
37
|
+
*/
|
|
38
|
+
export function recoveryEntry(
|
|
39
|
+
token: string | null | undefined,
|
|
40
|
+
email: string | null | undefined,
|
|
41
|
+
): { step: 'set-password' | 'initiate'; resetToken: string | null; email: string } {
|
|
42
|
+
const tok = (token ?? '').trim();
|
|
43
|
+
return {
|
|
44
|
+
step: tok ? 'set-password' : 'initiate',
|
|
45
|
+
resetToken: tok || null,
|
|
46
|
+
email: (email ?? '').trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* PAY-2121 (BAPert 70582): the set-password SUBMIT precondition, as a pure
|
|
52
|
+
* function. The old guard was `if (!passwordResetToken || !recoverySession) return;`.
|
|
53
|
+
* Removing the session half (so a deep-linked reset can submit) must NOT let a
|
|
54
|
+
* submit with NO token reach the API carrying an empty token - which is exactly
|
|
55
|
+
* what a guard-less submit would do. This decides it, in one testable place:
|
|
56
|
+
*
|
|
57
|
+
* resetSubmitTarget(resetToken, sessionEmail, queryEmail):
|
|
58
|
+
* - no token (after trim) -> null (REFUSE: never call the API with an empty token)
|
|
59
|
+
* - token, session email present -> { token, email: sessionEmail }
|
|
60
|
+
* - token, only a query email -> { token, email: queryEmail } (the deep-link case)
|
|
61
|
+
* - token, but NO email anywhere -> null (REFUSE: nothing to reset)
|
|
62
|
+
*
|
|
63
|
+
* Returns the exact { token, email } the caller must pass to
|
|
64
|
+
* accountApi.resetPasswordWithToken, or null to refuse BEFORE any request.
|
|
65
|
+
*/
|
|
66
|
+
export function resetSubmitTarget(
|
|
67
|
+
resetToken: string | null | undefined,
|
|
68
|
+
sessionEmail: string | null | undefined,
|
|
69
|
+
queryEmail: string | null | undefined,
|
|
70
|
+
): { token: string; email: string } | null {
|
|
71
|
+
const tok = (resetToken ?? '').trim();
|
|
72
|
+
if (!tok) return null;
|
|
73
|
+
const email = ((sessionEmail ?? '').trim() || (queryEmail ?? '').trim());
|
|
74
|
+
if (!email) return null;
|
|
75
|
+
return { token: tok, email };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The redirect the fragment asks for, or null when the fragment is not a reset
|
|
80
|
+
* request. `hash` is `window.location.hash` (leading '#' optional).
|
|
81
|
+
*
|
|
82
|
+
* Returns null - leaves `/` untouched - when:
|
|
83
|
+
* - the fragment is empty;
|
|
84
|
+
* - `password_reset_required` is absent or not exactly 'true';
|
|
85
|
+
* - no `reset_token` is present (nothing to reset with; not a real link).
|
|
86
|
+
*/
|
|
87
|
+
export function passwordResetRedirect(hash: string | null | undefined): string | null {
|
|
88
|
+
const raw = (hash ?? '').replace(/^#/, '');
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
const params = new URLSearchParams(raw);
|
|
91
|
+
if (params.get('password_reset_required') !== 'true') return null;
|
|
92
|
+
const token = params.get('reset_token') ?? '';
|
|
93
|
+
if (!token) return null;
|
|
94
|
+
const email = params.get('email') ?? '';
|
|
95
|
+
const qs = new URLSearchParams({ token });
|
|
96
|
+
if (email) qs.set('email', email);
|
|
97
|
+
return `${RECOVERY_PATH}?${qs.toString()}`;
|
|
98
|
+
}
|
package/src/auth/route-config.ts
CHANGED
|
@@ -63,6 +63,23 @@ const defaultUnauthenticatedRoutes: UnauthenticatedRouteConfig[] = [
|
|
|
63
63
|
allowDuringCircuitBreakerOpen: true,
|
|
64
64
|
requiresRateLimit: false
|
|
65
65
|
},
|
|
66
|
+
// PAY-2121: the IdP emails an invite link to /account-auth/onboarding and a
|
|
67
|
+
// post-verify reset FRAGMENT on '/'. Both must be reachable unauthenticated -
|
|
68
|
+
// the invitee has no session (the onboard_token IS the credential), and the
|
|
69
|
+
// fragment lands on the already-public root. Listed explicitly, not left to
|
|
70
|
+
// the /account-auth/* wildcard, so the two conventional paths are visible here.
|
|
71
|
+
{
|
|
72
|
+
pattern: '/account-auth/onboarding',
|
|
73
|
+
description: 'IdP invite/onboarding landing page (token-driven, no session)',
|
|
74
|
+
allowDuringCircuitBreakerOpen: true,
|
|
75
|
+
requiresRateLimit: false
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
pattern: '/api/onboarding/*',
|
|
79
|
+
description: 'Onboarding API (token-driven, no session)',
|
|
80
|
+
allowDuringCircuitBreakerOpen: true,
|
|
81
|
+
requiresRateLimit: true
|
|
82
|
+
},
|
|
66
83
|
{
|
|
67
84
|
pattern: '/api/auth/*',
|
|
68
85
|
description: 'Authentication API endpoints',
|
|
@@ -9,12 +9,44 @@ interface Props {
|
|
|
9
9
|
isLoading?: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* PAY-2104 — PER-PROVIDER BRAND TREATMENT, NOT ONE GENERIC BUTTON.
|
|
14
|
+
*
|
|
15
|
+
* Apple and Facebook both publish binding rules for their sign-in buttons and
|
|
16
|
+
* neither is satisfied by the generic "Continue with X" control this section
|
|
17
|
+
* used to render for every provider:
|
|
18
|
+
* - APPLE (App Store Review Guidelines 4.8 / "Sign in with Apple" HIG): the
|
|
19
|
+
* button MUST carry the Apple logo and one of Apple's own wordings. The
|
|
20
|
+
* approved primary wording is "Sign in with Apple" ("Continue with Apple"
|
|
21
|
+
* is the permitted alternative). "Continue with Apple" with no logo, which
|
|
22
|
+
* is what the generic path produced, is non-compliant and is what App Store
|
|
23
|
+
* review rejects. Black or white only.
|
|
24
|
+
* - FACEBOOK (Meta Platform Terms / brand guidelines): "Continue with
|
|
25
|
+
* Facebook", the Facebook "f" mark, and the brand blue (#1877F2) or white
|
|
26
|
+
* are the sanctioned treatments.
|
|
27
|
+
* Google/Microsoft/GitHub keep the neutral control — they impose no such rule,
|
|
28
|
+
* and inventing brand colours for them would be a change nobody asked for.
|
|
29
|
+
*
|
|
30
|
+
* The mark inherits `currentColor` so it is legible on both the white neutral
|
|
31
|
+
* buttons and the black/blue branded ones without a second SVG per provider.
|
|
32
|
+
*/
|
|
33
|
+
const PROVIDER_BRAND: Record<
|
|
34
|
+
FederatedProvider,
|
|
35
|
+
{ label: string; background: string; color: string; border: string }
|
|
36
|
+
> = {
|
|
37
|
+
google: { label: 'Continue with Google', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
|
|
38
|
+
microsoft: { label: 'Continue with Microsoft', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
|
|
39
|
+
github: { label: 'Continue with GitHub', background: 'var(--bg-default, #ffffff)', color: 'var(--text-primary, #111827)', border: 'var(--border-default, #e5e7eb)' },
|
|
40
|
+
// Meta brand blue; white mark on blue, per the brand guidelines.
|
|
41
|
+
facebook: { label: 'Continue with Facebook', background: '#1877F2', color: '#ffffff', border: '#1877F2' },
|
|
42
|
+
// Apple HIG: black (or white) field, "Sign in with Apple", Apple logo.
|
|
43
|
+
apple: { label: 'Sign in with Apple', background: '#000000', color: '#ffffff', border: '#000000' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const providerIcons: Record<FederatedProvider, { svg: React.ReactNode }> = {
|
|
13
47
|
google: {
|
|
14
|
-
label: 'Google',
|
|
15
|
-
color: '#4285F4',
|
|
16
48
|
svg: (
|
|
17
|
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
49
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
18
50
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
|
19
51
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
20
52
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
@@ -23,10 +55,8 @@ const providerIcons: Record<FederatedProvider, { label: string; svg: React.React
|
|
|
23
55
|
),
|
|
24
56
|
},
|
|
25
57
|
microsoft: {
|
|
26
|
-
label: 'Microsoft',
|
|
27
|
-
color: '#0078D4',
|
|
28
58
|
svg: (
|
|
29
|
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
59
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
30
60
|
<rect x="1" y="1" width="9" height="9" fill="#F25022"/>
|
|
31
61
|
<rect x="12" y="1" width="9" height="9" fill="#7FBA00"/>
|
|
32
62
|
<rect x="1" y="12" width="9" height="9" fill="#00A4EF"/>
|
|
@@ -35,28 +65,25 @@ const providerIcons: Record<FederatedProvider, { label: string; svg: React.React
|
|
|
35
65
|
),
|
|
36
66
|
},
|
|
37
67
|
facebook: {
|
|
38
|
-
|
|
39
|
-
|
|
68
|
+
// currentColor: white on the Meta-blue button. A hardcoded #1877F2 fill on a
|
|
69
|
+
// #1877F2 field is an invisible mark — the exact way a branded button ships
|
|
70
|
+
// looking like a blank one.
|
|
40
71
|
svg: (
|
|
41
|
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="
|
|
72
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
42
73
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
|
43
74
|
</svg>
|
|
44
75
|
),
|
|
45
76
|
},
|
|
46
77
|
github: {
|
|
47
|
-
label: 'GitHub',
|
|
48
|
-
color: '#000000',
|
|
49
78
|
svg: (
|
|
50
|
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
79
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
51
80
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
52
81
|
</svg>
|
|
53
82
|
),
|
|
54
83
|
},
|
|
55
84
|
apple: {
|
|
56
|
-
label: 'Apple',
|
|
57
|
-
color: '#000000',
|
|
58
85
|
svg: (
|
|
59
|
-
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
86
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
60
87
|
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
|
61
88
|
</svg>
|
|
62
89
|
),
|
|
@@ -71,22 +98,29 @@ export function FederatedAuthSection({ providers, onProviderClick, isLoading }:
|
|
|
71
98
|
return (
|
|
72
99
|
<div className="space-y-3">
|
|
73
100
|
{providers.map((provider) => {
|
|
74
|
-
const
|
|
101
|
+
const icon = providerIcons[provider];
|
|
102
|
+
const brand = PROVIDER_BRAND[provider] ?? {
|
|
103
|
+
label: `Continue with ${provider}`,
|
|
104
|
+
background: 'var(--bg-default, #ffffff)',
|
|
105
|
+
color: 'var(--text-primary, #111827)',
|
|
106
|
+
border: 'var(--border-default, #e5e7eb)',
|
|
107
|
+
};
|
|
75
108
|
return (
|
|
76
109
|
<button
|
|
77
110
|
key={provider}
|
|
78
111
|
type="button"
|
|
79
112
|
onClick={() => onProviderClick(provider)}
|
|
80
113
|
disabled={isLoading}
|
|
114
|
+
data-provider={provider}
|
|
81
115
|
className={`w-full px-4 py-3 rounded-lg border-2 font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
82
116
|
style={{
|
|
83
|
-
border:
|
|
84
|
-
color:
|
|
85
|
-
background:
|
|
117
|
+
border: `1px solid ${brand.border}`,
|
|
118
|
+
color: brand.color,
|
|
119
|
+
background: brand.background,
|
|
86
120
|
}}
|
|
87
121
|
>
|
|
88
|
-
{
|
|
89
|
-
|
|
122
|
+
{icon.svg}
|
|
123
|
+
{brand.label}
|
|
90
124
|
</button>
|
|
91
125
|
);
|
|
92
126
|
})}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { passwordResetRedirect } from '../../auth/password-reset-bridge';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PAY-2121: mount this once on your app's `/` page. When the IdP's post-verify
|
|
8
|
+
* redirect lands on `/#password_reset_required=true&reset_token=<t>&email=<e>`
|
|
9
|
+
* (a FRAGMENT), this performs ONE replace to the kit's reset surface and clears
|
|
10
|
+
* the hash. With any other fragment it renders nothing and leaves `/` alone.
|
|
11
|
+
*
|
|
12
|
+
* It renders nothing and reads no user data - it only inspects the fragment.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // app/page.tsx
|
|
16
|
+
* import { PasswordResetBridge } from '@payez/next-mvp/components/auth/PasswordResetBridge';
|
|
17
|
+
* export default function Home() {
|
|
18
|
+
* return (<><PasswordResetBridge /></>);
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
export function PasswordResetBridge() {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (typeof window === 'undefined') return;
|
|
24
|
+
const target = passwordResetRedirect(window.location.hash);
|
|
25
|
+
if (target) window.location.replace(target);
|
|
26
|
+
}, []);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default PasswordResetBridge;
|
|
@@ -62,35 +62,58 @@ export function useAvailableProviders(): UseAvailableProvidersResult {
|
|
|
62
62
|
|
|
63
63
|
async function fetchProviders() {
|
|
64
64
|
try {
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
// PAY-2104: THIS USED TO BE A HARDCODED GOOGLE LITERAL.
|
|
66
|
+
//
|
|
67
|
+
// const result = { google: { id: 'google', name: 'Google' } };
|
|
68
|
+
//
|
|
69
|
+
// under a comment claiming providers were "loaded dynamically". It
|
|
70
|
+
// returned Google for EVERY client whatever the tenant had enabled -
|
|
71
|
+
// a config-shaped constant, so Apple/Facebook (PAY-2104) could never
|
|
72
|
+
// appear and a client that had DISABLED Google would still see its
|
|
73
|
+
// button. The real answer is served by /api/auth/settings (the same
|
|
74
|
+
// route routes/auth/settings.ts builds from the IDP's oauthProviders).
|
|
75
|
+
const response = await fetch('/api/auth/settings', { cache: 'no-store' });
|
|
71
76
|
|
|
72
77
|
if (!mounted) return;
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.filter(id => OAUTH_PROVIDERS.includes(id))
|
|
81
|
-
.map(id => PROVIDER_MAP[id])
|
|
82
|
-
.filter((p): p is FederatedProvider => p !== undefined);
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
// FAIL CLOSED. An unreadable policy is not an empty provider list
|
|
81
|
+
// that we may then pad with a guess - it is "we do not know". See
|
|
82
|
+
// the PAY-1243 rule: never fabricate a provider list.
|
|
83
|
+
throw new Error(`Failed to fetch auth settings: ${response.status}`);
|
|
84
|
+
}
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
const body = await response.json().catch(() => null);
|
|
87
|
+
const settings = body?.data ?? body;
|
|
88
|
+
if (body?.success === false || !settings) {
|
|
89
|
+
throw new Error(body?.error?.code ?? 'POLICY_UNAVAILABLE');
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
// Keep the raw provider rows (id -> the settings row) for callers that
|
|
93
|
+
// want more than the mapped union type, exactly as before.
|
|
94
|
+
const enabledIds: string[] = Array.isArray(settings.enabledProviders)
|
|
95
|
+
? settings.enabledProviders.filter((p: unknown): p is string => typeof p === 'string')
|
|
96
|
+
: [];
|
|
97
|
+
const raw: Record<string, { id: string; name: string }> = {};
|
|
98
|
+
for (const id of enabledIds) raw[id] = { id, name: id };
|
|
99
|
+
|
|
100
|
+
if (!mounted) return;
|
|
101
|
+
setRawProviders(raw);
|
|
102
|
+
|
|
103
|
+
// Filter to OAuth providers only (exclude credentials) and map to our
|
|
104
|
+
// FederatedProvider union, in the IDP's order.
|
|
105
|
+
const oauthProviders = enabledIds
|
|
106
|
+
.filter(id => OAUTH_PROVIDERS.includes(id))
|
|
107
|
+
.map(id => PROVIDER_MAP[id])
|
|
108
|
+
.filter((p): p is FederatedProvider => p !== undefined);
|
|
109
|
+
|
|
110
|
+
setProviders(oauthProviders);
|
|
89
111
|
setError(null);
|
|
90
112
|
} catch (err) {
|
|
91
113
|
if (!mounted) return;
|
|
92
114
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
93
115
|
setProviders([]);
|
|
116
|
+
setRawProviders(null); // never leave a stale list behind a failure
|
|
94
117
|
} finally {
|
|
95
118
|
if (mounted) {
|
|
96
119
|
setIsLoading(false);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-2121: shared anonymous IDP proxy for the /api/onboarding/* handlers.
|
|
3
|
+
*
|
|
4
|
+
* The onboarding flow is TOKEN-DRIVEN: the invitee has no account or session
|
|
5
|
+
* yet, and the onboard_token IS the credential. So these calls must NOT go
|
|
6
|
+
* through idpFetchJSON (which injects a Bearer from a Redis session) - there is
|
|
7
|
+
* no session. They are plain server-to-server POSTs to the IdP, exactly as the
|
|
8
|
+
* VibeID portal's hand-built /api/onboarding/* routes do (card 182611).
|
|
9
|
+
*
|
|
10
|
+
* WHY A SERVER ROUTE AT ALL, AND NOT A BROWSER CALL: the IdP URL is not exposed
|
|
11
|
+
* to the browser, and the route is the one place that owns that. The token is
|
|
12
|
+
* forwarded, never the IdP's host.
|
|
13
|
+
*/
|
|
14
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
15
|
+
import { ENV_CONFIG } from '../config/env';
|
|
16
|
+
import { assertAbsoluteIdpUrl } from './idp-url-guard';
|
|
17
|
+
|
|
18
|
+
/** A typed refusal in the kit's error envelope - used for a missing field, BEFORE any IdP call. */
|
|
19
|
+
export function onboardingBadRequest(code: string, message: string) {
|
|
20
|
+
return NextResponse.json({ success: false, message, error: { code } }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POST a JSON body to `{IDP_URL}{idpPath}` with no credentials, and relay the
|
|
25
|
+
* IdP's status and body verbatim (the page acts on what the IdP actually
|
|
26
|
+
* returns, not on a shape this layer guesses). Never relays the IdP error TEXT
|
|
27
|
+
* for a non-2xx is NOT done here: the onboarding endpoints answer structured
|
|
28
|
+
* envelopes the page reads; the page owns the copy.
|
|
29
|
+
*/
|
|
30
|
+
export async function onboardingProxy(req: NextRequest, idpPath: string): Promise<NextResponse> {
|
|
31
|
+
const idpBase = ENV_CONFIG.IDP_URL;
|
|
32
|
+
if (!idpBase) {
|
|
33
|
+
return NextResponse.json({ success: false, message: 'IDP_URL is not configured', error: { code: 'NO_IDP_URL' } }, { status: 500 });
|
|
34
|
+
}
|
|
35
|
+
const url = `${idpBase}${idpPath}`;
|
|
36
|
+
assertAbsoluteIdpUrl(url);
|
|
37
|
+
let bodyText = '{}';
|
|
38
|
+
try {
|
|
39
|
+
const raw = await req.text();
|
|
40
|
+
if (raw && raw.trim().length > 0) bodyText = raw;
|
|
41
|
+
} catch { /* empty body is fine */ }
|
|
42
|
+
|
|
43
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
44
|
+
const xfwd = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
|
|
45
|
+
if (xfwd) headers['X-Forwarded-For'] = xfwd;
|
|
46
|
+
const ua = req.headers.get('user-agent');
|
|
47
|
+
if (ua) headers['User-Agent'] = ua;
|
|
48
|
+
|
|
49
|
+
let res: Response;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(url, { method: 'POST', headers, body: bodyText, cache: 'no-store' });
|
|
52
|
+
} catch {
|
|
53
|
+
return NextResponse.json({ success: false, message: 'The identity service is unreachable.', error: { code: 'UPSTREAM_UNREACHABLE' } }, { status: 502 });
|
|
54
|
+
}
|
|
55
|
+
const json = await res.json().catch(() => null);
|
|
56
|
+
return NextResponse.json(json ?? { success: res.ok }, { status: res.status });
|
|
57
|
+
}
|
|
@@ -124,3 +124,26 @@ export function projectPasswordPolicyRules(json: unknown): string[] | undefined
|
|
|
124
124
|
if (!details || details.code !== 'password_policy') return undefined;
|
|
125
125
|
return knownPasswordRules(details.rules);
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* PAY-2103 (AUTH-009 breached-password check): the IdP refuses a password found in a breach corpus with
|
|
130
|
+
* the typed code `password_breached` in its 400 validation envelope (error.code, or error.details.code,
|
|
131
|
+
* or a value[] entry while it settles). The reset-password route is ANONYMOUS, so only a BOOLEAN crosses
|
|
132
|
+
* (`password_breached: true`), never the code text: the page maps the boolean to its own sentence. Reads
|
|
133
|
+
* only the code, never IdP text.
|
|
134
|
+
*/
|
|
135
|
+
export function projectPasswordBreached(json: unknown): boolean {
|
|
136
|
+
if (!json || typeof json !== 'object') return false;
|
|
137
|
+
const top = json as Record<string, unknown>;
|
|
138
|
+
const err = top.error && typeof top.error === 'object' ? (top.error as Record<string, unknown>) : {};
|
|
139
|
+
const direct = str(err.code) ?? str(top.code) ?? str(top.error_code);
|
|
140
|
+
if (direct?.toLowerCase() === 'password_breached') return true;
|
|
141
|
+
const details = (err.details && typeof err.details === 'object' ? err.details : top.details && typeof top.details === 'object' ? top.details : null) as Record<string, unknown> | null;
|
|
142
|
+
if (str(details?.code)?.toLowerCase() === 'password_breached') return true;
|
|
143
|
+
// THE SHAPE THE IdP ACTUALLY SENDS (DotNetPert-Scout, PAY-2103 PR 850caa07a): error.details =
|
|
144
|
+
// { field:'new_password', code:'password_policy', rules:['password_breached'], requirements:[...] }.
|
|
145
|
+
// The breach is a member of rules[], not a code - the house envelope carries every password rule
|
|
146
|
+
// that way. Reading only a code would MISS the real refusal.
|
|
147
|
+
if (Array.isArray(details?.rules) && (details!.rules as unknown[]).some((r) => typeof r === 'string' && r.toLowerCase() === 'password_breached')) return true;
|
|
148
|
+
return Array.isArray(details?.value) && (details!.value as unknown[]).some((v) => !!v && typeof v === 'object' && str((v as Record<string, unknown>).code)?.toLowerCase() === 'password_breached');
|
|
149
|
+
}
|
|
@@ -113,6 +113,30 @@ export interface MvpMiddlewareOptions {
|
|
|
113
113
|
bypassPaths?: string[];
|
|
114
114
|
/** Paths exempt from RBAC checks (auth still enforced, just no page-permission check) */
|
|
115
115
|
rbacExemptPaths?: string[];
|
|
116
|
+
/**
|
|
117
|
+
* PAY-1882 step 4 - how this app treats a producer that reports the session's
|
|
118
|
+
* revocation status as `'unknown'` (a Vibe read that FAILED: non-OK, timeout,
|
|
119
|
+
* throw - the status could not be determined).
|
|
120
|
+
*
|
|
121
|
+
* 'closed' (DEFAULT) - an unknown revocation status is NOT viable; the
|
|
122
|
+
* session is force-invalidated and the user re-auths.
|
|
123
|
+
* This is Jon's PAY-1880 ruling (option A, fail closed),
|
|
124
|
+
* 2026-09-27, and BAPert 66316/66320 restates it for
|
|
125
|
+
* step 4. Consequence, accepted explicitly: a Vibe
|
|
126
|
+
* outage signs this app's users out. 'not_configured'
|
|
127
|
+
* also fails closed here - both live consumers ARE
|
|
128
|
+
* configured (BAPert 66356), so for them it means a
|
|
129
|
+
* misconfiguration, not "no revocation source".
|
|
130
|
+
* 'open' - an unknown OR not-configured status stays viable
|
|
131
|
+
* (availability over fail-closed). Per-app opt-out for a
|
|
132
|
+
* deployment with no revocation source, or the two live
|
|
133
|
+
* consumers while Jon decides.
|
|
134
|
+
*
|
|
135
|
+
* Neither mode changes `'revoked'` (always force-invalid) or an ABSENT
|
|
136
|
+
* `revocationStatus` (an older producer - unchanged behaviour, so no consumer
|
|
137
|
+
* is signed out for not implementing the check).
|
|
138
|
+
*/
|
|
139
|
+
revocationFailMode?: 'closed' | 'open';
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
// =============================================================================
|
|
@@ -436,6 +460,8 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
436
460
|
const refreshEndpoint = options.refreshEndpoint || '/api/auth/refresh';
|
|
437
461
|
const bypassPaths = options.bypassPaths || [];
|
|
438
462
|
const rbacExemptPaths = options.rbacExemptPaths || [];
|
|
463
|
+
// PAY-1882 step 4: default 'closed' (Jon's option A / BAPert 66316).
|
|
464
|
+
const revocationFailMode = options.revocationFailMode || 'closed';
|
|
439
465
|
|
|
440
466
|
return async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
441
467
|
const { pathname, searchParams } = request.nextUrl;
|
|
@@ -472,7 +498,12 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
472
498
|
}
|
|
473
499
|
|
|
474
500
|
// --- Fetch session viability from internal API ---
|
|
475
|
-
const { sessionStatus, sessionPointer } = await checkViability(
|
|
501
|
+
const { sessionStatus, sessionPointer } = await checkViability(
|
|
502
|
+
request,
|
|
503
|
+
viabilityEndpoint,
|
|
504
|
+
log,
|
|
505
|
+
revocationFailMode
|
|
506
|
+
);
|
|
476
507
|
|
|
477
508
|
// --- Stale session (cookie exists but session doesn't): OBSERVE ONLY, DO NOT DECIDE ---
|
|
478
509
|
//
|
|
@@ -538,11 +569,43 @@ export function createMvpMiddleware(options: MvpMiddlewareOptions = {}) {
|
|
|
538
569
|
};
|
|
539
570
|
}
|
|
540
571
|
|
|
572
|
+
/**
|
|
573
|
+
* The revocation decision, as a pure function (PAY-1882 step 4, NightHawk 66392).
|
|
574
|
+
*
|
|
575
|
+
* Separate + exported so the B1 defect is testable with a REAL mutant: the bug was
|
|
576
|
+
* that `data.forceInvalid === true` was read BEFORE revocationFailMode, so a
|
|
577
|
+
* producer that folds 'unknown' into forceInvalid made the 'open' lever dead.
|
|
578
|
+
*
|
|
579
|
+
* RULE: when the producer supplies `revocationStatus`, it is AUTHORITATIVE - the
|
|
580
|
+
* decision is made from it plus failMode, and the folded booleans are ignored for
|
|
581
|
+
* 'unknown'/'not_configured'. The booleans are read ONLY when revocationStatus is
|
|
582
|
+
* absent (idealresume.online's older boolean-only route).
|
|
583
|
+
*/
|
|
584
|
+
export function decideForceInvalid(
|
|
585
|
+
data: { revocationStatus?: unknown; forceInvalid?: unknown; revoked?: unknown },
|
|
586
|
+
revocationFailMode: 'closed' | 'open',
|
|
587
|
+
): boolean {
|
|
588
|
+
const status = data.revocationStatus;
|
|
589
|
+
if (typeof status === 'string') {
|
|
590
|
+
if (status === 'revoked') return true;
|
|
591
|
+
if (status === 'active') return false;
|
|
592
|
+
// S1 (NightHawk 66409): ANY other string - 'unknown', 'not_configured', a
|
|
593
|
+
// typo like 'Revoked', 'error', or '' - is treated as 'unknown' (fail mode
|
|
594
|
+
// applies), NEVER as 'active'. Returning false here would let the first
|
|
595
|
+
// producer that emits an unexpected state silently switch revocation OFF -
|
|
596
|
+
// the exact PAY-1880 fail-open class. Only an explicit 'active' is viable.
|
|
597
|
+
return revocationFailMode === 'closed';
|
|
598
|
+
}
|
|
599
|
+
// No revocationStatus: an older boolean producer. Absence of both stays false.
|
|
600
|
+
return data.forceInvalid === true || data.revoked === true;
|
|
601
|
+
}
|
|
602
|
+
|
|
541
603
|
/** Check session viability via internal API */
|
|
542
604
|
async function checkViability(
|
|
543
605
|
request: NextRequest,
|
|
544
606
|
endpoint: string,
|
|
545
|
-
log: MiddlewareLogger
|
|
607
|
+
log: MiddlewareLogger,
|
|
608
|
+
revocationFailMode: 'closed' | 'open'
|
|
546
609
|
): Promise<{
|
|
547
610
|
sessionStatus: SessionStatus;
|
|
548
611
|
sessionPointer: SessionPointer;
|
|
@@ -581,6 +644,46 @@ async function checkViability(
|
|
|
581
644
|
sessionStatus.exists = data.authenticated ?? (!!data.sessionTokenId && data.reason !== 'session_not_found');
|
|
582
645
|
sessionStatus.requires2FA = data.requires2FA || false;
|
|
583
646
|
sessionStatus.twoFactorComplete = data.twoFactorComplete || false;
|
|
647
|
+
// --- Session revocation (PAY-1882 Step 4) ---
|
|
648
|
+
// Two producer spellings are honoured, because both exist on the wire:
|
|
649
|
+
// - boolean `forceInvalid` / `revoked` (idealresume.online's route today)
|
|
650
|
+
// - the three-state `revocationStatus` the package's own viability
|
|
651
|
+
// handlers now emit (PAY-1882 step 4).
|
|
652
|
+
//
|
|
653
|
+
// RULING (Jon, via BAPert 66316): 'revoked' ALWAYS fails closed. 'unknown'
|
|
654
|
+
// (a FAILED Vibe read) fails closed by default and is the ONLY state that
|
|
655
|
+
// the per-app `revocationFailMode` option can relax to viable.
|
|
656
|
+
//
|
|
657
|
+
// Four producer states (BAPert 66320), with the fail-closed ruling for
|
|
658
|
+
// 'not_configured' added by BAPert 66356:
|
|
659
|
+
// 'active' - Vibe read OK, not revoked -> viable
|
|
660
|
+
// 'revoked' - Vibe says revoked -> force-invalid, ALWAYS
|
|
661
|
+
// 'unknown' - read FAILED -> force-invalid if 'closed'
|
|
662
|
+
// 'not_configured' - no revocation source -> force-invalid if 'closed'
|
|
663
|
+
//
|
|
664
|
+
// WHY 'not_configured' FAILS CLOSED WHEN 'closed': both live consumers of
|
|
665
|
+
// this middleware (portal.keelauth.com / keelauth-portal, idealvibe.online)
|
|
666
|
+
// DO set IDP_URL + VIBE_CLIENT_ID + VIBE_HMAC_KEY (BAPert 66356, measured in
|
|
667
|
+
// their committed manifests + the graft pipeline's own secret check), so for
|
|
668
|
+
// them 'not_configured' can only mean a MISCONFIGURATION - revocation
|
|
669
|
+
// silently switched off is exactly the PAY-1880 fail-open bug. An app with
|
|
670
|
+
// no revocation source that genuinely wants availability sets
|
|
671
|
+
// revocationFailMode 'open'.
|
|
672
|
+
//
|
|
673
|
+
// A MISSING revocationStatus (an older producer) keeps today's behaviour,
|
|
674
|
+
// i.e. NOT force-invalid: absence must never sign out a population that
|
|
675
|
+
// never opted into the check.
|
|
676
|
+
//
|
|
677
|
+
// *** revocationStatus IS AUTHORITATIVE WHEN PRESENT (NightHawk 66392). ***
|
|
678
|
+
// The package's own producers emit `forceInvalid: revocationStatus ===
|
|
679
|
+
// 'revoked' || revocationStatus === 'unknown'`. If the boolean were read
|
|
680
|
+
// FIRST, `forceInvalid: true` would short-circuit before revocationFailMode
|
|
681
|
+
// was consulted, so `'open'` could never keep an 'unknown' session viable -
|
|
682
|
+
// the opt-out lever would be DEAD. So: when a producer supplies a
|
|
683
|
+
// revocations status, decide from it + failMode and ignore the folded
|
|
684
|
+
// booleans for unknown/not_configured. The booleans are read ONLY when
|
|
685
|
+
// revocationStatus is ABSENT (idealresume.online's older boolean route).
|
|
686
|
+
sessionStatus.forceInvalid = decideForceInvalid(data, revocationFailMode);
|
|
584
687
|
sessionPointer.exists = sessionStatus.exists ?? false;
|
|
585
688
|
sessionPointer.sessionTokenId = data.sessionTokenId ?? null;
|
|
586
689
|
sessionPointer.expired = data.accessTokenExpired || false;
|
|
@@ -792,3 +895,4 @@ async function handleRefresh(
|
|
|
792
895
|
);
|
|
793
896
|
}
|
|
794
897
|
}
|
|
898
|
+
|