Skip to content

Commit bae743d

Browse files
committed
DC-150 Add: Address stale PKCE scenario in sessionStorage
1 parent e371dbb commit bae743d

2 files changed

Lines changed: 103 additions & 12 deletions

File tree

src/SEBT.Portal.Web/src/lib/oidc-pkce.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import {
44
buildAuthorizationUrl,
5+
clearPkceStorage,
56
generateCodeChallenge,
67
generateCodeVerifier,
7-
generateState
8+
generateState,
9+
getPkceFromStorage,
10+
PKCE_STORAGE_MAX_AGE_MS,
11+
savePkceForCallback
812
} from './oidc-pkce'
913

1014
describe('oidc-pkce', () => {
@@ -90,4 +94,70 @@ describe('oidc-pkce', () => {
9094
expect(challenge).not.toContain('=')
9195
})
9296
})
97+
98+
describe('savePkceForCallback / getPkceFromStorage', () => {
99+
beforeEach(() => {
100+
sessionStorage.clear()
101+
vi.useRealTimers()
102+
})
103+
104+
afterEach(() => {
105+
sessionStorage.clear()
106+
vi.useRealTimers()
107+
})
108+
109+
it('round-trips PKCE with storedAtMs and returns it while fresh', () => {
110+
const before = Date.now()
111+
savePkceForCallback('st', 'verifier', {
112+
redirectUri: 'https://app/cb',
113+
tokenEndpoint: 'https://auth/token',
114+
clientId: 'cid'
115+
})
116+
const got = getPkceFromStorage()
117+
expect(got).not.toBeNull()
118+
expect(got!.state).toBe('st')
119+
expect(got!.code_verifier).toBe('verifier')
120+
expect(got!.storedAtMs).toBeGreaterThanOrEqual(before)
121+
expect(got!.storedAtMs).toBeLessThanOrEqual(Date.now())
122+
})
123+
124+
it('returns null and clears storage when older than max age', () => {
125+
vi.useFakeTimers()
126+
const t0 = Date.now()
127+
vi.setSystemTime(t0)
128+
savePkceForCallback('st', 'verifier', {
129+
redirectUri: 'https://app/cb',
130+
tokenEndpoint: 'https://auth/token',
131+
clientId: 'cid'
132+
})
133+
vi.setSystemTime(t0 + PKCE_STORAGE_MAX_AGE_MS + 1)
134+
expect(getPkceFromStorage()).toBeNull()
135+
expect(sessionStorage.getItem('oidc_co_pkce')).toBeNull()
136+
})
137+
138+
it('returns null and clears storage when storedAtMs is missing (legacy blob)', () => {
139+
sessionStorage.setItem(
140+
'oidc_co_pkce',
141+
JSON.stringify({
142+
state: 's',
143+
code_verifier: 'v',
144+
redirect_uri: 'https://r',
145+
token_endpoint: 'https://t',
146+
client_id: 'c'
147+
})
148+
)
149+
expect(getPkceFromStorage()).toBeNull()
150+
expect(sessionStorage.getItem('oidc_co_pkce')).toBeNull()
151+
})
152+
153+
it('clearPkceStorage removes the key', () => {
154+
savePkceForCallback('st', 'verifier', {
155+
redirectUri: 'https://app/cb',
156+
tokenEndpoint: 'https://auth/token',
157+
clientId: 'cid'
158+
})
159+
clearPkceStorage()
160+
expect(sessionStorage.getItem('oidc_co_pkce')).toBeNull()
161+
})
162+
})
93163
})

src/SEBT.Portal.Web/src/lib/oidc-pkce.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44

55
const 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+
713
function 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

9199
export 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 onlynot 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+
118132
export 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

Comments
 (0)