dara-core 1.22.4__py3-none-any.whl → 1.23.0__py3-none-any.whl

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.
@@ -0,0 +1,586 @@
1
+ from typing import ClassVar
2
+ from urllib.parse import urlencode
3
+
4
+ import httpx
5
+ import jwt
6
+ from fastapi import HTTPException, Response
7
+ from jwt import PyJWKClient
8
+ from pydantic import Field
9
+ from pydantic.config import ConfigDict
10
+
11
+ from dara.core.definitions import ApiRoute
12
+ from dara.core.internal.settings import get_settings
13
+ from dara.core.logging import dev_logger
14
+
15
+ from ..base import AuthComponent, AuthComponentConfig, BaseAuthConfig
16
+ from ..definitions import (
17
+ ID_TOKEN,
18
+ INVALID_TOKEN_ERROR,
19
+ JWT_ALGO,
20
+ SESSION_ID,
21
+ UNAUTHORIZED_ERROR,
22
+ USER,
23
+ AuthError,
24
+ RedirectResponse,
25
+ SessionRequestBody,
26
+ SuccessResponse,
27
+ TokenData,
28
+ TokenResponse,
29
+ UserData,
30
+ UserGroup,
31
+ )
32
+ from ..utils import decode_token, sign_jwt
33
+ from .definitions import (
34
+ JWK_CLIENT_REGISTRY_KEY,
35
+ REFRESH_TOKEN_COOKIE_NAME,
36
+ IdTokenClaims,
37
+ OIDCDiscoveryMetadata,
38
+ StateObject,
39
+ )
40
+ from .routes import sso_callback
41
+ from .settings import get_oidc_settings
42
+ from .utils import decode_id_token, get_token_from_idp
43
+
44
+ OIDCAuthLogin = AuthComponent(js_module='@darajs/core', py_module='dara.core', js_name='OIDCAuthLogin')
45
+
46
+ OIDCAuthLogout = AuthComponent(js_module='@darajs/core', py_module='dara.core', js_name='OIDCAuthLogout')
47
+
48
+ OIDCAuthSSOCallback = AuthComponent(js_module='@darajs/core', py_module='dara.core', js_name='OIDCAuthSSOCallback')
49
+
50
+
51
+ class OIDCAuthConfig(BaseAuthConfig):
52
+ """
53
+ Generic OIDC auth config.
54
+
55
+ This config requires the following ENV variables to be set:
56
+ - SSO_ISSUER_URL - URL of the identity provider issuer; should expose a `SSO_ISSUER_URL/.well-known/openid-configuration` endpoint for discovery
57
+ - SSO_CLIENT_ID - client_id generated for the application by the identity provider
58
+ - SSO_CLIENT_SECRET - client_secret generated for the application by the identity provider
59
+ - SSO_REDIRECT_URI - URL that identity provider should redirect back to, in most cases https://deployed-app-url/sso-callback
60
+ - SSO_GROUPS - comma separated list of allowed SSO groups
61
+
62
+ In addition, the following ENV variables can be set:
63
+ - SSO_ALLOWED_IDENTITY_ID - if set, only the user with matching identity_id will be allowed to access the app
64
+ - SSO_VERIFY_AUDIENCE - if set, the ID token will be verified against the configured audience, by default `sso_client_id`
65
+ - SSO_EXTRA_AUDIENCE - if set, extra audiences to verify against the ID token in addition to `sso_client_id`
66
+ - SSO_SCOPES - space-separated list of scopes to request from the identity provider, defaults to `openid`
67
+ - SSO_JWT_ALGO - algorithm to use for verifying IDP-provided JWTs, defaults to `ES256`
68
+ - SSO_USE_USERINFO - if set to `true`, fetch additional claims from the userinfo endpoint when an access token is available
69
+ """
70
+
71
+ # NOTE: the config follows OIDC specification, but makes a few concessions
72
+ # to be more lenient with the internal IDP. These are marked with CONCESSION comments.
73
+
74
+ required_routes: ClassVar[list[ApiRoute]] = [sso_callback]
75
+
76
+ component_config: ClassVar[AuthComponentConfig] = AuthComponentConfig(
77
+ login=OIDCAuthLogin,
78
+ logout=OIDCAuthLogout,
79
+ extra={
80
+ 'sso-callback': OIDCAuthSSOCallback,
81
+ },
82
+ )
83
+
84
+ client: httpx.AsyncClient = Field(default_factory=httpx.AsyncClient, exclude=True)
85
+
86
+ model_config = ConfigDict(arbitrary_types_allowed=True)
87
+
88
+ # Populated during startup_hook
89
+ _discovery: OIDCDiscoveryMetadata | None = None
90
+
91
+ @property
92
+ def discovery(self) -> OIDCDiscoveryMetadata:
93
+ """Get the OIDC discovery metadata. Raises if not initialized."""
94
+ if self._discovery is None:
95
+ raise RuntimeError('OIDC discovery metadata not initialized. Ensure startup_hook has been called.')
96
+ return self._discovery
97
+
98
+ @property
99
+ def allowed_groups(self):
100
+ # initialise user groups from ENV
101
+ env_groups = get_oidc_settings().groups
102
+ parsed_groups = env_groups.split(',')
103
+ return {group.strip(): UserGroup(name=group.strip()) for group in parsed_groups}
104
+
105
+ def get_discovery_url(self) -> str:
106
+ issuer_url = get_oidc_settings().issuer_url
107
+ return f'{issuer_url}/.well-known/openid-configuration'
108
+
109
+ async def startup_hook(self) -> None:
110
+ await self.client.__aenter__()
111
+
112
+ # 1. Enforce SSO env vars are set - this will run validation and raise if not set
113
+ get_settings.cache_clear()
114
+ get_settings()
115
+ get_oidc_settings.cache_clear()
116
+ oidc_settings = get_oidc_settings()
117
+
118
+ # 2. Fetch OIDC discovery document
119
+ discovery_url = self.get_discovery_url()
120
+ dev_logger.info(f'Fetching OIDC discovery document from {discovery_url}...')
121
+ try:
122
+ response = await self.client.get(discovery_url)
123
+ response.raise_for_status()
124
+ except httpx.HTTPStatusError as e:
125
+ raise RuntimeError(
126
+ f'Failed to fetch OIDC discovery document from {discovery_url}: HTTP {e.response.status_code}'
127
+ ) from e
128
+ except httpx.RequestError as e:
129
+ raise RuntimeError(f'Failed to fetch OIDC discovery document from {discovery_url}: {e}') from e
130
+
131
+ try:
132
+ self._discovery = OIDCDiscoveryMetadata.model_validate(response.json())
133
+ except Exception as e:
134
+ raise RuntimeError(f'Failed to parse OIDC discovery document from {discovery_url}: {e}') from e
135
+
136
+ dev_logger.info(f'Successfully fetched OIDC discovery document from {discovery_url}')
137
+
138
+ # 3. Register a PyJWKClient instance bound to the jwks_uri from discovery
139
+ from dara.core.internal.registries import utils_registry
140
+
141
+ py_jwk_client = PyJWKClient(self.discovery.jwks_uri, lifespan=oidc_settings.jwks_lifespan)
142
+ utils_registry.register(JWK_CLIENT_REGISTRY_KEY, py_jwk_client)
143
+
144
+ def generate_state(self, redirect_to: str | None = None) -> str:
145
+ """
146
+ Generate a signed JWT state parameter for CSRF protection.
147
+
148
+ The state is a JWT signed with the application's secret containing:
149
+ - nonce: cryptographically random value for uniqueness
150
+ - redirect_to: optional URL to redirect to after authentication
151
+ - exp: expiration timestamp
152
+
153
+ :param redirect_to: Optional URL to redirect to after successful authentication
154
+ :return: Signed JWT string to use as the state parameter
155
+ """
156
+ payload = StateObject(redirect_to=redirect_to)
157
+ return jwt.encode(payload.model_dump(), get_settings().jwt_secret, algorithm=JWT_ALGO)
158
+
159
+ def verify_state(self, state: str) -> StateObject:
160
+ """
161
+ Verify and decode the state JWT.
162
+
163
+ :param state: The state JWT string from the callback
164
+ :return: Decoded payload containing nonce and optional redirect_to
165
+ :raises jwt.ExpiredSignatureError: If the state has expired
166
+ :raises jwt.InvalidTokenError: If the state is invalid
167
+ """
168
+ return StateObject.model_validate(jwt.decode(state, get_settings().jwt_secret, algorithms=[JWT_ALGO]))
169
+
170
+ def get_authorization_params(self, state: str) -> dict[str, str]:
171
+ """
172
+ Build the query parameters for the authorization request per OpenID Connect Core 1.0 Section 3.1.2.1.
173
+
174
+ Required parameters:
175
+ - scope: Must contain 'openid', may contain additional scopes (from SSO_SCOPES setting)
176
+ - response_type: 'code' for Authorization Code Flow
177
+ - client_id: OAuth 2.0 Client Identifier (from SSO_CLIENT_ID setting)
178
+ - redirect_uri: Redirection URI for the response (from SSO_REDIRECT_URI setting)
179
+
180
+ Recommended parameters:
181
+ - state: Opaque value for CSRF protection (signed JWT containing nonce and optional redirect URL)
182
+
183
+ Override this method to add optional parameters like nonce, display, prompt, max_age, etc.
184
+ """
185
+ oidc_settings = get_oidc_settings()
186
+ return {
187
+ 'scope': oidc_settings.scopes,
188
+ 'response_type': 'code',
189
+ 'client_id': oidc_settings.client_id,
190
+ 'redirect_uri': oidc_settings.redirect_uri,
191
+ 'state': state,
192
+ }
193
+
194
+ def get_authorization_url(self, state: str) -> str:
195
+ """
196
+ Build the full authorization URL using the discovery document's authorization_endpoint.
197
+ """
198
+ params = self.get_authorization_params(state)
199
+ return f'{self.discovery.authorization_endpoint}?{urlencode(params)}'
200
+
201
+ def get_token(self, body: SessionRequestBody) -> TokenResponse | RedirectResponse:
202
+ """
203
+ Get token from the IDP - redirect to the authorization endpoint.
204
+
205
+ Generates a signed JWT state parameter containing a nonce for CSRF protection
206
+ and optionally the redirect URL for post-authentication navigation.
207
+
208
+ :param body: Request body, may contain redirect_to for post-auth navigation
209
+ """
210
+ state = self.generate_state(redirect_to=body.redirect_to)
211
+ return RedirectResponse(redirect_uri=self.get_authorization_url(state))
212
+
213
+ async def fetch_userinfo(self, access_token: str) -> dict | None:
214
+ """
215
+ Fetch user information from the OIDC userinfo endpoint.
216
+
217
+ Per OpenID Connect Core 1.0 Section 5.3, the userinfo endpoint returns claims
218
+ about the authenticated user. This is useful when the ID token doesn't contain
219
+ all required claims.
220
+
221
+ :param access_token: The access token to authenticate the request
222
+ :return: Dictionary of userinfo claims, or None if the request fails
223
+ """
224
+ userinfo_endpoint = self.discovery.userinfo_endpoint
225
+ if not userinfo_endpoint:
226
+ dev_logger.warning('Userinfo endpoint not available in OIDC discovery')
227
+ return None
228
+
229
+ try:
230
+ response = await self.client.get(
231
+ userinfo_endpoint,
232
+ headers={'Authorization': f'Bearer {access_token}'},
233
+ timeout=10,
234
+ )
235
+ response.raise_for_status()
236
+ return response.json()
237
+ except httpx.HTTPStatusError as e:
238
+ dev_logger.warning(
239
+ f'Failed to fetch userinfo: HTTP {e.response.status_code}',
240
+ )
241
+ return None
242
+ except httpx.RequestError as e:
243
+ dev_logger.warning(f'Failed to fetch userinfo: {e}')
244
+ return None
245
+
246
+ def extract_user_data(self, claims: IdTokenClaims, userinfo: dict | None = None) -> UserData:
247
+ """
248
+ Extract user data from ID token claims and optional userinfo response.
249
+
250
+ Override this method in subclasses to handle provider-specific claim structures.
251
+ The default implementation uses standard OIDC claims, with support for the
252
+ non-standard 'identity' claim. When userinfo is provided and SSO_USE_USERINFO
253
+ is enabled, userinfo claims take precedence over ID token claims.
254
+
255
+ :param claims: Decoded ID token claims
256
+ :param userinfo: Optional userinfo response from the userinfo endpoint
257
+ :return: UserData extracted from the claims
258
+ """
259
+ oidc_settings = get_oidc_settings()
260
+
261
+ # When userinfo is provided and use_userinfo is enabled, prefer userinfo claims
262
+ if userinfo and oidc_settings.use_userinfo:
263
+ # userinfo 'sub' must match id_token 'sub' per OIDC spec
264
+ identity_id = userinfo.get('sub') or claims.sub
265
+ identity_email = userinfo.get('email') or claims.email
266
+ identity_name = (
267
+ userinfo.get('name')
268
+ or userinfo.get('preferred_username')
269
+ or userinfo.get('nickname')
270
+ or (
271
+ f'{userinfo.get("given_name", "")} {userinfo.get("family_name", "")}'.strip()
272
+ if userinfo.get('given_name') or userinfo.get('family_name')
273
+ else None
274
+ )
275
+ )
276
+ groups = userinfo.get('groups') or claims.groups
277
+ else:
278
+ # Check for non-standard 'identity' claim (Causalens IDP)
279
+ # This is a nested object with id, name, email fields
280
+ identity_claim = getattr(claims, 'identity', None)
281
+ if isinstance(identity_claim, dict):
282
+ identity_id = identity_claim.get('id') or claims.sub
283
+ identity_name = identity_claim.get('name')
284
+ identity_email = identity_claim.get('email') or claims.email
285
+ else:
286
+ # Standard OIDC: use 'sub' as the identity ID
287
+ identity_id = claims.sub
288
+ identity_email = claims.email
289
+ identity_name = None
290
+ groups = claims.groups
291
+
292
+ # Fall back to standard claims for name if not set
293
+ if not identity_name:
294
+ identity_name = (
295
+ claims.name
296
+ or claims.preferred_username
297
+ or claims.nickname
298
+ or (
299
+ f'{claims.given_name} {claims.family_name}'.strip()
300
+ if claims.given_name or claims.family_name
301
+ else None
302
+ )
303
+ or identity_email
304
+ or claims.sub
305
+ )
306
+
307
+ return UserData(
308
+ identity_id=identity_id,
309
+ identity_name=identity_name,
310
+ identity_email=identity_email,
311
+ groups=groups,
312
+ )
313
+
314
+ def verify_token(self, token: str) -> TokenData:
315
+ """
316
+ Verify a session token.
317
+
318
+ Handles both:
319
+ 1. Dara-issued session tokens (wrapped tokens signed with jwt_secret)
320
+ 2. Raw IDP tokens (ID tokens signed by the OIDC provider)
321
+
322
+ Sets SESSION_ID, USER, and ID_TOKEN context variables.
323
+
324
+ :param token: encoded JWT token (either Dara session token or raw IDP token)
325
+ :return: TokenData for the verified token
326
+ """
327
+ # First, decode without verification to check the issuer
328
+ try:
329
+ unverified = jwt.decode(token, options={'verify_signature': False})
330
+ except jwt.DecodeError as e:
331
+ raise AuthError(code=401, detail=INVALID_TOKEN_ERROR) from e
332
+
333
+ # Check if this is a raw IDP token (issuer matches the configured SSO issuer)
334
+ if unverified.get('iss') == get_oidc_settings().issuer_url:
335
+ return self._verify_idp_token(token)
336
+ else:
337
+ return self._verify_dara_token(token)
338
+
339
+ def _verify_idp_token(self, token: str) -> TokenData:
340
+ """
341
+ Verify a raw ID token from the IDP.
342
+
343
+ :param token: Raw ID token JWT
344
+ :return: TokenData extracted from the ID token
345
+ """
346
+ # Decode and verify the ID token signature using JWKS
347
+ claims = decode_id_token(token)
348
+
349
+ # Extract user data (can be overridden for provider-specific claim structures)
350
+ user_data = self.extract_user_data(claims)
351
+
352
+ # Verify user has access based on groups
353
+ self.verify_user_access(user_data)
354
+
355
+ # Set context variables
356
+ SESSION_ID.set(user_data.identity_id)
357
+ USER.set(user_data)
358
+ ID_TOKEN.set(token)
359
+
360
+ # Return TokenData structure
361
+ return TokenData(
362
+ session_id=user_data.identity_id,
363
+ exp=claims.exp,
364
+ identity_id=user_data.identity_id,
365
+ identity_name=user_data.identity_name,
366
+ identity_email=user_data.identity_email,
367
+ id_token=token,
368
+ groups=user_data.groups,
369
+ )
370
+
371
+ def _verify_dara_token(self, token: str) -> TokenData:
372
+ """
373
+ Verify a Dara-issued session token.
374
+
375
+ :param token: Dara session token JWT
376
+ :return: TokenData from the decoded token
377
+ """
378
+ # Decode and verify with our jwt_secret
379
+ token_data = decode_token(token)
380
+
381
+ user_data = UserData.from_token_data(token_data)
382
+
383
+ # Verify user has access based on groups
384
+ self.verify_user_access(user_data)
385
+
386
+ # Set context variables
387
+ SESSION_ID.set(token_data.session_id)
388
+ USER.set(user_data)
389
+ ID_TOKEN.set(token_data.id_token)
390
+
391
+ return token_data
392
+
393
+ def verify_user_access(self, user_data: UserData) -> None:
394
+ """
395
+ Verify that the user has access based on their groups.
396
+
397
+ :param user_groups: List of groups the user belongs to
398
+ :raises HTTPException: If user doesn't have access
399
+ """
400
+ # Identity verification enabled
401
+ if (allowed_identity_id := get_oidc_settings().allowed_identity_id) is not None:
402
+ identity_id = user_data.identity_id
403
+ if identity_id != allowed_identity_id:
404
+ dev_logger.error(
405
+ 'User identity does not have access to this app',
406
+ error=Exception(UNAUTHORIZED_ERROR),
407
+ extra={
408
+ 'identity_id': identity_id,
409
+ },
410
+ )
411
+ raise HTTPException(status_code=403, detail=UNAUTHORIZED_ERROR)
412
+
413
+ allowed_groups = set(self.allowed_groups.keys())
414
+ user_group_set = set(user_data.groups or [])
415
+
416
+ # Check if there's any intersection between allowed and user groups
417
+ if not allowed_groups.intersection(user_group_set):
418
+ dev_logger.error(
419
+ 'User group does not have access to this app',
420
+ error=Exception('Unauthorized'),
421
+ extra={'user_groups': user_data.groups or [], 'allowed_groups': list(allowed_groups)},
422
+ )
423
+ raise HTTPException(status_code=403, detail=UNAUTHORIZED_ERROR)
424
+
425
+ def get_token_endpoint(self) -> str:
426
+ """
427
+ Get the token endpoint URL from discovery.
428
+
429
+ :return: Token endpoint URL
430
+ :raises RuntimeError: If token_endpoint is not available in discovery
431
+ """
432
+ return self.discovery.token_endpoint
433
+
434
+ async def refresh_token(self, old_token: TokenData, refresh_token: str) -> tuple[str, str]:
435
+ """
436
+ Refresh the session using an OIDC refresh token.
437
+
438
+ Per RFC 6749 Section 6, sends a refresh token grant to the token endpoint
439
+ to obtain new access/id tokens.
440
+
441
+ Note: the new issued session token includes the same session_id as the old token
442
+ to maintain session continuity.
443
+
444
+ :param old_token: Old session token data (used to preserve session_id)
445
+ :param refresh_token: OIDC refresh token
446
+ :return: Tuple of (new_session_token, new_refresh_token)
447
+ :raises HTTPException: If the refresh fails
448
+ """
449
+ oidc_settings = get_oidc_settings()
450
+
451
+ # Request new tokens from the IDP
452
+ oidc_tokens = await get_token_from_idp(
453
+ self,
454
+ {
455
+ 'grant_type': 'refresh_token',
456
+ 'refresh_token': refresh_token,
457
+ },
458
+ )
459
+
460
+ # Ensure we got an id_token back
461
+ if not oidc_tokens.id_token:
462
+ raise HTTPException(status_code=401, detail=INVALID_TOKEN_ERROR)
463
+
464
+ # Decode and verify the new ID token
465
+ claims = decode_id_token(oidc_tokens.id_token)
466
+
467
+ # Fetch userinfo if enabled and we have an access token
468
+ userinfo = None
469
+ if oidc_settings.use_userinfo and oidc_tokens.access_token:
470
+ userinfo = await self.fetch_userinfo(oidc_tokens.access_token)
471
+
472
+ # Extract user data from claims
473
+ user_data = self.extract_user_data(claims, userinfo=userinfo)
474
+
475
+ # Verify user still has access
476
+ self.verify_user_access(user_data)
477
+
478
+ # Create a new Dara session token, preserving the original session_id
479
+ new_session_token = sign_jwt(
480
+ identity_id=user_data.identity_id,
481
+ identity_name=user_data.identity_name,
482
+ identity_email=user_data.identity_email,
483
+ groups=user_data.groups or [],
484
+ id_token=oidc_tokens.id_token,
485
+ exp=int(claims.exp),
486
+ session_id=old_token.session_id,
487
+ )
488
+
489
+ # Return new session token and refresh token (or the old one if not rotated)
490
+ new_refresh_token = oidc_tokens.refresh_token or refresh_token
491
+
492
+ return new_session_token, new_refresh_token
493
+
494
+ def get_end_session_endpoint(self) -> str | None:
495
+ """
496
+ Get the end session endpoint URL.
497
+
498
+ Uses the end_session_endpoint from OIDC discovery if available.
499
+
500
+ Override this method in subclasses to customize the logout endpoint.
501
+ """
502
+ return self.discovery.end_session_endpoint
503
+
504
+ def get_logout_params(self, id_token: str | None) -> dict[str, str]:
505
+ """
506
+ Build the query parameters for the logout/end session request.
507
+
508
+ Per OpenID Connect RP-Initiated Logout 1.0:
509
+ - id_token_hint: RECOMMENDED. ID Token previously issued by the OP, used as a hint
510
+ about the End-User's current authenticated session.
511
+ - client_id: OPTIONAL. OAuth 2.0 Client Identifier. Required if id_token_hint is not provided.
512
+ - post_logout_redirect_uri: OPTIONAL. URI to redirect to after logout.
513
+ - state: OPTIONAL. Opaque value for maintaining state.
514
+
515
+ Override this method to add custom parameters like post_logout_redirect_uri.
516
+
517
+ :param id_token: The ID token to use as a hint, or None if not available
518
+ :return: Dictionary of query parameters
519
+ """
520
+ oidc_settings = get_oidc_settings()
521
+ params: dict[str, str] = {}
522
+
523
+ if id_token:
524
+ params['id_token_hint'] = id_token
525
+
526
+ # Include client_id if we're verifying audience, or if no id_token_hint is provided
527
+ if oidc_settings.verify_audience or not id_token:
528
+ params['client_id'] = oidc_settings.client_id
529
+
530
+ return params
531
+
532
+ def get_logout_url(self, id_token: str | None = None) -> str | None:
533
+ """
534
+ Build the full logout URL for RP-Initiated Logout.
535
+
536
+ :param id_token: The ID token to use as a hint, or None if not available
537
+ :return: Full logout URL with query parameters
538
+ """
539
+ endpoint = self.get_end_session_endpoint()
540
+
541
+ if not endpoint:
542
+ return None
543
+
544
+ params = self.get_logout_params(id_token)
545
+
546
+ if params:
547
+ return f'{endpoint}?{urlencode(params)}'
548
+ return endpoint
549
+
550
+ def revoke_token(self, token: str, response: Response) -> SuccessResponse | RedirectResponse:
551
+ """
552
+ Revoke the session and redirect to the OP's end session endpoint.
553
+
554
+ Per OpenID Connect RP-Initiated Logout 1.0, this initiates logout at the OP
555
+ by redirecting to the end_session_endpoint with the id_token_hint.
556
+
557
+ :param token: Session token to revoke (Dara-issued or raw IDP token)
558
+ :param response: FastAPI response object
559
+ :return: RedirectResponse to the logout URL
560
+ """
561
+ oidc_settings = get_oidc_settings()
562
+
563
+ # Clean up the refresh token cookie
564
+ response.delete_cookie(REFRESH_TOKEN_COOKIE_NAME)
565
+
566
+ # Extract the ID token to use as a hint
567
+ id_token: str | None = None
568
+
569
+ try:
570
+ # Decode without verification to check the issuer
571
+ unverified = jwt.decode(token, options={'verify_signature': False})
572
+
573
+ # Raw IDP token -> use directly as the id_token_hint
574
+ # Dara-issued token -> extract the embedded id_token
575
+ id_token = token if unverified.get('iss') == oidc_settings.issuer_url else unverified.get('id_token')
576
+ except jwt.DecodeError:
577
+ # If we can't decode the token, proceed without id_token_hint
578
+ dev_logger.warning('Failed to decode token for logout, proceeding without id_token_hint')
579
+
580
+ logout_url = self.get_logout_url(id_token)
581
+
582
+ # IDP doesn't support RP-Initiated Logout, so treat logout as success
583
+ if not logout_url:
584
+ return {'success': True}
585
+
586
+ return RedirectResponse(redirect_uri=logout_url)