44
55const CO_PKCE_STORAGE_KEY = 'oidc_co_pkce'
66
7+ /**
8+ * Discard PKCE blobs older than this so a long-lived tab cannot complete a flow with stale state.
9+ * Aligned with typical authorization-code lifetime plus a small buffer.
10+ */
11+ export const PKCE_STORAGE_MAX_AGE_MS = 15 * 60 * 1000
12+
713function randomBase64Url ( length : number ) : string {
814 const bytes = new Uint8Array ( length )
9- // crypto.getRandomValues is required for PKCE security — Math.random is not cryptographically secure.
15+ // crypto.getRandomValues is required for PKCE security; Math.random is not cryptographically secure.
1016 // All modern browsers support this (since 2013), and generateCodeChallenge already requires crypto.subtle.
1117 crypto . getRandomValues ( bytes )
1218 return btoa ( String . fromCharCode ( ...bytes ) )
@@ -86,6 +92,8 @@ export interface StoredPkce {
8692 isStepUp ?: boolean
8793 /** URL to redirect to after step-up completes. */
8894 returnUrl ?: string
95+ /** When this blob was written (`Date.now()`), for max-age checks. */
96+ storedAtMs : number
8997}
9098
9199export function savePkceForCallback (
@@ -106,32 +114,45 @@ export function savePkceForCallback(
106114 redirect_uri : config . redirectUri ,
107115 token_endpoint : config . tokenEndpoint ,
108116 client_id : config . clientId ,
117+ storedAtMs : Date . now ( ) ,
109118 ...( config . isStepUp !== undefined && { isStepUp : config . isStepUp } ) ,
110119 ...( config . returnUrl !== undefined &&
111120 config . returnUrl !== '' && { returnUrl : config . returnUrl } )
112121 }
113- // PKCE data is stored in sessionStorage only — not localStorage.
114- // localStorage would persist across tabs/sessions, which could allow stale PKCE to be accepted.
122+ // PKCE data is stored in sessionStorage only, not localStorage:
123+ // localStorage would persist across tabs/sessions and could allow stale PKCE to be accepted.
115124 sessionStorage . setItem ( CO_PKCE_STORAGE_KEY , JSON . stringify ( payload ) )
116125}
117126
127+ function parseStoredAtMs ( value : unknown ) : number | null {
128+ if ( typeof value !== 'number' || ! Number . isFinite ( value ) || value <= 0 ) return null
129+ return value
130+ }
131+
118132export function getPkceFromStorage ( ) : StoredPkce | null {
119133 if ( typeof window === 'undefined' ) return null
120134 const raw = sessionStorage . getItem ( CO_PKCE_STORAGE_KEY )
121135 if ( ! raw ) return null
122136 try {
123137 const payload = JSON . parse ( raw ) as StoredPkce
124138 if (
125- payload ?. state &&
126- payload ?. code_verifier &&
127- payload ?. redirect_uri &&
128- payload ?. token_endpoint &&
129- payload ?. client_id
139+ ! payload ?. state ||
140+ ! payload ?. code_verifier ||
141+ ! payload ?. redirect_uri ||
142+ ! payload ?. token_endpoint ||
143+ ! payload ?. client_id
130144 ) {
131- return payload
145+ sessionStorage . removeItem ( CO_PKCE_STORAGE_KEY )
146+ return null
147+ }
148+ const storedAtMs = parseStoredAtMs ( payload . storedAtMs )
149+ if ( storedAtMs === null || Date . now ( ) - storedAtMs > PKCE_STORAGE_MAX_AGE_MS ) {
150+ sessionStorage . removeItem ( CO_PKCE_STORAGE_KEY )
151+ return null
132152 }
153+ return { ...payload , storedAtMs }
133154 } catch {
134- // ignore
155+ sessionStorage . removeItem ( CO_PKCE_STORAGE_KEY )
135156 }
136157 return null
137158}
0 commit comments