mitid-client 0.1.0__tar.gz

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.
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Hundter
4
+ Copyright (c) 2026 Kilian Tscherny
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.
23
+
24
+ ---
25
+ mitid/core.py and mitid/srp.py are adapted from
26
+ https://github.com/Hundter/MitID-BrowserClient, which is MIT-licensed as above.
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.3
2
+ Name: mitid-client
3
+ Version: 0.1.0
4
+ Summary: Log in to Danish services with MitID, from Python
5
+ Keywords: mitid,nemlogin,denmark,authentication
6
+ Author: Kilian Tscherny
7
+ License: MIT License
8
+
9
+ Copyright (c) 2024 Hundter
10
+ Copyright (c) 2026 Kilian Tscherny
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ ---
31
+ mitid/core.py and mitid/srp.py are adapted from
32
+ https://github.com/Hundter/MitID-BrowserClient, which is MIT-licensed as above.
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
38
+ Requires-Dist: beautifulsoup4>=4.12
39
+ Requires-Dist: pycryptodome>=3.20
40
+ Requires-Dist: qrcode>=8.0
41
+ Requires-Dist: requests>=2.32
42
+ Requires-Dist: textual>=3.1 ; extra == 'textual'
43
+ Requires-Python: >=3.10
44
+ Provides-Extra: textual
45
+ Description-Content-Type: text/markdown
46
+
47
+ # mitid-client
48
+
49
+ Log in to Danish services with MitID, from Python. No browser, no Selenium.
50
+
51
+ ---
52
+
53
+ > [!CAUTION]
54
+ > **A hobby project, not a product.** Nothing here is supported or built for
55
+ > production use.
56
+ >
57
+ > Not affiliated with or endorsed by MitID, NemLog-in, Digitaliseringsstyrelsen,
58
+ > Nets, Signicat, or any service that authenticates through them.
59
+ >
60
+ > Provided as-is, with no warranty. **Use it at your own risk.** The author
61
+ > accepts no liability for any loss, damage, lockout or misuse.
62
+
63
+ Every MitID-protected site works the same way. An identity broker hands the
64
+ browser an `aux` blob, the browser feeds that to MitID's JavaScript core client,
65
+ and the core client returns an authorisation code the broker swaps for a session.
66
+ This package is a Python version of that core client, plus a broker for the
67
+ public sector and two ways to put a login on screen.
68
+
69
+ ```python
70
+ import requests
71
+ from mitid.brokers import nemlogin
72
+
73
+ session = nemlogin.new_session()
74
+ final = nemlogin.log_in(session, "https://www.tinglysning.dk/...", "MyMitIDUserID")
75
+ # `session` now carries the service's own login cookies.
76
+ ```
77
+
78
+ ## What is in it
79
+
80
+ | module | what it does |
81
+ | ------------------------ | ----------------------------------------------------------------- |
82
+ | `mitid.authenticate` | an `aux` blob and a user ID in, an authorisation code out |
83
+ | `mitid.core` | the core client: SRP, the app channel, the QR frames, polling |
84
+ | `mitid.srp` | SRP-6a and the AES-GCM bits the authenticators need |
85
+ | `mitid.brokers.nemlogin` | NemLog-in, which fronts the Danish public sector |
86
+ | `mitid.store` | keeps a login's cookies between runs, 0600, in `$XDG_CONFIG_HOME` |
87
+ | `mitid.ui.console` | status lines, a scannable QR and a code box, on stderr |
88
+ | `mitid.ui.tui` | the same login as a Textual screen |
89
+
90
+ The protocol draws nothing itself. It reports progress through callbacks
91
+ (`on_status`, `on_qr`, `on_otp`, `ask_token_code`, `choose_identity`), which is
92
+ what lets the same login be a few lines on stderr in one program and a screen in
93
+ another.
94
+
95
+ ## Logging in
96
+
97
+ Two methods. `APP` sends a request to the MitID app and shows either a QR to
98
+ scan or a six-digit code to type, whichever the app asks for. `TOKEN` takes six
99
+ digits from a code token, followed by the account password.
100
+
101
+ ```python
102
+ from mitid.ui.console import LoginConsole
103
+
104
+ screen = LoginConsole()
105
+ final = nemlogin.log_in(
106
+ session, START_URL, user_id,
107
+ on_status=screen.status, # progress, and which service is asking
108
+ on_qr=screen.qr, # a QR matrix, redrawn in place each second
109
+ on_otp=screen.otp, # a code to type into the app
110
+ ask_token_code=screen.ask, # only with method=mitid.TOKEN
111
+ choose_identity=screen.choose, # when one MitID unlocks several identities
112
+ )
113
+ ```
114
+
115
+ [Textual](https://github.com/Textualize/textual) is a framework for building
116
+ terminal UIs. If your app uses it, the same login is a screen instead:
117
+
118
+ ```python
119
+ from functools import partial
120
+ from mitid.ui.tui import MitIDLoginScreen
121
+
122
+ result = await self.push_screen_wait(
123
+ MitIDLoginScreen(partial(nemlogin.log_in, session, START_URL))
124
+ )
125
+ ```
126
+
127
+ `MitIDLoginScreen` calls what you give it with the five callbacks above and
128
+ dismisses with whatever it returns, or `None` if the user gave up. It renders the
129
+ QR, the code, the token prompt and the identity chooser, and runs the login on a
130
+ worker thread so the UI stays responsive. Textual is an optional dependency, so
131
+ install `mitid-client[textual]` if you want it.
132
+
133
+ ## Keeping the session
134
+
135
+ A login costs a tap on a phone, so it must not happen once per request. What it
136
+ produces is a set of cookies, and `CookieStore` keeps those between runs:
137
+
138
+ ```python
139
+ from mitid.store import CookieStore
140
+
141
+ store = CookieStore("yourapp", "service-session.json",
142
+ session_factory=nemlogin.new_session)
143
+ store.save(session, user_id=user_id)
144
+
145
+ restored = store.restore() # (session, saved) or None
146
+ if restored:
147
+ session, saved = restored
148
+ idle = store.idle_for(saved["saved_at"])
149
+ ```
150
+
151
+ Whether the service still honours those cookies is up to the service, so ask it.
152
+ Most end a session that has sat idle for half an hour.
153
+
154
+ ## Which services it works with
155
+
156
+ Any service that authenticates through MitID, in principle. What differs between
157
+ them is the broker: the part that starts the session and hands over the `aux`
158
+ blob. Once you have that blob, `mitid.authenticate` does the rest, and that half
159
+ is the same everywhere.
160
+
161
+ `mitid.brokers.nemlogin` covers NemLog-in, which fronts the Danish public sector.
162
+ Point it at any NemLog-in-protected URL and it should work as it stands. Other
163
+ brokers need a short module of their own to fetch the `aux` blob; `nemlogin.py`
164
+ is the worked example to copy from.
165
+
166
+ Tried so far against tinglysning.dk (NemLog-in) and nordnet.dk (Signicat).
167
+
168
+ ## Installing
169
+
170
+ ```sh
171
+ uv add mitid-client # or: pip install mitid-client
172
+ uv add "mitid-client[textual]" # for the Textual screen
173
+ ```
174
+
175
+ ## What you are taking on
176
+
177
+ > [!WARNING]
178
+ > This logs in **as you**, with a national electronic identity, over a protocol
179
+ > that is not a published API. It is reverse-engineered from what a browser does.
180
+ >
181
+ > - MitID can rate-limit an account, and temporarily block one, after repeated
182
+ > failed logins. A broken login flow running in a loop will get you there.
183
+ > - The protocol can change without notice. When it does, this stops working,
184
+ > possibly halfway through a login.
185
+ > - The terms of service of whatever you point this at still apply to you.
186
+ > - A saved session is a live credential. `CookieStore` writes it `0600` in your
187
+ > config directory, and after that it is yours to look after.
188
+
189
+ > [!IMPORTANT]
190
+ > This has not been security-audited and is not a security product. It handles
191
+ > credentials, tokens and cookies on a best-effort basis. Don't build anything on
192
+ > it that other people's identities depend on.
193
+
194
+ Use it for your own accounts and your own data.
195
+
196
+ ## Tests
197
+
198
+ ```sh
199
+ uv run pytest
200
+ ```
201
+
202
+ Covers the cookie store's round trip and file mode, both QR renderers' shape and
203
+ polarity, and the parsing of MitID's error responses. The protocol itself is not
204
+ covered. It can only be run against the real thing.
205
+
206
+ ## Credits and licence
207
+
208
+ `mitid/core.py` and `mitid/srp.py` are adapted from
209
+ [Hundter/MitID-BrowserClient](https://github.com/Hundter/MitID-BrowserClient),
210
+ MIT-licensed, © 2024 Hundter.
211
+
212
+ MIT. See `LICENSE`.
@@ -0,0 +1,166 @@
1
+ # mitid-client
2
+
3
+ Log in to Danish services with MitID, from Python. No browser, no Selenium.
4
+
5
+ ---
6
+
7
+ > [!CAUTION]
8
+ > **A hobby project, not a product.** Nothing here is supported or built for
9
+ > production use.
10
+ >
11
+ > Not affiliated with or endorsed by MitID, NemLog-in, Digitaliseringsstyrelsen,
12
+ > Nets, Signicat, or any service that authenticates through them.
13
+ >
14
+ > Provided as-is, with no warranty. **Use it at your own risk.** The author
15
+ > accepts no liability for any loss, damage, lockout or misuse.
16
+
17
+ Every MitID-protected site works the same way. An identity broker hands the
18
+ browser an `aux` blob, the browser feeds that to MitID's JavaScript core client,
19
+ and the core client returns an authorisation code the broker swaps for a session.
20
+ This package is a Python version of that core client, plus a broker for the
21
+ public sector and two ways to put a login on screen.
22
+
23
+ ```python
24
+ import requests
25
+ from mitid.brokers import nemlogin
26
+
27
+ session = nemlogin.new_session()
28
+ final = nemlogin.log_in(session, "https://www.tinglysning.dk/...", "MyMitIDUserID")
29
+ # `session` now carries the service's own login cookies.
30
+ ```
31
+
32
+ ## What is in it
33
+
34
+ | module | what it does |
35
+ | ------------------------ | ----------------------------------------------------------------- |
36
+ | `mitid.authenticate` | an `aux` blob and a user ID in, an authorisation code out |
37
+ | `mitid.core` | the core client: SRP, the app channel, the QR frames, polling |
38
+ | `mitid.srp` | SRP-6a and the AES-GCM bits the authenticators need |
39
+ | `mitid.brokers.nemlogin` | NemLog-in, which fronts the Danish public sector |
40
+ | `mitid.store` | keeps a login's cookies between runs, 0600, in `$XDG_CONFIG_HOME` |
41
+ | `mitid.ui.console` | status lines, a scannable QR and a code box, on stderr |
42
+ | `mitid.ui.tui` | the same login as a Textual screen |
43
+
44
+ The protocol draws nothing itself. It reports progress through callbacks
45
+ (`on_status`, `on_qr`, `on_otp`, `ask_token_code`, `choose_identity`), which is
46
+ what lets the same login be a few lines on stderr in one program and a screen in
47
+ another.
48
+
49
+ ## Logging in
50
+
51
+ Two methods. `APP` sends a request to the MitID app and shows either a QR to
52
+ scan or a six-digit code to type, whichever the app asks for. `TOKEN` takes six
53
+ digits from a code token, followed by the account password.
54
+
55
+ ```python
56
+ from mitid.ui.console import LoginConsole
57
+
58
+ screen = LoginConsole()
59
+ final = nemlogin.log_in(
60
+ session, START_URL, user_id,
61
+ on_status=screen.status, # progress, and which service is asking
62
+ on_qr=screen.qr, # a QR matrix, redrawn in place each second
63
+ on_otp=screen.otp, # a code to type into the app
64
+ ask_token_code=screen.ask, # only with method=mitid.TOKEN
65
+ choose_identity=screen.choose, # when one MitID unlocks several identities
66
+ )
67
+ ```
68
+
69
+ [Textual](https://github.com/Textualize/textual) is a framework for building
70
+ terminal UIs. If your app uses it, the same login is a screen instead:
71
+
72
+ ```python
73
+ from functools import partial
74
+ from mitid.ui.tui import MitIDLoginScreen
75
+
76
+ result = await self.push_screen_wait(
77
+ MitIDLoginScreen(partial(nemlogin.log_in, session, START_URL))
78
+ )
79
+ ```
80
+
81
+ `MitIDLoginScreen` calls what you give it with the five callbacks above and
82
+ dismisses with whatever it returns, or `None` if the user gave up. It renders the
83
+ QR, the code, the token prompt and the identity chooser, and runs the login on a
84
+ worker thread so the UI stays responsive. Textual is an optional dependency, so
85
+ install `mitid-client[textual]` if you want it.
86
+
87
+ ## Keeping the session
88
+
89
+ A login costs a tap on a phone, so it must not happen once per request. What it
90
+ produces is a set of cookies, and `CookieStore` keeps those between runs:
91
+
92
+ ```python
93
+ from mitid.store import CookieStore
94
+
95
+ store = CookieStore("yourapp", "service-session.json",
96
+ session_factory=nemlogin.new_session)
97
+ store.save(session, user_id=user_id)
98
+
99
+ restored = store.restore() # (session, saved) or None
100
+ if restored:
101
+ session, saved = restored
102
+ idle = store.idle_for(saved["saved_at"])
103
+ ```
104
+
105
+ Whether the service still honours those cookies is up to the service, so ask it.
106
+ Most end a session that has sat idle for half an hour.
107
+
108
+ ## Which services it works with
109
+
110
+ Any service that authenticates through MitID, in principle. What differs between
111
+ them is the broker: the part that starts the session and hands over the `aux`
112
+ blob. Once you have that blob, `mitid.authenticate` does the rest, and that half
113
+ is the same everywhere.
114
+
115
+ `mitid.brokers.nemlogin` covers NemLog-in, which fronts the Danish public sector.
116
+ Point it at any NemLog-in-protected URL and it should work as it stands. Other
117
+ brokers need a short module of their own to fetch the `aux` blob; `nemlogin.py`
118
+ is the worked example to copy from.
119
+
120
+ Tried so far against tinglysning.dk (NemLog-in) and nordnet.dk (Signicat).
121
+
122
+ ## Installing
123
+
124
+ ```sh
125
+ uv add mitid-client # or: pip install mitid-client
126
+ uv add "mitid-client[textual]" # for the Textual screen
127
+ ```
128
+
129
+ ## What you are taking on
130
+
131
+ > [!WARNING]
132
+ > This logs in **as you**, with a national electronic identity, over a protocol
133
+ > that is not a published API. It is reverse-engineered from what a browser does.
134
+ >
135
+ > - MitID can rate-limit an account, and temporarily block one, after repeated
136
+ > failed logins. A broken login flow running in a loop will get you there.
137
+ > - The protocol can change without notice. When it does, this stops working,
138
+ > possibly halfway through a login.
139
+ > - The terms of service of whatever you point this at still apply to you.
140
+ > - A saved session is a live credential. `CookieStore` writes it `0600` in your
141
+ > config directory, and after that it is yours to look after.
142
+
143
+ > [!IMPORTANT]
144
+ > This has not been security-audited and is not a security product. It handles
145
+ > credentials, tokens and cookies on a best-effort basis. Don't build anything on
146
+ > it that other people's identities depend on.
147
+
148
+ Use it for your own accounts and your own data.
149
+
150
+ ## Tests
151
+
152
+ ```sh
153
+ uv run pytest
154
+ ```
155
+
156
+ Covers the cookie store's round trip and file mode, both QR renderers' shape and
157
+ polarity, and the parsing of MitID's error responses. The protocol itself is not
158
+ covered. It can only be run against the real thing.
159
+
160
+ ## Credits and licence
161
+
162
+ `mitid/core.py` and `mitid/srp.py` are adapted from
163
+ [Hundter/MitID-BrowserClient](https://github.com/Hundter/MitID-BrowserClient),
164
+ MIT-licensed, © 2024 Hundter.
165
+
166
+ MIT. See `LICENSE`.
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "mitid-client"
3
+ version = "0.1.0"
4
+ description = "Log in to Danish services with MitID, from Python"
5
+ readme = "README.md"
6
+ keywords = [
7
+ "mitid",
8
+ "nemlogin",
9
+ "denmark",
10
+ "authentication",
11
+ ]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: System :: Systems Administration :: Authentication/Directory",
18
+ ]
19
+ requires-python = ">=3.10"
20
+ dependencies = [
21
+ "beautifulsoup4>=4.12",
22
+ "pycryptodome>=3.20",
23
+ "qrcode>=8.0",
24
+ "requests>=2.32",
25
+ ]
26
+
27
+ [project.license]
28
+ file = "LICENSE"
29
+
30
+ [[project.authors]]
31
+ name = "Kilian Tscherny"
32
+
33
+ [project.optional-dependencies]
34
+ textual = ["textual>=3.1"]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=8.3",
39
+ "textual>=3.1",
40
+ ]
41
+
42
+ [tool.ruff]
43
+ line-length = 90
44
+ extend-exclude = [
45
+ "src/mitid/core.py",
46
+ "src/mitid/srp.py",
47
+ ]
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E",
52
+ "F",
53
+ "W",
54
+ "I",
55
+ "UP",
56
+ "B",
57
+ ]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ addopts = "--tb=short -q"
62
+
63
+ [tool.uv.build-backend]
64
+ module-name = "mitid"
65
+
66
+ [build-system]
67
+ requires = ["uv_build>=0.9.26,<0.10.0"]
68
+ build-backend = "uv_build"
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "mitid-client"
3
+ version = "0.1.0"
4
+ description = "Log in to Danish services with MitID, from Python"
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ authors = [{ name = "Kilian Tscherny" }]
8
+ keywords = ["mitid", "nemlogin", "denmark", "authentication"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: System :: Systems Administration :: Authentication/Directory",
15
+ ]
16
+ requires-python = ">=3.10"
17
+ dependencies = [
18
+ "beautifulsoup4>=4.12",
19
+ "pycryptodome>=3.20",
20
+ "qrcode>=8.0",
21
+ "requests>=2.32",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ # mitid.ui.tui only. The console renderer and the protocol itself need nothing
26
+ # beyond the four above, so a script that never draws a Textual screen does not
27
+ # pay for one.
28
+ textual = ["textual>=3.1"]
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=8.3",
33
+ # mitid.ui.tui is part of the library, so the tests need what it needs.
34
+ "textual>=3.1",
35
+ ]
36
+
37
+ [tool.ruff]
38
+ line-length = 90
39
+ # core.py and srp.py are kept close to their upstream so that fixes there can be
40
+ # dropped straight in. Reformatting them to house style would make every future
41
+ # diff against upstream unreadable.
42
+ extend-exclude = ["src/mitid/core.py", "src/mitid/srp.py"]
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "W", "I", "UP", "B"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ addopts = "--tb=short -q"
50
+
51
+ [build-system]
52
+ requires = ["uv_build>=0.9.26,<0.10.0"]
53
+ build-backend = "uv_build"
54
+
55
+ [tool.uv.build-backend]
56
+ module-name = "mitid"
@@ -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
+ """