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.
Files changed (36) hide show
  1. django_offline_auth-0.1.0/LICENSE +21 -0
  2. django_offline_auth-0.1.0/PKG-INFO +699 -0
  3. django_offline_auth-0.1.0/README.md +680 -0
  4. django_offline_auth-0.1.0/pyproject.toml +68 -0
  5. django_offline_auth-0.1.0/setup.cfg +4 -0
  6. django_offline_auth-0.1.0/src/django_offline_auth/__init__.py +1 -0
  7. django_offline_auth-0.1.0/src/django_offline_auth/admin.py +68 -0
  8. django_offline_auth-0.1.0/src/django_offline_auth/application/__init__.py +15 -0
  9. django_offline_auth-0.1.0/src/django_offline_auth/application/api.py +76 -0
  10. django_offline_auth-0.1.0/src/django_offline_auth/application/interfaces/__init__.py +0 -0
  11. django_offline_auth-0.1.0/src/django_offline_auth/application/interfaces/credential_provider.py +5 -0
  12. django_offline_auth-0.1.0/src/django_offline_auth/application/interfaces/envelope_encryptor.py +16 -0
  13. django_offline_auth-0.1.0/src/django_offline_auth/application/services/__init__.py +0 -0
  14. django_offline_auth-0.1.0/src/django_offline_auth/application/services/issue_credentials.py +123 -0
  15. django_offline_auth-0.1.0/src/django_offline_auth/application/services/manage_offline_auth.py +66 -0
  16. django_offline_auth-0.1.0/src/django_offline_auth/apps.py +7 -0
  17. django_offline_auth-0.1.0/src/django_offline_auth/conf.py +44 -0
  18. django_offline_auth-0.1.0/src/django_offline_auth/exceptions.py +34 -0
  19. django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/__init__.py +0 -0
  20. django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/credential_provider.py +39 -0
  21. django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/crypto/__init__.py +0 -0
  22. django_offline_auth-0.1.0/src/django_offline_auth/infrastructure/crypto/envelope.py +84 -0
  23. django_offline_auth-0.1.0/src/django_offline_auth/migrations/0001_initial.py +32 -0
  24. django_offline_auth-0.1.0/src/django_offline_auth/migrations/__init__.py +0 -0
  25. django_offline_auth-0.1.0/src/django_offline_auth/models.py +33 -0
  26. django_offline_auth-0.1.0/src/django_offline_auth/protocol/__init__.py +0 -0
  27. django_offline_auth-0.1.0/src/django_offline_auth/protocol/constants.py +19 -0
  28. django_offline_auth-0.1.0/src/django_offline_auth/protocol/payloads.py +80 -0
  29. django_offline_auth-0.1.0/src/django_offline_auth/protocol/requests.py +12 -0
  30. django_offline_auth-0.1.0/src/django_offline_auth/urls.py +5 -0
  31. django_offline_auth-0.1.0/src/django_offline_auth.egg-info/PKG-INFO +699 -0
  32. django_offline_auth-0.1.0/src/django_offline_auth.egg-info/SOURCES.txt +34 -0
  33. django_offline_auth-0.1.0/src/django_offline_auth.egg-info/dependency_links.txt +1 -0
  34. django_offline_auth-0.1.0/src/django_offline_auth.egg-info/requires.txt +9 -0
  35. django_offline_auth-0.1.0/src/django_offline_auth.egg-info/top_level.txt +1 -0
  36. 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.