pyios-firebase 0.2.0__py3-none-any.whl
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.
- pyios_firebase/__init__.py +26 -0
- pyios_firebase/analytics/__init__.py +434 -0
- pyios_firebase/analytics/names.py +251 -0
- pyios_firebase/app_check/__init__.py +483 -0
- pyios_firebase/auth/__init__.py +131 -0
- pyios_firebase/auth/_apple_name_cache.py +97 -0
- pyios_firebase/auth/_common.py +121 -0
- pyios_firebase/auth/_ui.py +21 -0
- pyios_firebase/auth/apple.py +326 -0
- pyios_firebase/auth/auth.py +592 -0
- pyios_firebase/auth/credentials.py +551 -0
- pyios_firebase/auth/errors.py +321 -0
- pyios_firebase/auth/facebook.py +240 -0
- pyios_firebase/auth/google.py +167 -0
- pyios_firebase/auth/multi_factor.py +394 -0
- pyios_firebase/auth/results.py +361 -0
- pyios_firebase/auth/user.py +457 -0
- pyios_firebase/core/__init__.py +32 -0
- pyios_firebase/crashlytics/__init__.py +463 -0
- pyios_firebase/database/__init__.py +62 -0
- pyios_firebase/database/database.py +144 -0
- pyios_firebase/database/errors.py +80 -0
- pyios_firebase/database/query.py +569 -0
- pyios_firebase/database/reference.py +428 -0
- pyios_firebase/database/snapshot.py +218 -0
- pyios_firebase/database/values.py +271 -0
- pyios_firebase/firestore/__init__.py +59 -0
- pyios_firebase/firestore/_limits.py +48 -0
- pyios_firebase/firestore/_query_state.py +228 -0
- pyios_firebase/firestore/_write.py +18 -0
- pyios_firebase/firestore/aggregate.py +137 -0
- pyios_firebase/firestore/collection_reference.py +100 -0
- pyios_firebase/firestore/document_change.py +63 -0
- pyios_firebase/firestore/document_reference.py +229 -0
- pyios_firebase/firestore/document_snapshot.py +102 -0
- pyios_firebase/firestore/enums.py +66 -0
- pyios_firebase/firestore/field_value.py +94 -0
- pyios_firebase/firestore/filter.py +319 -0
- pyios_firebase/firestore/firestore.py +184 -0
- pyios_firebase/firestore/listener_registration.py +55 -0
- pyios_firebase/firestore/query.py +679 -0
- pyios_firebase/firestore/query_snapshot.py +103 -0
- pyios_firebase/firestore/snapshot_metadata.py +36 -0
- pyios_firebase/firestore/transaction.py +130 -0
- pyios_firebase/firestore/write_batch.py +125 -0
- pyios_firebase/functions/__init__.py +329 -0
- pyios_firebase/messaging/__init__.py +37 -0
- pyios_firebase/messaging/messaging.py +435 -0
- pyios_firebase/messaging/notifications.py +292 -0
- pyios_firebase/performance/__init__.py +486 -0
- pyios_firebase/py.typed +0 -0
- pyios_firebase/remote_config/__init__.py +660 -0
- pyios_firebase/storage/__init__.py +41 -0
- pyios_firebase/storage/errors.py +103 -0
- pyios_firebase/storage/metadata.py +202 -0
- pyios_firebase/storage/reference.py +402 -0
- pyios_firebase/storage/storage.py +120 -0
- pyios_firebase/storage/task.py +198 -0
- pyios_firebase/types.py +93 -0
- pyios_firebase/util.py +413 -0
- pyios_firebase-0.2.0.dist-info/METADATA +1969 -0
- pyios_firebase-0.2.0.dist-info/RECORD +65 -0
- pyios_firebase-0.2.0.dist-info/WHEEL +5 -0
- pyios_firebase-0.2.0.dist-info/licenses/LICENSE +21 -0
- pyios_firebase-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Keeps the name Apple shares at a user's FIRST authorization until Firebase
|
|
2
|
+
has it.
|
|
3
|
+
|
|
4
|
+
Sign in with Apple sends the name once per user per app. If the Firebase
|
|
5
|
+
sign-in that should carry it fails (for example a flaky network), every retry
|
|
6
|
+
gets no name. So the name is saved here, keyed by Apple's stable user ID
|
|
7
|
+
(``credential.user``), as soon as Apple sends it, reused by a retry that gets
|
|
8
|
+
none, and deleted once Firebase sign-in works.
|
|
9
|
+
|
|
10
|
+
The store is NSUserDefaults on iOS; tests can pass a dict.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__ = ("AppleNameCache", "NameStore", "UserDefaultsStore")
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import Any, Final, Protocol
|
|
19
|
+
|
|
20
|
+
DEFAULTS_KEY: Final = "pyios_firebase.apple_sign_in.pending_names"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NameStore(Protocol):
|
|
24
|
+
"""Where names are kept: ``{apple_user: [given, family]}``. A ``dict`` works."""
|
|
25
|
+
|
|
26
|
+
def get(self, apple_user: str, default: Any = None, /) -> Any: ...
|
|
27
|
+
def __setitem__(self, apple_user: str, value: list[str], /) -> None: ...
|
|
28
|
+
def pop(self, apple_user: str, default: Any = None, /) -> Any: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UserDefaultsStore:
|
|
32
|
+
"""A ``NameStore`` kept in one NSUserDefaults entry, so it survives restarts."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, key: str = DEFAULTS_KEY) -> None:
|
|
35
|
+
from rubicon.objc import ns_from_py
|
|
36
|
+
|
|
37
|
+
from ..util import objc_class, to_python
|
|
38
|
+
|
|
39
|
+
self._key = key
|
|
40
|
+
self._defaults = objc_class("NSUserDefaults").standardUserDefaults
|
|
41
|
+
self._ns, self._py = ns_from_py, to_python
|
|
42
|
+
|
|
43
|
+
def _load(self) -> dict[str, list[str]]:
|
|
44
|
+
value = self._defaults.objectForKey_(self._key)
|
|
45
|
+
return dict(self._py(value)) if value is not None else {}
|
|
46
|
+
|
|
47
|
+
def _save(self, data: dict[str, list[str]]) -> None:
|
|
48
|
+
if data:
|
|
49
|
+
self._defaults.setObject_forKey_(self._ns(data), self._key)
|
|
50
|
+
else:
|
|
51
|
+
self._defaults.removeObjectForKey_(self._key)
|
|
52
|
+
|
|
53
|
+
def get(self, apple_user: str, default: Any = None, /) -> Any:
|
|
54
|
+
return self._load().get(apple_user, default)
|
|
55
|
+
|
|
56
|
+
def __setitem__(self, apple_user: str, value: list[str], /) -> None:
|
|
57
|
+
data = self._load()
|
|
58
|
+
data[apple_user] = list(value)
|
|
59
|
+
self._save(data)
|
|
60
|
+
|
|
61
|
+
def pop(self, apple_user: str, default: Any = None, /) -> Any:
|
|
62
|
+
data = self._load()
|
|
63
|
+
value = data.pop(apple_user, default)
|
|
64
|
+
self._save(data)
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AppleNameCache:
|
|
69
|
+
"""Remembers the name Apple sent until a Firebase sign-in carries it."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, store: NameStore | None = None) -> None:
|
|
72
|
+
self._store: NameStore = store if store is not None else UserDefaultsStore()
|
|
73
|
+
|
|
74
|
+
def resolve(
|
|
75
|
+
self, apple_user: str, first: str | None, last: str | None
|
|
76
|
+
) -> tuple[str, str]:
|
|
77
|
+
"""The name to use for this authorization: Apple's when it sent one
|
|
78
|
+
(which is then saved), else the one saved by an earlier attempt.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
``(first_name, last_name)``; ``""`` for parts nobody knows.
|
|
82
|
+
"""
|
|
83
|
+
first, last = first or "", last or ""
|
|
84
|
+
if not apple_user:
|
|
85
|
+
return first, last
|
|
86
|
+
if first or last:
|
|
87
|
+
self._store[apple_user] = [first, last]
|
|
88
|
+
return first, last
|
|
89
|
+
saved: Sequence[str | None] | None = self._store.get(apple_user)
|
|
90
|
+
if saved:
|
|
91
|
+
return str(saved[0] or ""), str(saved[1] or "")
|
|
92
|
+
return "", ""
|
|
93
|
+
|
|
94
|
+
def forget(self, apple_user: str | None) -> None:
|
|
95
|
+
"""Drop the saved name: Firebase has it now (call after sign-in works)."""
|
|
96
|
+
if apple_user:
|
|
97
|
+
self._store.pop(apple_user, None)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Helpers shared by the auth modules: block builders and value readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from rubicon.objc import Block, objc_id
|
|
9
|
+
|
|
10
|
+
from ..types import ObjCObject
|
|
11
|
+
from ..util import objc_class, objc_instance, one_shot_block, to_python
|
|
12
|
+
from .errors import AuthError, auth_error_from_pointer, simple_auth_error
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def flag(obj: ObjCObject, name: str) -> bool:
|
|
18
|
+
"""Read a Swift ``Bool`` property, whichever way Rubicon exposes it.
|
|
19
|
+
|
|
20
|
+
A plain getter is an attribute; a custom getter (``getter=isX``) is a
|
|
21
|
+
method, and reading it without calling it would always be truthy.
|
|
22
|
+
"""
|
|
23
|
+
value = getattr(obj, name)
|
|
24
|
+
return bool(value() if callable(value) else value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def text(value: object) -> str | None:
|
|
28
|
+
"""``str(value)``, or ``None`` for ``None``."""
|
|
29
|
+
return None if value is None else str(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def url_text(url: ObjCObject | None) -> str | None:
|
|
33
|
+
"""An ``NSURL`` as a string, or ``None``."""
|
|
34
|
+
return None if url is None else str(url.absoluteString)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def result_block(
|
|
38
|
+
callback: Callable[[T | None, AuthError | None], None] | None,
|
|
39
|
+
wrap: Callable[[ObjCObject], T],
|
|
40
|
+
) -> Block:
|
|
41
|
+
"""Completion block for ``(result, NSError)``: ``callback(wrap(result), error)``."""
|
|
42
|
+
|
|
43
|
+
def done(result_ptr: Any, error_ptr: Any) -> None:
|
|
44
|
+
if callback is None:
|
|
45
|
+
return
|
|
46
|
+
error = auth_error_from_pointer(error_ptr)
|
|
47
|
+
if error is not None or not result_ptr:
|
|
48
|
+
callback(None, error)
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
result = wrap(objc_instance(result_ptr))
|
|
52
|
+
except Exception as e: # noqa: BLE001 - must always answer the caller
|
|
53
|
+
# an exception here would be swallowed by the SDK's block, and the
|
|
54
|
+
# caller would wait forever
|
|
55
|
+
callback(None, simple_auth_error(f"could not read the result: {e!r}"))
|
|
56
|
+
return
|
|
57
|
+
callback(result, None)
|
|
58
|
+
|
|
59
|
+
return one_shot_block(done, objc_id, objc_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def error_block(callback: Callable[[AuthError | None], None] | None) -> Block:
|
|
63
|
+
"""Completion block for ``(NSError)``: ``callback(error)``."""
|
|
64
|
+
|
|
65
|
+
def done(error_ptr: Any) -> None:
|
|
66
|
+
if callback is not None:
|
|
67
|
+
callback(auth_error_from_pointer(error_ptr))
|
|
68
|
+
|
|
69
|
+
return one_shot_block(done, objc_id)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def string_block(callback: Callable[[str | None, AuthError | None], None]) -> Block:
|
|
73
|
+
"""Completion block for ``(NSString, NSError)``."""
|
|
74
|
+
return result_block(callback, lambda value: str(value))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def registered_url_schemes() -> set[str]:
|
|
78
|
+
"""The URL schemes in the app's ``Info.plist`` (``CFBundleURLTypes``), lower case."""
|
|
79
|
+
url_types = objc_class("NSBundle").mainBundle.objectForInfoDictionaryKey_(
|
|
80
|
+
"CFBundleURLTypes"
|
|
81
|
+
)
|
|
82
|
+
schemes: set[str] = set()
|
|
83
|
+
for url_type in to_python(url_types) or []:
|
|
84
|
+
for scheme in (
|
|
85
|
+
url_type.get("CFBundleURLSchemes", []) if isinstance(url_type, dict) else []
|
|
86
|
+
):
|
|
87
|
+
schemes.add(str(scheme).lower())
|
|
88
|
+
return schemes
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def missing_callback_scheme(auth_native: ObjCObject) -> str | None:
|
|
92
|
+
"""Why phone and web sign-in cannot start, or ``None`` if they can.
|
|
93
|
+
|
|
94
|
+
The Firebase SDK stops the whole app (``fatalError``) when the callback URL
|
|
95
|
+
scheme is not in ``Info.plist``. This checks the same thing first: the
|
|
96
|
+
reversed client ID if it is registered, else ``app-<GOOGLE_APP_ID>``.
|
|
97
|
+
"""
|
|
98
|
+
app = auth_native.app
|
|
99
|
+
options = app.options if app is not None else None
|
|
100
|
+
if options is None:
|
|
101
|
+
return "Firebase is not configured: call core.configure() first"
|
|
102
|
+
schemes = registered_url_schemes()
|
|
103
|
+
reversed_client_id = None
|
|
104
|
+
if options.clientID is not None:
|
|
105
|
+
reversed_client_id = ".".join(reversed(str(options.clientID).split(".")))
|
|
106
|
+
if reversed_client_id.lower() in schemes:
|
|
107
|
+
return None
|
|
108
|
+
if options.googleAppID is None:
|
|
109
|
+
return "the Firebase options have no GOOGLE_APP_ID"
|
|
110
|
+
app_scheme = "app-" + str(options.googleAppID).replace(":", "-")
|
|
111
|
+
if app_scheme.lower() in schemes:
|
|
112
|
+
return None
|
|
113
|
+
wanted = (
|
|
114
|
+
app_scheme
|
|
115
|
+
if reversed_client_id is None
|
|
116
|
+
else f"{reversed_client_id} (or {app_scheme})"
|
|
117
|
+
)
|
|
118
|
+
return (
|
|
119
|
+
f"add the URL scheme {wanted} to CFBundleURLTypes in Info.plist: Firebase Auth "
|
|
120
|
+
"needs it for phone and web sign-in, and stops the app without it"
|
|
121
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..types import ObjCObject
|
|
4
|
+
from ..util import objc_class
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def key_window() -> ObjCObject | None:
|
|
8
|
+
"""The app's key window (scene-based apps first, then the old ``keyWindow``)."""
|
|
9
|
+
app = objc_class("UIApplication").sharedApplication
|
|
10
|
+
for scene in app.connectedScenes.allObjects():
|
|
11
|
+
for window in getattr(scene, "windows", None) or []:
|
|
12
|
+
if window.isKeyWindow(): # a getter with a custom name: must be called
|
|
13
|
+
return window
|
|
14
|
+
fallback: ObjCObject | None = app.keyWindow
|
|
15
|
+
return fallback
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def root_view_controller() -> ObjCObject | None:
|
|
19
|
+
"""The key window's root view controller, to present sign-in sheets from."""
|
|
20
|
+
window = key_window()
|
|
21
|
+
return window.rootViewController if window is not None else None
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Sign in with Apple, then Firebase Auth.
|
|
2
|
+
|
|
3
|
+
from pyios_firebase.auth.apple import start_apple_sign_in
|
|
4
|
+
|
|
5
|
+
def on_signed_in(result, error):
|
|
6
|
+
if error is not None:
|
|
7
|
+
if not error.cancelled:
|
|
8
|
+
print(error.message or error.description)
|
|
9
|
+
return
|
|
10
|
+
print("Signed in:", result.user.uid, result.first_name)
|
|
11
|
+
|
|
12
|
+
start_apple_sign_in(on_signed_in)
|
|
13
|
+
|
|
14
|
+
Apple shares the name (and a real or relay email) only the FIRST time a user
|
|
15
|
+
signs in to the app; later sign-ins give ``""`` for those. The name is kept on
|
|
16
|
+
the device until a Firebase sign-in carries it.
|
|
17
|
+
|
|
18
|
+
Needs the ``com.apple.developer.applesignin`` entitlement and the Apple
|
|
19
|
+
provider enabled in the Firebase console (Authentication, Sign-in method).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
__all__ = (
|
|
25
|
+
"AUTHORIZATION_ERROR_CANCELED",
|
|
26
|
+
"AUTHORIZATION_ERROR_DOMAIN",
|
|
27
|
+
"AUTHORIZATION_ERROR_UNKNOWN",
|
|
28
|
+
"AppleCredential",
|
|
29
|
+
"AppleSignInResult",
|
|
30
|
+
"get_apple_credential",
|
|
31
|
+
"start_apple_sign_in",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import secrets
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Any, Final, cast
|
|
39
|
+
|
|
40
|
+
from rubicon.objc import NSObject, ObjCInstance, ObjCProtocol, ns_from_py, objc_method
|
|
41
|
+
from rubicon.objc.runtime import load_library, objc_id
|
|
42
|
+
|
|
43
|
+
from ..types import ObjCObject
|
|
44
|
+
from ..util import objc_class
|
|
45
|
+
from ._apple_name_cache import AppleNameCache
|
|
46
|
+
from ._ui import key_window
|
|
47
|
+
from .auth import Auth
|
|
48
|
+
from .credentials import OAuthCredential, OAuthProvider
|
|
49
|
+
from .errors import AuthError, auth_error_from_nserror, simple_auth_error
|
|
50
|
+
from .results import AuthDataResult
|
|
51
|
+
|
|
52
|
+
_auth_services = load_library("AuthenticationServices")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _string_constant(name: str, fallback: str) -> str:
|
|
56
|
+
"""An NSString constant exported by AuthenticationServices."""
|
|
57
|
+
try:
|
|
58
|
+
value = objc_id.in_dll(_auth_services, name)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return fallback
|
|
61
|
+
return str(ObjCInstance(value)) if value.value else fallback
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
SCOPE_FULL_NAME: Final = _string_constant("ASAuthorizationScopeFullName", "full_name")
|
|
65
|
+
SCOPE_EMAIL: Final = _string_constant("ASAuthorizationScopeEmail", "email")
|
|
66
|
+
AUTHORIZATION_ERROR_DOMAIN: Final = _string_constant(
|
|
67
|
+
"ASAuthorizationErrorDomain", "com.apple.AuthenticationServices.AuthorizationError"
|
|
68
|
+
)
|
|
69
|
+
"""The error domain of Apple's sign-in errors."""
|
|
70
|
+
AUTHORIZATION_ERROR_UNKNOWN: Final = 1000
|
|
71
|
+
"""Set-up problems, or no Apple Account on the device (also: the user closed
|
|
72
|
+
iOS's "sign in to your Apple Account" alert)."""
|
|
73
|
+
AUTHORIZATION_ERROR_CANCELED: Final = 1001
|
|
74
|
+
"""``ASAuthorizationErrorCanceled``: the user closed the Apple sheet."""
|
|
75
|
+
|
|
76
|
+
NOT_SIGNED_IN_MESSAGE: Final = (
|
|
77
|
+
"Sign in with Apple didn't finish. Check that you're signed in to your "
|
|
78
|
+
"Apple Account in Settings, then try again"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
NSUTF8StringEncoding: Final = 4
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class AppleCredential:
|
|
86
|
+
"""What Sign in with Apple gave, ready for Firebase."""
|
|
87
|
+
|
|
88
|
+
credential: OAuthCredential
|
|
89
|
+
"""The Firebase credential (provider ``"apple.com"``)."""
|
|
90
|
+
apple_user: str
|
|
91
|
+
"""Apple's stable ID for the user in this app."""
|
|
92
|
+
first_name: str
|
|
93
|
+
"""The given name: only on the first sign-in to the app, else ``""``
|
|
94
|
+
(or the name kept from an earlier, failed attempt)."""
|
|
95
|
+
last_name: str
|
|
96
|
+
"""The family name: same rules as ``first_name``."""
|
|
97
|
+
email: str
|
|
98
|
+
"""The email (real or private relay): only on the first sign-in, else ``""``."""
|
|
99
|
+
authorization_code: str
|
|
100
|
+
"""A short-lived code: pass it to ``Auth.revoke_token`` before deleting the
|
|
101
|
+
account (App Store rule)."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AppleSignInResult(AuthDataResult):
|
|
105
|
+
"""A Firebase sign-in with Apple: an ``AuthDataResult`` plus what Apple shared.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
native: the native ``FIRAuthDataResult``.
|
|
109
|
+
apple: what Apple gave for this sign-in.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, native: ObjCObject, apple: AppleCredential) -> None:
|
|
113
|
+
super().__init__(native)
|
|
114
|
+
self.apple = apple
|
|
115
|
+
"""What Apple gave (names, email, authorization code)."""
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def first_name(self) -> str:
|
|
119
|
+
"""The given name Apple shared (first sign-in only), else ``""``."""
|
|
120
|
+
return self.apple.first_name
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def last_name(self) -> str:
|
|
124
|
+
"""The family name Apple shared (first sign-in only), else ``""``."""
|
|
125
|
+
return self.apple.last_name
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
AppleCredentialCallback = Callable[[AppleCredential | None, AuthError | None], None]
|
|
129
|
+
AppleSignInCallback = Callable[[AppleSignInResult | None, AuthError | None], None]
|
|
130
|
+
|
|
131
|
+
_names: AppleNameCache | None = None # made on first use (NSUserDefaults)
|
|
132
|
+
# the sign-ins in progress, by delegate: UIKit keeps the delegate only weakly
|
|
133
|
+
_pending: dict[int, dict[str, Any]] = {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _key(delegate: ObjCObject) -> int:
|
|
137
|
+
"""The delegate's address: the key of its sign-in in ``_pending``."""
|
|
138
|
+
return int(delegate.ptr.value or 0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _name_cache() -> AppleNameCache:
|
|
142
|
+
global _names
|
|
143
|
+
if _names is None:
|
|
144
|
+
_names = AppleNameCache()
|
|
145
|
+
return _names
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _protocols(*names: str) -> list[ObjCProtocol]:
|
|
149
|
+
found = []
|
|
150
|
+
for name in names:
|
|
151
|
+
try:
|
|
152
|
+
found.append(ObjCProtocol(name))
|
|
153
|
+
except NameError:
|
|
154
|
+
pass
|
|
155
|
+
return found
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _apple_error(error: ObjCObject) -> AuthError:
|
|
159
|
+
result = auth_error_from_nserror(error)
|
|
160
|
+
apple = result.domain == AUTHORIZATION_ERROR_DOMAIN
|
|
161
|
+
result.cancelled = result.cancelled or (
|
|
162
|
+
apple and result.code == AUTHORIZATION_ERROR_CANCELED
|
|
163
|
+
)
|
|
164
|
+
# 1000 covers "no Apple Account on this device" (and set-up problems):
|
|
165
|
+
# not a network failure, so don't let the app say it is
|
|
166
|
+
if apple and result.code == AUTHORIZATION_ERROR_UNKNOWN:
|
|
167
|
+
result.message = NOT_SIGNED_IN_MESSAGE
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _text(value: object) -> str:
|
|
172
|
+
return "" if value is None else str(value)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _utf8(data: ObjCObject | None) -> str:
|
|
176
|
+
if data is None:
|
|
177
|
+
return ""
|
|
178
|
+
return str(
|
|
179
|
+
objc_class("NSString")
|
|
180
|
+
.alloc()
|
|
181
|
+
.initWithData_encoding_(data, NSUTF8StringEncoding)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class PyAppleSignInDelegate( # type: ignore[call-arg] # Rubicon's class keyword
|
|
186
|
+
NSObject,
|
|
187
|
+
protocols=_protocols(
|
|
188
|
+
"ASAuthorizationControllerDelegate",
|
|
189
|
+
"ASAuthorizationControllerPresentationContextProviding",
|
|
190
|
+
),
|
|
191
|
+
):
|
|
192
|
+
"""The ``ASAuthorizationController`` delegate and presentation context.
|
|
193
|
+
|
|
194
|
+
Rubicon reads these methods' annotations to build their Objective-C
|
|
195
|
+
signatures: parameters must be annotated with ctypes types (``objc_id``),
|
|
196
|
+
not Python types.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
@objc_method
|
|
200
|
+
def presentationAnchorForAuthorizationController_(
|
|
201
|
+
self, controller: objc_id
|
|
202
|
+
) -> objc_id:
|
|
203
|
+
"""The window the Apple sheet is shown over: the key window."""
|
|
204
|
+
# objc_id is what Rubicon needs for the signature; the value is an object
|
|
205
|
+
return cast("objc_id", key_window())
|
|
206
|
+
|
|
207
|
+
@objc_method
|
|
208
|
+
def authorizationController_didCompleteWithAuthorization_(
|
|
209
|
+
self, controller: objc_id, authorization: objc_id
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Apple signed the user in: turn the identity token into a credential."""
|
|
212
|
+
state = _pending.pop(_key(self), None)
|
|
213
|
+
if state is None:
|
|
214
|
+
return
|
|
215
|
+
callback: AppleCredentialCallback = state["callback"]
|
|
216
|
+
auth: ObjCObject = (
|
|
217
|
+
authorization # an object at runtime (see the class docstring)
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
credential = auth.credential
|
|
221
|
+
if not credential.isKindOfClass(
|
|
222
|
+
objc_class("ASAuthorizationAppleIDCredential")
|
|
223
|
+
):
|
|
224
|
+
callback(None, simple_auth_error("Unexpected credential type"))
|
|
225
|
+
return
|
|
226
|
+
id_token = _utf8(credential.identityToken)
|
|
227
|
+
if not id_token:
|
|
228
|
+
callback(None, simple_auth_error("Apple returned no identity token"))
|
|
229
|
+
return
|
|
230
|
+
apple_user = _text(credential.user)
|
|
231
|
+
name = credential.fullName
|
|
232
|
+
sent_first = _text(name.givenName) if name is not None else ""
|
|
233
|
+
sent_last = _text(name.familyName) if name is not None else ""
|
|
234
|
+
# Apple sends the name only once: reuse the one an earlier,
|
|
235
|
+
# failed attempt saved
|
|
236
|
+
first, last = _name_cache().resolve(apple_user, sent_first, sent_last)
|
|
237
|
+
result = AppleCredential(
|
|
238
|
+
credential=OAuthProvider.apple_credential(
|
|
239
|
+
id_token, state["raw_nonce"], first, last
|
|
240
|
+
),
|
|
241
|
+
apple_user=apple_user,
|
|
242
|
+
first_name=first,
|
|
243
|
+
last_name=last,
|
|
244
|
+
email=_text(credential.email),
|
|
245
|
+
authorization_code=_utf8(credential.authorizationCode),
|
|
246
|
+
)
|
|
247
|
+
except Exception as e: # noqa: BLE001 - must always answer the caller
|
|
248
|
+
callback(None, simple_auth_error(str(e)))
|
|
249
|
+
return
|
|
250
|
+
callback(result, None)
|
|
251
|
+
|
|
252
|
+
@objc_method
|
|
253
|
+
def authorizationController_didCompleteWithError_(
|
|
254
|
+
self, controller: objc_id, error: objc_id
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Apple sign-in failed or was cancelled: report it."""
|
|
257
|
+
state = _pending.pop(_key(self), None)
|
|
258
|
+
if state is not None:
|
|
259
|
+
state["callback"](None, _apple_error(error))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_apple_credential(callback: AppleCredentialCallback) -> None:
|
|
263
|
+
"""Show the Sign in with Apple sheet and give back a Firebase credential,
|
|
264
|
+
without signing in to Firebase (for example to link Apple to the current
|
|
265
|
+
user, or to re-authenticate before deleting the account).
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
callback: gets ``(AppleCredential, None)``, or ``(None, error)``.
|
|
269
|
+
``error.cancelled`` is True when the user closed the sheet;
|
|
270
|
+
``error.message``, when not ``""``, is a sentence for the user.
|
|
271
|
+
"""
|
|
272
|
+
# Apple signs SHA-256(nonce); Firebase checks it against the raw nonce
|
|
273
|
+
raw_nonce = secrets.token_urlsafe(32)
|
|
274
|
+
request = (
|
|
275
|
+
objc_class("ASAuthorizationAppleIDProvider").alloc().init().createRequest()
|
|
276
|
+
)
|
|
277
|
+
request.requestedScopes = ns_from_py([SCOPE_FULL_NAME, SCOPE_EMAIL])
|
|
278
|
+
request.nonce = hashlib.sha256(raw_nonce.encode()).hexdigest()
|
|
279
|
+
|
|
280
|
+
delegate = PyAppleSignInDelegate.alloc().init()
|
|
281
|
+
controller = (
|
|
282
|
+
objc_class("ASAuthorizationController")
|
|
283
|
+
.alloc()
|
|
284
|
+
.initWithAuthorizationRequests_(ns_from_py([request]))
|
|
285
|
+
)
|
|
286
|
+
controller.delegate = delegate # weak in UIKit: _pending keeps both alive
|
|
287
|
+
controller.presentationContextProvider = delegate
|
|
288
|
+
_pending[_key(delegate)] = {
|
|
289
|
+
"callback": callback,
|
|
290
|
+
"raw_nonce": raw_nonce,
|
|
291
|
+
"delegate": delegate,
|
|
292
|
+
"controller": controller,
|
|
293
|
+
}
|
|
294
|
+
controller.performRequests()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def start_apple_sign_in(
|
|
298
|
+
callback: AppleSignInCallback, auth: Auth | None = None
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Show the Sign in with Apple sheet, then sign in to Firebase.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
callback: gets ``(AppleSignInResult, None)``, or ``(None, error)``.
|
|
304
|
+
``error.cancelled`` is True when the user closed the sheet: not a
|
|
305
|
+
failure to report. ``error.message``, when not ``""``, is a
|
|
306
|
+
sentence to show to the user.
|
|
307
|
+
auth: the ``Auth`` to sign in to (default: ``Auth.auth()``).
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def got_credential(apple: AppleCredential | None, error: AuthError | None) -> None:
|
|
311
|
+
if apple is None:
|
|
312
|
+
callback(None, error)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
def signed_in(
|
|
316
|
+
result: AuthDataResult | None, sign_in_error: AuthError | None
|
|
317
|
+
) -> None:
|
|
318
|
+
if result is None:
|
|
319
|
+
callback(None, sign_in_error)
|
|
320
|
+
return
|
|
321
|
+
_name_cache().forget(apple.apple_user) # Firebase has the name now
|
|
322
|
+
callback(AppleSignInResult(result._native, apple), None)
|
|
323
|
+
|
|
324
|
+
(auth or Auth.auth()).sign_in_with_credential(apple.credential, signed_in)
|
|
325
|
+
|
|
326
|
+
get_apple_credential(got_credential)
|