Skip to content

Commit e25f336

Browse files
feat: Add WebAuthn plugin component to handle WebAuthn get assertion request (#1464)
1 parent 93d681e commit e25f336

File tree

6 files changed

+625
-2
lines changed

6 files changed

+625
-2
lines changed

google/auth/identity_pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from collections.abc import Mapping
4040
# Python 2.7 compatibility
4141
except ImportError: # pragma: NO COVER
42-
from collections import Mapping
42+
from collections import Mapping # type: ignore
4343
import abc
4444
import json
4545
import os

google/auth/pluggable.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from collections.abc import Mapping
3535
# Python 2.7 compatibility
3636
except ImportError: # pragma: NO COVER
37-
from collections import Mapping
37+
from collections import Mapping # type: ignore
3838
import json
3939
import os
4040
import subprocess

google/oauth2/webauthn_handler.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import abc
2+
import os
3+
import struct
4+
import subprocess
5+
6+
from google.auth import exceptions
7+
from google.oauth2.webauthn_types import GetRequest, GetResponse
8+
9+
10+
class WebAuthnHandler(abc.ABC):
11+
@abc.abstractmethod
12+
def is_available(self) -> bool:
13+
"""Check whether this WebAuthn handler is available"""
14+
raise NotImplementedError("is_available method must be implemented")
15+
16+
@abc.abstractmethod
17+
def get(self, get_request: GetRequest) -> GetResponse:
18+
"""WebAuthn get (assertion)"""
19+
raise NotImplementedError("get method must be implemented")
20+
21+
22+
class PluginHandler(WebAuthnHandler):
23+
"""Offloads WebAuthn get reqeust to a pluggable command-line tool.
24+
25+
Offloads WebAuthn get to a plugin which takes the form of a
26+
command-line tool. The command-line tool is configurable via the
27+
PluginHandler._ENV_VAR environment variable.
28+
29+
The WebAuthn plugin should implement the following interface:
30+
31+
Communication occurs over stdin/stdout, and messages are both sent and
32+
received in the form:
33+
34+
[4 bytes - payload size (little-endian)][variable bytes - json payload]
35+
"""
36+
37+
_ENV_VAR = "GOOGLE_AUTH_WEBAUTHN_PLUGIN"
38+
39+
def is_available(self) -> bool:
40+
try:
41+
self._find_plugin()
42+
except Exception:
43+
return False
44+
else:
45+
return True
46+
47+
def get(self, get_request: GetRequest) -> GetResponse:
48+
request_json = get_request.to_json()
49+
cmd = self._find_plugin()
50+
response_json = self._call_plugin(cmd, request_json)
51+
return GetResponse.from_json(response_json)
52+
53+
def _call_plugin(self, cmd: str, input_json: str) -> str:
54+
# Calculate length of input
55+
input_length = len(input_json)
56+
length_bytes_le = struct.pack("<I", input_length)
57+
request = length_bytes_le + input_json.encode()
58+
59+
# Call plugin
60+
process_result = subprocess.run(
61+
[cmd], input=request, capture_output=True, check=True
62+
)
63+
64+
# Check length of response
65+
response_len_le = process_result.stdout[:4]
66+
response_len = struct.unpack("<I", response_len_le)[0]
67+
response = process_result.stdout[4:]
68+
if response_len != len(response):
69+
raise exceptions.MalformedError(
70+
"Plugin response length {} does not match data {}".format(
71+
response_len, len(response)
72+
)
73+
)
74+
return response.decode()
75+
76+
def _find_plugin(self) -> str:
77+
plugin_cmd = os.environ.get(PluginHandler._ENV_VAR)
78+
if plugin_cmd is None:
79+
raise exceptions.InvalidResource(
80+
"{} env var is not set".format(PluginHandler._ENV_VAR)
81+
)
82+
return plugin_cmd

google/oauth2/webauthn_types.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from dataclasses import dataclass
2+
import json
3+
from typing import Any, Dict, List, Optional
4+
5+
from google.auth import exceptions
6+
7+
8+
@dataclass(frozen=True)
9+
class PublicKeyCredentialDescriptor:
10+
"""Descriptor for a security key based credential.
11+
12+
https://www.w3.org/TR/webauthn-3/#dictionary-credential-descriptor
13+
14+
Args:
15+
id: <url-safe base64-encoded> credential id (key handle).
16+
transports: <'usb'|'nfc'|'ble'|'internal'> List of supported transports.
17+
"""
18+
19+
id: str
20+
transports: Optional[List[str]] = None
21+
22+
def to_dict(self):
23+
cred = {"type": "public-key", "id": self.id}
24+
if self.transports:
25+
cred["transports"] = self.transports
26+
return cred
27+
28+
29+
@dataclass
30+
class AuthenticationExtensionsClientInputs:
31+
"""Client extensions inputs for WebAuthn extensions.
32+
33+
Args:
34+
appid: app id that can be asserted with in addition to rpid.
35+
https://www.w3.org/TR/webauthn-3/#sctn-appid-extension
36+
"""
37+
38+
appid: Optional[str] = None
39+
40+
def to_dict(self):
41+
extensions = {}
42+
if self.appid:
43+
extensions["appid"] = self.appid
44+
return extensions
45+
46+
47+
@dataclass
48+
class GetRequest:
49+
"""WebAuthn get request
50+
51+
Args:
52+
origin: Origin where the WebAuthn get assertion takes place.
53+
rpid: Relying Party ID.
54+
challenge: <url-safe base64-encoded> raw challenge.
55+
timeout_ms: Timeout number in millisecond.
56+
allow_credentials: List of allowed credentials.
57+
user_verification: <'required'|'preferred'|'discouraged'> User verification requirement.
58+
extensions: WebAuthn authentication extensions inputs.
59+
"""
60+
61+
origin: str
62+
rpid: str
63+
challenge: str
64+
timeout_ms: Optional[int] = None
65+
allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None
66+
user_verification: Optional[str] = None
67+
extensions: Optional[AuthenticationExtensionsClientInputs] = None
68+
69+
def to_json(self) -> str:
70+
req_options: Dict[str, Any] = {"rpid": self.rpid, "challenge": self.challenge}
71+
if self.timeout_ms:
72+
req_options["timeout"] = self.timeout_ms
73+
if self.allow_credentials:
74+
req_options["allowCredentials"] = [
75+
c.to_dict() for c in self.allow_credentials
76+
]
77+
if self.user_verification:
78+
req_options["userVerification"] = self.user_verification
79+
if self.extensions:
80+
req_options["extensions"] = self.extensions.to_dict()
81+
return json.dumps(
82+
{"type": "get", "origin": self.origin, "requestData": req_options}
83+
)
84+
85+
86+
@dataclass(frozen=True)
87+
class AuthenticatorAssertionResponse:
88+
"""Authenticator response to a WebAuthn get (assertion) request.
89+
90+
https://www.w3.org/TR/webauthn-3/#authenticatorassertionresponse
91+
92+
Args:
93+
client_data_json: <url-safe base64-encoded> client data JSON.
94+
authenticator_data: <url-safe base64-encoded> authenticator data.
95+
signature: <url-safe base64-encoded> signature.
96+
user_handle: <url-safe base64-encoded> user handle.
97+
"""
98+
99+
client_data_json: str
100+
authenticator_data: str
101+
signature: str
102+
user_handle: Optional[str]
103+
104+
105+
@dataclass(frozen=True)
106+
class GetResponse:
107+
"""WebAuthn get (assertion) response.
108+
109+
Args:
110+
id: <url-safe base64-encoded> credential id (key handle).
111+
response: The authenticator assertion response.
112+
authenticator_attachment: <'cross-platform'|'platform'> The attachment status of the authenticator.
113+
client_extension_results: WebAuthn authentication extensions output results in a dictionary.
114+
"""
115+
116+
id: str
117+
response: AuthenticatorAssertionResponse
118+
authenticator_attachment: Optional[str]
119+
client_extension_results: Optional[Dict]
120+
121+
@staticmethod
122+
def from_json(json_str: str):
123+
"""Verify and construct GetResponse from a JSON string."""
124+
try:
125+
resp_json = json.loads(json_str)
126+
except ValueError:
127+
raise exceptions.MalformedError("Invalid Get JSON response")
128+
if resp_json.get("type") != "getResponse":
129+
raise exceptions.MalformedError(
130+
"Invalid Get response type: {}".format(resp_json.get("type"))
131+
)
132+
pk_cred = resp_json.get("responseData")
133+
if pk_cred is None:
134+
if resp_json.get("error"):
135+
raise exceptions.ReauthFailError(
136+
"WebAuthn.get failure: {}".format(resp_json["error"])
137+
)
138+
else:
139+
raise exceptions.MalformedError("Get response is empty")
140+
if pk_cred.get("type") != "public-key":
141+
raise exceptions.MalformedError(
142+
"Invalid credential type: {}".format(pk_cred.get("type"))
143+
)
144+
assertion_json = pk_cred["response"]
145+
assertion_resp = AuthenticatorAssertionResponse(
146+
client_data_json=assertion_json["clientDataJSON"],
147+
authenticator_data=assertion_json["authenticatorData"],
148+
signature=assertion_json["signature"],
149+
user_handle=assertion_json.get("userHandle"),
150+
)
151+
return GetResponse(
152+
id=pk_cred["id"],
153+
response=assertion_resp,
154+
authenticator_attachment=pk_cred.get("authenticatorAttachment"),
155+
client_extension_results=pk_cred.get("clientExtensionResults"),
156+
)

0 commit comments

Comments
 (0)