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.
@@ -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,4 @@
1
+ # tba-router-webauthn
2
+
3
+ Application HTTPS carrier mapping for WebAuthn. Cryptographic and ceremony
4
+ verification remain owned by the versioned layer-50 protocol package.
@@ -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,5 @@
1
+ from .binding import *
2
+ from .schemas import *
3
+ from .serialization import *
4
+
5
+ __all__ = [name for name in globals() if not name.startswith("_")]
@@ -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,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
+ ]