mitid-connect 0.0.1__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.
- mitid_connect/__init__.py +29 -0
- mitid_connect/browser_client.py +813 -0
- mitid_connect/helpers.py +98 -0
- mitid_connect/py.typed +0 -0
- mitid_connect/srp.py +216 -0
- mitid_connect-0.0.1.dist-info/METADATA +55 -0
- mitid_connect-0.0.1.dist-info/RECORD +10 -0
- mitid_connect-0.0.1.dist-info/WHEEL +5 -0
- mitid_connect-0.0.1.dist-info/licenses/LICENSE +22 -0
- mitid_connect-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Log in through MitID from Python: an unofficial implementation of MitID's browser client.
|
|
2
|
+
|
|
3
|
+
It authenticates with the MitID app (QR code or one-time code) or a code token, and gives back
|
|
4
|
+
the authorization code the service being logged in to exchanges for its own tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
from .browser_client import BrowserClient
|
|
10
|
+
from .helpers import (
|
|
11
|
+
choose_between_multiple_identitites,
|
|
12
|
+
generate_nem_login_parameters,
|
|
13
|
+
get_authentication_code,
|
|
14
|
+
)
|
|
15
|
+
from .srp import CustomSRP
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("mitid_connect")
|
|
19
|
+
except PackageNotFoundError: # pragma: no cover - only in a checkout that is not installed
|
|
20
|
+
__version__ = "0.0.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BrowserClient",
|
|
24
|
+
"CustomSRP",
|
|
25
|
+
"__version__",
|
|
26
|
+
"choose_between_multiple_identitites",
|
|
27
|
+
"generate_nem_login_parameters",
|
|
28
|
+
"get_authentication_code",
|
|
29
|
+
]
|
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import requests, time, hashlib, base64, hmac, qrcode, threading, json, logging
|
|
2
|
+
from .srp import CustomSRP, hex_to_bytes, bytes_to_hex, pad
|
|
3
|
+
|
|
4
|
+
_LOGGER = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFAULT_TIMEOUT = 30
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BrowserClient:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
client_hash: str,
|
|
14
|
+
authentication_session_id: str,
|
|
15
|
+
requests_session=None,
|
|
16
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
17
|
+
):
|
|
18
|
+
self.qr_display_thread_lock = threading.Lock()
|
|
19
|
+
self.session = requests_session if requests_session is not None else requests.Session()
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
|
|
22
|
+
self.client_hash = client_hash
|
|
23
|
+
self.authentication_session_id = authentication_session_id
|
|
24
|
+
|
|
25
|
+
r = self.session.get(
|
|
26
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v1/authentication-sessions/{authentication_session_id}",
|
|
27
|
+
timeout=self.timeout,
|
|
28
|
+
)
|
|
29
|
+
if r.status_code != 200:
|
|
30
|
+
_LOGGER.error(
|
|
31
|
+
f"Failed to get authentication session ({authentication_session_id}), status code {r.status_code}"
|
|
32
|
+
)
|
|
33
|
+
raise Exception(r.content)
|
|
34
|
+
|
|
35
|
+
r = r.json()
|
|
36
|
+
# This is all needed for flowValueProofs later on
|
|
37
|
+
self.broker_security_context = r["brokerSecurityContext"]
|
|
38
|
+
self.service_provider_name = r["serviceProviderName"]
|
|
39
|
+
self.reference_text_header = r["referenceTextHeader"]
|
|
40
|
+
self.reference_text_body = r["referenceTextBody"]
|
|
41
|
+
self.status_message = (
|
|
42
|
+
f"Beginning login session for {self.service_provider_name}"
|
|
43
|
+
)
|
|
44
|
+
_LOGGER.info(f"Beginning login session for {self.service_provider_name}")
|
|
45
|
+
_LOGGER.debug(f"{self.reference_text_header}")
|
|
46
|
+
_LOGGER.debug(f"{self.reference_text_body}")
|
|
47
|
+
|
|
48
|
+
def __display_qr_ascii(self, stop_event):
|
|
49
|
+
|
|
50
|
+
frame = True
|
|
51
|
+
while not stop_event.is_set():
|
|
52
|
+
# os.system("cls" if os.name == "nt" else "clear")
|
|
53
|
+
# print("Scan this QR Code in the app:")
|
|
54
|
+
qr1, qr2 = self.__get_qr_codes()
|
|
55
|
+
# print(render_qr(qr1) if frame else render_qr(qr2))
|
|
56
|
+
frame = not frame
|
|
57
|
+
stop_event.wait(1)
|
|
58
|
+
|
|
59
|
+
def __set_qr_codes(self, qr1, qr2):
|
|
60
|
+
with self.qr_display_thread_lock:
|
|
61
|
+
self.qr1 = qr1
|
|
62
|
+
self.qr2 = qr2
|
|
63
|
+
|
|
64
|
+
def __get_qr_codes(self):
|
|
65
|
+
with self.qr_display_thread_lock:
|
|
66
|
+
return self.qr1, self.qr2
|
|
67
|
+
|
|
68
|
+
def get_current_qr_codes(self):
|
|
69
|
+
"""Get current QR codes for external display (e.g., Home Assistant GUI)."""
|
|
70
|
+
try:
|
|
71
|
+
return self.__get_qr_codes()
|
|
72
|
+
except AttributeError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def start_app_authentication(self):
|
|
76
|
+
"""Start APP authentication and return poll information."""
|
|
77
|
+
self.__select_authenticator("APP")
|
|
78
|
+
|
|
79
|
+
r = self.session.post(
|
|
80
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init-auth",
|
|
81
|
+
json={},
|
|
82
|
+
timeout=self.timeout,
|
|
83
|
+
)
|
|
84
|
+
if r.status_code != 200:
|
|
85
|
+
raise Exception(f"Failed to request app login, status code {r.status_code}")
|
|
86
|
+
|
|
87
|
+
r = r.json()
|
|
88
|
+
if (
|
|
89
|
+
"errorCode" in r
|
|
90
|
+
and r["errorCode"]
|
|
91
|
+
== "auth.codeapp.authentication.parallel_sessions_detected"
|
|
92
|
+
):
|
|
93
|
+
raise Exception("Parallel app sessions detected")
|
|
94
|
+
|
|
95
|
+
self.poll_url = r["pollUrl"]
|
|
96
|
+
self.ticket = r["ticket"]
|
|
97
|
+
self.auth_status = "waiting"
|
|
98
|
+
self.status_message = "Waiting for MitID app"
|
|
99
|
+
self.otp_code = None
|
|
100
|
+
return {"poll_url": self.poll_url, "ticket": self.ticket}
|
|
101
|
+
|
|
102
|
+
def poll_app_authentication_status(self):
|
|
103
|
+
"""Poll once for APP authentication status. Returns status dict."""
|
|
104
|
+
if not hasattr(self, "poll_url"):
|
|
105
|
+
raise Exception(
|
|
106
|
+
"Authentication not started - call start_app_authentication() first"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
r = self.session.post(self.poll_url, json={"ticket": self.ticket}, timeout=self.timeout)
|
|
110
|
+
|
|
111
|
+
if r.status_code != 200:
|
|
112
|
+
return {"status": "error", "message": "Poll request failed"}
|
|
113
|
+
|
|
114
|
+
data = r.json()
|
|
115
|
+
status = data.get("status")
|
|
116
|
+
|
|
117
|
+
if status == "timeout":
|
|
118
|
+
return {"status": "waiting", "message": "Waiting for response..."}
|
|
119
|
+
|
|
120
|
+
elif status == "channel_validation_otp":
|
|
121
|
+
self.otp_code = data["channelBindingValue"]
|
|
122
|
+
self.status_message = f"Use OTP code: {self.otp_code}"
|
|
123
|
+
return {
|
|
124
|
+
"status": "otp_ready",
|
|
125
|
+
"message": self.status_message,
|
|
126
|
+
"otp_code": self.otp_code,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
elif status == "channel_validation_tqr":
|
|
130
|
+
# Generate QR codes
|
|
131
|
+
channel_binding = data["channelBindingValue"]
|
|
132
|
+
half = int(len(channel_binding) / 2)
|
|
133
|
+
|
|
134
|
+
qr_data_1 = {
|
|
135
|
+
"v": 1,
|
|
136
|
+
"p": 1,
|
|
137
|
+
"t": 2,
|
|
138
|
+
"h": channel_binding[:half],
|
|
139
|
+
"uc": data["updateCount"],
|
|
140
|
+
}
|
|
141
|
+
qr1 = qrcode.QRCode(border=1)
|
|
142
|
+
qr1.add_data(json.dumps(qr_data_1, separators=(",", ":")))
|
|
143
|
+
qr1.make()
|
|
144
|
+
|
|
145
|
+
qr_data_2 = {
|
|
146
|
+
"v": 1,
|
|
147
|
+
"p": 2,
|
|
148
|
+
"t": 2,
|
|
149
|
+
"h": channel_binding[half:],
|
|
150
|
+
"uc": data["updateCount"],
|
|
151
|
+
}
|
|
152
|
+
qr2 = qrcode.QRCode(border=1)
|
|
153
|
+
qr2.add_data(json.dumps(qr_data_2, separators=(",", ":")))
|
|
154
|
+
qr2.make()
|
|
155
|
+
|
|
156
|
+
self.__set_qr_codes(qr1, qr2)
|
|
157
|
+
self.status_message = "Scan QR code with MitID app"
|
|
158
|
+
return {"status": "qr_ready", "message": self.status_message}
|
|
159
|
+
|
|
160
|
+
elif status == "channel_verified":
|
|
161
|
+
self.status_message = "QR verified, waiting for approval"
|
|
162
|
+
return {"status": "verified", "message": self.status_message}
|
|
163
|
+
|
|
164
|
+
elif status == "OK" and data.get("confirmation") == True:
|
|
165
|
+
# Authentication completed
|
|
166
|
+
self.auth_response = data["payload"]["response"]
|
|
167
|
+
self.auth_response_signature = data["payload"]["responseSignature"]
|
|
168
|
+
return {"status": "completed", "message": "Authentication successful"}
|
|
169
|
+
|
|
170
|
+
else:
|
|
171
|
+
return {"status": "error", "message": "Authentication was not accepted"}
|
|
172
|
+
|
|
173
|
+
def complete_app_authentication(self):
|
|
174
|
+
"""Complete APP authentication after polling succeeds. Returns finalization session ID."""
|
|
175
|
+
if not hasattr(self, "auth_response"):
|
|
176
|
+
raise Exception(
|
|
177
|
+
"Authentication not completed - poll until status is 'completed'"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# SRP protocol stages
|
|
181
|
+
timer_1 = time.time()
|
|
182
|
+
SRP = CustomSRP()
|
|
183
|
+
A = SRP.SRPStage1()
|
|
184
|
+
|
|
185
|
+
r = self.session.post(
|
|
186
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init",
|
|
187
|
+
json={"randomA": {"value": A}},
|
|
188
|
+
timeout=self.timeout,
|
|
189
|
+
)
|
|
190
|
+
if r.status_code != 200:
|
|
191
|
+
raise Exception(f"Failed to init app protocol, status code {r.status_code}")
|
|
192
|
+
|
|
193
|
+
srpSalt = r.json()["srpSalt"]["value"]
|
|
194
|
+
randomB = r.json()["randomB"]["value"]
|
|
195
|
+
|
|
196
|
+
m = hashlib.sha256()
|
|
197
|
+
m.update(
|
|
198
|
+
base64.b64decode(self.auth_response)
|
|
199
|
+
+ self.current_authenticator_session_flow_key.encode("utf8")
|
|
200
|
+
)
|
|
201
|
+
password = m.hexdigest()
|
|
202
|
+
|
|
203
|
+
m1 = SRP.SRPStage3(
|
|
204
|
+
srpSalt, randomB, password, self.current_authenticator_session_id
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
unhashed_flow_value_proof = self.__create_flow_value_proof()
|
|
208
|
+
m = hashlib.sha256()
|
|
209
|
+
unhashed_flow_value_proof_key = "flowValues" + bytes_to_hex(SRP.K_bits)
|
|
210
|
+
m.update(unhashed_flow_value_proof_key.encode("utf8"))
|
|
211
|
+
hashed_flow_value_proof_key = m.hexdigest()
|
|
212
|
+
hmac_flow_value_proof = hmac.new(
|
|
213
|
+
hex_to_bytes(hashed_flow_value_proof_key),
|
|
214
|
+
unhashed_flow_value_proof,
|
|
215
|
+
hashlib.sha256,
|
|
216
|
+
).digest()
|
|
217
|
+
flow_value_proof = base64.b64encode(hmac_flow_value_proof).decode("ascii")
|
|
218
|
+
|
|
219
|
+
# Complete authentication
|
|
220
|
+
r = self.session.post(
|
|
221
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/complete",
|
|
222
|
+
json={"M1": {"value": m1}, "flowValueProof": {"value": flow_value_proof}},
|
|
223
|
+
timeout=self.timeout,
|
|
224
|
+
)
|
|
225
|
+
if r.status_code != 200:
|
|
226
|
+
raise Exception(
|
|
227
|
+
f"Failed to complete app authentication, status code {r.status_code}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
self.status_message = "App login was accepted, finalizing authentication"
|
|
231
|
+
_LOGGER.info(
|
|
232
|
+
"App login was accepted, you can now finalize authentication and receive your authorization code"
|
|
233
|
+
)
|
|
234
|
+
self.finalization_authentication_session_id = r.json()[
|
|
235
|
+
"authenticationSessionId"
|
|
236
|
+
]
|
|
237
|
+
return self.finalization_authentication_session_id
|
|
238
|
+
|
|
239
|
+
def __convert_human_authenticator_name_to_combination_id(self, authenticator_name):
|
|
240
|
+
match authenticator_name:
|
|
241
|
+
case "APP":
|
|
242
|
+
return "S3"
|
|
243
|
+
case "TOKEN":
|
|
244
|
+
return "S1"
|
|
245
|
+
case _:
|
|
246
|
+
raise Exception(f"No such authenticator name ({authenticator_name})")
|
|
247
|
+
|
|
248
|
+
def __convert_combination_id_to_human_authenticator_name(self, combination_id):
|
|
249
|
+
match combination_id:
|
|
250
|
+
case "S4": # App + MitID chip
|
|
251
|
+
return "APP"
|
|
252
|
+
case "S3":
|
|
253
|
+
return "APP"
|
|
254
|
+
case "L2":
|
|
255
|
+
return "APP"
|
|
256
|
+
case "S1":
|
|
257
|
+
return "TOKEN"
|
|
258
|
+
case _:
|
|
259
|
+
raise Exception(f"No such combination ID ({combination_id})")
|
|
260
|
+
|
|
261
|
+
def identify_as_user_and_get_available_authenticators(self, user_id):
|
|
262
|
+
self.user_id = user_id
|
|
263
|
+
r = self.session.put(
|
|
264
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v1/authentication-sessions/{self.authentication_session_id}",
|
|
265
|
+
json={"identityClaim": user_id},
|
|
266
|
+
timeout=self.timeout,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if r.status_code != 200:
|
|
270
|
+
_LOGGER.error(
|
|
271
|
+
f"Received status code ({r.status_code}) while attempting to identify as user ({user_id})"
|
|
272
|
+
)
|
|
273
|
+
if (
|
|
274
|
+
r.status_code == 400
|
|
275
|
+
and r.json()["errorCode"] == "control.identity_not_found"
|
|
276
|
+
):
|
|
277
|
+
_LOGGER.error(f"User '{user_id}' does not exist.")
|
|
278
|
+
raise Exception(r.content)
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
r.status_code == 400
|
|
282
|
+
and r.json()["errorCode"] == "control.authentication_session_not_found"
|
|
283
|
+
):
|
|
284
|
+
_LOGGER.error("Authentication session not found")
|
|
285
|
+
raise Exception(r.content)
|
|
286
|
+
|
|
287
|
+
raise Exception(r.content)
|
|
288
|
+
|
|
289
|
+
r = self.session.post(
|
|
290
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
|
|
291
|
+
json={"combinationId": ""},
|
|
292
|
+
timeout=self.timeout,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if r.status_code != 200:
|
|
296
|
+
_LOGGER.error(
|
|
297
|
+
f"Received status code ({r.status_code}) while attempting to get authenticators for user ({user_id})"
|
|
298
|
+
)
|
|
299
|
+
raise Exception(r.content)
|
|
300
|
+
|
|
301
|
+
r = r.json()
|
|
302
|
+
if (
|
|
303
|
+
r["errors"]
|
|
304
|
+
and len(r["errors"]) > 0
|
|
305
|
+
and r["errors"][0]["errorCode"] == "control.authenticator_cannot_be_started"
|
|
306
|
+
):
|
|
307
|
+
error_text = r["errors"][0]["userMessage"]["text"]["text"]
|
|
308
|
+
_LOGGER.error(
|
|
309
|
+
f"Could not get authenticators, got the following error text: {error_text}"
|
|
310
|
+
)
|
|
311
|
+
raise Exception(error_text)
|
|
312
|
+
|
|
313
|
+
self.current_authenticator_type = r["nextAuthenticator"]["authenticatorType"]
|
|
314
|
+
self.current_authenticator_session_flow_key = r["nextAuthenticator"][
|
|
315
|
+
"authenticatorSessionFlowKey"
|
|
316
|
+
]
|
|
317
|
+
self.current_authenticator_eafe_hash = r["nextAuthenticator"]["eafeHash"]
|
|
318
|
+
self.current_authenticator_session_id = r["nextAuthenticator"][
|
|
319
|
+
"authenticatorSessionId"
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
available_combinations = r["combinations"]
|
|
323
|
+
available_authenticators = {}
|
|
324
|
+
for available_combination in available_combinations:
|
|
325
|
+
available_authenticators[
|
|
326
|
+
self.__convert_combination_id_to_human_authenticator_name(
|
|
327
|
+
available_combination["id"]
|
|
328
|
+
)
|
|
329
|
+
] = available_combination["combinationItems"][0]["name"]
|
|
330
|
+
|
|
331
|
+
return available_authenticators
|
|
332
|
+
|
|
333
|
+
def __create_flow_value_proof(self):
|
|
334
|
+
hashed_broker_security_context = hashlib.sha256(
|
|
335
|
+
self.broker_security_context.encode("utf8")
|
|
336
|
+
).hexdigest()
|
|
337
|
+
base64_reference_text_header = base64.b64encode(
|
|
338
|
+
(self.reference_text_header.encode("utf8"))
|
|
339
|
+
).decode("ascii")
|
|
340
|
+
base64_reference_text_body = base64.b64encode(
|
|
341
|
+
(self.reference_text_body.encode("utf8"))
|
|
342
|
+
).decode("ascii")
|
|
343
|
+
base64_service_provider_name = base64.b64encode(
|
|
344
|
+
(self.service_provider_name.encode("utf8"))
|
|
345
|
+
).decode("ascii")
|
|
346
|
+
return f"{self.current_authenticator_session_id},{self.current_authenticator_session_flow_key},{self.client_hash},{self.current_authenticator_eafe_hash},{hashed_broker_security_context},{base64_reference_text_header},{base64_reference_text_body},{base64_service_provider_name}".encode(
|
|
347
|
+
"utf-8"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def __select_authenticator(self, authenticator_type: str):
|
|
351
|
+
if authenticator_type == self.current_authenticator_type:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
combination_id = self.__convert_human_authenticator_name_to_combination_id(
|
|
355
|
+
authenticator_type
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
r = self.session.post(
|
|
359
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
|
|
360
|
+
json={"combinationId": combination_id},
|
|
361
|
+
timeout=self.timeout,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if r.status_code != 200:
|
|
365
|
+
_LOGGER.error(
|
|
366
|
+
f"Received status code ({r.status_code}) while attempting to get authenticators for user ({self.user_id})"
|
|
367
|
+
)
|
|
368
|
+
raise Exception(r.content)
|
|
369
|
+
|
|
370
|
+
r = r.json()
|
|
371
|
+
if (
|
|
372
|
+
r["errors"]
|
|
373
|
+
and len(r["errors"]) > 0
|
|
374
|
+
and r["errors"][0]["errorCode"] == "control.authenticator_cannot_be_started"
|
|
375
|
+
):
|
|
376
|
+
error_text = r["errors"][0]["userMessage"]["text"]["text"]
|
|
377
|
+
_LOGGER.error(
|
|
378
|
+
f"Could not get authenticators, got the following error text: {error_text}"
|
|
379
|
+
)
|
|
380
|
+
raise Exception(error_text)
|
|
381
|
+
|
|
382
|
+
self.current_authenticator_type = r["nextAuthenticator"]["authenticatorType"]
|
|
383
|
+
self.current_authenticator_session_flow_key = r["nextAuthenticator"][
|
|
384
|
+
"authenticatorSessionFlowKey"
|
|
385
|
+
]
|
|
386
|
+
self.current_authenticator_eafe_hash = r["nextAuthenticator"]["eafeHash"]
|
|
387
|
+
self.current_authenticator_session_id = r["nextAuthenticator"][
|
|
388
|
+
"authenticatorSessionId"
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
if self.current_authenticator_type != authenticator_type:
|
|
392
|
+
raise Exception(
|
|
393
|
+
f"Was not able to choose the desired authenticator ({authenticator_type}), instead we received ({self.current_authenticator_type})"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def authenticate_with_token(self, token_digits: str):
|
|
397
|
+
self.__select_authenticator("TOKEN")
|
|
398
|
+
|
|
399
|
+
timer_1 = time.time()
|
|
400
|
+
SRP = CustomSRP()
|
|
401
|
+
A = SRP.SRPStage1()
|
|
402
|
+
timer_1 = time.time() - timer_1
|
|
403
|
+
|
|
404
|
+
r = self.session.post(
|
|
405
|
+
f"https://www.mitid.dk/mitid-code-token-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/codetoken-init",
|
|
406
|
+
json={"randomA": {"value": A}},
|
|
407
|
+
timeout=self.timeout,
|
|
408
|
+
)
|
|
409
|
+
if r.status_code != 200:
|
|
410
|
+
_LOGGER.error(
|
|
411
|
+
f"Failed to init TOTP code protocol, status code {r.status_code}"
|
|
412
|
+
)
|
|
413
|
+
raise Exception(r.content)
|
|
414
|
+
|
|
415
|
+
timer_2 = time.time()
|
|
416
|
+
r = r.json()
|
|
417
|
+
# pbkdfSalt is not actually used even though we receive it, what the hell are they doing here?
|
|
418
|
+
# This seems like schlock
|
|
419
|
+
# pbkdfSalt = r["pbkdf2Salt"]["value"]
|
|
420
|
+
srpSalt = r["srpSalt"]["value"]
|
|
421
|
+
randomB = r["randomB"]["value"]
|
|
422
|
+
|
|
423
|
+
m1 = SRP.SRPStage3(
|
|
424
|
+
srpSalt,
|
|
425
|
+
randomB,
|
|
426
|
+
bytes_to_hex(self.current_authenticator_session_flow_key.encode("utf-8")),
|
|
427
|
+
self.current_authenticator_session_id,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
unhashed_flow_value_proof = self.__create_flow_value_proof()
|
|
431
|
+
m = hashlib.sha256()
|
|
432
|
+
unhashed_flow_value_proof_key = "OTP" + token_digits + bytes_to_hex(SRP.K_bits)
|
|
433
|
+
m.update(unhashed_flow_value_proof_key.encode("utf8"))
|
|
434
|
+
flow_value_proof_key = m.digest()
|
|
435
|
+
|
|
436
|
+
flow_value_proof = hmac.new(
|
|
437
|
+
flow_value_proof_key, unhashed_flow_value_proof, hashlib.sha256
|
|
438
|
+
).hexdigest()
|
|
439
|
+
|
|
440
|
+
timer_2 = time.time() - timer_2
|
|
441
|
+
front_end_processing_time = int((timer_1 + timer_2) * 1000)
|
|
442
|
+
|
|
443
|
+
r = self.session.post(
|
|
444
|
+
f"https://www.mitid.dk/mitid-code-token-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/codetoken-prove",
|
|
445
|
+
json={
|
|
446
|
+
"m1": {"value": m1},
|
|
447
|
+
"flowValueProof": {"value": flow_value_proof},
|
|
448
|
+
"frontEndProcessingTime": front_end_processing_time,
|
|
449
|
+
},
|
|
450
|
+
timeout=self.timeout,
|
|
451
|
+
)
|
|
452
|
+
if r.status_code != 204:
|
|
453
|
+
_LOGGER.error(f"Failed to submit TOTP code, status code {r.status_code}")
|
|
454
|
+
raise Exception(r.content)
|
|
455
|
+
|
|
456
|
+
r = self.session.post(
|
|
457
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
|
|
458
|
+
json={"combinationId": ""},
|
|
459
|
+
timeout=self.timeout,
|
|
460
|
+
)
|
|
461
|
+
if r.status_code != 200:
|
|
462
|
+
_LOGGER.error(f"Failed to prove TOTP code, status code {r.status_code}")
|
|
463
|
+
raise Exception(r.content)
|
|
464
|
+
|
|
465
|
+
if (
|
|
466
|
+
r.json()["errors"]
|
|
467
|
+
and len(r.json()["errors"]) > 0
|
|
468
|
+
and r.json()["errors"][0]["errorCode"] == "TOTP_INVALID"
|
|
469
|
+
):
|
|
470
|
+
error_text = r.json()["errors"][0]["message"]
|
|
471
|
+
_LOGGER.error(
|
|
472
|
+
f"Could not log in with the provided TOTP code, got the following message: {error_text}"
|
|
473
|
+
)
|
|
474
|
+
raise Exception(r.content)
|
|
475
|
+
|
|
476
|
+
r = r.json()
|
|
477
|
+
if (
|
|
478
|
+
"nextAuthenticator" not in r
|
|
479
|
+
or "authenticatorType" not in r["nextAuthenticator"]
|
|
480
|
+
or r["nextAuthenticator"]["authenticatorType"] != "PASSWORD"
|
|
481
|
+
):
|
|
482
|
+
_LOGGER.error(
|
|
483
|
+
f"Ran into an unexpected situation, was expecting to be asked for password after TOTP but got the following response"
|
|
484
|
+
)
|
|
485
|
+
raise Exception(r.content)
|
|
486
|
+
|
|
487
|
+
self.current_authenticator_type = r["nextAuthenticator"]["authenticatorType"]
|
|
488
|
+
self.current_authenticator_session_flow_key = r["nextAuthenticator"][
|
|
489
|
+
"authenticatorSessionFlowKey"
|
|
490
|
+
]
|
|
491
|
+
self.current_authenticator_eafe_hash = r["nextAuthenticator"]["eafeHash"]
|
|
492
|
+
self.current_authenticator_session_id = r["nextAuthenticator"][
|
|
493
|
+
"authenticatorSessionId"
|
|
494
|
+
]
|
|
495
|
+
_LOGGER.info("Token code accepted, you now need to validate your password")
|
|
496
|
+
|
|
497
|
+
def authenticate_with_password(self, password: str):
|
|
498
|
+
if self.current_authenticator_type != "PASSWORD":
|
|
499
|
+
raise Exception(
|
|
500
|
+
f"You cannot authenticate with password before completing authentication with token code, the current authenticator type was ({self.current_authenticator_type})"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
timer_1 = time.time()
|
|
504
|
+
SRP = CustomSRP()
|
|
505
|
+
A = SRP.SRPStage1()
|
|
506
|
+
timer_1 = time.time() - timer_1
|
|
507
|
+
|
|
508
|
+
r = self.session.post(
|
|
509
|
+
f"https://www.mitid.dk/mitid-password-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/init",
|
|
510
|
+
json={"randomA": {"value": A}},
|
|
511
|
+
timeout=self.timeout,
|
|
512
|
+
)
|
|
513
|
+
if r.status_code != 200:
|
|
514
|
+
_LOGGER.error(
|
|
515
|
+
f"Failed to init password protocol, status code {r.status_code}"
|
|
516
|
+
)
|
|
517
|
+
raise Exception(r.content)
|
|
518
|
+
|
|
519
|
+
timer_2 = time.time()
|
|
520
|
+
r = r.json()
|
|
521
|
+
pbkdfSalt = r["pbkdf2Salt"]["value"]
|
|
522
|
+
srpSalt = r["srpSalt"]["value"]
|
|
523
|
+
randomB = r["randomB"]["value"]
|
|
524
|
+
|
|
525
|
+
password = hashlib.pbkdf2_hmac(
|
|
526
|
+
"sha256", password.encode("utf-8"), hex_to_bytes(pbkdfSalt), 20000, 32
|
|
527
|
+
).hex()
|
|
528
|
+
|
|
529
|
+
m1 = SRP.SRPStage3(
|
|
530
|
+
srpSalt, randomB, password, self.current_authenticator_session_id
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
unhashed_flow_value_proof = self.__create_flow_value_proof()
|
|
534
|
+
m = hashlib.sha256()
|
|
535
|
+
unhashed_flow_value_proof_key = "flowValues" + bytes_to_hex(SRP.K_bits)
|
|
536
|
+
m.update(unhashed_flow_value_proof_key.encode("utf8"))
|
|
537
|
+
flow_value_proof_key = m.digest()
|
|
538
|
+
|
|
539
|
+
flow_value_proof = hmac.new(
|
|
540
|
+
flow_value_proof_key, unhashed_flow_value_proof, hashlib.sha256
|
|
541
|
+
).hexdigest()
|
|
542
|
+
|
|
543
|
+
timer_2 = time.time() - timer_2
|
|
544
|
+
front_end_processing_time = int((timer_1 + timer_2) * 1000)
|
|
545
|
+
|
|
546
|
+
r = self.session.post(
|
|
547
|
+
f"https://www.mitid.dk/mitid-password-auth/v1/authenticator-sessions/{self.current_authenticator_session_id}/password-prove",
|
|
548
|
+
json={
|
|
549
|
+
"m1": {"value": m1},
|
|
550
|
+
"flowValueProof": {"value": flow_value_proof},
|
|
551
|
+
"frontEndProcessingTime": front_end_processing_time,
|
|
552
|
+
},
|
|
553
|
+
timeout=self.timeout,
|
|
554
|
+
)
|
|
555
|
+
if r.status_code != 204:
|
|
556
|
+
_LOGGER.error(f"Failed to submit password, status code {r.status_code}")
|
|
557
|
+
raise Exception(r.content)
|
|
558
|
+
|
|
559
|
+
r = self.session.post(
|
|
560
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
|
|
561
|
+
json={"combinationId": ""},
|
|
562
|
+
timeout=self.timeout,
|
|
563
|
+
)
|
|
564
|
+
if r.status_code != 200:
|
|
565
|
+
_LOGGER.error(f"Failed to prove password, status code {r.status_code}")
|
|
566
|
+
raise Exception(r.content)
|
|
567
|
+
|
|
568
|
+
r = r.json()
|
|
569
|
+
if r["errors"] and len(r["errors"]) > 0:
|
|
570
|
+
if r["errors"][0]["errorCode"] == "PASSWORD_INVALID":
|
|
571
|
+
error_text = r["errors"][0]["message"]
|
|
572
|
+
_LOGGER.error(
|
|
573
|
+
f"Could not log in with the provided password, got the following message: {error_text}"
|
|
574
|
+
)
|
|
575
|
+
raise Exception(error_text)
|
|
576
|
+
elif r["errors"][0]["errorCode"] == "core.psd2.error":
|
|
577
|
+
error_text = r["errors"][0]["message"]
|
|
578
|
+
_LOGGER.error(
|
|
579
|
+
f"Could not log in due to an error, probably due to a wrong password provided. Got the following message: {error_text}"
|
|
580
|
+
)
|
|
581
|
+
raise Exception(error_text)
|
|
582
|
+
else:
|
|
583
|
+
error_text = r["errors"][0]["message"]
|
|
584
|
+
_LOGGER.error(
|
|
585
|
+
f"Could not log in due to an unknown error, got the following message: {error_text}"
|
|
586
|
+
)
|
|
587
|
+
raise Exception(error_text)
|
|
588
|
+
|
|
589
|
+
self.finalization_authentication_session_id = r["nextSessionId"]
|
|
590
|
+
self.status_message = "Password accepted, finalizing authentication"
|
|
591
|
+
_LOGGER.info(
|
|
592
|
+
"Password was accepted, you can now finalize authentication and receive your authorization code"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def authenticate_with_app(self):
|
|
596
|
+
self.__select_authenticator("APP")
|
|
597
|
+
|
|
598
|
+
r = self.session.post(
|
|
599
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init-auth",
|
|
600
|
+
json={},
|
|
601
|
+
timeout=self.timeout,
|
|
602
|
+
)
|
|
603
|
+
if r.status_code != 200:
|
|
604
|
+
_LOGGER.error(f"Failed to request app login, status code {r.status_code}")
|
|
605
|
+
raise Exception(r.content)
|
|
606
|
+
|
|
607
|
+
r = r.json()
|
|
608
|
+
if (
|
|
609
|
+
"errorCode" in r
|
|
610
|
+
and r["errorCode"]
|
|
611
|
+
== "auth.codeapp.authentication.parallel_sessions_detected"
|
|
612
|
+
):
|
|
613
|
+
_LOGGER.error(
|
|
614
|
+
"Parallel app sessions detected, only a single app login session can be happening at any one time"
|
|
615
|
+
)
|
|
616
|
+
raise Exception(
|
|
617
|
+
"Parallel app sessions detected. Please wait a few minutes before trying again."
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
poll_url = r["pollUrl"]
|
|
621
|
+
ticket = r["ticket"]
|
|
622
|
+
self.status_message = "Login request has been made, open your MitID app now"
|
|
623
|
+
_LOGGER.info("Login request has been made, open your MitID app now")
|
|
624
|
+
qr_stop_event = None
|
|
625
|
+
qr_display_thread = None
|
|
626
|
+
while True:
|
|
627
|
+
r = self.session.post(poll_url, json={"ticket": ticket}, timeout=self.timeout)
|
|
628
|
+
|
|
629
|
+
if r.status_code == 200 and r.json()["status"] == "timeout":
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
if r.status_code == 200 and r.json()["status"] == "channel_validation_otp":
|
|
633
|
+
self.status_message = f"Please use the following OTP code in the app: {r.json()['channelBindingValue']}"
|
|
634
|
+
_LOGGER.info(
|
|
635
|
+
f"Please use the following OTP code in the app: {r.json()['channelBindingValue']}"
|
|
636
|
+
)
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
if r.status_code == 200 and r.json()["status"] == "channel_validation_tqr":
|
|
640
|
+
qr_data = {
|
|
641
|
+
"v": 1,
|
|
642
|
+
"p": 1,
|
|
643
|
+
"t": 2,
|
|
644
|
+
"h": r.json()["channelBindingValue"][
|
|
645
|
+
: int(len(r.json()["channelBindingValue"]) / 2)
|
|
646
|
+
],
|
|
647
|
+
"uc": r.json()["updateCount"],
|
|
648
|
+
}
|
|
649
|
+
qr1 = qrcode.QRCode(border=1)
|
|
650
|
+
qr1.add_data(json.dumps(qr_data, separators=(",", ":")))
|
|
651
|
+
qr1.make()
|
|
652
|
+
|
|
653
|
+
qr_data["p"] = 2
|
|
654
|
+
qr_data["h"] = r.json()["channelBindingValue"][
|
|
655
|
+
int(len(r.json()["channelBindingValue"]) / 2) :
|
|
656
|
+
]
|
|
657
|
+
|
|
658
|
+
qr2 = qrcode.QRCode(border=1)
|
|
659
|
+
qr2.add_data(json.dumps(qr_data, separators=(",", ":")))
|
|
660
|
+
qr2.make()
|
|
661
|
+
|
|
662
|
+
self.__set_qr_codes(qr1, qr2)
|
|
663
|
+
|
|
664
|
+
if qr_stop_event is None:
|
|
665
|
+
qr_stop_event = threading.Event()
|
|
666
|
+
qr_display_thread = threading.Thread(
|
|
667
|
+
target=self.__display_qr_ascii, args=[qr_stop_event]
|
|
668
|
+
)
|
|
669
|
+
qr_display_thread.start()
|
|
670
|
+
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
if r.status_code == 200 and r.json()["status"] == "channel_verified":
|
|
674
|
+
if qr_display_thread and qr_display_thread.is_alive():
|
|
675
|
+
qr_stop_event.set()
|
|
676
|
+
qr_display_thread.join()
|
|
677
|
+
self.status_message = "The OTP/QR code has been verified, now waiting user to approve login"
|
|
678
|
+
_LOGGER.info(
|
|
679
|
+
"The OTP/QR code has been verified, now waiting user to approve login"
|
|
680
|
+
)
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
if not (
|
|
684
|
+
r.status_code == 200
|
|
685
|
+
and r.json()["status"] == "OK"
|
|
686
|
+
and r.json()["confirmation"] == True
|
|
687
|
+
):
|
|
688
|
+
if qr_display_thread and qr_display_thread.is_alive():
|
|
689
|
+
qr_stop_event.set()
|
|
690
|
+
qr_display_thread.join()
|
|
691
|
+
_LOGGER.error("Login request was not accepted")
|
|
692
|
+
raise Exception(r.content)
|
|
693
|
+
|
|
694
|
+
break
|
|
695
|
+
|
|
696
|
+
r = r.json()
|
|
697
|
+
response = r["payload"]["response"]
|
|
698
|
+
response_signature = r["payload"]["responseSignature"]
|
|
699
|
+
|
|
700
|
+
timer_1 = time.time()
|
|
701
|
+
SRP = CustomSRP()
|
|
702
|
+
A = SRP.SRPStage1()
|
|
703
|
+
timer_1 = time.time() - timer_1
|
|
704
|
+
|
|
705
|
+
r = self.session.post(
|
|
706
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/init",
|
|
707
|
+
json={"randomA": {"value": A}},
|
|
708
|
+
timeout=self.timeout,
|
|
709
|
+
)
|
|
710
|
+
if r.status_code != 200:
|
|
711
|
+
_LOGGER.error(f"Failed to init app protocol, status code {r.status_code}")
|
|
712
|
+
raise Exception(r.content)
|
|
713
|
+
|
|
714
|
+
timer_2 = time.time()
|
|
715
|
+
srpSalt = r.json()["srpSalt"]["value"]
|
|
716
|
+
randomB = r.json()["randomB"]["value"]
|
|
717
|
+
|
|
718
|
+
m = hashlib.sha256()
|
|
719
|
+
m.update(
|
|
720
|
+
base64.b64decode(response)
|
|
721
|
+
+ self.current_authenticator_session_flow_key.encode("utf8")
|
|
722
|
+
)
|
|
723
|
+
password = m.hexdigest()
|
|
724
|
+
|
|
725
|
+
m1 = SRP.SRPStage3(
|
|
726
|
+
srpSalt, randomB, password, self.current_authenticator_session_id
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
unhashed_flow_value_proof = self.__create_flow_value_proof()
|
|
730
|
+
m = hashlib.sha256()
|
|
731
|
+
unhashed_flow_value_proof_key = "flowValues" + bytes_to_hex(SRP.K_bits)
|
|
732
|
+
m.update(unhashed_flow_value_proof_key.encode("utf8"))
|
|
733
|
+
flow_value_proof_key = m.digest()
|
|
734
|
+
|
|
735
|
+
flow_value_proof = hmac.new(
|
|
736
|
+
flow_value_proof_key, unhashed_flow_value_proof, hashlib.sha256
|
|
737
|
+
).hexdigest()
|
|
738
|
+
|
|
739
|
+
timer_2 = time.time() - timer_2
|
|
740
|
+
|
|
741
|
+
r = self.session.post(
|
|
742
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/prove",
|
|
743
|
+
json={"m1": {"value": m1}, "flowValueProof": {"value": flow_value_proof}},
|
|
744
|
+
timeout=self.timeout,
|
|
745
|
+
)
|
|
746
|
+
if r.status_code != 200:
|
|
747
|
+
_LOGGER.error(
|
|
748
|
+
f"Failed to submit app response proof, status code {r.status_code}"
|
|
749
|
+
)
|
|
750
|
+
raise Exception(r.content)
|
|
751
|
+
|
|
752
|
+
timer_3 = time.time()
|
|
753
|
+
m2 = r.json()["m2"]["value"]
|
|
754
|
+
if not SRP.SRPStage5(m2):
|
|
755
|
+
raise Exception("m2 could not be validated during proving of app response")
|
|
756
|
+
auth_enc = base64.b64encode(
|
|
757
|
+
SRP.AuthEnc(base64.b64decode(pad(response_signature)))
|
|
758
|
+
).decode("ascii")
|
|
759
|
+
timer_3 = time.time() - timer_3
|
|
760
|
+
|
|
761
|
+
front_end_processing_time = int((timer_1 + timer_2 + timer_3) * 1000)
|
|
762
|
+
|
|
763
|
+
r = self.session.post(
|
|
764
|
+
f"https://www.mitid.dk/mitid-code-app-auth/v1/authenticator-sessions/web/{self.current_authenticator_session_id}/verify",
|
|
765
|
+
json={
|
|
766
|
+
"encAuth": auth_enc,
|
|
767
|
+
"frontEndProcessingTime": front_end_processing_time,
|
|
768
|
+
},
|
|
769
|
+
timeout=self.timeout,
|
|
770
|
+
)
|
|
771
|
+
if r.status_code != 204:
|
|
772
|
+
_LOGGER.error(
|
|
773
|
+
f"Failed to verify app response signature, status code {r.status_code}"
|
|
774
|
+
)
|
|
775
|
+
raise Exception(r.content)
|
|
776
|
+
|
|
777
|
+
r = self.session.post(
|
|
778
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v2/authentication-sessions/{self.authentication_session_id}/next",
|
|
779
|
+
json={"combinationId": ""},
|
|
780
|
+
timeout=self.timeout,
|
|
781
|
+
)
|
|
782
|
+
if r.status_code != 200:
|
|
783
|
+
_LOGGER.error(f"Failed to prove app login, status code {r.status_code}")
|
|
784
|
+
raise Exception(r.content)
|
|
785
|
+
|
|
786
|
+
r = r.json()
|
|
787
|
+
if r["errors"] and len(r["errors"]) > 0:
|
|
788
|
+
_LOGGER.error(f"Could not prove the app login")
|
|
789
|
+
raise Exception("Could not prove the app login. Please try again.")
|
|
790
|
+
|
|
791
|
+
self.finalization_authentication_session_id = r["nextSessionId"]
|
|
792
|
+
self.status_message = "App login was accepted, finalizing authentication"
|
|
793
|
+
_LOGGER.info(
|
|
794
|
+
"App login was accepted, you can now finalize authentication and receive your authorization code"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def finalize_authentication_and_get_authorization_code(self):
|
|
798
|
+
if not self.finalization_authentication_session_id:
|
|
799
|
+
raise Exception(
|
|
800
|
+
"No finalization session ID set, make sure you have completed an authentication flow."
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
r = self.session.put(
|
|
804
|
+
f"https://www.mitid.dk/mitid-core-client-backend/v1/authentication-sessions/{self.finalization_authentication_session_id}/finalization",
|
|
805
|
+
timeout=self.timeout,
|
|
806
|
+
)
|
|
807
|
+
if r.status_code != 200:
|
|
808
|
+
_LOGGER.error(
|
|
809
|
+
f"Failed to retrieve authorization code, status code {r.status_code}"
|
|
810
|
+
)
|
|
811
|
+
raise Exception(r.content)
|
|
812
|
+
|
|
813
|
+
return r.json()["authorizationCode"]
|
mitid_connect/helpers.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Ready-made steps of a NemLog-in login flow around the MitID browser client."""
|
|
2
|
+
|
|
3
|
+
import binascii, base64, hashlib, logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from Cryptodome import Random
|
|
7
|
+
from requests import Response, Session
|
|
8
|
+
from .browser_client import BrowserClient
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
def get_authentication_code(
|
|
14
|
+
session: Session, aux: dict[str, Any], method: str, user_id: str, password: str | None
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Log `user_id` in with the MitID app, from NemLog-in's parameters `aux`, and give the code.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
NotImplementedError: `method` is "TOKEN", which needs the token's digits.
|
|
20
|
+
Exception: the method is unknown, or not one the user has.
|
|
21
|
+
"""
|
|
22
|
+
client_hash = binascii.hexlify(base64.b64decode(aux["coreClient"]["checksum"])).decode('ascii')
|
|
23
|
+
authentication_session_id = aux["parameters"]["authenticationSessionId"]
|
|
24
|
+
|
|
25
|
+
MitIDClient = BrowserClient(client_hash, authentication_session_id, session)
|
|
26
|
+
available_authenticators = MitIDClient.identify_as_user_and_get_available_authenticators(user_id)
|
|
27
|
+
|
|
28
|
+
_LOGGER.debug(f"Available authenticator: {available_authenticators}")
|
|
29
|
+
|
|
30
|
+
if method == "TOKEN" and "TOKEN" in available_authenticators:
|
|
31
|
+
if not password:
|
|
32
|
+
raise ValueError("Password is required for TOKEN authentication")
|
|
33
|
+
# Token digits must be provided via the caller, not via interactive input
|
|
34
|
+
raise NotImplementedError(
|
|
35
|
+
"TOKEN authentication requires token_digits parameter - interactive input not supported"
|
|
36
|
+
)
|
|
37
|
+
elif method == "APP" and "APP" in available_authenticators:
|
|
38
|
+
MitIDClient.authenticate_with_app()
|
|
39
|
+
elif method == "TOKEN" and "TOKEN" not in available_authenticators:
|
|
40
|
+
raise Exception(f"Token authentication method chosen but not available for MitID user")
|
|
41
|
+
elif method == "APP" and "APP" not in available_authenticators:
|
|
42
|
+
raise Exception(f"App authentication method chosen but not available for MitID user")
|
|
43
|
+
else:
|
|
44
|
+
raise Exception(f"Unknown authenticator method: {method}")
|
|
45
|
+
|
|
46
|
+
authorization_code = MitIDClient.finalize_authentication_and_get_authorization_code()
|
|
47
|
+
return authorization_code
|
|
48
|
+
|
|
49
|
+
def choose_between_multiple_identitites(
|
|
50
|
+
session: Session, request: Response, soup: BeautifulSoup, identity_index: int = 0
|
|
51
|
+
) -> tuple[Response, BeautifulSoup]:
|
|
52
|
+
"""Choose between multiple identities.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
session: requests session
|
|
56
|
+
request: current request object
|
|
57
|
+
soup: BeautifulSoup parsed response
|
|
58
|
+
identity_index: zero-based index of the identity to select (default: 0, first identity)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Tuple of (request, soup) after identity selection
|
|
62
|
+
"""
|
|
63
|
+
data = {}
|
|
64
|
+
for soup_input in soup.form.select("input"):
|
|
65
|
+
try:
|
|
66
|
+
data[soup_input["name"]] = soup_input["value"]
|
|
67
|
+
except KeyError:
|
|
68
|
+
data[soup_input.get("name", "")] = ""
|
|
69
|
+
login_options = soup.select("div.list-link-box")
|
|
70
|
+
_LOGGER.info('Multiple identities available:')
|
|
71
|
+
for i, login_option in enumerate(login_options):
|
|
72
|
+
_LOGGER.info(f'{i+1}: {login_option.select_one("div.list-link-text").string}')
|
|
73
|
+
|
|
74
|
+
if identity_index < 0 or identity_index >= len(login_options):
|
|
75
|
+
raise ValueError(f"Identity index {identity_index} out of range (0-{len(login_options)-1})")
|
|
76
|
+
|
|
77
|
+
selected_option = login_options[identity_index].a["data-loginoptions"]
|
|
78
|
+
data["ChosenOptionJson"] = selected_option
|
|
79
|
+
_LOGGER.info(f'Selected identity {identity_index + 1}')
|
|
80
|
+
|
|
81
|
+
request = session.post(request.url, data=data)
|
|
82
|
+
soup = BeautifulSoup(request.text, "xml")
|
|
83
|
+
return request, soup
|
|
84
|
+
|
|
85
|
+
def __generate_random_string():
|
|
86
|
+
return binascii.hexlify(Random.new().read(28)).decode("utf-8")
|
|
87
|
+
|
|
88
|
+
def __generate_challenge(verifier):
|
|
89
|
+
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("utf-8")).digest()).decode("utf-8").rstrip("=")
|
|
90
|
+
|
|
91
|
+
def generate_nem_login_parameters() -> tuple[str, str, str, str]:
|
|
92
|
+
"""A fresh state, nonce, PKCE code verifier and its S256 code challenge for NemLog-in."""
|
|
93
|
+
nem_login_state = __generate_random_string()
|
|
94
|
+
nem_login_nonce = __generate_random_string()
|
|
95
|
+
nem_login_code_verifier = __generate_random_string()
|
|
96
|
+
nem_login_code_challenge = __generate_challenge(nem_login_code_verifier)
|
|
97
|
+
|
|
98
|
+
return nem_login_state, nem_login_nonce, nem_login_code_verifier, nem_login_code_challenge
|
mitid_connect/py.typed
ADDED
|
File without changes
|
mitid_connect/srp.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""The SRP-6a exchange and AES-GCM encryption MitID's browser client speaks."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import secrets
|
|
5
|
+
import binascii
|
|
6
|
+
import hashlib
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Protocol, cast
|
|
9
|
+
|
|
10
|
+
from Cryptodome.Cipher import AES
|
|
11
|
+
from Cryptodome import Random
|
|
12
|
+
|
|
13
|
+
BLOCK_SIZE = 16
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Gcm(Protocol):
|
|
17
|
+
def encrypt_and_digest(self, plaintext: bytes) -> tuple[bytes, bytes]: ...
|
|
18
|
+
def decrypt_and_verify(self, ciphertext: bytes, received_mac_tag: bytes) -> bytes: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# pycryptodomex annotates AES.new with a bare Dict among its overloads, which a strict check reads
|
|
22
|
+
# as unknown; it is taken by name, with the type it has in GCM mode.
|
|
23
|
+
_new_gcm = cast(Callable[[bytes, int, bytes], _Gcm], getattr(AES, "new"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pad(s: str) -> str:
|
|
27
|
+
"""`s` padded to a whole number of blocks, PKCS#7 style."""
|
|
28
|
+
return s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unpad(s: str) -> str:
|
|
32
|
+
"""`s` with its PKCS#7 padding taken off."""
|
|
33
|
+
return s[:-ord(s[len(s) - 1:])]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def int_to_hex(x: int) -> str:
|
|
37
|
+
"""`x` in lower-case hex, without a prefix."""
|
|
38
|
+
return format(x, 'x')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def int_to_bytes(x: int) -> bytes:
|
|
42
|
+
"""`x` as big-endian bytes, as few as hold it."""
|
|
43
|
+
return x.to_bytes((x.bit_length() + 7) // 8, 'big')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bytes_to_int(x: bytes) -> int:
|
|
47
|
+
"""The big-endian number `x` holds."""
|
|
48
|
+
return int.from_bytes(x, 'big')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def bytes_to_hex(x: bytes) -> str:
|
|
52
|
+
"""`x` in lower-case hex."""
|
|
53
|
+
return binascii.hexlify(x).decode('utf-8')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hex_to_bytes(x: str) -> bytes:
|
|
57
|
+
"""The bytes the hex `x` spells."""
|
|
58
|
+
return binascii.unhexlify(x)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hex_to_int(x: str) -> int:
|
|
62
|
+
"""The number the hex `x` spells."""
|
|
63
|
+
return int(x, 16)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def AesDecryptWithKey(encMessage: str | bytes, key: bytes) -> bytes:
|
|
67
|
+
"""The plain text of the base64 AES-GCM message `encMessage`: IV, cipher text, then tag."""
|
|
68
|
+
encMessage = base64.b64decode(encMessage)
|
|
69
|
+
|
|
70
|
+
iv = encMessage[:BLOCK_SIZE]
|
|
71
|
+
cipherText = encMessage[BLOCK_SIZE:len(encMessage)-BLOCK_SIZE]
|
|
72
|
+
macTag = encMessage[len(encMessage)-BLOCK_SIZE:]
|
|
73
|
+
|
|
74
|
+
cipher = _new_gcm(key, AES.MODE_GCM, iv)
|
|
75
|
+
decrypted = cipher.decrypt_and_verify(cipherText, macTag)
|
|
76
|
+
return decrypted
|
|
77
|
+
|
|
78
|
+
class CustomSRP():
|
|
79
|
+
"""One SRP-6a exchange with MitID, and the session key it agrees."""
|
|
80
|
+
|
|
81
|
+
n: int
|
|
82
|
+
g: int
|
|
83
|
+
a: int
|
|
84
|
+
big_a: int
|
|
85
|
+
big_b: int
|
|
86
|
+
hashed_password: int
|
|
87
|
+
K_bits: bytes
|
|
88
|
+
M1_hex: str
|
|
89
|
+
|
|
90
|
+
def SRPStage1(self) -> str:
|
|
91
|
+
"""Pick a secret, and give the public value A, in hex, to send."""
|
|
92
|
+
self.n = 4983313092069490398852700692508795473567251422586244806694940877242664573189903192937797446992068818099986958054998012331720869136296780936009508700487789962429161515853541556719593346959929531150706457338429058926505817847524855862259333438239756474464759974189984231409170758360686392625635632084395639143229889862041528635906990913087245817959460948345336333086784608823084788906689865566621015175424691535711520273786261989851360868669067101108956159530739641990220546209432953829448997561743719584980402874346226230488627145977608389858706391858138200618631385210304429902847702141587470513336905449351327122086464725143970313054358650488241167131544692349123381333204515637608656643608393788598011108539679620836313915590459891513992208387515629240292926570894321165482608544030173975452781623791805196546326996790536207359143527182077625412731080411108775183565594553871817639221414953634530830290393130518228654795859
|
|
93
|
+
self.g = 2
|
|
94
|
+
a = secrets.randbits(256)
|
|
95
|
+
if a < 0:
|
|
96
|
+
a += self.n
|
|
97
|
+
self.a = a
|
|
98
|
+
|
|
99
|
+
self.big_a = pow(self.g, self.a, self.n)
|
|
100
|
+
return int_to_hex(self.big_a)
|
|
101
|
+
|
|
102
|
+
def computeLittleS(self) -> int:
|
|
103
|
+
"""The multiplier k of SRP-6a, as MitID works it out."""
|
|
104
|
+
N_bytes = int_to_bytes(self.n)
|
|
105
|
+
g_bytes = int_to_bytes(self.g)
|
|
106
|
+
|
|
107
|
+
# Prepend g_bytes with |N_bytes|-|g_bytes| of 0
|
|
108
|
+
g_bytes = (b"\0" * (len(N_bytes) - len(g_bytes))) + g_bytes
|
|
109
|
+
|
|
110
|
+
m = hashlib.sha256()
|
|
111
|
+
m.update(str(self.n).encode('utf-8') + g_bytes)
|
|
112
|
+
digest = m.hexdigest()
|
|
113
|
+
|
|
114
|
+
return hex_to_int(digest)
|
|
115
|
+
|
|
116
|
+
def computeU(self) -> int:
|
|
117
|
+
"""The scrambler u, from A and B."""
|
|
118
|
+
N_length = len(int_to_bytes(self.n))
|
|
119
|
+
A_bytes = int_to_bytes(self.big_a)
|
|
120
|
+
B_bytes = int_to_bytes(self.big_b)
|
|
121
|
+
|
|
122
|
+
# Prepend A_bytes with |N_bytes|-|A_bytes| of 0
|
|
123
|
+
A_bytes = (b"\0"*(N_length-len(A_bytes)))+A_bytes
|
|
124
|
+
|
|
125
|
+
# Prepend r_bytes with |N_bytes|-|r_bytes| of 0
|
|
126
|
+
B_bytes = (b"\0"*(N_length-len(B_bytes)))+B_bytes
|
|
127
|
+
|
|
128
|
+
m = hashlib.sha256()
|
|
129
|
+
m.update(A_bytes + B_bytes)
|
|
130
|
+
hashed = m.hexdigest()
|
|
131
|
+
u = hex_to_int(hashed) % self.n
|
|
132
|
+
return u
|
|
133
|
+
|
|
134
|
+
def computeSessionKey(self) -> int:
|
|
135
|
+
"""The premaster secret S. MitID's own app does not refuse u == 0, so this does not."""
|
|
136
|
+
u = self.computeU()
|
|
137
|
+
|
|
138
|
+
s = self.computeLittleS()
|
|
139
|
+
|
|
140
|
+
a = u * self.hashed_password + self.a
|
|
141
|
+
c = pow((self.big_b - (pow(self.g, self.hashed_password, self.n) * s)), a, self.n)
|
|
142
|
+
if a < 0:
|
|
143
|
+
a += self.n
|
|
144
|
+
|
|
145
|
+
return c
|
|
146
|
+
|
|
147
|
+
def computeM1(self, r: str, srpSalt: str) -> str:
|
|
148
|
+
"""The client's proof M1, in hex."""
|
|
149
|
+
m = hashlib.sha256()
|
|
150
|
+
m.update(str(self.n).encode("utf-8"))
|
|
151
|
+
N = hex_to_int(m.hexdigest())
|
|
152
|
+
|
|
153
|
+
m = hashlib.sha256()
|
|
154
|
+
m.update(str(self.g).encode("utf-8"))
|
|
155
|
+
g = hex_to_int(m.hexdigest())
|
|
156
|
+
a = N ^ g
|
|
157
|
+
|
|
158
|
+
m = hashlib.sha256()
|
|
159
|
+
m.update((str(a) + r + srpSalt + str(self.big_a) + str(self.big_b) + bytes_to_hex(self.K_bits)).encode("ascii"))
|
|
160
|
+
return m.hexdigest()
|
|
161
|
+
|
|
162
|
+
def SRPStage3(self, srpSalt: str, randomB: str, password: str, auth_session_id: str) -> str:
|
|
163
|
+
"""Take the server's B, agree the session key, and give the proof M1 to send.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
Exception: B fails SRP's safety check.
|
|
167
|
+
"""
|
|
168
|
+
self.big_b = hex_to_int(randomB)
|
|
169
|
+
|
|
170
|
+
if(self.big_b == 0 or self.big_b % self.n == 0):
|
|
171
|
+
raise Exception("randomB did not pass safety check")
|
|
172
|
+
|
|
173
|
+
m = hashlib.sha256()
|
|
174
|
+
m.update((srpSalt + password).encode("ascii"))
|
|
175
|
+
self.hashed_password = hex_to_int(m.hexdigest())
|
|
176
|
+
|
|
177
|
+
a = self.computeSessionKey()
|
|
178
|
+
|
|
179
|
+
m = hashlib.sha256()
|
|
180
|
+
m.update(str(a).encode("utf-8"))
|
|
181
|
+
self.K_bits = m.digest()
|
|
182
|
+
|
|
183
|
+
m = hashlib.sha256()
|
|
184
|
+
m.update(auth_session_id.encode("utf-8"))
|
|
185
|
+
I_hex = m.hexdigest()
|
|
186
|
+
|
|
187
|
+
self.M1_hex = self.computeM1(I_hex, srpSalt)
|
|
188
|
+
|
|
189
|
+
return self.M1_hex
|
|
190
|
+
|
|
191
|
+
# Should satisfy if the server is correct
|
|
192
|
+
# Interestingly enough, this cannot be checked for the pin-binding proof
|
|
193
|
+
def SRPStage5(self, M2_hex: str) -> bool:
|
|
194
|
+
"""Whether the server's proof M2 matches the session key agreed."""
|
|
195
|
+
M1_bigInt = int(self.M1_hex, 16)
|
|
196
|
+
|
|
197
|
+
m = hashlib.sha256()
|
|
198
|
+
m.update((str(self.big_a) + str(M1_bigInt) + bytes_to_hex(self.K_bits)).encode('utf-8'))
|
|
199
|
+
M2_hex_verify = m.hexdigest()
|
|
200
|
+
return M2_hex_verify == M2_hex
|
|
201
|
+
|
|
202
|
+
def AuthEnc(self, plainText: bytes) -> bytes:
|
|
203
|
+
"""`plainText` encrypted with the session key: IV, cipher text, then tag."""
|
|
204
|
+
iv = Random.new().read(BLOCK_SIZE)
|
|
205
|
+
cipher = _new_gcm(self.K_bits, AES.MODE_GCM, iv)
|
|
206
|
+
ciphertext, tag = cipher.encrypt_and_digest(plainText)
|
|
207
|
+
return (iv + ciphertext + tag)
|
|
208
|
+
|
|
209
|
+
def AuthDec(self, encMessage: str | bytes) -> bytes:
|
|
210
|
+
"""The plain text of a message encrypted with the session key."""
|
|
211
|
+
return AesDecryptWithKey(encMessage, self.K_bits)
|
|
212
|
+
|
|
213
|
+
def AuthDecPin(self, encMessage: str | bytes) -> bytes:
|
|
214
|
+
"""The plain text of a message encrypted with the PIN key derived from the session key."""
|
|
215
|
+
pin_key = hashlib.sha256(binascii.hexlify(self.K_bits) + b"PIN").digest()
|
|
216
|
+
return AesDecryptWithKey(encMessage, pin_key)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mitid_connect
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Log in through MitID from Python: an unofficial implementation of MitID's browser client, with the MitID app or a code token.
|
|
5
|
+
Author: HairingX
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/HairingX/mitid_connect
|
|
8
|
+
Project-URL: Source, https://github.com/HairingX/mitid_connect
|
|
9
|
+
Project-URL: Issues, https://github.com/HairingX/mitid_connect/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/HairingX/mitid_connect/releases
|
|
11
|
+
Keywords: mitid,nemlog-in,login,denmark
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
26
|
+
Requires-Dist: lxml>=5.0.0
|
|
27
|
+
Requires-Dist: pycryptodomex>=3.24.1
|
|
28
|
+
Requires-Dist: qrcode>=8.2
|
|
29
|
+
Requires-Dist: requests>=2.31.0
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# mitid_connect
|
|
33
|
+
|
|
34
|
+
Log in through [MitID](https://www.mitid.dk) from Python: an unofficial implementation of MitID's
|
|
35
|
+
browser client. It authenticates with the MitID app - by QR code or one-time code - or with a
|
|
36
|
+
code token, and gives back the authorization code that the service being logged in to exchanges
|
|
37
|
+
for its own tokens.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from mitid_connect import BrowserClient
|
|
41
|
+
|
|
42
|
+
client = BrowserClient(client_hash, authentication_session_id)
|
|
43
|
+
authenticators = client.identify_as_user_and_get_available_authenticators("username")
|
|
44
|
+
client.authenticate_with_app() # approve in the MitID app
|
|
45
|
+
code = client.finalize_authentication_and_get_authorization_code()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`client_hash` and `authentication_session_id` come from the service's NemLog-in page;
|
|
49
|
+
`get_authentication_code` does all of the above from that page's parameters.
|
|
50
|
+
|
|
51
|
+
It is used by [aula_connect](https://github.com/HairingX/aula_connect) to log in to Aula.
|
|
52
|
+
|
|
53
|
+
## Origin and licence
|
|
54
|
+
|
|
55
|
+
It carries on MitID-BrowserClient by Hundter, under the MIT licence (`LICENSE`).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mitid_connect/__init__.py,sha256=6lPc4-HF1grYMWhwV_C96vweANum8EY2M7PnjWUsgrc,889
|
|
2
|
+
mitid_connect/browser_client.py,sha256=EAd4NqMaJtepm5qx1N5v0n4DKhdtrDwDIoXjX1BGOug,32429
|
|
3
|
+
mitid_connect/helpers.py,sha256=P6SI3Wt-mPnFQkNOXDq4-lbj9YHihZTFWcwJpFdUzb8,4385
|
|
4
|
+
mitid_connect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
mitid_connect/srp.py,sha256=TeVUZcbJb3qrCV4KMmpUbWwTMoteCY6lOyACqkeRP6E,7914
|
|
6
|
+
mitid_connect-0.0.1.dist-info/licenses/LICENSE,sha256=v-WwuWyqFMKfwXLWDSB_LS99yZNu4Ibd1HnMiF8ddyY,1153
|
|
7
|
+
mitid_connect-0.0.1.dist-info/METADATA,sha256=MkzDd-xJZLeEHbpYnMQ05U2hYLalQXpFB4f1nOO4A4g,2316
|
|
8
|
+
mitid_connect-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
mitid_connect-0.0.1.dist-info/top_level.txt,sha256=6OqNVy-tgVlykookob7hBs2KZkcN7VcAvuU66XJ2aBg,14
|
|
10
|
+
mitid_connect-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Hundter, the author of MitID-BrowserClient, which this library carries on
|
|
4
|
+
Copyright (c) 2026 HairingX
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mitid_connect
|