Skip to content

Commit f85c39f

Browse files
committed
cache introspected tokens
1 parent b46eb96 commit f85c39f

File tree

5 files changed

+109
-7
lines changed

5 files changed

+109
-7
lines changed

backend/src/zimfarm_backend/api/token.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import datetime
2+
import hashlib
3+
from dataclasses import dataclass
24

35
import jwt
46
from ory_client.api.o_auth2_api import OAuth2Api as OryOAuth2Api
@@ -16,14 +18,70 @@
1618
KIWIX_ISSUER,
1719
ORY_ACCESS_TOKEN,
1820
)
21+
from zimfarm_backend.common import getnow
1922

2023
_ory_client_configuration = OryClientConfiguration(
2124
host=KIWIX_ISSUER, access_token=ORY_ACCESS_TOKEN
2225
)
2326

2427

28+
@dataclass
29+
class _CachedToken:
30+
"""Cached introspected token with expiration time."""
31+
32+
introspected_token: IntrospectedOAuth2Token
33+
expires_at: datetime.datetime
34+
35+
36+
# Cache to store introspected tokens with their expiration time
37+
# Key is SHA256 hash of the token, value is _CachedToken
38+
_introspection_token_cache: dict[str, _CachedToken] = {}
39+
40+
41+
def _hash_token(token: str) -> str:
42+
"""Hash token for use as cache key to avoid storing raw tokens in memory."""
43+
return hashlib.sha256(token.encode()).hexdigest()
44+
45+
46+
def _is_cache_entry_valid(cached: _CachedToken) -> bool:
47+
"""Check if a cached token entry is still valid (not expired)."""
48+
return getnow() < cached.expires_at
49+
50+
51+
def _get_cached_introspection_token(token: str) -> IntrospectedOAuth2Token | None:
52+
"""Get cached introspection result if available and not expired."""
53+
token_hash = _hash_token(token)
54+
if cached := _introspection_token_cache.get(token_hash):
55+
if _is_cache_entry_valid(cached):
56+
return cached.introspected_token
57+
# Remove expired entry
58+
del _introspection_token_cache[token_hash]
59+
return None
60+
61+
62+
def _cache_introspection_token(token: str, introspected_token: IntrospectedOAuth2Token):
63+
"""Cache an introspected token with TTL based on its expiration time."""
64+
token_hash = _hash_token(token)
65+
# Use token's exp time if available, otherwise cache for a short duration
66+
if introspected_token.exp:
67+
# exp is a Unix timestamp (seconds since epoch)
68+
expires_at = datetime.datetime.fromtimestamp(introspected_token.exp)
69+
_introspection_token_cache[token_hash] = _CachedToken(
70+
introspected_token=introspected_token,
71+
expires_at=expires_at,
72+
)
73+
74+
2575
def verify_kiwix_access_token(token: str) -> IntrospectedOAuth2Token:
26-
"""Verify a Kiwix access token by calling introspection endpoint."""
76+
"""Verify a Kiwix access token by calling introspection endpoint.
77+
78+
Results are cached based on token expiration time to reduce API calls.
79+
"""
80+
# Check cache first
81+
if cached_token := _get_cached_introspection_token(token):
82+
return cached_token
83+
84+
# Cache miss - perform introspection
2785
with OryApiClient(_ory_client_configuration) as api_client:
2886
api_instance = OryOAuth2Api(api_client)
2987
try:
@@ -37,6 +95,10 @@ def verify_kiwix_access_token(token: str) -> IntrospectedOAuth2Token:
3795
raise ValueError("Kiwix access token issuer is not valid")
3896
if KIWIX_CLIENT_ID != introspected_token.client_id:
3997
raise ValueError("Kiwix access token client ID is not valid")
98+
99+
# Cache the successful introspection result
100+
_cache_introspection_token(token, introspected_token)
101+
40102
return introspected_token
41103

42104

frontend-ui/src/components/UpdateUser.vue

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@
2626
hide-details
2727
/>
2828
</v-col>
29-
<v-col cols="12" sm="6" md="4" class="d-flex align-end">
29+
<v-col cols="12" sm="6" md="4">
30+
<v-text-field
31+
v-model="form.idp_sub"
32+
label="IDP Sub (UUID)"
33+
placeholder="00000000-0000-0000-0000-000000000000"
34+
variant="outlined"
35+
density="compact"
36+
hide-details
37+
/>
38+
</v-col>
39+
<v-col cols="12" sm="12" md="4" class="d-flex align-end">
3040
<v-btn type="submit" color="primary" variant="elevated" :disabled="!payload" block>
3141
Update User
3242
</v-btn>
@@ -167,6 +177,7 @@ interface Props {
167177
email: string
168178
role?: string
169179
scope?: Record<string, Record<string, boolean>>
180+
idp_sub?: string
170181
}
171182
}
172183
@@ -179,6 +190,7 @@ const emit = defineEmits<{
179190
role?: (typeof constants.ROLES)[number]
180191
email?: string
181192
scope?: Record<string, Record<string, boolean>>
193+
idp_sub?: string
182194
},
183195
): void
184196
(e: 'change-password', password: string): void
@@ -193,6 +205,7 @@ const form = ref({
193205
role: '' as (typeof constants.ROLES)[number],
194206
password: '',
195207
customScope: '',
208+
idp_sub: '',
196209
})
197210
198211
const keyForm = ref({
@@ -211,6 +224,7 @@ const payload = computed(() => {
211224
role?: (typeof constants.ROLES)[number]
212225
email?: string
213226
scope?: Record<string, Record<string, boolean>>
227+
idp_sub?: string
214228
} = {}
215229
216230
// If role is custom, we send scope instead of role
@@ -234,8 +248,13 @@ const payload = computed(() => {
234248
result.email = form.value.email
235249
}
236250
251+
// Only include idp_sub if it has changed
252+
if (form.value.idp_sub !== (props.user.idp_sub || '')) {
253+
result.idp_sub = form.value.idp_sub || undefined
254+
}
255+
237256
// Return null if no changes were made
238-
if (!result.role && !result.scope && !result.email) {
257+
if (!result.role && !result.scope && !result.email && !result.idp_sub) {
239258
return null
240259
}
241260
@@ -390,6 +409,7 @@ onMounted(() => {
390409
role,
391410
password: genPassword(),
392411
customScope,
412+
idp_sub: props.user.idp_sub || '',
393413
}
394414
}
395415
})

frontend-ui/src/stores/user.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,27 @@ export const useUserStore = defineStore('user', () => {
116116

117117
const updateUser = async (
118118
username: string,
119-
payload: { role?: string; email?: string; scope?: Record<string, Record<string, boolean>> },
119+
payload: {
120+
role?: string
121+
email?: string
122+
scope?: Record<string, Record<string, boolean>>
123+
idp_sub?: string
124+
},
120125
) => {
121126
const service = await authStore.getApiService('users')
127+
const cleanedPayload = Object.fromEntries(
128+
Object.entries(payload).filter(([, value]) => value !== null || value != undefined),
129+
)
122130
try {
123131
await service.patch<
124-
{ role?: string; email?: string; scope?: Record<string, Record<string, boolean>> },
132+
{
133+
role?: string
134+
email?: string
135+
scope?: Record<string, Record<string, boolean>>
136+
idp_sub?: string
137+
},
125138
null
126-
>(`/${username}`, payload)
139+
>(`/${username}`, cleanedPayload)
127140
errors.value = []
128141
return true
129142
} catch (error) {

frontend-ui/src/types/user.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface JWTPayload {
1616

1717
export interface User extends JWTUser {
1818
role: string
19+
idp_sub?: string
1920
}
2021

2122
export interface Token {

frontend-ui/src/views/UserView.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@
6464
</a>
6565
</p>
6666

67+
<!-- IDP Sub -->
68+
<p v-if="user.idp_sub" class="mb-4">
69+
<strong>IDP Sub:</strong> <code>{{ user.idp_sub }}</code>
70+
</p>
71+
6772
<!-- Permissions List -->
6873
<v-card class="mb-4" variant="outlined">
6974
<v-card-title class="text-subtitle-1">
@@ -363,8 +368,9 @@ const updateUser = async (payload: {
363368
role?: string
364369
email?: string
365370
scope?: Record<string, Record<string, boolean>>
371+
idp_sub?: string
366372
}) => {
367-
if (!(payload.role || payload.email || payload.scope)) return
373+
if (!(payload.role || payload.email || payload.scope || payload.idp_sub)) return
368374
369375
loadingStore.startLoading('updating user…')
370376
const success = await userStore.updateUser(props.username, payload)

0 commit comments

Comments
 (0)