tba-router-webauthn 0.4.0.dev2__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.
- tba_router_webauthn-0.4.0.dev2/PKG-INFO +22 -0
- tba_router_webauthn-0.4.0.dev2/README.md +4 -0
- tba_router_webauthn-0.4.0.dev2/pyproject.toml +19 -0
- tba_router_webauthn-0.4.0.dev2/src/tba_router_webauthn/__init__.py +5 -0
- tba_router_webauthn-0.4.0.dev2/src/tba_router_webauthn/binding.py +134 -0
- tba_router_webauthn-0.4.0.dev2/src/tba_router_webauthn/py.typed +1 -0
- tba_router_webauthn-0.4.0.dev2/src/tba_router_webauthn/schemas.py +52 -0
- tba_router_webauthn-0.4.0.dev2/src/tba_router_webauthn/serialization.py +105 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tba-router-webauthn
|
|
3
|
+
Version: 0.4.0.dev2
|
|
4
|
+
Summary: HTTPS carrier binding for WebAuthn ceremonies.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10,<3.15
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: pydantic (>=2.0)
|
|
14
|
+
Requires-Dist: tba-protocol-webauthn (==0.4.0.dev2)
|
|
15
|
+
Requires-Dist: tigrbl (==0.4.5.dev8)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# tba-router-webauthn
|
|
19
|
+
|
|
20
|
+
Application HTTPS carrier mapping for WebAuthn. Cryptographic and ceremony
|
|
21
|
+
verification remain owned by the versioned layer-50 protocol package.
|
|
22
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tba-router-webauthn"
|
|
3
|
+
version = "0.4.0.dev2"
|
|
4
|
+
description = "HTTPS carrier binding for WebAuthn ceremonies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10,<3.15"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pydantic>=2.0",
|
|
10
|
+
"tigrbl==0.4.5.dev8",
|
|
11
|
+
"tba-protocol-webauthn==0.4.0.dev2",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["poetry-core>=1.0.0"]
|
|
16
|
+
build-backend = "poetry.core.masonry.api"
|
|
17
|
+
|
|
18
|
+
[tool.poetry]
|
|
19
|
+
packages = [{ include = "tba_router_webauthn", from = "src" }]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tigrbl route binding for WebAuthn semantic operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from tigrbl import Request, TigrblApp, TigrblRouter
|
|
9
|
+
|
|
10
|
+
from .schemas import (
|
|
11
|
+
AuthenticationOptionsIn,
|
|
12
|
+
PublicKeyCredentialIn,
|
|
13
|
+
RegistrationOptionsIn,
|
|
14
|
+
RenameCredentialIn,
|
|
15
|
+
)
|
|
16
|
+
from .serialization import parse_public_key_credential, to_json_value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _call(target: Callable[..., object], **kwargs: object) -> object:
|
|
20
|
+
value = target(**kwargs)
|
|
21
|
+
return await value if inspect.isawaitable(value) else value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_webauthn_router(
|
|
25
|
+
*,
|
|
26
|
+
begin_public_key_registration: Callable[..., object],
|
|
27
|
+
complete_public_key_registration: Callable[..., object],
|
|
28
|
+
begin_public_key_authentication: Callable[..., object],
|
|
29
|
+
complete_public_key_authentication: Callable[..., object],
|
|
30
|
+
list_public_key_credentials: Callable[..., object] | None = None,
|
|
31
|
+
rename_public_key_credential: Callable[..., object] | None = None,
|
|
32
|
+
revoke_public_key_credential: Callable[..., object] | None = None,
|
|
33
|
+
) -> TigrblRouter:
|
|
34
|
+
router = TigrblRouter()
|
|
35
|
+
|
|
36
|
+
@router.route("/webauthn/registration/options", methods=["POST"])
|
|
37
|
+
async def registration_options(
|
|
38
|
+
request: Request, body: RegistrationOptionsIn | None = None
|
|
39
|
+
):
|
|
40
|
+
body = body or RegistrationOptionsIn.model_validate(await request.json() or {})
|
|
41
|
+
result = await _call(
|
|
42
|
+
begin_public_key_registration,
|
|
43
|
+
request=request,
|
|
44
|
+
user_name=body.user_name,
|
|
45
|
+
display_name=body.display_name,
|
|
46
|
+
)
|
|
47
|
+
return to_json_value(result)
|
|
48
|
+
|
|
49
|
+
@router.route("/webauthn/registration/complete", methods=["POST"])
|
|
50
|
+
async def registration_complete(
|
|
51
|
+
request: Request, body: PublicKeyCredentialIn | None = None
|
|
52
|
+
):
|
|
53
|
+
body = body or PublicKeyCredentialIn.model_validate(await request.json() or {})
|
|
54
|
+
result = await _call(
|
|
55
|
+
complete_public_key_registration,
|
|
56
|
+
request=request,
|
|
57
|
+
credential=parse_public_key_credential(body),
|
|
58
|
+
)
|
|
59
|
+
return to_json_value(result)
|
|
60
|
+
|
|
61
|
+
@router.route("/webauthn/authentication/options", methods=["POST"])
|
|
62
|
+
async def authentication_options(
|
|
63
|
+
request: Request, body: AuthenticationOptionsIn | None = None
|
|
64
|
+
):
|
|
65
|
+
body = body or AuthenticationOptionsIn.model_validate(
|
|
66
|
+
await request.json() or {}
|
|
67
|
+
)
|
|
68
|
+
result = await _call(
|
|
69
|
+
begin_public_key_authentication,
|
|
70
|
+
request=request,
|
|
71
|
+
user_name=body.user_name,
|
|
72
|
+
conditional=body.conditional,
|
|
73
|
+
)
|
|
74
|
+
return to_json_value(result)
|
|
75
|
+
|
|
76
|
+
@router.route("/webauthn/authentication/complete", methods=["POST"])
|
|
77
|
+
async def authentication_complete(
|
|
78
|
+
request: Request, body: PublicKeyCredentialIn | None = None
|
|
79
|
+
):
|
|
80
|
+
body = body or PublicKeyCredentialIn.model_validate(await request.json() or {})
|
|
81
|
+
result = await _call(
|
|
82
|
+
complete_public_key_authentication,
|
|
83
|
+
request=request,
|
|
84
|
+
credential=parse_public_key_credential(body),
|
|
85
|
+
)
|
|
86
|
+
return to_json_value(result)
|
|
87
|
+
|
|
88
|
+
if list_public_key_credentials is not None:
|
|
89
|
+
|
|
90
|
+
@router.route("/webauthn/credentials", methods=["GET"])
|
|
91
|
+
async def credential_list(request: Request):
|
|
92
|
+
return to_json_value(
|
|
93
|
+
await _call(list_public_key_credentials, request=request)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if rename_public_key_credential is not None:
|
|
97
|
+
|
|
98
|
+
@router.route("/webauthn/credentials/{credential_id}", methods=["PATCH"])
|
|
99
|
+
async def credential_rename(
|
|
100
|
+
request: Request, credential_id: str, body: RenameCredentialIn | None = None
|
|
101
|
+
):
|
|
102
|
+
body = body or RenameCredentialIn.model_validate(await request.json() or {})
|
|
103
|
+
return to_json_value(
|
|
104
|
+
await _call(
|
|
105
|
+
rename_public_key_credential,
|
|
106
|
+
request=request,
|
|
107
|
+
credential_id=credential_id,
|
|
108
|
+
display_name=body.display_name,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if revoke_public_key_credential is not None:
|
|
113
|
+
|
|
114
|
+
@router.route("/webauthn/credentials/{credential_id}", methods=["DELETE"])
|
|
115
|
+
async def credential_revoke(request: Request, credential_id: str):
|
|
116
|
+
return to_json_value(
|
|
117
|
+
await _call(
|
|
118
|
+
revoke_public_key_credential,
|
|
119
|
+
request=request,
|
|
120
|
+
credential_id=credential_id,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return router
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def include_webauthn_endpoints(
|
|
128
|
+
app: TigrblApp, router: TigrblRouter, *, enabled: bool = True
|
|
129
|
+
) -> None:
|
|
130
|
+
if enabled:
|
|
131
|
+
app.include_router(router)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["build_webauthn_router", "include_webauthn_endpoints"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""WebAuthn application/JSON carrier envelopes."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RegistrationOptionsIn(BaseModel):
|
|
7
|
+
user_name: str = Field(min_length=1, max_length=320)
|
|
8
|
+
display_name: str = Field(min_length=1, max_length=320)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthenticationOptionsIn(BaseModel):
|
|
12
|
+
user_name: str | None = Field(default=None, max_length=320)
|
|
13
|
+
conditional: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CredentialResponseIn(BaseModel):
|
|
17
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
18
|
+
|
|
19
|
+
client_data_json: str = Field(alias="clientDataJSON")
|
|
20
|
+
attestation_object: str | None = Field(default=None, alias="attestationObject")
|
|
21
|
+
authenticator_data: str | None = Field(default=None, alias="authenticatorData")
|
|
22
|
+
signature: str | None = None
|
|
23
|
+
user_handle: str | None = Field(default=None, alias="userHandle")
|
|
24
|
+
transports: tuple[str, ...] = ()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PublicKeyCredentialIn(BaseModel):
|
|
28
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
raw_id: str = Field(alias="rawId")
|
|
32
|
+
type: str = "public-key"
|
|
33
|
+
response: CredentialResponseIn
|
|
34
|
+
authenticator_attachment: str | None = Field(
|
|
35
|
+
default=None, alias="authenticatorAttachment"
|
|
36
|
+
)
|
|
37
|
+
client_extension_results: dict[str, object] = Field(
|
|
38
|
+
default_factory=dict, alias="clientExtensionResults"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RenameCredentialIn(BaseModel):
|
|
43
|
+
display_name: str = Field(min_length=1, max_length=120)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AuthenticationOptionsIn",
|
|
48
|
+
"CredentialResponseIn",
|
|
49
|
+
"PublicKeyCredentialIn",
|
|
50
|
+
"RegistrationOptionsIn",
|
|
51
|
+
"RenameCredentialIn",
|
|
52
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Byte-preserving application/JSON conversion for WebAuthn values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import fields, is_dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from tba_protocol_webauthn import (
|
|
11
|
+
AuthenticatorAssertionResponse,
|
|
12
|
+
AuthenticatorAttestationResponse,
|
|
13
|
+
PublicKeyCredential,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .schemas import PublicKeyCredentialIn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encode_base64url(value: bytes) -> str:
|
|
20
|
+
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decode_base64url(value: str) -> bytes:
|
|
24
|
+
if not isinstance(value, str) or not value:
|
|
25
|
+
raise ValueError("a non-empty base64url value is required")
|
|
26
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
27
|
+
raise ValueError("invalid base64url value")
|
|
28
|
+
try:
|
|
29
|
+
return base64.b64decode(
|
|
30
|
+
value + "=" * (-len(value) % 4), altchars=b"-_", validate=True
|
|
31
|
+
)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
raise ValueError("invalid base64url value") from exc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_json_value(value: object) -> object:
|
|
37
|
+
if isinstance(value, bytes):
|
|
38
|
+
return encode_base64url(value)
|
|
39
|
+
if isinstance(value, Enum):
|
|
40
|
+
return value.value
|
|
41
|
+
if is_dataclass(value):
|
|
42
|
+
output: dict[str, object] = {}
|
|
43
|
+
for item in fields(value):
|
|
44
|
+
key = {
|
|
45
|
+
"raw_id": "rawId",
|
|
46
|
+
"rp_id": "rpId",
|
|
47
|
+
"pub_key_cred_params": "pubKeyCredParams",
|
|
48
|
+
"exclude_credentials": "excludeCredentials",
|
|
49
|
+
"allow_credentials": "allowCredentials",
|
|
50
|
+
"authenticator_selection": "authenticatorSelection",
|
|
51
|
+
"authenticator_attachment": "authenticatorAttachment",
|
|
52
|
+
"resident_key": "residentKey",
|
|
53
|
+
"require_resident_key": "requireResidentKey",
|
|
54
|
+
"user_verification": "userVerification",
|
|
55
|
+
"display_name": "displayName",
|
|
56
|
+
}.get(item.name, item.name)
|
|
57
|
+
item_value = getattr(value, item.name)
|
|
58
|
+
if item_value is not None and item_value not in ((), {}):
|
|
59
|
+
output[key] = to_json_value(item_value)
|
|
60
|
+
return output
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
return {str(key): to_json_value(item) for key, item in value.items()}
|
|
63
|
+
if isinstance(value, (tuple, list)):
|
|
64
|
+
return [to_json_value(item) for item in value]
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_public_key_credential(value: PublicKeyCredentialIn) -> PublicKeyCredential:
|
|
69
|
+
raw_id = decode_base64url(value.raw_id)
|
|
70
|
+
body = value.response
|
|
71
|
+
if body.attestation_object is not None:
|
|
72
|
+
response = AuthenticatorAttestationResponse(
|
|
73
|
+
client_data_json=decode_base64url(body.client_data_json),
|
|
74
|
+
attestation_object=decode_base64url(body.attestation_object),
|
|
75
|
+
transports=body.transports,
|
|
76
|
+
)
|
|
77
|
+
elif body.authenticator_data is not None and body.signature is not None:
|
|
78
|
+
response = AuthenticatorAssertionResponse(
|
|
79
|
+
client_data_json=decode_base64url(body.client_data_json),
|
|
80
|
+
authenticator_data=decode_base64url(body.authenticator_data),
|
|
81
|
+
signature=decode_base64url(body.signature),
|
|
82
|
+
user_handle=decode_base64url(body.user_handle)
|
|
83
|
+
if body.user_handle
|
|
84
|
+
else None,
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"credential response is neither attestation nor assertion data"
|
|
89
|
+
)
|
|
90
|
+
return PublicKeyCredential(
|
|
91
|
+
id=value.id,
|
|
92
|
+
raw_id=raw_id,
|
|
93
|
+
response=response,
|
|
94
|
+
type=value.type,
|
|
95
|
+
authenticator_attachment=value.authenticator_attachment,
|
|
96
|
+
client_extension_results=value.client_extension_results,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"decode_base64url",
|
|
102
|
+
"encode_base64url",
|
|
103
|
+
"parse_public_key_credential",
|
|
104
|
+
"to_json_value",
|
|
105
|
+
]
|