portal-mojo 0.1.0
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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// Auth client — ported from web-mojo TokenManager + PortalApp._installAuthGate
|
|
2
|
+
// + the mojo-auth endpoint contract, verified against django-mojo
|
|
3
|
+
// (apps/account/rest/user.py, passkeys.py).
|
|
4
|
+
//
|
|
5
|
+
// Contracts carried over exactly:
|
|
6
|
+
// · storage keys `access_token` / `refresh_token` (web-mojo parity — an
|
|
7
|
+
// existing web-mojo session survives a portal swap)
|
|
8
|
+
// · single-flight refresh: concurrent callers share ONE in-flight POST
|
|
9
|
+
// · token-status decision table incl. the proactive branch (expiring within
|
|
10
|
+
// 10 min, or older than 60 min, with a valid refresh token → refresh)
|
|
11
|
+
// · pre-request gate: bypass /api/token/refresh exactly (recursion guard)
|
|
12
|
+
// and requests made with no stored token (pre-login / public); otherwise
|
|
13
|
+
// ensureValidToken() — throws AuthRequiredError, which the transport
|
|
14
|
+
// turns into a synthetic 401 REJECT without calling fetch
|
|
15
|
+
// · auth-code / magic-token URL handoff: the param is scrubbed from the URL
|
|
16
|
+
// *synchronously, before any network call*, so a slow exchange cannot
|
|
17
|
+
// leak the one-time code to third-party scripts reading location.search
|
|
18
|
+
// · logout is client-side only — django-mojo has no logout endpoint
|
|
19
|
+
//
|
|
20
|
+
// Two web-mojo traps deliberately NOT carried forward:
|
|
21
|
+
// · setTokens now clears BOTH storages before writing one, so a stale
|
|
22
|
+
// localStorage token can never shadow a fresh sessionStorage session
|
|
23
|
+
// · a refresh re-stores into the storage the session came from — web-mojo
|
|
24
|
+
// silently promoted remember-me=false sessions to localStorage
|
|
25
|
+
//
|
|
26
|
+
// C3 additions (board #1259), same wire authority (django-mojo account app):
|
|
27
|
+
// · MFA completion — totp/verify, totp/recover, sms/send (which CONSUMES
|
|
28
|
+
// and RE-ISSUES the mfa_token), sms/verify (account/rest/totp.py, sms.py)
|
|
29
|
+
// · fresh-auth (step-up) challenge surface — HTTP 440 / 'reauth_required'
|
|
30
|
+
// (mojo/errors.py ReauthRequiredException, services/fresh_auth.py). A
|
|
31
|
+
// refresh can NEVER satisfy it: auth_time is carried forward unchanged
|
|
32
|
+
// across refreshes by design — only a genuine re-login mints a new one.
|
|
33
|
+
import { AuthRequiredError, MojoError } from './errors';
|
|
34
|
+
import { tokenInfo } from './jwt';
|
|
35
|
+
import { apiOrigin, installAuthHooks, mojoCall } from './client';
|
|
36
|
+
|
|
37
|
+
const ACCESS_KEY = 'access_token';
|
|
38
|
+
const REFRESH_KEY = 'refresh_token';
|
|
39
|
+
const AUTH_CODE_PARAM = 'auth_code';
|
|
40
|
+
|
|
41
|
+
/** The one path the pre-request gate must never gate (recursion guard). */
|
|
42
|
+
export const REFRESH_PATH = '/api/token/refresh';
|
|
43
|
+
|
|
44
|
+
// ── Token storage ─────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function getAccessToken(): string | null {
|
|
47
|
+
return localStorage.getItem(ACCESS_KEY) ?? sessionStorage.getItem(ACCESS_KEY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getRefreshToken(): string | null {
|
|
51
|
+
return localStorage.getItem(REFRESH_KEY) ?? sessionStorage.getItem(REFRESH_KEY);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Which storage the current session lives in (refresh keeps it there).
|
|
56
|
+
* Public since C3: a fresh-auth re-login must keep the session in the
|
|
57
|
+
* storage the user originally chose (remember-me), not silently promote it.
|
|
58
|
+
*/
|
|
59
|
+
export function sessionIsPersistent(): boolean {
|
|
60
|
+
return localStorage.getItem(REFRESH_KEY) != null || localStorage.getItem(ACCESS_KEY) != null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setTokens(access: string, refresh?: string | null, persistent = true): void {
|
|
64
|
+
// Clear both storages first — a leftover token in the other storage must
|
|
65
|
+
// never shadow this session (web-mojo wrote without clearing).
|
|
66
|
+
clearTokens({ silent: true });
|
|
67
|
+
const storage = persistent ? localStorage : sessionStorage;
|
|
68
|
+
storage.setItem(ACCESS_KEY, access);
|
|
69
|
+
if (refresh) storage.setItem(REFRESH_KEY, refresh);
|
|
70
|
+
notifyAuthChanged();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearTokens(opts: { silent?: boolean } = {}): void {
|
|
74
|
+
localStorage.removeItem(ACCESS_KEY);
|
|
75
|
+
localStorage.removeItem(REFRESH_KEY);
|
|
76
|
+
sessionStorage.removeItem(ACCESS_KEY);
|
|
77
|
+
sessionStorage.removeItem(REFRESH_KEY);
|
|
78
|
+
if (!opts.silent) notifyAuthChanged();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Auth state (useSyncExternalStore-compatible) ──────────────────────
|
|
82
|
+
|
|
83
|
+
export interface AuthSnapshot {
|
|
84
|
+
/** A currently-valid access token OR a valid refresh token exists. */
|
|
85
|
+
authenticated: boolean;
|
|
86
|
+
uid: string | null;
|
|
87
|
+
email: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
|
|
91
|
+
let snapshotKey = '∅';
|
|
92
|
+
const authListeners = new Set<() => void>();
|
|
93
|
+
|
|
94
|
+
function computeSnapshot(): void {
|
|
95
|
+
const access = getAccessToken();
|
|
96
|
+
const refresh = getRefreshToken();
|
|
97
|
+
const key = `${access ?? ''}|${refresh ?? ''}`;
|
|
98
|
+
if (key === snapshotKey) return;
|
|
99
|
+
snapshotKey = key;
|
|
100
|
+
const a = tokenInfo(access);
|
|
101
|
+
const r = tokenInfo(refresh);
|
|
102
|
+
snapshotCache = {
|
|
103
|
+
authenticated: Boolean(a?.isValid || r?.isValid),
|
|
104
|
+
uid: a?.uid ?? r?.uid ?? null,
|
|
105
|
+
email: a?.email ?? r?.email ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function notifyAuthChanged(): void {
|
|
110
|
+
computeSnapshot();
|
|
111
|
+
for (const cb of authListeners) cb();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stable snapshot — reference changes only when the stored tokens change. */
|
|
115
|
+
export function getAuthSnapshot(): AuthSnapshot {
|
|
116
|
+
computeSnapshot();
|
|
117
|
+
return snapshotCache;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let storageListenerInstalled = false;
|
|
121
|
+
|
|
122
|
+
/** Subscribe to auth-state changes (incl. cross-tab via the storage event). */
|
|
123
|
+
export function subscribeAuth(cb: () => void): () => void {
|
|
124
|
+
if (!storageListenerInstalled && typeof window !== 'undefined') {
|
|
125
|
+
storageListenerInstalled = true;
|
|
126
|
+
window.addEventListener('storage', (e) => {
|
|
127
|
+
if (e.key === null || e.key === ACCESS_KEY || e.key === REFRESH_KEY) notifyAuthChanged();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
authListeners.add(cb);
|
|
131
|
+
return () => authListeners.delete(cb);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Auth events ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export type AuthEvent = 'login' | 'logout' | 'refreshed' | 'refresh-failed' | 'unauthorized';
|
|
137
|
+
const eventListeners = new Map<AuthEvent, Set<(detail?: unknown) => void>>();
|
|
138
|
+
|
|
139
|
+
export function onAuth(event: AuthEvent, cb: (detail?: unknown) => void): () => void {
|
|
140
|
+
let set = eventListeners.get(event);
|
|
141
|
+
if (!set) eventListeners.set(event, (set = new Set()));
|
|
142
|
+
set.add(cb);
|
|
143
|
+
return () => set.delete(cb);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function emitAuth(event: AuthEvent, detail?: unknown): void {
|
|
147
|
+
eventListeners.get(event)?.forEach((cb) => cb(detail));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Token status + refresh (single-flight) ────────────────────────────
|
|
151
|
+
|
|
152
|
+
export type TokenAction = 'none' | 'refresh' | 'logout';
|
|
153
|
+
|
|
154
|
+
/** The TokenManager decision table, ported verbatim. */
|
|
155
|
+
export function checkTokenStatus(): { action: TokenAction; reason: string } {
|
|
156
|
+
const access = tokenInfo(getAccessToken());
|
|
157
|
+
const refresh = tokenInfo(getRefreshToken());
|
|
158
|
+
const refreshUsable = Boolean(refresh?.isValid && !refresh.isExpired);
|
|
159
|
+
|
|
160
|
+
if (!access || !access.isValid || access.isExpired) {
|
|
161
|
+
if (!refreshUsable) return { action: 'logout', reason: 'Both access and refresh tokens are invalid/expired' };
|
|
162
|
+
return { action: 'refresh', reason: 'Access token invalid/expired but refresh token valid' };
|
|
163
|
+
}
|
|
164
|
+
const age = access.ageMinutes();
|
|
165
|
+
if (access.expiringSoon(10) || (age != null && age > 60)) {
|
|
166
|
+
if (!refreshUsable) return { action: 'none', reason: 'Access token expiring but refresh token invalid' };
|
|
167
|
+
return { action: 'refresh', reason: 'Access token expiring soon or aged' };
|
|
168
|
+
}
|
|
169
|
+
return { action: 'none', reason: 'All tokens valid and not expiring soon' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let refreshPromise: Promise<boolean> | null = null;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Refresh the access token. Single-flight: concurrent callers share one
|
|
176
|
+
* in-flight POST. Always resolves to a boolean; never throws.
|
|
177
|
+
*/
|
|
178
|
+
export function refreshTokens(): Promise<boolean> {
|
|
179
|
+
if (!refreshPromise) {
|
|
180
|
+
refreshPromise = doRefresh().finally(() => {
|
|
181
|
+
refreshPromise = null;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return refreshPromise;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function doRefresh(): Promise<boolean> {
|
|
188
|
+
const refresh = getRefreshToken();
|
|
189
|
+
const info = tokenInfo(refresh);
|
|
190
|
+
if (!refresh || !info?.isValid || info.isExpired) {
|
|
191
|
+
emitAuth('unauthorized');
|
|
192
|
+
stopAutoRefresh();
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
const persistent = sessionIsPersistent();
|
|
196
|
+
try {
|
|
197
|
+
const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
|
|
198
|
+
const pkg = body.data as { access_token: string; refresh_token?: string };
|
|
199
|
+
setTokens(pkg.access_token, pkg.refresh_token, persistent);
|
|
200
|
+
emitAuth('refreshed');
|
|
201
|
+
return true;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : 0;
|
|
204
|
+
if (status === 401 || status === 403) {
|
|
205
|
+
emitAuth('unauthorized');
|
|
206
|
+
stopAutoRefresh();
|
|
207
|
+
} else {
|
|
208
|
+
// Transient (network/5xx): keep the session, report, let the
|
|
209
|
+
// watcher or the next gated request retry.
|
|
210
|
+
emitAuth('refresh-failed', error);
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Pre-request gate (TokenManager.ensureValidToken). Throws AuthRequiredError
|
|
218
|
+
* when the request must not be sent; the transport short-circuits it to a
|
|
219
|
+
* synthetic-401 reject without touching the network.
|
|
220
|
+
*/
|
|
221
|
+
export async function ensureValidToken(): Promise<void> {
|
|
222
|
+
const status = checkTokenStatus();
|
|
223
|
+
if (status.action === 'logout') {
|
|
224
|
+
emitAuth('unauthorized');
|
|
225
|
+
stopAutoRefresh();
|
|
226
|
+
throw new AuthRequiredError('Both access and refresh tokens are invalid');
|
|
227
|
+
}
|
|
228
|
+
if (status.action === 'refresh') {
|
|
229
|
+
const ok = await refreshTokens();
|
|
230
|
+
if (!ok) throw new AuthRequiredError('Token refresh failed');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Auto-refresh watcher ──────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
let tokenWatcher: ReturnType<typeof setInterval> | null = null;
|
|
237
|
+
|
|
238
|
+
export function startAutoRefresh(): void {
|
|
239
|
+
stopAutoRefresh();
|
|
240
|
+
tokenWatcher = setInterval(() => {
|
|
241
|
+
const status = checkTokenStatus();
|
|
242
|
+
if (status.action === 'logout') {
|
|
243
|
+
emitAuth('unauthorized');
|
|
244
|
+
stopAutoRefresh();
|
|
245
|
+
} else if (status.action === 'refresh') {
|
|
246
|
+
void refreshTokens();
|
|
247
|
+
}
|
|
248
|
+
}, 60_000);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function stopAutoRefresh(): void {
|
|
252
|
+
if (tokenWatcher) {
|
|
253
|
+
clearInterval(tokenWatcher);
|
|
254
|
+
tokenWatcher = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Login flows ───────────────────────────────────────────────────────
|
|
259
|
+
// Every flow REJECTS (MojoError) on failure — including "Invalid username or
|
|
260
|
+
// password" (401) and the verification gates `email_not_verified` /
|
|
261
|
+
// `phone_not_verified` (403) — and stores tokens on success.
|
|
262
|
+
|
|
263
|
+
/** django-mojo user dict (`to_dict("basic")`). Typed properly in Chunk A2. */
|
|
264
|
+
export type AuthUser = Record<string, unknown>;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The MFA challenge a password login can answer with. `methods` come from
|
|
268
|
+
* the server's get_mfa_methods: 'totp' | 'sms' | 'passkey' (a deployment may
|
|
269
|
+
* grow more — treat unknown entries as unofferable, never as fatal).
|
|
270
|
+
*/
|
|
271
|
+
export interface MfaChallenge {
|
|
272
|
+
mfaToken: string;
|
|
273
|
+
methods: string[];
|
|
274
|
+
expiresIn: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type LoginResult =
|
|
278
|
+
| { kind: 'authenticated'; user: AuthUser }
|
|
279
|
+
/** Password accepted but MFA is required — no tokens issued yet. */
|
|
280
|
+
| ({ kind: 'mfa' } & MfaChallenge);
|
|
281
|
+
|
|
282
|
+
export interface LoginOptions {
|
|
283
|
+
/** true (default) → localStorage; false → sessionStorage. */
|
|
284
|
+
remember?: boolean;
|
|
285
|
+
/** Extra payload fields, e.g. a `bouncer_token` when the deployment enforces one. */
|
|
286
|
+
extra?: Record<string, unknown>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface TokenGrant {
|
|
290
|
+
access_token: string;
|
|
291
|
+
refresh_token?: string;
|
|
292
|
+
user?: AuthUser;
|
|
293
|
+
mfa_required?: boolean;
|
|
294
|
+
mfa_token?: string;
|
|
295
|
+
mfa_methods?: string[];
|
|
296
|
+
expires_in?: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function adoptGrant(data: TokenGrant, remember = true): AuthUser {
|
|
300
|
+
setTokens(data.access_token, data.refresh_token, remember);
|
|
301
|
+
startAutoRefresh();
|
|
302
|
+
const user = data.user ?? {};
|
|
303
|
+
emitAuth('login', user);
|
|
304
|
+
return user;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Password login. `username` may be a username, email, or (if enabled) phone. */
|
|
308
|
+
export async function login(username: string, password: string, opts: LoginOptions = {}): Promise<LoginResult> {
|
|
309
|
+
const body = await mojoCall('/api/login', { method: 'POST', body: { username, password, ...opts.extra } });
|
|
310
|
+
const data = body.data as TokenGrant;
|
|
311
|
+
if (data.mfa_required) {
|
|
312
|
+
return { kind: 'mfa', mfaToken: data.mfa_token ?? '', methods: data.mfa_methods ?? [], expiresIn: data.expires_in ?? 300 };
|
|
313
|
+
}
|
|
314
|
+
return { kind: 'authenticated', user: adoptGrant(data, opts.remember ?? true) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── MFA completion (deferred from A1; landed with the C3 pages) ───────
|
|
318
|
+
// Each completion consumes the mfa_token and answers with a full TokenGrant
|
|
319
|
+
// (django-mojo jwt_login) — adopted here exactly like a password login.
|
|
320
|
+
// `remember` mirrors LoginOptions: the MFA step continues the login the
|
|
321
|
+
// user began, so the page passes the same remember-me choice through.
|
|
322
|
+
|
|
323
|
+
export interface MfaCompletionOptions {
|
|
324
|
+
/** true (default) → localStorage; false → sessionStorage. */
|
|
325
|
+
remember?: boolean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Second factor: TOTP code (`POST /api/auth/totp/verify`). */
|
|
329
|
+
export async function completeMfaTotp(mfaToken: string, code: string, opts: MfaCompletionOptions = {}): Promise<AuthUser> {
|
|
330
|
+
const body = await mojoCall('/api/auth/totp/verify', { method: 'POST', body: { mfa_token: mfaToken, code } });
|
|
331
|
+
return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Second factor: one single-use TOTP recovery code (`POST /api/auth/totp/recover`). */
|
|
335
|
+
export async function completeMfaRecovery(mfaToken: string, recoveryCode: string, opts: MfaCompletionOptions = {}): Promise<AuthUser> {
|
|
336
|
+
const body = await mojoCall('/api/auth/totp/recover', { method: 'POST', body: { mfa_token: mfaToken, recovery_code: recoveryCode } });
|
|
337
|
+
return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Send the SMS one-time code for an `sms` MFA challenge. The server CONSUMES
|
|
342
|
+
* the mfa_token and RE-ISSUES a fresh one in the response — the caller must
|
|
343
|
+
* replace its stored token with the returned `mfaToken` or the verify step
|
|
344
|
+
* will fail with "Invalid or expired MFA token".
|
|
345
|
+
*/
|
|
346
|
+
export async function sendMfaSms(mfaToken: string): Promise<{ mfaToken: string; expiresIn: number }> {
|
|
347
|
+
const body = await mojoCall('/api/auth/sms/send', { method: 'POST', body: { mfa_token: mfaToken } });
|
|
348
|
+
const data = body.data as { mfa_token: string; expires_in?: number };
|
|
349
|
+
return { mfaToken: data.mfa_token, expiresIn: data.expires_in ?? 300 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Second factor: the SMS code (`POST /api/auth/sms/verify` with mfa_token). */
|
|
353
|
+
export async function completeMfaSms(mfaToken: string, code: string, opts: MfaCompletionOptions = {}): Promise<AuthUser> {
|
|
354
|
+
const body = await mojoCall('/api/auth/sms/verify', { method: 'POST', body: { mfa_token: mfaToken, code } });
|
|
355
|
+
return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// TODO(C3-deferred) OAuth redirect flows: django-mojo ships the full wire —
|
|
359
|
+
// GET /api/auth/oauth/<provider>/begin?redirect_uri=… → {auth_url, state}
|
|
360
|
+
// (browser navigates to auth_url; provider → backend callback → 302 back
|
|
361
|
+
// to redirect_uri with ?code=&state= merged into its query)
|
|
362
|
+
// POST /api/auth/oauth/<provider>/complete {code, state} → TokenGrant.
|
|
363
|
+
// Deferred with the workspec's blessing: redirect_uri must be allowlisted
|
|
364
|
+
// server-side (ALLOWED_REDIRECT_URLS), providers are deployment-specific
|
|
365
|
+
// (GET /api/auth/config → login.methods), and the round-trip cannot be
|
|
366
|
+
// exercised against the mock or without real provider credentials. The
|
|
367
|
+
// hosted /auth pages cover OAuth deployments today. Seam: add
|
|
368
|
+
// `beginOAuth(provider, redirectUri)` + `completeOAuth(provider, code,
|
|
369
|
+
// state)` here, and an `?code=&state=` landing branch beside
|
|
370
|
+
// handleAuthCodeFromURL().
|
|
371
|
+
|
|
372
|
+
/** Request a reset email: `code` → 6-digit code, `link` → `pr:` token link. */
|
|
373
|
+
export async function forgotPassword(email: string, method: 'code' | 'link' = 'code'): Promise<void> {
|
|
374
|
+
await mojoCall('/api/auth/forgot', { method: 'POST', body: { email, method } });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Complete a code reset. Logs the user in (tokens stored). */
|
|
378
|
+
export async function resetPasswordWithCode(email: string, code: string, newPassword: string): Promise<AuthUser> {
|
|
379
|
+
const body = await mojoCall('/api/auth/password/reset/code', { method: 'POST', body: { email, code, new_password: newPassword } });
|
|
380
|
+
return adoptGrant(body.data as TokenGrant);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Complete a link reset with the `pr:` token. Logs the user in. */
|
|
384
|
+
export async function resetPasswordWithToken(token: string, newPassword: string): Promise<AuthUser> {
|
|
385
|
+
const body = await mojoCall('/api/auth/password/reset/token', { method: 'POST', body: { token, new_password: newPassword } });
|
|
386
|
+
return adoptGrant(body.data as TokenGrant);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Send a passwordless `ml:` login link. */
|
|
390
|
+
export async function sendMagicLink(email: string): Promise<void> {
|
|
391
|
+
await mojoCall('/api/auth/magic/send', { method: 'POST', body: { email } });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Complete login with the `ml:` token from a magic link. */
|
|
395
|
+
export async function loginWithMagicToken(token: string): Promise<AuthUser> {
|
|
396
|
+
const body = await mojoCall('/api/auth/magic/login', { method: 'POST', body: { token } });
|
|
397
|
+
return adoptGrant(body.data as TokenGrant);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Read `?token=ml:…` from the URL, scrub it (before any network call), and
|
|
402
|
+
* log in. Null when no magic token is present. Never throws URL-side; the
|
|
403
|
+
* network call rejects like any other flow.
|
|
404
|
+
*/
|
|
405
|
+
export function handleMagicTokenFromURL(): Promise<AuthUser> | null {
|
|
406
|
+
if (typeof window === 'undefined') return null;
|
|
407
|
+
const params = new URLSearchParams(window.location.search);
|
|
408
|
+
const token = params.get('token');
|
|
409
|
+
if (!token || !token.startsWith('ml:')) return null;
|
|
410
|
+
params.delete('token');
|
|
411
|
+
scrubUrl(params, window.location.hash || '');
|
|
412
|
+
return loginWithMagicToken(token);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Passkeys (WebAuthn) ───────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
export function isPasskeySupported(): boolean {
|
|
418
|
+
return typeof window !== 'undefined'
|
|
419
|
+
&& typeof window.PublicKeyCredential !== 'undefined'
|
|
420
|
+
&& typeof navigator.credentials?.get === 'function';
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function base64urlToBuffer(base64url: string): ArrayBuffer {
|
|
424
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
425
|
+
const binary = atob(base64);
|
|
426
|
+
const bytes = new Uint8Array(binary.length);
|
|
427
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
428
|
+
return bytes.buffer;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function bufferToBase64url(buffer: ArrayBuffer): string {
|
|
432
|
+
const bytes = new Uint8Array(buffer);
|
|
433
|
+
let binary = '';
|
|
434
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
435
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface PasskeyBeginResponse {
|
|
439
|
+
challenge_id: string;
|
|
440
|
+
publicKey: PublicKeyCredentialRequestOptions & {
|
|
441
|
+
challenge: unknown;
|
|
442
|
+
allowCredentials?: { id: unknown; type: string; transports?: string[] }[];
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Passkey login: begin → authenticator prompt → complete. With no username
|
|
448
|
+
* the server returns empty allowCredentials and the browser offers every
|
|
449
|
+
* discoverable credential for this domain.
|
|
450
|
+
*/
|
|
451
|
+
export interface PasskeyLoginOptions {
|
|
452
|
+
/** true (default) → localStorage; false → sessionStorage. */
|
|
453
|
+
remember?: boolean;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function loginWithPasskey(username?: string, opts: PasskeyLoginOptions = {}): Promise<AuthUser> {
|
|
457
|
+
if (!isPasskeySupported()) throw new Error('Passkeys are not supported in this browser');
|
|
458
|
+
|
|
459
|
+
const begin = await mojoCall('/api/auth/passkeys/login/begin', { method: 'POST', body: username ? { username } : {} });
|
|
460
|
+
const { challenge_id, publicKey } = begin.data as unknown as PasskeyBeginResponse;
|
|
461
|
+
|
|
462
|
+
const request: PublicKeyCredentialRequestOptions = {
|
|
463
|
+
...publicKey,
|
|
464
|
+
challenge: base64urlToBuffer(String(publicKey.challenge)),
|
|
465
|
+
allowCredentials: publicKey.allowCredentials?.map((c) => ({
|
|
466
|
+
type: 'public-key' as const,
|
|
467
|
+
id: base64urlToBuffer(String(c.id)),
|
|
468
|
+
transports: c.transports as AuthenticatorTransport[] | undefined,
|
|
469
|
+
})),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const credential = (await navigator.credentials.get({ publicKey: request })) as PublicKeyCredential | null;
|
|
473
|
+
if (!credential) throw new Error('No credential received from authenticator');
|
|
474
|
+
const assertion = credential.response as AuthenticatorAssertionResponse;
|
|
475
|
+
|
|
476
|
+
const body = await mojoCall('/api/auth/passkeys/login/complete', {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
body: {
|
|
479
|
+
challenge_id,
|
|
480
|
+
credential: {
|
|
481
|
+
id: credential.id,
|
|
482
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
483
|
+
type: credential.type,
|
|
484
|
+
response: {
|
|
485
|
+
clientDataJSON: bufferToBase64url(assertion.clientDataJSON),
|
|
486
|
+
authenticatorData: bufferToBase64url(assertion.authenticatorData),
|
|
487
|
+
signature: bufferToBase64url(assertion.signature),
|
|
488
|
+
userHandle: assertion.userHandle ? bufferToBase64url(assertion.userHandle) : null,
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Fresh-auth (step-up) challenges ───────────────────────────────────
|
|
497
|
+
// django-mojo's freshness gate (account/services/fresh_auth.py): sensitive
|
|
498
|
+
// operations can require that the JWT's `auth_time` claim — stamped at the
|
|
499
|
+
// last GENUINE login — is younger than FRESH_AUTH_WINDOW. A stale call gets
|
|
500
|
+
// HTTP 440 / reason 'reauth_required' (mojo/errors.py, deliberately NOT 401:
|
|
501
|
+
// a token refresh carries auth_time forward unchanged and can never satisfy
|
|
502
|
+
// the gate). The only cure is a real re-login (password / MFA / passkey),
|
|
503
|
+
// which the UI presents as a modal OVER the current screen — page state is
|
|
504
|
+
// never thrown away for a redirect.
|
|
505
|
+
//
|
|
506
|
+
// Wiring: the app registers ONE UI handler (setFreshAuthHandler → the C3
|
|
507
|
+
// fresh-auth modal). Call sites wrap sensitive operations in
|
|
508
|
+
// `withFreshAuth(fn)` — on a 440 the handler prompts, and a successful
|
|
509
|
+
// re-login (new tokens stored by the normal login flows) retries fn once.
|
|
510
|
+
|
|
511
|
+
/** HTTP status + envelope error_code of a step-up challenge. */
|
|
512
|
+
export const REAUTH_STATUS = 440;
|
|
513
|
+
|
|
514
|
+
/** True when an error is django-mojo's fresh-auth (step-up) challenge. */
|
|
515
|
+
export function isReauthRequired(error: unknown): boolean {
|
|
516
|
+
return error instanceof MojoError
|
|
517
|
+
&& (error.status === REAUTH_STATUS || error.message === 'reauth_required');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Resolve true when the user completed a fresh re-login, false when they
|
|
522
|
+
* dismissed the prompt (or no prompt is wired).
|
|
523
|
+
*/
|
|
524
|
+
export type FreshAuthHandler = () => Promise<boolean>;
|
|
525
|
+
|
|
526
|
+
let freshAuthHandler: FreshAuthHandler | null = null;
|
|
527
|
+
let freshAuthPromise: Promise<boolean> | null = null;
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Register the UI that answers step-up challenges (one per app — the C3
|
|
531
|
+
* FreshAuthHost). Pass null to unregister. The handler re-authenticates via
|
|
532
|
+
* the normal login flows, so the fresh tokens (and their new auth_time) are
|
|
533
|
+
* stored before it resolves true; keep the session's storage by passing
|
|
534
|
+
* `{remember: sessionIsPersistent()}` to the login call.
|
|
535
|
+
*/
|
|
536
|
+
export function setFreshAuthHandler(handler: FreshAuthHandler | null): void {
|
|
537
|
+
freshAuthHandler = handler;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Prompt for a fresh re-login. Single-flight: concurrent 440s (a burst of
|
|
542
|
+
* gated saves) share ONE prompt and all observe its outcome. Resolves false
|
|
543
|
+
* — with a console.warn, never a throw — when no handler is registered.
|
|
544
|
+
*/
|
|
545
|
+
export function requestFreshAuth(): Promise<boolean> {
|
|
546
|
+
if (!freshAuthHandler) {
|
|
547
|
+
console.warn('requestFreshAuth: no fresh-auth handler registered (setFreshAuthHandler) — challenge cannot be answered');
|
|
548
|
+
return Promise.resolve(false);
|
|
549
|
+
}
|
|
550
|
+
if (!freshAuthPromise) {
|
|
551
|
+
const handler = freshAuthHandler;
|
|
552
|
+
freshAuthPromise = handler()
|
|
553
|
+
.catch((error) => {
|
|
554
|
+
// A handler bug must not wedge the single-flight forever.
|
|
555
|
+
console.warn('fresh-auth handler threw', error);
|
|
556
|
+
return false;
|
|
557
|
+
})
|
|
558
|
+
.finally(() => {
|
|
559
|
+
freshAuthPromise = null;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return freshAuthPromise;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Run a sensitive operation with step-up handling: on a 440 reject, prompt
|
|
567
|
+
* (requestFreshAuth) and retry ONCE after a successful re-login. Any other
|
|
568
|
+
* failure — and a dismissed prompt — rejects with the ORIGINAL error, so
|
|
569
|
+
* failure stays unmissable at the call site.
|
|
570
|
+
*/
|
|
571
|
+
export async function withFreshAuth<T>(fn: () => Promise<T>): Promise<T> {
|
|
572
|
+
try {
|
|
573
|
+
return await fn();
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (!isReauthRequired(error)) throw error;
|
|
576
|
+
const ok = await requestFreshAuth();
|
|
577
|
+
if (!ok) throw error;
|
|
578
|
+
return fn();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Cross-origin auth handoff ─────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
let exchangePromise: Promise<AuthUser | null> | null = null;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Exchange a one-time handoff code (32-hex, 60s TTL, single-use — all
|
|
588
|
+
* server-enforced) for tokens. Single-flight; resolves null on failure
|
|
589
|
+
* (the code is burned either way — nothing to retry).
|
|
590
|
+
*/
|
|
591
|
+
export function exchangeAuthCode(code: string): Promise<AuthUser | null> {
|
|
592
|
+
if (!exchangePromise) {
|
|
593
|
+
exchangePromise = doExchange(code).finally(() => {
|
|
594
|
+
exchangePromise = null;
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return exchangePromise;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function doExchange(code: string): Promise<AuthUser | null> {
|
|
601
|
+
try {
|
|
602
|
+
const body = await mojoCall('/api/auth/exchange', { method: 'POST', body: { code } });
|
|
603
|
+
return adoptGrant(body.data as TokenGrant);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
emitAuth('refresh-failed', error);
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Read `?auth_code=` from the URL and exchange it. The scrub happens
|
|
612
|
+
* synchronously, BEFORE any await, so a slow exchange cannot leak the code.
|
|
613
|
+
* Resolves null when no code is present or the exchange fails.
|
|
614
|
+
*
|
|
615
|
+
* Two landing shapes are handled, because the hosted auth page string-appends
|
|
616
|
+
* `?auth_code=` to whatever `?redirect=` carried:
|
|
617
|
+
* · the real search string (`/?auth_code=x#/route`) — what
|
|
618
|
+
* redirectToHostedAuth() produces by sending a hash-free return URL;
|
|
619
|
+
* · inside the hash query (`/#/route?auth_code=x`) — a deployment that
|
|
620
|
+
* passed a full hash-router URL as the redirect.
|
|
621
|
+
* After a successful exchange the stashed return route (if any) is restored.
|
|
622
|
+
*/
|
|
623
|
+
export function handleAuthCodeFromURL(): Promise<AuthUser | null> {
|
|
624
|
+
if (typeof window === 'undefined' || !window.location) return Promise.resolve(null);
|
|
625
|
+
if (exchangePromise) return exchangePromise;
|
|
626
|
+
|
|
627
|
+
const params = new URLSearchParams(window.location.search);
|
|
628
|
+
let code = params.get(AUTH_CODE_PARAM);
|
|
629
|
+
if (code) {
|
|
630
|
+
params.delete(AUTH_CODE_PARAM);
|
|
631
|
+
scrubUrl(params, window.location.hash || '');
|
|
632
|
+
} else {
|
|
633
|
+
// Hash-embedded: '#/route?auth_code=x&…'
|
|
634
|
+
const hash = window.location.hash || '';
|
|
635
|
+
const qIndex = hash.indexOf('?');
|
|
636
|
+
if (qIndex === -1) return Promise.resolve(null);
|
|
637
|
+
const hashParams = new URLSearchParams(hash.slice(qIndex + 1));
|
|
638
|
+
code = hashParams.get(AUTH_CODE_PARAM);
|
|
639
|
+
if (!code) return Promise.resolve(null);
|
|
640
|
+
hashParams.delete(AUTH_CODE_PARAM);
|
|
641
|
+
const rest = hashParams.toString();
|
|
642
|
+
scrubUrl(params, hash.slice(0, qIndex) + (rest ? `?${rest}` : ''));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return exchangeAuthCode(code).then((user) => {
|
|
646
|
+
if (user) restoreReturnRoute();
|
|
647
|
+
return user;
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function scrubUrl(params: URLSearchParams, hash: string): void {
|
|
652
|
+
const remaining = params.toString();
|
|
653
|
+
const clean = window.location.pathname + (remaining ? `?${remaining}` : '') + hash;
|
|
654
|
+
window.history.replaceState({}, '', clean);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── Hosted auth pages (the django-mojo /auth "bouncer" pages) ─────────
|
|
658
|
+
// The backend serves themed login/register pages at <origin>/auth. A portal
|
|
659
|
+
// sends the user there with ?redirect=<return url>; after sign-in the page
|
|
660
|
+
// mints a single-use handoff code (60s TTL) and navigates to
|
|
661
|
+
// <redirect>?auth_code=<code>, which handleAuthCodeFromURL() exchanges.
|
|
662
|
+
|
|
663
|
+
const RETURN_ROUTE_KEY = 'mojo:auth-return-route';
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The hosted auth page URL for this deployment, or null under the mock
|
|
667
|
+
* transport (there is no page to host). `returnTo` defaults to a HASH-FREE
|
|
668
|
+
* form of the current URL — the auth page appends `?auth_code=` with plain
|
|
669
|
+
* string concatenation, and a hash in the redirect would swallow it.
|
|
670
|
+
*/
|
|
671
|
+
export function hostedAuthUrl(returnTo?: string): string | null {
|
|
672
|
+
const origin = apiOrigin();
|
|
673
|
+
if (!origin || typeof window === 'undefined') return null;
|
|
674
|
+
const target = returnTo ?? window.location.origin + window.location.pathname + window.location.search;
|
|
675
|
+
return `${origin}/auth?redirect=${encodeURIComponent(target)}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Send the user to the hosted auth page, remembering the current in-app
|
|
680
|
+
* (hash) route so the post-exchange boot can put them back where they were.
|
|
681
|
+
*/
|
|
682
|
+
export function redirectToHostedAuth(): boolean {
|
|
683
|
+
const url = hostedAuthUrl();
|
|
684
|
+
if (!url) return false;
|
|
685
|
+
try {
|
|
686
|
+
const route = window.location.hash;
|
|
687
|
+
if (route && route !== '#/') sessionStorage.setItem(RETURN_ROUTE_KEY, route);
|
|
688
|
+
} catch { /* storage denied — land on the default route */ }
|
|
689
|
+
window.location.href = url;
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function restoreReturnRoute(): void {
|
|
694
|
+
try {
|
|
695
|
+
const route = sessionStorage.getItem(RETURN_ROUTE_KEY);
|
|
696
|
+
if (route) {
|
|
697
|
+
sessionStorage.removeItem(RETURN_ROUTE_KEY);
|
|
698
|
+
window.location.hash = route;
|
|
699
|
+
}
|
|
700
|
+
} catch { /* storage denied */ }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Logout + boot wiring ──────────────────────────────────────────────
|
|
704
|
+
|
|
705
|
+
/** Client-side logout (django-mojo has no logout endpoint — tokens just die). */
|
|
706
|
+
export function logout(): void {
|
|
707
|
+
clearTokens();
|
|
708
|
+
stopAutoRefresh();
|
|
709
|
+
emitAuth('logout');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Wire auth into the transport and start session upkeep. Call once at app
|
|
714
|
+
* boot. Installs the pre-request gate + Authorization header provider
|
|
715
|
+
* (PortalApp._installAuthGate parity) and starts the 60s watcher when a
|
|
716
|
+
* session exists. Without this call the client runs ungated (pre-login).
|
|
717
|
+
*/
|
|
718
|
+
export function initAuth(): void {
|
|
719
|
+
installAuthHooks({
|
|
720
|
+
async preRequest(path: string) {
|
|
721
|
+
// Recursion guard: never gate the refresh call itself.
|
|
722
|
+
if (path === REFRESH_PATH) return;
|
|
723
|
+
// No stored token → pre-login / public flow, pass through.
|
|
724
|
+
if (!getAccessToken()) return;
|
|
725
|
+
await ensureValidToken();
|
|
726
|
+
// NOTE: web-mojo had to re-stamp Authorization here because its
|
|
727
|
+
// Rest snapshotted headers before the gate ran. Our transport
|
|
728
|
+
// reads the token AFTER this hook, so the fresh token is picked
|
|
729
|
+
// up by construction.
|
|
730
|
+
},
|
|
731
|
+
authHeader() {
|
|
732
|
+
const token = getAccessToken();
|
|
733
|
+
return token ? `Bearer ${token}` : null;
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
if (getRefreshToken()) startAutoRefresh();
|
|
737
|
+
}
|