scalio-identity 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.
- scalio_identity-0.1.0/PKG-INFO +61 -0
- scalio_identity-0.1.0/README.md +38 -0
- scalio_identity-0.1.0/pyproject.toml +36 -0
- scalio_identity-0.1.0/setup.cfg +4 -0
- scalio_identity-0.1.0/src/scalio_identity/__init__.py +16 -0
- scalio_identity-0.1.0/src/scalio_identity/accounts.py +55 -0
- scalio_identity-0.1.0/src/scalio_identity/broker.py +217 -0
- scalio_identity-0.1.0/src/scalio_identity/mfa.py +168 -0
- scalio_identity-0.1.0/src/scalio_identity/roles.py +39 -0
- scalio_identity-0.1.0/src/scalio_identity/testing/__init__.py +4 -0
- scalio_identity-0.1.0/src/scalio_identity/testing/fake_broker.py +62 -0
- scalio_identity-0.1.0/src/scalio_identity/testing/softkey.py +65 -0
- scalio_identity-0.1.0/src/scalio_identity.egg-info/PKG-INFO +61 -0
- scalio_identity-0.1.0/src/scalio_identity.egg-info/SOURCES.txt +19 -0
- scalio_identity-0.1.0/src/scalio_identity.egg-info/dependency_links.txt +1 -0
- scalio_identity-0.1.0/src/scalio_identity.egg-info/requires.txt +18 -0
- scalio_identity-0.1.0/src/scalio_identity.egg-info/top_level.txt +1 -0
- scalio_identity-0.1.0/tests/test_accounts.py +31 -0
- scalio_identity-0.1.0/tests/test_broker.py +129 -0
- scalio_identity-0.1.0/tests/test_mfa.py +118 -0
- scalio_identity-0.1.0/tests/test_roles.py +25 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scalio-identity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sign-in through Scalio ID, Entra app roles and second factor, shared by the Scalio apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: PyJWT[crypto]>=2.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: mfa
|
|
11
|
+
Requires-Dist: pyotp>=2.9; extra == "mfa"
|
|
12
|
+
Requires-Dist: webauthn>=2.0; extra == "mfa"
|
|
13
|
+
Provides-Extra: testing
|
|
14
|
+
Requires-Dist: cbor2>=5.6; extra == "testing"
|
|
15
|
+
Requires-Dist: cryptography>=42; extra == "testing"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
19
|
+
Requires-Dist: pyotp>=2.9; extra == "dev"
|
|
20
|
+
Requires-Dist: webauthn>=2.0; extra == "dev"
|
|
21
|
+
Requires-Dist: cbor2>=5.6; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# scalio-identity
|
|
25
|
+
|
|
26
|
+
Identity helpers shared by the Scalio apps Nexus, Citadel, Rosetta and Foundry. Decisions: scalio-docs ADR 0028
|
|
27
|
+
(`idm/I1`–`I12`), concept `concepts/central-user-management.md`. Protocol of the broker: scalio-docs
|
|
28
|
+
`interfaces/scalio-id-oidc.md`.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install "scalio-identity[mfa]" # app
|
|
32
|
+
pip install "scalio-identity[mfa,testing]" # app tests
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Module | What it does |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `scalio_identity.broker` | Sign-in through Scalio ID: `Broker.from_env()`, `Flow.new()`, `authorize_url()`, `redeem()` → `Identity`; `check_binding()` refuses another organisation (`SCALIO_ORG_ID`) or tenant |
|
|
38
|
+
| `scalio_identity.roles` | `RoleMap("Nexus", {"Admin": "admin", ...})`: Entra app roles (`Nexus.Admin`) → app role, unknown = no rights |
|
|
39
|
+
| `scalio_identity.accounts` | Account lookup by Scalio ID subject, then Microsoft tenant + object id; e-mail only for a one-time link |
|
|
40
|
+
| `scalio_identity.mfa` | TOTP (replay safe), backup codes, passkeys (WebAuthn, user verification required) |
|
|
41
|
+
| `scalio_identity.testing` | `FakeBroker` (Scalio ID in memory, `httpx.MockTransport`) and `SoftKey` (software passkey) |
|
|
42
|
+
|
|
43
|
+
The app keeps its routes, state storage, sessions and accounts; this package only checks.
|
|
44
|
+
|
|
45
|
+
## Settings of an app
|
|
46
|
+
|
|
47
|
+
| Variable | Meaning |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `SCALIO_ID_ISSUER` | broker, e.g. `https://sso.scaliocloud.com`; empty = Scalio ID off |
|
|
50
|
+
| `SCALIO_ID_CLIENT_ID` | the app's origin; default from the app's own URL |
|
|
51
|
+
| `SCALIO_ORG_ID` | Scalio organisation of a SaaS instance (set at provisioning); empty on self-hosted |
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
ruff check . && pytest -q
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Release: bump `pyproject.toml` and `__version__`, CHANGELOG line, merge, then "Freigeben" in the Scalio Dev
|
|
61
|
+
cockpit (or Actions → Publish with the version). PyPI trusted publishing, environment `pypi`, no token.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# scalio-identity
|
|
2
|
+
|
|
3
|
+
Identity helpers shared by the Scalio apps Nexus, Citadel, Rosetta and Foundry. Decisions: scalio-docs ADR 0028
|
|
4
|
+
(`idm/I1`–`I12`), concept `concepts/central-user-management.md`. Protocol of the broker: scalio-docs
|
|
5
|
+
`interfaces/scalio-id-oidc.md`.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install "scalio-identity[mfa]" # app
|
|
9
|
+
pip install "scalio-identity[mfa,testing]" # app tests
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Module | What it does |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `scalio_identity.broker` | Sign-in through Scalio ID: `Broker.from_env()`, `Flow.new()`, `authorize_url()`, `redeem()` → `Identity`; `check_binding()` refuses another organisation (`SCALIO_ORG_ID`) or tenant |
|
|
15
|
+
| `scalio_identity.roles` | `RoleMap("Nexus", {"Admin": "admin", ...})`: Entra app roles (`Nexus.Admin`) → app role, unknown = no rights |
|
|
16
|
+
| `scalio_identity.accounts` | Account lookup by Scalio ID subject, then Microsoft tenant + object id; e-mail only for a one-time link |
|
|
17
|
+
| `scalio_identity.mfa` | TOTP (replay safe), backup codes, passkeys (WebAuthn, user verification required) |
|
|
18
|
+
| `scalio_identity.testing` | `FakeBroker` (Scalio ID in memory, `httpx.MockTransport`) and `SoftKey` (software passkey) |
|
|
19
|
+
|
|
20
|
+
The app keeps its routes, state storage, sessions and accounts; this package only checks.
|
|
21
|
+
|
|
22
|
+
## Settings of an app
|
|
23
|
+
|
|
24
|
+
| Variable | Meaning |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `SCALIO_ID_ISSUER` | broker, e.g. `https://sso.scaliocloud.com`; empty = Scalio ID off |
|
|
27
|
+
| `SCALIO_ID_CLIENT_ID` | the app's origin; default from the app's own URL |
|
|
28
|
+
| `SCALIO_ORG_ID` | Scalio organisation of a SaaS instance (set at provisioning); empty on self-hosted |
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install -e ".[dev]"
|
|
34
|
+
ruff check . && pytest -q
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Release: bump `pyproject.toml` and `__version__`, CHANGELOG line, merge, then "Freigeben" in the Scalio Dev
|
|
38
|
+
cockpit (or Actions → Publish with the version). PyPI trusted publishing, environment `pypi`, no token.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scalio-identity"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Sign-in through Scalio ID, Entra app roles and second factor, shared by the Scalio apps"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
dependencies = ["PyJWT[crypto]>=2.10", "httpx>=0.27"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
mfa = ["pyotp>=2.9", "webauthn>=2.0"]
|
|
16
|
+
testing = ["cbor2>=5.6", "cryptography>=42"]
|
|
17
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyotp>=2.9", "webauthn>=2.0", "cbor2>=5.6", "ruff>=0.6"]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
asyncio_mode = "auto"
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 120
|
|
28
|
+
target-version = "py311"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = ["E", "F", "W", "I", "B"]
|
|
32
|
+
ignore = ["B008"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint.per-file-ignores]
|
|
35
|
+
# copied unchanged from the apps; webauthn raises several exception types
|
|
36
|
+
"tests/test_mfa.py" = ["B017"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Shared identity helpers of the Scalio apps (Nexus, Citadel, Rosetta, Foundry).
|
|
2
|
+
|
|
3
|
+
- broker: sign-in through Scalio ID (OpenID Connect, PKCE, nonce), checked identity, instance binding
|
|
4
|
+
- roles: Entra app roles (claim `roles`) -> the app's own role
|
|
5
|
+
- accounts: which stored account belongs to an identity (Microsoft tenant + object id, never e-mail alone)
|
|
6
|
+
- mfa: second factor for local accounts (TOTP, backup codes, passkeys), extra `mfa`
|
|
7
|
+
- testing: fake Scalio ID broker and software passkey for app tests, extra `testing`
|
|
8
|
+
|
|
9
|
+
Decisions: scalio-docs ADR 0028 (idm/I1-I12).
|
|
10
|
+
"""
|
|
11
|
+
from .broker import Broker, CodeRejected, Flow, Identity, IdentityRejected, NotAllowed, check_binding, error_code
|
|
12
|
+
from .roles import RoleMap
|
|
13
|
+
|
|
14
|
+
__all__ = ["Broker", "CodeRejected", "Flow", "Identity", "IdentityRejected", "NotAllowed", "RoleMap",
|
|
15
|
+
"check_binding", "error_code"]
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Which stored account belongs to an identity (idm, security finding N9).
|
|
2
|
+
|
|
3
|
+
An account is found by the Scalio ID subject, then by Microsoft tenant + object id. E-mail is never enough on
|
|
4
|
+
its own: an e-mail address can be renamed or reused, the object id cannot. Linking an existing account by
|
|
5
|
+
e-mail is allowed once, for an account that has no Microsoft identity yet, and only when the app says so
|
|
6
|
+
(e.g. self-registration closed, so every account was created by an admin).
|
|
7
|
+
|
|
8
|
+
Field names differ per app (Citadel `scalio_sub`, Nexus camelCase), so the app passes them in.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .broker import Identity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Fields:
|
|
19
|
+
sub: str = "scalio_sub"
|
|
20
|
+
tid: str = "scalio_tid"
|
|
21
|
+
oid: str = "scalio_oid"
|
|
22
|
+
email: str = "email"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lookups(ident: Identity, f: Fields = Fields()) -> list[dict]:
|
|
26
|
+
"""Mongo filters to try in order. Empty when the identity has neither subject nor Microsoft ids."""
|
|
27
|
+
out = []
|
|
28
|
+
if ident.sub:
|
|
29
|
+
out.append({f.sub: ident.sub})
|
|
30
|
+
if ident.is_microsoft:
|
|
31
|
+
out.append({f.tid: ident.tid, f.oid: ident.oid})
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def email_link_filter(ident: Identity, f: Fields = Fields()) -> dict | None:
|
|
36
|
+
"""Filter for the one-time e-mail link: same e-mail, no Microsoft identity or subject stored yet."""
|
|
37
|
+
if not ident.email or "@" not in ident.email:
|
|
38
|
+
return None
|
|
39
|
+
return {f.email: ident.email, f.sub: {"$exists": False}, f.oid: {"$exists": False}}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def link_fields(ident: Identity, f: Fields = Fields()) -> dict:
|
|
43
|
+
"""Fields to $set on the account when it is linked or created."""
|
|
44
|
+
doc = {f.sub: ident.sub}
|
|
45
|
+
if ident.is_microsoft:
|
|
46
|
+
doc.update({f.tid: ident.tid, f.oid: ident.oid})
|
|
47
|
+
return doc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def same_person(account: dict, ident: Identity, f: Fields = Fields()) -> bool:
|
|
51
|
+
"""True when the stored Microsoft ids (if any) match the identity. Use before trusting a lookup by sub."""
|
|
52
|
+
tid, oid = str(account.get(f.tid) or "").lower(), str(account.get(f.oid) or "").lower()
|
|
53
|
+
if not (tid or oid):
|
|
54
|
+
return True
|
|
55
|
+
return ident.is_microsoft and tid == ident.tid and oid == ident.oid
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Sign-in through Scalio ID, the shared sign-in service of the Scalio apps (repo Scalio-Cloud/scalio-id).
|
|
2
|
+
|
|
3
|
+
The app is a public client of the broker: client id = its origin, code flow with PKCE (S256) and nonce, no
|
|
4
|
+
secret. This module only talks to the broker and checks what comes back. State storage, sessions and accounts
|
|
5
|
+
stay in the app.
|
|
6
|
+
|
|
7
|
+
broker = Broker.from_env(fallback_origin=FRONTEND_URL)
|
|
8
|
+
flow = Flow.new() # store state/nonce/verifier in the app, bound to the browser
|
|
9
|
+
url = broker.authorize_url(flow) # redirect
|
|
10
|
+
ident = await broker.redeem(code, flow.verifier, flow.nonce)
|
|
11
|
+
check_binding(ident, org_id=..., tenant_ids=...) # raises NotAllowed
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from urllib.parse import urlencode, urlsplit
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import jwt
|
|
25
|
+
|
|
26
|
+
CALLBACK_PATH = "/api/auth/scalio/callback"
|
|
27
|
+
JWKS_TTL = 3600
|
|
28
|
+
LEEWAY = 60
|
|
29
|
+
# Errors at the broker that only mean "no silent sign-in possible": show the normal login page.
|
|
30
|
+
SILENT_ERRORS = ("login_required", "consent_required", "interaction_required")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CodeRejected(Exception):
|
|
34
|
+
"""The broker did not accept the code (expired, used, wrong verifier)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IdentityRejected(Exception):
|
|
38
|
+
"""The ID token is invalid (signature, audience, issuer, nonce, expiry, missing claims)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotAllowed(Exception):
|
|
42
|
+
"""A valid identity that may not use this instance (other organisation or tenant)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, reason: str):
|
|
45
|
+
super().__init__(reason)
|
|
46
|
+
self.reason = reason
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def origin(url: str) -> str:
|
|
50
|
+
"""scheme://host[:port] in lower case, or "" for anything that is not http(s)."""
|
|
51
|
+
parts = urlsplit((url or "").strip())
|
|
52
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
53
|
+
return ""
|
|
54
|
+
return f"{parts.scheme}://{parts.netloc}".lower()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _b64u(data: bytes) -> str:
|
|
58
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Flow:
|
|
63
|
+
"""Secrets of one sign-in. The app stores them (hash the state) and binds them to the browser."""
|
|
64
|
+
state: str
|
|
65
|
+
nonce: str
|
|
66
|
+
verifier: str
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def new(cls) -> Flow:
|
|
70
|
+
return cls(*(secrets.token_urlsafe(32) for _ in range(3)))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def challenge(self) -> str:
|
|
74
|
+
return _b64u(hashlib.sha256(self.verifier.encode()).digest())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Identity:
|
|
79
|
+
"""A checked identity from the broker's ID token."""
|
|
80
|
+
sub: str
|
|
81
|
+
idp: str = ""
|
|
82
|
+
tid: str = ""
|
|
83
|
+
oid: str = ""
|
|
84
|
+
email: str = ""
|
|
85
|
+
name: str = ""
|
|
86
|
+
org: str = "" # Scalio organisation (Scalio Konto, idm/I2); empty until the broker sends it
|
|
87
|
+
app_role: str = "" # role for this app from Scalio Konto (idm/I3)
|
|
88
|
+
roles: tuple[str, ...] = () # Entra app roles, already limited to this app by the broker or RoleMap
|
|
89
|
+
via_msp: str = "" # MSP organisation acting for the customer (idm/I8)
|
|
90
|
+
claims: dict = field(default_factory=dict, repr=False, compare=False)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_microsoft(self) -> bool:
|
|
94
|
+
return self.idp == "entra" and bool(self.tid and self.oid)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_claims(cls, c: dict) -> Identity:
|
|
98
|
+
roles = c.get("roles") or ()
|
|
99
|
+
if isinstance(roles, str):
|
|
100
|
+
roles = (roles,)
|
|
101
|
+
return cls(sub=str(c["sub"]), idp=str(c.get("idp") or ""), tid=str(c.get("tid") or "").lower(),
|
|
102
|
+
oid=str(c.get("oid") or "").lower(), email=str(c.get("email") or "").strip().lower(),
|
|
103
|
+
name=str(c.get("name") or "").strip(), org=str(c.get("org") or ""),
|
|
104
|
+
app_role=str(c.get("app_role") or ""), roles=tuple(str(r) for r in roles if r),
|
|
105
|
+
via_msp=str(c.get("via_msp") or ""), claims=dict(c))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def error_code(error: str, description: str = "") -> str:
|
|
109
|
+
"""Broker error -> short code for the login page: personal, tenant_not_allowed, cancelled, failed."""
|
|
110
|
+
if error == "access_denied":
|
|
111
|
+
if description == "entra_personal_account":
|
|
112
|
+
return "personal"
|
|
113
|
+
if description == "entra_tenant_not_allowed":
|
|
114
|
+
return "tenant_not_allowed"
|
|
115
|
+
if description.startswith("microsoft_") or description == "consent_denied":
|
|
116
|
+
return "cancelled"
|
|
117
|
+
return "failed"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_binding(ident: Identity, org_id: str = "", tenant_ids=()) -> None:
|
|
121
|
+
"""Refuse an identity of another customer. Raises NotAllowed("org" | "tenant").
|
|
122
|
+
|
|
123
|
+
org_id: the instance's Scalio organisation (SCALIO_ORG_ID, set at provisioning). When set, the token must
|
|
124
|
+
carry exactly this organisation, so a token for customer A never works at customer B.
|
|
125
|
+
tenant_ids: Microsoft tenants of this installation; when given, a Microsoft identity must come from one.
|
|
126
|
+
"""
|
|
127
|
+
if org_id and not secrets.compare_digest(ident.org, org_id):
|
|
128
|
+
raise NotAllowed("org")
|
|
129
|
+
allowed = {t.strip().lower() for t in tenant_ids if t and t.strip()}
|
|
130
|
+
if allowed and ident.idp == "entra" and ident.tid not in allowed:
|
|
131
|
+
raise NotAllowed("tenant")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def org_id_from_env() -> str:
|
|
135
|
+
return os.environ.get("SCALIO_ORG_ID", "").strip()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Broker:
|
|
139
|
+
def __init__(self, issuer: str, client_id: str, transport: httpx.AsyncBaseTransport | None = None,
|
|
140
|
+
callback_path: str = CALLBACK_PATH):
|
|
141
|
+
self.issuer = (issuer or "").strip().rstrip("/")
|
|
142
|
+
self.client_id = origin(client_id)
|
|
143
|
+
self.callback_path = callback_path
|
|
144
|
+
self.transport = transport # tests: httpx.MockTransport (testing.FakeBroker)
|
|
145
|
+
self._jwks: dict = {}
|
|
146
|
+
self._jwks_at = 0.0
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_env(cls, fallback_origin: str = "", **kw) -> Broker:
|
|
150
|
+
"""SCALIO_ID_ISSUER (empty = off), SCALIO_ID_CLIENT_ID (default: origin of fallback_origin)."""
|
|
151
|
+
return cls(os.environ.get("SCALIO_ID_ISSUER", ""),
|
|
152
|
+
os.environ.get("SCALIO_ID_CLIENT_ID", "").strip() or fallback_origin, **kw)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def enabled(self) -> bool:
|
|
156
|
+
return bool(self.issuer and self.client_id)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def redirect_uri(self) -> str:
|
|
160
|
+
return self.client_id + self.callback_path
|
|
161
|
+
|
|
162
|
+
def authorize_url(self, flow: Flow, prompt: str = "", login_hint: str = "",
|
|
163
|
+
scope: str = "openid email profile") -> str:
|
|
164
|
+
q = {"client_id": self.client_id, "redirect_uri": self.redirect_uri, "response_type": "code",
|
|
165
|
+
"scope": scope, "state": flow.state, "nonce": flow.nonce, "code_challenge": flow.challenge,
|
|
166
|
+
"code_challenge_method": "S256"}
|
|
167
|
+
if prompt:
|
|
168
|
+
q["prompt"] = prompt
|
|
169
|
+
if login_hint:
|
|
170
|
+
q["login_hint"] = login_hint
|
|
171
|
+
return f"{self.issuer}/authorize?{urlencode(q)}"
|
|
172
|
+
|
|
173
|
+
def issuer_matches(self, iss: str | None) -> bool:
|
|
174
|
+
"""The callback's optional iss parameter (RFC 9207) must name this broker."""
|
|
175
|
+
return not iss or iss.rstrip("/") == self.issuer
|
|
176
|
+
|
|
177
|
+
def _client(self) -> httpx.AsyncClient:
|
|
178
|
+
return httpx.AsyncClient(transport=self.transport, timeout=15)
|
|
179
|
+
|
|
180
|
+
async def _key(self, kid: str):
|
|
181
|
+
if time.time() - self._jwks_at >= JWKS_TTL or kid not in self._jwks:
|
|
182
|
+
async with self._client() as c:
|
|
183
|
+
r = await c.get(f"{self.issuer}/.well-known/jwks.json")
|
|
184
|
+
r.raise_for_status()
|
|
185
|
+
self._jwks = {k["kid"]: k for k in r.json().get("keys", []) if k.get("kid")}
|
|
186
|
+
self._jwks_at = time.time()
|
|
187
|
+
if kid not in self._jwks:
|
|
188
|
+
raise IdentityRejected("unknown key")
|
|
189
|
+
return jwt.PyJWK(self._jwks[kid], algorithm="RS256").key
|
|
190
|
+
|
|
191
|
+
async def verify(self, id_token: str, nonce: str) -> Identity:
|
|
192
|
+
try:
|
|
193
|
+
header = jwt.get_unverified_header(id_token)
|
|
194
|
+
if header.get("alg") != "RS256":
|
|
195
|
+
raise IdentityRejected("algorithm")
|
|
196
|
+
c = jwt.decode(id_token, key=await self._key(str(header.get("kid") or "")), algorithms=["RS256"],
|
|
197
|
+
audience=self.client_id, issuer=self.issuer, leeway=LEEWAY,
|
|
198
|
+
options={"require": ["exp", "iat", "aud", "iss", "sub", "nonce"]})
|
|
199
|
+
except jwt.PyJWTError as e:
|
|
200
|
+
raise IdentityRejected(str(e)) from e
|
|
201
|
+
if not nonce or not secrets.compare_digest(str(c.get("nonce")), nonce):
|
|
202
|
+
raise IdentityRejected("nonce")
|
|
203
|
+
return Identity.from_claims(c)
|
|
204
|
+
|
|
205
|
+
async def redeem(self, code: str, verifier: str, nonce: str) -> Identity:
|
|
206
|
+
"""Code -> checked Identity. Raises CodeRejected, IdentityRejected or httpx.HTTPError."""
|
|
207
|
+
async with self._client() as c:
|
|
208
|
+
r = await c.post(f"{self.issuer}/token", data={
|
|
209
|
+
"grant_type": "authorization_code", "code": code, "client_id": self.client_id,
|
|
210
|
+
"redirect_uri": self.redirect_uri, "code_verifier": verifier})
|
|
211
|
+
if r.status_code != 200:
|
|
212
|
+
raise CodeRejected()
|
|
213
|
+
try:
|
|
214
|
+
token = str((r.json() or {}).get("id_token") or "")
|
|
215
|
+
except ValueError as e:
|
|
216
|
+
raise CodeRejected() from e
|
|
217
|
+
return await self.verify(token, nonce)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Second factor for local accounts: authenticator app (TOTP), backup codes and passkeys (WebAuthn).
|
|
2
|
+
|
|
3
|
+
Plain helpers without database or web framework, shared by every Scalio app (package scalio-identity, ported from
|
|
4
|
+
Scalio Compass). Routes and storage live in the app. Compared with Compass:
|
|
5
|
+
- a TOTP code works only once (the caller stores the returned time step),
|
|
6
|
+
- backup codes have 50 bits (10 characters), stored as sha256,
|
|
7
|
+
- passkeys are discoverable (sign-in without email) and need user verification (PIN, Face ID),
|
|
8
|
+
so a passkey alone counts as two factors.
|
|
9
|
+
"""
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import jwt
|
|
20
|
+
import pyotp
|
|
21
|
+
from webauthn import (
|
|
22
|
+
generate_authentication_options,
|
|
23
|
+
generate_registration_options,
|
|
24
|
+
options_to_json,
|
|
25
|
+
verify_authentication_response,
|
|
26
|
+
verify_registration_response,
|
|
27
|
+
)
|
|
28
|
+
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
|
|
29
|
+
from webauthn.helpers.structs import (
|
|
30
|
+
AuthenticatorSelectionCriteria,
|
|
31
|
+
PublicKeyCredentialDescriptor,
|
|
32
|
+
ResidentKeyRequirement,
|
|
33
|
+
UserVerificationRequirement,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
TOTP_STEP = 30
|
|
37
|
+
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I
|
|
38
|
+
CHALLENGE_TTL = timedelta(minutes=5)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------- Authenticator app (TOTP) ----------
|
|
42
|
+
def new_totp_secret() -> str:
|
|
43
|
+
return pyotp.random_base32()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def totp_uri(secret: str, account: str, issuer: str) -> str:
|
|
47
|
+
return pyotp.TOTP(secret).provisioning_uri(name=account, issuer_name=issuer)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def totp_match(secret: str, code: str, last_step: int = 0, at: float | None = None) -> int | None:
|
|
51
|
+
"""Time step of a valid code (one step clock drift either way), None if wrong or already used.
|
|
52
|
+
|
|
53
|
+
Store the returned step and pass it as last_step next time: a code is then accepted only once.
|
|
54
|
+
"""
|
|
55
|
+
code = "".join((code or "").split())
|
|
56
|
+
if not secret or len(code) != 6 or not code.isdigit():
|
|
57
|
+
return None
|
|
58
|
+
totp = pyotp.TOTP(secret)
|
|
59
|
+
now_step = int((time.time() if at is None else at) // TOTP_STEP)
|
|
60
|
+
for step in (now_step - 1, now_step, now_step + 1):
|
|
61
|
+
if step > (last_step or 0) and hmac.compare_digest(totp.at(step * TOTP_STEP), code):
|
|
62
|
+
return step
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------- Backup codes ----------
|
|
67
|
+
def _norm_code(code: str) -> str:
|
|
68
|
+
return "".join(ch for ch in (code or "").upper() if ch.isalnum())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def hash_backup_code(code: str) -> str:
|
|
72
|
+
return hashlib.sha256(_norm_code(code).encode()).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def new_backup_codes(n: int = 10) -> tuple[list[str], list[str]]:
|
|
76
|
+
"""n codes like ABCDE-FGH23 (shown once) and their hashes (stored)."""
|
|
77
|
+
codes = []
|
|
78
|
+
while len(codes) < n:
|
|
79
|
+
raw = "".join(secrets.choice(CODE_ALPHABET) for _ in range(10))
|
|
80
|
+
code = f"{raw[:5]}-{raw[5:]}"
|
|
81
|
+
if code not in codes:
|
|
82
|
+
codes.append(code)
|
|
83
|
+
return codes, [hash_backup_code(c) for c in codes]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def use_backup_code(code: str, hashes: list[str]) -> list[str] | None:
|
|
87
|
+
"""Remaining hashes if the code is valid, else None."""
|
|
88
|
+
if not _norm_code(code):
|
|
89
|
+
return None
|
|
90
|
+
h = hash_backup_code(code)
|
|
91
|
+
if not any(hmac.compare_digest(h, x) for x in hashes or []):
|
|
92
|
+
return None
|
|
93
|
+
return [x for x in hashes if x != h]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------- Sign-in step 2 token ----------
|
|
97
|
+
def issue_mfa_token(user_id: str, secret: str, minutes: int = 5) -> tuple[str, str]:
|
|
98
|
+
"""Short-lived token between password and code. Not usable as an access token."""
|
|
99
|
+
jti = uuid.uuid4().hex
|
|
100
|
+
payload = {"sub": user_id, "type": "mfa", "jti": jti,
|
|
101
|
+
"exp": datetime.now(timezone.utc) + timedelta(minutes=minutes)}
|
|
102
|
+
return jwt.encode(payload, secret, algorithm="HS256"), jti
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_mfa_token(token: str, secret: str) -> dict:
|
|
106
|
+
try:
|
|
107
|
+
payload = jwt.decode(token or "", secret, algorithms=["HS256"])
|
|
108
|
+
except jwt.PyJWTError as e:
|
|
109
|
+
raise ValueError("Sign-in expired. Please sign in again.") from e
|
|
110
|
+
if payload.get("type") != "mfa" or not payload.get("jti"):
|
|
111
|
+
raise ValueError("Invalid sign-in token")
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------- Passkeys (WebAuthn) ----------
|
|
116
|
+
def relying_party(origin: str, rp_id: str | None = None) -> tuple[str, str]:
|
|
117
|
+
"""(rp_id, origin) for the browser origin. rp_id fixes the domain (e.g. WEBAUTHN_RP_ID)."""
|
|
118
|
+
p = urlparse(origin or "")
|
|
119
|
+
host = (p.hostname or "").lower()
|
|
120
|
+
if not host or p.scheme not in ("https", "http") or (p.scheme == "http" and host != "localhost"):
|
|
121
|
+
raise ValueError("Passkeys need the app's https address")
|
|
122
|
+
if rp_id:
|
|
123
|
+
rp_id = rp_id.lower()
|
|
124
|
+
if host != rp_id and not host.endswith("." + rp_id):
|
|
125
|
+
raise ValueError("This address cannot use passkeys")
|
|
126
|
+
return rp_id, f"{p.scheme}://{p.netloc}"
|
|
127
|
+
return host, f"{p.scheme}://{p.netloc}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def registration_options(rp_id: str, rp_name: str, user_handle: bytes, user_name: str, display_name: str,
|
|
131
|
+
exclude_ids: list[str]) -> tuple[dict, str]:
|
|
132
|
+
opts = generate_registration_options(
|
|
133
|
+
rp_id=rp_id, rp_name=rp_name, user_id=user_handle, user_name=user_name,
|
|
134
|
+
user_display_name=display_name or user_name,
|
|
135
|
+
exclude_credentials=[PublicKeyCredentialDescriptor(id=base64url_to_bytes(i)) for i in exclude_ids],
|
|
136
|
+
authenticator_selection=AuthenticatorSelectionCriteria(
|
|
137
|
+
resident_key=ResidentKeyRequirement.REQUIRED, require_resident_key=True,
|
|
138
|
+
user_verification=UserVerificationRequirement.REQUIRED))
|
|
139
|
+
return json.loads(options_to_json(opts)), bytes_to_base64url(opts.challenge)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_registration(response: dict, challenge: str, rp_id: str, origin: str) -> dict:
|
|
143
|
+
v = verify_registration_response(credential=response, expected_challenge=base64url_to_bytes(challenge),
|
|
144
|
+
expected_rp_id=rp_id, expected_origin=origin, require_user_verification=True)
|
|
145
|
+
return {"id": bytes_to_base64url(v.credential_id), "public_key": bytes_to_base64url(v.credential_public_key),
|
|
146
|
+
"sign_count": v.sign_count}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def authentication_options(rp_id: str) -> tuple[dict, str]:
|
|
150
|
+
"""Sign-in without email: the browser offers the passkeys it has for this domain."""
|
|
151
|
+
opts = generate_authentication_options(rp_id=rp_id, user_verification=UserVerificationRequirement.REQUIRED)
|
|
152
|
+
return json.loads(options_to_json(opts)), bytes_to_base64url(opts.challenge)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def user_handle(response: dict) -> bytes:
|
|
156
|
+
raw = ((response or {}).get("response") or {}).get("userHandle") or ""
|
|
157
|
+
return base64url_to_bytes(raw) if raw else b""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_authentication(response: dict, challenge: str, rp_id: str, origin: str, public_key: str,
|
|
161
|
+
sign_count: int) -> int:
|
|
162
|
+
"""New sign count; raises if the signature, challenge, origin or user verification is wrong."""
|
|
163
|
+
v = verify_authentication_response(credential=response, expected_challenge=base64url_to_bytes(challenge),
|
|
164
|
+
expected_rp_id=rp_id, expected_origin=origin,
|
|
165
|
+
credential_public_key=base64url_to_bytes(public_key),
|
|
166
|
+
credential_current_sign_count=sign_count or 0,
|
|
167
|
+
require_user_verification=True)
|
|
168
|
+
return v.new_sign_count
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Entra app roles -> the app's own role (idm/I3).
|
|
2
|
+
|
|
3
|
+
Scalio defines the app roles once in the manifest of the Scalio enterprise app, with one prefix per product
|
|
4
|
+
(`Nexus.Admin`, `Citadel.Reader`, ...). The customer's IT assigns people (free) or groups (Entra ID P1) in their
|
|
5
|
+
own tenant, and the claim `roles` carries them. Each app maps the part after its prefix to one of its own roles:
|
|
6
|
+
|
|
7
|
+
ROLES = RoleMap("Nexus", {"Admin": "admin", "HelpDesk": "helpdesk", "Reader": "viewer"})
|
|
8
|
+
ROLES.resolve(ident.roles) # -> "admin", or None when no known role is assigned
|
|
9
|
+
|
|
10
|
+
Rules: only roles with this app's prefix count; unknown values give no rights; several roles -> the strongest,
|
|
11
|
+
in the order of the mapping (first = strongest). Prefix and names are case sensitive like in Entra.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RoleMap:
|
|
17
|
+
def __init__(self, prefix: str, mapping: dict[str, str]):
|
|
18
|
+
if not prefix or "." in prefix:
|
|
19
|
+
raise ValueError("prefix is the product name without a dot, e.g. 'Nexus'")
|
|
20
|
+
if not mapping:
|
|
21
|
+
raise ValueError("mapping needs at least one role")
|
|
22
|
+
self.prefix = prefix
|
|
23
|
+
self.mapping = dict(mapping)
|
|
24
|
+
self._rank = {name: i for i, name in enumerate(self.mapping)}
|
|
25
|
+
|
|
26
|
+
def values(self) -> list[str]:
|
|
27
|
+
"""Role values for the Entra manifest, strongest first (e.g. ['Nexus.Admin', 'Nexus.Reader'])."""
|
|
28
|
+
return [f"{self.prefix}.{name}" for name in self.mapping]
|
|
29
|
+
|
|
30
|
+
def own(self, roles) -> list[str]:
|
|
31
|
+
"""The known roles of this app among `roles`, strongest first, without prefix."""
|
|
32
|
+
start = self.prefix + "."
|
|
33
|
+
names = {r[len(start):] for r in roles or () if isinstance(r, str) and r.startswith(start)}
|
|
34
|
+
return sorted((n for n in names if n in self._rank), key=self._rank.__getitem__)
|
|
35
|
+
|
|
36
|
+
def resolve(self, roles) -> str | None:
|
|
37
|
+
"""The app role for the strongest assigned Entra role, or None."""
|
|
38
|
+
own = self.own(roles)
|
|
39
|
+
return self.mapping[own[0]] if own else None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""A Scalio ID broker in memory for app tests (httpx.MockTransport). Only for tests.
|
|
2
|
+
|
|
3
|
+
fake = FakeBroker("https://ssodev.example", client_id="https://app.example")
|
|
4
|
+
broker = Broker(fake.issuer, fake.client_id, transport=fake.transport)
|
|
5
|
+
code = fake.issue_code(nonce=flow.nonce, verifier=flow.verifier, tid="t1", oid="o1", roles=["Nexus.Admin"])
|
|
6
|
+
ident = await broker.redeem(code, flow.verifier, flow.nonce)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from urllib.parse import parse_qsl
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import jwt
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _b64u_int(n: int) -> str:
|
|
22
|
+
raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
|
23
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeBroker:
|
|
27
|
+
def __init__(self, issuer: str = "https://sso.test", client_id: str = "https://app.test", kid: str = "k1"):
|
|
28
|
+
self.issuer, self.client_id, self.kid = issuer.rstrip("/"), client_id, kid
|
|
29
|
+
self.key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
30
|
+
self._codes: dict[str, dict] = {}
|
|
31
|
+
self.transport = httpx.MockTransport(self._handle)
|
|
32
|
+
|
|
33
|
+
def jwks(self) -> dict:
|
|
34
|
+
n = self.key.public_key().public_numbers()
|
|
35
|
+
return {"keys": [{"kty": "RSA", "kid": self.kid, "use": "sig", "alg": "RS256",
|
|
36
|
+
"n": _b64u_int(n.n), "e": _b64u_int(n.e)}]}
|
|
37
|
+
|
|
38
|
+
def token(self, nonce: str, sub: str = "u1", idp: str = "entra", aud: str = "", **claims) -> str:
|
|
39
|
+
now = int(time.time())
|
|
40
|
+
body = {"iss": self.issuer, "aud": aud or self.client_id, "sub": sub, "iat": now, "exp": now + 300,
|
|
41
|
+
"nonce": nonce, "idp": idp, **{k: v for k, v in claims.items() if v is not None}}
|
|
42
|
+
return jwt.encode(body, self.key, algorithm="RS256", headers={"kid": self.kid})
|
|
43
|
+
|
|
44
|
+
def issue_code(self, nonce: str, verifier: str, **claims) -> str:
|
|
45
|
+
code = secrets.token_urlsafe(16)
|
|
46
|
+
self._codes[code] = {"verifier": verifier, "token": self.token(nonce, **claims)}
|
|
47
|
+
return code
|
|
48
|
+
|
|
49
|
+
def _handle(self, request: httpx.Request) -> httpx.Response:
|
|
50
|
+
path = request.url.path
|
|
51
|
+
if path == "/.well-known/jwks.json":
|
|
52
|
+
return httpx.Response(200, json=self.jwks())
|
|
53
|
+
if path == "/token" and request.method == "POST":
|
|
54
|
+
form = dict(parse_qsl(request.content.decode()))
|
|
55
|
+
rec = self._codes.pop(form.get("code", ""), None)
|
|
56
|
+
challenge_ok = rec and secrets.compare_digest(
|
|
57
|
+
hashlib.sha256(form.get("code_verifier", "").encode()).digest(),
|
|
58
|
+
hashlib.sha256(rec["verifier"].encode()).digest())
|
|
59
|
+
if not rec or not challenge_ok or form.get("client_id") != self.client_id:
|
|
60
|
+
return httpx.Response(400, json={"error": "invalid_grant"})
|
|
61
|
+
return httpx.Response(200, json={"id_token": rec["token"], "token_type": "Bearer"})
|
|
62
|
+
return httpx.Response(404)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""A software passkey for tests: builds WebAuthn responses like a browser plus authenticator would.
|
|
2
|
+
|
|
3
|
+
Only for tests. ES256 key, attestation "none", user presence and user verification set.
|
|
4
|
+
"""
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import cbor2
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def b64u(data: bytes) -> str:
|
|
16
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unb64u(text: str) -> bytes:
|
|
20
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SoftKey:
|
|
24
|
+
def __init__(self, rp_id: str, origin: str):
|
|
25
|
+
self.rp_id, self.origin = rp_id, origin
|
|
26
|
+
self.key = ec.generate_private_key(ec.SECP256R1())
|
|
27
|
+
self.cred_id = os.urandom(32)
|
|
28
|
+
self.user_handle = b""
|
|
29
|
+
self.counter = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def id(self) -> str:
|
|
33
|
+
return b64u(self.cred_id)
|
|
34
|
+
|
|
35
|
+
def _client_data(self, kind: str, challenge: str, origin: str | None) -> bytes:
|
|
36
|
+
return json.dumps({"type": kind, "challenge": challenge, "origin": origin or self.origin,
|
|
37
|
+
"crossOrigin": False}).encode()
|
|
38
|
+
|
|
39
|
+
def _cose_key(self) -> bytes:
|
|
40
|
+
nums = self.key.public_key().public_numbers()
|
|
41
|
+
return cbor2.dumps({1: 2, 3: -7, -1: 1, -2: nums.x.to_bytes(32, "big"), -3: nums.y.to_bytes(32, "big")})
|
|
42
|
+
|
|
43
|
+
def register(self, options: dict, origin: str | None = None, rp_id: str | None = None) -> dict:
|
|
44
|
+
"""Answer to navigator.credentials.create() for the given options."""
|
|
45
|
+
self.user_handle = unb64u(options["user"]["id"])
|
|
46
|
+
rp_hash = hashlib.sha256((rp_id or self.rp_id).encode()).digest()
|
|
47
|
+
attested = bytes(16) + len(self.cred_id).to_bytes(2, "big") + self.cred_id + self._cose_key()
|
|
48
|
+
auth_data = rp_hash + bytes([0x45]) + self.counter.to_bytes(4, "big") + attested
|
|
49
|
+
att = cbor2.dumps({"fmt": "none", "attStmt": {}, "authData": auth_data})
|
|
50
|
+
return {"id": self.id, "rawId": self.id, "type": "public-key",
|
|
51
|
+
"response": {"clientDataJSON": b64u(self._client_data("webauthn.create", options["challenge"], origin)),
|
|
52
|
+
"attestationObject": b64u(att), "transports": ["internal"]},
|
|
53
|
+
"clientExtensionResults": {}}
|
|
54
|
+
|
|
55
|
+
def assert_(self, options: dict, origin: str | None = None, uv: bool = True) -> dict:
|
|
56
|
+
"""Answer to navigator.credentials.get() for the given options."""
|
|
57
|
+
self.counter += 1
|
|
58
|
+
rp_hash = hashlib.sha256(self.rp_id.encode()).digest()
|
|
59
|
+
auth_data = rp_hash + bytes([0x05 if uv else 0x01]) + self.counter.to_bytes(4, "big")
|
|
60
|
+
client = self._client_data("webauthn.get", options["challenge"], origin)
|
|
61
|
+
sig = self.key.sign(auth_data + hashlib.sha256(client).digest(), ec.ECDSA(hashes.SHA256()))
|
|
62
|
+
return {"id": self.id, "rawId": self.id, "type": "public-key",
|
|
63
|
+
"response": {"clientDataJSON": b64u(client), "authenticatorData": b64u(auth_data),
|
|
64
|
+
"signature": b64u(sig), "userHandle": b64u(self.user_handle)},
|
|
65
|
+
"clientExtensionResults": {}}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scalio-identity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sign-in through Scalio ID, Entra app roles and second factor, shared by the Scalio apps
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: PyJWT[crypto]>=2.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: mfa
|
|
11
|
+
Requires-Dist: pyotp>=2.9; extra == "mfa"
|
|
12
|
+
Requires-Dist: webauthn>=2.0; extra == "mfa"
|
|
13
|
+
Provides-Extra: testing
|
|
14
|
+
Requires-Dist: cbor2>=5.6; extra == "testing"
|
|
15
|
+
Requires-Dist: cryptography>=42; extra == "testing"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
19
|
+
Requires-Dist: pyotp>=2.9; extra == "dev"
|
|
20
|
+
Requires-Dist: webauthn>=2.0; extra == "dev"
|
|
21
|
+
Requires-Dist: cbor2>=5.6; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# scalio-identity
|
|
25
|
+
|
|
26
|
+
Identity helpers shared by the Scalio apps Nexus, Citadel, Rosetta and Foundry. Decisions: scalio-docs ADR 0028
|
|
27
|
+
(`idm/I1`–`I12`), concept `concepts/central-user-management.md`. Protocol of the broker: scalio-docs
|
|
28
|
+
`interfaces/scalio-id-oidc.md`.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install "scalio-identity[mfa]" # app
|
|
32
|
+
pip install "scalio-identity[mfa,testing]" # app tests
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Module | What it does |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `scalio_identity.broker` | Sign-in through Scalio ID: `Broker.from_env()`, `Flow.new()`, `authorize_url()`, `redeem()` → `Identity`; `check_binding()` refuses another organisation (`SCALIO_ORG_ID`) or tenant |
|
|
38
|
+
| `scalio_identity.roles` | `RoleMap("Nexus", {"Admin": "admin", ...})`: Entra app roles (`Nexus.Admin`) → app role, unknown = no rights |
|
|
39
|
+
| `scalio_identity.accounts` | Account lookup by Scalio ID subject, then Microsoft tenant + object id; e-mail only for a one-time link |
|
|
40
|
+
| `scalio_identity.mfa` | TOTP (replay safe), backup codes, passkeys (WebAuthn, user verification required) |
|
|
41
|
+
| `scalio_identity.testing` | `FakeBroker` (Scalio ID in memory, `httpx.MockTransport`) and `SoftKey` (software passkey) |
|
|
42
|
+
|
|
43
|
+
The app keeps its routes, state storage, sessions and accounts; this package only checks.
|
|
44
|
+
|
|
45
|
+
## Settings of an app
|
|
46
|
+
|
|
47
|
+
| Variable | Meaning |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `SCALIO_ID_ISSUER` | broker, e.g. `https://sso.scaliocloud.com`; empty = Scalio ID off |
|
|
50
|
+
| `SCALIO_ID_CLIENT_ID` | the app's origin; default from the app's own URL |
|
|
51
|
+
| `SCALIO_ORG_ID` | Scalio organisation of a SaaS instance (set at provisioning); empty on self-hosted |
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
ruff check . && pytest -q
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Release: bump `pyproject.toml` and `__version__`, CHANGELOG line, merge, then "Freigeben" in the Scalio Dev
|
|
61
|
+
cockpit (or Actions → Publish with the version). PyPI trusted publishing, environment `pypi`, no token.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/scalio_identity/__init__.py
|
|
4
|
+
src/scalio_identity/accounts.py
|
|
5
|
+
src/scalio_identity/broker.py
|
|
6
|
+
src/scalio_identity/mfa.py
|
|
7
|
+
src/scalio_identity/roles.py
|
|
8
|
+
src/scalio_identity.egg-info/PKG-INFO
|
|
9
|
+
src/scalio_identity.egg-info/SOURCES.txt
|
|
10
|
+
src/scalio_identity.egg-info/dependency_links.txt
|
|
11
|
+
src/scalio_identity.egg-info/requires.txt
|
|
12
|
+
src/scalio_identity.egg-info/top_level.txt
|
|
13
|
+
src/scalio_identity/testing/__init__.py
|
|
14
|
+
src/scalio_identity/testing/fake_broker.py
|
|
15
|
+
src/scalio_identity/testing/softkey.py
|
|
16
|
+
tests/test_accounts.py
|
|
17
|
+
tests/test_broker.py
|
|
18
|
+
tests/test_mfa.py
|
|
19
|
+
tests/test_roles.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scalio_identity
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from scalio_identity import accounts
|
|
2
|
+
from scalio_identity.broker import Identity
|
|
3
|
+
|
|
4
|
+
MS = Identity(sub="s1", idp="entra", tid="t1", oid="o1", email="pia@acme.ch")
|
|
5
|
+
NEXUS = accounts.Fields(sub="scalioSub", tid="entraTenantId", oid="entraObjectId")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_lookup_by_subject_then_microsoft_ids_never_email():
|
|
9
|
+
assert accounts.lookups(MS) == [{"scalio_sub": "s1"}, {"scalio_tid": "t1", "scalio_oid": "o1"}]
|
|
10
|
+
assert accounts.lookups(MS, NEXUS)[1] == {"entraTenantId": "t1", "entraObjectId": "o1"}
|
|
11
|
+
assert all("email" not in q for q in accounts.lookups(MS))
|
|
12
|
+
assert accounts.lookups(Identity(sub="s2", idp="scalio", email="x@y.ch")) == [{"scalio_sub": "s2"}]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_email_link_only_for_accounts_without_identity():
|
|
16
|
+
f = accounts.email_link_filter(MS)
|
|
17
|
+
assert f == {"email": "pia@acme.ch", "scalio_sub": {"$exists": False}, "scalio_oid": {"$exists": False}}
|
|
18
|
+
assert accounts.email_link_filter(Identity(sub="s", email="")) is None
|
|
19
|
+
assert accounts.email_link_filter(Identity(sub="s", email="no-at")) is None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_link_fields():
|
|
23
|
+
assert accounts.link_fields(MS) == {"scalio_sub": "s1", "scalio_tid": "t1", "scalio_oid": "o1"}
|
|
24
|
+
assert accounts.link_fields(Identity(sub="s3", idp="scalio")) == {"scalio_sub": "s3"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_same_person_guards_stored_microsoft_ids():
|
|
28
|
+
assert accounts.same_person({}, MS)
|
|
29
|
+
assert accounts.same_person({"scalio_tid": "T1", "scalio_oid": "O1"}, MS)
|
|
30
|
+
assert not accounts.same_person({"scalio_tid": "t1", "scalio_oid": "other"}, MS)
|
|
31
|
+
assert not accounts.same_person({"scalio_tid": "t1", "scalio_oid": "o1"}, Identity(sub="s1", idp="scalio"))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Sign-in through Scalio ID: authorize URL, code redemption, token checks, instance binding."""
|
|
2
|
+
from urllib.parse import parse_qs, urlsplit
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from scalio_identity import Broker, CodeRejected, Flow, IdentityRejected, NotAllowed, check_binding, error_code
|
|
8
|
+
from scalio_identity.broker import Identity, origin
|
|
9
|
+
from scalio_identity.testing import FakeBroker
|
|
10
|
+
|
|
11
|
+
APP = "https://acme.ch.scalionexus.com"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def fake():
|
|
16
|
+
return FakeBroker("https://sso.test", client_id=APP)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def broker(fake):
|
|
21
|
+
return Broker(fake.issuer, APP + "/login?x=1", transport=fake.transport)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_client_id_is_the_origin_and_off_without_issuer():
|
|
25
|
+
assert origin("HTTPS://Acme.Example:8443/path?q=1") == "https://acme.example:8443"
|
|
26
|
+
assert origin("javascript:alert(1)") == "" and origin("") == ""
|
|
27
|
+
assert not Broker("", APP).enabled
|
|
28
|
+
assert not Broker("https://sso.test", "ftp://x").enabled
|
|
29
|
+
assert Broker("https://sso.test/", APP).issuer == "https://sso.test"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_from_env(monkeypatch):
|
|
33
|
+
monkeypatch.setenv("SCALIO_ID_ISSUER", "https://ssodev.scaliocloud.com/")
|
|
34
|
+
monkeypatch.delenv("SCALIO_ID_CLIENT_ID", raising=False)
|
|
35
|
+
b = Broker.from_env(fallback_origin=APP + "/")
|
|
36
|
+
assert b.enabled and b.client_id == APP and b.redirect_uri == APP + "/api/auth/scalio/callback"
|
|
37
|
+
monkeypatch.setenv("SCALIO_ID_CLIENT_ID", "https://other.example")
|
|
38
|
+
assert Broker.from_env(fallback_origin=APP).client_id == "https://other.example"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_authorize_url_has_pkce_nonce_and_prompt(broker):
|
|
42
|
+
flow = Flow.new()
|
|
43
|
+
url = broker.authorize_url(flow, prompt="none", login_hint="pia@acme.ch")
|
|
44
|
+
parts = urlsplit(url)
|
|
45
|
+
q = {k: v[0] for k, v in parse_qs(parts.query).items()}
|
|
46
|
+
assert f"{parts.scheme}://{parts.netloc}{parts.path}" == "https://sso.test/authorize"
|
|
47
|
+
assert q["client_id"] == APP and q["redirect_uri"] == APP + "/api/auth/scalio/callback"
|
|
48
|
+
assert q["code_challenge_method"] == "S256" and q["code_challenge"] == flow.challenge != flow.verifier
|
|
49
|
+
assert q["state"] == flow.state and q["nonce"] == flow.nonce and q["prompt"] == "none"
|
|
50
|
+
assert q["login_hint"] == "pia@acme.ch"
|
|
51
|
+
assert "prompt" not in broker.authorize_url(flow)
|
|
52
|
+
assert len({flow.state, flow.nonce, flow.verifier}) == 3
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def test_redeem_returns_checked_identity(fake, broker):
|
|
56
|
+
flow = Flow.new()
|
|
57
|
+
code = fake.issue_code(flow.nonce, flow.verifier, sub="s1", tid="TID-1", oid="OID-1", email="Pia@Acme.ch",
|
|
58
|
+
name=" Pia ", org="org-acme", app_role="admin", roles=["Nexus.Admin", "Citadel.Reader"],
|
|
59
|
+
via_msp="msp-1")
|
|
60
|
+
ident = await broker.redeem(code, flow.verifier, flow.nonce)
|
|
61
|
+
assert (ident.sub, ident.tid, ident.oid, ident.email, ident.name) == ("s1", "tid-1", "oid-1", "pia@acme.ch", "Pia")
|
|
62
|
+
assert ident.is_microsoft and ident.org == "org-acme" and ident.app_role == "admin" and ident.via_msp == "msp-1"
|
|
63
|
+
assert ident.roles == ("Nexus.Admin", "Citadel.Reader")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_code_is_single_use_and_needs_the_verifier(fake, broker):
|
|
67
|
+
flow = Flow.new()
|
|
68
|
+
code = fake.issue_code(flow.nonce, flow.verifier)
|
|
69
|
+
with pytest.raises(CodeRejected):
|
|
70
|
+
await broker.redeem(code, "wrong-verifier", flow.nonce)
|
|
71
|
+
code = fake.issue_code(flow.nonce, flow.verifier)
|
|
72
|
+
await broker.redeem(code, flow.verifier, flow.nonce)
|
|
73
|
+
with pytest.raises(CodeRejected):
|
|
74
|
+
await broker.redeem(code, flow.verifier, flow.nonce)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize("bad", ["nonce", "aud", "iss", "expired", "alg", "kid", "sig"])
|
|
78
|
+
async def test_bad_tokens_are_refused(fake, broker, bad):
|
|
79
|
+
nonce = "n-1"
|
|
80
|
+
if bad == "nonce":
|
|
81
|
+
token = fake.token("other")
|
|
82
|
+
elif bad == "aud":
|
|
83
|
+
token = fake.token(nonce, aud="https://evil.example")
|
|
84
|
+
elif bad == "iss":
|
|
85
|
+
token = fake.token(nonce, iss="https://evil.example")
|
|
86
|
+
elif bad == "expired":
|
|
87
|
+
token = fake.token(nonce, iat=1, exp=2)
|
|
88
|
+
elif bad == "alg":
|
|
89
|
+
token = jwt.encode({"sub": "x", "nonce": nonce}, "k" * 32, algorithm="HS256", headers={"kid": fake.kid})
|
|
90
|
+
elif bad == "kid":
|
|
91
|
+
token = jwt.encode({"sub": "x"}, fake.key, algorithm="RS256", headers={"kid": "nope"})
|
|
92
|
+
else:
|
|
93
|
+
token = jwt.encode({"iss": fake.issuer, "aud": APP, "sub": "x", "iat": 9e9, "exp": 9e9, "nonce": nonce},
|
|
94
|
+
FakeBroker().key, algorithm="RS256", headers={"kid": fake.kid})
|
|
95
|
+
with pytest.raises(IdentityRejected):
|
|
96
|
+
await broker.verify(token, nonce)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def test_empty_nonce_is_refused(fake, broker):
|
|
100
|
+
with pytest.raises(IdentityRejected):
|
|
101
|
+
await broker.verify(fake.token(""), "")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_issuer_parameter_must_match(broker):
|
|
105
|
+
assert broker.issuer_matches(None) and broker.issuer_matches("https://sso.test/")
|
|
106
|
+
assert not broker.issuer_matches("https://evil.example")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_error_codes():
|
|
110
|
+
assert error_code("access_denied", "entra_personal_account") == "personal"
|
|
111
|
+
assert error_code("access_denied", "entra_tenant_not_allowed") == "tenant_not_allowed"
|
|
112
|
+
assert error_code("access_denied", "microsoft_access_denied") == "cancelled"
|
|
113
|
+
assert error_code("access_denied", "consent_denied") == "cancelled"
|
|
114
|
+
assert error_code("server_error") == "failed"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_binding_refuses_other_organisation_and_tenant():
|
|
118
|
+
ident = Identity(sub="s", idp="entra", tid="t-a", oid="o", org="org-a")
|
|
119
|
+
check_binding(ident)
|
|
120
|
+
check_binding(ident, org_id="org-a", tenant_ids=["T-A", ""])
|
|
121
|
+
with pytest.raises(NotAllowed) as e:
|
|
122
|
+
check_binding(ident, org_id="org-b")
|
|
123
|
+
assert e.value.reason == "org"
|
|
124
|
+
# an instance bound to an organisation refuses tokens without one (old broker, other client)
|
|
125
|
+
with pytest.raises(NotAllowed):
|
|
126
|
+
check_binding(Identity(sub="s", idp="entra", tid="t-a", oid="o"), org_id="org-a")
|
|
127
|
+
with pytest.raises(NotAllowed) as e:
|
|
128
|
+
check_binding(ident, tenant_ids=["t-b"])
|
|
129
|
+
assert e.value.reason == "tenant"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Second factor helpers (mfa.py): TOTP with replay protection, backup codes, MFA tokens, passkeys."""
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pyotp
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from scalio_identity import mfa
|
|
8
|
+
from scalio_identity.testing.softkey import SoftKey
|
|
9
|
+
|
|
10
|
+
ORIGIN, RP = "https://dev.scalionexus.com", "dev.scalionexus.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_totp_accepts_current_code_once():
|
|
14
|
+
secret = mfa.new_totp_secret()
|
|
15
|
+
now = time.time()
|
|
16
|
+
code = pyotp.TOTP(secret).at(now)
|
|
17
|
+
step = mfa.totp_match(secret, code, last_step=0, at=now)
|
|
18
|
+
assert step == int(now // 30)
|
|
19
|
+
# the same code again (replay) is refused
|
|
20
|
+
assert mfa.totp_match(secret, code, last_step=step, at=now) is None
|
|
21
|
+
# spaces are fine, wrong codes not
|
|
22
|
+
assert mfa.totp_match(secret, f"{code[:3]} {code[3:]}", at=now) == step
|
|
23
|
+
assert mfa.totp_match(secret, "000000" if code != "000000" else "111111", at=now) is None
|
|
24
|
+
assert mfa.totp_match(secret, "", at=now) is None
|
|
25
|
+
assert mfa.totp_match("", code, at=now) is None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_totp_allows_one_step_clock_drift_only():
|
|
29
|
+
secret = mfa.new_totp_secret()
|
|
30
|
+
now = 1_800_000_000
|
|
31
|
+
assert mfa.totp_match(secret, pyotp.TOTP(secret).at(now - 30), at=now) is not None
|
|
32
|
+
assert mfa.totp_match(secret, pyotp.TOTP(secret).at(now + 30), at=now) is not None
|
|
33
|
+
assert mfa.totp_match(secret, pyotp.TOTP(secret).at(now - 90), at=now) is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_totp_uri_names_issuer_and_account():
|
|
37
|
+
uri = mfa.totp_uri("JBSWY3DPEHPK3PXP", "pia@example.com", "Scalio Nexus")
|
|
38
|
+
assert uri.startswith("otpauth://totp/Scalio%20Nexus:pia%40example.com?")
|
|
39
|
+
assert "issuer=Scalio%20Nexus" in uri
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_backup_codes_are_strong_single_use_and_hashed():
|
|
43
|
+
codes, hashes = mfa.new_backup_codes()
|
|
44
|
+
assert len(codes) == 10 == len(set(codes))
|
|
45
|
+
assert all(len(c) == 11 and c[5] == "-" for c in codes)
|
|
46
|
+
assert not set(codes) & set(hashes)
|
|
47
|
+
# lower case and without dash also work
|
|
48
|
+
left = mfa.use_backup_code(codes[0].lower().replace("-", ""), hashes)
|
|
49
|
+
assert left is not None and len(left) == 9
|
|
50
|
+
assert mfa.use_backup_code(codes[0], left) is None
|
|
51
|
+
assert mfa.use_backup_code("", hashes) is None
|
|
52
|
+
assert mfa.hash_backup_code(codes[1]) in hashes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_mfa_token_roundtrip_and_expiry():
|
|
56
|
+
token, jti = mfa.issue_mfa_token("u1", "unit-test-secret-with-32-bytes-ok!!", minutes=5)
|
|
57
|
+
p = mfa.read_mfa_token(token, "unit-test-secret-with-32-bytes-ok!!")
|
|
58
|
+
assert p["sub"] == "u1" and p["jti"] == jti
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
mfa.read_mfa_token(token, "another-unit-test-secret-32-bytes!!")
|
|
61
|
+
old, _ = mfa.issue_mfa_token("u1", "unit-test-secret-with-32-bytes-ok!!", minutes=-1)
|
|
62
|
+
with pytest.raises(ValueError):
|
|
63
|
+
mfa.read_mfa_token(old, "unit-test-secret-with-32-bytes-ok!!")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_mfa_token_is_not_an_access_token():
|
|
67
|
+
import jwt
|
|
68
|
+
access = jwt.encode({"sub": "u1", "type": "access"}, "unit-test-secret-with-32-bytes-ok!!", algorithm="HS256")
|
|
69
|
+
with pytest.raises(ValueError):
|
|
70
|
+
mfa.read_mfa_token(access, "unit-test-secret-with-32-bytes-ok!!")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_rp_comes_from_origin_or_fixed_rp_id():
|
|
74
|
+
assert mfa.relying_party(ORIGIN) == (RP, ORIGIN)
|
|
75
|
+
assert mfa.relying_party("http://localhost:3000") == ("localhost", "http://localhost:3000")
|
|
76
|
+
assert mfa.relying_party("https://dev.scalionexus.com", rp_id="scalionexus.com") == ("scalionexus.com", ORIGIN)
|
|
77
|
+
for bad in ("", "null", "https://evil.example.com"):
|
|
78
|
+
with pytest.raises(ValueError):
|
|
79
|
+
mfa.relying_party(bad, rp_id="scalionexus.com")
|
|
80
|
+
with pytest.raises(ValueError):
|
|
81
|
+
mfa.relying_party("http://dev.scalionexus.com") # plain http only for localhost
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_passkey_register_and_sign_in():
|
|
85
|
+
key = SoftKey(RP, ORIGIN)
|
|
86
|
+
options, challenge = mfa.registration_options(RP, "Scalio Nexus", b"user-1", "pia@example.com", "Pia", [])
|
|
87
|
+
assert options["authenticatorSelection"]["residentKey"] == "required"
|
|
88
|
+
assert options["authenticatorSelection"]["userVerification"] == "required"
|
|
89
|
+
cred = mfa.check_registration(key.register(options), challenge, RP, ORIGIN)
|
|
90
|
+
assert cred["id"] == key.id and cred["sign_count"] == 0 and cred["public_key"]
|
|
91
|
+
|
|
92
|
+
# usernameless: no allowCredentials, the authenticator picks the account
|
|
93
|
+
options, challenge = mfa.authentication_options(RP)
|
|
94
|
+
assert not options.get("allowCredentials")
|
|
95
|
+
assert options["userVerification"] == "required"
|
|
96
|
+
resp = key.assert_(options)
|
|
97
|
+
assert mfa.user_handle(resp) == b"user-1"
|
|
98
|
+
assert mfa.check_authentication(resp, challenge, RP, ORIGIN, cred["public_key"], cred["sign_count"]) == 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_passkey_refuses_wrong_origin_challenge_and_missing_verification():
|
|
102
|
+
key = SoftKey(RP, ORIGIN)
|
|
103
|
+
options, challenge = mfa.registration_options(RP, "Scalio Nexus", b"user-1", "pia@example.com", "Pia", [])
|
|
104
|
+
with pytest.raises(Exception):
|
|
105
|
+
mfa.check_registration(key.register(options, origin="https://evil.example.com"), challenge, RP, ORIGIN)
|
|
106
|
+
cred = mfa.check_registration(key.register(options), challenge, RP, ORIGIN)
|
|
107
|
+
|
|
108
|
+
options, challenge = mfa.authentication_options(RP)
|
|
109
|
+
other, _ = mfa.authentication_options(RP)
|
|
110
|
+
with pytest.raises(Exception):
|
|
111
|
+
mfa.check_authentication(key.assert_(other), challenge, RP, ORIGIN, cred["public_key"], 0)
|
|
112
|
+
with pytest.raises(Exception):
|
|
113
|
+
mfa.check_authentication(key.assert_(options, uv=False), challenge, RP, ORIGIN, cred["public_key"], 0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_registration_excludes_known_credentials():
|
|
117
|
+
options, _ = mfa.registration_options(RP, "Scalio Nexus", b"user-1", "pia@example.com", "Pia", ["AAAA"])
|
|
118
|
+
assert [c["id"] for c in options["excludeCredentials"]] == ["AAAA"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from scalio_identity import RoleMap
|
|
4
|
+
|
|
5
|
+
NEXUS = RoleMap("Nexus", {"Admin": "admin", "HelpDesk": "helpdesk", "Reader": "viewer"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_strongest_known_role_of_this_app_wins():
|
|
9
|
+
assert NEXUS.resolve(["Nexus.Reader", "Nexus.Admin"]) == "admin"
|
|
10
|
+
assert NEXUS.resolve(["Nexus.HelpDesk", "Citadel.Admin"]) == "helpdesk"
|
|
11
|
+
assert NEXUS.own(["Nexus.Reader", "Nexus.Admin", "Nexus.Admin"]) == ["Admin", "Reader"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.parametrize("roles", [None, [], ["Citadel.Admin"], ["Nexus.Owner"], ["nexus.admin"], ["Admin"],
|
|
15
|
+
["Nexus.Admin.Extra"], [42]])
|
|
16
|
+
def test_unknown_or_foreign_roles_give_no_rights(roles):
|
|
17
|
+
assert NEXUS.resolve(roles) is None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_manifest_values_and_bad_setup():
|
|
21
|
+
assert NEXUS.values() == ["Nexus.Admin", "Nexus.HelpDesk", "Nexus.Reader"]
|
|
22
|
+
with pytest.raises(ValueError):
|
|
23
|
+
RoleMap("Nexus.", {"Admin": "admin"})
|
|
24
|
+
with pytest.raises(ValueError):
|
|
25
|
+
RoleMap("Nexus", {})
|