mitid-client 0.1.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.
mitid/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """Drive a MitID authentication session to an authorisation code.
2
+
3
+ Every MitID-protected site works the same way: its identity broker hands the
4
+ browser an `aux` blob, the browser feeds that to MitID's own JavaScript core
5
+ client, and the core client hands back an authorisation code the broker
6
+ exchanges for an identity. `authenticate` is the middle step - it takes the
7
+ `aux` a broker gave us and returns that code.
8
+
9
+ Which broker produced the aux is none of this module's business. See
10
+ nemlogin.py for the NemLog-in half of the dance.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import binascii
17
+ import json
18
+
19
+ from mitid.core import BrowserClient
20
+
21
+ APP = "APP"
22
+ TOKEN = "TOKEN"
23
+
24
+
25
+ class MitIDError(Exception):
26
+ """Raised when a MitID authentication cannot be completed."""
27
+
28
+
29
+ def authenticate(
30
+ session,
31
+ aux: dict,
32
+ user_id: str,
33
+ *,
34
+ method: str = APP,
35
+ password: str | None = None,
36
+ ask_token_code=None,
37
+ on_status=None,
38
+ on_qr=None,
39
+ on_otp=None,
40
+ ) -> str:
41
+ """Authenticate as `user_id` and return a MitID authorisation code.
42
+
43
+ `aux` is the decoded blob from the broker. `method` is APP (approve in the
44
+ MitID app) or TOKEN (six digits from a code token, followed by the account
45
+ password). `ask_token_code` is called to collect those digits.
46
+
47
+ The callbacks are how the user finds out what is happening: `on_status` for
48
+ progress, `on_qr` with a QR matrix to render, `on_otp` with a code to type
49
+ into the app.
50
+ """
51
+ checksum = aux["coreClient"]["checksum"]
52
+ client_hash = binascii.hexlify(base64.b64decode(checksum)).decode("ascii")
53
+ session_id = aux["parameters"]["authenticationSessionId"]
54
+
55
+ client = BrowserClient(
56
+ client_hash,
57
+ session_id,
58
+ session,
59
+ on_qr_display=on_qr,
60
+ on_status=on_status,
61
+ on_otp=on_otp,
62
+ )
63
+
64
+ # The protocol code below reports failures by raising the server's raw
65
+ # response body, which is a wall of JSON. MitID writes a perfectly good
66
+ # explanation inside it, so unwrap that rather than passing the wall on.
67
+ try:
68
+ available = client.identify_as_user_and_get_available_authenticators(user_id)
69
+
70
+ # MitID decides what a given user may authenticate with, so a method
71
+ # that is merely configured on our side is not necessarily one they
72
+ # can use.
73
+ if method not in available:
74
+ offered = ", ".join(sorted(available)) or "none"
75
+ raise MitIDError(
76
+ f"{user_id} cannot log in with {method} - MitID offers: {offered}"
77
+ )
78
+
79
+ if method == APP:
80
+ client.authenticate_with_app()
81
+ elif method == TOKEN:
82
+ digits = (ask_token_code or input)("Six digits from your code token:")
83
+ client.authenticate_with_token(digits.strip())
84
+ if not password:
85
+ raise MitIDError("the code token method also needs your MitID password")
86
+ client.authenticate_with_password(password)
87
+ else:
88
+ raise MitIDError(f"unknown MitID method {method!r}")
89
+
90
+ return client.finalize_authentication_and_get_authorization_code()
91
+ except MitIDError:
92
+ raise
93
+ except Exception as error:
94
+ raise MitIDError(_explain(error)) from error
95
+
96
+
97
+ def _explain(error: Exception) -> str:
98
+ """Dig the human-readable half out of a MitID error response."""
99
+ detail = error.args[0] if error.args else error
100
+ if isinstance(detail, bytes):
101
+ detail = detail.decode("utf-8", "replace")
102
+ if isinstance(detail, str):
103
+ try:
104
+ detail = json.loads(detail)
105
+ except ValueError:
106
+ return detail.strip() or str(error)
107
+ if not isinstance(detail, dict):
108
+ return str(error)
109
+
110
+ # userMessage is what the real client would have put on screen; message and
111
+ # errorCode are what it logs. Prefer the one written for a person.
112
+ spoken = detail.get("userMessage") or {}
113
+ title = (spoken.get("title") or {}).get("text", "")
114
+ body = (spoken.get("text") or {}).get("text", "")
115
+ written = ": ".join(part for part in (title, body) if part)
116
+ return written or detail.get("message") or detail.get("errorCode") or str(detail)
@@ -0,0 +1,9 @@
1
+ """Identity brokers that sit between a service and MitID.
2
+
3
+ MitID never talks to a service directly. A broker does: it starts the
4
+ authentication session, hands the browser the `aux` blob that mitid.authenticate
5
+ needs, and exchanges the resulting authorisation code for whatever the service
6
+ recognises as a login. Every Danish site that accepts MitID uses one - NemLog-in
7
+ for the public sector, Signicat and Nets for most banks - and which one it is,
8
+ is the only part of a login that differs between services.
9
+ """
@@ -0,0 +1,397 @@
1
+ """Log in to a Danish public-sector site through NemLog-in, using MitID.
2
+
3
+ NemLog-in is the government's identity broker: tinglysning.dk, borger.dk,
4
+ skat.dk and the rest all delegate to it over SAML, so the flow below is not
5
+ tinglysning-specific. Point `log_in` at any NemLog-in-protected URL and it will
6
+ come back with the session cookie that URL was guarding.
7
+
8
+ The dance, once you strip the redirects away:
9
+
10
+ 1. GET the protected URL. It 302s to nemlog-in.mitid.dk/login/mitid.
11
+ 2. POST login/mitid/initialize. NemLog-in answers with the `aux` blob
12
+ that MitID's core client needs.
13
+ 3. Run the MitID authentication (see mitid/) to get an authorisation code.
14
+ 4. POST that code back to login/mitid. NemLog-in answers with a signed
15
+ SAML assertion in an auto-submitting form.
16
+ 5. POST the form. The service checks the assertion and sets its session.
17
+
18
+ Step 2 is the part the published reference scripts get wrong: they scrape an
19
+ `"Aux":"..."` string straight out of the login page's HTML, which NemLog-in
20
+ stopped shipping. It now sits behind that XHR instead, gated by a "Fortsæt til
21
+ login" button.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import json
28
+ from urllib.parse import urljoin, urlparse
29
+
30
+ import requests
31
+ from bs4 import BeautifulSoup
32
+
33
+ import mitid
34
+
35
+ NEMLOGIN = "https://nemlog-in.mitid.dk"
36
+ LOGIN_PAGE = f"{NEMLOGIN}/login/mitid"
37
+ INITIALIZE = f"{NEMLOGIN}/login/mitid/initialize"
38
+
39
+ # NemLog-in is fussy about looking like a browser, and MitID's own backend
40
+ # refuses sessions that do not carry a plausible one.
41
+ USER_AGENT = (
42
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
43
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
44
+ )
45
+
46
+ # Sent when following the flow's *pages*, never on the JSON calls MitID's
47
+ # backend serves - those two want to be told apart, and a session-wide Accept
48
+ # header could not.
49
+ PAGE_HEADERS = {
50
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
51
+ "Upgrade-Insecure-Requests": "1",
52
+ "Sec-Fetch-Dest": "document",
53
+ "Sec-Fetch-Mode": "navigate",
54
+ "Sec-Fetch-User": "?1",
55
+ }
56
+
57
+
58
+ class NemLogInError(Exception):
59
+ """Raised when the NemLog-in flow cannot be completed."""
60
+
61
+
62
+ def new_session() -> requests.Session:
63
+ """A requests session with the headers NemLog-in and MitID expect."""
64
+ session = requests.Session()
65
+ session.headers.update(
66
+ {
67
+ "User-Agent": USER_AGENT,
68
+ "Accept-Language": "da-DK,da;q=0.9,en-US;q=0.8,en;q=0.7",
69
+ }
70
+ )
71
+ return session
72
+
73
+
74
+ def log_in(
75
+ session: requests.Session,
76
+ start_url: str,
77
+ user_id: str,
78
+ *,
79
+ method: str = mitid.APP,
80
+ password: str | None = None,
81
+ choose_identity=None,
82
+ ask_token_code=None,
83
+ on_status=None,
84
+ on_qr=None,
85
+ on_otp=None,
86
+ trace=None,
87
+ ) -> requests.Response:
88
+ """Authenticate `session` against `start_url` and return the final response.
89
+
90
+ `user_id` is the MitID user ID (the name you type on mitid.dk, not a CPR
91
+ number). Everything else is passed through to mitid.authenticate.
92
+
93
+ Pass a list as `trace` to have every hop recorded into it. Logging in costs
94
+ the user a tap on their phone, so when something goes wrong the run has to
95
+ come back with enough detail to fix it without asking for another one.
96
+ """
97
+ say = on_status or (lambda message: None)
98
+
99
+ say("Contacting NemLog-in...")
100
+ landing = session.get(
101
+ start_url, headers={**PAGE_HEADERS, "Sec-Fetch-Site": "none"}, timeout=60
102
+ )
103
+ landing.raise_for_status()
104
+ _record(trace, "landed on NemLog-in", landing, session)
105
+ if not landing.url.startswith(LOGIN_PAGE):
106
+ raise NemLogInError(
107
+ f"expected to land on {LOGIN_PAGE}, ended up at {landing.url} - "
108
+ "either the session is already logged in or the flow has changed"
109
+ )
110
+
111
+ # Post back everything the page's own form holds, not just the fields we
112
+ # care about: the browser submits the whole form, and a server that reads
113
+ # the ones we left out would see a request no browser would ever send.
114
+ form_fields = _form_fields(BeautifulSoup(landing.text, "html.parser").find("form"))
115
+ verification_token = form_fields.get("__RequestVerificationToken", "")
116
+ if not verification_token:
117
+ raise NemLogInError(
118
+ "no __RequestVerificationToken on the NemLog-in page - "
119
+ "the login page has changed"
120
+ )
121
+ aux = _initialize(session, verification_token, referer=landing.url)
122
+
123
+ code = mitid.authenticate(
124
+ session,
125
+ aux,
126
+ user_id,
127
+ method=method,
128
+ password=password,
129
+ ask_token_code=ask_token_code,
130
+ on_status=on_status,
131
+ on_qr=on_qr,
132
+ on_otp=on_otp,
133
+ )
134
+
135
+ say("Exchanging the MitID approval for a session...")
136
+ form_fields.update(
137
+ {
138
+ # The page's JavaScript sets these two: the confirmation flag the
139
+ # moment initialize() succeeds, and the code when MitID hands it
140
+ # over.
141
+ "MitIDUseConfirmed": "True",
142
+ "MitIDAuthCode": code,
143
+ # And these two prove the submission belongs to the flow NemLog-in
144
+ # started. It keeps the pair in cookies, the core client mirrors
145
+ # them into sessionStorage, and the form posts them back so the two
146
+ # can be compared. Get them wrong - or leave them empty, which is
147
+ # what a script naturally does - and NemLog-in reads the mismatch
148
+ # as a second browser tab: "Du er allerede logget ind".
149
+ **_flow_state(session),
150
+ }
151
+ )
152
+ response = session.post(
153
+ LOGIN_PAGE,
154
+ data=form_fields,
155
+ headers={
156
+ **PAGE_HEADERS,
157
+ "Referer": landing.url,
158
+ "Origin": NEMLOGIN,
159
+ "Sec-Fetch-Site": "same-origin",
160
+ },
161
+ timeout=60,
162
+ )
163
+ response.raise_for_status()
164
+ _record(trace, "posted the MitID authorisation code", response, session)
165
+
166
+ return _hand_back(session, response, choose_identity, trace, say)
167
+
168
+
169
+ def _flow_state(session: requests.Session) -> dict[str, str]:
170
+ """The flow identifiers NemLog-in expects to hear back from sessionStorage."""
171
+ held = {"SessionUuid": "", "Challenge": ""}
172
+ for cookie in session.cookies:
173
+ if cookie.name in held and cookie.domain.lstrip(".").endswith(
174
+ urlparse(NEMLOGIN).netloc
175
+ ):
176
+ held[cookie.name] = cookie.value or ""
177
+
178
+ missing = [name for name, value in held.items() if not value]
179
+ if missing:
180
+ raise NemLogInError(
181
+ f"NemLog-in never set its flow cookies ({', '.join(missing)}) - "
182
+ "the login page has changed"
183
+ )
184
+ return {
185
+ "SessionStorageActiveSessionUuid": held["SessionUuid"],
186
+ "SessionStorageActiveChallenge": held["Challenge"],
187
+ }
188
+
189
+
190
+ def _initialize(session: requests.Session, token: str, *, referer: str) -> dict:
191
+ """Ask NemLog-in to start a MitID session and return the decoded aux blob."""
192
+ response = session.post(
193
+ INITIALIZE,
194
+ data={
195
+ "__RequestVerificationToken": token,
196
+ # Only set when a previous MitID attempt in this browser tab was
197
+ # interrupted. We always start fresh, so both are empty.
198
+ "SessionStorageActiveSessionUuid": "",
199
+ "SessionStorageActiveChallenge": "",
200
+ },
201
+ headers={"Referer": referer, "X-Requested-With": "XMLHttpRequest"},
202
+ timeout=60,
203
+ )
204
+ response.raise_for_status()
205
+
206
+ # The body is a JSON string that itself contains JSON, so it needs decoding
207
+ # twice - but only when the outer layer really is a string.
208
+ payload = response.json()
209
+ if isinstance(payload, str):
210
+ payload = json.loads(payload)
211
+ if "Aux" not in payload:
212
+ raise NemLogInError(f"no Aux in the initialize response: {payload}")
213
+
214
+ return json.loads(base64.b64decode(payload["Aux"]))
215
+
216
+
217
+ def _hand_back(
218
+ session: requests.Session,
219
+ response: requests.Response,
220
+ choose,
221
+ trace,
222
+ say,
223
+ *,
224
+ max_hops: int = 8,
225
+ ) -> requests.Response:
226
+ """Carry the assertion from NemLog-in back to the service that asked for it.
227
+
228
+ Between the MitID approval and the service's own session there can be an
229
+ identity chooser and one or more auto-submitting SAML forms - pages whose
230
+ entire content is a hidden form and a line of JavaScript that submits it.
231
+ requests has no JavaScript, so we do the submitting.
232
+
233
+ The loop ends either at the service, or at a NemLog-in page we cannot
234
+ account for - and says which page that was, rather than quietly handing
235
+ back a response that was never a session.
236
+ """
237
+ for _ in range(max_hops):
238
+ soup = BeautifulSoup(response.text, "html.parser")
239
+
240
+ # Identity chooser. Recognised by what is on the page rather than by
241
+ # its URL, which has moved before.
242
+ if soup.select("div.list-link-box a[data-loginoptions]"):
243
+ say("Choosing which identity to use...")
244
+ response = _choose_identity(session, response, choose, soup)
245
+ _record(trace, "chose an identity", response, session)
246
+ continue
247
+
248
+ form = soup.find("form")
249
+ fields = _form_fields(form)
250
+ if fields.keys() & {"SAMLResponse", "SAMLRequest"}:
251
+ action = urljoin(response.url, str(form.get("action", "")) or response.url)
252
+ response = session.post(
253
+ action,
254
+ data=fields,
255
+ headers={
256
+ **PAGE_HEADERS,
257
+ "Referer": response.url,
258
+ "Origin": f"{urlparse(response.url).scheme}://{urlparse(response.url).netloc}",
259
+ "Sec-Fetch-Site": "cross-site",
260
+ },
261
+ timeout=60,
262
+ )
263
+ response.raise_for_status()
264
+ _record(
265
+ trace,
266
+ f"submitted a SAML form to {urlparse(action).netloc}",
267
+ response,
268
+ session,
269
+ )
270
+ continue
271
+
272
+ if urlparse(response.url).netloc.endswith(urlparse(NEMLOGIN).netloc):
273
+ raise NemLogInError(
274
+ f"stopped on a NemLog-in page with nothing to submit: "
275
+ f"{response.url}\n{_summarise(soup)}"
276
+ )
277
+ return response
278
+
279
+ raise NemLogInError(
280
+ f"still being handed between endpoints after {max_hops} hops "
281
+ f"(currently at {urlparse(response.url).netloc})"
282
+ )
283
+
284
+
285
+ def _choose_identity(
286
+ session: requests.Session, response: requests.Response, choose, soup
287
+ ) -> requests.Response:
288
+ """Pick an identity when MitID resolves to more than one.
289
+
290
+ People with a company signature see this page: the same MitID unlocks a
291
+ private identity and one per company they can sign for.
292
+ """
293
+ options = []
294
+ for box in soup.select("div.list-link-box"):
295
+ label = box.select_one("div.list-link-text")
296
+ link = box.find("a")
297
+ if link is not None and link.get("data-loginoptions"):
298
+ options.append(
299
+ (
300
+ label.get_text(strip=True) if label else "?",
301
+ str(link["data-loginoptions"]),
302
+ )
303
+ )
304
+
305
+ if len(options) == 1:
306
+ chosen = 0
307
+ elif choose is None:
308
+ raise NemLogInError(
309
+ "MitID resolved to several identities and there is no way to pick one: "
310
+ + ", ".join(label for label, _ in options)
311
+ )
312
+ else:
313
+ chosen = choose([label for label, _ in options])
314
+
315
+ form = soup.find("form")
316
+ if form is None:
317
+ raise NemLogInError("the identity page has no form to submit")
318
+ fields = _form_fields(form)
319
+ fields["ChosenOptionJson"] = options[chosen][1]
320
+ # This page belongs to the same flow as the login page, and is checked the
321
+ # same way. Leaving these out is what turns a chosen identity into
322
+ # "Du er allerede logget ind".
323
+ fields.update(_flow_state(session))
324
+
325
+ posted = session.post(
326
+ urljoin(response.url, str(form.get("action", "")) or response.url),
327
+ data=fields,
328
+ timeout=60,
329
+ )
330
+ posted.raise_for_status()
331
+ return posted
332
+
333
+
334
+ def _form_fields(form) -> dict[str, str]:
335
+ """Every named input a browser would submit, hidden ones included.
336
+
337
+ Unticked checkboxes and unselected radios are left out, because a browser
338
+ leaves them out - and one of these pages carries an `acceptTerms` box that
339
+ we have no business ticking on the user's behalf.
340
+ """
341
+ if form is None:
342
+ return {}
343
+ fields = {}
344
+ for field in form.find_all("input"):
345
+ name = field.get("name")
346
+ if not name:
347
+ continue
348
+ kind = str(field.get("type", "")).lower()
349
+ if kind in ("checkbox", "radio") and not field.has_attr("checked"):
350
+ continue
351
+ fields[str(name)] = str(field.get("value", ""))
352
+ return fields
353
+
354
+
355
+ def _summarise(soup) -> str:
356
+ """What a page says, for an error message that has to explain itself."""
357
+ title = soup.find("title")
358
+ text = " ".join(soup.get_text(" ").split())
359
+ forms = [
360
+ f"{str(form.get('method', 'GET')).upper()} {form.get('action', '')} "
361
+ f"[{', '.join(sorted(_form_fields(form)))}]"
362
+ for form in soup.find_all("form")
363
+ ]
364
+ lines = [f" title: {title.get_text(strip=True) if title else '(none)'}"]
365
+ lines += [f" form: {form}" for form in forms]
366
+ lines.append(f" text: {text[:400]}")
367
+ return "\n".join(lines)
368
+
369
+
370
+ def _record(trace, step: str, response: requests.Response, session=None) -> None:
371
+ """Note one hop, in enough detail to work out afterwards what happened."""
372
+ if trace is None:
373
+ return
374
+ session = session if session is not None else requests.Session()
375
+ soup = BeautifulSoup(response.text, "html.parser")
376
+ trace.append(
377
+ {
378
+ "step": step,
379
+ "status": response.status_code,
380
+ "url": response.url,
381
+ "redirects": [hop.url for hop in response.history],
382
+ "new_cookies": sorted({cookie.name for cookie in response.cookies}),
383
+ "session_cookies": sorted({cookie.name for cookie in session.cookies}),
384
+ "forms": [
385
+ {
386
+ "action": form.get("action", ""),
387
+ "method": str(form.get("method", "GET")).upper(),
388
+ "fields": sorted(_form_fields(form)),
389
+ }
390
+ for form in soup.find_all("form")
391
+ ],
392
+ "summary": _summarise(soup),
393
+ # The page itself, because the thing that finally explains a
394
+ # failure is usually the inline script we could not have guessed at.
395
+ "html": response.text,
396
+ }
397
+ )