-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathoidc.ts
More file actions
224 lines (204 loc) · 7.04 KB
/
Copy pathoidc.ts
File metadata and controls
224 lines (204 loc) · 7.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
import axios from 'axios';
import 'dotenv/config';
export interface TokenIntrospectionResponse {
active: boolean;
username?: string;
name?: string;
email?: string;
sub?: string;
exp?: number;
iat?: number;
client_id?: string;
scope?: string;
[key: string]: unknown;
}
interface TokenCacheEntry {
response: TokenIntrospectionResponse;
expires: number;
}
// Simple in-memory cache for token introspection results
const tokenCache = new Map<string, TokenCacheEntry>();
// How long an introspection result is trusted before we ask Keycloak again.
// Short enough that a revoked session loses access promptly (#960), long
// enough that a burst of requests (e.g. a multipart upload) isn't one
// introspection call per request.
export const INTROSPECTION_CACHE_MS = 60 * 1000;
/**
* Introspects an OIDC token with the configured authorization server
* @param token The access token to introspect
* @returns Promise<TokenIntrospectionResponse>
*/
export async function introspectToken(
token: string
): Promise<TokenIntrospectionResponse> {
// Check cache first
const cached = tokenCache.get(token);
if (cached && cached.expires > Date.now()) {
return cached.response;
}
const introspectionUrl = process.env.OIDC_TOKEN_INTROSPECTION_URL;
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
if (!introspectionUrl || !clientId || !clientSecret) {
throw new Error(
'OIDC configuration missing. Please set OIDC_TOKEN_INTROSPECTION_URL, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET'
);
}
try {
const response = await axios.post(
introspectionUrl,
new URLSearchParams({
token,
token_type_hint: 'access_token',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
auth: {
username: clientId,
password: clientSecret,
},
timeout: 5000,
}
);
const introspectionResult: TokenIntrospectionResponse = response.data;
// Cache the result briefly so per-request introspection doesn't hammer
// Keycloak (e.g. during multipart uploads), while still catching a revoked
// session — logout / password change / admin force-logout — within
// INTROSPECTION_CACHE_MS. Never cache past the token's own expiry.
const tokenExpiry = introspectionResult.exp
? introspectionResult.exp * 1000
: Date.now() + INTROSPECTION_CACHE_MS;
const cacheExpiry = Math.min(
Date.now() + INTROSPECTION_CACHE_MS,
tokenExpiry
);
tokenCache.set(token, {
response: introspectionResult,
expires: cacheExpiry,
});
// Clean up expired cache entries periodically
if (Math.random() < 0.1) {
// 10% chance to cleanup on each introspection
const now = Date.now();
for (const [cachedToken, entry] of tokenCache.entries()) {
if (entry.expires <= now) {
tokenCache.delete(cachedToken);
}
}
}
return introspectionResult;
} catch (error) {
console.error('Token introspection failed:', error);
throw new Error('Token introspection failed');
}
}
/**
* Per-request liveness check for an access token (#960).
* @returns `true` if Keycloak reports the token active, `false` if it reports
* it inactive (session revoked/expired), and `null` if introspection could not
* be performed (misconfig or Keycloak unreachable). Callers should treat `null`
* as inconclusive and fail OPEN — a Keycloak blip must not sign everyone out.
*/
export async function isTokenActive(token: string): Promise<boolean | null> {
try {
const result = await introspectToken(token);
return result.active === true;
} catch (error) {
console.error('Token introspection unavailable (failing open):', error);
return null;
}
}
/**
* Read a JWT's `exp` (seconds since epoch) without verifying the signature.
* Returns `null` for a non-JWT/opaque token. NOTE: the refresh-vs-revoke
* distinction in isAccessTokenRevoked depends on this being readable — Keycloak
* issues JWT access tokens, so it is today. If access tokens ever become opaque,
* decodeTokenExp returns null, the expiry gate is skipped, and a routinely
* expired token would be treated as revoked (forced logout on expiry). Revisit
* this gate before switching token formats.
*/
function decodeTokenExp(token: string): number | null {
try {
const payload = token.split('.')[1];
if (!payload) {
return null;
}
const claims = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8')
);
return typeof claims?.exp === 'number' ? claims.exp : null;
} catch {
return null;
}
}
/**
* True only when the access token is still within its lifetime but Keycloak
* reports it inactive — i.e. the session was revoked (logout elsewhere,
* password change, admin force-logout), NOT merely expired (#960).
*
* An expired token returns `false` on purpose: routine expiry must go through
* the normal refresh flow (a refresh succeeds if the session is still alive and
* fails — logging the user out — if it isn't). Introspection errors also return
* `false` (fail open) so a Keycloak outage can't sign everyone out. This keeps
* forced logout limited to genuine revocations, per the refresh requirement in
* the issue discussion.
*/
export async function isAccessTokenRevoked(token: string): Promise<boolean> {
const exp = decodeTokenExp(token);
if (exp !== null && exp * 1000 <= Date.now()) {
return false; // expired — let the refresh flow decide, don't force logout
}
const active = await isTokenActive(token);
return active === false;
}
/**
* Validates an OIDC token and returns user information if valid
* @param token The access token to validate
* @returns Promise<{isValid: boolean, userInfo?: any}>
*/
export async function validateOIDCToken(token: string): Promise<{
isValid: boolean;
userInfo?: {
sub: string;
email?: string;
username?: string;
thundermailEmail?: string;
name?: string;
};
}> {
try {
const introspectionResult = await introspectToken(token);
// `active` already reflects expiry and revocation, so it is the single
// source of truth — an expired or revoked token comes back active:false.
if (!introspectionResult.active) {
return { isValid: false };
}
return {
isValid: true,
userInfo: {
sub: introspectionResult.sub || introspectionResult.username || '',
email: introspectionResult.email,
username: introspectionResult.username,
name: introspectionResult.name,
},
};
} catch (error) {
console.error('OIDC token validation failed:', error);
return { isValid: false };
}
}
/**
* Extract Bearer token from Authorization header
* @param authHeader The Authorization header value
* @returns The token or null if not found
*/
export function extractBearerToken(
authHeader: string | undefined
): string | null {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.slice(7); // Remove 'Bearer ' prefix
}