django-offline-auth 0.1.0__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.
- django_offline_auth-0.1.0/LICENSE +21 -0
- django_offline_auth-0.1.0/PKG-INFO +699 -0
- django_offline_auth-0.1.0/README.md +680 -0
- django_offline_auth-0.1.0/pyproject.toml +68 -0
- django_offline_auth-0.1.0/setup.cfg +4 -0
- django_offline_auth-0.1.0/src/django_offline_auth/__init__.py +1 -0
- django_offline_auth-0.1.0/src/django_offline_auth/admin.py +68 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/__init__.py +15 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/api.py +76 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/interfaces/__init__.py +0 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/interfaces/credential_provider.py +5 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/interfaces/envelope_encryptor.py +16 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/services/__init__.py +0 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/services/issue_credentials.py +123 -0
- django_offline_auth-0.1.0/src/django_offline_auth/application/services/manage_offline_auth.py +66 -0
- django_offline_auth-0.1.0/src/django_offline_auth/apps.py +7 -0
- django_offline_auth-0.1.0/src/django_offline_auth/conf.py +44 -0
- django_offline_auth-0.1.0/src/django_offline_auth/exceptions.py +34 -0
- django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/__init__.py +0 -0
- django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/credential_provider.py +39 -0
- django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/crypto/__init__.py +0 -0
- django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/crypto/envelope.py +84 -0
- django_offline_auth-0.1.0/src/django_offline_auth/migrations/0001_initial.py +32 -0
- django_offline_auth-0.1.0/src/django_offline_auth/migrations/__init__.py +0 -0
- django_offline_auth-0.1.0/src/django_offline_auth/models.py +33 -0
- django_offline_auth-0.1.0/src/django_offline_auth/protocol/__init__.py +0 -0
- django_offline_auth-0.1.0/src/django_offline_auth/protocol/constants.py +19 -0
- django_offline_auth-0.1.0/src/django_offline_auth/protocol/payloads.py +80 -0
- django_offline_auth-0.1.0/src/django_offline_auth/protocol/requests.py +12 -0
- django_offline_auth-0.1.0/src/django_offline_auth/urls.py +5 -0
- django_offline_auth-0.1.0/src/django_offline_auth.egg-info/PKG-INFO +699 -0
- django_offline_auth-0.1.0/src/django_offline_auth.egg-info/SOURCES.txt +34 -0
- django_offline_auth-0.1.0/src/django_offline_auth.egg-info/dependency_links.txt +1 -0
- django_offline_auth-0.1.0/src/django_offline_auth.egg-info/requires.txt +9 -0
- django_offline_auth-0.1.0/src/django_offline_auth.egg-info/top_level.txt +1 -0
- django_offline_auth-0.1.0/tests/test_models.py +70 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Dembaremba
|
|
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,699 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-offline-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable offline authentication support for Django applications.
|
|
5
|
+
Author-email: John Dembaremba <johndembaremba97@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: Django>=5.2
|
|
11
|
+
Requires-Dist: cryptography>=43.0
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-django>=4.13; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-cov>=6.0; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# django-offline-auth
|
|
21
|
+
|
|
22
|
+
Reusable Django package for securely issuing offline authentication credentials to mobile applications.
|
|
23
|
+
|
|
24
|
+
The package is intentionally **user-centric and device-agnostic on the Django side**.
|
|
25
|
+
|
|
26
|
+
It does not store:
|
|
27
|
+
|
|
28
|
+
- device IDs;
|
|
29
|
+
- device names;
|
|
30
|
+
- IMEI numbers;
|
|
31
|
+
- asset relationships;
|
|
32
|
+
- Android private keys.
|
|
33
|
+
|
|
34
|
+
The host Django application decides which users are eligible for offline authentication.
|
|
35
|
+
|
|
36
|
+
`django-offline-auth` securely prepares and encrypts their offline credentials.
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Host Django Application
|
|
42
|
+
│
|
|
43
|
+
│ decides WHO can authenticate offline
|
|
44
|
+
▼
|
|
45
|
+
django-offline-auth
|
|
46
|
+
│
|
|
47
|
+
│ packages + encrypts credentials
|
|
48
|
+
▼
|
|
49
|
+
Android Application
|
|
50
|
+
│
|
|
51
|
+
│ protects keys with Android Keystore
|
|
52
|
+
▼
|
|
53
|
+
Offline Authentication
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The Django backend remains user-centric.
|
|
57
|
+
|
|
58
|
+
The Android application protects downloaded credentials using an installation-local private key stored in Android Keystore.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
Install the package into the host Django project.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install django-offline-auth
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For local development:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -e .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Add the package to `INSTALLED_APPS`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
INSTALLED_APPS = [
|
|
78
|
+
# ...
|
|
79
|
+
"django_offline_auth",
|
|
80
|
+
]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Run migrations:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python manage.py migrate
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## OfflineAuth Model
|
|
90
|
+
|
|
91
|
+
The package stores one `OfflineAuth` record per Django user.
|
|
92
|
+
|
|
93
|
+
The model tracks:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
user
|
|
97
|
+
is_enabled
|
|
98
|
+
credential_version
|
|
99
|
+
created_at
|
|
100
|
+
updated_at
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The package does **not** store another copy of the user's password verifier.
|
|
104
|
+
|
|
105
|
+
`user.password` remains the source of truth.
|
|
106
|
+
|
|
107
|
+
A user is eligible for credential issuance only when:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
user.is_active = True
|
|
111
|
+
AND
|
|
112
|
+
OfflineAuth exists
|
|
113
|
+
AND
|
|
114
|
+
OfflineAuth.is_enabled = True
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Enabling and Disabling Offline Authentication
|
|
118
|
+
|
|
119
|
+
The host application decides when a user should be enrolled.
|
|
120
|
+
|
|
121
|
+
Use the package application API:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from django_offline_auth.application import enable_offline_auth
|
|
125
|
+
|
|
126
|
+
enable_offline_auth(user)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Disable offline authentication with:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from django_offline_auth.application import disable_offline_auth
|
|
133
|
+
|
|
134
|
+
disable_offline_auth(user)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Explicit credential rotation is also available:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from django_offline_auth.application import rotate_offline_credentials
|
|
141
|
+
|
|
142
|
+
rotate_offline_credentials(user)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`credential_version` represents the generation of the user's offline-authentication state.
|
|
146
|
+
|
|
147
|
+
It is incremented when credentials are explicitly rotated or when offline authentication is disabled and later re-enabled.
|
|
148
|
+
|
|
149
|
+
Normal synchronization does not increment the version.
|
|
150
|
+
|
|
151
|
+
## Main Flow
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
ONLINE
|
|
155
|
+
|
|
156
|
+
User logs in normally
|
|
157
|
+
↓
|
|
158
|
+
Django returns JWT
|
|
159
|
+
↓
|
|
160
|
+
Host application determines eligible offline users
|
|
161
|
+
↓
|
|
162
|
+
Android sends its public key
|
|
163
|
+
↓
|
|
164
|
+
django-offline-auth reads each user's
|
|
165
|
+
current Django password verifier
|
|
166
|
+
↓
|
|
167
|
+
Credential payload is encrypted
|
|
168
|
+
↓
|
|
169
|
+
Encrypted credentials are downloaded
|
|
170
|
+
and stored locally
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
OFFLINE
|
|
174
|
+
|
|
175
|
+
User enters username + password
|
|
176
|
+
↓
|
|
177
|
+
Android finds that user's downloaded credential
|
|
178
|
+
↓
|
|
179
|
+
Android Keystore private key decrypts the credential
|
|
180
|
+
↓
|
|
181
|
+
Django password verifier is recovered
|
|
182
|
+
inside native code
|
|
183
|
+
↓
|
|
184
|
+
PBKDF2 verification runs locally
|
|
185
|
+
↓
|
|
186
|
+
Success / Failure
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
JWT issuance and offline credential synchronization are intentionally separate.
|
|
190
|
+
|
|
191
|
+
A Django password verifier must never be included in a JWT.
|
|
192
|
+
|
|
193
|
+
## Issuing Offline Credentials
|
|
194
|
+
|
|
195
|
+
The main public API is:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from django_offline_auth.application import issue_offline_credentials
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
snapshot = issue_offline_credentials(
|
|
205
|
+
users=eligible_users,
|
|
206
|
+
public_key=android_public_key,
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The package receives:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
users
|
|
214
|
+
Django user objects selected by the host application
|
|
215
|
+
|
|
216
|
+
public_key
|
|
217
|
+
Base64-encoded RSA public key supplied by the Android client
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The package returns an `OfflineCredentialSnapshot`.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{
|
|
226
|
+
"protocol_version": 1,
|
|
227
|
+
"credentials": [
|
|
228
|
+
{
|
|
229
|
+
"user_id": "1",
|
|
230
|
+
"username": "mary",
|
|
231
|
+
"envelope": {
|
|
232
|
+
"key_algorithm": "RSA-OAEP-256",
|
|
233
|
+
"content_algorithm": "A256GCM",
|
|
234
|
+
"encrypted_key": "...",
|
|
235
|
+
"nonce": "...",
|
|
236
|
+
"ciphertext": "..."
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
],
|
|
240
|
+
"failures": [
|
|
241
|
+
{
|
|
242
|
+
"user_id": "2",
|
|
243
|
+
"username": "john",
|
|
244
|
+
"reason": "unusable_password"
|
|
245
|
+
}
|
|
246
|
+
]
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The encrypted payload contains the sensitive password verifier.
|
|
251
|
+
|
|
252
|
+
The outer response does not expose it.
|
|
253
|
+
|
|
254
|
+
## Host Application Integration
|
|
255
|
+
|
|
256
|
+
The package does not decide which users should be available offline.
|
|
257
|
+
|
|
258
|
+
A host application may use any business logic it needs:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
eligible_users = determine_eligible_users(request.user)
|
|
262
|
+
|
|
263
|
+
snapshot = issue_offline_credentials(
|
|
264
|
+
users=eligible_users,
|
|
265
|
+
public_key=public_key,
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The host application is responsible for:
|
|
270
|
+
|
|
271
|
+
```text
|
|
272
|
+
authentication
|
|
273
|
+
authorization
|
|
274
|
+
business rules
|
|
275
|
+
user selection
|
|
276
|
+
API endpoint integration
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The package is responsible for securely generating credentials for the supplied users.
|
|
280
|
+
|
|
281
|
+
The client should never be allowed to submit arbitrary user IDs and decide who receives offline credentials.
|
|
282
|
+
|
|
283
|
+
## Request Validation
|
|
284
|
+
|
|
285
|
+
The package provides a request model for basic synchronization input validation.
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from django_offline_auth.protocol.requests import (
|
|
289
|
+
OfflineCredentialSyncRequest,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
sync_request = OfflineCredentialSyncRequest.model_validate(
|
|
293
|
+
request.data
|
|
294
|
+
)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Example request:
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"public_key": "MIIBIjANBgkqh..."
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The package validates the request structure separately from cryptographic public-key validation.
|
|
306
|
+
|
|
307
|
+
## Example DRF Integration
|
|
308
|
+
|
|
309
|
+
The package itself does not depend on Django REST Framework.
|
|
310
|
+
|
|
311
|
+
A host application can integrate it with DRF like this:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
from rest_framework.permissions import IsAuthenticated
|
|
315
|
+
from rest_framework.response import Response
|
|
316
|
+
from rest_framework.views import APIView
|
|
317
|
+
|
|
318
|
+
from django_offline_auth.application import issue_offline_credentials
|
|
319
|
+
from django_offline_auth.protocol.requests import (
|
|
320
|
+
OfflineCredentialSyncRequest,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class OfflineCredentialSyncView(APIView):
|
|
325
|
+
permission_classes = [IsAuthenticated]
|
|
326
|
+
|
|
327
|
+
def post(self, request):
|
|
328
|
+
sync_request = OfflineCredentialSyncRequest.model_validate(
|
|
329
|
+
request.data
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
users = determine_eligible_users(request.user)
|
|
333
|
+
|
|
334
|
+
snapshot = issue_offline_credentials(
|
|
335
|
+
users=users,
|
|
336
|
+
public_key=sync_request.public_key,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return Response(snapshot.model_dump())
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`determine_eligible_users()` belongs to the host application.
|
|
343
|
+
|
|
344
|
+
It is intentionally not part of `django-offline-auth`.
|
|
345
|
+
|
|
346
|
+
## Credential Failures
|
|
347
|
+
|
|
348
|
+
User-specific credential problems do not need to fail the entire synchronization batch.
|
|
349
|
+
|
|
350
|
+
For example:
|
|
351
|
+
|
|
352
|
+
```text
|
|
353
|
+
Mary
|
|
354
|
+
valid supported password
|
|
355
|
+
→ credential issued
|
|
356
|
+
|
|
357
|
+
John
|
|
358
|
+
unusable password
|
|
359
|
+
→ failure returned
|
|
360
|
+
|
|
361
|
+
Peter
|
|
362
|
+
valid supported password
|
|
363
|
+
→ credential issued
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The response may therefore contain both:
|
|
367
|
+
|
|
368
|
+
```text
|
|
369
|
+
credentials[]
|
|
370
|
+
failures[]
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Examples of user-level failures include:
|
|
374
|
+
|
|
375
|
+
```text
|
|
376
|
+
unusable_password
|
|
377
|
+
unsupported_password_algorithm
|
|
378
|
+
invalid_password_verifier
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
An invalid Android public key or other request-wide cryptographic failure should fail the synchronization request because credentials cannot be securely generated.
|
|
382
|
+
|
|
383
|
+
## Supported Password Algorithm
|
|
384
|
+
|
|
385
|
+
Protocol version 1 supports Django:
|
|
386
|
+
|
|
387
|
+
```text
|
|
388
|
+
pbkdf2_sha256
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The package checks the user's current Django password verifier before issuing a credential.
|
|
392
|
+
|
|
393
|
+
Unsupported Django password algorithms are not sent to the client.
|
|
394
|
+
|
|
395
|
+
This prevents the backend from issuing a verifier that the Android implementation cannot validate.
|
|
396
|
+
|
|
397
|
+
## Cryptography
|
|
398
|
+
|
|
399
|
+
Protocol version 1 uses:
|
|
400
|
+
|
|
401
|
+
```text
|
|
402
|
+
Password verification:
|
|
403
|
+
PBKDF2-HMAC-SHA256
|
|
404
|
+
|
|
405
|
+
Credential encryption:
|
|
406
|
+
AES-256-GCM
|
|
407
|
+
|
|
408
|
+
AES key protection:
|
|
409
|
+
RSA-OAEP-SHA256
|
|
410
|
+
|
|
411
|
+
Private key storage:
|
|
412
|
+
Android Keystore
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
For every credential:
|
|
416
|
+
|
|
417
|
+
```text
|
|
418
|
+
fresh AES-256 key
|
|
419
|
+
↓
|
|
420
|
+
encrypt credential payload with AES-GCM
|
|
421
|
+
↓
|
|
422
|
+
wrap AES key using Android RSA public key
|
|
423
|
+
↓
|
|
424
|
+
return encrypted envelope
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The Android private key never leaves Android Keystore.
|
|
428
|
+
|
|
429
|
+
The public key may be sent to Django during credential synchronization and does not need to be persisted by Django.
|
|
430
|
+
|
|
431
|
+
Different Android installations can therefore request independently encrypted copies of the same user's credential without Django storing device records.
|
|
432
|
+
|
|
433
|
+
## Public-Key Requirements
|
|
434
|
+
|
|
435
|
+
The supplied public key must:
|
|
436
|
+
|
|
437
|
+
```text
|
|
438
|
+
be Base64 encoded
|
|
439
|
+
contain DER SubjectPublicKeyInfo data
|
|
440
|
+
be an RSA public key
|
|
441
|
+
meet the configured minimum RSA key size
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
The default minimum RSA key size is:
|
|
445
|
+
|
|
446
|
+
```text
|
|
447
|
+
2048 bits
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Invalid or unsupported keys are rejected before credentials are returned.
|
|
451
|
+
|
|
452
|
+
## Configuration
|
|
453
|
+
|
|
454
|
+
The default offline credential lifetime is 24 hours.
|
|
455
|
+
|
|
456
|
+
A host Django project can override it:
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
DJANGO_OFFLINE_AUTH_CREDENTIAL_TTL_SECONDS = 24 * 60 * 60
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The minimum RSA key size can also be configured:
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
DJANGO_OFFLINE_AUTH_MIN_RSA_KEY_SIZE = 2048
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Configuration rules:
|
|
469
|
+
|
|
470
|
+
```text
|
|
471
|
+
credential TTL
|
|
472
|
+
must be an integer
|
|
473
|
+
must be greater than 0
|
|
474
|
+
|
|
475
|
+
minimum RSA key size
|
|
476
|
+
must be an integer
|
|
477
|
+
must be at least 2048
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
An individual issuance call may also override the configured TTL:
|
|
481
|
+
|
|
482
|
+
```python
|
|
483
|
+
from datetime import timedelta
|
|
484
|
+
|
|
485
|
+
snapshot = issue_offline_credentials(
|
|
486
|
+
users=eligible_users,
|
|
487
|
+
public_key=public_key,
|
|
488
|
+
ttl=timedelta(hours=6),
|
|
489
|
+
)
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
The precedence is:
|
|
493
|
+
|
|
494
|
+
```text
|
|
495
|
+
explicit ttl argument
|
|
496
|
+
↓
|
|
497
|
+
Django setting
|
|
498
|
+
↓
|
|
499
|
+
package default
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
## Offline Credential Policy
|
|
503
|
+
|
|
504
|
+
Offline credentials:
|
|
505
|
+
|
|
506
|
+
- are based on the user's current Django password verifier;
|
|
507
|
+
- are encrypted before leaving Django;
|
|
508
|
+
- are never persisted as plaintext by the package;
|
|
509
|
+
- expire after a configurable period;
|
|
510
|
+
- use 24 hours by default;
|
|
511
|
+
- are refreshed when the application successfully synchronizes online.
|
|
512
|
+
|
|
513
|
+
If a password changes while a client is offline, the old cached credential may remain usable until the next synchronization or credential expiry.
|
|
514
|
+
|
|
515
|
+
The new password becomes available for offline authentication after a successful synchronization.
|
|
516
|
+
|
|
517
|
+
## Synchronization
|
|
518
|
+
|
|
519
|
+
The host application determines which users should be available offline.
|
|
520
|
+
|
|
521
|
+
`django-offline-auth` does not know or care how those users were selected.
|
|
522
|
+
|
|
523
|
+
The synchronization response is treated as the authoritative offline-user snapshot.
|
|
524
|
+
|
|
525
|
+
Example:
|
|
526
|
+
|
|
527
|
+
```text
|
|
528
|
+
Current Android cache:
|
|
529
|
+
|
|
530
|
+
Mary
|
|
531
|
+
John
|
|
532
|
+
Peter
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
Latest server credentials:
|
|
536
|
+
|
|
537
|
+
Mary
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
Android result:
|
|
541
|
+
|
|
542
|
+
keep/update Mary
|
|
543
|
+
remove John
|
|
544
|
+
remove Peter
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The mobile application should therefore remove previously cached credentials that are no longer present in the latest authoritative synchronization result.
|
|
548
|
+
|
|
549
|
+
User-level entries in `failures` should also be handled appropriately by the host/mobile application because those users were considered for synchronization but could not receive a new credential.
|
|
550
|
+
|
|
551
|
+
## Account State
|
|
552
|
+
|
|
553
|
+
Inactive Django users do not receive offline credentials.
|
|
554
|
+
|
|
555
|
+
```text
|
|
556
|
+
user.is_active = False
|
|
557
|
+
→ no credential issued
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Users with no `OfflineAuth` record are also excluded.
|
|
561
|
+
|
|
562
|
+
Users with:
|
|
563
|
+
|
|
564
|
+
```text
|
|
565
|
+
OfflineAuth.is_enabled = False
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
are excluded.
|
|
569
|
+
|
|
570
|
+
Because offline authentication must function without connectivity, server-side account changes cannot instantly revoke a credential on a disconnected device.
|
|
571
|
+
|
|
572
|
+
Changes take effect when:
|
|
573
|
+
|
|
574
|
+
```text
|
|
575
|
+
the device synchronizes again
|
|
576
|
+
OR
|
|
577
|
+
the cached credential expires
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
## Multiple Users on One Android Installation
|
|
581
|
+
|
|
582
|
+
A single Android installation may cache credentials for multiple users.
|
|
583
|
+
|
|
584
|
+
Offline login should:
|
|
585
|
+
|
|
586
|
+
```text
|
|
587
|
+
receive username + password
|
|
588
|
+
↓
|
|
589
|
+
find exactly that username locally
|
|
590
|
+
↓
|
|
591
|
+
load that user's encrypted credential
|
|
592
|
+
↓
|
|
593
|
+
decrypt using Android Keystore
|
|
594
|
+
↓
|
|
595
|
+
verify that user's password
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The client should not iterate PBKDF2 verification across all cached users.
|
|
599
|
+
|
|
600
|
+
## Security Rules
|
|
601
|
+
|
|
602
|
+
- Never put Django password hashes in JWT claims.
|
|
603
|
+
- Never return password verifiers as plaintext API data.
|
|
604
|
+
- Never persist another plaintext copy of `user.password`.
|
|
605
|
+
- Never log passwords, verifiers, AES keys, or private keys.
|
|
606
|
+
- Never store Android private keys outside Android Keystore.
|
|
607
|
+
- Never trust client-supplied user IDs to decide who receives offline credentials.
|
|
608
|
+
- Always use HTTPS.
|
|
609
|
+
- Always validate supplied public keys.
|
|
610
|
+
- Use a fresh AES key and nonce for every credential issuance.
|
|
611
|
+
- Offline authentication must respect credential expiry.
|
|
612
|
+
- Sensitive verifier material should exist only transiently in backend memory.
|
|
613
|
+
- Decrypted verifier material should remain inside native Android code.
|
|
614
|
+
|
|
615
|
+
Application-level encryption does not replace TLS.
|
|
616
|
+
|
|
617
|
+
HTTPS remains mandatory.
|
|
618
|
+
|
|
619
|
+
## Package Boundary
|
|
620
|
+
|
|
621
|
+
`django-offline-auth` is responsible for:
|
|
622
|
+
|
|
623
|
+
```text
|
|
624
|
+
OfflineAuth policy
|
|
625
|
+
credential generation
|
|
626
|
+
credential versioning
|
|
627
|
+
protocol definition
|
|
628
|
+
credential expiry
|
|
629
|
+
password-algorithm validation
|
|
630
|
+
public-key validation
|
|
631
|
+
AES-GCM encryption
|
|
632
|
+
RSA-OAEP key wrapping
|
|
633
|
+
user-level issuance failures
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
The host application is responsible for:
|
|
637
|
+
|
|
638
|
+
```text
|
|
639
|
+
which users are eligible
|
|
640
|
+
business rules
|
|
641
|
+
permissions
|
|
642
|
+
authentication
|
|
643
|
+
authorization
|
|
644
|
+
API integration
|
|
645
|
+
when users are enabled or disabled for offline authentication
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Android is responsible for:
|
|
649
|
+
|
|
650
|
+
```text
|
|
651
|
+
Android Keystore
|
|
652
|
+
private-key protection
|
|
653
|
+
encrypted local storage
|
|
654
|
+
credential decryption
|
|
655
|
+
offline password verification
|
|
656
|
+
attempt limiting
|
|
657
|
+
credential expiry enforcement
|
|
658
|
+
authoritative snapshot replacement
|
|
659
|
+
removal of stale credentials
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
## Design Principles
|
|
663
|
+
|
|
664
|
+
The package follows a few important principles.
|
|
665
|
+
|
|
666
|
+
```text
|
|
667
|
+
Host application decides WHO.
|
|
668
|
+
|
|
669
|
+
django-offline-auth decides HOW credentials are securely issued.
|
|
670
|
+
|
|
671
|
+
Android decides HOW encrypted credentials are protected and used offline.
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
The Django package intentionally avoids device ownership or enrollment models.
|
|
675
|
+
|
|
676
|
+
This keeps it reusable across different Django applications and allows multiple Android installations to independently request encrypted credentials without introducing device-specific backend state.
|
|
677
|
+
|
|
678
|
+
## Protocol Version
|
|
679
|
+
|
|
680
|
+
Current protocol:
|
|
681
|
+
|
|
682
|
+
```text
|
|
683
|
+
protocol_version = 1
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Protocol version 1 currently defines:
|
|
687
|
+
|
|
688
|
+
```text
|
|
689
|
+
password algorithm:
|
|
690
|
+
pbkdf2_sha256
|
|
691
|
+
|
|
692
|
+
key wrapping:
|
|
693
|
+
RSA-OAEP-256
|
|
694
|
+
|
|
695
|
+
content encryption:
|
|
696
|
+
A256GCM
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Future protocol changes should increment `protocol_version` when the wire format or cryptographic contract changes.
|