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 +116 -0
- mitid/brokers/__init__.py +9 -0
- mitid/brokers/nemlogin.py +397 -0
- mitid/core.py +448 -0
- mitid/srp.py +166 -0
- mitid/store.py +143 -0
- mitid/ui/__init__.py +10 -0
- mitid/ui/console.py +126 -0
- mitid/ui/tui.py +312 -0
- mitid_client-0.1.0.dist-info/METADATA +212 -0
- mitid_client-0.1.0.dist-info/RECORD +12 -0
- mitid_client-0.1.0.dist-info/WHEEL +4 -0
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
|
+
)
|