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.
- mitid_client-0.1.0/LICENSE +26 -0
- mitid_client-0.1.0/PKG-INFO +212 -0
- mitid_client-0.1.0/README.md +166 -0
- mitid_client-0.1.0/pyproject.toml +68 -0
- mitid_client-0.1.0/pyproject.toml.orig +56 -0
- mitid_client-0.1.0/src/mitid/__init__.py +116 -0
- mitid_client-0.1.0/src/mitid/brokers/__init__.py +9 -0
- mitid_client-0.1.0/src/mitid/brokers/nemlogin.py +397 -0
- mitid_client-0.1.0/src/mitid/core.py +448 -0
- mitid_client-0.1.0/src/mitid/srp.py +166 -0
- mitid_client-0.1.0/src/mitid/store.py +143 -0
- mitid_client-0.1.0/src/mitid/ui/__init__.py +10 -0
- mitid_client-0.1.0/src/mitid/ui/console.py +126 -0
- mitid_client-0.1.0/src/mitid/ui/tui.py +312 -0
|
@@ -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
|
+
"""
|