Coverage for tests/oidc_client.py: 97%

133 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-09 16:45 +0100

1import dataclasses 

2import secrets 

3from collections.abc import Iterable, Sequence 

4from typing import Literal, Self, cast 

5from urllib.parse import parse_qs, urlencode, urlsplit 

6 

7import authlib 

8import pydantic 

9import requests 

10 

11 

12@dataclasses.dataclass(kw_only=True, frozen=True) 

13class ProviderConfiguration: 

14 """ 

15 https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata 

16 """ 

17 

18 @staticmethod 

19 def fetch(issuer_url: str) -> "ProviderConfiguration": 

20 # TODO: vaidate issuer url 

21 response = requests.get(f"{issuer_url}/.well-known/openid-configuration") 

22 response.raise_for_status() 

23 return ProviderConfiguration.decode(response.json()) 

24 

25 @staticmethod 

26 def decode(data: object) -> "ProviderConfiguration": 

27 # cache type adapter 

28 return pydantic.TypeAdapter(ProviderConfiguration).validate_python(data) 

29 

30 issuer: str 

31 # TODO: validate URL (scheme, no query, no fragment) 

32 """ 

33 REQUIRED. URL using the https scheme with no query or fragment components  

34 that the OP asserts as its Issuer Identifier. 

35 """ 

36 

37 authorization_endpoint: str 

38 """ 

39 REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint. 

40 """ 

41 

42 response_types_supported: Sequence[str] 

43 """ 

44 REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values  

45 that this OP supports. Dynamic OpenID Providers MUST support 'code', 'id_token', 

46 and 'id_token token'. 

47 """ 

48 

49 jwks_uri: str 

50 """ 

51 REQUIRED. URL of the OP's JWK Set document. 

52 """ 

53 

54 subject_types_supported: list[Literal["pairwise", "public"]] 

55 """ 

56 REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports. 

57 Valid types include 'pairwise' and 'public'. 

58 """ 

59 

60 id_token_signing_alg_values_supported: list[str] 

61 """ 

62 REQUIRED. JSON array containing a list of the JWS signing algorithms supported by the OP for the ID Token. 

63 The algorithm 'RS256' MUST be included. 

64 """ 

65 

66 token_endpoint: str | None = None 

67 """ 

68 URL of the OP's OAuth 2.0 Token Endpoint. 

69 This is REQUIRED unless only the Implicit Flow is used. 

70 """ 

71 

72 userinfo_endpoint: str | None = None 

73 """ 

74 RECOMMENDED. URL of the OP's UserInfo Endpoint. 

75 """ 

76 

77 registration_endpoint: str | None = None 

78 """ 

79 RECOMMENDED. URL of the OP's Dynamic Client Registration Endpoint. 

80 """ 

81 

82 scopes_supported: list[str] | None = None 

83 """ 

84 RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values  

85 that this server supports, including the 'openid' scope. 

86 """ 

87 

88 response_modes_supported: list[str] | None = None 

89 """ 

90 OPTIONAL. JSON array containing a list of the OAuth 2.0 response_mode values that this OP supports. 

91 Default for Dynamic OpenID Providers is ["query", "fragment"]. 

92 """ 

93 

94 grant_types_supported: list[Literal["authorization_code", "implicit"]] | None = None 

95 """ 

96 OPTIONAL. JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports. 

97 Dynamic OpenID Providers MUST support 'authorization_code' and 'implicit' Grant Types. 

98 """ 

99 

100 acr_values_supported: list[str] | None = None 

101 """ 

102 OPTIONAL. JSON array containing a list of the Authentication Context Class References supported by this OP. 

103 """ 

104 

105 id_token_encryption_alg_values_supported: list[str] | None = None 

106 """ 

107 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for the ID Token. 

108 """ 

109 

110 id_token_encryption_enc_values_supported: list[str] | None = None 

111 """ 

112 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for the ID Token. 

113 """ 

114 

115 userinfo_signing_alg_values_supported: list[str] | None = None 

116 """ 

117 OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the UserInfo Endpoint. 

118 """ 

119 

120 userinfo_encryption_alg_values_supported: list[str] | None = None 

121 """ 

122 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the UserInfo Endpoint. 

123 """ 

124 

125 userinfo_encryption_enc_values_supported: list[str] | None = None 

126 """ 

127 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the UserInfo Endpoint. 

128 """ 

129 

130 request_object_signing_alg_values_supported: list[str] | None = None 

131 """ 

132 OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the OP for Request Objects. 

133 """ 

134 

135 request_object_encryption_alg_values_supported: list[str] | None = None 

136 """ 

137 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for Request Objects. 

138 """ 

139 

140 request_object_encryption_enc_values_supported: list[str] | None = None 

141 """ 

142 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for Request Objects. 

143 """ 

144 

145 token_endpoint_auth_methods_supported: ( 

146 list[ 

147 Literal[ 

148 "client_secret_post", 

149 "client_secret_basic", 

150 "client_secret_jwt", 

151 "private_key_jwt", 

152 ] 

153 ] 

154 | None 

155 ) = None 

156 """ 

157 OPTIONAL. JSON array containing a list of Client Authentication methods supported by this Token Endpoint. 

158 """ 

159 

160 token_endpoint_auth_signing_alg_values_supported: list[str] | None = None 

161 """ 

162 OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the Token Endpoint for the signature. 

163 """ 

164 

165 display_values_supported: list[str] | None = None 

166 """ 

167 OPTIONAL. JSON array containing a list of the display parameter values supported by the OpenID Provider. 

168 """ 

169 

170 claim_types_supported: list[str] | None = None 

171 """ 

172 OPTIONAL. JSON array containing a list of the Claim Types that the OpenID Provider supports. 

173 """ 

174 

175 claims_supported: list[str] | None = None 

176 """ 

177 RECOMMENDED. JSON array containing a list of the Claim Names that the OpenID Provider MAY supply values for. 

178 """ 

179 

180 service_documentation: str | None = None 

181 """ 

182 OPTIONAL. URL of a page containing human-readable information that developers might need when using the OpenID Provider. 

183 """ 

184 

185 claims_locales_supported: list[str] | None = None 

186 """ 

187 OPTIONAL. Languages and scripts supported for values in Claims being returned, represented as a JSON array of language tag values. 

188 """ 

189 

190 ui_locales_supported: list[str] | None = None 

191 """ 

192 OPTIONAL. Languages and scripts supported for the user interface, represented as a JSON array of language tag values. 

193 """ 

194 

195 claims_parameter_supported: bool | None = None 

196 """ 

197 OPTIONAL. Boolean value specifying whether the OP supports the claims parameter. 

198 """ 

199 

200 request_parameter_supported: bool | None = None 

201 """ 

202 OPTIONAL. Boolean value specifying whether the OP supports the request parameter. 

203 """ 

204 

205 request_uri_parameter_supported: bool | None = None 

206 """ 

207 OPTIONAL. Boolean value specifying whether the OP supports the request_uri parameter. 

208 """ 

209 

210 require_request_uri_registration: bool | None = None 

211 """ 

212 OPTIONAL. Boolean value specifying whether the OP requires any request_uri values used to be pre-registered. 

213 """ 

214 

215 op_policy_uri: str | None = None 

216 """ 

217 OPTIONAL. URL that the OpenID Provider provides to read about the OP's requirements for the Relying Party. 

218 """ 

219 

220 op_tos_uri: str | None = None 

221 """ 

222 OPTIONAL. URL that the OpenID Provider provides to read about its terms of service. 

223 """ 

224 

225 def as_dict(self) -> dict[str, object]: 

226 return dataclasses.asdict(self) 

227 

228 

229@dataclasses.dataclass(kw_only=True, frozen=True) 

230class AuthorizationRequest: 

231 url: str 

232 state: str 

233 nonce: str | None 

234 client_id: str 

235 

236 

237def start_authorization( 

238 provider_config: ProviderConfiguration, 

239 *, 

240 client_id: str, 

241 # client_secret: str, 

242 redirect_uri: str, 

243 nonce: bool = True, 

244 scope: Iterable[str] = ("openid",), 

245) -> AuthorizationRequest: 

246 """ 

247 https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest 

248 """ 

249 # TODO: check "openid in scoe" 

250 state = secrets.token_urlsafe(16) 

251 

252 if nonce: 252 ↛ 255line 252 didn't jump to line 255 because the condition on line 252 was always true

253 nonce_value = secrets.token_urlsafe(16) 

254 else: 

255 nonce_value = None 

256 

257 query = { 

258 "state": state, 

259 "client_id": client_id, 

260 "redirect_uri": redirect_uri, 

261 "nonce": nonce_value, 

262 } 

263 

264 url = urlsplit(provider_config.authorization_endpoint) 

265 url = url._replace(query=urlencode(query)).geturl() 

266 return AuthorizationRequest( 

267 url=url, 

268 state=state, 

269 nonce=nonce_value, 

270 client_id=client_id, 

271 ) 

272 

273 

274@dataclasses.dataclass(kw_only=True, frozen=True) 

275class AuthentiationResult: 

276 access_token: str 

277 claims: dict[str, str] 

278 

279 

280@dataclasses.dataclass 

281class TokenResponse: 

282 access_token: str 

283 id_token: str 

284 

285 @classmethod 

286 def decode(cls, data: object) -> Self: 

287 # cache type adapter 

288 return pydantic.TypeAdapter(cls).validate_python(data) 

289 

290 

291def authenticate( 

292 openid_config: ProviderConfiguration, 

293 request: AuthorizationRequest, 

294 query_string: str, 

295) -> AuthentiationResult: 

296 # TODO: return access_token, refresh_token, etc 

297 query = parse_qs(query_string) 

298 

299 # TODO: handle errors 

300 

301 # TODO: raise exception 

302 assert query["state"] == [request.state] 

303 

304 # TODO: ValueError 

305 code = query["code"][0] 

306 

307 # TODO: ValueError 

308 assert openid_config.token_endpoint 

309 

310 response = requests.post(openid_config.token_endpoint, data={"code": code}) 

311 response.raise_for_status() 

312 token_response = TokenResponse.decode(response.json()) 

313 

314 keys = requests.get(openid_config.jwks_uri).json() 

315 keys = authlib.jose.JsonWebKey.import_key_set(keys) # type: ignore 

316 

317 claims = authlib.jose.JsonWebToken(["RS256"]).decode(token_response.id_token, keys) # type: ignore 

318 claims = cast("dict[str, str]", claims) 

319 

320 # TODO: check claims 

321 assert claims["iss"] == openid_config.issuer 

322 assert claims["aud"] == request.client_id 

323 if request.nonce is not None or "nonce" in claims: 323 ↛ 326line 323 didn't jump to line 326 because the condition on line 323 was always true

324 assert claims["nonce"] == request.nonce 

325 

326 return AuthentiationResult( 

327 access_token=token_response.access_token, 

328 claims=claims, 

329 )