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.
- dara/core/_assets/auto_js/dara.core.umd.cjs +175 -6
- dara/core/auth/__init__.py +2 -0
- dara/core/auth/base.py +15 -3
- dara/core/auth/definitions.py +17 -0
- dara/core/auth/oidc/__init__.py +3 -0
- dara/core/auth/oidc/config.py +586 -0
- dara/core/auth/oidc/definitions.py +312 -0
- dara/core/auth/oidc/routes.py +147 -0
- dara/core/auth/oidc/settings.py +60 -0
- dara/core/auth/oidc/utils.py +162 -0
- dara/core/auth/utils.py +5 -4
- dara/core/configuration.py +6 -2
- dara/core/internal/settings.py +2 -27
- dara/core/internal/utils.py +4 -9
- dara/core/internal/websocket.py +3 -4
- {dara_core-1.22.4.dist-info → dara_core-1.23.0.dist-info}/METADATA +10 -10
- {dara_core-1.22.4.dist-info → dara_core-1.23.0.dist-info}/RECORD +20 -14
- {dara_core-1.22.4.dist-info → dara_core-1.23.0.dist-info}/LICENSE +0 -0
- {dara_core-1.22.4.dist-info → dara_core-1.23.0.dist-info}/WHEEL +0 -0
- {dara_core-1.22.4.dist-info → dara_core-1.23.0.dist-info}/entry_points.txt +0 -0
|
@@ -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())
|