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,312 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from secrets import token_urlsafe
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ JWK_CLIENT_REGISTRY_KEY = 'PyJWKClient'
7
+
8
+ REFRESH_TOKEN_COOKIE_NAME = 'dara_refresh_token'
9
+
10
+
11
+ class AuthCodeRequestBody(BaseModel):
12
+ """Request body for the SSO callback endpoint."""
13
+
14
+ auth_code: str
15
+ """The authorization code received from the IDP"""
16
+
17
+ state: str | None = None
18
+ """The state parameter for CSRF validation (optional for backward compatibility)"""
19
+
20
+
21
+ class OIDCDiscoveryMetadata(BaseModel):
22
+ """
23
+ OpenID Provider Metadata as defined in OpenID Connect Discovery 1.0.
24
+ https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
25
+ """
26
+
27
+ issuer: str = Field(
28
+ ...,
29
+ description='REQUIRED. URL using the https scheme with no query or fragment components that the OP asserts as its Issuer Identifier. If Issuer discovery is supported, this value MUST be identical to the issuer value returned by WebFinger. This also MUST be identical to the iss Claim value in ID Tokens issued from this Issuer.',
30
+ )
31
+
32
+ authorization_endpoint: str = Field(
33
+ ...,
34
+ description="REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint. This URL MUST use the https scheme and MAY contain port, path, and query parameter components.",
35
+ )
36
+
37
+ token_endpoint: str = Field(
38
+ ...,
39
+ description="URL of the OP's OAuth 2.0 Token Endpoint. This is REQUIRED unless only the Implicit Flow is used. This URL MUST use the https scheme and MAY contain port, path, and query parameter components. Dara relies on the token flow so this is required.",
40
+ )
41
+
42
+ userinfo_endpoint: str | None = Field(
43
+ default=None,
44
+ description="RECOMMENDED. URL of the OP's UserInfo Endpoint. This URL MUST use the https scheme and MAY contain port, path, and query parameter components.",
45
+ )
46
+
47
+ jwks_uri: str = Field(
48
+ ...,
49
+ description="REQUIRED. URL of the OP's JWK Set document, which MUST use the https scheme. This contains the signing key(s) the RP uses to validate signatures from the OP. The JWK Set MAY also contain the Server's encryption key(s), which are used by RPs to encrypt requests to the Server.",
50
+ )
51
+
52
+ registration_endpoint: str | None = Field(
53
+ default=None,
54
+ description="RECOMMENDED. URL of the OP's Dynamic Client Registration Endpoint, which MUST use the https scheme.",
55
+ )
56
+
57
+ scopes_supported: list[str] | None = Field(
58
+ default=None,
59
+ description='RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that this server supports. The server MUST support the openid scope value. Servers MAY choose not to advertise some supported scope values even when this parameter is used.',
60
+ )
61
+
62
+ response_types_supported: list[str] = Field(
63
+ ...,
64
+ description='REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values that this OP supports. Dynamic OpenID Providers MUST support the code, id_token, and the id_token token Response Type values.',
65
+ )
66
+
67
+ response_modes_supported: list[str] | None = Field(
68
+ default=None,
69
+ description='OPTIONAL. JSON array containing a list of the OAuth 2.0 response_mode values that this OP supports. If omitted, the default for Dynamic OpenID Providers is ["query", "fragment"].',
70
+ )
71
+
72
+ grant_types_supported: list[str] | None = Field(
73
+ default=None,
74
+ description='OPTIONAL. JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports. Dynamic OpenID Providers MUST support the authorization_code and implicit Grant Type values and MAY support other Grant Types. If omitted, the default value is ["authorization_code", "implicit"].',
75
+ )
76
+
77
+ acr_values_supported: list[str] | None = Field(
78
+ default=None,
79
+ description='OPTIONAL. JSON array containing a list of the Authentication Context Class References that this OP supports.',
80
+ )
81
+
82
+ subject_types_supported: list[str] | None = Field(
83
+ default_factory=lambda: ['pairwise'], # default to unique identifiers if not provided
84
+ description="""
85
+ REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. Valid types include pairwise and public.
86
+ CONCESSION: Not provided by our internal IDP so marking as optional.
87
+ """,
88
+ )
89
+
90
+ id_token_signing_alg_values_supported: list[str] = Field(
91
+ ...,
92
+ description='REQUIRED. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for the ID Token to encode the Claims in a JWT. The algorithm RS256 MUST be included. The value none MAY be supported but MUST NOT be used unless the Response Type used returns no ID Token from the Authorization Endpoint.',
93
+ )
94
+
95
+ id_token_encryption_alg_values_supported: list[str] | None = Field(
96
+ default=None,
97
+ description='OPTIONAL. JSON array containing a list of the JWE encryption algorithms (alg values) supported by the OP for the ID Token to encode the Claims in a JWT.',
98
+ )
99
+
100
+ id_token_encryption_enc_values_supported: list[str] | None = Field(
101
+ default=None,
102
+ description='OPTIONAL. JSON array containing a list of the JWE encryption algorithms (enc values) supported by the OP for the ID Token to encode the Claims in a JWT.',
103
+ )
104
+
105
+ userinfo_signing_alg_values_supported: list[str] | None = Field(
106
+ default=None,
107
+ description='OPTIONAL. JSON array containing a list of the JWS signing algorithms (alg values) supported by the UserInfo Endpoint to encode the Claims in a JWT. The value none MAY be included.',
108
+ )
109
+
110
+ userinfo_encryption_alg_values_supported: list[str] | None = Field(
111
+ default=None,
112
+ description='OPTIONAL. JSON array containing a list of the JWE encryption algorithms (alg values) supported by the UserInfo Endpoint to encode the Claims in a JWT.',
113
+ )
114
+
115
+ userinfo_encryption_enc_values_supported: list[str] | None = Field(
116
+ default=None,
117
+ description='OPTIONAL. JSON array containing a list of the JWE encryption algorithms (enc values) supported by the UserInfo Endpoint to encode the Claims in a JWT.',
118
+ )
119
+
120
+ request_object_signing_alg_values_supported: list[str] | None = Field(
121
+ default=None,
122
+ description='OPTIONAL. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for Request Objects. These algorithms are used both when the Request Object is passed by value (using the request parameter) and when it is passed by reference (using the request_uri parameter). Servers SHOULD support none and RS256.',
123
+ )
124
+
125
+ request_object_encryption_alg_values_supported: list[str] | None = Field(
126
+ default=None,
127
+ description='OPTIONAL. JSON array containing a list of the JWE encryption algorithms (alg values) supported by the OP for Request Objects. These algorithms are used both when the Request Object is passed by value and when it is passed by reference.',
128
+ )
129
+
130
+ request_object_encryption_enc_values_supported: list[str] | None = Field(
131
+ default=None,
132
+ description='OPTIONAL. JSON array containing a list of the JWE encryption algorithms (enc values) supported by the OP for Request Objects. These algorithms are used both when the Request Object is passed by value and when it is passed by reference.',
133
+ )
134
+
135
+ token_endpoint_auth_methods_supported: list[str] | None = Field(
136
+ default=None,
137
+ description='OPTIONAL. JSON array containing a list of Client Authentication methods supported by this Token Endpoint. The options are client_secret_post, client_secret_basic, client_secret_jwt, and private_key_jwt. Other authentication methods MAY be defined by extensions. If omitted, the default is client_secret_basic.',
138
+ )
139
+
140
+ token_endpoint_auth_signing_alg_values_supported: list[str] | None = Field(
141
+ default=None,
142
+ description='OPTIONAL. JSON array containing a list of the JWS signing algorithms (alg values) supported by the Token Endpoint for the signature on the JWT used to authenticate the Client at the Token Endpoint for the private_key_jwt and client_secret_jwt authentication methods. Servers SHOULD support RS256. The value none MUST NOT be used.',
143
+ )
144
+
145
+ display_values_supported: list[str] | None = Field(
146
+ default=None,
147
+ description='OPTIONAL. JSON array containing a list of the display parameter values that the OpenID Provider supports.',
148
+ )
149
+
150
+ claim_types_supported: list[str] | None = Field(
151
+ default=None,
152
+ description='OPTIONAL. JSON array containing a list of the Claim Types that the OpenID Provider supports. Values defined by this specification are normal, aggregated, and distributed. If omitted, the implementation supports only normal Claims.',
153
+ )
154
+
155
+ claims_supported: list[str] | None = Field(
156
+ default=None,
157
+ description='RECOMMENDED. JSON array containing a list of the Claim Names of the Claims that the OpenID Provider MAY be able to supply values for. Note that for privacy or other reasons, this might not be an exhaustive list.',
158
+ )
159
+
160
+ service_documentation: str | None = Field(
161
+ default=None,
162
+ description='OPTIONAL. URL of a page containing human-readable information that developers might want or need to know when using the OpenID Provider. In particular, if the OpenID Provider does not support Dynamic Client Registration, then information on how to register Clients needs to be provided in this documentation.',
163
+ )
164
+
165
+ claims_locales_supported: list[str] | None = Field(
166
+ default=None,
167
+ description='OPTIONAL. Languages and scripts supported for values in Claims being returned, represented as a JSON array of BCP47 language tag values. Not all languages and scripts are necessarily supported for all Claim values.',
168
+ )
169
+
170
+ ui_locales_supported: list[str] | None = Field(
171
+ default=None,
172
+ description='OPTIONAL. Languages and scripts supported for the user interface, represented as a JSON array of BCP47 language tag values.',
173
+ )
174
+
175
+ claims_parameter_supported: bool | None = Field(
176
+ default=None,
177
+ description='OPTIONAL. Boolean value specifying whether the OP supports use of the claims parameter, with true indicating support. If omitted, the default value is false.',
178
+ )
179
+
180
+ request_parameter_supported: bool | None = Field(
181
+ default=None,
182
+ description='OPTIONAL. Boolean value specifying whether the OP supports use of the request parameter, with true indicating support. If omitted, the default value is false.',
183
+ )
184
+
185
+ request_uri_parameter_supported: bool | None = Field(
186
+ default=None,
187
+ description='OPTIONAL. Boolean value specifying whether the OP supports use of the request_uri parameter, with true indicating support. If omitted, the default value is true.',
188
+ )
189
+
190
+ require_request_uri_registration: bool | None = Field(
191
+ default=None,
192
+ description='OPTIONAL. Boolean value specifying whether the OP requires any request_uri values used to be pre-registered using the request_uris registration parameter. Pre-registration is REQUIRED when the value is true. If omitted, the default value is false.',
193
+ )
194
+
195
+ op_policy_uri: str | None = Field(
196
+ default=None,
197
+ description="OPTIONAL. URL that the OpenID Provider provides to the person registering the Client to read about the OP's requirements on how the Relying Party can use the data provided by the OP. The registration process SHOULD display this URL to the person registering the Client if it is given.",
198
+ )
199
+
200
+ op_tos_uri: str | None = Field(
201
+ default=None,
202
+ description="OPTIONAL. URL that the OpenID Provider provides to the person registering the Client to read about the OpenID Provider's terms of service. The registration process SHOULD display this URL to the person registering the Client if it is given.",
203
+ )
204
+
205
+ # OpenID Connect Session Management / RP-Initiated Logout fields
206
+ end_session_endpoint: str | None = Field(
207
+ default=None,
208
+ description='OPTIONAL. URL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP. This URL MUST use the https scheme and MAY contain port, path, and query parameter components.',
209
+ )
210
+
211
+ check_session_iframe: str | None = Field(
212
+ default=None,
213
+ description='OPTIONAL. URL of an OP iframe that supports cross-origin communications for session state information with the RP Client, using the HTML5 postMessage API.',
214
+ )
215
+
216
+ # Allow additional fields as per spec: "Additional OpenID Provider Metadata parameters MAY also be used"
217
+ model_config = ConfigDict(extra='allow')
218
+
219
+
220
+ class IdTokenClaims(BaseModel):
221
+ """
222
+ Standard OIDC ID Token claims as defined in OpenID Connect Core 1.0 Section 2.
223
+ https://openid.net/specs/openid-connect-core-1_0.html#IDToken
224
+
225
+ This model allows extra fields for provider-specific claims.
226
+ Subclass this to add custom claim extraction logic for specific IDPs.
227
+ """
228
+
229
+ # Required claims
230
+ iss: str = Field(..., description='Issuer Identifier')
231
+ sub: str = Field(..., description='Subject Identifier - unique identifier for the user')
232
+ aud: str | list[str] | None = Field(
233
+ default=None,
234
+ description="""
235
+ REQUIRED. Audience(s) that this ID Token is intended for.
236
+ It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value.
237
+ It MAY also contain identifiers for other audiences.
238
+ In the general case, the aud value is an array of case-sensitive strings.
239
+ In the common special case when there is one audience, the aud value MAY be a single case-sensitive string.
240
+
241
+ CONCESSION: Not provided by our internal IDP so marking as optional.
242
+ """,
243
+ )
244
+ exp: int | float = Field(..., description='Expiration time (Unix timestamp)')
245
+ iat: int | float = Field(..., description='Issued at time (Unix timestamp)')
246
+
247
+ # Optional but commonly used claims
248
+ auth_time: int | None = Field(default=None, description='Time of authentication (Unix timestamp)')
249
+ nonce: str | None = Field(default=None, description='Nonce value from the authentication request')
250
+ acr: str | None = Field(default=None, description='Authentication Context Class Reference')
251
+ amr: list[str] | None = Field(default=None, description='Authentication Methods References')
252
+ azp: str | None = Field(default=None, description='Authorized party')
253
+
254
+ # Standard profile claims (from scope: profile)
255
+ name: str | None = Field(default=None, description="End-User's full name")
256
+ given_name: str | None = Field(default=None, description="End-User's given name(s)")
257
+ family_name: str | None = Field(default=None, description="End-User's surname(s)")
258
+ middle_name: str | None = Field(default=None, description="End-User's middle name(s)")
259
+ nickname: str | None = Field(default=None, description="End-User's casual name")
260
+ preferred_username: str | None = Field(default=None, description="End-User's preferred username")
261
+ profile: str | None = Field(default=None, description='URL of the End-User profile page')
262
+ picture: str | None = Field(default=None, description='URL of the End-User profile picture')
263
+ website: str | None = Field(default=None, description='URL of the End-User web page or blog')
264
+ gender: str | None = Field(default=None, description="End-User's gender")
265
+ birthdate: str | None = Field(default=None, description="End-User's birthday (YYYY-MM-DD or YYYY)")
266
+ zoneinfo: str | None = Field(default=None, description="End-User's time zone (e.g., Europe/Paris)")
267
+ locale: str | None = Field(default=None, description="End-User's locale (e.g., en-US)")
268
+ updated_at: int | None = Field(default=None, description='Time the information was last updated (Unix timestamp)')
269
+
270
+ # Email claims (from scope: email)
271
+ email: str | None = Field(default=None, description="End-User's email address")
272
+ email_verified: bool | None = Field(default=None, description='Whether the email has been verified')
273
+
274
+ # Phone claims (from scope: phone)
275
+ phone_number: str | None = Field(default=None, description="End-User's phone number")
276
+ phone_number_verified: bool | None = Field(default=None, description='Whether the phone number has been verified')
277
+
278
+ # Address claim (from scope: address) - typically a JSON object
279
+ address: dict | None = Field(default=None, description="End-User's postal address")
280
+
281
+ # Groups claim (non-standard but common)
282
+ groups: list[str] | None = Field(default=None, description='Groups the user belongs to (non-standard claim)')
283
+
284
+ # Allow provider-specific claims
285
+ model_config = ConfigDict(extra='allow')
286
+
287
+
288
+ # Expiration time for the state JWT
289
+ STATE_EXPIRATION_MINUTES = 5
290
+
291
+
292
+ class StateObject(BaseModel):
293
+ """
294
+ State object content used by Dara when sending `state` to the authorization endpoint of the IDP
295
+ """
296
+
297
+ nonce: str = Field(
298
+ default_factory=lambda: token_urlsafe(16),
299
+ description='Nonce value',
300
+ )
301
+ iat: datetime = Field(
302
+ default_factory=lambda: datetime.now(tz=timezone.utc),
303
+ description='Issued at time',
304
+ )
305
+ exp: datetime = Field(
306
+ default_factory=lambda: datetime.now(tz=timezone.utc) + timedelta(minutes=STATE_EXPIRATION_MINUTES),
307
+ description='Expiration time',
308
+ )
309
+ redirect_to: str | None = Field(
310
+ default=None,
311
+ description='Optional redirect to URL',
312
+ )
@@ -0,0 +1,147 @@
1
+ """
2
+ Copyright 2023 Impulse Innovations Limited
3
+
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+ """
17
+
18
+ from typing import cast
19
+
20
+ import jwt
21
+ from fastapi import Depends, HTTPException, Response
22
+
23
+ from dara.core.auth.definitions import (
24
+ BAD_REQUEST_ERROR,
25
+ EXPIRED_TOKEN_ERROR,
26
+ INVALID_TOKEN_ERROR,
27
+ )
28
+ from dara.core.auth.oidc.settings import OIDCSettings, get_oidc_settings
29
+ from dara.core.auth.utils import sign_jwt
30
+ from dara.core.http import post
31
+ from dara.core.logging import dev_logger
32
+
33
+ from .definitions import REFRESH_TOKEN_COOKIE_NAME, AuthCodeRequestBody
34
+ from .utils import decode_id_token, get_token_from_idp
35
+
36
+
37
+ @post('/auth/sso-callback', authenticated=False)
38
+ async def sso_callback(
39
+ body: AuthCodeRequestBody, response: Response, oidc_settings: OIDCSettings = Depends(get_oidc_settings)
40
+ ):
41
+ """
42
+ Handle the OIDC authorization callback.
43
+
44
+ This endpoint is called after the user authenticates with the IDP. It:
45
+ 1. Validates the state parameter (CSRF protection) if provided
46
+ 2. Exchanges the authorization code for tokens at the IDP's token endpoint
47
+ 3. Verifies the ID token and extracts user information
48
+ 4. Issues a Dara session token and sets the refresh token cookie
49
+
50
+ Per OpenID Connect Core 1.0 Section 3.1.2.5 (Authorization Code Flow).
51
+
52
+ :param body: Request body containing auth_code and optional state
53
+ :param response: FastAPI response object (for setting cookies)
54
+ :param settings: Application settings
55
+ :return: Token response containing the session token
56
+ """
57
+ from dara.core.internal.registries import auth_registry
58
+
59
+ from .config import OIDCAuthConfig
60
+
61
+ # Verify the app is configured for OIDC
62
+ auth_config = auth_registry.get('auth_config')
63
+ if not isinstance(auth_config, OIDCAuthConfig):
64
+ raise HTTPException(
65
+ status_code=400,
66
+ detail=BAD_REQUEST_ERROR('Cannot use sso-callback for non-OIDC auth configuration'),
67
+ )
68
+
69
+ auth_config = cast(OIDCAuthConfig, auth_config)
70
+
71
+ # Validate state parameter if provided (CSRF protection)
72
+ if body.state:
73
+ try:
74
+ auth_config.verify_state(body.state)
75
+ except jwt.ExpiredSignatureError as e:
76
+ dev_logger.error('State parameter expired', error=e)
77
+ raise HTTPException(status_code=400, detail=BAD_REQUEST_ERROR('State parameter expired')) from e
78
+ except jwt.InvalidTokenError as e:
79
+ dev_logger.error('Invalid state parameter', error=e)
80
+ raise HTTPException(status_code=400, detail=BAD_REQUEST_ERROR('Invalid state parameter')) from e
81
+
82
+ try:
83
+ # Exchange authorization code for tokens per RFC 6749 Section 4.1.3
84
+ oidc_tokens = await get_token_from_idp(
85
+ auth_config,
86
+ {
87
+ 'grant_type': 'authorization_code',
88
+ 'code': body.auth_code,
89
+ 'redirect_uri': oidc_settings.redirect_uri,
90
+ },
91
+ )
92
+
93
+ # Ensure we received an ID token
94
+ if not oidc_tokens.id_token:
95
+ raise HTTPException(
96
+ status_code=401,
97
+ detail=INVALID_TOKEN_ERROR,
98
+ )
99
+
100
+ # Decode and verify the ID token
101
+ claims = decode_id_token(oidc_tokens.id_token)
102
+
103
+ # Fetch userinfo if enabled and we have an access token
104
+ userinfo = None
105
+ if oidc_settings.use_userinfo and oidc_tokens.access_token:
106
+ userinfo = await auth_config.fetch_userinfo(oidc_tokens.access_token)
107
+
108
+ # Extract user data from claims (handles both standard OIDC and Causalens identity claim)
109
+ user_data = auth_config.extract_user_data(claims, userinfo=userinfo)
110
+
111
+ # Verify user has access based on groups
112
+ auth_config.verify_user_access(user_data)
113
+
114
+ # Create a Dara session token wrapping the ID token data
115
+ session_token = sign_jwt(
116
+ identity_id=user_data.identity_id,
117
+ identity_name=user_data.identity_name,
118
+ identity_email=user_data.identity_email,
119
+ groups=user_data.groups or [],
120
+ id_token=oidc_tokens.id_token,
121
+ exp=int(claims.exp),
122
+ )
123
+
124
+ # Set refresh token cookie if provided
125
+ if oidc_tokens.refresh_token:
126
+ response.set_cookie(
127
+ key=REFRESH_TOKEN_COOKIE_NAME,
128
+ value=oidc_tokens.refresh_token,
129
+ secure=True,
130
+ httponly=True,
131
+ samesite='strict',
132
+ )
133
+
134
+ return {'token': session_token}
135
+
136
+ except jwt.ExpiredSignatureError as e:
137
+ dev_logger.error('Expired Token Signature', error=e)
138
+ raise HTTPException(status_code=401, detail=EXPIRED_TOKEN_ERROR) from e
139
+ except jwt.PyJWTError as e:
140
+ dev_logger.error('Invalid Token', error=e)
141
+ raise HTTPException(status_code=401, detail=INVALID_TOKEN_ERROR) from e
142
+ except HTTPException:
143
+ # Re-raise HTTP exceptions as-is
144
+ raise
145
+ except Exception as err:
146
+ dev_logger.error('Auth Error', error=err)
147
+ raise HTTPException(status_code=500, detail=BAD_REQUEST_ERROR('Authentication failed')) from err
@@ -0,0 +1,60 @@
1
+ import os
2
+ from functools import lru_cache
3
+
4
+ from pydantic import Field, model_validator
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class OIDCSettings(BaseSettings):
9
+ """
10
+ OIDC-specific settings, prefixed with SSO_.
11
+ """
12
+
13
+ # Required, using field with default=... to have pyright not complain about missing values
14
+ client_id: str = Field(default=...)
15
+ client_secret: str = Field(default=...)
16
+ redirect_uri: str = Field(default=...)
17
+ groups: str = Field(default=...)
18
+
19
+ # Optional
20
+ issuer_url: str = 'https://login.causalens.com/api/authentication'
21
+ jwks_lifespan: int = 86400 # 1 day
22
+ jwt_algo: str = 'ES256'
23
+ scopes: str = 'openid'
24
+ verify_audience: bool = False
25
+ extra_audience: list[str] | None = None
26
+ allowed_identity_id: str | None = None
27
+ use_userinfo: bool = False
28
+ """If True, fetch additional claims from the userinfo endpoint when an access token is available."""
29
+
30
+ model_config = SettingsConfigDict(env_file='.env', extra='allow', env_prefix='sso_')
31
+
32
+ @model_validator(mode='after')
33
+ def apply_audience_env_overrides(self):
34
+ """
35
+ If SSO_AUDIENCE_CLIENT_ID and SSO_AUDIENCE_CLIENT_SECRET are set,
36
+ override client_id/client_secret and enable verify_audience (unless explicitly disabled).
37
+ """
38
+ # Get directly from environment — pydantic doesn't automatically read arbitrary ones
39
+ audience_id = os.getenv('SSO_AUDIENCE_CLIENT_ID')
40
+ audience_secret = os.getenv('SSO_AUDIENCE_CLIENT_SECRET')
41
+ verify_override = os.getenv('SSO_VERIFY_AUDIENCE')
42
+
43
+ if audience_id and audience_secret and (verify_override is None or verify_override.lower() != 'false'):
44
+ self.client_id = audience_id
45
+ self.client_secret = audience_secret
46
+ self.verify_audience = True
47
+
48
+ return self
49
+
50
+
51
+ @lru_cache
52
+ def get_oidc_settings():
53
+ """
54
+ Get a cached instance of the OIDC settings, loading values from the .env if present.
55
+ """
56
+ # Test purposes - if DARA_TEST_FLAG is set then override env with .env.test
57
+ if os.environ.get('DARA_TEST_FLAG', None) is not None:
58
+ return OIDCSettings(_env_file='.env.test') # type: ignore
59
+
60
+ return OIDCSettings()
@@ -0,0 +1,162 @@
1
+ """
2
+ Copyright 2023 Impulse Innovations Limited
3
+
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from base64 import b64encode
21
+ from typing import TYPE_CHECKING
22
+
23
+ import httpx
24
+ import jwt
25
+ from fastapi import HTTPException
26
+ from pydantic import BaseModel, ConfigDict
27
+
28
+ from dara.core.auth.definitions import OTHER_AUTH_ERROR
29
+ from dara.core.logging import dev_logger
30
+
31
+ from .definitions import JWK_CLIENT_REGISTRY_KEY, IdTokenClaims
32
+ from .settings import get_oidc_settings
33
+
34
+ if TYPE_CHECKING:
35
+ from .config import OIDCAuthConfig
36
+
37
+
38
+ class OIDCTokenResponse(BaseModel):
39
+ """
40
+ Token response from the OIDC token endpoint per OIDC Core 1.0 Section 3.1.3.3
41
+ """
42
+
43
+ id_token: str
44
+
45
+ access_token: str | None = None
46
+ """
47
+ CONCESSION: Not used by Dara so accepting missing token here
48
+ """
49
+
50
+ refresh_token: str | None = None
51
+
52
+ token_type: str | None = None
53
+ """
54
+ CONCESSION: Not provided by our internal IDP so marking as optional.
55
+ Normally should be 'Bearer'
56
+ """
57
+
58
+ expires_in: int | None = None
59
+ scope: str | None = None
60
+
61
+ model_config = ConfigDict(extra='allow')
62
+
63
+
64
+ def decode_id_token(id_token: str) -> IdTokenClaims:
65
+ """
66
+ Decode and verify a JWT ID token received from an OIDC provider.
67
+
68
+ Uses the registered PyJWKClient to fetch the signing key and verify the signature.
69
+
70
+ :param id_token: The raw JWT ID token string
71
+ :return: Decoded and validated ID token claims
72
+ :raises jwt.InvalidTokenError: If the token is invalid or signature verification fails
73
+ """
74
+ from dara.core.internal.registries import utils_registry
75
+
76
+ jwks_client: jwt.PyJWKClient = utils_registry.get(JWK_CLIENT_REGISTRY_KEY)
77
+ oidc_settings = get_oidc_settings()
78
+
79
+ # Build audience list for verification if enabled
80
+ audience = None
81
+ if oidc_settings.verify_audience:
82
+ audience = [oidc_settings.client_id]
83
+ if oidc_settings.extra_audience:
84
+ audience.extend(oidc_settings.extra_audience)
85
+
86
+ # Decode and verify the token
87
+ decoded = jwt.decode(
88
+ id_token,
89
+ jwks_client.get_signing_key_from_jwt(id_token).key,
90
+ algorithms=[oidc_settings.jwt_algo],
91
+ audience=audience,
92
+ )
93
+
94
+ return IdTokenClaims.model_validate(decoded)
95
+
96
+
97
+ def handle_idp_error(response: httpx.Response) -> HTTPException:
98
+ """
99
+ Handle an error response from the IDP token endpoint.
100
+
101
+ :param response: The HTTP response from the IDP
102
+ :return: HTTPException to raise
103
+ """
104
+ exc = HTTPException(
105
+ status_code=401,
106
+ detail=OTHER_AUTH_ERROR('Identity provider authorization failed'),
107
+ )
108
+ try:
109
+ content = response.json()
110
+ except Exception:
111
+ content = response.text
112
+ dev_logger.error(
113
+ 'IDP authorization failed',
114
+ exc,
115
+ {'idp_response_content': content, 'idp_response_status': response.status_code},
116
+ )
117
+ return exc
118
+
119
+
120
+ async def get_token_from_idp(
121
+ auth_config: OIDCAuthConfig,
122
+ body: dict,
123
+ ) -> OIDCTokenResponse:
124
+ """
125
+ Request tokens from the OIDC provider's token endpoint.
126
+
127
+ Per RFC 6749 Section 4.1.3 (Authorization Code Grant) and Section 6 (Refreshing an Access Token),
128
+ the token request is sent to the token_endpoint using POST with application/x-www-form-urlencoded.
129
+
130
+ Client authentication uses HTTP Basic auth with client_id:client_secret per RFC 6749 Section 2.3.1.
131
+
132
+ :param auth_config: Current OIDC auth config (used to get token_endpoint from discovery)
133
+ :param body: Request body parameters (grant_type, code/refresh_token, redirect_uri, etc.)
134
+ :return: Token response containing access_token, id_token, refresh_token, etc.
135
+ :raises HTTPException: If the IDP returns an error
136
+ """
137
+ oidc_settings = get_oidc_settings()
138
+
139
+ # Get token endpoint from discovery
140
+ token_endpoint = auth_config.get_token_endpoint()
141
+
142
+ # Build Basic auth header: base64(client_id:client_secret)
143
+ credentials = f'{oidc_settings.client_id}:{oidc_settings.client_secret}'
144
+ encoded_credentials = b64encode(credentials.encode()).decode()
145
+
146
+ # Make the token request per RFC 6749
147
+ # Note: Using application/x-www-form-urlencoded as required by spec
148
+ response = await auth_config.client.post(
149
+ url=token_endpoint,
150
+ headers={
151
+ 'Accept': 'application/json',
152
+ 'Authorization': f'Basic {encoded_credentials}',
153
+ 'Content-Type': 'application/x-www-form-urlencoded',
154
+ },
155
+ data=body, # httpx will encode as form data
156
+ timeout=10,
157
+ )
158
+
159
+ if response.status_code >= 400:
160
+ raise handle_idp_error(response)
161
+
162
+ return OIDCTokenResponse.model_validate(response.json())