pyxpo-apple-authentication 0.1.0a1__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishal Chaubey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyxpo-apple-authentication
3
+ Version: 0.1.0a1
4
+ Summary: Sign in with Apple for pyxpo apps (AuthenticationServices)
5
+ Author: Vishal Chaubey
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pyxpo/pyxpo-apple-authentication
8
+ Project-URL: Documentation, https://github.com/pyxpo/pyxpo-apple-authentication/blob/main/README.md
9
+ Project-URL: Changelog, https://github.com/pyxpo/pyxpo-apple-authentication/blob/main/CHANGELOG.md
10
+ Project-URL: Source, https://github.com/pyxpo/pyxpo-apple-authentication
11
+ Project-URL: Issues, https://github.com/pyxpo/pyxpo-apple-authentication/issues
12
+ Keywords: pyxpo,pyxpo-module,mobile,ios,apple,sign-in-with-apple,authentication,id-token
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: iOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Software Development :: User Interfaces
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: pyxpo<0.2,>=0.1.0a9
30
+ Dynamic: license-file
31
+
32
+ # pyxpo-apple-authentication
33
+
34
+ [![CI](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml/badge.svg)](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/pyxpo-apple-authentication)](https://pypi.org/project/pyxpo-apple-authentication/) [![Python](https://img.shields.io/pypi/pyversions/pyxpo-apple-authentication)](https://pypi.org/project/pyxpo-apple-authentication/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
35
+
36
+ Sign in with Apple for [pyxpo](https://github.com/pyxpo/pyxpo) apps written in Python. The API follows
37
+ [expo-apple-authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/). It runs on iOS with
38
+ AuthenticationServices. You get an **identity token** (a JWT signed by Apple) to verify on your server.
39
+
40
+ ```python
41
+ import pyxpo_apple_authentication as AppleAuthentication
42
+ from pyxpo_apple_authentication import AppleAuthenticationScope, AppleAuthenticationError
43
+
44
+ if await AppleAuthentication.is_available_async():
45
+ try:
46
+ cred = await AppleAuthentication.sign_in_async(
47
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
48
+ await api.post("/auth/apple", {"identityToken": cred["identityToken"]})
49
+ except AppleAuthenticationError as e:
50
+ if e.code != "ERR_REQUEST_CANCELED":
51
+ raise
52
+ ```
53
+
54
+ ## Features
55
+
56
+ - **Apple's own sheet**, with Face ID and the Hide My Email relay.
57
+ - **Clear errors:** `AppleAuthenticationError.code` is `ERR_REQUEST_CANCELED` when the user closes the sheet.
58
+ - **Credential state** checks, so you can sign out a user who revoked the app in Settings.
59
+ - **Config plugin** for the `com.apple.developer.applesignin` entitlement, so you never edit the entitlements file.
60
+ - **Safe on every platform:** `is_available_async()` is `False` on Android, the web and the desktop.
61
+ - **camelCase too:** `signInAsync(requestedScopes=...)`.
62
+
63
+ ## Contents
64
+
65
+ | If you are... | Start here |
66
+ | --- | --- |
67
+ | Setting up | [Install](#install) · [Apple Developer setup](#apple-developer-setup) |
68
+ | Signing users in | [Quick start](#quick-start) |
69
+ | Exact names | [API reference](#api-reference) · [Errors](#errors) |
70
+ | Stuck | [Testing](#testing) · [Troubleshooting](#troubleshooting) |
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install pyxpo-apple-authentication
76
+ ```
77
+
78
+ Add the module and its config plugin to your app's `pyproject.toml`:
79
+
80
+ ```toml
81
+ [tool.pyxpo]
82
+ modules = ["apple-authentication"]
83
+ plugins = ["pyxpo_apple_authentication.plugin"]
84
+ ```
85
+
86
+ Then rebuild the iOS app (`pyxpo run ios`). The plugin adds `com.apple.developer.applesignin = ["Default"]` to the
87
+ app entitlements.
88
+
89
+ | Platform | Engine | Minimum |
90
+ | --- | --- | --- |
91
+ | iOS | AuthenticationServices | iOS 13 |
92
+ | Android, web, desktop | not supported (`is_available_async()` is `False`) | |
93
+
94
+ ## Apple Developer setup
95
+
96
+ 1. In Certificates, Identifiers & Profiles, open your App ID and turn on **Sign in with Apple**.
97
+ 2. Make a new provisioning profile for that App ID, or let Xcode signing make it.
98
+ 3. On your server, verify `identityToken` against Apple's keys at `https://appleid.apple.com/auth/keys`. Check
99
+ that `aud` is your bundle ID and `iss` is `https://appleid.apple.com`.
100
+
101
+ The simulator needs an Apple ID signed in under Settings.
102
+
103
+ ## Quick start
104
+
105
+ Apple sends `fullName` and `email` **only on the first sign-in** of a user for your app. Send them to your server
106
+ then, with the token:
107
+
108
+ ```python
109
+ cred = await AppleAuthentication.sign_in_async(
110
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
111
+ name = cred["fullName"] or {}
112
+ full = " ".join(p for p in (name.get("givenName"), name.get("familyName")) if p) or None
113
+ await api.post("/auth/apple", {"identityToken": cred["identityToken"], "fullName": full})
114
+ ```
115
+
116
+ Check later that the user still allows your app:
117
+
118
+ ```python
119
+ from pyxpo_apple_authentication import AppleAuthenticationCredentialState as State
120
+
121
+ if await AppleAuthentication.get_credential_state_async(saved_user) is State.REVOKED:
122
+ await sign_out()
123
+ ```
124
+
125
+ Draw your own button that follows Apple's
126
+ [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple)
127
+ (black or white, the Apple logo, "Sign in with Apple" or "Continue with Apple").
128
+
129
+ ## API reference
130
+
131
+ | Name | Returns | Notes |
132
+ | --- | --- | --- |
133
+ | `is_available_async()` | `bool` | `True` on iOS with the module linked. |
134
+ | `sign_in_async(requested_scopes=None, state=None, nonce=None)` | credential `dict` | Shows Apple's sheet. |
135
+ | `get_credential_state_async(user)` | `AppleAuthenticationCredentialState` | `user` is `credential["user"]`. |
136
+ | `AppleAuthenticationScope` | `FULL_NAME`, `EMAIL` | |
137
+ | `AppleAuthenticationCredentialState` | `REVOKED`, `AUTHORIZED`, `NOT_FOUND`, `TRANSFERRED` | |
138
+ | `AppleAuthenticationUserDetectionStatus` | `UNSUPPORTED`, `UNKNOWN`, `LIKELY_REAL` | `credential["realUserStatus"]` |
139
+
140
+ The credential has `user`, `state`, `fullName` (`namePrefix`, `givenName`, `middleName`, `familyName`,
141
+ `nameSuffix`, `nickname`, or `None`), `email`, `realUserStatus`, `identityToken` and `authorizationCode`.
142
+
143
+ ## Errors
144
+
145
+ `AppleAuthenticationError` has `code`:
146
+
147
+ | Code | Meaning |
148
+ | --- | --- |
149
+ | `ERR_REQUEST_CANCELED` | The user closed the sheet. |
150
+ | `ERR_REQUEST_FAILED` | The request failed, for example with no Apple ID on the device. |
151
+ | `ERR_INVALID_RESPONSE` | Apple sent an answer without a user. |
152
+ | `ERR_REQUEST_NOT_HANDLED` | No provider handled the request. |
153
+ | `ERR_REQUEST_NOT_INTERACTIVE` | The request needed UI that the app cannot show now. |
154
+ | `ERR_IN_PROGRESS` | A sign-in is already open. |
155
+ | `ERR_REQUEST_UNKNOWN` | Anything else. |
156
+
157
+ On other platforms `sign_in_async` raises `pyxpo.module_api.NotSupportedError`.
158
+
159
+ ## Testing
160
+
161
+ Use `FakeBridge` from pyxpo to run your sign-in logic on the desktop:
162
+
163
+ ```python
164
+ from pyxpo.module_testing import FakeBridge
165
+ import pyxpo_apple_authentication as A
166
+
167
+ def test_sign_in():
168
+ with FakeBridge("ios") as fb:
169
+ fb.on("AppleAuthentication", "signIn", lambda o: {"user": "001.a", "identityToken": "t"})
170
+ assert asyncio.run(A.sign_in_async())["identityToken"] == "t"
171
+ ```
172
+
173
+ ## Troubleshooting
174
+
175
+ - **`ERR_REQUEST_UNKNOWN` with error 1000.** The entitlement is missing. Add the plugin and rebuild, and turn on
176
+ Sign in with Apple for the App ID.
177
+ - **`fullName` is `None`.** It comes only on the first sign-in. Remove the app under Settings > Apple ID > Sign in
178
+ with Apple to test it again.
179
+ - **Simulator: nothing happens.** Sign in to an Apple ID in the simulator's Settings.
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,152 @@
1
+ # pyxpo-apple-authentication
2
+
3
+ [![CI](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml/badge.svg)](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/pyxpo-apple-authentication)](https://pypi.org/project/pyxpo-apple-authentication/) [![Python](https://img.shields.io/pypi/pyversions/pyxpo-apple-authentication)](https://pypi.org/project/pyxpo-apple-authentication/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
+
5
+ Sign in with Apple for [pyxpo](https://github.com/pyxpo/pyxpo) apps written in Python. The API follows
6
+ [expo-apple-authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/). It runs on iOS with
7
+ AuthenticationServices. You get an **identity token** (a JWT signed by Apple) to verify on your server.
8
+
9
+ ```python
10
+ import pyxpo_apple_authentication as AppleAuthentication
11
+ from pyxpo_apple_authentication import AppleAuthenticationScope, AppleAuthenticationError
12
+
13
+ if await AppleAuthentication.is_available_async():
14
+ try:
15
+ cred = await AppleAuthentication.sign_in_async(
16
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
17
+ await api.post("/auth/apple", {"identityToken": cred["identityToken"]})
18
+ except AppleAuthenticationError as e:
19
+ if e.code != "ERR_REQUEST_CANCELED":
20
+ raise
21
+ ```
22
+
23
+ ## Features
24
+
25
+ - **Apple's own sheet**, with Face ID and the Hide My Email relay.
26
+ - **Clear errors:** `AppleAuthenticationError.code` is `ERR_REQUEST_CANCELED` when the user closes the sheet.
27
+ - **Credential state** checks, so you can sign out a user who revoked the app in Settings.
28
+ - **Config plugin** for the `com.apple.developer.applesignin` entitlement, so you never edit the entitlements file.
29
+ - **Safe on every platform:** `is_available_async()` is `False` on Android, the web and the desktop.
30
+ - **camelCase too:** `signInAsync(requestedScopes=...)`.
31
+
32
+ ## Contents
33
+
34
+ | If you are... | Start here |
35
+ | --- | --- |
36
+ | Setting up | [Install](#install) · [Apple Developer setup](#apple-developer-setup) |
37
+ | Signing users in | [Quick start](#quick-start) |
38
+ | Exact names | [API reference](#api-reference) · [Errors](#errors) |
39
+ | Stuck | [Testing](#testing) · [Troubleshooting](#troubleshooting) |
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install pyxpo-apple-authentication
45
+ ```
46
+
47
+ Add the module and its config plugin to your app's `pyproject.toml`:
48
+
49
+ ```toml
50
+ [tool.pyxpo]
51
+ modules = ["apple-authentication"]
52
+ plugins = ["pyxpo_apple_authentication.plugin"]
53
+ ```
54
+
55
+ Then rebuild the iOS app (`pyxpo run ios`). The plugin adds `com.apple.developer.applesignin = ["Default"]` to the
56
+ app entitlements.
57
+
58
+ | Platform | Engine | Minimum |
59
+ | --- | --- | --- |
60
+ | iOS | AuthenticationServices | iOS 13 |
61
+ | Android, web, desktop | not supported (`is_available_async()` is `False`) | |
62
+
63
+ ## Apple Developer setup
64
+
65
+ 1. In Certificates, Identifiers & Profiles, open your App ID and turn on **Sign in with Apple**.
66
+ 2. Make a new provisioning profile for that App ID, or let Xcode signing make it.
67
+ 3. On your server, verify `identityToken` against Apple's keys at `https://appleid.apple.com/auth/keys`. Check
68
+ that `aud` is your bundle ID and `iss` is `https://appleid.apple.com`.
69
+
70
+ The simulator needs an Apple ID signed in under Settings.
71
+
72
+ ## Quick start
73
+
74
+ Apple sends `fullName` and `email` **only on the first sign-in** of a user for your app. Send them to your server
75
+ then, with the token:
76
+
77
+ ```python
78
+ cred = await AppleAuthentication.sign_in_async(
79
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
80
+ name = cred["fullName"] or {}
81
+ full = " ".join(p for p in (name.get("givenName"), name.get("familyName")) if p) or None
82
+ await api.post("/auth/apple", {"identityToken": cred["identityToken"], "fullName": full})
83
+ ```
84
+
85
+ Check later that the user still allows your app:
86
+
87
+ ```python
88
+ from pyxpo_apple_authentication import AppleAuthenticationCredentialState as State
89
+
90
+ if await AppleAuthentication.get_credential_state_async(saved_user) is State.REVOKED:
91
+ await sign_out()
92
+ ```
93
+
94
+ Draw your own button that follows Apple's
95
+ [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple)
96
+ (black or white, the Apple logo, "Sign in with Apple" or "Continue with Apple").
97
+
98
+ ## API reference
99
+
100
+ | Name | Returns | Notes |
101
+ | --- | --- | --- |
102
+ | `is_available_async()` | `bool` | `True` on iOS with the module linked. |
103
+ | `sign_in_async(requested_scopes=None, state=None, nonce=None)` | credential `dict` | Shows Apple's sheet. |
104
+ | `get_credential_state_async(user)` | `AppleAuthenticationCredentialState` | `user` is `credential["user"]`. |
105
+ | `AppleAuthenticationScope` | `FULL_NAME`, `EMAIL` | |
106
+ | `AppleAuthenticationCredentialState` | `REVOKED`, `AUTHORIZED`, `NOT_FOUND`, `TRANSFERRED` | |
107
+ | `AppleAuthenticationUserDetectionStatus` | `UNSUPPORTED`, `UNKNOWN`, `LIKELY_REAL` | `credential["realUserStatus"]` |
108
+
109
+ The credential has `user`, `state`, `fullName` (`namePrefix`, `givenName`, `middleName`, `familyName`,
110
+ `nameSuffix`, `nickname`, or `None`), `email`, `realUserStatus`, `identityToken` and `authorizationCode`.
111
+
112
+ ## Errors
113
+
114
+ `AppleAuthenticationError` has `code`:
115
+
116
+ | Code | Meaning |
117
+ | --- | --- |
118
+ | `ERR_REQUEST_CANCELED` | The user closed the sheet. |
119
+ | `ERR_REQUEST_FAILED` | The request failed, for example with no Apple ID on the device. |
120
+ | `ERR_INVALID_RESPONSE` | Apple sent an answer without a user. |
121
+ | `ERR_REQUEST_NOT_HANDLED` | No provider handled the request. |
122
+ | `ERR_REQUEST_NOT_INTERACTIVE` | The request needed UI that the app cannot show now. |
123
+ | `ERR_IN_PROGRESS` | A sign-in is already open. |
124
+ | `ERR_REQUEST_UNKNOWN` | Anything else. |
125
+
126
+ On other platforms `sign_in_async` raises `pyxpo.module_api.NotSupportedError`.
127
+
128
+ ## Testing
129
+
130
+ Use `FakeBridge` from pyxpo to run your sign-in logic on the desktop:
131
+
132
+ ```python
133
+ from pyxpo.module_testing import FakeBridge
134
+ import pyxpo_apple_authentication as A
135
+
136
+ def test_sign_in():
137
+ with FakeBridge("ios") as fb:
138
+ fb.on("AppleAuthentication", "signIn", lambda o: {"user": "001.a", "identityToken": "t"})
139
+ assert asyncio.run(A.sign_in_async())["identityToken"] == "t"
140
+ ```
141
+
142
+ ## Troubleshooting
143
+
144
+ - **`ERR_REQUEST_UNKNOWN` with error 1000.** The entitlement is missing. Add the plugin and rebuild, and turn on
145
+ Sign in with Apple for the App ID.
146
+ - **`fullName` is `None`.** It comes only on the first sign-in. Remove the app under Settings > Apple ID > Sign in
147
+ with Apple to test it again.
148
+ - **Simulator: nothing happens.** Sign in to an Apple ID in the simulator's Settings.
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyxpo-apple-authentication"
7
+ dynamic = ["version"]
8
+ description = "Sign in with Apple for pyxpo apps (AuthenticationServices)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Vishal Chaubey" }]
13
+ keywords = ["pyxpo", "pyxpo-module", "mobile", "ios", "apple", "sign-in-with-apple", "authentication", "id-token"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: iOS",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries",
26
+ "Topic :: Software Development :: User Interfaces",
27
+ "Typing :: Typed",
28
+ ]
29
+ requires-python = ">=3.9"
30
+ dependencies = ["pyxpo>=0.1.0a9,<0.2"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/pyxpo/pyxpo-apple-authentication"
34
+ Documentation = "https://github.com/pyxpo/pyxpo-apple-authentication/blob/main/README.md"
35
+ Changelog = "https://github.com/pyxpo/pyxpo-apple-authentication/blob/main/CHANGELOG.md"
36
+ Source = "https://github.com/pyxpo/pyxpo-apple-authentication"
37
+ Issues = "https://github.com/pyxpo/pyxpo-apple-authentication/issues"
38
+
39
+ # How `pyxpo build` finds this module: the entry point name is the module name apps list in
40
+ # `[tool.pyxpo] modules`, its value the import package holding pyxpo-module.json.
41
+ [project.entry-points."pyxpo.modules"]
42
+ apple-authentication = "pyxpo_apple_authentication"
43
+
44
+ [tool.setuptools]
45
+ packages = ["pyxpo_apple_authentication"]
46
+
47
+ [tool.setuptools.dynamic]
48
+ version = { attr = "pyxpo_apple_authentication.__version__" }
49
+
50
+ [tool.setuptools.package-data]
51
+ pyxpo_apple_authentication = ["pyxpo-module.json", "py.typed", "ios/**/*"]
52
+
53
+ [tool.setuptools.exclude-package-data]
54
+ "*" = ["__pycache__", "*.pyc", ".DS_Store"]
@@ -0,0 +1,160 @@
1
+ """Sign in with Apple: the expo-apple-authentication API.
2
+
3
+ import pyxpo_apple_authentication as AppleAuthentication
4
+ from pyxpo_apple_authentication import AppleAuthenticationScope, AppleAuthenticationError
5
+
6
+ try:
7
+ cred = await AppleAuthentication.sign_in_async(
8
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
9
+ cred["identityToken"] # send to your server, verify there
10
+ except AppleAuthenticationError as e:
11
+ if e.code == "ERR_REQUEST_CANCELED":
12
+ ... # the user closed the sheet
13
+
14
+ iOS only (AuthenticationServices). On Android and the desktop `is_available_async()` is False and the other calls
15
+ raise NotSupportedError. Apple sends the name and the email only on the first sign-in of a user: keep them then.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from enum import IntEnum
21
+ from typing import Any, Dict, Iterable, Optional
22
+
23
+ from pyxpo.module_api import NotSupportedError, call, platform, require_linked
24
+
25
+ __version__ = "0.1.0a1"
26
+
27
+ NATIVE = "AppleAuthentication"
28
+ Credential = Dict[str, Any]
29
+
30
+
31
+ class AppleAuthenticationScope(IntEnum):
32
+ FULL_NAME = 0
33
+ EMAIL = 1
34
+
35
+
36
+ class AppleAuthenticationCredentialState(IntEnum):
37
+ REVOKED = 0
38
+ AUTHORIZED = 1
39
+ NOT_FOUND = 2
40
+ TRANSFERRED = 3
41
+
42
+
43
+ class AppleAuthenticationUserDetectionStatus(IntEnum):
44
+ UNSUPPORTED = 0
45
+ UNKNOWN = 1
46
+ LIKELY_REAL = 2
47
+
48
+
49
+ ERR_REQUEST_CANCELED = "ERR_REQUEST_CANCELED"
50
+ ERR_REQUEST_FAILED = "ERR_REQUEST_FAILED"
51
+ ERR_INVALID_RESPONSE = "ERR_INVALID_RESPONSE"
52
+ ERR_REQUEST_NOT_HANDLED = "ERR_REQUEST_NOT_HANDLED"
53
+ ERR_REQUEST_NOT_INTERACTIVE = "ERR_REQUEST_NOT_INTERACTIVE"
54
+ ERR_REQUEST_UNKNOWN = "ERR_REQUEST_UNKNOWN"
55
+ ERR_IN_PROGRESS = "ERR_IN_PROGRESS"
56
+
57
+
58
+ class AppleAuthenticationError(Exception):
59
+ """A failed request. `code` is one of the `ERR_*` names; `ERR_REQUEST_CANCELED` when the user closed it."""
60
+
61
+ def __init__(self, code: str, message: str = "") -> None:
62
+ super().__init__(message or code)
63
+ self.code = code
64
+
65
+
66
+ _CODE_RE = re.compile(r"^(ERR_[A-Z_]+):\s*(.*)$", re.S)
67
+ _busy = False
68
+
69
+
70
+ def _error(e: BaseException) -> AppleAuthenticationError:
71
+ m = _CODE_RE.match(str(e))
72
+ if m:
73
+ return AppleAuthenticationError(m.group(1), m.group(2))
74
+ return AppleAuthenticationError(ERR_REQUEST_UNKNOWN, str(e))
75
+
76
+
77
+ def _ios(feature: str) -> None:
78
+ if platform() != "ios":
79
+ raise NotSupportedError("Sign in with Apple (%s) works on iOS only" % feature)
80
+ require_linked(NATIVE, "apple-authentication", package="pyxpo-apple-authentication")
81
+
82
+
83
+ async def _native(method: str, *args: Any) -> Any:
84
+ try:
85
+ return await call(NATIVE, method, *args, reply=True)
86
+ except Exception as e: # native replies "ERR_CODE: message"
87
+ raise _error(e) from None
88
+
89
+
90
+ async def is_available_async() -> bool:
91
+ """True on iOS 13 and later with the module linked; False elsewhere."""
92
+ if platform() != "ios":
93
+ return False
94
+ try:
95
+ require_linked(NATIVE, "apple-authentication", package="pyxpo-apple-authentication")
96
+ except Exception:
97
+ return False
98
+ return bool(await _native("isAvailable"))
99
+
100
+
101
+ def _scopes(scopes: Optional[Iterable[Any]]) -> list:
102
+ return sorted({int(AppleAuthenticationScope(s)) for s in (scopes or [])})
103
+
104
+
105
+ def _credential(raw: Dict[str, Any]) -> Credential:
106
+ name = raw.get("fullName")
107
+ if isinstance(name, dict):
108
+ name = {k: name.get(k) for k in ("namePrefix", "givenName", "middleName", "familyName", "nameSuffix",
109
+ "nickname")}
110
+ if not any(name.values()):
111
+ name = None
112
+ else:
113
+ name = None
114
+ return {
115
+ "user": raw.get("user"), "state": raw.get("state"), "fullName": name, "email": raw.get("email"),
116
+ "realUserStatus": int(raw.get("realUserStatus") or 0), "identityToken": raw.get("identityToken"),
117
+ "authorizationCode": raw.get("authorizationCode"),
118
+ }
119
+
120
+
121
+ async def sign_in_async(requested_scopes: Optional[Iterable[Any]] = None, state: Optional[str] = None,
122
+ nonce: Optional[str] = None, **camel: Any) -> Credential:
123
+ """Shows Apple's sign-in sheet. Returns `{"user", "state", "fullName", "email", "realUserStatus",
124
+ "identityToken", "authorizationCode"}`. `fullName` and `email` come only on a user's first sign-in.
125
+ Raises `AppleAuthenticationError` (`ERR_REQUEST_CANCELED` when the user closes the sheet)."""
126
+ global _busy
127
+ requested_scopes = camel.pop("requestedScopes", requested_scopes)
128
+ if camel:
129
+ raise TypeError("sign_in_async() got unknown options %s" % sorted(camel))
130
+ _ios("sign_in_async")
131
+ if _busy:
132
+ raise AppleAuthenticationError(ERR_IN_PROGRESS, "a sign-in is already in progress")
133
+ _busy = True
134
+ try:
135
+ raw = await _native("signIn", {"scopes": _scopes(requested_scopes), "state": state or "",
136
+ "nonce": nonce or ""})
137
+ finally:
138
+ _busy = False
139
+ if not isinstance(raw, dict) or not raw.get("user"):
140
+ raise AppleAuthenticationError(ERR_INVALID_RESPONSE, "Apple returned no user")
141
+ return _credential(raw)
142
+
143
+
144
+ async def get_credential_state_async(user: str) -> AppleAuthenticationCredentialState:
145
+ """Whether the Apple ID `user` (from a credential) still authorizes this app."""
146
+ if not user:
147
+ raise ValueError("user must be the `user` of an earlier credential")
148
+ _ios("get_credential_state_async")
149
+ return AppleAuthenticationCredentialState(int(await _native("credentialState", user)))
150
+
151
+
152
+ isAvailableAsync = is_available_async
153
+ signInAsync = sign_in_async
154
+ getCredentialStateAsync = get_credential_state_async
155
+
156
+ __all__ = ["is_available_async", "sign_in_async", "get_credential_state_async", "AppleAuthenticationScope",
157
+ "AppleAuthenticationCredentialState", "AppleAuthenticationUserDetectionStatus", "AppleAuthenticationError",
158
+ "ERR_REQUEST_CANCELED", "ERR_REQUEST_FAILED", "ERR_INVALID_RESPONSE", "ERR_REQUEST_NOT_HANDLED",
159
+ "ERR_REQUEST_NOT_INTERACTIVE", "ERR_REQUEST_UNKNOWN", "ERR_IN_PROGRESS", "isAvailableAsync",
160
+ "signInAsync", "getCredentialStateAsync"]
@@ -0,0 +1,101 @@
1
+ import AuthenticationServices
2
+ import UIKit
3
+
4
+ /// Sign in with Apple on ASAuthorizationController. Errors reply as "ERR_CODE: message" for the Python side.
5
+ final class AppleAuthenticationModule: PxModule {
6
+ static let moduleName = "AppleAuthentication"
7
+
8
+ private var pending: Request?
9
+
10
+ required init() {}
11
+
12
+ func call(_ ctx: PxModuleContext, _ method: String, _ args: [Any], _ reply: @escaping PxReply) {
13
+ switch method {
14
+ case "isAvailable": reply(true, nil)
15
+ case "signIn": signIn(ctx, (args.first as? [String: Any]) ?? [:], reply)
16
+ case "credentialState":
17
+ guard let user = args.first as? String else { reply(nil, "ERR_REQUEST_FAILED: no user"); return }
18
+ ASAuthorizationAppleIDProvider().getCredentialState(forUserID: user) { state, error in
19
+ if let e = error { reply(nil, "ERR_REQUEST_FAILED: \(e.localizedDescription)"); return }
20
+ switch state {
21
+ case .revoked: reply(0, nil)
22
+ case .authorized: reply(1, nil)
23
+ case .transferred: reply(3, nil)
24
+ default: reply(2, nil)
25
+ }
26
+ }
27
+ default: reply(nil, "unknown native call AppleAuthentication.\(method)")
28
+ }
29
+ }
30
+
31
+ private func signIn(_ ctx: PxModuleContext, _ o: [String: Any], _ reply: @escaping PxReply) {
32
+ guard pending == nil else { reply(nil, "ERR_IN_PROGRESS: a sign-in is already in progress"); return }
33
+ guard let window = ctx.presenter()?.view.window else {
34
+ reply(nil, "ERR_REQUEST_FAILED: no window to present from"); return
35
+ }
36
+ let request = ASAuthorizationAppleIDProvider().createRequest()
37
+ var scopes: [ASAuthorization.Scope] = []
38
+ for s in (o["scopes"] as? [Int]) ?? [] { scopes.append(s == 0 ? .fullName : .email) }
39
+ request.requestedScopes = scopes
40
+ if let state = o["state"] as? String, !state.isEmpty { request.state = state }
41
+ if let nonce = o["nonce"] as? String, !nonce.isEmpty { request.nonce = nonce }
42
+ let r = Request(window: window) { [weak self] result, error in
43
+ self?.pending = nil
44
+ reply(result, error)
45
+ }
46
+ pending = r
47
+ let controller = ASAuthorizationController(authorizationRequests: [request])
48
+ controller.delegate = r
49
+ controller.presentationContextProvider = r
50
+ r.controller = controller
51
+ controller.performRequests()
52
+ }
53
+ }
54
+
55
+ private final class Request: NSObject, ASAuthorizationControllerDelegate,
56
+ ASAuthorizationControllerPresentationContextProviding {
57
+ let window: UIWindow
58
+ let done: (Any?, String?) -> Void
59
+ var controller: ASAuthorizationController?
60
+
61
+ init(window: UIWindow, done: @escaping (Any?, String?) -> Void) {
62
+ self.window = window
63
+ self.done = done
64
+ }
65
+
66
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { window }
67
+
68
+ func authorizationController(controller: ASAuthorizationController,
69
+ didCompleteWithAuthorization authorization: ASAuthorization) {
70
+ guard let c = authorization.credential as? ASAuthorizationAppleIDCredential else {
71
+ done(nil, "ERR_INVALID_RESPONSE: not an Apple ID credential"); return
72
+ }
73
+ var name: Any = NSNull()
74
+ if let n = c.fullName {
75
+ name = [
76
+ "namePrefix": n.namePrefix ?? NSNull(), "givenName": n.givenName ?? NSNull(),
77
+ "middleName": n.middleName ?? NSNull(), "familyName": n.familyName ?? NSNull(),
78
+ "nameSuffix": n.nameSuffix ?? NSNull(), "nickname": n.nickname ?? NSNull(),
79
+ ]
80
+ }
81
+ func text(_ d: Data?) -> Any { d.flatMap { String(data: $0, encoding: .utf8) } ?? NSNull() }
82
+ done([
83
+ "user": c.user, "state": c.state ?? NSNull(), "fullName": name, "email": c.email ?? NSNull(),
84
+ "realUserStatus": c.realUserStatus.rawValue, "identityToken": text(c.identityToken),
85
+ "authorizationCode": text(c.authorizationCode),
86
+ ], nil)
87
+ }
88
+
89
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
90
+ let code: String
91
+ switch (error as? ASAuthorizationError)?.code {
92
+ case .canceled: code = "ERR_REQUEST_CANCELED"
93
+ case .failed: code = "ERR_REQUEST_FAILED"
94
+ case .invalidResponse: code = "ERR_INVALID_RESPONSE"
95
+ case .notHandled: code = "ERR_REQUEST_NOT_HANDLED"
96
+ case .notInteractive: code = "ERR_REQUEST_NOT_INTERACTIVE"
97
+ default: code = "ERR_REQUEST_UNKNOWN"
98
+ }
99
+ done(nil, "\(code): \(error.localizedDescription)")
100
+ }
101
+ }
@@ -0,0 +1,27 @@
1
+ """Config plugin for Sign in with Apple: the `com.apple.developer.applesignin` entitlement on iOS.
2
+
3
+ [tool.pyxpo]
4
+ modules = ["apple-authentication"]
5
+ plugins = ["pyxpo_apple_authentication.plugin"]
6
+
7
+ No options. The App ID in your Apple Developer account also needs the Sign in with Apple capability.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict
12
+
13
+ from pyxpo.app_config import ConfigError
14
+ from pyxpo.config_plugins import PluginConfig, with_entitlements
15
+
16
+ ENTITLEMENT = "com.apple.developer.applesignin"
17
+
18
+
19
+ def plugin(config: PluginConfig, props: Dict[str, Any]) -> PluginConfig:
20
+ if props:
21
+ raise ConfigError("pyxpo_apple_authentication.plugin: takes no options, got %s" % sorted(props))
22
+
23
+ def entitlements(d: Dict[str, Any]) -> None:
24
+ d[ENTITLEMENT] = ["Default"]
25
+
26
+ with_entitlements(config, entitlements)
27
+ return config
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "nativeContract": 1,
4
+ "name": "apple-authentication",
5
+ "native": "AppleAuthentication",
6
+ "ios": {
7
+ "classes": ["AppleAuthenticationModule"]
8
+ }
9
+ }
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyxpo-apple-authentication
3
+ Version: 0.1.0a1
4
+ Summary: Sign in with Apple for pyxpo apps (AuthenticationServices)
5
+ Author: Vishal Chaubey
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pyxpo/pyxpo-apple-authentication
8
+ Project-URL: Documentation, https://github.com/pyxpo/pyxpo-apple-authentication/blob/main/README.md
9
+ Project-URL: Changelog, https://github.com/pyxpo/pyxpo-apple-authentication/blob/main/CHANGELOG.md
10
+ Project-URL: Source, https://github.com/pyxpo/pyxpo-apple-authentication
11
+ Project-URL: Issues, https://github.com/pyxpo/pyxpo-apple-authentication/issues
12
+ Keywords: pyxpo,pyxpo-module,mobile,ios,apple,sign-in-with-apple,authentication,id-token
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: iOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Software Development :: User Interfaces
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: pyxpo<0.2,>=0.1.0a9
30
+ Dynamic: license-file
31
+
32
+ # pyxpo-apple-authentication
33
+
34
+ [![CI](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml/badge.svg)](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/pyxpo-apple-authentication)](https://pypi.org/project/pyxpo-apple-authentication/) [![Python](https://img.shields.io/pypi/pyversions/pyxpo-apple-authentication)](https://pypi.org/project/pyxpo-apple-authentication/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
35
+
36
+ Sign in with Apple for [pyxpo](https://github.com/pyxpo/pyxpo) apps written in Python. The API follows
37
+ [expo-apple-authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/). It runs on iOS with
38
+ AuthenticationServices. You get an **identity token** (a JWT signed by Apple) to verify on your server.
39
+
40
+ ```python
41
+ import pyxpo_apple_authentication as AppleAuthentication
42
+ from pyxpo_apple_authentication import AppleAuthenticationScope, AppleAuthenticationError
43
+
44
+ if await AppleAuthentication.is_available_async():
45
+ try:
46
+ cred = await AppleAuthentication.sign_in_async(
47
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
48
+ await api.post("/auth/apple", {"identityToken": cred["identityToken"]})
49
+ except AppleAuthenticationError as e:
50
+ if e.code != "ERR_REQUEST_CANCELED":
51
+ raise
52
+ ```
53
+
54
+ ## Features
55
+
56
+ - **Apple's own sheet**, with Face ID and the Hide My Email relay.
57
+ - **Clear errors:** `AppleAuthenticationError.code` is `ERR_REQUEST_CANCELED` when the user closes the sheet.
58
+ - **Credential state** checks, so you can sign out a user who revoked the app in Settings.
59
+ - **Config plugin** for the `com.apple.developer.applesignin` entitlement, so you never edit the entitlements file.
60
+ - **Safe on every platform:** `is_available_async()` is `False` on Android, the web and the desktop.
61
+ - **camelCase too:** `signInAsync(requestedScopes=...)`.
62
+
63
+ ## Contents
64
+
65
+ | If you are... | Start here |
66
+ | --- | --- |
67
+ | Setting up | [Install](#install) · [Apple Developer setup](#apple-developer-setup) |
68
+ | Signing users in | [Quick start](#quick-start) |
69
+ | Exact names | [API reference](#api-reference) · [Errors](#errors) |
70
+ | Stuck | [Testing](#testing) · [Troubleshooting](#troubleshooting) |
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install pyxpo-apple-authentication
76
+ ```
77
+
78
+ Add the module and its config plugin to your app's `pyproject.toml`:
79
+
80
+ ```toml
81
+ [tool.pyxpo]
82
+ modules = ["apple-authentication"]
83
+ plugins = ["pyxpo_apple_authentication.plugin"]
84
+ ```
85
+
86
+ Then rebuild the iOS app (`pyxpo run ios`). The plugin adds `com.apple.developer.applesignin = ["Default"]` to the
87
+ app entitlements.
88
+
89
+ | Platform | Engine | Minimum |
90
+ | --- | --- | --- |
91
+ | iOS | AuthenticationServices | iOS 13 |
92
+ | Android, web, desktop | not supported (`is_available_async()` is `False`) | |
93
+
94
+ ## Apple Developer setup
95
+
96
+ 1. In Certificates, Identifiers & Profiles, open your App ID and turn on **Sign in with Apple**.
97
+ 2. Make a new provisioning profile for that App ID, or let Xcode signing make it.
98
+ 3. On your server, verify `identityToken` against Apple's keys at `https://appleid.apple.com/auth/keys`. Check
99
+ that `aud` is your bundle ID and `iss` is `https://appleid.apple.com`.
100
+
101
+ The simulator needs an Apple ID signed in under Settings.
102
+
103
+ ## Quick start
104
+
105
+ Apple sends `fullName` and `email` **only on the first sign-in** of a user for your app. Send them to your server
106
+ then, with the token:
107
+
108
+ ```python
109
+ cred = await AppleAuthentication.sign_in_async(
110
+ requested_scopes=[AppleAuthenticationScope.FULL_NAME, AppleAuthenticationScope.EMAIL])
111
+ name = cred["fullName"] or {}
112
+ full = " ".join(p for p in (name.get("givenName"), name.get("familyName")) if p) or None
113
+ await api.post("/auth/apple", {"identityToken": cred["identityToken"], "fullName": full})
114
+ ```
115
+
116
+ Check later that the user still allows your app:
117
+
118
+ ```python
119
+ from pyxpo_apple_authentication import AppleAuthenticationCredentialState as State
120
+
121
+ if await AppleAuthentication.get_credential_state_async(saved_user) is State.REVOKED:
122
+ await sign_out()
123
+ ```
124
+
125
+ Draw your own button that follows Apple's
126
+ [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple)
127
+ (black or white, the Apple logo, "Sign in with Apple" or "Continue with Apple").
128
+
129
+ ## API reference
130
+
131
+ | Name | Returns | Notes |
132
+ | --- | --- | --- |
133
+ | `is_available_async()` | `bool` | `True` on iOS with the module linked. |
134
+ | `sign_in_async(requested_scopes=None, state=None, nonce=None)` | credential `dict` | Shows Apple's sheet. |
135
+ | `get_credential_state_async(user)` | `AppleAuthenticationCredentialState` | `user` is `credential["user"]`. |
136
+ | `AppleAuthenticationScope` | `FULL_NAME`, `EMAIL` | |
137
+ | `AppleAuthenticationCredentialState` | `REVOKED`, `AUTHORIZED`, `NOT_FOUND`, `TRANSFERRED` | |
138
+ | `AppleAuthenticationUserDetectionStatus` | `UNSUPPORTED`, `UNKNOWN`, `LIKELY_REAL` | `credential["realUserStatus"]` |
139
+
140
+ The credential has `user`, `state`, `fullName` (`namePrefix`, `givenName`, `middleName`, `familyName`,
141
+ `nameSuffix`, `nickname`, or `None`), `email`, `realUserStatus`, `identityToken` and `authorizationCode`.
142
+
143
+ ## Errors
144
+
145
+ `AppleAuthenticationError` has `code`:
146
+
147
+ | Code | Meaning |
148
+ | --- | --- |
149
+ | `ERR_REQUEST_CANCELED` | The user closed the sheet. |
150
+ | `ERR_REQUEST_FAILED` | The request failed, for example with no Apple ID on the device. |
151
+ | `ERR_INVALID_RESPONSE` | Apple sent an answer without a user. |
152
+ | `ERR_REQUEST_NOT_HANDLED` | No provider handled the request. |
153
+ | `ERR_REQUEST_NOT_INTERACTIVE` | The request needed UI that the app cannot show now. |
154
+ | `ERR_IN_PROGRESS` | A sign-in is already open. |
155
+ | `ERR_REQUEST_UNKNOWN` | Anything else. |
156
+
157
+ On other platforms `sign_in_async` raises `pyxpo.module_api.NotSupportedError`.
158
+
159
+ ## Testing
160
+
161
+ Use `FakeBridge` from pyxpo to run your sign-in logic on the desktop:
162
+
163
+ ```python
164
+ from pyxpo.module_testing import FakeBridge
165
+ import pyxpo_apple_authentication as A
166
+
167
+ def test_sign_in():
168
+ with FakeBridge("ios") as fb:
169
+ fb.on("AppleAuthentication", "signIn", lambda o: {"user": "001.a", "identityToken": "t"})
170
+ assert asyncio.run(A.sign_in_async())["identityToken"] == "t"
171
+ ```
172
+
173
+ ## Troubleshooting
174
+
175
+ - **`ERR_REQUEST_UNKNOWN` with error 1000.** The entitlement is missing. Add the plugin and rebuild, and turn on
176
+ Sign in with Apple for the App ID.
177
+ - **`fullName` is `None`.** It comes only on the first sign-in. Remove the app under Settings > Apple ID > Sign in
178
+ with Apple to test it again.
179
+ - **Simulator: nothing happens.** Sign in to an Apple ID in the simulator's Settings.
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pyxpo_apple_authentication/__init__.py
5
+ pyxpo_apple_authentication/plugin.py
6
+ pyxpo_apple_authentication/py.typed
7
+ pyxpo_apple_authentication/pyxpo-module.json
8
+ pyxpo_apple_authentication.egg-info/PKG-INFO
9
+ pyxpo_apple_authentication.egg-info/SOURCES.txt
10
+ pyxpo_apple_authentication.egg-info/dependency_links.txt
11
+ pyxpo_apple_authentication.egg-info/entry_points.txt
12
+ pyxpo_apple_authentication.egg-info/requires.txt
13
+ pyxpo_apple_authentication.egg-info/top_level.txt
14
+ pyxpo_apple_authentication/ios/AppleAuthenticationModule.swift
15
+ tests/test_pkg_apple_authentication.py
@@ -0,0 +1,2 @@
1
+ [pyxpo.modules]
2
+ apple-authentication = pyxpo_apple_authentication
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,105 @@
1
+ """pyxpo-apple-authentication: availability, the credential, error codes and the entitlement plugin."""
2
+ import asyncio
3
+
4
+ import pytest
5
+
6
+ from pyxpo.app_config import ConfigError
7
+ from pyxpo.config_plugins import Mods, PluginConfig
8
+ from pyxpo.module_api import NotSupportedError
9
+ from pyxpo.module_testing import FakeBridge
10
+
11
+ import pyxpo_apple_authentication as A
12
+ from pyxpo_apple_authentication import AppleAuthenticationError, AppleAuthenticationScope as Scope
13
+ from pyxpo_apple_authentication.plugin import ENTITLEMENT, plugin
14
+
15
+ RAW = {"user": "001.abc", "state": "s1", "email": "a@privaterelay.appleid.com", "realUserStatus": 2,
16
+ "identityToken": "eyJ.a.b", "authorizationCode": "c0de",
17
+ "fullName": {"givenName": "Ada", "familyName": "Lovelace"}}
18
+
19
+
20
+ def run(coro):
21
+ return asyncio.run(coro)
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def fresh():
26
+ A._busy = False
27
+ yield
28
+ A._busy = False
29
+
30
+
31
+ def test_available_only_on_ios():
32
+ with FakeBridge("android"):
33
+ assert run(A.is_available_async()) is False
34
+ with pytest.raises(NotSupportedError):
35
+ run(A.sign_in_async())
36
+ with FakeBridge("ios") as fb:
37
+ fb.on("AppleAuthentication", "isAvailable", lambda: True)
38
+ assert run(A.isAvailableAsync()) is True
39
+
40
+
41
+ def test_sign_in_sends_scopes_and_shapes_credential():
42
+ with FakeBridge("ios") as fb:
43
+ fb.on("AppleAuthentication", "signIn", lambda o: RAW)
44
+ cred = run(A.sign_in_async(requested_scopes=[Scope.EMAIL, Scope.FULL_NAME, Scope.EMAIL], state="s1"))
45
+ assert fb.calls[-1][2][0] == {"scopes": [0, 1], "state": "s1", "nonce": ""}
46
+ assert cred["user"] == "001.abc" and cred["identityToken"] == "eyJ.a.b"
47
+ assert cred["fullName"]["givenName"] == "Ada" and cred["fullName"]["nickname"] is None
48
+ assert cred["realUserStatus"] == 2
49
+
50
+
51
+ def test_camel_options_and_empty_name():
52
+ raw = dict(RAW, fullName={"givenName": None}, email=None)
53
+ with FakeBridge("ios") as fb:
54
+ fb.on("AppleAuthentication", "signIn", lambda o: raw)
55
+ cred = run(A.signInAsync(requestedScopes=[Scope.FULL_NAME]))
56
+ assert fb.calls[-1][2][0]["scopes"] == [0]
57
+ with pytest.raises(TypeError):
58
+ run(A.sign_in_async(bogus=1))
59
+ assert cred["fullName"] is None and cred["email"] is None
60
+
61
+
62
+ def test_native_error_codes():
63
+ def cancel(o):
64
+ raise RuntimeError("ERR_REQUEST_CANCELED: The operation couldn't be completed.")
65
+
66
+ with FakeBridge("ios") as fb:
67
+ fb.on("AppleAuthentication", "signIn", cancel)
68
+ with pytest.raises(AppleAuthenticationError) as e:
69
+ run(A.sign_in_async())
70
+ assert e.value.code == A.ERR_REQUEST_CANCELED
71
+ fb.on("AppleAuthentication", "signIn", lambda o: (_ for _ in ()).throw(RuntimeError("boom")))
72
+ with pytest.raises(AppleAuthenticationError) as e:
73
+ run(A.sign_in_async())
74
+ assert e.value.code == A.ERR_REQUEST_UNKNOWN and str(e.value) == "boom"
75
+ fb.on("AppleAuthentication", "signIn", lambda o: {})
76
+ with pytest.raises(AppleAuthenticationError) as e:
77
+ run(A.sign_in_async())
78
+ assert e.value.code == A.ERR_INVALID_RESPONSE
79
+ assert A._busy is False
80
+
81
+
82
+ def test_in_progress_guard():
83
+ A._busy = True
84
+ with FakeBridge("ios") as fb:
85
+ fb.on("AppleAuthentication", "signIn", lambda o: RAW)
86
+ with pytest.raises(AppleAuthenticationError) as e:
87
+ run(A.sign_in_async())
88
+ assert not fb.calls
89
+ assert e.value.code == A.ERR_IN_PROGRESS
90
+
91
+
92
+ def test_credential_state():
93
+ with FakeBridge("ios") as fb:
94
+ fb.on("AppleAuthentication", "credentialState", lambda u: 1)
95
+ assert run(A.get_credential_state_async("001.abc")) is A.AppleAuthenticationCredentialState.AUTHORIZED
96
+ with pytest.raises(ValueError):
97
+ run(A.get_credential_state_async(""))
98
+
99
+
100
+ def test_plugin_adds_entitlement(tmp_path):
101
+ mods = Mods()
102
+ plugin(PluginConfig({}, tmp_path, mods), {})
103
+ assert mods.apply_entitlements({})[ENTITLEMENT] == ["Default"]
104
+ with pytest.raises(ConfigError):
105
+ plugin(PluginConfig({}, tmp_path, Mods()), {"x": 1})