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.
- pyxpo_apple_authentication-0.1.0a1/LICENSE +21 -0
- pyxpo_apple_authentication-0.1.0a1/PKG-INFO +183 -0
- pyxpo_apple_authentication-0.1.0a1/README.md +152 -0
- pyxpo_apple_authentication-0.1.0a1/pyproject.toml +54 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication/__init__.py +160 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication/ios/AppleAuthenticationModule.swift +101 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication/plugin.py +27 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication/py.typed +0 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication/pyxpo-module.json +9 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication.egg-info/PKG-INFO +183 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication.egg-info/SOURCES.txt +15 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication.egg-info/dependency_links.txt +1 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication.egg-info/entry_points.txt +2 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication.egg-info/requires.txt +1 -0
- pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication.egg-info/top_level.txt +1 -0
- pyxpo_apple_authentication-0.1.0a1/setup.cfg +4 -0
- pyxpo_apple_authentication-0.1.0a1/tests/test_pkg_apple_authentication.py +105 -0
|
@@ -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
|
+
[](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml) [](https://pypi.org/project/pyxpo-apple-authentication/) [](https://pypi.org/project/pyxpo-apple-authentication/) [](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
|
+
[](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml) [](https://pypi.org/project/pyxpo-apple-authentication/) [](https://pypi.org/project/pyxpo-apple-authentication/) [](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"]
|
pyxpo_apple_authentication-0.1.0a1/pyxpo_apple_authentication/ios/AppleAuthenticationModule.swift
ADDED
|
@@ -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
|
|
File without changes
|
|
@@ -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
|
+
[](https://github.com/pyxpo/pyxpo-apple-authentication/actions/workflows/ci.yml) [](https://pypi.org/project/pyxpo-apple-authentication/) [](https://pypi.org/project/pyxpo-apple-authentication/) [](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyxpo<0.2,>=0.1.0a9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyxpo_apple_authentication
|
|
@@ -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})
|