Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91f06ccae1 | |||
| e9eb9b4172 | |||
| a1a684ee81 | |||
| 6044928c70 | |||
| 3cd7499f3f | |||
| 29a21fd3b3 | |||
| 21f5abb49b | |||
| 68469b0740 | |||
| 525a72b73b | |||
| d913dfaeb1 | |||
| fe9da65437 | |||
| 28d30fe392 | |||
| 1532c9704b | |||
| 76efcb835f | |||
| 2d1e6ea6e1 | |||
| 98e614a945 | |||
| ad3e51a9e8 | |||
| d8f72d620a | |||
| 53b36e506c | |||
| 7d5ad29a27 | |||
| 724ec2d134 | |||
| 32ffc1bbaa | |||
| a91dd9dda6 | |||
| 5462257398 | |||
| 2ad751ecba | |||
| a24b0d8be7 | |||
| 02c700e44d | |||
| e9f1b5dac9 | |||
| 6645806a87 | |||
| dc3f232f43 |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"npmci": {
|
"npmci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": [],
|
||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
|
"registry.gitlab.com": "code.foss.global/idp.global/app"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
"dockerBuildargEnvMap": {
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "idp.global",
|
"gitscope": "idp.global",
|
||||||
"gitrepo": "idp.global",
|
"gitrepo": "app",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"npmPackagename": "@idp.global/idp.global",
|
"npmPackagename": "@idp.global/idp.global",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -50,5 +50,49 @@
|
|||||||
"registries": ["https://verdaccio.lossless.digital"],
|
"registries": ["https://verdaccio.lossless.digital"],
|
||||||
"accessLevel": "public"
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_serve/bundle.js",
|
||||||
|
"outputMode": "bundle",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true,
|
||||||
|
"includeFiles": ["./html/index.html", "./assets/**/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/tswatch": {
|
||||||
|
"preset": "website",
|
||||||
|
"server": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"watchers": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"watch": "./ts/**/*",
|
||||||
|
"command": "pnpm run startTs",
|
||||||
|
"restart": true,
|
||||||
|
"debounce": 300,
|
||||||
|
"runOnStart": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_serve/bundle.js",
|
||||||
|
"watchPatterns": ["./ts_web/**/*"],
|
||||||
|
"triggerReload": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "html",
|
||||||
|
"from": "./html/index.html",
|
||||||
|
"to": "./dist_serve/index.html",
|
||||||
|
"watchPatterns": ["./html/**/*"],
|
||||||
|
"triggerReload": false
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+114
@@ -1,5 +1,119 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.21.0 - feat(reception)
|
||||||
|
add passport device authentication flows and alert delivery management
|
||||||
|
|
||||||
|
- introduce passport device, challenge, and nonce models with typed request interfaces for enrollment, challenge approval, push token registration, and signed device requests
|
||||||
|
- add alert and alert rule models plus alert manager endpoints for listing, resolving by hint, marking seen, and routing notifications to eligible recipients
|
||||||
|
- send security and admin alerts for global admin dashboard access and global app credential regeneration
|
||||||
|
- schedule housekeeping tasks to expire passport challenges and retry pending passport challenge and alert push deliveries
|
||||||
|
- cover passport and alert workflows with new node tests
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.20.0 - feat(auth)
|
||||||
|
add abuse protection for login and OIDC flows with consent-based authorization handling
|
||||||
|
|
||||||
|
- introduces AbuseProtectionManager and AbuseWindow storage to rate limit password login, magic link, password reset, and OIDC token exchange attempts
|
||||||
|
- adds housekeeping cleanup for expired abuse protection windows
|
||||||
|
- adds typed OIDC prepare/complete authorization requests plus consent evaluation and redirect URL generation
|
||||||
|
- updates the login prompt to support OIDC authorization continuation after user login or consent
|
||||||
|
- includes tests for abuse protection behavior and OIDC authorization preparation/completion flows
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.1 - fix(ts_interfaces)
|
||||||
|
rename generated TypeScript interface files to remove the loint-reception prefix
|
||||||
|
|
||||||
|
- Moves data and request interface files from loint-reception.* names to clean module names under ts_interfaces
|
||||||
|
- Renames the shared plugins export to ts_interfaces/plugins.ts
|
||||||
|
- Preserves interface contents while standardizing the generated file naming layout
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.0 - feat(oidc)
|
||||||
|
persist hashed OIDC tokens, authorization codes, and user consent in smartdata storage
|
||||||
|
|
||||||
|
- replace in-memory OIDC authorization code, access token, refresh token, and consent stores with SmartData document classes
|
||||||
|
- store authorization codes and tokens as hashes instead of persisting plaintext values, with helpers for matching, expiration, and revocation
|
||||||
|
- persist and merge user consent scopes when issuing authorization codes
|
||||||
|
- add cleanup lifecycle management for expired OIDC state and stop the cleanup task when reception shuts down
|
||||||
|
- add tests covering hashed code/token matching, authorization code usage, refresh token revocation, and consent scope merging
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.18.0 - feat(reception)
|
||||||
|
persist email action tokens and registration sessions for authentication and signup flows
|
||||||
|
|
||||||
|
- add persisted email action tokens for email login and password reset with one-time consumption and expiry cleanup
|
||||||
|
- store registration sessions in the database so signup state, email validation, and SMS verification survive restarts
|
||||||
|
- enforce password changes through either a valid reset token or the current password
|
||||||
|
- add housekeeping jobs and tests for token/session expiry and state persistence
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.17.1 - fix(docs)
|
||||||
|
refresh module readmes and add repository license file
|
||||||
|
|
||||||
|
- rewrite the root, backend, web, client, CLI, and interfaces README content to focus on current module responsibilities and usage
|
||||||
|
- standardize README license references to the lowercase license file path
|
||||||
|
- add the repository MIT license file
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.17.0 - feat(auth)
|
||||||
|
harden authentication with argon2 passwords and rotating hashed refresh tokens
|
||||||
|
|
||||||
|
- replace SHA-256 password hashing with argon2 while preserving verification and upgrade support for legacy hashes
|
||||||
|
- rotate refresh tokens on JWT refresh, detect token reuse, and invalidate compromised sessions
|
||||||
|
- store refresh and transfer tokens as hashes with one-time transfer token validation and expiry
|
||||||
|
- persist refresh tokens separately on the client so sessions can recover and refresh without embedding tokens in JWTs
|
||||||
|
- add authentication tests covering password verification, legacy hash migration, refresh token rotation, reuse detection, and one-time transfer tokens
|
||||||
|
|
||||||
|
## 2026-01-29 - 1.16.0 - feat(dev)
|
||||||
|
add local development docs, update tswatch preset and add Playwright screenshots
|
||||||
|
|
||||||
|
- readme.md: added a Local Development section with prerequisites, quick-start commands, environment variables, development routes, and default development credentials + security note
|
||||||
|
- npmextra.json: changed @git.zone/tswatch preset from "website" to "service" and disabled the built-in server (removed port/serveDir/liveReload and set server.enabled false); removed triggerReload from website watcher
|
||||||
|
- .playwright-mcp: added Playwright screenshots (login-page.png, register-page.png, account-dashboard.png) for visual tests / CI
|
||||||
|
|
||||||
|
## 2026-01-29 - 1.15.0 - feat(build)
|
||||||
|
add tsbundle/tswatch configs, update build/watch scripts, bump dependencies, and add CLI documentation
|
||||||
|
|
||||||
|
- Add tsbundle and tswatch configuration to npmextra.json to support bundling and a local dev server (dist_serve, liveReload, watch patterns).
|
||||||
|
- Update package.json build/watch scripts to use generic tsbundle/tswatch invocations (removed explicit 'website' target).
|
||||||
|
- Bump dependencies and devDependencies: @git.zone/tsbuild ^4.0.2 -> ^4.1.2, @git.zone/tsbundle ^2.6.3 -> ^2.8.3, @git.zone/tswatch ^2.3.13 -> ^3.0.1, @api.global/typedserver ^8.1.0 -> ^8.3.0, several @design.estate packages, @push.rocks/taskbuffer ^3.5.0 -> ^4.1.1, @types/node 25.0.3 -> 25.1.0, and other minor/patch bumps.
|
||||||
|
- Add a new CLI README (ts_idpcli/readme.md) with usage, commands, programmatic API examples and configuration.
|
||||||
|
- Update README license/Legal sections in ts_idpclient, ts_interfaces and ts_web to include license, trademark, and company information.
|
||||||
|
|
||||||
|
## 2025-12-22 - 1.14.1 - fix(oidc)
|
||||||
|
migrate OIDC endpoints and internal handlers to use typedserver IRequestContext and update dependencies
|
||||||
|
|
||||||
|
- Updated route handlers in ts/index.ts to pass ctx (IRequestContext) instead of req
|
||||||
|
- Refactored OIDC manager handlers to accept plugins.typedserver.IRequestContext and use ctx.url, ctx.headers, ctx.formData (handleAuthorize, handleToken, handleUserInfo, handleRevoke)
|
||||||
|
- Bumped dependencies to support the new typedserver API: @api.global/typedserver -> ^8.1.0
|
||||||
|
- Other dependency updates: @design.estate/dees-catalog ^3.4.0, @git.zone/tspublish ^1.11.0, @types/node ^25.0.3
|
||||||
|
- Changing public handler method signatures is a breaking API change; recommend a major version bump
|
||||||
|
|
||||||
|
## 2025-12-16 - 1.14.0 - feat(docs)
|
||||||
|
add package READMEs and publish metadata; update web package publish order
|
||||||
|
|
||||||
|
- Add comprehensive README for ts_web (web components/UI)
|
||||||
|
- Add README for ts_idpclient (TypeScript client)
|
||||||
|
- Add README for ts_interfaces (type definitions/interfaces)
|
||||||
|
- Add tspublish.json for ts_idpcli (@idp.global/cli) and ts_idpclient (@idp.global/client)
|
||||||
|
- Update ts_web/tspublish.json order from 4 to 5
|
||||||
|
|
||||||
|
## 2025-12-15 - 1.13.0 - feat(oidc)
|
||||||
|
feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces)
|
||||||
|
|
||||||
|
- Add OidcManager class implementing OpenID Connect / OAuth2 server functionality (authorization codes, access/refresh tokens, user consents, PKCE support, JWKS, ID token generation, token revocation, cleanup task).
|
||||||
|
- Expose OIDC endpoints on the website server: /.well-known/openid-configuration, /.well-known/jwks.json, /oauth/authorize, /oauth/token, /oauth/userinfo (GET/POST), and /oauth/revoke.
|
||||||
|
- Integrate OidcManager into Reception: add oidcManager property and instantiate it from ts/index.ts so routes can reference it.
|
||||||
|
- Add TypeScript interfaces for OIDC data structures (ts_interfaces/data/loint-reception.oidc.ts) and export them from the data index.
|
||||||
|
|
||||||
|
## 2025-12-15 - 1.12.1 - fix(dependencies)
|
||||||
|
fix(deps): bump @uptime.link/webwidget to ^1.2.6
|
||||||
|
|
||||||
|
- Updated dependency @uptime.link/webwidget from ^1.2.5 to ^1.2.6 in package.json
|
||||||
|
- No other files changed; this is a dependency patch update
|
||||||
|
|
||||||
|
## 2025-12-15 - 1.12.0 - feat(interfaces)
|
||||||
|
Add JWT public-key and blocklist request interfaces, publish ordering files, and update dependencies
|
||||||
|
|
||||||
|
- Introduce IReq_GetPublicKeyForValidation and IReq_PushPublicKeyForValidation with documentation in ts_interfaces/request/loint-reception.jwt.ts to support fetching and pushing JWT public keys for validation.
|
||||||
|
- Clarify IReq_PushOrGetJwtIdBlocklist to describe both GET (client requests blocklist) and PUSH (server pushes revoked JWT IDs) directions and required client handlers.
|
||||||
|
- Add tspublish.json ordering files for packaging: ts_interfaces (order: 1), ts (order: 2), ts_idpclient (order: 3), ts_web (order: 4).
|
||||||
|
- Update package.json dependencies to include @git.zone/tspublish and additional @push.rocks packages (@push.rocks/smartcli, @push.rocks/smartfile, @push.rocks/smartinteract).
|
||||||
|
|
||||||
## 2025-12-14 - 1.11.0 - feat(idpcli)
|
## 2025-12-14 - 1.11.0 - feat(idpcli)
|
||||||
Add idp CLI (IdpCli) with commands, file-based credential storage, typed request APIs; bump deps and update config
|
Add idp CLI (IdpCli) with commands, file-based credential storage, typed request APIs; bump deps and update config
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -10,14 +10,9 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
|
|
||||||
<!--Lets make sure we recognize this as an PWA-->
|
<!--Lets make sure we recognize this as an PWA-->
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/idp-manifest.json" />
|
||||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||||
|
|
||||||
<!--Lets load standard fonts-->
|
|
||||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
|
||||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
|
||||||
|
|
||||||
|
|
||||||
<!--Lets avoid a rescaling flicker due to default body margins-->
|
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||||
<style>
|
<style>
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+49
-42
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/idp.global",
|
||||||
"version": "1.11.0",
|
"version": "1.21.0",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run build",
|
"test": "pnpm run build && tstest test/",
|
||||||
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle website --production",
|
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
|
||||||
"watch": "tswatch website",
|
"watch": "tswatch",
|
||||||
|
"seed": "tsrun ts_seed/cli.ts",
|
||||||
"start": "(node cli.js)",
|
"start": "(node cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
@@ -16,63 +17,69 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.2.5",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^7.11.1",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.0",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@consent.software/catalog": "^2.0.1",
|
"@consent.software/catalog": "^2.0.1",
|
||||||
"@design.estate/dees-catalog": "^3.3.1",
|
"@design.estate/dees-catalog": "^3.81.0",
|
||||||
"@design.estate/dees-domtools": "^2.3.6",
|
"@design.estate/dees-domtools": "^2.5.6",
|
||||||
"@design.estate/dees-element": "^2.1.3",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@git.zone/tspublish": "^1.11.6",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@idp.global/catalog": "file:../catalog",
|
||||||
"@push.rocks/smartdata": "^7.0.15",
|
"@idp.global/interfaces": "file:../interfaces",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/lik": "^6.4.1",
|
||||||
"@push.rocks/smarthash": "^3.2.6",
|
"@push.rocks/qenv": "^6.1.4",
|
||||||
"@push.rocks/smartjson": "^6.0.0",
|
"@push.rocks/smartcli": "^4.0.21",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartdelay": "^3.1.0",
|
||||||
"@push.rocks/smartmail": "^2.2.0",
|
"@push.rocks/smartfile": "^13.1.3",
|
||||||
|
"@push.rocks/smarthash": "^3.2.7",
|
||||||
|
"@push.rocks/smartinteract": "^2.0.16",
|
||||||
|
"@push.rocks/smartjson": "^6.0.1",
|
||||||
|
"@push.rocks/smartjwt": "^2.2.2",
|
||||||
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
|
"@push.rocks/smartmail": "^2.2.1",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.4",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.0.27",
|
"@push.rocks/smartstate": "^2.3.1",
|
||||||
"@push.rocks/smarttime": "^4.1.1",
|
"@push.rocks/smarttime": "^4.2.3",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smarturl": "^3.1.0",
|
"@push.rocks/smarturl": "^3.1.0",
|
||||||
"@push.rocks/taskbuffer": "^3.5.0",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@push.rocks/smartcli": "^4.0.19",
|
"@push.rocks/webjwt": "^1.0.10",
|
||||||
"@push.rocks/smartfile": "^13.1.0",
|
"@push.rocks/websetup": "^3.0.20",
|
||||||
"@push.rocks/smartinteract": "^2.0.6",
|
"@push.rocks/webstore": "^2.0.22",
|
||||||
"@push.rocks/webjwt": "^1.0.9",
|
"@serve.zone/platformclient": "^1.1.4",
|
||||||
"@push.rocks/websetup": "^3.0.15",
|
"@tsclass/tsclass": "^9.5.1",
|
||||||
"@push.rocks/webstore": "^2.0.20",
|
"@uptime.link/webwidget": "^1.2.6",
|
||||||
"@serve.zone/platformclient": "^1.1.2",
|
"argon2": "^0.44.0"
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
|
||||||
"@uptime.link/webwidget": "^1.2.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.0.2",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsbundle": "^2.6.3",
|
"@git.zone/tsbundle": "^2.10.1",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.3",
|
||||||
"@git.zone/tswatch": "^2.3.13",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@git.zone/tswatch": "^3.3.3",
|
||||||
"@types/node": "^24.10.1"
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
|
"@types/node": "^25.6.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://code.foss.global/idp.global/idp.global.git"
|
"url": "git+https://code.foss.global/idp.global/app.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://code.foss.global/idp.global/idp.global/issues"
|
"url": "https://code.foss.global/idp.global/app/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/idp.global/idp.global#readme",
|
"homepage": "https://code.foss.global/idp.global/app#readme",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 1 chrome versions"
|
"last 1 chrome versions"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
"ts_seed/**/*",
|
||||||
"ts_web/**/*",
|
"ts_web/**/*",
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist_*/**/*",
|
"dist_*/**/*",
|
||||||
@@ -80,7 +87,7 @@
|
|||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
Generated
+4052
-3839
File diff suppressed because it is too large
Load Diff
@@ -1,312 +1,238 @@
|
|||||||
# @idp.global/idp.global
|
# @idp.global/idp.global
|
||||||
|
|
||||||
An identity provider software managing user authentications, registrations, and sessions.
|
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, mobile passport approvals, security alerts, and OpenID Connect in one TypeScript codebase.
|
||||||
|
|
||||||
## Install
|
This repository ships the `idp.global` server, browser SDK, CLI, web UI, and tspublish submodules used by the hosted service. Shared public contracts live in the sibling `@idp.global/interfaces` package.
|
||||||
|
|
||||||
To install `@idp.global/idp.global`, you can run the following command in your terminal:
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- Runs an identity provider with MongoDB-backed users, sessions, roles, organizations, invitations, API tokens, and billing plans.
|
||||||
|
- Serves a web app for login, registration, account management, org management, billing flows, and global admin views.
|
||||||
|
- Exposes typed realtime APIs over `typedrequest` and `typedsocket`.
|
||||||
|
- Implements OIDC/OAuth endpoints including discovery, JWKS, authorization, token, userinfo, and revoke.
|
||||||
|
- Supports passport-style mobile device enrollment, signed approval challenges, push registration, security alerts, and NFC/location-backed identity proof flows.
|
||||||
|
- Includes a reusable browser client and a terminal CLI for common account and org workflows.
|
||||||
|
|
||||||
|
## Monorepo Modules
|
||||||
|
|
||||||
|
| Folder | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `ts/` | Backend service entrypoint and the core `Reception` managers |
|
||||||
|
| `ts_idpclient/` | Browser-focused SDK published as `@idp.global/client` |
|
||||||
|
| `ts_idpcli/` | CLI published as `@idp.global/cli` |
|
||||||
|
| `ts_web/` | Frontend bundle with login, registration, account, org, billing, and admin views |
|
||||||
|
| `../interfaces/` | Shared request and data contracts published as `@idp.global/interfaces` |
|
||||||
|
|
||||||
|
## Core Backend Pieces
|
||||||
|
|
||||||
|
`Reception` wires the service together and starts these managers:
|
||||||
|
|
||||||
|
- `JwtManager` for signing, refreshing, and validating JWTs.
|
||||||
|
- `LoginSessionManager` for login state and session lifecycle.
|
||||||
|
- `RegistrationSessionManager` for multi-step sign-up flows.
|
||||||
|
- `UserManager` for user lookups and account data.
|
||||||
|
- `OrganizationManager` for org creation and membership lookup.
|
||||||
|
- `RoleManager` for org roles and permissions.
|
||||||
|
- `UserInvitationManager` for invites, membership updates, and ownership transfer.
|
||||||
|
- `ApiTokenManager` for long-lived token auth.
|
||||||
|
- `BillingPlanManager` for Paddle-backed billing data.
|
||||||
|
- `AppManager` and `AppConnectionManager` for app connections and admin app stats.
|
||||||
|
- `ActivityLogManager` for audit-style activity entries.
|
||||||
|
- `AlertManager` for passport alerts and organization/global alert rules.
|
||||||
|
- `AbuseProtectionManager` for rate-limited sensitive flows such as OIDC token exchange.
|
||||||
|
- `PassportManager` and `PassportPushManager` for trusted device enrollment, challenge approval, and push notification delivery.
|
||||||
|
- `OidcManager` for the OIDC/OAuth provider surface.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pnpm`
|
||||||
|
- MongoDB
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @idp.global/idp.global
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
This will download and install the necessary dependencies along with the module to your project.
|
### Required Environment
|
||||||
|
|
||||||
## Usage
|
```bash
|
||||||
|
export MONGODB_URL=mongodb://localhost:27017/idp-dev
|
||||||
To use `@idp.global/idp.global`, one needs to understand its key components and functionalities. Below, we'll guide you through setting up, logging in, registering, and managing users and organizations within an IDP (Identity Provider) environment using this package.
|
export IDP_BASEURL=http://localhost:2999
|
||||||
|
export INSTANCE_NAME=idp-dev
|
||||||
### Setting Up the Environment
|
|
||||||
|
|
||||||
First, let's set up the environment:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Import the necessary modules
|
|
||||||
import * as serviceworker from '@api.global/typedserver/web_serviceworker_client';
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
|
||||||
import { html, render } from '@design.estate/dees-element';
|
|
||||||
import { IdpWelcome } from './elements/idp-welcome.js';
|
|
||||||
|
|
||||||
// Define an asynchronous run function
|
|
||||||
const run = async () => {
|
|
||||||
// Set up DOM tools
|
|
||||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
|
||||||
domtools.elementBasic.setup();
|
|
||||||
|
|
||||||
// Configure website information
|
|
||||||
domtoolsInstance.setWebsiteInfo({
|
|
||||||
metaObject: {
|
|
||||||
title: 'idp.global',
|
|
||||||
description: 'the code that runs idp.global',
|
|
||||||
canonicalDomain: 'https://idp.global',
|
|
||||||
ldCompany: {
|
|
||||||
name: 'Task Venture Capital GmbH',
|
|
||||||
status: 'active',
|
|
||||||
contact: {
|
|
||||||
address: {
|
|
||||||
name: 'Task Venture Capital GmbH',
|
|
||||||
city: 'Grasberg',
|
|
||||||
country: 'Germany',
|
|
||||||
houseNumber: '24',
|
|
||||||
postalCode: '28879',
|
|
||||||
streetName: 'Eickedorfer Vorweide',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up the service worker
|
|
||||||
const serviceWorker = await serviceworker.getServiceworkerClient();
|
|
||||||
|
|
||||||
// Render the main template
|
|
||||||
const mainTemplate = html`
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0px;
|
|
||||||
--background-accent: #303f9f;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<idp-welcome></idp-welcome>
|
|
||||||
`;
|
|
||||||
|
|
||||||
render(mainTemplate, document.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the function
|
|
||||||
run();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using the IDP Client
|
Optional:
|
||||||
|
|
||||||
The IDP Client is essential to communicate with the IDP server. Below is a sample of how to set up and use the IDP client:
|
- `SERVEZONE_PLATFORM_AUTHORIZATION`
|
||||||
|
- `PADDLE_TOKEN`
|
||||||
|
- `PADDLE_PRICE_ID`
|
||||||
|
|
||||||
```typescript
|
### Build
|
||||||
import { IdpState } from './idp.state.js';
|
|
||||||
import * as plugins from './plugins.js';
|
|
||||||
|
|
||||||
// Instantiate IdpState which provides a singleton instance
|
```bash
|
||||||
export class IdpDemo {
|
pnpm build
|
||||||
private idpState = IdpState.getSingletonInstance();
|
```
|
||||||
|
|
||||||
// Function to initialize and use IdpClient
|
### Run Locally
|
||||||
public async demo() {
|
|
||||||
// Fetch the client instance
|
```bash
|
||||||
const { idpClient } = this.idpState;
|
pnpm watch
|
||||||
// Handler for login
|
```
|
||||||
const handleLogin = async () => {
|
|
||||||
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`. The service listens on port `2999`.
|
||||||
|
|
||||||
|
### Seed Development Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
The seed command starts an interactive CLI that writes to the configured local database. The default demo workspace creates a global admin, an organization, demo users, and global OAuth app records.
|
||||||
|
|
||||||
|
Default development credentials if accepted unchanged:
|
||||||
|
|
||||||
|
- Email: `admin@idp.global`
|
||||||
|
- Password: `idp.global`
|
||||||
|
|
||||||
|
## Runtime Surface
|
||||||
|
|
||||||
|
### Web Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Welcome page |
|
||||||
|
| `/login` | Login flow |
|
||||||
|
| `/logout` | Logout flow |
|
||||||
|
| `/register` | Registration flow |
|
||||||
|
| `/finishregistration` | Multi-step registration completion |
|
||||||
|
| `/account` | Signed-in account area and account subroutes |
|
||||||
|
|
||||||
|
### OIDC and OAuth Endpoints
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/.well-known/openid-configuration` | Discovery document |
|
||||||
|
| `/.well-known/jwks.json` | Public signing keys |
|
||||||
|
| `/oauth/authorize` | Authorization endpoint |
|
||||||
|
| `/oauth/token` | Token exchange |
|
||||||
|
| `/oauth/userinfo` | UserInfo endpoint |
|
||||||
|
| `/oauth/revoke` | Token revocation |
|
||||||
|
|
||||||
|
Supported scopes in the OIDC manager include `openid`, `profile`, `email`, `organizations`, and `roles`.
|
||||||
|
|
||||||
|
## Passport And Mobile Approval Flow
|
||||||
|
|
||||||
|
`PassportManager` powers the trusted-device side of idp.global. A web session can create a passport enrollment challenge, the Swift app completes enrollment through a QR/NFC pairing payload, and later sign-in or identity checks can be approved by the paired device with signed challenge responses.
|
||||||
|
|
||||||
|
The typed request surface includes:
|
||||||
|
|
||||||
|
- `createPassportEnrollmentChallenge` and `completePassportEnrollment` for pairing a trusted device.
|
||||||
|
- `getPassportDevices` and `revokePassportDevice` for account-level device management.
|
||||||
|
- `createPassportChallenge`, `approvePassportChallenge`, `rejectPassportChallenge`, and `listPendingPassportChallenges` for approval flows.
|
||||||
|
- `getPassportDashboard`, `listPassportAlerts`, and `markPassportAlertSeen` for mobile app dashboards and notifications.
|
||||||
|
- `registerPassportPushToken` for push delivery setup.
|
||||||
|
|
||||||
|
## SDK Example
|
||||||
|
|
||||||
|
The browser SDK lives in `ts_idpclient/` and is published as `@idp.global/client`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { IdpClient } from '@idp.global/client';
|
||||||
|
|
||||||
|
const idpClient = new IdpClient('https://idp.global');
|
||||||
|
await idpClient.enableTypedSocket();
|
||||||
|
|
||||||
|
const isLoggedIn = await idpClient.determineLoginStatus();
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
username: 'user@example.com',
|
username: 'user@example.com',
|
||||||
password: 'password123',
|
password: 'secret',
|
||||||
});
|
});
|
||||||
if (response.refreshToken) {
|
|
||||||
await idpClient.storeJwt(response.jwt);
|
if (loginResult.refreshToken) {
|
||||||
console.log("Logged in successfully, JWT stored.");
|
await idpClient.refreshJwt(loginResult.refreshToken);
|
||||||
} else {
|
|
||||||
console.log("Login failed.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Execute login handler
|
|
||||||
await handleLogin();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantiate and run demo
|
const whoIs = await idpClient.whoIs();
|
||||||
const demo = new IdpDemo();
|
console.log(whoIs.user.data.email);
|
||||||
demo.demo();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Managing User Authentication
|
## CLI Example
|
||||||
|
|
||||||
Several functionalities are available for managing user authentication. These include registering, logging in, and refreshing JWTs.
|
The terminal client lives in `ts_idpcli/` and is published as `@idp.global/cli`.
|
||||||
|
|
||||||
#### Registration Process
|
```bash
|
||||||
|
idp login
|
||||||
The registration process is typically more involved and requires steps such as email validation, setting user-specific data, and verifying OTPs for additional security.
|
idp whoami
|
||||||
|
idp orgs
|
||||||
```typescript
|
idp members --org <org-id>
|
||||||
import * as plugins from './plugins.js';
|
idp invite --org <org-id> --email user@example.com
|
||||||
import { IdpState } from './idp.state.js';
|
|
||||||
|
|
||||||
// Registration stepper element
|
|
||||||
export class IdpRegistrationStepper extends plugins.DeesElement {
|
|
||||||
private idpState = IdpState.getSingletonInstance();
|
|
||||||
|
|
||||||
public async firstUpdated() {
|
|
||||||
await this.domtoolsPromise;
|
|
||||||
this.domtools.router.on(`/finishregistration`, async (routeArg) => {
|
|
||||||
const validationToken = routeArg.queryParams.validationtoken;
|
|
||||||
if (!validationToken) {
|
|
||||||
this.renderErrorMessage("Validation token not found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const emailResponse = await this.validateEmail(validationToken);
|
|
||||||
if (!emailResponse.email) {
|
|
||||||
this.renderErrorMessage("Invalid validation token.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.renderRegistrationForm(emailResponse.email);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateEmail(token: string) {
|
|
||||||
return await this.idpState.idpClient.requests.afterRegistrationEmailClicked.fire({
|
|
||||||
token
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderRegistrationForm(email: string) {
|
|
||||||
const template = plugins.html`
|
|
||||||
<dees-form @formData="${async (event) => await this.handleFormSubmission(event, email)}">
|
|
||||||
<dees-input-text key="First Name" label="First Name" required></dees-input-text>
|
|
||||||
<dees-input-text key="Last Name" label="Last Name" required></dees-input-text>
|
|
||||||
<dees-form-submit>Next</dees-form-submit>
|
|
||||||
</dees-form>
|
|
||||||
`;
|
|
||||||
this.render(template, this.shadowRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFormSubmission(event: FormDataEvent, email: string) {
|
|
||||||
const formData = (event.target as any).getFormData();
|
|
||||||
await this.idpState.idpClient.requests.setData.fire({
|
|
||||||
token: this.storedData.validationTokenUrlParam,
|
|
||||||
userData: {
|
|
||||||
email,
|
|
||||||
first_name: formData.FirstName,
|
|
||||||
last_name: formData.LastName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Proceed to the next steps as per the registration flow
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderErrorMessage(message: string) {
|
|
||||||
const template = plugins.html`<div>Error: ${message}</div>`;
|
|
||||||
this.render(template, this.shadowRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### User Management
|
The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_URL` to override the target server.
|
||||||
|
|
||||||
Managing user data including roles, organizations, and billing plans is essential in any identity provider software.
|
## Shared Interfaces
|
||||||
|
|
||||||
#### Getting User Data
|
The sibling `@idp.global/interfaces` package exports the type contracts shared across the stack:
|
||||||
|
|
||||||
```typescript
|
- `data/*` for users, orgs, roles, JWTs, sessions, devices, billing plans, apps, passport records, alerts, and OIDC payloads.
|
||||||
import * as plugins from './plugins.js';
|
- `request/*` for auth, registration, user, org, invitation, app, admin, billing, JWT, passport, alert, and OIDC request contracts.
|
||||||
|
- `tags/*` for shared tag exports.
|
||||||
|
|
||||||
const fetchUserData = async (jwt: string) => {
|
## Frontend
|
||||||
const user = await plugins.typedrequest.TypedRequest<plugins.lointReception.request.IReq_GetUserData>(
|
|
||||||
`/getUserData`, 'POST').fire({jwt});
|
|
||||||
console.log(user);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUserData('<JWT_TOKEN_HERE>');
|
`ts_web/` is the web application bundle. It contains:
|
||||||
```
|
|
||||||
|
|
||||||
#### Creating an Organization
|
- Login and registration prompts.
|
||||||
|
- A registration stepper.
|
||||||
|
- Account navigation and account views.
|
||||||
|
- Organization creation and bulk invite modals.
|
||||||
|
- Billing and Paddle setup views.
|
||||||
|
- A global admin view.
|
||||||
|
|
||||||
```typescript
|
## Package Scripts
|
||||||
import { IdpState } from './idp.state.js';
|
|
||||||
|
|
||||||
export class OrganizationManager {
|
| Command | Purpose |
|
||||||
private idpState = IdpState.getSingletonInstance();
|
| --- | --- |
|
||||||
|
| `pnpm build` | Build TypeScript output and frontend bundle |
|
||||||
|
| `pnpm watch` | Run backend watch mode and frontend bundle watch |
|
||||||
|
| `pnpm test` | Build and run the test suite |
|
||||||
|
|
||||||
public async createOrganization(name: string, slug: string, jwt: string) {
|
## Repository Notes
|
||||||
const response = await this.idpState.idpClient.requests.createOrganization.fire({
|
|
||||||
jwt: jwt,
|
|
||||||
organizationName: name,
|
|
||||||
organizationSlug: slug,
|
|
||||||
action: 'manifest',
|
|
||||||
});
|
|
||||||
if (response.resultingOrganization) {
|
|
||||||
console.log(`Organization ${name} created successfully.`);
|
|
||||||
} else {
|
|
||||||
console.log(`Organization creation failed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
- Package manager: `pnpm`
|
||||||
const organizationManager = new OrganizationManager();
|
- Main backend entrypoint: `ts/index.ts`
|
||||||
organizationManager.createOrganization('Dev Org', 'dev-org', '<JWT_TOKEN_HERE>');
|
- Frontend entrypoint: `ts_web/index.ts`
|
||||||
```
|
- Browser SDK entrypoint: `ts_idpclient/index.ts`
|
||||||
|
- CLI entrypoint: `ts_idpcli/index.ts`
|
||||||
### Managing JWTs
|
|
||||||
|
|
||||||
The `@idp.global/idp.global` package involves managing JSON Web Tokens (JWTs) for session handling and security.
|
|
||||||
|
|
||||||
#### Refreshing JWTs
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IdpClient } from './idp.client.js';
|
|
||||||
|
|
||||||
export const refreshJwt = async (client: IdpClient) => {
|
|
||||||
const currentJwt = await client.getJwt();
|
|
||||||
if (!currentJwt) return null;
|
|
||||||
const response = await client.requests.refreshJwt.fire({
|
|
||||||
refreshToken: currentJwt.data.refreshToken
|
|
||||||
});
|
|
||||||
if (response.jwt) {
|
|
||||||
await client.storeJwt(response.jwt);
|
|
||||||
console.log("JWT refreshed and stored.");
|
|
||||||
return response.jwt;
|
|
||||||
} else {
|
|
||||||
console.log("JWT refresh failed.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const idpClient = new IdpClient('https://reception.lossless.one/typedrequest');
|
|
||||||
refreshJwt(idpClient);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Handling Authentication Tokens
|
|
||||||
|
|
||||||
Handling tokens (JWTs, refresh tokens, transfer tokens) securely is crucial for maintaining session integrity.
|
|
||||||
|
|
||||||
#### Exchanging Refresh Token for Transfer Token
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IdpClient } from './idp.client.js';
|
|
||||||
|
|
||||||
const getTransferToken = async (client: IdpClient) => {
|
|
||||||
const refreshToken = await client.getJwt().data.refreshToken;
|
|
||||||
const response = await client.requests.obtainOneTimeToken.fire({
|
|
||||||
refreshToken
|
|
||||||
});
|
|
||||||
if(response.transferToken) {
|
|
||||||
console.log("Obtained Transfer Token: ", response.transferToken);
|
|
||||||
return response.transferToken;
|
|
||||||
} else {
|
|
||||||
console.log("Failed to obtain Transfer Token.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const idpClient = new IdpClient('https://reception.lossless.one/typedrequest');
|
|
||||||
getTransferToken(idpClient);
|
|
||||||
```
|
|
||||||
|
|
||||||
This comprehensive guide should help you understand the detailed setup and usage of the `@idp.global/idp.global` module effectively.
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
This directory contains user stories for the idp.global Identity Provider platform, organized by persona.
|
This directory contains user stories for the idp.global Identity Provider platform, organized by persona.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
These stories are planning and product-discovery notes for the app repository. They are not API documentation and should be read alongside the current source in `ts/`, `ts_web/`, `ts_idpclient/`, `ts_idpcli/`, and the sibling `@idp.global/interfaces` package.
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -90,3 +98,24 @@ Stories derived from code TODOs reference these files:
|
|||||||
- `ts/reception/classes.loginsessionmanager.ts:229-238,256`
|
- `ts/reception/classes.loginsessionmanager.ts:229-238,256`
|
||||||
- `ts/reception/classes.billingplan.ts:16`
|
- `ts/reception/classes.billingplan.ts:16`
|
||||||
- `ts_idpclient/classes.idpclient.ts:30`
|
- `ts_idpclient/classes.idpclient.ts:30`
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbuseProtectionManager,
|
||||||
|
type IAbuseProtectionConfig,
|
||||||
|
} from '../ts/reception/classes.abuseprotectionmanager.js';
|
||||||
|
import { AbuseWindow } from '../ts/reception/classes.abusewindow.js';
|
||||||
|
|
||||||
|
const createTestAbuseProtectionManager = () => {
|
||||||
|
const manager = new AbuseProtectionManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const store = new Map<string, AbuseWindow>();
|
||||||
|
const originalSave = AbuseWindow.prototype.save;
|
||||||
|
const originalDelete = AbuseWindow.prototype.delete;
|
||||||
|
|
||||||
|
(AbuseWindow.prototype as AbuseWindow & { save: () => Promise<void> }).save = async function () {
|
||||||
|
store.set(this.id, this);
|
||||||
|
};
|
||||||
|
(AbuseWindow.prototype as AbuseWindow & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
store.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CAbuseWindow = {
|
||||||
|
getInstance: async (queryArg) => store.get(queryArg.id) ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
AbuseWindow.prototype.save = originalSave;
|
||||||
|
AbuseWindow.prototype.delete = originalDelete;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
store,
|
||||||
|
restore,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConfig: IAbuseProtectionConfig = {
|
||||||
|
maxAttempts: 2,
|
||||||
|
windowMillis: 1_000,
|
||||||
|
blockDurationMillis: 2_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('blocks after too many attempts within the active window', async () => {
|
||||||
|
const { manager, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
|
||||||
|
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('resets attempts after the block and window have elapsed', async () => {
|
||||||
|
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||||
|
|
||||||
|
const abuseWindow = Array.from(store.values())[0];
|
||||||
|
abuseWindow.data.blockedUntil = Date.now() - 10;
|
||||||
|
abuseWindow.data.windowStartedAt = Date.now() - testConfig.windowMillis - 10;
|
||||||
|
abuseWindow.data.validUntil = Date.now() + 1_000;
|
||||||
|
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
expect(abuseWindow.data.attemptCount).toEqual(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('clears stored attempts after a successful action', async () => {
|
||||||
|
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
expect(store.size).toEqual(1);
|
||||||
|
|
||||||
|
await manager.clearAttempts('passwordLogin', 'phil@example.com');
|
||||||
|
expect(store.size).toEqual(0);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { Alert } from '../ts/reception/classes.alert.js';
|
||||||
|
import { AlertManager } from '../ts/reception/classes.alertmanager.js';
|
||||||
|
import { AlertRule } from '../ts/reception/classes.alertrule.js';
|
||||||
|
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||||
|
import { Role } from '../ts/reception/classes.role.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
|
||||||
|
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||||
|
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||||
|
return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestAlertManager = () => {
|
||||||
|
const alerts = new Map<string, Alert>();
|
||||||
|
const alertRules = new Map<string, AlertRule>();
|
||||||
|
const users = new Map<string, User>();
|
||||||
|
const roles = new Map<string, Role>();
|
||||||
|
const passportDevices = new Map<string, PassportDevice>();
|
||||||
|
const deliveredHints: string[] = [];
|
||||||
|
|
||||||
|
const manager = new AlertManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
jwtManager: {
|
||||||
|
verifyJWTAndGetData: async (jwtArg: string) => ({
|
||||||
|
data: {
|
||||||
|
userId: jwtArg,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
userManager: {
|
||||||
|
CUser: {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async () => Array.from(users.values()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roleManager: {
|
||||||
|
CRole: {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(roles.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAllRolesForOrg: async (organizationIdArg: string) =>
|
||||||
|
Array.from(roles.values()).filter((roleArg) => roleArg.data.organizationId === organizationIdArg),
|
||||||
|
},
|
||||||
|
passportManager: {
|
||||||
|
authenticatePassportDeviceRequest: async (requestArg: { deviceId: string }) => {
|
||||||
|
return passportDevices.get(requestArg.deviceId)!;
|
||||||
|
},
|
||||||
|
getPassportDevicesForUser: async (userIdArg: string) =>
|
||||||
|
Array.from(passportDevices.values()).filter(
|
||||||
|
(deviceArg) => deviceArg.data.userId === userIdArg && deviceArg.data.status === 'active'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
passportPushManager: {
|
||||||
|
deliverAlertHint: async (_passportDeviceArg: PassportDevice, alertArg: Alert) => {
|
||||||
|
deliveredHints.push(alertArg.data.notification.hintId);
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await alertArg.save();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const originalAlertSave = Alert.prototype.save;
|
||||||
|
const originalAlertDelete = Alert.prototype.delete;
|
||||||
|
const originalAlertRuleSave = AlertRule.prototype.save;
|
||||||
|
const originalAlertRuleDelete = AlertRule.prototype.delete;
|
||||||
|
|
||||||
|
(Alert.prototype as Alert & { save: () => Promise<void> }).save = async function () {
|
||||||
|
alerts.set(this.id, this);
|
||||||
|
};
|
||||||
|
(Alert.prototype as Alert & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
alerts.delete(this.id);
|
||||||
|
};
|
||||||
|
(AlertRule.prototype as AlertRule & { save: () => Promise<void> }).save = async function () {
|
||||||
|
alertRules.set(this.id, this);
|
||||||
|
};
|
||||||
|
(AlertRule.prototype as AlertRule & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
alertRules.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CAlert = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(alerts.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(alerts.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(manager as any).CAlertRule = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(alertRules.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async () => Array.from(alertRules.values()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
alerts,
|
||||||
|
alertRules,
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
passportDevices,
|
||||||
|
deliveredHints,
|
||||||
|
restore: () => {
|
||||||
|
Alert.prototype.save = originalAlertSave;
|
||||||
|
Alert.prototype.delete = originalAlertDelete;
|
||||||
|
AlertRule.prototype.save = originalAlertRuleSave;
|
||||||
|
AlertRule.prototype.delete = originalAlertRuleDelete;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = (
|
||||||
|
usersArg: Map<string, User>,
|
||||||
|
optionsArg: { id: string; email: string; isGlobalAdmin?: boolean }
|
||||||
|
) => {
|
||||||
|
const user = new User();
|
||||||
|
user.id = optionsArg.id;
|
||||||
|
user.data = {
|
||||||
|
name: optionsArg.email,
|
||||||
|
username: optionsArg.email,
|
||||||
|
email: optionsArg.email,
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: [],
|
||||||
|
isGlobalAdmin: optionsArg.isGlobalAdmin,
|
||||||
|
};
|
||||||
|
usersArg.set(user.id, user);
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPassportDevice = (
|
||||||
|
passportDevicesArg: Map<string, PassportDevice>,
|
||||||
|
optionsArg: { id: string; userId: string; label: string }
|
||||||
|
) => {
|
||||||
|
const device = new PassportDevice();
|
||||||
|
device.id = optionsArg.id;
|
||||||
|
device.data = {
|
||||||
|
userId: optionsArg.userId,
|
||||||
|
label: optionsArg.label,
|
||||||
|
platform: 'ios',
|
||||||
|
status: 'active',
|
||||||
|
publicKeyAlgorithm: 'p256',
|
||||||
|
publicKeyX963Base64: 'public-key',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
pushRegistration: {
|
||||||
|
provider: 'apns',
|
||||||
|
token: `${optionsArg.id}-token`,
|
||||||
|
topic: 'global.idp.swiftapp',
|
||||||
|
environment: 'development',
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
};
|
||||||
|
passportDevicesArg.set(device.id, device);
|
||||||
|
return device;
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('creates global admin access alerts with the built-in fallback rule', async () => {
|
||||||
|
const { manager, users, passportDevices, alerts, deliveredHints, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
addUser(users, { id: 'admin-1', email: 'admin-1@example.com', isGlobalAdmin: true });
|
||||||
|
addPassportDevice(passportDevices, { id: 'device-1', userId: 'admin-1', label: 'Admin Phone' });
|
||||||
|
|
||||||
|
const createdAlerts = await manager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Global admin console accessed',
|
||||||
|
body: 'A global admin accessed the console.',
|
||||||
|
actorUserId: 'admin-1',
|
||||||
|
relatedEntityType: 'global-admin-console',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createdAlerts).toHaveLength(1);
|
||||||
|
expect(alerts.size).toEqual(1);
|
||||||
|
expect(createdAlerts[0].data.notification.status).toEqual('sent');
|
||||||
|
expect(deliveredHints).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('routes organization-scoped alerts to org admins by rule', async () => {
|
||||||
|
const { manager, users, roles, passportDevices, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
addUser(users, { id: 'owner-1', email: 'owner@example.com' });
|
||||||
|
addUser(users, { id: 'viewer-1', email: 'viewer@example.com' });
|
||||||
|
addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' });
|
||||||
|
|
||||||
|
const ownerRole = new Role();
|
||||||
|
ownerRole.id = 'role-owner';
|
||||||
|
ownerRole.data = {
|
||||||
|
userId: 'owner-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: ['owner'],
|
||||||
|
};
|
||||||
|
roles.set(ownerRole.id, ownerRole);
|
||||||
|
|
||||||
|
const viewerRole = new Role();
|
||||||
|
viewerRole.id = 'role-viewer';
|
||||||
|
viewerRole.data = {
|
||||||
|
userId: 'viewer-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: ['viewer'],
|
||||||
|
};
|
||||||
|
roles.set(viewerRole.id, viewerRole);
|
||||||
|
|
||||||
|
const rule = new AlertRule();
|
||||||
|
rule.id = 'org-admin-rule';
|
||||||
|
rule.data = {
|
||||||
|
scope: 'organization',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
eventType: 'org_security_notice',
|
||||||
|
minimumSeverity: 'medium',
|
||||||
|
recipientMode: 'org_admins',
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: 'owner-1',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await rule.save();
|
||||||
|
|
||||||
|
const createdAlerts = await manager.createAlertsForEvent({
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'org_security_notice',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Organization security event',
|
||||||
|
body: 'A sensitive organization event occurred.',
|
||||||
|
actorUserId: 'viewer-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createdAlerts).toHaveLength(1);
|
||||||
|
expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('uses built-in organization fallback rules for app connection events', async () => {
|
||||||
|
const { manager, users, roles, passportDevices, deliveredHints, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
addUser(users, { id: 'owner-1', email: 'owner@example.com' });
|
||||||
|
addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' });
|
||||||
|
|
||||||
|
const ownerRole = new Role();
|
||||||
|
ownerRole.id = 'role-owner';
|
||||||
|
ownerRole.data = {
|
||||||
|
userId: 'owner-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: ['owner'],
|
||||||
|
};
|
||||||
|
roles.set(ownerRole.id, ownerRole);
|
||||||
|
|
||||||
|
const createdAlerts = await manager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
eventType: 'org_app_connected',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Organization app connected',
|
||||||
|
body: 'A new app was connected.',
|
||||||
|
actorUserId: 'owner-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
relatedEntityId: 'app-1',
|
||||||
|
relatedEntityType: 'global-app',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createdAlerts).toHaveLength(1);
|
||||||
|
expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1');
|
||||||
|
expect(deliveredHints).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('lists alerts, resolves hint lookups, and marks alerts seen', async () => {
|
||||||
|
const { manager, alerts, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alert = new Alert();
|
||||||
|
alert.id = 'alert-1';
|
||||||
|
alert.data = {
|
||||||
|
recipientUserId: 'user-1',
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Important alert',
|
||||||
|
body: 'Please inspect this alert.',
|
||||||
|
notification: {
|
||||||
|
hintId: 'hint-1',
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: 1,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
seenAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
|
||||||
|
const listedAlerts = await manager.listAlertsForUser('user-1');
|
||||||
|
expect(listedAlerts).toHaveLength(1);
|
||||||
|
|
||||||
|
const hintAlert = await manager.getAlertByHint('user-1', 'hint-1');
|
||||||
|
expect(hintAlert?.id).toEqual('alert-1');
|
||||||
|
|
||||||
|
const seenAlert = await manager.markAlertSeen('user-1', 'hint-1');
|
||||||
|
expect(seenAlert.data.notification.status).toEqual('seen');
|
||||||
|
expect(seenAlert.data.seenAt).toBeGreaterThan(0);
|
||||||
|
expect(alerts.get('alert-1')?.data.notification.status).toEqual('seen');
|
||||||
|
|
||||||
|
const dismissedAlert = await manager.dismissAlert('user-1', 'hint-1');
|
||||||
|
expect(dismissedAlert.data.dismissedAt).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const defaultList = await manager.listAlertsForUser('user-1');
|
||||||
|
expect(defaultList).toHaveLength(0);
|
||||||
|
|
||||||
|
const fullList = await manager.listAlertsForUser('user-1', true);
|
||||||
|
expect(fullList).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { AppConnection } from '../ts/reception/classes.appconnection.js';
|
||||||
|
import { AppConnectionManager } from '../ts/reception/classes.appconnectionmanager.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
|
||||||
|
const createTestAppConnectionManager = (optionsArg: {
|
||||||
|
allowedScopes?: string[];
|
||||||
|
grantedScopes?: string[];
|
||||||
|
} = {}) => {
|
||||||
|
const activities: Array<{ userId: string; action: string; description: string; metadata?: any }> = [];
|
||||||
|
const alerts: Array<{ eventType: string; organizationId?: string; relatedEntityId?: string }> = [];
|
||||||
|
|
||||||
|
const user = new User();
|
||||||
|
user.id = 'user-1';
|
||||||
|
user.data = {
|
||||||
|
name: 'Admin User',
|
||||||
|
username: 'admin@example.com',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: ['org-1'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
id: 'app-1',
|
||||||
|
type: 'global',
|
||||||
|
data: {
|
||||||
|
name: 'Finance App',
|
||||||
|
oauthCredentials: {
|
||||||
|
allowedScopes: optionsArg.allowedScopes || ['openid', 'roles', 'billing'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const organization = {
|
||||||
|
id: 'org-1',
|
||||||
|
data: {
|
||||||
|
name: 'Lossless GmbH',
|
||||||
|
slug: 'lossless',
|
||||||
|
},
|
||||||
|
checkIfUserIsAdmin: async () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = new AppConnection();
|
||||||
|
connection.id = 'connection-1';
|
||||||
|
connection.data = {
|
||||||
|
organizationId: organization.id,
|
||||||
|
appId: app.id,
|
||||||
|
appType: 'global',
|
||||||
|
status: 'active',
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
connectedByUserId: user.id,
|
||||||
|
grantedScopes: optionsArg.grantedScopes || ['openid', 'roles', 'billing'],
|
||||||
|
roleMappings: [],
|
||||||
|
};
|
||||||
|
connection.save = async () => undefined;
|
||||||
|
|
||||||
|
const reception = {
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
organizationmanager: {
|
||||||
|
COrganization: {
|
||||||
|
getInstance: async () => organization,
|
||||||
|
},
|
||||||
|
getAvailableRoleKeys: async () => ['owner', 'admin', 'viewer', 'finance'],
|
||||||
|
validateRoleKey: (roleKeyArg: string) => roleKeyArg.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
||||||
|
},
|
||||||
|
appManager: {
|
||||||
|
getAppById: async () => app,
|
||||||
|
},
|
||||||
|
activityLogManager: {
|
||||||
|
logActivity: async (userId: string, action: string, description: string, metadata?: any) => {
|
||||||
|
activities.push({ userId, action, description, metadata });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alertManager: {
|
||||||
|
createAlertsForEvent: async (options: { eventType: string; organizationId?: string; relatedEntityId?: string }) => {
|
||||||
|
alerts.push(options);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const manager = new AppConnectionManager(reception);
|
||||||
|
(manager as any).CAppConnection = {
|
||||||
|
getInstance: async () => connection,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
user,
|
||||||
|
connection,
|
||||||
|
activities,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('rejects app role mappings with unsupported app scopes', async () => {
|
||||||
|
const { manager, user, connection, activities } = createTestAppConnectionManager({
|
||||||
|
allowedScopes: ['openid', 'roles'],
|
||||||
|
grantedScopes: ['openid', 'roles', 'billing'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.updateAppRoleMappings({
|
||||||
|
user,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
appId: 'app-1',
|
||||||
|
roleMappings: [{
|
||||||
|
orgRoleKey: 'finance',
|
||||||
|
appRoles: [],
|
||||||
|
permissions: [],
|
||||||
|
scopes: ['billing'],
|
||||||
|
}],
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(connection.data.roleMappings).toEqual([]);
|
||||||
|
expect(activities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rejects app role mappings with ungranted connection scopes', async () => {
|
||||||
|
const { manager, user, connection, activities } = createTestAppConnectionManager({
|
||||||
|
allowedScopes: ['openid', 'roles', 'billing'],
|
||||||
|
grantedScopes: ['openid', 'roles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.updateAppRoleMappings({
|
||||||
|
user,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
appId: 'app-1',
|
||||||
|
roleMappings: [{
|
||||||
|
orgRoleKey: 'finance',
|
||||||
|
appRoles: [],
|
||||||
|
permissions: [],
|
||||||
|
scopes: ['billing'],
|
||||||
|
}],
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(connection.data.roleMappings).toEqual([]);
|
||||||
|
expect(activities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('updates app role mappings and writes audit activity', async () => {
|
||||||
|
const { manager, user, connection, activities, alerts } = createTestAppConnectionManager();
|
||||||
|
|
||||||
|
await manager.updateAppRoleMappings({
|
||||||
|
user,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
appId: 'app-1',
|
||||||
|
roleMappings: [{
|
||||||
|
orgRoleKey: ' Finance ',
|
||||||
|
appRoles: ['accountant', 'accountant', ''],
|
||||||
|
permissions: ['invoices:read'],
|
||||||
|
scopes: ['billing'],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connection.data.roleMappings).toEqual([{
|
||||||
|
orgRoleKey: 'finance',
|
||||||
|
appRoles: ['accountant'],
|
||||||
|
permissions: ['invoices:read'],
|
||||||
|
scopes: ['billing'],
|
||||||
|
}]);
|
||||||
|
expect(activities[0].action).toEqual('org_app_role_mappings_updated');
|
||||||
|
expect(activities[0].metadata.targetId).toEqual(connection.id);
|
||||||
|
expect(alerts[0].eventType).toEqual('org_app_role_mappings_updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { EmailActionToken } from '../ts/reception/classes.emailactiontoken.js';
|
||||||
|
import { LoginSession } from '../ts/reception/classes.loginsession.js';
|
||||||
|
import { RegistrationSession } from '../ts/reception/classes.registrationsession.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
const createTestLoginSession = () => {
|
||||||
|
const loginSession = new LoginSession();
|
||||||
|
loginSession.id = 'test-session';
|
||||||
|
loginSession.data.userId = 'test-user';
|
||||||
|
(loginSession as LoginSession & { save: () => Promise<void> }).save = async () => undefined;
|
||||||
|
return loginSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestEmailActionToken = () => {
|
||||||
|
const emailActionToken = new EmailActionToken();
|
||||||
|
emailActionToken.id = 'email-action-token';
|
||||||
|
emailActionToken.data.email = 'user@example.com';
|
||||||
|
emailActionToken.data.action = 'emailLogin';
|
||||||
|
emailActionToken.data.validUntil = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let deleted = false;
|
||||||
|
(emailActionToken as EmailActionToken & { delete: () => Promise<void> }).delete = async () => {
|
||||||
|
deleted = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailActionToken,
|
||||||
|
wasDeleted: () => deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRegistrationSession = () => {
|
||||||
|
const registrationSession = new RegistrationSession();
|
||||||
|
registrationSession.id = 'registration-session';
|
||||||
|
registrationSession.data.emailAddress = 'user@example.com';
|
||||||
|
registrationSession.data.validUntil = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let deleted = false;
|
||||||
|
(registrationSession as RegistrationSession & { save: () => Promise<void> }).save = async () => undefined;
|
||||||
|
(registrationSession as RegistrationSession & { delete: () => Promise<void> }).delete = async () => {
|
||||||
|
deleted = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
registrationSession,
|
||||||
|
wasDeleted: () => deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('hashes passwords with argon2 and verifies them', async () => {
|
||||||
|
const passwordHash = await User.hashPassword('correct horse battery staple');
|
||||||
|
|
||||||
|
expect(passwordHash.startsWith('$argon2')).toBeTrue();
|
||||||
|
expect(await User.verifyPassword('correct horse battery staple', passwordHash)).toBeTrue();
|
||||||
|
expect(await User.verifyPassword('wrong password', passwordHash)).toBeFalse();
|
||||||
|
expect(User.shouldUpgradePasswordHash(passwordHash)).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('accepts legacy sha256 hashes and marks them for upgrade', async () => {
|
||||||
|
const legacyHash = await plugins.smarthash.sha256FromString('legacy-password');
|
||||||
|
|
||||||
|
expect(User.isLegacyPasswordHash(legacyHash)).toBeTrue();
|
||||||
|
expect(await User.verifyPassword('legacy-password', legacyHash)).toBeTrue();
|
||||||
|
expect(await User.verifyPassword('different-password', legacyHash)).toBeFalse();
|
||||||
|
expect(User.shouldUpgradePasswordHash(legacyHash)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('rotates refresh tokens and detects reuse', async () => {
|
||||||
|
const loginSession = createTestLoginSession();
|
||||||
|
|
||||||
|
const firstRefreshToken = await loginSession.getRefreshToken();
|
||||||
|
const secondRefreshToken = await loginSession.getRefreshToken();
|
||||||
|
|
||||||
|
expect(firstRefreshToken.startsWith('refresh_')).toBeTrue();
|
||||||
|
expect(secondRefreshToken.startsWith('refresh_')).toBeTrue();
|
||||||
|
expect(firstRefreshToken).not.toEqual(secondRefreshToken);
|
||||||
|
expect(loginSession.data.refreshToken).toBeNullOrUndefined();
|
||||||
|
expect(loginSession.data.refreshTokenHash).toBeTruthy();
|
||||||
|
expect(await loginSession.validateRefreshToken(secondRefreshToken)).toEqual('current');
|
||||||
|
expect(await loginSession.validateRefreshToken(firstRefreshToken)).toEqual('reused');
|
||||||
|
|
||||||
|
await loginSession.invalidate();
|
||||||
|
expect(await loginSession.validateRefreshToken(secondRefreshToken)).toEqual('invalidated');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('persists transfer tokens as one-time hashes', async () => {
|
||||||
|
const loginSession = createTestLoginSession();
|
||||||
|
const transferToken = await loginSession.getTransferToken();
|
||||||
|
|
||||||
|
expect(transferToken.startsWith('transfer_')).toBeTrue();
|
||||||
|
expect(loginSession.data.transferTokenHash).toBeTruthy();
|
||||||
|
expect(await loginSession.validateTransferToken(transferToken)).toBeTrue();
|
||||||
|
expect(await loginSession.validateTransferToken(transferToken)).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('consumes email action tokens exactly once', async () => {
|
||||||
|
const { emailActionToken, wasDeleted } = createTestEmailActionToken();
|
||||||
|
const plainToken = EmailActionToken.createOpaqueToken('emailLogin');
|
||||||
|
emailActionToken.data.tokenHash = EmailActionToken.hashToken(plainToken);
|
||||||
|
|
||||||
|
expect(await emailActionToken.consume(plainToken)).toBeTrue();
|
||||||
|
expect(wasDeleted()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('invalidates expired email action tokens', async () => {
|
||||||
|
const { emailActionToken, wasDeleted } = createTestEmailActionToken();
|
||||||
|
emailActionToken.data.tokenHash = EmailActionToken.hashToken('expired-token');
|
||||||
|
emailActionToken.data.validUntil = Date.now() - 1;
|
||||||
|
|
||||||
|
expect(await emailActionToken.consume('expired-token')).toBeFalse();
|
||||||
|
expect(wasDeleted()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('persists registration token validation and sms verification state', async () => {
|
||||||
|
const { registrationSession } = createTestRegistrationSession();
|
||||||
|
const emailToken = 'registration-token';
|
||||||
|
registrationSession.data.hashedEmailToken = RegistrationSession.hashToken(emailToken);
|
||||||
|
|
||||||
|
expect(await registrationSession.validateEmailToken(emailToken)).toBeTrue();
|
||||||
|
expect(registrationSession.data.status).toEqual('emailValidated');
|
||||||
|
expect(registrationSession.data.collectedData.userData.email).toEqual('user@example.com');
|
||||||
|
|
||||||
|
registrationSession.data.smsCodeHash = RegistrationSession.hashToken('123456');
|
||||||
|
expect(await registrationSession.validateSmsCode('123456')).toBeTrue();
|
||||||
|
expect(registrationSession.data.status).toEqual('mobileVerified');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('removes expired registration sessions on token validation', async () => {
|
||||||
|
const { registrationSession, wasDeleted } = createTestRegistrationSession();
|
||||||
|
registrationSession.data.hashedEmailToken = RegistrationSession.hashToken('expired-registration');
|
||||||
|
registrationSession.data.validUntil = Date.now() - 1;
|
||||||
|
|
||||||
|
expect(await registrationSession.validateEmailToken('expired-registration')).toBeFalse();
|
||||||
|
expect(wasDeleted()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { App } from '../ts/reception/classes.app.js';
|
||||||
|
import { AppConnection } from '../ts/reception/classes.appconnection.js';
|
||||||
|
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcManager } from '../ts/reception/classes.oidcmanager.js';
|
||||||
|
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||||
|
import { Role } from '../ts/reception/classes.role.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
|
||||||
|
const createTestOidcManager = (receptionOverridesArg: Record<string, any> = {}) => {
|
||||||
|
const oidcManager = new OidcManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
options: { baseUrl: 'https://idp.example' },
|
||||||
|
...receptionOverridesArg,
|
||||||
|
} as any);
|
||||||
|
void oidcManager.stop();
|
||||||
|
return oidcManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('stores authorization codes as hashes and marks them used', async () => {
|
||||||
|
const authCode = new OidcAuthorizationCode();
|
||||||
|
authCode.id = 'oidc-auth-code';
|
||||||
|
authCode.data.codeHash = OidcAuthorizationCode.hashCode('plain-auth-code');
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(authCode as OidcAuthorizationCode & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(authCode.matchesCode('plain-auth-code')).toBeTrue();
|
||||||
|
expect(authCode.matchesCode('wrong-code')).toBeFalse();
|
||||||
|
|
||||||
|
await authCode.markUsed();
|
||||||
|
expect(authCode.data.used).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stores access tokens without plaintext persistence', async () => {
|
||||||
|
const accessToken = new OidcAccessToken();
|
||||||
|
accessToken.id = 'oidc-access-token';
|
||||||
|
accessToken.data.tokenHash = OidcAccessToken.hashToken('plain-access-token');
|
||||||
|
accessToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
expect(accessToken.matchesToken('plain-access-token')).toBeTrue();
|
||||||
|
expect(accessToken.matchesToken('different-access-token')).toBeFalse();
|
||||||
|
expect(accessToken.isExpired()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('revokes persisted refresh tokens', async () => {
|
||||||
|
const refreshToken = new OidcRefreshToken();
|
||||||
|
refreshToken.id = 'oidc-refresh-token';
|
||||||
|
refreshToken.data.tokenHash = OidcRefreshToken.hashToken('plain-refresh-token');
|
||||||
|
refreshToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(refreshToken as OidcRefreshToken & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(refreshToken.matchesToken('plain-refresh-token')).toBeTrue();
|
||||||
|
expect(refreshToken.data.revoked).toBeFalse();
|
||||||
|
|
||||||
|
await refreshToken.revoke();
|
||||||
|
expect(refreshToken.data.revoked).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('merges user consent scopes without duplicates', async () => {
|
||||||
|
const consent = new OidcUserConsent();
|
||||||
|
consent.id = 'oidc-consent';
|
||||||
|
consent.data.userId = 'user-1';
|
||||||
|
consent.data.clientId = 'client-1';
|
||||||
|
consent.data.scopes = ['openid'];
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(consent as OidcUserConsent & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
await consent.grantScopes(['openid', 'email', 'profile']);
|
||||||
|
|
||||||
|
expect(consent.data.scopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(consent.data.grantedAt).toBeGreaterThan(0);
|
||||||
|
expect(consent.data.updatedAt).toBeGreaterThan(0);
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('builds an OAuth redirect URL after successful authorization completion', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).generateAuthorizationCode = async () => 'generated-auth-code';
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(oidcManager as any).upsertUserConsent = async () => undefined;
|
||||||
|
|
||||||
|
const result = await oidcManager.completeAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
nonce: 'nonce-1',
|
||||||
|
consentApproved: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.code).toEqual('generated-auth-code');
|
||||||
|
expect(result.redirectUrl).toEqual(
|
||||||
|
'https://app.example/callback?code=generated-auth-code&state=xyz-state'
|
||||||
|
);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('prepares OAuth consent when scopes are not yet granted', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
prompt: undefined,
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toEqual('consent_required');
|
||||||
|
expect(result.requestedScopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(result.grantedScopes).toEqual(['openid']);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('prepares OAuth authorization as ready when consent already exists', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
prompt: undefined,
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toEqual('ready');
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('includes connected app role mappings in roles-scope claims', async () => {
|
||||||
|
const user = new User();
|
||||||
|
user.id = 'user-1';
|
||||||
|
user.data = {
|
||||||
|
name: 'Finance User',
|
||||||
|
username: 'finance-user',
|
||||||
|
email: 'finance@example.com',
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: ['org-1'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const role = new Role();
|
||||||
|
role.id = 'role-1';
|
||||||
|
role.data = {
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: ['finance'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
app.id = 'app-1';
|
||||||
|
app.type = 'global';
|
||||||
|
app.data = {
|
||||||
|
name: 'Accounting',
|
||||||
|
description: 'Accounting app',
|
||||||
|
logoUrl: '',
|
||||||
|
appUrl: 'https://accounting.example',
|
||||||
|
category: 'finance',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
createdByUserId: 'admin-1',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
clientSecretHash: 'secret-hash',
|
||||||
|
redirectUris: ['https://accounting.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'roles'],
|
||||||
|
grantTypes: ['authorization_code'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = new AppConnection();
|
||||||
|
connection.id = 'connection-1';
|
||||||
|
connection.data = {
|
||||||
|
organizationId: 'org-1',
|
||||||
|
appId: app.id,
|
||||||
|
appType: 'global',
|
||||||
|
status: 'active',
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
connectedByUserId: 'admin-1',
|
||||||
|
grantedScopes: ['openid', 'roles'],
|
||||||
|
roleMappings: [{
|
||||||
|
orgRoleKey: 'finance',
|
||||||
|
appRoles: ['accountant'],
|
||||||
|
permissions: ['invoices:read'],
|
||||||
|
scopes: ['billing'],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const oidcManager = createTestOidcManager({
|
||||||
|
userManager: {
|
||||||
|
CUser: {
|
||||||
|
getInstance: async () => user,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roleManager: {
|
||||||
|
getAllRolesForUser: async () => [role],
|
||||||
|
},
|
||||||
|
appManager: {
|
||||||
|
CApp: {
|
||||||
|
getInstances: async () => [app],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appConnectionManager: {
|
||||||
|
CAppConnection: {
|
||||||
|
getInstances: async () => [connection],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = await (oidcManager as any).getUserClaims(user.id, ['roles'], 'client-1');
|
||||||
|
|
||||||
|
expect(claims.app_roles).toEqual(['accountant']);
|
||||||
|
expect(claims.app_permissions).toEqual(['invoices:read']);
|
||||||
|
expect(claims.app_scopes).toEqual(['billing']);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { AppConnection } from '../ts/reception/classes.appconnection.js';
|
||||||
|
import { BillingPlan } from '../ts/reception/classes.billingplan.js';
|
||||||
|
import { Organization } from '../ts/reception/classes.organization.js';
|
||||||
|
import { OrganizationManager } from '../ts/reception/classes.organizationmanager.js';
|
||||||
|
import { Role } from '../ts/reception/classes.role.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
import { UserInvitation } from '../ts/reception/classes.userinvitation.js';
|
||||||
|
|
||||||
|
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||||
|
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||||
|
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||||||
|
const currentValue = getNestedValue(targetArg, keyArg);
|
||||||
|
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||||
|
return Object.entries(valueArg).every(([nestedKeyArg, nestedValueArg]) => currentValue?.[nestedKeyArg] === nestedValueArg);
|
||||||
|
}
|
||||||
|
return currentValue === valueArg;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachPersistence = <TDoc extends { id: string; save?: () => Promise<void>; delete?: () => Promise<void> }>(
|
||||||
|
docArg: TDoc,
|
||||||
|
mapArg: Map<string, TDoc>
|
||||||
|
) => {
|
||||||
|
docArg.save = async () => {
|
||||||
|
mapArg.set(docArg.id, docArg);
|
||||||
|
};
|
||||||
|
docArg.delete = async () => {
|
||||||
|
mapArg.delete(docArg.id);
|
||||||
|
};
|
||||||
|
mapArg.set(docArg.id, docArg);
|
||||||
|
return docArg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestOrganizationManager = () => {
|
||||||
|
const organizations = new Map<string, Organization>();
|
||||||
|
const roles = new Map<string, Role>();
|
||||||
|
const users = new Map<string, User>();
|
||||||
|
const appConnections = new Map<string, AppConnection>();
|
||||||
|
const invitations = new Map<string, UserInvitation>();
|
||||||
|
const billingPlans = new Map<string, BillingPlan>();
|
||||||
|
const activities: Array<{ userId: string; action: string; description: string }> = [];
|
||||||
|
const alerts: Array<{ eventType: string; organizationId?: string }> = [];
|
||||||
|
|
||||||
|
const getInstancesFromMap = async <TDoc>(mapArg: Map<string, TDoc>, queryArg: Record<string, any> = {}) => {
|
||||||
|
return Array.from(mapArg.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reception = {
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
roleManager: {
|
||||||
|
getRoleForUserAndOrg: async (userArg: User, organizationArg: Organization) => {
|
||||||
|
return Array.from(roles.values()).find((roleArg) => roleArg.data.userId === userArg.id && roleArg.data.organizationId === organizationArg.id) || null;
|
||||||
|
},
|
||||||
|
getAllRolesForOrg: async (organizationIdArg: string) => {
|
||||||
|
return Array.from(roles.values()).filter((roleArg) => roleArg.data.organizationId === organizationIdArg);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userManager: {
|
||||||
|
CUser: {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
activityLogManager: {
|
||||||
|
logActivity: async (userId: string, action: string, description: string) => {
|
||||||
|
activities.push({ userId, action, description });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alertManager: {
|
||||||
|
createAlertsForEvent: async (optionsArg: { eventType: string; organizationId?: string }) => {
|
||||||
|
alerts.push(optionsArg);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appConnectionManager: {
|
||||||
|
CAppConnection: {
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(appConnections, queryArg),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userInvitationManager: {
|
||||||
|
CUserInvitation: {
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(invitations, queryArg),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
billingPlanManager: {
|
||||||
|
CBillingPlan: {
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(billingPlans, queryArg),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const manager = new OrganizationManager(reception);
|
||||||
|
(manager as any).COrganization = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(organizations.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(organizations, queryArg),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
organizations,
|
||||||
|
roles,
|
||||||
|
users,
|
||||||
|
appConnections,
|
||||||
|
invitations,
|
||||||
|
billingPlans,
|
||||||
|
activities,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = (usersArg: Map<string, User>, idArg: string, emailArg: string, connectedOrgsArg: string[] = []) => {
|
||||||
|
const user = new User();
|
||||||
|
user.id = idArg;
|
||||||
|
user.data = {
|
||||||
|
name: emailArg,
|
||||||
|
username: emailArg,
|
||||||
|
email: emailArg,
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: connectedOrgsArg,
|
||||||
|
};
|
||||||
|
return attachPersistence(user, usersArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addOrganization = (organizationsArg: Map<string, Organization>) => {
|
||||||
|
const organization = new Organization();
|
||||||
|
organization.id = 'org-1';
|
||||||
|
organization.data = {
|
||||||
|
name: 'Lossless GmbH',
|
||||||
|
slug: 'lossless',
|
||||||
|
billingPlanId: 'billing-1',
|
||||||
|
roleIds: ['role-owner', 'role-member'],
|
||||||
|
};
|
||||||
|
return attachPersistence(organization, organizationsArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRole = (rolesArg: Map<string, Role>, idArg: string, userIdArg: string, rolesValueArg: string[]) => {
|
||||||
|
const role = new Role();
|
||||||
|
role.id = idArg;
|
||||||
|
role.data = {
|
||||||
|
userId: userIdArg,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: rolesValueArg,
|
||||||
|
};
|
||||||
|
return attachPersistence(role, rolesArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('updates organization settings only with audited confirmation', async () => {
|
||||||
|
const { manager, organizations, roles, users, activities, alerts } = createTestOrganizationManager();
|
||||||
|
const owner = addUser(users, 'owner-1', 'owner@example.com', ['org-1']);
|
||||||
|
addOrganization(organizations);
|
||||||
|
addRole(roles, 'role-owner', owner.id, ['owner']);
|
||||||
|
|
||||||
|
await expect(manager.updateOrganizationWithAudit({
|
||||||
|
user: owner,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
name: 'Lossless Updated',
|
||||||
|
slug: 'lossless-updated',
|
||||||
|
confirmationText: 'wrong',
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
const updatedOrganization = await manager.updateOrganizationWithAudit({
|
||||||
|
user: owner,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
name: 'Lossless Updated',
|
||||||
|
slug: 'lossless-updated',
|
||||||
|
confirmationText: 'lossless',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedOrganization.data.name).toEqual('Lossless Updated');
|
||||||
|
expect(updatedOrganization.data.slug).toEqual('lossless-updated');
|
||||||
|
expect(activities[0].action).toEqual('org_updated');
|
||||||
|
expect(alerts[0].eventType).toEqual('org_updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deletes organization dependencies only with audited owner confirmation', async () => {
|
||||||
|
const { manager, organizations, roles, users, appConnections, invitations, billingPlans, activities, alerts } = createTestOrganizationManager();
|
||||||
|
const owner = addUser(users, 'owner-1', 'owner@example.com', ['org-1']);
|
||||||
|
const member = addUser(users, 'member-1', 'member@example.com', ['org-1']);
|
||||||
|
addOrganization(organizations);
|
||||||
|
addRole(roles, 'role-owner', owner.id, ['owner']);
|
||||||
|
addRole(roles, 'role-member', member.id, ['viewer']);
|
||||||
|
|
||||||
|
const appConnection = new AppConnection();
|
||||||
|
appConnection.id = 'connection-1';
|
||||||
|
appConnection.data = {
|
||||||
|
organizationId: 'org-1',
|
||||||
|
appId: 'app-1',
|
||||||
|
appType: 'global',
|
||||||
|
status: 'active',
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
connectedByUserId: owner.id,
|
||||||
|
grantedScopes: ['openid'],
|
||||||
|
};
|
||||||
|
attachPersistence(appConnection, appConnections);
|
||||||
|
|
||||||
|
const invitation = new UserInvitation();
|
||||||
|
invitation.id = 'invitation-1';
|
||||||
|
invitation.data = {
|
||||||
|
email: 'invite@example.com',
|
||||||
|
token: 'token',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 1000,
|
||||||
|
organizationRefs: [{
|
||||||
|
organizationId: 'org-1',
|
||||||
|
invitedByUserId: owner.id,
|
||||||
|
invitedAt: Date.now(),
|
||||||
|
roles: ['viewer'],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
attachPersistence(invitation, invitations);
|
||||||
|
|
||||||
|
const billingPlan = new BillingPlan();
|
||||||
|
billingPlan.id = 'billing-1';
|
||||||
|
billingPlan.data.organizationId = 'org-1';
|
||||||
|
attachPersistence(billingPlan, billingPlans);
|
||||||
|
|
||||||
|
await expect(manager.deleteOrganizationWithAudit({
|
||||||
|
user: owner,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
confirmationText: 'delete wrong',
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
await manager.deleteOrganizationWithAudit({
|
||||||
|
user: owner,
|
||||||
|
organizationId: 'org-1',
|
||||||
|
confirmationText: 'delete lossless',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(organizations.size).toEqual(0);
|
||||||
|
expect(roles.size).toEqual(0);
|
||||||
|
expect(appConnections.size).toEqual(0);
|
||||||
|
expect(billingPlans.size).toEqual(0);
|
||||||
|
expect(invitation.data.status).toEqual('cancelled');
|
||||||
|
expect(owner.data.connectedOrgs).toEqual([]);
|
||||||
|
expect(member.data.connectedOrgs).toEqual([]);
|
||||||
|
expect(activities[0].action).toEqual('org_deleted');
|
||||||
|
expect(alerts[0].eventType).toEqual('org_deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('manages custom role definitions and cleans assignments and mappings on delete', async () => {
|
||||||
|
const { manager, organizations, roles, users, appConnections } = createTestOrganizationManager();
|
||||||
|
const owner = addUser(users, 'owner-1', 'owner@example.com', ['org-1']);
|
||||||
|
const member = addUser(users, 'member-1', 'member@example.com', ['org-1']);
|
||||||
|
const organization = addOrganization(organizations);
|
||||||
|
addRole(roles, 'role-owner', owner.id, ['owner']);
|
||||||
|
const memberRole = addRole(roles, 'role-member', member.id, ['viewer', 'finance']);
|
||||||
|
|
||||||
|
const roleDefinitions = await manager.upsertOrgRoleDefinition({
|
||||||
|
user: owner,
|
||||||
|
organizationId: organization.id,
|
||||||
|
roleDefinition: {
|
||||||
|
key: 'finance',
|
||||||
|
name: 'Finance',
|
||||||
|
description: 'Finance team access',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(roleDefinitions).toHaveLength(1);
|
||||||
|
expect(roleDefinitions[0].key).toEqual('finance');
|
||||||
|
expect(await manager.assertRoleKeysAreValid(organization.id, ['finance'])).toEqual(['finance']);
|
||||||
|
|
||||||
|
const appConnection = new AppConnection();
|
||||||
|
appConnection.id = 'connection-1';
|
||||||
|
appConnection.data = {
|
||||||
|
organizationId: organization.id,
|
||||||
|
appId: 'app-1',
|
||||||
|
appType: 'global',
|
||||||
|
status: 'active',
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
connectedByUserId: owner.id,
|
||||||
|
grantedScopes: ['openid'],
|
||||||
|
roleMappings: [{
|
||||||
|
orgRoleKey: 'finance',
|
||||||
|
appRoles: ['accountant'],
|
||||||
|
permissions: ['invoices:read'],
|
||||||
|
scopes: ['billing'],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
attachPersistence(appConnection, appConnections);
|
||||||
|
|
||||||
|
await manager.deleteOrgRoleDefinition({
|
||||||
|
user: owner,
|
||||||
|
organizationId: organization.id,
|
||||||
|
roleKey: 'finance',
|
||||||
|
confirmationText: 'delete role finance',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(organization.data.roleDefinitions).toEqual([]);
|
||||||
|
expect(memberRole.data.roles).toEqual(['viewer']);
|
||||||
|
expect(appConnection.data.roleMappings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import { PassportChallenge } from '../ts/reception/classes.passportchallenge.js';
|
||||||
|
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||||
|
import { PassportManager } from '../ts/reception/classes.passportmanager.js';
|
||||||
|
import { PassportNonce } from '../ts/reception/classes.passportnonce.js';
|
||||||
|
|
||||||
|
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||||
|
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||||
|
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||||||
|
return getNestedValue(targetArg, keyArg) === valueArg;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestPassportManager = () => {
|
||||||
|
const passportDevices = new Map<string, PassportDevice>();
|
||||||
|
const passportChallenges = new Map<string, PassportChallenge>();
|
||||||
|
const passportNonces = new Map<string, PassportNonce>();
|
||||||
|
const activityLogCalls: Array<{
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
}> = [];
|
||||||
|
const deliveredHintIds: string[] = [];
|
||||||
|
|
||||||
|
const manager = new PassportManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
options: { baseUrl: 'https://idp.global' },
|
||||||
|
jwtManager: { verifyJWTAndGetData: async () => null },
|
||||||
|
activityLogManager: {
|
||||||
|
logActivity: async (userIdArg: string, actionArg: string, descriptionArg: string) => {
|
||||||
|
activityLogCalls.push({
|
||||||
|
userId: userIdArg,
|
||||||
|
action: actionArg,
|
||||||
|
description: descriptionArg,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passportPushManager: {
|
||||||
|
deliverChallengeHint: async (_passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) => {
|
||||||
|
deliveredHintIds.push(passportChallengeArg.data.notification!.hintId);
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification!,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: passportChallengeArg.data.notification!.attemptCount + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const originalPassportDeviceSave = PassportDevice.prototype.save;
|
||||||
|
const originalPassportDeviceDelete = PassportDevice.prototype.delete;
|
||||||
|
const originalPassportChallengeSave = PassportChallenge.prototype.save;
|
||||||
|
const originalPassportChallengeDelete = PassportChallenge.prototype.delete;
|
||||||
|
const originalPassportNonceSave = PassportNonce.prototype.save;
|
||||||
|
const originalPassportNonceDelete = PassportNonce.prototype.delete;
|
||||||
|
|
||||||
|
(PassportDevice.prototype as PassportDevice & { save: () => Promise<void> }).save = async function () {
|
||||||
|
passportDevices.set(this.id, this);
|
||||||
|
};
|
||||||
|
(PassportDevice.prototype as PassportDevice & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
passportDevices.delete(this.id);
|
||||||
|
};
|
||||||
|
(PassportChallenge.prototype as PassportChallenge & { save: () => Promise<void> }).save = async function () {
|
||||||
|
passportChallenges.set(this.id, this);
|
||||||
|
};
|
||||||
|
(PassportChallenge.prototype as PassportChallenge & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
passportChallenges.delete(this.id);
|
||||||
|
};
|
||||||
|
(PassportNonce.prototype as PassportNonce & { save: () => Promise<void> }).save = async function () {
|
||||||
|
passportNonces.set(this.id, this);
|
||||||
|
};
|
||||||
|
(PassportNonce.prototype as PassportNonce & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
passportNonces.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CPassportDevice = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportDevices.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportDevices.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CPassportChallenge = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return (
|
||||||
|
Array.from(passportChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CPassportNonce = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportNonces.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportNonces.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
passportDevices,
|
||||||
|
passportChallenges,
|
||||||
|
passportNonces,
|
||||||
|
activityLogCalls,
|
||||||
|
deliveredHintIds,
|
||||||
|
restore: () => {
|
||||||
|
PassportDevice.prototype.save = originalPassportDeviceSave;
|
||||||
|
PassportDevice.prototype.delete = originalPassportDeviceDelete;
|
||||||
|
PassportChallenge.prototype.save = originalPassportChallengeSave;
|
||||||
|
PassportChallenge.prototype.delete = originalPassportChallengeDelete;
|
||||||
|
PassportNonce.prototype.save = originalPassportNonceSave;
|
||||||
|
PassportNonce.prototype.delete = originalPassportNonceDelete;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRawPassportSigner = async () => {
|
||||||
|
const subtle = plugins.crypto.webcrypto.subtle;
|
||||||
|
const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||||
|
'sign',
|
||||||
|
'verify',
|
||||||
|
]);
|
||||||
|
const publicKeyRaw = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyX963Base64: publicKeyRaw,
|
||||||
|
sign: async (payloadArg: string) => {
|
||||||
|
const signature = await subtle.sign(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
keyPair.privateKey,
|
||||||
|
Buffer.from(payloadArg, 'utf8')
|
||||||
|
);
|
||||||
|
return Buffer.from(signature).toString('base64');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDerPassportSigner = () => {
|
||||||
|
const keyPair = plugins.crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||||
|
const publicJwk = keyPair.publicKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||||
|
const publicKeyX963Base64 = Buffer.concat([
|
||||||
|
Buffer.from([4]),
|
||||||
|
Buffer.from(publicJwk.x!, 'base64url'),
|
||||||
|
Buffer.from(publicJwk.y!, 'base64url'),
|
||||||
|
]).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyX963Base64,
|
||||||
|
sign: (payloadArg: string) => {
|
||||||
|
return plugins.crypto.sign('sha256', Buffer.from(payloadArg, 'utf8'), keyPair.privateKey).toString('base64');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSignedDeviceRequest = async (
|
||||||
|
managerArg: PassportManager,
|
||||||
|
signerArg: { sign: (payloadArg: string) => Promise<string> | string },
|
||||||
|
requestArg: {
|
||||||
|
deviceId: string;
|
||||||
|
action: string;
|
||||||
|
signedFields?: string[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const baseRequest = {
|
||||||
|
deviceId: requestArg.deviceId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
nonce: plugins.crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
const payload = (managerArg as any).buildDeviceRequestSigningPayload(
|
||||||
|
baseRequest,
|
||||||
|
requestArg.action,
|
||||||
|
requestArg.signedFields || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseRequest,
|
||||||
|
signatureBase64: await signerArg.sign(payload),
|
||||||
|
signatureFormat: 'raw' as const,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('enrolls a passport device from a pairing challenge', async () => {
|
||||||
|
const { manager, passportDevices, passportChallenges, activityLogCalls, restore } =
|
||||||
|
createTestPassportManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-1', {
|
||||||
|
deviceLabel: 'Phil iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = await createRawPassportSigner();
|
||||||
|
const signatureBase64 = await signer.sign(enrollment.signingPayload);
|
||||||
|
|
||||||
|
const passportDevice = await manager.completeEnrollment({
|
||||||
|
pairingToken: enrollment.pairingToken,
|
||||||
|
deviceLabel: 'Phil iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||||
|
signatureBase64,
|
||||||
|
signatureFormat: 'raw',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(passportDevice.data.userId).toEqual('user-1');
|
||||||
|
expect(passportDevice.data.label).toEqual('Phil iPhone');
|
||||||
|
expect(passportDevices.size).toEqual(1);
|
||||||
|
expect(passportChallenges.size).toEqual(1);
|
||||||
|
expect(Array.from(passportChallenges.values())[0].data.status).toEqual('approved');
|
||||||
|
expect(activityLogCalls[0].action).toEqual('passport_device_enrolled');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('creates and approves a passport challenge with DER signatures and evidence', async () => {
|
||||||
|
const { manager, activityLogCalls, deliveredHintIds, restore } = createTestPassportManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-2', {
|
||||||
|
deviceLabel: 'Office iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = createDerPassportSigner();
|
||||||
|
const passportDevice = await manager.completeEnrollment({
|
||||||
|
pairingToken: enrollment.pairingToken,
|
||||||
|
deviceLabel: 'Office iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||||
|
signatureBase64: signer.sign(enrollment.signingPayload),
|
||||||
|
signatureFormat: 'der',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const challengeResult = await manager.createPassportChallengeForUser('user-2', {
|
||||||
|
type: 'physical_access',
|
||||||
|
preferredDeviceId: passportDevice.id,
|
||||||
|
audience: 'hq-door-a',
|
||||||
|
notificationTitle: 'Office entry request',
|
||||||
|
requireLocation: true,
|
||||||
|
requireNfc: true,
|
||||||
|
locationPolicy: {
|
||||||
|
mode: 'geofence',
|
||||||
|
label: 'HQ Berlin',
|
||||||
|
latitude: 53.0793,
|
||||||
|
longitude: 8.8017,
|
||||||
|
radiusMeters: 80,
|
||||||
|
maxAccuracyMeters: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deliveredHintIds).toHaveLength(1);
|
||||||
|
expect(challengeResult.challenge.data.notification?.status).toEqual('sent');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.approvePassportChallenge({
|
||||||
|
challengeId: challengeResult.challenge.id,
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||||
|
signatureFormat: 'der',
|
||||||
|
location: {
|
||||||
|
latitude: 53.5,
|
||||||
|
longitude: 8.1,
|
||||||
|
accuracyMeters: 12,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
},
|
||||||
|
nfc: {
|
||||||
|
readerId: 'door-reader-a',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const approvedChallenge = await manager.approvePassportChallenge({
|
||||||
|
challengeId: challengeResult.challenge.id,
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||||
|
signatureFormat: 'der',
|
||||||
|
location: {
|
||||||
|
latitude: 53.0793,
|
||||||
|
longitude: 8.8017,
|
||||||
|
accuracyMeters: 12,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
},
|
||||||
|
nfc: {
|
||||||
|
readerId: 'door-reader-a',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(approvedChallenge.data.status).toEqual('approved');
|
||||||
|
expect(approvedChallenge.data.evidence?.signatureFormat).toEqual('der');
|
||||||
|
expect(approvedChallenge.data.evidence?.location?.accuracyMeters).toEqual(12);
|
||||||
|
expect(approvedChallenge.data.evidence?.locationEvaluation?.matched).toBeTrue();
|
||||||
|
expect(approvedChallenge.data.evidence?.nfc?.readerId).toEqual('door-reader-a');
|
||||||
|
expect(activityLogCalls.at(-1)?.action).toEqual('passport_challenge_approved');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('registers push tokens and loads pending challenges through signed device requests', async () => {
|
||||||
|
const { manager, passportNonces, restore } = createTestPassportManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-3', {
|
||||||
|
deviceLabel: 'Work iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: false,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = await createRawPassportSigner();
|
||||||
|
const passportDevice = await manager.completeEnrollment({
|
||||||
|
pairingToken: enrollment.pairingToken,
|
||||||
|
deviceLabel: 'Work iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||||
|
signatureBase64: await signer.sign(enrollment.signingPayload),
|
||||||
|
signatureFormat: 'raw',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: false,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'registerPassportPushToken',
|
||||||
|
signedFields: [
|
||||||
|
'provider=apns',
|
||||||
|
'token=device-token-1',
|
||||||
|
'topic=global.idp.swiftapp',
|
||||||
|
'environment=development',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const registeredPassportDevice = await (manager as any).authenticatePassportDeviceRequest(
|
||||||
|
{
|
||||||
|
...pushRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'registerPassportPushToken',
|
||||||
|
signedFields: [
|
||||||
|
'provider=apns',
|
||||||
|
'token=device-token-1',
|
||||||
|
'topic=global.idp.swiftapp',
|
||||||
|
'environment=development',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
registeredPassportDevice.data.pushRegistration = {
|
||||||
|
provider: 'apns',
|
||||||
|
token: 'device-token-1',
|
||||||
|
topic: 'global.idp.swiftapp',
|
||||||
|
environment: 'development',
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
};
|
||||||
|
await registeredPassportDevice.save();
|
||||||
|
|
||||||
|
const challengeResult = await manager.createPassportChallengeForUser('user-3', {
|
||||||
|
type: 'authentication',
|
||||||
|
preferredDeviceId: passportDevice.id,
|
||||||
|
audience: 'office-saas',
|
||||||
|
notificationTitle: 'Office sign-in verification',
|
||||||
|
});
|
||||||
|
|
||||||
|
const listRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'listPendingPassportChallenges',
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticatedDevice = await (manager as any).authenticatePassportDeviceRequest(listRequest, {
|
||||||
|
action: 'listPendingPassportChallenges',
|
||||||
|
});
|
||||||
|
const pendingChallenges = await manager.listPendingChallengesForDevice(authenticatedDevice.id);
|
||||||
|
expect(pendingChallenges).toHaveLength(1);
|
||||||
|
expect(pendingChallenges[0].id).toEqual(challengeResult.challenge.id);
|
||||||
|
|
||||||
|
const hintId = challengeResult.challenge.data.notification!.hintId;
|
||||||
|
const getRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'getPassportChallengeByHint',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
});
|
||||||
|
const hintChallenge = await manager.getPassportChallengeByHint(
|
||||||
|
(
|
||||||
|
await (manager as any).authenticatePassportDeviceRequest(getRequest, {
|
||||||
|
action: 'getPassportChallengeByHint',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
})
|
||||||
|
).id,
|
||||||
|
hintId
|
||||||
|
);
|
||||||
|
expect(hintChallenge?.id).toEqual(challengeResult.challenge.id);
|
||||||
|
|
||||||
|
const seenRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'markPassportChallengeSeen',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
});
|
||||||
|
await (manager as any).authenticatePassportDeviceRequest(seenRequest, {
|
||||||
|
action: 'markPassportChallengeSeen',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
});
|
||||||
|
const seenChallenge = await manager.markPassportChallengeSeen(passportDevice.id, hintId);
|
||||||
|
expect(seenChallenge.data.notification?.status).toEqual('seen');
|
||||||
|
expect(passportNonces.size).toEqual(4);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.11.0',
|
version: '1.21.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-2
@@ -2,8 +2,44 @@ import * as plugins from './plugins.js';
|
|||||||
import * as paths from './paths.js';
|
import * as paths from './paths.js';
|
||||||
import { Reception } from './reception/classes.reception.js';
|
import { Reception } from './reception/classes.reception.js';
|
||||||
|
|
||||||
|
const manifestIconPng = Uint8Array.from(Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAABzklEQVR4nO3OMQ0AMAzAsEIc3aHrOOyJIuXw75lzN/mGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB6L2AK5GkZ1Ln/HeAAAAAElFTkSuQmCC',
|
||||||
|
'base64'
|
||||||
|
));
|
||||||
|
const createManifestResponse = () => new Response(JSON.stringify({
|
||||||
|
name: 'idp.global',
|
||||||
|
short_name: 'idp.global',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
background_color: '#000000',
|
||||||
|
theme_color: '#000000',
|
||||||
|
icons: [],
|
||||||
|
related_applications: [],
|
||||||
|
scope: '/',
|
||||||
|
lang: 'en',
|
||||||
|
display_override: ['window-controls-overlay'],
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createManifestIconResponse = () => new Response(manifestIconPng.slice(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Content-Length': String(manifestIconPng.byteLength),
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const runCli = async () => {
|
export const runCli = async () => {
|
||||||
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
||||||
|
|
||||||
|
// Create reception first so we can reference it in routes
|
||||||
|
let reception: Reception;
|
||||||
|
|
||||||
const websiteServer = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
const websiteServer = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||||
feedMetadata: null,
|
feedMetadata: null,
|
||||||
domain: 'idp.global',
|
domain: 'idp.global',
|
||||||
@@ -14,7 +50,7 @@ export const runCli = async () => {
|
|||||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.paddle.com", "https://public.profitwell.com"],
|
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.paddle.com", "https://public.profitwell.com"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.paddle.com", "https://assetbroker.lossless.one"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.paddle.com", "https://assetbroker.lossless.one"],
|
||||||
imgSrc: ["'self'", "data:", "https:"],
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
fontSrc: ["'self'", "data:"],
|
fontSrc: ["'self'", "data:", "https://assetbroker.lossless.one"],
|
||||||
connectSrc: ["'self'", "https://*.paddle.com", "https://buy.paddle.com", "https://checkout.paddle.com", "https://checkout-service.paddle.com", "https://cdn.paddle.com", "https://*.sentry.io", "https://public.profitwell.com", "wss:"],
|
connectSrc: ["'self'", "https://*.paddle.com", "https://buy.paddle.com", "https://checkout.paddle.com", "https://checkout-service.paddle.com", "https://cdn.paddle.com", "https://*.sentry.io", "https://public.profitwell.com", "wss:"],
|
||||||
frameSrc: ["https://buy.paddle.com", "https://checkout.paddle.com", "https://*.paddle.com"],
|
frameSrc: ["https://buy.paddle.com", "https://checkout.paddle.com", "https://*.paddle.com"],
|
||||||
},
|
},
|
||||||
@@ -22,11 +58,61 @@ export const runCli = async () => {
|
|||||||
addCustomRoutes: async (typedserver) => {
|
addCustomRoutes: async (typedserver) => {
|
||||||
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
|
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
|
||||||
typedserver.options.spaFallback = true;
|
typedserver.options.spaFallback = true;
|
||||||
|
|
||||||
|
// OIDC Discovery endpoint
|
||||||
|
typedserver.addRoute('/.well-known/openid-configuration', 'GET', async (ctx) => {
|
||||||
|
return new Response(JSON.stringify(reception.oidcManager.getDiscoveryDocument()), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWKS endpoint
|
||||||
|
typedserver.addRoute('/.well-known/jwks.json', 'GET', async (ctx) => {
|
||||||
|
return new Response(JSON.stringify(reception.oidcManager.getJwks()), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
typedserver.addRoute('/manifest.json', 'GET', async () => createManifestResponse());
|
||||||
|
typedserver.addRoute('/manifest.json', 'HEAD', async () => createManifestResponse());
|
||||||
|
typedserver.addRoute('/idp-manifest.json', 'GET', async () => createManifestResponse());
|
||||||
|
typedserver.addRoute('/idp-manifest.json', 'HEAD', async () => createManifestResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/favicon.png', 'GET', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/icon-144x144.png', 'GET', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/icon-512x512.png', 'GET', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/icon-large.png', 'GET', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/favicon.png', 'HEAD', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/icon-144x144.png', 'HEAD', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/icon-512x512.png', 'HEAD', async () => createManifestIconResponse());
|
||||||
|
typedserver.addRoute('/assetbroker/manifest/icon-large.png', 'HEAD', async () => createManifestIconResponse());
|
||||||
|
|
||||||
|
// OAuth Authorization endpoint
|
||||||
|
typedserver.addRoute('/oauth/authorize', 'GET', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleAuthorize(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth Token endpoint
|
||||||
|
typedserver.addRoute('/oauth/token', 'POST', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleToken(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth UserInfo endpoint (GET and POST)
|
||||||
|
typedserver.addRoute('/oauth/userinfo', 'GET', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleUserInfo(ctx);
|
||||||
|
});
|
||||||
|
typedserver.addRoute('/oauth/userinfo', 'POST', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleUserInfo(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth Revocation endpoint
|
||||||
|
typedserver.addRoute('/oauth/revoke', 'POST', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleRevoke(ctx);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// lets add the reception routes
|
// lets add the reception routes
|
||||||
const reception = new Reception({
|
reception = new Reception({
|
||||||
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
|
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
|
||||||
mongoDescriptor: {
|
mongoDescriptor: {
|
||||||
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
||||||
|
|||||||
+5
-2
@@ -1,9 +1,10 @@
|
|||||||
// Native scope
|
// Native scope
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
export { path };
|
export { crypto, path };
|
||||||
|
|
||||||
// Project scope
|
// Project scope
|
||||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
import * as idpInterfaces from '@idp.global/interfaces';
|
||||||
export { idpInterfaces };
|
export { idpInterfaces };
|
||||||
|
|
||||||
// @api.global scope
|
// @api.global scope
|
||||||
@@ -32,8 +33,10 @@ import * as smartpromise from '@push.rocks/smartpromise';
|
|||||||
import * as smarttime from '@push.rocks/smarttime';
|
import * as smarttime from '@push.rocks/smarttime';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
argon2,
|
||||||
lik,
|
lik,
|
||||||
projectinfo,
|
projectinfo,
|
||||||
qenv,
|
qenv,
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# `ts/` Backend Module
|
||||||
|
|
||||||
|
The `ts/` folder contains the server runtime for `idp.global`: startup, website server wiring, typed routes, OIDC endpoints, passport approval APIs, alerting, and the core `Reception` managers.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## What Lives Here
|
||||||
|
|
||||||
|
- `index.ts` boots the service, loads env vars, starts the website server, and mounts OIDC endpoints.
|
||||||
|
- `reception/classes.reception.ts` creates the service container and initializes all managers.
|
||||||
|
- `reception/` contains the domain logic for users, sessions, orgs, roles, invites, apps, billing, passport devices, alerts, abuse protection, and OIDC.
|
||||||
|
- `plugins.ts` centralizes external imports used by the backend.
|
||||||
|
|
||||||
|
## Startup Behavior
|
||||||
|
|
||||||
|
The backend startup in `ts/index.ts` does four main things:
|
||||||
|
|
||||||
|
1. Loads runtime configuration from `.nogit` and the working directory.
|
||||||
|
2. Creates a `UtilityWebsiteServer` that serves the built frontend.
|
||||||
|
3. Registers OIDC endpoints such as discovery, JWKS, authorize, token, userinfo, and revoke.
|
||||||
|
4. Creates and starts `Reception`, then starts HTTP serving on port `2999`.
|
||||||
|
|
||||||
|
## Required Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MONGODB_URL=mongodb://localhost:27017/idp-dev
|
||||||
|
export IDP_BASEURL=http://localhost:2999
|
||||||
|
export INSTANCE_NAME=idp-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `SERVEZONE_PLATFORM_AUTHORIZATION`
|
||||||
|
- `PADDLE_TOKEN`
|
||||||
|
- `PADDLE_PRICE_ID`
|
||||||
|
|
||||||
|
## Key Managers
|
||||||
|
|
||||||
|
| Class | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `JwtManager` | JWT issuance, validation, and key rotation support |
|
||||||
|
| `LoginSessionManager` | Session creation, refresh, logout, and session metadata |
|
||||||
|
| `RegistrationSessionManager` | Registration flow state |
|
||||||
|
| `UserManager` | User-centric queries and mutations |
|
||||||
|
| `OrganizationManager` | Organization creation and access checks |
|
||||||
|
| `RoleManager` | Role and permission management |
|
||||||
|
| `UserInvitationManager` | Invitations, member updates, and ownership transfer |
|
||||||
|
| `BillingPlanManager` | Billing plan state and Paddle config endpoint |
|
||||||
|
| `AppManager` | Global app administration |
|
||||||
|
| `AppConnectionManager` | App connection tracking |
|
||||||
|
| `ActivityLogManager` | User activity logging |
|
||||||
|
| `AlertManager` | Passport alerts and alert rule management |
|
||||||
|
| `AbuseProtectionManager` | Attempt windows and temporary blocks for sensitive flows |
|
||||||
|
| `PassportManager` | Trusted device enrollment, approval challenges, dashboard data, and signed device requests |
|
||||||
|
| `PassportPushManager` | Push notification delivery hooks for passport challenges and alerts |
|
||||||
|
| `OidcManager` | OIDC discovery, auth code flow, token exchange, userinfo, revoke |
|
||||||
|
|
||||||
|
## Passport Request Surface
|
||||||
|
|
||||||
|
The backend exposes signed-device workflows over the same `typedrequest` router as the rest of the service:
|
||||||
|
|
||||||
|
- enrollment: `createPassportEnrollmentChallenge`, `completePassportEnrollment`
|
||||||
|
- devices: `getPassportDevices`, `revokePassportDevice`, `registerPassportPushToken`
|
||||||
|
- challenges: `createPassportChallenge`, `approvePassportChallenge`, `rejectPassportChallenge`, `listPendingPassportChallenges`
|
||||||
|
- dashboard and hints: `getPassportDashboard`, `getPassportChallengeByHint`, `markPassportChallengeSeen`
|
||||||
|
- alerts: `listPassportAlerts`, `getPassportAlertByHint`, `markPassportAlertSeen`, `dismissPassportAlert`
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm watch
|
||||||
|
```
|
||||||
|
|
||||||
|
The watch setup runs the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`.
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Reception } from './classes.reception.js';
|
||||||
|
import { AbuseWindow } from './classes.abusewindow.js';
|
||||||
|
|
||||||
|
export interface IAbuseProtectionConfig {
|
||||||
|
maxAttempts: number;
|
||||||
|
windowMillis: number;
|
||||||
|
blockDurationMillis: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbuseProtectionManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CAbuseWindow = plugins.smartdata.setDefaultManagerForDoc(this, AbuseWindow);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIdentifier(identifierArg: string) {
|
||||||
|
return identifierArg.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashIdentifier(identifierArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(this.normalizeIdentifier(identifierArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWindowId(actionArg: string, identifierArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(
|
||||||
|
`${actionArg}:${this.hashIdentifier(identifierArg)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWindow(actionArg: string, identifierArg: string) {
|
||||||
|
return this.CAbuseWindow.getInstance({
|
||||||
|
id: this.createWindowId(actionArg, identifierArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consumeAttempt(
|
||||||
|
actionArg: string,
|
||||||
|
identifierArg: string,
|
||||||
|
configArg: IAbuseProtectionConfig,
|
||||||
|
errorTextArg = 'Too many attempts. Please wait before trying again.'
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
let abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||||
|
|
||||||
|
if (!abuseWindow) {
|
||||||
|
abuseWindow = new AbuseWindow();
|
||||||
|
abuseWindow.id = this.createWindowId(actionArg, identifierArg);
|
||||||
|
abuseWindow.data.action = actionArg;
|
||||||
|
abuseWindow.data.identifierHash = this.hashIdentifier(identifierArg);
|
||||||
|
abuseWindow.data.createdAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abuseWindow.isBlocked(now)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abuseWindow.data.blockedUntil && abuseWindow.data.blockedUntil <= now) {
|
||||||
|
abuseWindow.data.attemptCount = 0;
|
||||||
|
abuseWindow.data.windowStartedAt = now;
|
||||||
|
abuseWindow.data.blockedUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!abuseWindow.data.windowStartedAt ||
|
||||||
|
abuseWindow.data.windowStartedAt + configArg.windowMillis <= now
|
||||||
|
) {
|
||||||
|
abuseWindow.data.attemptCount = 0;
|
||||||
|
abuseWindow.data.windowStartedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
abuseWindow.data.attemptCount += 1;
|
||||||
|
abuseWindow.data.updatedAt = now;
|
||||||
|
abuseWindow.data.validUntil = now + configArg.windowMillis;
|
||||||
|
|
||||||
|
if (abuseWindow.data.attemptCount > configArg.maxAttempts) {
|
||||||
|
abuseWindow.data.blockedUntil = now + configArg.blockDurationMillis;
|
||||||
|
abuseWindow.data.validUntil = abuseWindow.data.blockedUntil;
|
||||||
|
await abuseWindow.save();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
await abuseWindow.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearAttempts(actionArg: string, identifierArg: string) {
|
||||||
|
const abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||||
|
if (!abuseWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await abuseWindow.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class AbuseWindow extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
AbuseWindow,
|
||||||
|
plugins.idpInterfaces.data.IAbuseWindow,
|
||||||
|
AbuseProtectionManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAbuseWindow['data'] = {
|
||||||
|
action: '',
|
||||||
|
identifierHash: '',
|
||||||
|
attemptCount: 0,
|
||||||
|
windowStartedAt: 0,
|
||||||
|
blockedUntil: 0,
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isBlocked(nowArg = Date.now()) {
|
||||||
|
return this.data.blockedUntil > nowArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.validUntil < nowArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AlertManager } from './classes.alertmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class Alert extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
Alert,
|
||||||
|
plugins.idpInterfaces.data.IAlert,
|
||||||
|
AlertManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAlert['data'] = {
|
||||||
|
recipientUserId: '',
|
||||||
|
organizationId: undefined,
|
||||||
|
category: 'security',
|
||||||
|
eventType: '',
|
||||||
|
severity: 'medium',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
actorUserId: undefined,
|
||||||
|
relatedEntityId: undefined,
|
||||||
|
relatedEntityType: undefined,
|
||||||
|
notification: {
|
||||||
|
hintId: '',
|
||||||
|
status: 'pending',
|
||||||
|
attemptCount: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
deliveredAt: null,
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: 0,
|
||||||
|
seenAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Alert } from './classes.alert.js';
|
||||||
|
import { AlertRule } from './classes.alertrule.js';
|
||||||
|
import type { Reception } from './classes.reception.js';
|
||||||
|
|
||||||
|
const severityOrder: Record<plugins.idpInterfaces.data.TAlertSeverity, number> = {
|
||||||
|
low: 1,
|
||||||
|
medium: 2,
|
||||||
|
high: 3,
|
||||||
|
critical: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AlertManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
public CAlert = plugins.smartdata.setDefaultManagerForDoc(this, Alert);
|
||||||
|
public CAlertRule = plugins.smartdata.setDefaultManagerForDoc(this, AlertRule);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPassportAlerts>(
|
||||||
|
'listPassportAlerts',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'listPassportAlerts',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const alerts = await this.listAlertsForUser(
|
||||||
|
passportDevice.data.userId,
|
||||||
|
!!requestArg.includeDismissed
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportAlertByHint>(
|
||||||
|
'getPassportAlertByHint',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'getPassportAlertByHint',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const alert = await this.getAlertByHint(passportDevice.data.userId, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
alert: alert ? { id: alert.id, data: alert.data } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportAlertSeen>(
|
||||||
|
'markPassportAlertSeen',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'markPassportAlertSeen',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.markAlertSeen(passportDevice.data.userId, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DismissPassportAlert>(
|
||||||
|
'dismissPassportAlert',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'dismissPassportAlert',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.dismissAlert(passportDevice.data.userId, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertAlertRule>(
|
||||||
|
'upsertAlertRule',
|
||||||
|
async (requestArg) => {
|
||||||
|
const actorUserId = await this.verifyAlertRuleAccess(
|
||||||
|
requestArg.jwt,
|
||||||
|
requestArg.scope,
|
||||||
|
requestArg.organizationId
|
||||||
|
);
|
||||||
|
const rule = requestArg.ruleId
|
||||||
|
? await this.CAlertRule.getInstance({ id: requestArg.ruleId })
|
||||||
|
: new AlertRule();
|
||||||
|
if (!rule) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.id = rule.id || plugins.smartunique.shortId();
|
||||||
|
rule.data = {
|
||||||
|
scope: requestArg.scope,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: requestArg.eventType,
|
||||||
|
minimumSeverity: requestArg.minimumSeverity,
|
||||||
|
recipientMode: requestArg.recipientMode,
|
||||||
|
recipientUserIds: requestArg.recipientUserIds || [],
|
||||||
|
push: requestArg.push,
|
||||||
|
enabled: requestArg.enabled,
|
||||||
|
createdByUserId: rule.data?.createdByUserId || actorUserId,
|
||||||
|
createdAt: rule.data?.createdAt || Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await rule.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule: {
|
||||||
|
id: rule.id,
|
||||||
|
data: rule.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAlertRules>(
|
||||||
|
'getAlertRules',
|
||||||
|
async (requestArg) => {
|
||||||
|
await this.verifyAlertRuleAccess(requestArg.jwt, requestArg.scope || 'global', requestArg.organizationId);
|
||||||
|
const rules = await this.CAlertRule.getInstances({});
|
||||||
|
return {
|
||||||
|
rules: rules
|
||||||
|
.filter((ruleArg) => {
|
||||||
|
if (requestArg.scope && ruleArg.data.scope !== requestArg.scope) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requestArg.organizationId && ruleArg.data.organizationId !== requestArg.organizationId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((ruleArg) => ({ id: ruleArg.id, data: ruleArg.data })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteAlertRule>(
|
||||||
|
'deleteAlertRule',
|
||||||
|
async (requestArg) => {
|
||||||
|
const rule = await this.CAlertRule.getInstance({ id: requestArg.ruleId });
|
||||||
|
if (!rule) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
|
||||||
|
}
|
||||||
|
await this.verifyAlertRuleAccess(requestArg.jwt, rule.data.scope, rule.data.organizationId);
|
||||||
|
await rule.delete();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyAlertRuleAccess(
|
||||||
|
jwtArg: string,
|
||||||
|
scopeArg: plugins.idpInterfaces.data.TAlertRuleScope,
|
||||||
|
organizationIdArg?: string
|
||||||
|
) {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeArg === 'global') {
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({ id: jwt.data.userId });
|
||||||
|
if (!user?.data?.isGlobalAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Global admin privileges required');
|
||||||
|
}
|
||||||
|
return jwt.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organizationIdArg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('organizationId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||||
|
data: {
|
||||||
|
userId: jwt.data.userId,
|
||||||
|
organizationId: organizationIdArg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!role || !role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required');
|
||||||
|
}
|
||||||
|
return jwt.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveGlobalAdminRecipients() {
|
||||||
|
const users = await this.receptionRef.userManager.CUser.getInstances({});
|
||||||
|
return users.filter((userArg) => !!userArg.data.isGlobalAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveOrganizationAdminRecipients(organizationIdArg: string) {
|
||||||
|
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organizationIdArg);
|
||||||
|
const adminUserIds = [...new Set(
|
||||||
|
roles
|
||||||
|
.filter((roleArg) => roleArg.data.roles.some((roleNameArg) => ['owner', 'admin'].includes(roleNameArg)))
|
||||||
|
.map((roleArg) => roleArg.data.userId)
|
||||||
|
)];
|
||||||
|
const users = await Promise.all(
|
||||||
|
adminUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg }))
|
||||||
|
);
|
||||||
|
return users.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRuleRecipients(ruleArg: AlertRule) {
|
||||||
|
switch (ruleArg.data.recipientMode) {
|
||||||
|
case 'global_admins':
|
||||||
|
return this.resolveGlobalAdminRecipients();
|
||||||
|
case 'org_admins':
|
||||||
|
if (!ruleArg.data.organizationId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.resolveOrganizationAdminRecipients(ruleArg.data.organizationId);
|
||||||
|
case 'specific_users':
|
||||||
|
if (!ruleArg.data.recipientUserIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const users = await Promise.all(
|
||||||
|
ruleArg.data.recipientUserIds.map((userIdArg) =>
|
||||||
|
this.receptionRef.userManager.CUser.getInstance({ id: userIdArg })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return users.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMatchingRules(optionsArg: {
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
organizationId?: string;
|
||||||
|
}) {
|
||||||
|
const rules = await this.CAlertRule.getInstances({});
|
||||||
|
const matchingRules = rules.filter((ruleArg) => {
|
||||||
|
if (!ruleArg.data.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ruleArg.data.eventType !== optionsArg.eventType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ruleArg.data.scope === 'organization' && ruleArg.data.organizationId !== optionsArg.organizationId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return severityOrder[optionsArg.severity] >= severityOrder[ruleArg.data.minimumSeverity];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingRules.length > 0) {
|
||||||
|
return matchingRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsArg.eventType === 'global_admin_access') {
|
||||||
|
return [this.createBuiltInRule('builtin-global-admin-access', {
|
||||||
|
scope: 'global',
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
minimumSeverity: 'high',
|
||||||
|
recipientMode: 'global_admins',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsArg.eventType === 'global_app_credentials_regenerated') {
|
||||||
|
return [this.createBuiltInRule('builtin-global-app-credentials-regenerated', {
|
||||||
|
scope: 'global',
|
||||||
|
eventType: 'global_app_credentials_regenerated',
|
||||||
|
minimumSeverity: 'critical',
|
||||||
|
recipientMode: 'global_admins',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsArg.organizationId) {
|
||||||
|
const organizationFallbackMap: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
org_app_connected: { minimumSeverity: 'medium' },
|
||||||
|
org_app_disconnected: { minimumSeverity: 'medium' },
|
||||||
|
org_invitation_created: { minimumSeverity: 'low' },
|
||||||
|
org_invitation_resent: { minimumSeverity: 'low' },
|
||||||
|
org_updated: { minimumSeverity: 'high' },
|
||||||
|
org_deleted: { minimumSeverity: 'critical' },
|
||||||
|
org_role_definition_updated: { minimumSeverity: 'medium' },
|
||||||
|
org_role_definition_deleted: { minimumSeverity: 'high' },
|
||||||
|
org_app_role_mappings_updated: { minimumSeverity: 'medium' },
|
||||||
|
org_member_removed: { minimumSeverity: 'high' },
|
||||||
|
org_member_roles_updated: { minimumSeverity: 'high' },
|
||||||
|
org_ownership_transferred: { minimumSeverity: 'critical' },
|
||||||
|
};
|
||||||
|
const fallbackConfig = organizationFallbackMap[optionsArg.eventType];
|
||||||
|
if (fallbackConfig) {
|
||||||
|
return [this.createBuiltInRule(`builtin-${optionsArg.eventType}`, {
|
||||||
|
scope: 'organization',
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
minimumSeverity: fallbackConfig.minimumSeverity,
|
||||||
|
recipientMode: 'org_admins',
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBuiltInRule(
|
||||||
|
ruleIdArg: string,
|
||||||
|
optionsArg: {
|
||||||
|
scope: plugins.idpInterfaces.data.TAlertRuleScope;
|
||||||
|
organizationId?: string;
|
||||||
|
eventType: string;
|
||||||
|
minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
recipientMode: plugins.idpInterfaces.data.TAlertRuleRecipientMode;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const fallbackRule = new AlertRule();
|
||||||
|
fallbackRule.id = ruleIdArg;
|
||||||
|
fallbackRule.data = {
|
||||||
|
scope: optionsArg.scope,
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
minimumSeverity: optionsArg.minimumSeverity,
|
||||||
|
recipientMode: optionsArg.recipientMode,
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: 'system',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
return fallbackRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAlertsForEvent(optionsArg: {
|
||||||
|
category: plugins.idpInterfaces.data.TAlertCategory;
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
relatedEntityId?: string;
|
||||||
|
relatedEntityType?: string;
|
||||||
|
}) {
|
||||||
|
const matchingRules = await this.getMatchingRules(optionsArg);
|
||||||
|
if (matchingRules.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientIds = new Set<string>();
|
||||||
|
for (const rule of matchingRules) {
|
||||||
|
const recipients = await this.resolveRuleRecipients(rule);
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
recipientIds.add(recipient.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAlerts: Alert[] = [];
|
||||||
|
for (const recipientUserId of recipientIds) {
|
||||||
|
const alert = new Alert();
|
||||||
|
alert.id = plugins.smartunique.shortId();
|
||||||
|
alert.data = {
|
||||||
|
recipientUserId,
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
category: optionsArg.category,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
severity: optionsArg.severity,
|
||||||
|
title: optionsArg.title,
|
||||||
|
body: optionsArg.body,
|
||||||
|
actorUserId: optionsArg.actorUserId,
|
||||||
|
relatedEntityId: optionsArg.relatedEntityId,
|
||||||
|
relatedEntityType: optionsArg.relatedEntityType,
|
||||||
|
notification: {
|
||||||
|
hintId: plugins.crypto.randomUUID(),
|
||||||
|
status: 'pending',
|
||||||
|
attemptCount: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deliveredAt: null,
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
seenAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
createdAlerts.push(alert);
|
||||||
|
|
||||||
|
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(recipientUserId);
|
||||||
|
let delivered = false;
|
||||||
|
for (const device of devices) {
|
||||||
|
const result = await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
|
||||||
|
delivered = delivered || result;
|
||||||
|
}
|
||||||
|
if (!delivered && devices.length === 0) {
|
||||||
|
alert.data.notification = {
|
||||||
|
...alert.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alert.data.notification.attemptCount + 1,
|
||||||
|
lastError: 'Recipient has no active passport device',
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listAlertsForUser(userIdArg: string, includeDismissedArg = false) {
|
||||||
|
const alerts = await this.CAlert.getInstances({
|
||||||
|
'data.recipientUserId': userIdArg,
|
||||||
|
});
|
||||||
|
return alerts
|
||||||
|
.filter((alertArg) => includeDismissedArg || !alertArg.data.dismissedAt)
|
||||||
|
.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAlertByHint(userIdArg: string, hintIdArg: string) {
|
||||||
|
return this.CAlert.getInstance({
|
||||||
|
'data.recipientUserId': userIdArg,
|
||||||
|
'data.notification.hintId': hintIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markAlertSeen(userIdArg: string, hintIdArg: string) {
|
||||||
|
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
|
||||||
|
if (!alert) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.data.seenAt = Date.now();
|
||||||
|
alert.data.notification = {
|
||||||
|
...alert.data.notification,
|
||||||
|
status: 'seen',
|
||||||
|
seenAt: Date.now(),
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async dismissAlert(userIdArg: string, hintIdArg: string) {
|
||||||
|
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
|
||||||
|
if (!alert) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.data.dismissedAt = Date.now();
|
||||||
|
if (!alert.data.seenAt) {
|
||||||
|
alert.data.seenAt = Date.now();
|
||||||
|
}
|
||||||
|
alert.data.notification = {
|
||||||
|
...alert.data.notification,
|
||||||
|
status: 'seen',
|
||||||
|
seenAt: alert.data.notification.seenAt || Date.now(),
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reDeliverPendingAlerts() {
|
||||||
|
const alerts = await this.CAlert.getInstances({});
|
||||||
|
for (const alert of alerts) {
|
||||||
|
if (alert.data.notification.status === 'sent' || alert.data.notification.status === 'seen') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(
|
||||||
|
alert.data.recipientUserId
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AlertManager } from './classes.alertmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class AlertRule extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
AlertRule,
|
||||||
|
plugins.idpInterfaces.data.IAlertRule,
|
||||||
|
AlertManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAlertRule['data'] = {
|
||||||
|
scope: 'global',
|
||||||
|
organizationId: undefined,
|
||||||
|
eventType: '',
|
||||||
|
minimumSeverity: 'medium',
|
||||||
|
recipientMode: 'global_admins',
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: '',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { Reception } from './classes.reception.js';
|
import type { Reception } from './classes.reception.js';
|
||||||
import { AppConnection } from './classes.appconnection.js';
|
import { AppConnection } from './classes.appconnection.js';
|
||||||
|
import type { User } from './classes.user.js';
|
||||||
|
|
||||||
export class AppConnectionManager {
|
export class AppConnectionManager {
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
@@ -11,6 +12,29 @@ export class AppConnectionManager {
|
|||||||
|
|
||||||
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
|
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
|
||||||
|
|
||||||
|
private async emitOrganizationAlert(optionsArg: {
|
||||||
|
organizationId: string;
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actorUserId: string;
|
||||||
|
relatedEntityId?: string;
|
||||||
|
relatedEntityType?: string;
|
||||||
|
}) {
|
||||||
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
severity: optionsArg.severity,
|
||||||
|
title: optionsArg.title,
|
||||||
|
body: optionsArg.body,
|
||||||
|
actorUserId: optionsArg.actorUserId,
|
||||||
|
relatedEntityId: optionsArg.relatedEntityId,
|
||||||
|
relatedEntityType: optionsArg.relatedEntityType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
@@ -127,10 +151,22 @@ export class AppConnectionManager {
|
|||||||
connectedAt: Date.now(),
|
connectedAt: Date.now(),
|
||||||
connectedByUserId: user.id,
|
connectedByUserId: user.id,
|
||||||
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
|
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
|
||||||
|
roleMappings: [],
|
||||||
};
|
};
|
||||||
await connection.save();
|
await connection.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_app_connected',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Organization app connected',
|
||||||
|
body: `${user.data.email} connected ${app.data.name} to this organization.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: app.id,
|
||||||
|
relatedEntityType: 'global-app',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
connection: await connection.createSavableObject(),
|
connection: await connection.createSavableObject(),
|
||||||
@@ -145,6 +181,17 @@ export class AppConnectionManager {
|
|||||||
|
|
||||||
await connection.disconnect();
|
await connection.disconnect();
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_app_disconnected',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Organization app disconnected',
|
||||||
|
body: `${user.data.email} disconnected ${app.data.name} from this organization.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: app.id,
|
||||||
|
relatedEntityType: 'global-app',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
connection: await connection.createSavableObject(),
|
connection: await connection.createSavableObject(),
|
||||||
@@ -153,6 +200,116 @@ export class AppConnectionManager {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>(
|
||||||
|
'updateAppRoleMappings',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
id: jwtData.data.userId,
|
||||||
|
});
|
||||||
|
const connection = await this.updateAppRoleMappings({
|
||||||
|
user,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
appId: requestArg.appId,
|
||||||
|
roleMappings: requestArg.roleMappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
connection: await connection.createSavableObject(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateAppRoleMappings(optionsArg: {
|
||||||
|
user: User;
|
||||||
|
organizationId: string;
|
||||||
|
appId: string;
|
||||||
|
roleMappings: plugins.idpInterfaces.data.IAppRoleMapping[];
|
||||||
|
}) {
|
||||||
|
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||||
|
id: optionsArg.organizationId,
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||||
|
}
|
||||||
|
if (!await organization.checkIfUserIsAdmin(optionsArg.user)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Only organization admins can manage app role mappings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.receptionRef.appManager.getAppById(optionsArg.appId);
|
||||||
|
if (!app) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('App not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await this.CAppConnection.getInstance({
|
||||||
|
'data.organizationId': optionsArg.organizationId,
|
||||||
|
'data.appId': optionsArg.appId,
|
||||||
|
});
|
||||||
|
if (!connection || !connection.isActive()) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('App must be connected before role mappings can be configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRoleKeys = await this.receptionRef.organizationmanager.getAvailableRoleKeys(optionsArg.organizationId);
|
||||||
|
const cleanMappings = (optionsArg.roleMappings || []).map((mappingArg) => ({
|
||||||
|
orgRoleKey: this.receptionRef.organizationmanager.validateRoleKey(mappingArg.orgRoleKey),
|
||||||
|
appRoles: this.cleanStringList(mappingArg.appRoles),
|
||||||
|
permissions: this.cleanStringList(mappingArg.permissions),
|
||||||
|
scopes: this.cleanStringList(mappingArg.scopes),
|
||||||
|
})).filter((mappingArg) => mappingArg.appRoles.length || mappingArg.permissions.length || mappingArg.scopes.length);
|
||||||
|
const invalidRoleKeys = cleanMappings
|
||||||
|
.map((mappingArg) => mappingArg.orgRoleKey)
|
||||||
|
.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg));
|
||||||
|
if (invalidRoleKeys.length) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${[...new Set(invalidRoleKeys)].join(', ')}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedScopes = cleanMappings.flatMap((mappingArg) => mappingArg.scopes);
|
||||||
|
const allowedScopes = app.data.oauthCredentials?.allowedScopes || [];
|
||||||
|
const grantedScopes = connection.data.grantedScopes || [];
|
||||||
|
const unsupportedScopes = requestedScopes.filter((scopeArg) => !allowedScopes.includes(scopeArg));
|
||||||
|
if (unsupportedScopes.length) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`Unsupported app scopes: ${[...new Set(unsupportedScopes)].join(', ')}.`);
|
||||||
|
}
|
||||||
|
const ungrantedScopes = requestedScopes.filter((scopeArg) => !grantedScopes.includes(scopeArg));
|
||||||
|
if (ungrantedScopes.length) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`Scopes not granted to this connection: ${[...new Set(ungrantedScopes)].join(', ')}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.data.roleMappings = cleanMappings;
|
||||||
|
await connection.save();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
optionsArg.user.id,
|
||||||
|
'org_app_role_mappings_updated',
|
||||||
|
`${optionsArg.user.data.email} updated ${cleanMappings.length} role mappings for ${app.data.name}.`,
|
||||||
|
{
|
||||||
|
targetId: connection.id,
|
||||||
|
targetType: 'app-connection',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
eventType: 'org_app_role_mappings_updated',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Organization app role mappings updated',
|
||||||
|
body: `${optionsArg.user.data.email} updated role mappings for ${app.data.name}.`,
|
||||||
|
actorUserId: optionsArg.user.id,
|
||||||
|
relatedEntityId: app.id,
|
||||||
|
relatedEntityType: 'global-app',
|
||||||
|
});
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanStringList(valuesArg: string[]) {
|
||||||
|
return [...new Set((valuesArg || [])
|
||||||
|
.map((valueArg) => (valueArg || '').trim())
|
||||||
|
.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -59,7 +59,20 @@ export class AppManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||||
'getGlobalAppStats',
|
'getGlobalAppStats',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
await this.verifyGlobalAdmin(requestArg.jwt);
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
id: jwtData.data.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Global admin console accessed',
|
||||||
|
body: `${user?.data?.email || 'A global admin'} accessed the global app administration dashboard.`,
|
||||||
|
actorUserId: jwtData.data.userId,
|
||||||
|
relatedEntityType: 'global-admin-console',
|
||||||
|
});
|
||||||
|
|
||||||
// Get all global apps (including inactive)
|
// Get all global apps (including inactive)
|
||||||
const globalApps = await this.CApp.getInstances({
|
const globalApps = await this.CApp.getInstances({
|
||||||
@@ -198,7 +211,7 @@ export class AppManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||||
'regenerateAppCredentials',
|
'regenerateAppCredentials',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
await this.verifyGlobalAdmin(requestArg.jwt);
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
||||||
if (!app) {
|
if (!app) {
|
||||||
@@ -214,6 +227,17 @@ export class AppManager {
|
|||||||
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
||||||
await app.save();
|
await app.save();
|
||||||
|
|
||||||
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'global_app_credentials_regenerated',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Global app credentials regenerated',
|
||||||
|
body: `OAuth credentials for ${app.data.name} were regenerated.`,
|
||||||
|
actorUserId: jwtData.data.userId,
|
||||||
|
relatedEntityId: app.id,
|
||||||
|
relatedEntityType: 'global-app',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret, // Only shown once
|
clientSecret, // Only shown once
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { LoginSessionManager } from './classes.loginsessionmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class EmailActionToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
EmailActionToken,
|
||||||
|
plugins.idpInterfaces.data.IEmailActionToken,
|
||||||
|
LoginSessionManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createOpaqueToken(actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction) {
|
||||||
|
return `${actionArg}_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IEmailActionToken['data'] = {
|
||||||
|
email: '',
|
||||||
|
action: 'emailLogin',
|
||||||
|
tokenHash: '',
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.validUntil < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === EmailActionToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consume(tokenArg: string) {
|
||||||
|
if (this.isExpired() || !this.matchesToken(tokenArg)) {
|
||||||
|
if (this.isExpired()) {
|
||||||
|
await this.delete();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,98 @@ export class ReceptionHousekeeping {
|
|||||||
'2 * * * * *'
|
'2 * * * * *'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredEmailActionTokens',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredEmailActionTokens =
|
||||||
|
await this.receptionRef.loginSessionManager.CEmailActionToken.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const emailActionToken of expiredEmailActionTokens) {
|
||||||
|
await emailActionToken.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredRegistrationSessions',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredRegistrationSessions =
|
||||||
|
await this.receptionRef.registrationSessionManager.CRegistrationSession.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const registrationSession of expiredRegistrationSessions) {
|
||||||
|
await registrationSession.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredAbuseWindows',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredAbuseWindows =
|
||||||
|
await this.receptionRef.abuseProtectionManager.CAbuseWindow.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const abuseWindow of expiredAbuseWindows) {
|
||||||
|
await abuseWindow.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredPassportChallenges',
|
||||||
|
taskFunction: async () => {
|
||||||
|
await this.receptionRef.passportManager.cleanupExpiredChallenges();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'redeliverPassportChallengeHints',
|
||||||
|
taskFunction: async () => {
|
||||||
|
await this.receptionRef.passportManager.reDeliverPendingChallengeHints();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'7 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'redeliverAlertHints',
|
||||||
|
taskFunction: async () => {
|
||||||
|
await this.receptionRef.alertManager.reDeliverPendingAlerts();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'12 * * * * *'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
this.taskmanager.start();
|
this.taskmanager.start();
|
||||||
logger.log('info', 'housekeeping started');
|
logger.log('info', 'housekeeping started');
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-14
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { JwtManager } from './classes.jwtmanager.js';
|
import { JwtManager } from './classes.jwtmanager.js';
|
||||||
|
import type { LoginSession } from './classes.loginsession.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a User is identified by its username or email.
|
* a User is identified by its username or email.
|
||||||
@@ -11,21 +12,27 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
|
|||||||
public static async createJwtForRefreshToken(
|
public static async createJwtForRefreshToken(
|
||||||
jwtManagerInstance: JwtManager,
|
jwtManagerInstance: JwtManager,
|
||||||
refreshTokenArg: string
|
refreshTokenArg: string
|
||||||
) {
|
): Promise<string | null> {
|
||||||
const loginSession =
|
const sessionLookup =
|
||||||
await jwtManagerInstance.receptionRef.loginSessionManager.CLoginSession.getLoginSessionByRefreshToken(
|
await jwtManagerInstance.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
|
||||||
refreshTokenArg
|
refreshTokenArg
|
||||||
);
|
);
|
||||||
if (!loginSession) {
|
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const refreshTokenValid = await loginSession.validateRefreshToken(refreshTokenArg);
|
return this.createJwtForLoginSession(jwtManagerInstance, sessionLookup.loginSession);
|
||||||
if (!refreshTokenValid) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async createJwtForLoginSession(
|
||||||
|
jwtManagerInstance: JwtManager,
|
||||||
|
loginSession: LoginSession
|
||||||
|
): Promise<string | null> {
|
||||||
const user = await jwtManagerInstance.receptionRef.userManager.CUser.getInstance({
|
const user = await jwtManagerInstance.receptionRef.userManager.CUser.getInstance({
|
||||||
id: loginSession.data.userId,
|
id: loginSession.data.userId,
|
||||||
});
|
});
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const validUntil = plugins.smarttime.ExtendedDate.fromMillis(
|
const validUntil = plugins.smarttime.ExtendedDate.fromMillis(
|
||||||
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 1 })
|
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 1 })
|
||||||
);
|
);
|
||||||
@@ -33,10 +40,10 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
|
|||||||
jwt.id = plugins.smartunique.shortId();
|
jwt.id = plugins.smartunique.shortId();
|
||||||
jwt.data = {
|
jwt.data = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
sessionId: loginSession.id,
|
||||||
validUntil: validUntil.getTime(),
|
validUntil: validUntil.getTime(),
|
||||||
refreshEvery: 1000000,
|
refreshEvery: 1000000,
|
||||||
refreshFrom: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 0.5 }),
|
refreshFrom: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 0.5 }),
|
||||||
refreshToken: await loginSession.getRefreshToken(), // TODO: handle multiple refresh tokens
|
|
||||||
justForLooks: {
|
justForLooks: {
|
||||||
validUntilIsoString: validUntil.toISOString(),
|
validUntilIsoString: validUntil.toISOString(),
|
||||||
}
|
}
|
||||||
@@ -46,7 +53,7 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
|
|||||||
|
|
||||||
const jwtString = await jwtManagerInstance.smartjwtInstance.createJWT({
|
const jwtString = await jwtManagerInstance.smartjwtInstance.createJWT({
|
||||||
id: jwt.id,
|
id: jwt.id,
|
||||||
blocked: null,
|
blocked: false,
|
||||||
data: jwt.data,
|
data: jwt.data,
|
||||||
} as plugins.idpInterfaces.data.IJwt);
|
} as plugins.idpInterfaces.data.IJwt);
|
||||||
return jwtString;
|
return jwtString;
|
||||||
@@ -68,11 +75,26 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getLoginSession() {
|
public async getLoginSession() {
|
||||||
const loginSession = await this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
|
if (this.data.sessionId) {
|
||||||
data: {
|
return this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
|
||||||
refreshToken: this.data.refreshToken,
|
id: this.data.sessionId,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return loginSession;
|
}
|
||||||
|
|
||||||
|
if (!this.data.refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionLookup =
|
||||||
|
await this.manager.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
|
||||||
|
this.data.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessionLookup) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionLookup.loginSession;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,41 @@ export class JwtManager {
|
|||||||
new plugins.typedrequest.TypedHandler(
|
new plugins.typedrequest.TypedHandler(
|
||||||
'refreshJwt',
|
'refreshJwt',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
const resultJwt = await Jwt.createJwtForRefreshToken(this, requestArg.refreshToken);
|
const sessionLookup =
|
||||||
|
await this.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
|
||||||
|
requestArg.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessionLookup || sessionLookup.validationStatus === 'invalid') {
|
||||||
|
return {
|
||||||
|
status: 'not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionLookup.validationStatus === 'invalidated') {
|
||||||
|
return {
|
||||||
|
status: 'invalidated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionLookup.validationStatus === 'reused') {
|
||||||
|
await sessionLookup.loginSession.invalidate();
|
||||||
|
return {
|
||||||
|
status: 'invalidated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedRefreshToken = await sessionLookup.loginSession.getRefreshToken();
|
||||||
|
const resultJwt = await Jwt.createJwtForLoginSession(this, sessionLookup.loginSession);
|
||||||
|
if (!rotatedRefreshToken || !resultJwt) {
|
||||||
|
return {
|
||||||
|
status: 'invalidated',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: 'loggedIn',
|
status: 'loggedIn',
|
||||||
jwt: resultJwt,
|
jwt: resultJwt,
|
||||||
|
refreshToken: rotatedRefreshToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -120,19 +151,24 @@ export class JwtManager {
|
|||||||
await this.pushPublicKeyToClients();
|
await this.pushPublicKeyToClients();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
|
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt | null> {
|
||||||
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
|
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
|
||||||
const jwt = await this.CJwt.getInstance({
|
const jwt = await this.CJwt.getInstance({
|
||||||
id: jwtData.id,
|
id: jwtData.id,
|
||||||
});
|
});
|
||||||
|
if (!jwt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (jwt.blocked) {
|
if (jwt.blocked) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
const loginSession = await jwt.getLoginSession();
|
const loginSession = await jwt.getLoginSession();
|
||||||
if (!loginSession) {
|
if (!loginSession || loginSession.data.invalidated) {
|
||||||
await jwt.block();
|
await jwt.block();
|
||||||
|
if (!this.blockedJwtIdList.includes(jwt.id)) {
|
||||||
this.blockedJwtIdList.push(jwt.id);
|
this.blockedJwtIdList.push(jwt.id);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import * as plugins from '../plugins.js';
|
|||||||
import { LoginSessionManager } from './classes.loginsessionmanager.js';
|
import { LoginSessionManager } from './classes.loginsessionmanager.js';
|
||||||
import { User } from './classes.user.js';
|
import { User } from './classes.user.js';
|
||||||
|
|
||||||
|
export type TRefreshTokenValidationResult = 'current' | 'invalid' | 'invalidated' | 'reused';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a LoginSession keeps track of a login over the whole time of the user being loggedin
|
* a LoginSession keeps track of a login over the whole time of the user being loggedin
|
||||||
*/
|
*/
|
||||||
@@ -40,7 +42,14 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async getLoginSessionByRefreshToken(refreshTokenArg: string) {
|
public static async getLoginSessionByRefreshToken(refreshTokenArg: string) {
|
||||||
const loginSession = await LoginSession.getInstance({
|
const refreshTokenHash = await LoginSession.hashSessionToken(refreshTokenArg);
|
||||||
|
let loginSession = await LoginSession.getInstance({
|
||||||
|
'data.refreshTokenHash': refreshTokenHash,
|
||||||
|
});
|
||||||
|
if (loginSession) {
|
||||||
|
return loginSession;
|
||||||
|
}
|
||||||
|
loginSession = await LoginSession.getInstance({
|
||||||
data: {
|
data: {
|
||||||
refreshToken: refreshTokenArg,
|
refreshToken: refreshTokenArg,
|
||||||
},
|
},
|
||||||
@@ -48,6 +57,14 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
return loginSession;
|
return loginSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async hashSessionToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromString(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createOpaqueToken(prefixArg: string) {
|
||||||
|
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ========
|
// ========
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
// ========
|
// ========
|
||||||
@@ -60,13 +77,17 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
|
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
|
||||||
invalidated: false,
|
invalidated: false,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
|
refreshTokenHash: null,
|
||||||
|
rotatedRefreshTokenHashes: [],
|
||||||
|
transferTokenHash: null,
|
||||||
|
transferTokenExpiresAt: null,
|
||||||
deviceId: null,
|
deviceId: null,
|
||||||
deviceInfo: null,
|
deviceInfo: null,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastActive: Date.now(),
|
lastActive: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
public transferToken: string;
|
public transferToken: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -77,40 +98,99 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
*/
|
*/
|
||||||
public async invalidate() {
|
public async invalidate() {
|
||||||
this.data.invalidated = true;
|
this.data.invalidated = true;
|
||||||
|
this.data.refreshToken = null;
|
||||||
|
this.data.refreshTokenHash = null;
|
||||||
|
this.data.transferTokenHash = null;
|
||||||
|
this.data.transferTokenExpiresAt = null;
|
||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a refresh token is unique to a login session and ONLY created once per login session
|
* a refresh token is unique to a login session and rotated whenever it is issued
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public async getRefreshToken() {
|
public async getRefreshToken() {
|
||||||
if (this.data.invalidated) {
|
if (this.data.invalidated) {
|
||||||
console.log('login session is invalidated. no refresh token can be generated.');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!this.data.refreshToken) {
|
const previousRefreshTokenHash =
|
||||||
this.data.refreshToken = plugins.smartunique.uni('refresh_');
|
this.data.refreshTokenHash ||
|
||||||
|
(this.data.refreshToken
|
||||||
|
? await LoginSession.hashSessionToken(this.data.refreshToken)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (previousRefreshTokenHash) {
|
||||||
|
this.data.rotatedRefreshTokenHashes = [
|
||||||
|
...(this.data.rotatedRefreshTokenHashes || []),
|
||||||
|
previousRefreshTokenHash,
|
||||||
|
].slice(-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshToken = LoginSession.createOpaqueToken('refresh_');
|
||||||
|
this.data.refreshTokenHash = await LoginSession.hashSessionToken(refreshToken);
|
||||||
|
this.data.refreshToken = null;
|
||||||
|
this.data.lastActive = Date.now();
|
||||||
await this.save();
|
await this.save();
|
||||||
return this.data.refreshToken;
|
return refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getTransferToken() {
|
public async getTransferToken() {
|
||||||
this.transferToken = plugins.smartunique.uni('transfer_');
|
this.transferToken = LoginSession.createOpaqueToken('transfer_');
|
||||||
|
this.data.transferTokenHash = await LoginSession.hashSessionToken(this.transferToken);
|
||||||
|
this.data.transferTokenExpiresAt =
|
||||||
|
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 });
|
||||||
|
await this.save();
|
||||||
return this.transferToken;
|
return this.transferToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validateRefreshToken(refreshTokenArg: string) {
|
public async validateRefreshToken(
|
||||||
return this.data.refreshToken === refreshTokenArg;
|
refreshTokenArg: string
|
||||||
|
): Promise<TRefreshTokenValidationResult> {
|
||||||
|
if (this.data.invalidated) {
|
||||||
|
return 'invalidated';
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTokenHash = await LoginSession.hashSessionToken(refreshTokenArg);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.data.refreshTokenHash === refreshTokenHash ||
|
||||||
|
(!!this.data.refreshToken && this.data.refreshToken === refreshTokenArg)
|
||||||
|
) {
|
||||||
|
return 'current';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((this.data.rotatedRefreshTokenHashes || []).includes(refreshTokenHash)) {
|
||||||
|
return 'reused';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'invalid';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validateTransferToken(transferTokenArg: string) {
|
public async validateTransferToken(transferTokenArg: string) {
|
||||||
const result = this.transferToken === transferTokenArg;
|
if (this.data.invalidated || !this.data.transferTokenHash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.data.transferTokenExpiresAt &&
|
||||||
|
this.data.transferTokenExpiresAt < Date.now()
|
||||||
|
) {
|
||||||
|
this.data.transferTokenHash = null;
|
||||||
|
this.data.transferTokenExpiresAt = null;
|
||||||
|
await this.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
this.data.transferTokenHash ===
|
||||||
|
(await LoginSession.hashSessionToken(transferTokenArg));
|
||||||
|
|
||||||
// a transfer token can only be used once, so we invalidate it here
|
// a transfer token can only be used once, so we invalidate it here
|
||||||
if (result) {
|
if (result) {
|
||||||
this.transferToken = null;
|
this.transferToken = null;
|
||||||
|
this.data.transferTokenHash = null;
|
||||||
|
this.data.transferTokenExpiresAt = null;
|
||||||
|
await this.save();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { LoginSession } from './classes.loginsession.js';
|
import { EmailActionToken } from './classes.emailactiontoken.js';
|
||||||
|
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
|
||||||
import { Reception } from './classes.reception.js';
|
import { Reception } from './classes.reception.js';
|
||||||
import { logger } from './logging.js';
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
export class LoginSessionManager {
|
export class LoginSessionManager {
|
||||||
|
private readonly abuseProtectionConfigs = {
|
||||||
|
passwordLogin: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
emailLoginRequest: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
emailLoginToken: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
passwordResetRequest: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
passwordResetCompletion: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// refs
|
// refs
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
public get db() {
|
public get db() {
|
||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken);
|
||||||
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
||||||
|
|
||||||
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
|
|
||||||
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
public emailTokenMap = new plugins.lik.ObjectMap<{
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
action: 'emailLogin' | 'passwordReset';
|
|
||||||
}>();
|
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
@@ -29,12 +51,17 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
'loginWithEmailOrUsernameAndPassword',
|
'loginWithEmailOrUsernameAndPassword',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
|
const loginIdentifier = requestData.username;
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordLogin',
|
||||||
|
loginIdentifier,
|
||||||
|
this.abuseProtectionConfigs.passwordLogin,
|
||||||
|
'Too many login attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
|
||||||
let user = await this.receptionRef.userManager.CUser.getInstance({
|
let user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
username: requestData.username,
|
username: requestData.username,
|
||||||
passwordHash: await this.receptionRef.userManager.CUser.hashPassword(
|
|
||||||
requestData.password
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,33 +69,34 @@ export class LoginSessionManager {
|
|||||||
user = await this.receptionRef.userManager.CUser.getInstance({
|
user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
email: requestData.username,
|
email: requestData.username,
|
||||||
passwordHash: await this.receptionRef.userManager.CUser.hashPassword(
|
|
||||||
requestData.password
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user && (await this.receptionRef.userManager.CUser.verifyPassword(
|
||||||
// lets recheck
|
requestData.password,
|
||||||
if (
|
user.data.passwordHash
|
||||||
(user.data.username !== requestData.username &&
|
))) {
|
||||||
user.data.email !== requestData.username) ||
|
if (this.receptionRef.userManager.CUser.shouldUpgradePasswordHash(user.data.passwordHash)) {
|
||||||
user.data.passwordHash !==
|
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
|
||||||
(await this.receptionRef.userManager.CUser.hashPassword(requestData.password))
|
requestData.password
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'database returned a user that does not match wanted criterea. CRITICAL!'
|
|
||||||
);
|
);
|
||||||
|
await user.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
this.loginSessions.add(loginSession);
|
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'passwordLogin',
|
||||||
|
loginIdentifier
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
refreshToken,
|
||||||
refreshToken: refreshToken,
|
|
||||||
twoFaNeeded: false,
|
twoFaNeeded: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -82,6 +110,12 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||||
'loginWithEmail',
|
'loginWithEmail',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'emailLoginRequest',
|
||||||
|
requestDataArg.email,
|
||||||
|
this.abuseProtectionConfigs.emailLoginRequest,
|
||||||
|
'Too many magic link requests. Please wait before trying again.'
|
||||||
|
);
|
||||||
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
||||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -90,31 +124,21 @@ export class LoginSessionManager {
|
|||||||
});
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
const loginEmailToken = await this.createEmailActionToken(
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
existingUser.data.email,
|
||||||
|
'emailLogin'
|
||||||
);
|
);
|
||||||
const loginEmailToken = plugins.smartunique.uuid4();
|
|
||||||
this.emailTokenMap.add({
|
|
||||||
email: existingUser.data.email,
|
|
||||||
token: loginEmailToken,
|
|
||||||
action: 'emailLogin',
|
|
||||||
});
|
|
||||||
// lets make sure its only valid for 10 minutes
|
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
|
||||||
(itemArg) => itemArg.token === loginEmailToken
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
testOnlyToken: process.env.TEST_MODE
|
testOnlyToken: undefined,
|
||||||
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
|
||||||
.token
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -124,19 +148,37 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||||
'loginWithEmailAfterEmailTokenAquired',
|
'loginWithEmailAfterEmailTokenAquired',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
const tokenObject = this.emailTokenMap.findSync((itemArg) => {
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
return itemArg.email === requestArg.email && itemArg.token === requestArg.token;
|
'emailLoginToken',
|
||||||
});
|
requestArg.email,
|
||||||
|
this.abuseProtectionConfigs.emailLoginToken,
|
||||||
|
'Too many magic link attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
const tokenObject = await this.consumeEmailActionToken(
|
||||||
|
requestArg.email,
|
||||||
|
requestArg.token,
|
||||||
|
'emailLogin'
|
||||||
|
);
|
||||||
if (tokenObject) {
|
if (tokenObject) {
|
||||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
email: requestArg.email,
|
email: requestArg.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
this.loginSessions.add(loginSession);
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
|
}
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'emailLoginToken',
|
||||||
|
requestArg.email
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
refreshToken: await loginSession.getRefreshToken(),
|
refreshToken,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Validation Token not found');
|
throw new plugins.typedrequest.TypedResponseError('Validation Token not found');
|
||||||
@@ -147,8 +189,11 @@ export class LoginSessionManager {
|
|||||||
|
|
||||||
this.typedRouter.addTypedHandler<plugins.idpInterfaces.request.ILogoutRequest>(
|
this.typedRouter.addTypedHandler<plugins.idpInterfaces.request.ILogoutRequest>(
|
||||||
new plugins.typedrequest.TypedHandler('logout', async (requestDataArg) => {
|
new plugins.typedrequest.TypedHandler('logout', async (requestDataArg) => {
|
||||||
const loginSession = await this.CLoginSession.getLoginSessionByRefreshToken(requestDataArg.refreshToken);
|
const sessionLookup = await this.findLoginSessionByRefreshToken(requestDataArg.refreshToken);
|
||||||
await loginSession.invalidate();
|
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid refresh token');
|
||||||
|
}
|
||||||
|
await sessionLookup.loginSession.invalidate();
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -158,31 +203,39 @@ export class LoginSessionManager {
|
|||||||
'exchangeRefreshTokenAndTransferToken',
|
'exchangeRefreshTokenAndTransferToken',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case !!requestDataArg.refreshToken:
|
case !!requestDataArg.refreshToken: {
|
||||||
const loginSession = await this.loginSessions.find(async (loginSessionArg) => {
|
const sessionLookup = await this.findLoginSessionByRefreshToken(
|
||||||
return loginSessionArg.validateRefreshToken(requestDataArg.refreshToken);
|
requestDataArg.refreshToken
|
||||||
});
|
);
|
||||||
if (!loginSession) {
|
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
|
||||||
|
if (sessionLookup?.validationStatus === 'reused') {
|
||||||
|
await sessionLookup.loginSession.invalidate();
|
||||||
|
}
|
||||||
throw new plugins.typedrequest.TypedResponseError('your refresh token is invalid');
|
throw new plugins.typedrequest.TypedResponseError('your refresh token is invalid');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
transferToken: await loginSession.getTransferToken(),
|
transferToken: await sessionLookup.loginSession.getTransferToken(),
|
||||||
};
|
};
|
||||||
break;
|
}
|
||||||
case !!requestDataArg.transferToken:
|
case !!requestDataArg.transferToken: {
|
||||||
let transferToken: string;
|
const loginSession2 = await this.findLoginSessionByTransferToken(
|
||||||
const loginSession2 = await this.loginSessions.find(async (loginSessionArg) => {
|
requestDataArg.transferToken
|
||||||
return loginSessionArg.validateTransferToken(requestDataArg.transferToken);
|
);
|
||||||
});
|
|
||||||
if (!loginSession2) {
|
if (!loginSession2) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Your transfer token is not valid.'
|
'Your transfer token is not valid.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const refreshToken = await loginSession2.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
refreshToken: await loginSession2.getRefreshToken(),
|
refreshToken,
|
||||||
};
|
};
|
||||||
break;
|
}
|
||||||
|
default:
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid token exchange request');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -192,6 +245,12 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||||
'resetPassword',
|
'resetPassword',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordResetRequest',
|
||||||
|
requestDataArg.email,
|
||||||
|
this.abuseProtectionConfigs.passwordResetRequest,
|
||||||
|
'Too many password reset requests. Please wait before trying again.'
|
||||||
|
);
|
||||||
const emailOfPasswordToReset = requestDataArg.email;
|
const emailOfPasswordToReset = requestDataArg.email;
|
||||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -199,23 +258,13 @@ export class LoginSessionManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
const resetToken = await this.createEmailActionToken(
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
existingUser.data.email,
|
||||||
|
'passwordReset'
|
||||||
);
|
);
|
||||||
this.emailTokenMap.add({
|
|
||||||
email: existingUser.data.email,
|
|
||||||
token: plugins.smartunique.shortId(),
|
|
||||||
action: 'passwordReset',
|
|
||||||
});
|
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
||||||
existingUser,
|
existingUser,
|
||||||
this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
resetToken
|
||||||
.token
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
||||||
@@ -230,6 +279,53 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||||
'setNewPassword',
|
'setNewPassword',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordResetCompletion',
|
||||||
|
requestData.email,
|
||||||
|
this.abuseProtectionConfigs.passwordResetCompletion,
|
||||||
|
'Too many password change attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
data: {
|
||||||
|
email: requestData.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestData.tokenArg) {
|
||||||
|
const tokenObject = await this.consumeEmailActionToken(
|
||||||
|
requestData.email,
|
||||||
|
requestData.tokenArg,
|
||||||
|
'passwordReset'
|
||||||
|
);
|
||||||
|
if (!tokenObject) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Password reset token invalid');
|
||||||
|
}
|
||||||
|
} else if (requestData.oldPassword) {
|
||||||
|
const passwordOk = await this.receptionRef.userManager.CUser.verifyPassword(
|
||||||
|
requestData.oldPassword,
|
||||||
|
user.data.passwordHash
|
||||||
|
);
|
||||||
|
if (!passwordOk) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Old password invalid');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Either a reset token or the old password is required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
|
||||||
|
requestData.newPassword
|
||||||
|
);
|
||||||
|
await user.save();
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'passwordResetCompletion',
|
||||||
|
requestData.email
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
};
|
};
|
||||||
@@ -271,8 +367,7 @@ export class LoginSessionManager {
|
|||||||
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current session's refresh token to identify the current session
|
const currentLoginSession = await jwt.getLoginSession();
|
||||||
const currentRefreshToken = jwt.data.refreshToken;
|
|
||||||
|
|
||||||
// Get all sessions for this user
|
// Get all sessions for this user
|
||||||
const sessions = await this.CLoginSession.getInstances({
|
const sessions = await this.CLoginSession.getInstances({
|
||||||
@@ -290,7 +385,7 @@ export class LoginSessionManager {
|
|||||||
ip: session.data.deviceInfo?.ip || 'Unknown',
|
ip: session.data.deviceInfo?.ip || 'Unknown',
|
||||||
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
|
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
|
||||||
createdAt: session.data.createdAt || Date.now(),
|
createdAt: session.data.createdAt || Date.now(),
|
||||||
isCurrent: session.data.refreshToken === currentRefreshToken,
|
isCurrent: session.id === currentLoginSession?.id,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -317,8 +412,10 @@ export class LoginSessionManager {
|
|||||||
throw new plugins.typedrequest.TypedResponseError('Session not found');
|
throw new plugins.typedrequest.TypedResponseError('Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentLoginSession = await jwt.getLoginSession();
|
||||||
|
|
||||||
// Don't allow revoking the current session via this method
|
// Don't allow revoking the current session via this method
|
||||||
if (sessionToRevoke.data.refreshToken === jwt.data.refreshToken) {
|
if (sessionToRevoke.id === currentLoginSession?.id) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Cannot revoke current session. Use logout instead.'
|
'Cannot revoke current session. Use logout instead.'
|
||||||
);
|
);
|
||||||
@@ -338,4 +435,90 @@ export class LoginSessionManager {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findLoginSessionByRefreshToken(refreshTokenArg: string): Promise<{
|
||||||
|
loginSession: LoginSession;
|
||||||
|
validationStatus: TRefreshTokenValidationResult;
|
||||||
|
} | null> {
|
||||||
|
const directMatch = await this.CLoginSession.getLoginSessionByRefreshToken(refreshTokenArg);
|
||||||
|
if (directMatch) {
|
||||||
|
return {
|
||||||
|
loginSession: directMatch,
|
||||||
|
validationStatus: await directMatch.validateRefreshToken(refreshTokenArg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSessions = await this.CLoginSession.getInstances({});
|
||||||
|
for (const loginSession of loginSessions) {
|
||||||
|
const validationStatus = await loginSession.validateRefreshToken(refreshTokenArg);
|
||||||
|
if (validationStatus !== 'invalid') {
|
||||||
|
return {
|
||||||
|
loginSession,
|
||||||
|
validationStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findLoginSessionByTransferToken(transferTokenArg: string) {
|
||||||
|
const transferTokenHash = await LoginSession.hashSessionToken(transferTokenArg);
|
||||||
|
const loginSession = await this.CLoginSession.getInstance({
|
||||||
|
'data.transferTokenHash': transferTokenHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await loginSession.validateTransferToken(transferTokenArg);
|
||||||
|
return isValid ? loginSession : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createEmailActionToken(
|
||||||
|
emailArg: string,
|
||||||
|
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||||
|
) {
|
||||||
|
const existingTokens = await this.CEmailActionToken.getInstances({
|
||||||
|
'data.email': emailArg,
|
||||||
|
'data.action': actionArg,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const existingToken of existingTokens) {
|
||||||
|
await existingToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainToken = EmailActionToken.createOpaqueToken(actionArg);
|
||||||
|
const emailActionToken = new EmailActionToken();
|
||||||
|
emailActionToken.id = plugins.smartunique.shortId();
|
||||||
|
emailActionToken.data = {
|
||||||
|
email: emailArg,
|
||||||
|
action: actionArg,
|
||||||
|
tokenHash: EmailActionToken.hashToken(plainToken),
|
||||||
|
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
await emailActionToken.save();
|
||||||
|
return plainToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consumeEmailActionToken(
|
||||||
|
emailArg: string,
|
||||||
|
tokenArg: string,
|
||||||
|
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||||
|
) {
|
||||||
|
const emailActionToken = await this.CEmailActionToken.getInstance({
|
||||||
|
'data.email': emailArg,
|
||||||
|
'data.action': actionArg,
|
||||||
|
'data.tokenHash': EmailActionToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailActionToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await emailActionToken.consume(tokenArg);
|
||||||
|
return consumed ? emailActionToken : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAccessToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAccessToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcAccessToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcAccessToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcAccessToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAuthorizationCode extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAuthorizationCode,
|
||||||
|
plugins.idpInterfaces.data.IAuthorizationCode,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashCode(codeArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAuthorizationCode['data'] = {
|
||||||
|
codeHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
redirectUri: '',
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesCode(codeArg: string) {
|
||||||
|
return this.data.codeHash === OidcAuthorizationCode.hashCode(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markUsed() {
|
||||||
|
this.data.used = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { Reception } from './classes.reception.js';
|
||||||
|
import type { App } from './classes.app.js';
|
||||||
|
import { OidcAccessToken } from './classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from './classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcRefreshToken } from './classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from './classes.oidcuserconsent.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OidcManager handles OpenID Connect (OIDC) server functionality
|
||||||
|
* for third-party client authentication.
|
||||||
|
*/
|
||||||
|
export class OidcManager {
|
||||||
|
private readonly abuseProtectionConfig = {
|
||||||
|
oidcTokenExchange: {
|
||||||
|
maxAttempts: 10,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public receptionRef: Reception;
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||||
|
this,
|
||||||
|
OidcAuthorizationCode
|
||||||
|
);
|
||||||
|
|
||||||
|
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||||
|
|
||||||
|
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||||
|
|
||||||
|
public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent);
|
||||||
|
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||||
|
'prepareOidcAuthorization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prepareAuthorizationForUser(jwt.data.userId, requestArg);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||||
|
'completeOidcAuthorization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.completeAuthorizationForUser(jwt.data.userId, requestArg);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.startCleanupTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OIDC Discovery Document
|
||||||
|
*/
|
||||||
|
public getDiscoveryDocument(): plugins.idpInterfaces.data.IOidcDiscoveryDocument {
|
||||||
|
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
|
||||||
|
return {
|
||||||
|
issuer: baseUrl,
|
||||||
|
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
||||||
|
token_endpoint: `${baseUrl}/oauth/token`,
|
||||||
|
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
|
||||||
|
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
||||||
|
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
||||||
|
scopes_supported: ['openid', 'profile', 'email', 'organizations', 'roles'],
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
claims_supported: [
|
||||||
|
'sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce',
|
||||||
|
'name', 'preferred_username', 'picture',
|
||||||
|
'email', 'email_verified',
|
||||||
|
'organizations', 'roles'
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JSON Web Key Set (JWKS)
|
||||||
|
*/
|
||||||
|
public getJwks(): plugins.idpInterfaces.data.IJwks {
|
||||||
|
const keypair = this.receptionRef.jwtManager.smartjwtInstance.getKeyPairAsJson();
|
||||||
|
// Convert PEM to JWK format
|
||||||
|
const jwk = this.pemToJwk(keypair.publicPem);
|
||||||
|
return {
|
||||||
|
keys: [jwk],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PEM public key to JWK format
|
||||||
|
*/
|
||||||
|
private pemToJwk(publicPem: string): plugins.idpInterfaces.data.IJwk {
|
||||||
|
// For now, use a simplified approach - in production, parse the PEM properly
|
||||||
|
// The smartjwt library should provide this, or use crypto.createPublicKey
|
||||||
|
const kid = plugins.smarthash.sha256FromStringSync(publicPem).substring(0, 16);
|
||||||
|
|
||||||
|
// This is a placeholder - proper implementation would extract n and e from PEM
|
||||||
|
// For now, return a minimal structure
|
||||||
|
return {
|
||||||
|
kty: 'RSA',
|
||||||
|
use: 'sig',
|
||||||
|
alg: 'RS256',
|
||||||
|
kid: kid,
|
||||||
|
// These would be extracted from the actual public key
|
||||||
|
n: Buffer.from(publicPem).toString('base64url').substring(0, 256),
|
||||||
|
e: 'AQAB', // Standard RSA exponent (65537)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the authorization endpoint request
|
||||||
|
*/
|
||||||
|
public async handleAuthorize(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
const params = ctx.url.searchParams;
|
||||||
|
|
||||||
|
// Extract authorization request parameters
|
||||||
|
const clientId = params.get('client_id');
|
||||||
|
const redirectUri = params.get('redirect_uri');
|
||||||
|
const responseType = params.get('response_type');
|
||||||
|
const scope = params.get('scope');
|
||||||
|
const state = params.get('state');
|
||||||
|
const codeChallenge = params.get('code_challenge');
|
||||||
|
const codeChallengeMethod = params.get('code_challenge_method');
|
||||||
|
const nonce = params.get('nonce');
|
||||||
|
const prompt = params.get('prompt') as 'none' | 'login' | 'consent' | null;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!clientId || !redirectUri || !responseType || !scope || !state) {
|
||||||
|
return this.errorResponse('invalid_request', 'Missing required parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType !== 'code') {
|
||||||
|
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt && !this.isSupportedPrompt(prompt)) {
|
||||||
|
return this.errorResponse('invalid_request', 'Unsupported prompt value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate code challenge method if present
|
||||||
|
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
||||||
|
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the app by client_id
|
||||||
|
const app = await this.findAppByClientId(clientId);
|
||||||
|
if (!app) {
|
||||||
|
return this.errorResponse('invalid_client', 'Unknown client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirect URI
|
||||||
|
if (!app.data.oauthCredentials.redirectUris.includes(redirectUri)) {
|
||||||
|
return this.errorResponse('invalid_request', 'Invalid redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate scopes
|
||||||
|
const requestedScopes = scope.split(' ') as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const allowedScopes = app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const validScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
||||||
|
|
||||||
|
if (!validScopes.includes('openid')) {
|
||||||
|
return this.errorResponse('invalid_scope', 'openid scope is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, redirect to login page with OAuth parameters
|
||||||
|
// The login page will handle authentication and call back to complete authorization
|
||||||
|
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
|
||||||
|
const loginUrl = new URL(`${baseUrl}/login`);
|
||||||
|
loginUrl.searchParams.set('oauth', 'true');
|
||||||
|
loginUrl.searchParams.set('client_id', clientId);
|
||||||
|
loginUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
loginUrl.searchParams.set('scope', validScopes.join(' '));
|
||||||
|
loginUrl.searchParams.set('state', state);
|
||||||
|
if (codeChallenge) {
|
||||||
|
loginUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
loginUrl.searchParams.set('code_challenge_method', codeChallengeMethod!);
|
||||||
|
}
|
||||||
|
if (nonce) {
|
||||||
|
loginUrl.searchParams.set('nonce', nonce);
|
||||||
|
}
|
||||||
|
if (prompt) {
|
||||||
|
loginUrl.searchParams.set('prompt', prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.redirect(loginUrl.toString(), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an authorization code after user authentication
|
||||||
|
*/
|
||||||
|
public async generateAuthorizationCode(
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
redirectUri: string,
|
||||||
|
codeChallenge?: string,
|
||||||
|
nonce?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const code = this.createOpaqueToken();
|
||||||
|
const authCode = new OidcAuthorizationCode();
|
||||||
|
authCode.id = plugins.smartunique.shortId(12);
|
||||||
|
authCode.data = {
|
||||||
|
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
scopes,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||||
|
nonce,
|
||||||
|
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await authCode.save();
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async prepareAuthorizationForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
requestArg: Omit<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['request'], 'jwt'>
|
||||||
|
): Promise<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response']> {
|
||||||
|
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||||
|
const consentState = await this.evaluateConsentRequirement(
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const),
|
||||||
|
clientId: resolvedRequest.clientId,
|
||||||
|
appName: resolvedRequest.app.data.name,
|
||||||
|
appUrl: resolvedRequest.app.data.appUrl,
|
||||||
|
logoUrl: resolvedRequest.app.data.logoUrl,
|
||||||
|
requestedScopes: resolvedRequest.validScopes,
|
||||||
|
grantedScopes: consentState.grantedScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeAuthorizationForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
requestArg: Omit<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'jwt'>
|
||||||
|
) {
|
||||||
|
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||||
|
const consentState = await this.evaluateConsentRequirement(
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (consentState.consentRequired && !requestArg.consentApproved) {
|
||||||
|
throw new Error('Consent required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.consentApproved) {
|
||||||
|
await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await this.generateAuthorizationCode(
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.redirectUri,
|
||||||
|
resolvedRequest.codeChallenge,
|
||||||
|
resolvedRequest.nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectUrl = new URL(resolvedRequest.redirectUri);
|
||||||
|
redirectUrl.searchParams.set('code', code);
|
||||||
|
redirectUrl.searchParams.set('state', resolvedRequest.state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
redirectUrl: redirectUrl.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the token endpoint request
|
||||||
|
*/
|
||||||
|
public async handleToken(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
// Parse form data
|
||||||
|
const contentType = ctx.headers.get('content-type');
|
||||||
|
if (!contentType?.includes('application/x-www-form-urlencoded')) {
|
||||||
|
return this.tokenErrorResponse('invalid_request', 'Content-Type must be application/x-www-form-urlencoded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await ctx.formData();
|
||||||
|
const grantType = formData.get('grant_type') as string;
|
||||||
|
|
||||||
|
// Extract client credentials from Basic auth or form
|
||||||
|
let clientId = formData.get('client_id') as string;
|
||||||
|
let clientSecret = formData.get('client_secret') as string;
|
||||||
|
|
||||||
|
const authHeader = ctx.headers.get('authorization');
|
||||||
|
if (authHeader?.startsWith('Basic ')) {
|
||||||
|
const base64 = authHeader.substring(6);
|
||||||
|
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
|
||||||
|
const [id, secret] = decoded.split(':');
|
||||||
|
clientId = clientId || id;
|
||||||
|
clientSecret = clientSecret || secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'oidcTokenExchange',
|
||||||
|
clientId,
|
||||||
|
this.abuseProtectionConfig.oidcTokenExchange,
|
||||||
|
'Too many token endpoint attempts. Please wait before retrying.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find and validate app
|
||||||
|
const app = await this.findAppByClientId(clientId);
|
||||||
|
if (!app) {
|
||||||
|
return this.tokenErrorResponse('invalid_client', 'Unknown client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate client secret for confidential clients
|
||||||
|
if (clientSecret) {
|
||||||
|
const secretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
||||||
|
if (secretHash !== app.data.oauthCredentials.clientSecretHash) {
|
||||||
|
return this.tokenErrorResponse('invalid_client', 'Invalid client credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
if (grantType === 'authorization_code') {
|
||||||
|
response = await this.handleAuthorizationCodeGrant(formData, app);
|
||||||
|
} else if (grantType === 'refresh_token') {
|
||||||
|
response = await this.handleRefreshTokenGrant(formData, app);
|
||||||
|
} else {
|
||||||
|
response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle authorization_code grant type
|
||||||
|
*/
|
||||||
|
private async handleAuthorizationCodeGrant(
|
||||||
|
formData: FormData,
|
||||||
|
app: App
|
||||||
|
): Promise<Response> {
|
||||||
|
const code = formData.get('code') as string;
|
||||||
|
const redirectUri = formData.get('redirect_uri') as string;
|
||||||
|
const codeVerifier = formData.get('code_verifier') as string;
|
||||||
|
|
||||||
|
if (!code || !redirectUri) {
|
||||||
|
return this.tokenErrorResponse('invalid_request', 'Missing code or redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and validate authorization code
|
||||||
|
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||||
|
if (!authCode) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.data.used) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.isExpired()) {
|
||||||
|
await authCode.delete();
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.data.redirectUri !== redirectUri) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify PKCE if code challenge was used
|
||||||
|
if (authCode.data.codeChallenge) {
|
||||||
|
if (!codeVerifier) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||||
|
}
|
||||||
|
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||||
|
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark code as used
|
||||||
|
await authCode.markUsed();
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.generateTokens(
|
||||||
|
authCode.data.userId,
|
||||||
|
app.data.oauthCredentials.clientId,
|
||||||
|
authCode.data.scopes,
|
||||||
|
authCode.data.nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(tokens), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle refresh_token grant type
|
||||||
|
*/
|
||||||
|
private async handleRefreshTokenGrant(
|
||||||
|
formData: FormData,
|
||||||
|
app: App
|
||||||
|
): Promise<Response> {
|
||||||
|
const refreshToken = formData.get('refresh_token') as string;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||||
|
|
||||||
|
if (!storedToken) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedToken.data.revoked) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedToken.isExpired()) {
|
||||||
|
await storedToken.delete();
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedToken.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens (without new refresh token by default)
|
||||||
|
const tokens = await this.generateTokens(
|
||||||
|
storedToken.data.userId,
|
||||||
|
storedToken.data.clientId,
|
||||||
|
storedToken.data.scopes,
|
||||||
|
undefined,
|
||||||
|
false // Don't generate new refresh token
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(tokens), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token, ID token, and optionally refresh token
|
||||||
|
*/
|
||||||
|
private async generateTokens(
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
nonce?: string,
|
||||||
|
includeRefreshToken = true
|
||||||
|
): Promise<plugins.idpInterfaces.data.ITokenResponse> {
|
||||||
|
const now = Date.now();
|
||||||
|
const accessTokenLifetime = 3600; // 1 hour
|
||||||
|
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
const accessToken = this.createOpaqueToken();
|
||||||
|
const accessTokenData = new OidcAccessToken();
|
||||||
|
accessTokenData.id = plugins.smartunique.shortId(12);
|
||||||
|
accessTokenData.data = {
|
||||||
|
tokenHash: OidcAccessToken.hashToken(accessToken),
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
scopes,
|
||||||
|
expiresAt: now + accessTokenLifetime * 1000,
|
||||||
|
issuedAt: now,
|
||||||
|
};
|
||||||
|
await accessTokenData.save();
|
||||||
|
|
||||||
|
// Generate ID token (JWT)
|
||||||
|
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||||
|
|
||||||
|
const response: plugins.idpInterfaces.data.ITokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: accessTokenLifetime,
|
||||||
|
id_token: idToken,
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate refresh token if requested
|
||||||
|
if (includeRefreshToken) {
|
||||||
|
const refreshToken = this.createOpaqueToken(48);
|
||||||
|
const refreshTokenData = new OidcRefreshToken();
|
||||||
|
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||||
|
refreshTokenData.data = {
|
||||||
|
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
scopes,
|
||||||
|
expiresAt: now + refreshTokenLifetime * 1000,
|
||||||
|
issuedAt: now,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
await refreshTokenData.save();
|
||||||
|
response.refresh_token = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an ID token (JWT)
|
||||||
|
*/
|
||||||
|
private async generateIdToken(
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
nonce?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const claims: plugins.idpInterfaces.data.IIdTokenClaims = {
|
||||||
|
iss: baseUrl,
|
||||||
|
sub: userId,
|
||||||
|
aud: clientId,
|
||||||
|
exp: now + 3600, // 1 hour
|
||||||
|
iat: now,
|
||||||
|
auth_time: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nonce) {
|
||||||
|
claims.nonce = nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add claims based on scopes
|
||||||
|
if (scopes.includes('profile') || scopes.includes('email') || scopes.includes('organizations') || scopes.includes('roles')) {
|
||||||
|
const userInfo = await this.getUserClaims(userId, scopes, clientId);
|
||||||
|
Object.assign(claims, userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the JWT
|
||||||
|
const idToken = await this.receptionRef.jwtManager.smartjwtInstance.createJWT(claims);
|
||||||
|
return idToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the userinfo endpoint
|
||||||
|
*/
|
||||||
|
public async handleUserInfo(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
// Get access token from Authorization header
|
||||||
|
const authHeader = ctx.headers.get('authorization');
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer error="invalid_token"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = authHeader.substring(7);
|
||||||
|
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||||
|
|
||||||
|
if (!tokenData) {
|
||||||
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer error="invalid_token"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.isExpired()) {
|
||||||
|
await tokenData.delete();
|
||||||
|
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer error="invalid_token", error_description="Token expired"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user claims based on token scopes
|
||||||
|
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes, tokenData.data.clientId);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(userInfo), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user claims based on scopes
|
||||||
|
*/
|
||||||
|
private async getUserClaims(
|
||||||
|
userId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
clientId?: string
|
||||||
|
): Promise<plugins.idpInterfaces.data.IUserInfoResponse> {
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({ id: userId });
|
||||||
|
if (!user) {
|
||||||
|
return { sub: userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims: plugins.idpInterfaces.data.IUserInfoResponse = {
|
||||||
|
sub: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile scope
|
||||||
|
if (scopes.includes('profile')) {
|
||||||
|
claims.name = user.data?.name;
|
||||||
|
claims.preferred_username = user.data?.username;
|
||||||
|
// claims.picture = user.data?.avatarUrl; // If avatar exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email scope
|
||||||
|
if (scopes.includes('email')) {
|
||||||
|
claims.email = user.data?.email;
|
||||||
|
claims.email_verified = user.data?.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organizations scope (custom)
|
||||||
|
if (scopes.includes('organizations')) {
|
||||||
|
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user);
|
||||||
|
const roles = await this.receptionRef.roleManager.getAllRolesForUser(user);
|
||||||
|
if (organizations) {
|
||||||
|
claims.organizations = organizations.map(org => ({
|
||||||
|
id: org.id,
|
||||||
|
name: org.data?.name || '',
|
||||||
|
slug: org.data?.slug || '',
|
||||||
|
roles: roles
|
||||||
|
.find(r => r.data?.organizationId === org.id)?.data?.roles || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles scope (custom - global roles)
|
||||||
|
if (scopes.includes('roles')) {
|
||||||
|
const roles: string[] = ['user'];
|
||||||
|
if (user.data?.isGlobalAdmin) {
|
||||||
|
roles.push('admin');
|
||||||
|
}
|
||||||
|
claims.roles = roles;
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
Object.assign(claims, await this.getMappedAppClaims(user, clientId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMappedAppClaims(userArg: any, clientIdArg: string) {
|
||||||
|
const app = await this.findAppByClientId(clientIdArg);
|
||||||
|
if (!app) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||||
|
'data.appId': app.id,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
const memberRoles = await this.receptionRef.roleManager.getAllRolesForUser(userArg);
|
||||||
|
const appRoles = new Set<string>();
|
||||||
|
const appPermissions = new Set<string>();
|
||||||
|
const appScopes = new Set<string>();
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
const memberRole = memberRoles.find((roleArg) => roleArg.data.organizationId === connection.data.organizationId);
|
||||||
|
if (!memberRole) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const mapping of connection.data.roleMappings || []) {
|
||||||
|
if (!memberRole.data.roles.includes(mapping.orgRoleKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const appRole of mapping.appRoles || []) appRoles.add(appRole);
|
||||||
|
for (const permission of mapping.permissions || []) appPermissions.add(permission);
|
||||||
|
for (const scope of mapping.scopes || []) appScopes.add(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app_roles: [...appRoles],
|
||||||
|
app_permissions: [...appPermissions],
|
||||||
|
app_scopes: [...appScopes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the revocation endpoint
|
||||||
|
*/
|
||||||
|
public async handleRevoke(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
const formData = await ctx.formData();
|
||||||
|
const token = formData.get('token') as string;
|
||||||
|
const tokenTypeHint = formData.get('token_type_hint') as string;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to revoke as refresh token
|
||||||
|
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||||
|
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||||
|
if (refreshToken) {
|
||||||
|
await refreshToken.revoke();
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to revoke as access token
|
||||||
|
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||||
|
const accessToken = await this.getAccessTokenByToken(token);
|
||||||
|
if (accessToken) {
|
||||||
|
await accessToken.delete();
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token not found - still return 200 per spec
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an app by its OAuth client_id
|
||||||
|
*/
|
||||||
|
private async findAppByClientId(clientId: string): Promise<App | null> {
|
||||||
|
const apps = await this.receptionRef.appManager.CApp.getInstances({
|
||||||
|
'data.oauthCredentials.clientId': clientId,
|
||||||
|
});
|
||||||
|
return apps[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' {
|
||||||
|
return ['none', 'login', 'consent'].includes(promptArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAuthorizationRequest(
|
||||||
|
requestArg: Pick<
|
||||||
|
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||||
|
'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce'
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) {
|
||||||
|
throw new Error('Missing required OAuth authorization parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) {
|
||||||
|
throw new Error('Unsupported prompt value');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') {
|
||||||
|
throw new Error('Only S256 code challenge method is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.findAppByClientId(requestArg.clientId);
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('Unknown client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) {
|
||||||
|
throw new Error('Invalid redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedScopes = requestArg.scope
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const allowedScopes =
|
||||||
|
app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg));
|
||||||
|
|
||||||
|
if (!validScopes.includes('openid')) {
|
||||||
|
throw new Error('openid scope is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
clientId: requestArg.clientId,
|
||||||
|
redirectUri: requestArg.redirectUri,
|
||||||
|
state: requestArg.state,
|
||||||
|
prompt: requestArg.prompt,
|
||||||
|
codeChallenge: requestArg.codeChallenge,
|
||||||
|
codeChallengeMethod: requestArg.codeChallengeMethod,
|
||||||
|
nonce: requestArg.nonce,
|
||||||
|
validScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateConsentRequirement(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
promptArg?: 'none' | 'login' | 'consent'
|
||||||
|
) {
|
||||||
|
const existingConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
const grantedScopes = existingConsent?.data.scopes || [];
|
||||||
|
const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg));
|
||||||
|
|
||||||
|
return {
|
||||||
|
grantedScopes,
|
||||||
|
missingScopes,
|
||||||
|
consentRequired: promptArg === 'consent' || missingScopes.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOpaqueToken(byteLength = 32): string {
|
||||||
|
return plugins.crypto.randomBytes(byteLength).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthorizationCodeByCode(codeArg: string) {
|
||||||
|
return this.COidcAuthorizationCode.getInstance({
|
||||||
|
'data.codeHash': OidcAuthorizationCode.hashCode(codeArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcAccessToken.getInstance({
|
||||||
|
'data.tokenHash': OidcAccessToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRefreshTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcRefreshToken.getInstance({
|
||||||
|
'data.tokenHash': OidcRefreshToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserConsent(userIdArg: string, clientIdArg: string) {
|
||||||
|
return this.COidcUserConsent.getInstance({
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.clientId': clientIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsertUserConsent(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[]
|
||||||
|
) {
|
||||||
|
let userConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
|
||||||
|
if (!userConsent) {
|
||||||
|
userConsent = new OidcUserConsent();
|
||||||
|
userConsent.id = plugins.smartunique.shortId(12);
|
||||||
|
userConsent.data.userId = userIdArg;
|
||||||
|
userConsent.data.clientId = clientIdArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
await userConsent.grantScopes(scopesArg);
|
||||||
|
return userConsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate S256 PKCE challenge from verifier
|
||||||
|
*/
|
||||||
|
private generateS256Challenge(verifier: string): string {
|
||||||
|
const hash = plugins.smarthash.sha256FromStringSync(verifier);
|
||||||
|
return Buffer.from(hash, 'hex').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error response for authorization endpoint
|
||||||
|
*/
|
||||||
|
private errorResponse(error: string, description: string): Response {
|
||||||
|
return new Response(JSON.stringify({ error, error_description: description }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error response for token endpoint
|
||||||
|
*/
|
||||||
|
private tokenErrorResponse(
|
||||||
|
error: plugins.idpInterfaces.data.ITokenErrorResponse['error'],
|
||||||
|
description: string
|
||||||
|
): Response {
|
||||||
|
const body: plugins.idpInterfaces.data.ITokenErrorResponse = {
|
||||||
|
error,
|
||||||
|
error_description: description,
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start cleanup task for expired tokens/codes
|
||||||
|
*/
|
||||||
|
private startCleanupTask(): void {
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
void this.cleanupExpiredOidcState();
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupExpiredOidcState() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||||
|
data: {
|
||||||
|
expiresAt: {
|
||||||
|
$lt: now,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const authCode of expiredAuthorizationCodes) {
|
||||||
|
await authCode.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||||
|
data: {
|
||||||
|
expiresAt: {
|
||||||
|
$lt: now,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const accessToken of expiredAccessTokens) {
|
||||||
|
await accessToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredRefreshTokens = await this.COidcRefreshToken.getInstances({
|
||||||
|
data: {
|
||||||
|
expiresAt: {
|
||||||
|
$lt: now,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const refreshToken of expiredRefreshTokens) {
|
||||||
|
await refreshToken.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcRefreshToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcRefreshToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcRefreshToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcRefreshToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcRefreshToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revoke() {
|
||||||
|
this.data.revoked = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcUserConsent extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcUserConsent,
|
||||||
|
plugins.idpInterfaces.data.IUserConsent,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IUserConsent['data'] = {
|
||||||
|
userId: '',
|
||||||
|
clientId: '',
|
||||||
|
scopes: [],
|
||||||
|
grantedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async grantScopes(scopesArg: plugins.idpInterfaces.data.TOidcScope[]) {
|
||||||
|
this.data.scopes = [...new Set([...this.data.scopes, ...scopesArg])];
|
||||||
|
if (!this.data.grantedAt) {
|
||||||
|
this.data.grantedAt = Date.now();
|
||||||
|
}
|
||||||
|
this.data.updatedAt = Date.now();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
slug: slugNameArg,
|
slug: slugNameArg,
|
||||||
billingPlanId: null,
|
billingPlanId: null,
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
|
roleDefinitions: [],
|
||||||
}
|
}
|
||||||
await newOrg.save();
|
await newOrg.save();
|
||||||
return newOrg;
|
return newOrg;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Organization } from './classes.organization.js';
|
|||||||
import { User } from './classes.user.js';
|
import { User } from './classes.user.js';
|
||||||
|
|
||||||
export class OrganizationManager {
|
export class OrganizationManager {
|
||||||
|
public static readonly platformRoleKeys = ['owner', 'admin', 'editor', 'viewer', 'guest', 'outlaw'];
|
||||||
|
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
public get db() {
|
public get db() {
|
||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
@@ -93,6 +95,476 @@ export class OrganizationManager {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateOrganization>(
|
||||||
|
'updateOrganization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
|
const organization = await this.updateOrganizationWithAudit({
|
||||||
|
user,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
name: requestArg.name,
|
||||||
|
slug: requestArg.slug,
|
||||||
|
confirmationText: requestArg.confirmationText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
organization: await organization.createSavableObject(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteOrganization>(
|
||||||
|
'deleteOrganization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
|
await this.deleteOrganizationWithAudit({
|
||||||
|
user,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
confirmationText: requestArg.confirmationText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deletedOrganizationId: requestArg.organizationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgRoleDefinitions>(
|
||||||
|
'getOrgRoleDefinitions',
|
||||||
|
async (requestArg) => {
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
|
const organization = await this.getOrganizationOrThrow(requestArg.organizationId);
|
||||||
|
await this.getRoleOrThrow(user, organization);
|
||||||
|
return {
|
||||||
|
roleDefinitions: this.getCustomRoleDefinitions(organization),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>(
|
||||||
|
'upsertOrgRoleDefinition',
|
||||||
|
async (requestArg) => {
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
|
const roleDefinitions = await this.upsertOrgRoleDefinition({
|
||||||
|
user,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
roleDefinition: requestArg.roleDefinition,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
roleDefinitions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>(
|
||||||
|
'deleteOrgRoleDefinition',
|
||||||
|
async (requestArg) => {
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
|
const roleDefinitions = await this.deleteOrgRoleDefinition({
|
||||||
|
user,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
roleKey: requestArg.roleKey,
|
||||||
|
confirmationText: requestArg.confirmationText,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
roleDefinitions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCustomRoleDefinitions(organizationArg: Organization) {
|
||||||
|
return organizationArg.data.roleDefinitions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRoleKey(roleKeyArg: string) {
|
||||||
|
return (roleKeyArg || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateRoleKey(roleKeyArg: string) {
|
||||||
|
const roleKey = this.normalizeRoleKey(roleKeyArg);
|
||||||
|
if (!roleKey || roleKey.length < 2 || roleKey.length > 64) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Role key must be between 2 and 64 characters.');
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(roleKey)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Role key may only contain lowercase letters, numbers, and single dashes.');
|
||||||
|
}
|
||||||
|
return roleKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAvailableRoleKeys(organizationIdArg: string) {
|
||||||
|
const organization = await this.getOrganizationOrThrow(organizationIdArg);
|
||||||
|
return [
|
||||||
|
...OrganizationManager.platformRoleKeys,
|
||||||
|
...this.getCustomRoleDefinitions(organization).map((roleDefinitionArg) => roleDefinitionArg.key),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertRoleKeysAreValid(organizationIdArg: string, roleKeysArg: string[]) {
|
||||||
|
const normalizedRoleKeys = [...new Set((roleKeysArg || []).map((roleKeyArg) => this.validateRoleKey(roleKeyArg)))];
|
||||||
|
if (!normalizedRoleKeys.length) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('At least one role is required.');
|
||||||
|
}
|
||||||
|
const availableRoleKeys = await this.getAvailableRoleKeys(organizationIdArg);
|
||||||
|
const invalidRoleKeys = normalizedRoleKeys.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg));
|
||||||
|
if (invalidRoleKeys.length) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${invalidRoleKeys.join(', ')}.`);
|
||||||
|
}
|
||||||
|
return normalizedRoleKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSlug(slugArg: string) {
|
||||||
|
return (slugArg || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateSlug(slugArg: string) {
|
||||||
|
const slug = this.normalizeSlug(slugArg);
|
||||||
|
if (!slug || slug.length < 3 || slug.length > 64) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization slug must be between 3 and 64 characters.');
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization slug may only contain lowercase letters, numbers, and single dashes.');
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfirmation(confirmationTextArg: string, expectedTextArg: string) {
|
||||||
|
if ((confirmationTextArg || '').trim() !== expectedTextArg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`Confirmation text must be exactly "${expectedTextArg}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrganizationOrThrow(organizationIdArg: string) {
|
||||||
|
const organization = await this.COrganization.getInstance({
|
||||||
|
id: organizationIdArg,
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found.');
|
||||||
|
}
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRoleOrThrow(userArg: User, organizationArg: Organization) {
|
||||||
|
const role = await this.receptionRef.roleManager.getRoleForUserAndOrg(userArg, organizationArg);
|
||||||
|
if (!role) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not authorized for this organization.');
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyAdmin(userArg: User, organizationArg: Organization) {
|
||||||
|
const role = await this.getRoleOrThrow(userArg, organizationArg);
|
||||||
|
if (!role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required.');
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyOwner(userArg: User, organizationArg: Organization) {
|
||||||
|
const role = await this.getRoleOrThrow(userArg, organizationArg);
|
||||||
|
if (!role.data.roles.includes('owner')) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization owner privileges required.');
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitOrganizationAlert(optionsArg: {
|
||||||
|
organizationId: string;
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actorUserId: string;
|
||||||
|
relatedEntityId?: string;
|
||||||
|
relatedEntityType?: string;
|
||||||
|
}) {
|
||||||
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
severity: optionsArg.severity,
|
||||||
|
title: optionsArg.title,
|
||||||
|
body: optionsArg.body,
|
||||||
|
actorUserId: optionsArg.actorUserId,
|
||||||
|
relatedEntityId: optionsArg.relatedEntityId,
|
||||||
|
relatedEntityType: optionsArg.relatedEntityType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsertOrgRoleDefinition(optionsArg: {
|
||||||
|
user: User;
|
||||||
|
organizationId: string;
|
||||||
|
roleDefinition: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||||
|
await this.verifyAdmin(optionsArg.user, organization);
|
||||||
|
const roleKey = this.validateRoleKey(optionsArg.roleDefinition.key);
|
||||||
|
if (OrganizationManager.platformRoleKeys.includes(roleKey)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be redefined by an organization.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleName = (optionsArg.roleDefinition.name || '').trim();
|
||||||
|
if (!roleName) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Role name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const roleDefinitions = this.getCustomRoleDefinitions(organization);
|
||||||
|
const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === roleKey);
|
||||||
|
if (existingRoleDefinition) {
|
||||||
|
existingRoleDefinition.name = roleName;
|
||||||
|
existingRoleDefinition.description = optionsArg.roleDefinition.description?.trim() || '';
|
||||||
|
existingRoleDefinition.updatedAt = now;
|
||||||
|
} else {
|
||||||
|
roleDefinitions.push({
|
||||||
|
key: roleKey,
|
||||||
|
name: roleName,
|
||||||
|
description: optionsArg.roleDefinition.description?.trim() || '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
organization.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name));
|
||||||
|
await organization.save();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
optionsArg.user.id,
|
||||||
|
'role_changed',
|
||||||
|
`${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`,
|
||||||
|
{
|
||||||
|
targetId: organization.id,
|
||||||
|
targetType: 'organization-role',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: organization.id,
|
||||||
|
eventType: 'org_role_definition_updated',
|
||||||
|
severity: 'medium',
|
||||||
|
title: 'Organization role definition updated',
|
||||||
|
body: `${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`,
|
||||||
|
actorUserId: optionsArg.user.id,
|
||||||
|
relatedEntityId: roleKey,
|
||||||
|
relatedEntityType: 'organization-role',
|
||||||
|
});
|
||||||
|
|
||||||
|
return organization.data.roleDefinitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteOrgRoleDefinition(optionsArg: {
|
||||||
|
user: User;
|
||||||
|
organizationId: string;
|
||||||
|
roleKey: string;
|
||||||
|
confirmationText: string;
|
||||||
|
}) {
|
||||||
|
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||||
|
await this.verifyAdmin(optionsArg.user, organization);
|
||||||
|
const roleKey = this.validateRoleKey(optionsArg.roleKey);
|
||||||
|
if (OrganizationManager.platformRoleKeys.includes(roleKey)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be deleted.');
|
||||||
|
}
|
||||||
|
this.assertConfirmation(optionsArg.confirmationText, `delete role ${roleKey}`);
|
||||||
|
|
||||||
|
const roleDefinitions = this.getCustomRoleDefinitions(organization);
|
||||||
|
if (!roleDefinitions.some((roleDefinitionArg) => roleDefinitionArg.key === roleKey)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization role definition not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
organization.data.roleDefinitions = roleDefinitions.filter((roleDefinitionArg) => roleDefinitionArg.key !== roleKey);
|
||||||
|
await organization.save();
|
||||||
|
|
||||||
|
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id);
|
||||||
|
for (const role of roles) {
|
||||||
|
if (role.data.roles.includes(roleKey)) {
|
||||||
|
role.data.roles = role.data.roles.filter((roleKeyArg) => roleKeyArg !== roleKey);
|
||||||
|
if (!role.data.roles.length) {
|
||||||
|
role.data.roles = ['viewer'];
|
||||||
|
}
|
||||||
|
await role.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||||
|
'data.organizationId': organization.id,
|
||||||
|
});
|
||||||
|
for (const connection of appConnections) {
|
||||||
|
if (connection.data.roleMappings?.some((mappingArg) => mappingArg.orgRoleKey === roleKey)) {
|
||||||
|
connection.data.roleMappings = connection.data.roleMappings.filter((mappingArg) => mappingArg.orgRoleKey !== roleKey);
|
||||||
|
await connection.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
optionsArg.user.id,
|
||||||
|
'role_changed',
|
||||||
|
`${optionsArg.user.data.email} deleted organization role ${roleKey}.`,
|
||||||
|
{
|
||||||
|
targetId: organization.id,
|
||||||
|
targetType: 'organization-role',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: organization.id,
|
||||||
|
eventType: 'org_role_definition_deleted',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Organization role definition deleted',
|
||||||
|
body: `${optionsArg.user.data.email} deleted organization role ${roleKey}. Member assignments and app mappings were cleaned up.`,
|
||||||
|
actorUserId: optionsArg.user.id,
|
||||||
|
relatedEntityId: roleKey,
|
||||||
|
relatedEntityType: 'organization-role',
|
||||||
|
});
|
||||||
|
|
||||||
|
return organization.data.roleDefinitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateOrganizationWithAudit(optionsArg: {
|
||||||
|
user: User;
|
||||||
|
organizationId: string;
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
confirmationText: string;
|
||||||
|
}) {
|
||||||
|
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||||
|
await this.verifyAdmin(optionsArg.user, organization);
|
||||||
|
this.assertConfirmation(optionsArg.confirmationText, organization.data.slug);
|
||||||
|
|
||||||
|
const previousName = organization.data.name;
|
||||||
|
const previousSlug = organization.data.slug;
|
||||||
|
const nextName = typeof optionsArg.name === 'string' ? optionsArg.name.trim() : previousName;
|
||||||
|
const nextSlug = typeof optionsArg.slug === 'string' ? this.validateSlug(optionsArg.slug) : previousSlug;
|
||||||
|
|
||||||
|
if (!nextName) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSlug !== previousSlug) {
|
||||||
|
const existingOrganization = await this.COrganization.getInstance({
|
||||||
|
data: {
|
||||||
|
slug: nextSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existingOrganization && existingOrganization.id !== organization.id) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization slug is already in use.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
organization.data.name = nextName;
|
||||||
|
organization.data.slug = nextSlug;
|
||||||
|
await organization.save();
|
||||||
|
|
||||||
|
const changes = [
|
||||||
|
previousName !== nextName ? `name "${previousName}" -> "${nextName}"` : '',
|
||||||
|
previousSlug !== nextSlug ? `slug "${previousSlug}" -> "${nextSlug}"` : '',
|
||||||
|
].filter(Boolean).join(', ') || 'no field changes';
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
optionsArg.user.id,
|
||||||
|
'org_updated',
|
||||||
|
`Organization ${previousName} updated: ${changes}.`,
|
||||||
|
{
|
||||||
|
targetId: organization.id,
|
||||||
|
targetType: 'organization',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: organization.id,
|
||||||
|
eventType: 'org_updated',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Organization settings updated',
|
||||||
|
body: `${optionsArg.user.data.email} updated ${previousName}: ${changes}.`,
|
||||||
|
actorUserId: optionsArg.user.id,
|
||||||
|
relatedEntityId: organization.id,
|
||||||
|
relatedEntityType: 'organization',
|
||||||
|
});
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteOrganizationWithAudit(optionsArg: {
|
||||||
|
user: User;
|
||||||
|
organizationId: string;
|
||||||
|
confirmationText: string;
|
||||||
|
}) {
|
||||||
|
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||||
|
await this.verifyOwner(optionsArg.user, organization);
|
||||||
|
this.assertConfirmation(optionsArg.confirmationText, `delete ${organization.data.slug}`);
|
||||||
|
|
||||||
|
const organizationName = organization.data.name;
|
||||||
|
const organizationSlug = organization.data.slug;
|
||||||
|
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id);
|
||||||
|
const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||||
|
'data.organizationId': organization.id,
|
||||||
|
});
|
||||||
|
const invitations = await this.receptionRef.userInvitationManager.CUserInvitation.getInstances({});
|
||||||
|
const billingPlans = await this.receptionRef.billingPlanManager.CBillingPlan.getInstances({
|
||||||
|
'data.organizationId': organization.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
optionsArg.user.id,
|
||||||
|
'org_deleted',
|
||||||
|
`Organization ${organizationName} (${organizationSlug}) deleted.`,
|
||||||
|
{
|
||||||
|
targetId: organization.id,
|
||||||
|
targetType: 'organization',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: organization.id,
|
||||||
|
eventType: 'org_deleted',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Organization deleted',
|
||||||
|
body: `${optionsArg.user.data.email} deleted ${organizationName}. ${roles.length} memberships and ${appConnections.length} app connections were removed.`,
|
||||||
|
actorUserId: optionsArg.user.id,
|
||||||
|
relatedEntityId: organization.id,
|
||||||
|
relatedEntityType: 'organization',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const connection of appConnections) {
|
||||||
|
await connection.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const invitation of invitations) {
|
||||||
|
if (invitation.data.organizationRefs.some((refArg) => refArg.organizationId === organization.id)) {
|
||||||
|
await invitation.removeOrganization(organization.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const billingPlan of billingPlans) {
|
||||||
|
await billingPlan.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
id: role.data.userId,
|
||||||
|
});
|
||||||
|
if (memberUser?.data.connectedOrgs) {
|
||||||
|
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||||
|
(organizationIdArg) => organizationIdArg !== organization.id
|
||||||
|
);
|
||||||
|
await memberUser.save();
|
||||||
|
}
|
||||||
|
await role.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await organization.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAllOrganizationsForUser(
|
public async getAllOrganizationsForUser(
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { PassportManager } from './classes.passportmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class PassportChallenge extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
PassportChallenge,
|
||||||
|
plugins.idpInterfaces.data.IPassportChallenge,
|
||||||
|
PassportManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IPassportChallenge['data'] = {
|
||||||
|
userId: '',
|
||||||
|
deviceId: null,
|
||||||
|
type: 'device_enrollment',
|
||||||
|
status: 'pending',
|
||||||
|
tokenHash: null,
|
||||||
|
challenge: '',
|
||||||
|
metadata: {
|
||||||
|
originHost: undefined,
|
||||||
|
audience: undefined,
|
||||||
|
notificationTitle: undefined,
|
||||||
|
deviceLabel: undefined,
|
||||||
|
requireLocation: false,
|
||||||
|
requireNfc: false,
|
||||||
|
locationPolicy: undefined,
|
||||||
|
requestedCapabilities: undefined,
|
||||||
|
},
|
||||||
|
evidence: undefined,
|
||||||
|
notification: undefined,
|
||||||
|
createdAt: 0,
|
||||||
|
expiresAt: 0,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.expiresAt < nowArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markApproved(
|
||||||
|
evidenceArg?: plugins.idpInterfaces.data.IPassportChallenge['data']['evidence']
|
||||||
|
) {
|
||||||
|
this.data.status = 'approved';
|
||||||
|
this.data.completedAt = Date.now();
|
||||||
|
this.data.evidence = evidenceArg;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markExpired() {
|
||||||
|
this.data.status = 'expired';
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markRejected() {
|
||||||
|
this.data.status = 'rejected';
|
||||||
|
this.data.completedAt = Date.now();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { PassportManager } from './classes.passportmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class PassportDevice extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
PassportDevice,
|
||||||
|
plugins.idpInterfaces.data.IPassportDevice,
|
||||||
|
PassportManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IPassportDevice['data'] = {
|
||||||
|
userId: '',
|
||||||
|
label: '',
|
||||||
|
platform: 'unknown',
|
||||||
|
status: 'active',
|
||||||
|
publicKeyAlgorithm: 'p256',
|
||||||
|
publicKeyX963Base64: '',
|
||||||
|
capabilities: {
|
||||||
|
gps: false,
|
||||||
|
nfc: false,
|
||||||
|
push: false,
|
||||||
|
},
|
||||||
|
pushRegistration: undefined,
|
||||||
|
appVersion: undefined,
|
||||||
|
createdAt: 0,
|
||||||
|
lastSeenAt: undefined,
|
||||||
|
lastChallengeAt: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isActive() {
|
||||||
|
return this.data.status === 'active';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,959 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { PassportChallenge } from './classes.passportchallenge.js';
|
||||||
|
import { PassportDevice } from './classes.passportdevice.js';
|
||||||
|
import { PassportNonce } from './classes.passportnonce.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
import { Reception } from './classes.reception.js';
|
||||||
|
|
||||||
|
export class PassportManager {
|
||||||
|
private readonly enrollmentChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||||
|
minutes: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly assertionChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||||
|
minutes: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly deviceRequestWindowMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||||
|
minutes: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
public CPassportDevice = plugins.smartdata.setDefaultManagerForDoc(this, PassportDevice);
|
||||||
|
public CPassportChallenge = plugins.smartdata.setDefaultManagerForDoc(this, PassportChallenge);
|
||||||
|
public CPassportNonce = plugins.smartdata.setDefaultManagerForDoc(this, PassportNonce);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportEnrollmentChallenge>(
|
||||||
|
'createPassportEnrollmentChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
const enrollmentChallenge = await this.createEnrollmentChallengeForUser(userId, {
|
||||||
|
deviceLabel: requestArg.deviceLabel,
|
||||||
|
platform: requestArg.platform,
|
||||||
|
appVersion: requestArg.appVersion,
|
||||||
|
capabilities: requestArg.capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
challengeId: enrollmentChallenge.challenge.id,
|
||||||
|
pairingToken: enrollmentChallenge.pairingToken,
|
||||||
|
pairingPayload: enrollmentChallenge.pairingPayload,
|
||||||
|
signingPayload: enrollmentChallenge.signingPayload,
|
||||||
|
expiresAt: enrollmentChallenge.challenge.data.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompletePassportEnrollment>(
|
||||||
|
'completePassportEnrollment',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.completeEnrollment({
|
||||||
|
pairingToken: requestArg.pairingToken,
|
||||||
|
deviceLabel: requestArg.deviceLabel,
|
||||||
|
platform: requestArg.platform,
|
||||||
|
publicKeyX963Base64: requestArg.publicKeyX963Base64,
|
||||||
|
signatureBase64: requestArg.signatureBase64,
|
||||||
|
signatureFormat: requestArg.signatureFormat,
|
||||||
|
appVersion: requestArg.appVersion,
|
||||||
|
capabilities: requestArg.capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
device: {
|
||||||
|
id: passportDevice.id,
|
||||||
|
data: passportDevice.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDevices>(
|
||||||
|
'getPassportDevices',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
const devices = await this.getPassportDevicesForUser(userId);
|
||||||
|
return {
|
||||||
|
devices: devices.map((deviceArg) => ({
|
||||||
|
id: deviceArg.id,
|
||||||
|
data: deviceArg.data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokePassportDevice>(
|
||||||
|
'revokePassportDevice',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
await this.revokePassportDeviceForUser(userId, requestArg.deviceId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportChallenge>(
|
||||||
|
'createPassportChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
const challengeResult = await this.createPassportChallengeForUser(userId, {
|
||||||
|
type: requestArg.type,
|
||||||
|
preferredDeviceId: requestArg.preferredDeviceId,
|
||||||
|
audience: requestArg.audience,
|
||||||
|
notificationTitle: requestArg.notificationTitle,
|
||||||
|
requireLocation: requestArg.requireLocation,
|
||||||
|
requireNfc: requestArg.requireNfc,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
challengeId: challengeResult.challenge.id,
|
||||||
|
challenge: challengeResult.challenge.data.challenge,
|
||||||
|
signingPayload: challengeResult.signingPayload,
|
||||||
|
deviceId: challengeResult.challenge.data.deviceId!,
|
||||||
|
expiresAt: challengeResult.challenge.data.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDashboard>(
|
||||||
|
'getPassportDashboard',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'getPassportDashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
id: passportDevice.data.userId,
|
||||||
|
});
|
||||||
|
const organizations = user
|
||||||
|
? await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user)
|
||||||
|
: [];
|
||||||
|
const devices = await this.getPassportDevicesForUser(passportDevice.data.userId);
|
||||||
|
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
|
||||||
|
const alerts = await this.receptionRef.alertManager.listAlertsForUser(passportDevice.data.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
userId: passportDevice.data.userId,
|
||||||
|
name: user?.data?.name || user?.data?.email || 'Passport User',
|
||||||
|
handle: user?.data?.username || user?.data?.email || passportDevice.data.userId,
|
||||||
|
organizations: organizations.map((organizationArg) => ({
|
||||||
|
id: organizationArg.id,
|
||||||
|
name: organizationArg.data.name,
|
||||||
|
})),
|
||||||
|
deviceCount: devices.length,
|
||||||
|
recoverySummary: 'Recovery workflows are not configured yet for this passport.',
|
||||||
|
},
|
||||||
|
devices: devices.map((deviceArg) => ({ id: deviceArg.id, data: deviceArg.data })),
|
||||||
|
challenges: challenges.map((challengeArg) => ({
|
||||||
|
challenge: { id: challengeArg.id, data: challengeArg.data },
|
||||||
|
signingPayload: this.buildChallengeSigningPayload(challengeArg),
|
||||||
|
})),
|
||||||
|
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ApprovePassportChallenge>(
|
||||||
|
'approvePassportChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportChallenge = await this.approvePassportChallenge({
|
||||||
|
challengeId: requestArg.challengeId,
|
||||||
|
deviceId: requestArg.deviceId,
|
||||||
|
signatureBase64: requestArg.signatureBase64,
|
||||||
|
signatureFormat: requestArg.signatureFormat,
|
||||||
|
location: requestArg.location,
|
||||||
|
nfc: requestArg.nfc,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
challenge: {
|
||||||
|
id: passportChallenge.id,
|
||||||
|
data: passportChallenge.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RejectPassportChallenge>(
|
||||||
|
'rejectPassportChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'rejectPassportChallenge',
|
||||||
|
signedFields: [`challenge_id=${requestArg.challengeId}`],
|
||||||
|
});
|
||||||
|
const challenge = await this.rejectPassportChallenge(passportDevice.id, requestArg.challengeId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
challenge: {
|
||||||
|
id: challenge.id,
|
||||||
|
data: challenge.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegisterPassportPushToken>(
|
||||||
|
'registerPassportPushToken',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'registerPassportPushToken',
|
||||||
|
signedFields: [
|
||||||
|
`provider=${requestArg.provider}`,
|
||||||
|
`token=${requestArg.token}`,
|
||||||
|
`topic=${requestArg.topic}`,
|
||||||
|
`environment=${requestArg.environment}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
passportDevice.data.pushRegistration = {
|
||||||
|
provider: requestArg.provider,
|
||||||
|
token: requestArg.token,
|
||||||
|
topic: requestArg.topic,
|
||||||
|
environment: requestArg.environment,
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
lastDeliveredAt: passportDevice.data.pushRegistration?.lastDeliveredAt,
|
||||||
|
lastError: undefined,
|
||||||
|
};
|
||||||
|
passportDevice.data.lastSeenAt = Date.now();
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPendingPassportChallenges>(
|
||||||
|
'listPendingPassportChallenges',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'listPendingPassportChallenges',
|
||||||
|
});
|
||||||
|
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
|
||||||
|
return {
|
||||||
|
challenges: challenges.map((challengeArg) => ({
|
||||||
|
id: challengeArg.id,
|
||||||
|
data: challengeArg.data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportChallengeByHint>(
|
||||||
|
'getPassportChallengeByHint',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'getPassportChallengeByHint',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
});
|
||||||
|
const passportChallenge = await this.getPassportChallengeByHint(passportDevice.id, requestArg.hintId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: passportChallenge
|
||||||
|
? {
|
||||||
|
challenge: {
|
||||||
|
id: passportChallenge.id,
|
||||||
|
data: passportChallenge.data,
|
||||||
|
},
|
||||||
|
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportChallengeSeen>(
|
||||||
|
'markPassportChallengeSeen',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'markPassportChallengeSeen',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
});
|
||||||
|
await this.markPassportChallengeSeen(passportDevice.id, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthenticatedUserId(jwtArg: string) {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOriginHost() {
|
||||||
|
return new URL(this.receptionRef.options.baseUrl).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOpaqueToken(prefixArg: string) {
|
||||||
|
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDeviceRequestSigningPayload(
|
||||||
|
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
|
||||||
|
actionArg: string,
|
||||||
|
signedFieldsArg: string[] = []
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'purpose=passport-device-request',
|
||||||
|
`origin=${this.getOriginHost()}`,
|
||||||
|
`action=${actionArg}`,
|
||||||
|
`device_id=${requestArg.deviceId}`,
|
||||||
|
`timestamp=${requestArg.timestamp}`,
|
||||||
|
`nonce=${requestArg.nonce}`,
|
||||||
|
...signedFieldsArg,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumePassportNonce(deviceIdArg: string, nonceArg: string, timestampArg: number) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (Math.abs(now - timestampArg) > this.deviceRequestWindowMillis) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device request timestamp expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNonce = await this.CPassportNonce.getInstance({
|
||||||
|
id: PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`),
|
||||||
|
});
|
||||||
|
if (existingNonce && !existingNonce.isExpired(now)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device request replay detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportNonce = existingNonce || new PassportNonce();
|
||||||
|
passportNonce.id = PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`);
|
||||||
|
passportNonce.data = {
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
nonceHash: PassportNonce.hashNonce(nonceArg),
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + this.deviceRequestWindowMillis,
|
||||||
|
};
|
||||||
|
await passportNonce.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authenticatePassportDeviceRequest(
|
||||||
|
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
|
||||||
|
optionsArg: {
|
||||||
|
action: string;
|
||||||
|
signedFields?: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: requestArg.deviceId,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (!passportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = this.verifyPassportSignature(
|
||||||
|
passportDevice.data.publicKeyX963Base64,
|
||||||
|
requestArg.signatureBase64,
|
||||||
|
requestArg.signatureFormat || 'raw',
|
||||||
|
this.buildDeviceRequestSigningPayload(
|
||||||
|
requestArg,
|
||||||
|
optionsArg.action,
|
||||||
|
optionsArg.signedFields || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device signature invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumePassportNonce(requestArg.deviceId, requestArg.nonce, requestArg.timestamp);
|
||||||
|
passportDevice.data.lastSeenAt = Date.now();
|
||||||
|
await passportDevice.save();
|
||||||
|
return passportDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCapabilities(
|
||||||
|
capabilitiesArg?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>
|
||||||
|
): plugins.idpInterfaces.data.IPassportCapabilities {
|
||||||
|
return {
|
||||||
|
gps: !!capabilitiesArg?.gps,
|
||||||
|
nfc: !!capabilitiesArg?.nfc,
|
||||||
|
push: !!capabilitiesArg?.push,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEnrollmentSigningPayload(pairingTokenArg: string, challengeArg: PassportChallenge) {
|
||||||
|
return [
|
||||||
|
'purpose=passport-enrollment',
|
||||||
|
`origin=${this.getOriginHost()}`,
|
||||||
|
`token=${pairingTokenArg}`,
|
||||||
|
`challenge=${challengeArg.data.challenge}`,
|
||||||
|
`challenge_id=${challengeArg.id}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildChallengeSigningPayload(challengeArg: PassportChallenge) {
|
||||||
|
return [
|
||||||
|
'purpose=passport-challenge',
|
||||||
|
`origin=${this.getOriginHost()}`,
|
||||||
|
`challenge=${challengeArg.data.challenge}`,
|
||||||
|
`challenge_id=${challengeArg.id}`,
|
||||||
|
`type=${challengeArg.data.type}`,
|
||||||
|
`device_id=${challengeArg.data.deviceId || ''}`,
|
||||||
|
`audience=${challengeArg.data.metadata.audience || ''}`,
|
||||||
|
`require_location=${challengeArg.data.metadata.requireLocation}`,
|
||||||
|
`require_nfc=${challengeArg.data.metadata.requireNfc}`,
|
||||||
|
`location_policy=${challengeArg.data.metadata.locationPolicy ? JSON.stringify(challengeArg.data.metadata.locationPolicy) : ''}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateLocationPolicy(
|
||||||
|
locationPolicyArg: plugins.idpInterfaces.data.IPassportLocationPolicy,
|
||||||
|
locationEvidenceArg: plugins.idpInterfaces.data.IPassportLocationEvidence
|
||||||
|
) {
|
||||||
|
const earthRadiusMeters = 6371000;
|
||||||
|
const latitude1 = (locationPolicyArg.latitude * Math.PI) / 180;
|
||||||
|
const latitude2 = (locationEvidenceArg.latitude * Math.PI) / 180;
|
||||||
|
const deltaLatitude = ((locationEvidenceArg.latitude - locationPolicyArg.latitude) * Math.PI) / 180;
|
||||||
|
const deltaLongitude = ((locationEvidenceArg.longitude - locationPolicyArg.longitude) * Math.PI) / 180;
|
||||||
|
|
||||||
|
const haversine =
|
||||||
|
Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) +
|
||||||
|
Math.cos(latitude1) * Math.cos(latitude2) * Math.sin(deltaLongitude / 2) * Math.sin(deltaLongitude / 2);
|
||||||
|
const distanceMeters = 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
|
||||||
|
|
||||||
|
const accuracyAccepted =
|
||||||
|
!locationPolicyArg.maxAccuracyMeters ||
|
||||||
|
locationEvidenceArg.accuracyMeters <= locationPolicyArg.maxAccuracyMeters;
|
||||||
|
const withinGeofence = distanceMeters <= locationPolicyArg.radiusMeters;
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: accuracyAccepted && withinGeofence,
|
||||||
|
distanceMeters,
|
||||||
|
accuracyAccepted,
|
||||||
|
evaluatedAt: Date.now(),
|
||||||
|
reason: !accuracyAccepted
|
||||||
|
? `Accuracy ${locationEvidenceArg.accuracyMeters}m exceeds allowed ${locationPolicyArg.maxAccuracyMeters}m`
|
||||||
|
: !withinGeofence
|
||||||
|
? `Location is ${Math.round(distanceMeters)}m away from ${locationPolicyArg.label || 'required area'}`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPairingPayload(
|
||||||
|
pairingTokenArg: string,
|
||||||
|
challengeArg: PassportChallenge,
|
||||||
|
deviceLabelArg: string
|
||||||
|
) {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
token: pairingTokenArg,
|
||||||
|
challenge: challengeArg.data.challenge,
|
||||||
|
challenge_id: challengeArg.id,
|
||||||
|
origin: this.getOriginHost(),
|
||||||
|
device: deviceLabelArg,
|
||||||
|
});
|
||||||
|
return `idp.global://pair?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createP256JwkFromX963(publicKeyX963Base64Arg: string) {
|
||||||
|
const rawPublicKey = Buffer.from(publicKeyX963Base64Arg, 'base64');
|
||||||
|
if (rawPublicKey.length !== 65 || rawPublicKey[0] !== 4) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid passport public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kty: 'EC',
|
||||||
|
crv: 'P-256',
|
||||||
|
x: rawPublicKey.subarray(1, 33).toString('base64url'),
|
||||||
|
y: rawPublicKey.subarray(33, 65).toString('base64url'),
|
||||||
|
ext: true,
|
||||||
|
} as JsonWebKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyPassportSignature(
|
||||||
|
publicKeyX963Base64Arg: string,
|
||||||
|
signatureBase64Arg: string,
|
||||||
|
signatureFormatArg: plugins.idpInterfaces.data.TPassportSignatureFormat,
|
||||||
|
payloadArg: string
|
||||||
|
) {
|
||||||
|
const publicKey = plugins.crypto.createPublicKey({
|
||||||
|
key: this.createP256JwkFromX963(publicKeyX963Base64Arg),
|
||||||
|
format: 'jwk',
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = Buffer.from(signatureBase64Arg, 'base64');
|
||||||
|
const payload = Buffer.from(payloadArg, 'utf8');
|
||||||
|
|
||||||
|
return signatureFormatArg === 'raw'
|
||||||
|
? plugins.crypto.verify('sha256', payload, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature)
|
||||||
|
: plugins.crypto.verify('sha256', payload, publicKey, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createEnrollmentChallengeForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
optionsArg: {
|
||||||
|
deviceLabel: string;
|
||||||
|
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
|
||||||
|
appVersion?: string;
|
||||||
|
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const pairingToken = this.createOpaqueToken('passport_pair_');
|
||||||
|
const passportChallenge = new PassportChallenge();
|
||||||
|
passportChallenge.id = plugins.smartunique.shortId();
|
||||||
|
passportChallenge.data = {
|
||||||
|
userId: userIdArg,
|
||||||
|
deviceId: null,
|
||||||
|
type: 'device_enrollment',
|
||||||
|
status: 'pending',
|
||||||
|
tokenHash: PassportChallenge.hashToken(pairingToken),
|
||||||
|
challenge: this.createOpaqueToken('challenge_'),
|
||||||
|
metadata: {
|
||||||
|
originHost: this.getOriginHost(),
|
||||||
|
deviceLabel: optionsArg.deviceLabel,
|
||||||
|
requireLocation: false,
|
||||||
|
requireNfc: false,
|
||||||
|
locationPolicy: undefined,
|
||||||
|
requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities),
|
||||||
|
},
|
||||||
|
evidence: undefined,
|
||||||
|
notification: undefined,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + this.enrollmentChallengeMillis,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
await passportChallenge.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: passportChallenge,
|
||||||
|
pairingToken,
|
||||||
|
pairingPayload: this.createPairingPayload(
|
||||||
|
pairingToken,
|
||||||
|
passportChallenge,
|
||||||
|
optionsArg.deviceLabel
|
||||||
|
),
|
||||||
|
signingPayload: this.buildEnrollmentSigningPayload(pairingToken, passportChallenge),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeEnrollment(optionsArg: {
|
||||||
|
pairingToken: string;
|
||||||
|
deviceLabel: string;
|
||||||
|
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
|
||||||
|
publicKeyX963Base64: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
|
||||||
|
appVersion?: string;
|
||||||
|
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
|
||||||
|
}) {
|
||||||
|
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||||
|
'data.tokenHash': PassportChallenge.hashToken(optionsArg.pairingToken),
|
||||||
|
'data.type': 'device_enrollment',
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Pairing token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Pairing token expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPassportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
'data.publicKeyX963Base64': optionsArg.publicKeyX963Base64,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (existingPassportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device already enrolled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = this.verifyPassportSignature(
|
||||||
|
optionsArg.publicKeyX963Base64,
|
||||||
|
optionsArg.signatureBase64,
|
||||||
|
optionsArg.signatureFormat || 'raw',
|
||||||
|
this.buildEnrollmentSigningPayload(optionsArg.pairingToken, passportChallenge)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportDevice = new PassportDevice();
|
||||||
|
passportDevice.id = plugins.smartunique.shortId();
|
||||||
|
passportDevice.data = {
|
||||||
|
userId: passportChallenge.data.userId,
|
||||||
|
label: optionsArg.deviceLabel,
|
||||||
|
platform: optionsArg.platform,
|
||||||
|
status: 'active',
|
||||||
|
publicKeyAlgorithm: 'p256',
|
||||||
|
publicKeyX963Base64: optionsArg.publicKeyX963Base64,
|
||||||
|
capabilities: this.normalizeCapabilities(
|
||||||
|
optionsArg.capabilities || passportChallenge.data.metadata.requestedCapabilities
|
||||||
|
),
|
||||||
|
pushRegistration: undefined,
|
||||||
|
appVersion: optionsArg.appVersion,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
lastChallengeAt: undefined,
|
||||||
|
};
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
passportChallenge.data.deviceId = passportDevice.id;
|
||||||
|
passportChallenge.data.tokenHash = null;
|
||||||
|
await passportChallenge.markApproved({
|
||||||
|
signatureFormat: optionsArg.signatureFormat || 'raw',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
passportChallenge.data.userId,
|
||||||
|
'passport_device_enrolled',
|
||||||
|
`Enrolled passport device ${passportDevice.data.label}`,
|
||||||
|
{
|
||||||
|
targetId: passportDevice.id,
|
||||||
|
targetType: 'passport-device',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return passportDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPassportDevicesForUser(userIdArg: string) {
|
||||||
|
const devices = await this.CPassportDevice.getInstances({
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
return devices.sort(
|
||||||
|
(leftArg, rightArg) =>
|
||||||
|
(rightArg.data.lastSeenAt || rightArg.data.createdAt) -
|
||||||
|
(leftArg.data.lastSeenAt || leftArg.data.createdAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revokePassportDeviceForUser(userIdArg: string, deviceIdArg: string) {
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: deviceIdArg,
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
passportDevice.data.status = 'revoked';
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
userIdArg,
|
||||||
|
'passport_device_revoked',
|
||||||
|
`Revoked passport device ${passportDevice.data.label}`,
|
||||||
|
{
|
||||||
|
targetId: passportDevice.id,
|
||||||
|
targetType: 'passport-device',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createPassportChallengeForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
optionsArg: {
|
||||||
|
type?: Exclude<plugins.idpInterfaces.data.TPassportChallengeType, 'device_enrollment'>;
|
||||||
|
preferredDeviceId?: string;
|
||||||
|
audience?: string;
|
||||||
|
notificationTitle?: string;
|
||||||
|
requireLocation?: boolean;
|
||||||
|
requireNfc?: boolean;
|
||||||
|
locationPolicy?: plugins.idpInterfaces.data.IPassportLocationPolicy;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const passportDevices = await this.getPassportDevicesForUser(userIdArg);
|
||||||
|
if (passportDevices.length === 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No passport device enrolled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDevice = optionsArg.preferredDeviceId
|
||||||
|
? passportDevices.find((deviceArg) => deviceArg.id === optionsArg.preferredDeviceId)
|
||||||
|
: passportDevices[0];
|
||||||
|
|
||||||
|
if (!targetDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Target passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportChallenge = new PassportChallenge();
|
||||||
|
passportChallenge.id = plugins.smartunique.shortId();
|
||||||
|
passportChallenge.data = {
|
||||||
|
userId: userIdArg,
|
||||||
|
deviceId: targetDevice.id,
|
||||||
|
type: optionsArg.type || 'step_up',
|
||||||
|
status: 'pending',
|
||||||
|
tokenHash: null,
|
||||||
|
challenge: this.createOpaqueToken('passport_challenge_'),
|
||||||
|
metadata: {
|
||||||
|
originHost: this.getOriginHost(),
|
||||||
|
audience: optionsArg.audience,
|
||||||
|
notificationTitle: optionsArg.notificationTitle,
|
||||||
|
deviceLabel: targetDevice.data.label,
|
||||||
|
requireLocation: !!optionsArg.requireLocation || !!optionsArg.locationPolicy,
|
||||||
|
requireNfc: !!optionsArg.requireNfc,
|
||||||
|
locationPolicy: optionsArg.locationPolicy,
|
||||||
|
},
|
||||||
|
evidence: undefined,
|
||||||
|
notification: {
|
||||||
|
hintId: plugins.crypto.randomUUID(),
|
||||||
|
status: 'pending',
|
||||||
|
attemptCount: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deliveredAt: null,
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + this.assertionChallengeMillis,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
await passportChallenge.save();
|
||||||
|
|
||||||
|
targetDevice.data.lastChallengeAt = Date.now();
|
||||||
|
await targetDevice.save();
|
||||||
|
|
||||||
|
await this.receptionRef.passportPushManager.deliverChallengeHint(targetDevice, passportChallenge);
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: passportChallenge,
|
||||||
|
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async approvePassportChallenge(optionsArg: {
|
||||||
|
challengeId: string;
|
||||||
|
deviceId: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
|
||||||
|
location?: plugins.idpInterfaces.data.IPassportLocationEvidence;
|
||||||
|
nfc?: plugins.idpInterfaces.data.IPassportNfcEvidence;
|
||||||
|
}) {
|
||||||
|
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||||
|
id: optionsArg.challengeId,
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.data.deviceId && passportChallenge.data.deviceId !== optionsArg.deviceId) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not assigned to this device');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: optionsArg.deviceId,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (!passportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportDevice.data.userId !== passportChallenge.data.userId) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device user mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.data.metadata.requireLocation && !optionsArg.location) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Location evidence required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.data.metadata.requireNfc && !optionsArg.nfc) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('NFC evidence required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = this.verifyPassportSignature(
|
||||||
|
passportDevice.data.publicKeyX963Base64,
|
||||||
|
optionsArg.signatureBase64,
|
||||||
|
optionsArg.signatureFormat || 'raw',
|
||||||
|
this.buildChallengeSigningPayload(passportChallenge)
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationEvaluation =
|
||||||
|
passportChallenge.data.metadata.locationPolicy && optionsArg.location
|
||||||
|
? this.evaluateLocationPolicy(passportChallenge.data.metadata.locationPolicy, optionsArg.location)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (passportChallenge.data.metadata.locationPolicy && !locationEvaluation?.matched) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
locationEvaluation?.reason || 'Location evidence did not satisfy the office policy'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await passportChallenge.markApproved({
|
||||||
|
signatureFormat: optionsArg.signatureFormat || 'raw',
|
||||||
|
location: optionsArg.location,
|
||||||
|
locationEvaluation,
|
||||||
|
nfc: optionsArg.nfc,
|
||||||
|
});
|
||||||
|
|
||||||
|
passportDevice.data.lastSeenAt = Date.now();
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
passportChallenge.data.userId,
|
||||||
|
'passport_challenge_approved',
|
||||||
|
`Approved passport challenge ${passportChallenge.data.type}`,
|
||||||
|
{
|
||||||
|
targetId: passportChallenge.id,
|
||||||
|
targetType: 'passport-challenge',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return passportChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rejectPassportChallenge(deviceIdArg: string, challengeIdArg: string) {
|
||||||
|
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||||
|
id: challengeIdArg,
|
||||||
|
'data.deviceId': deviceIdArg,
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
await passportChallenge.markRejected();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
passportChallenge.data.userId,
|
||||||
|
'passport_challenge_rejected',
|
||||||
|
`Rejected passport challenge ${passportChallenge.data.type}`,
|
||||||
|
{
|
||||||
|
targetId: passportChallenge.id,
|
||||||
|
targetType: 'passport-challenge',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return passportChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listPendingChallengesForDevice(deviceIdArg: string) {
|
||||||
|
const passportChallenges = await this.CPassportChallenge.getInstances({
|
||||||
|
'data.deviceId': deviceIdArg,
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
return passportChallenges.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPassportChallengeByHint(deviceIdArg: string, hintIdArg: string) {
|
||||||
|
return this.CPassportChallenge.getInstance({
|
||||||
|
'data.deviceId': deviceIdArg,
|
||||||
|
'data.status': 'pending',
|
||||||
|
'data.notification.hintId': hintIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markPassportChallengeSeen(deviceIdArg: string, hintIdArg: string) {
|
||||||
|
const passportChallenge = await this.getPassportChallengeByHint(deviceIdArg, hintIdArg);
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
passportChallenge.data.notification = {
|
||||||
|
...passportChallenge.data.notification!,
|
||||||
|
status: 'seen',
|
||||||
|
seenAt: Date.now(),
|
||||||
|
};
|
||||||
|
await passportChallenge.save();
|
||||||
|
return passportChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cleanupExpiredChallenges() {
|
||||||
|
const passportChallenges = await this.CPassportChallenge.getInstances({});
|
||||||
|
for (const passportChallenge of passportChallenges) {
|
||||||
|
if (passportChallenge.data.status === 'pending' && passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportNonces = await this.CPassportNonce.getInstances({});
|
||||||
|
for (const passportNonce of passportNonces) {
|
||||||
|
if (passportNonce.isExpired()) {
|
||||||
|
await passportNonce.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reDeliverPendingChallengeHints() {
|
||||||
|
const passportChallenges = await this.CPassportChallenge.getInstances({
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
for (const passportChallenge of passportChallenges) {
|
||||||
|
if (!passportChallenge.data.notification || passportChallenge.data.notification.status === 'sent') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passportChallenge.data.deviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: passportChallenge.data.deviceId,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (!passportDevice) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.receptionRef.passportPushManager.deliverChallengeHint(passportDevice, passportChallenge);
|
||||||
|
} catch (errorArg) {
|
||||||
|
logger.log('warn', `passport hint redelivery failed: ${(errorArg as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { PassportManager } from './classes.passportmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class PassportNonce extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
PassportNonce,
|
||||||
|
plugins.idpInterfaces.data.IPassportNonce,
|
||||||
|
PassportManager
|
||||||
|
> {
|
||||||
|
public static hashNonce(nonceArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(nonceArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IPassportNonce['data'] = {
|
||||||
|
deviceId: '',
|
||||||
|
nonceHash: '',
|
||||||
|
createdAt: 0,
|
||||||
|
expiresAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.expiresAt < nowArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Alert } from './classes.alert.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
import { PassportChallenge } from './classes.passportchallenge.js';
|
||||||
|
import { PassportDevice } from './classes.passportdevice.js';
|
||||||
|
import type { Reception } from './classes.reception.js';
|
||||||
|
|
||||||
|
interface IApnsConfig {
|
||||||
|
keyId: string;
|
||||||
|
teamId: string;
|
||||||
|
privateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PassportPushManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getApnsConfig(): Promise<IApnsConfig | null> {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
keyId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_KEY_ID'),
|
||||||
|
teamId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_TEAM_ID'),
|
||||||
|
privateKey: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_PRIVATE_KEY'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64UrlEncode(valueArg: string | Buffer) {
|
||||||
|
return Buffer.from(valueArg).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createApnsJwt(configArg: IApnsConfig) {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const header = this.base64UrlEncode(
|
||||||
|
JSON.stringify({ alg: 'ES256', kid: configArg.keyId, typ: 'JWT' })
|
||||||
|
);
|
||||||
|
const payload = this.base64UrlEncode(JSON.stringify({ iss: configArg.teamId, iat: nowSeconds }));
|
||||||
|
const unsignedToken = `${header}.${payload}`;
|
||||||
|
const signature = plugins.crypto.sign('sha256', Buffer.from(unsignedToken, 'utf8'), {
|
||||||
|
key: configArg.privateKey.replace(/\\n/g, '\n'),
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
});
|
||||||
|
return `${unsignedToken}.${this.base64UrlEncode(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverApnsPayload(
|
||||||
|
passportDeviceArg: PassportDevice,
|
||||||
|
payloadArg: Record<string, any>
|
||||||
|
) {
|
||||||
|
if (!passportDeviceArg.data.pushRegistration) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
text: async () => 'Passport device has no push registration',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apnsConfig = await this.getApnsConfig();
|
||||||
|
if (!apnsConfig) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
text: async () => 'APNs push transport is not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushRegistration = passportDeviceArg.data.pushRegistration;
|
||||||
|
const apnsHost =
|
||||||
|
pushRegistration.environment === 'production'
|
||||||
|
? 'https://api.push.apple.com'
|
||||||
|
: 'https://api.sandbox.push.apple.com';
|
||||||
|
const authorizationToken = this.createApnsJwt(apnsConfig);
|
||||||
|
return fetch(`${apnsHost}/3/device/${pushRegistration.token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `bearer ${authorizationToken}`,
|
||||||
|
'apns-topic': pushRegistration.topic,
|
||||||
|
'apns-push-type': 'alert',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payloadArg),
|
||||||
|
}).catch((errorArg: Error) => {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
text: async () => errorArg.message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deliverChallengeHint(passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) {
|
||||||
|
if (!passportDeviceArg.data.pushRegistration) {
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
lastError: 'Passport device has no push registration',
|
||||||
|
};
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.getApnsConfig())) {
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
lastError: 'APNs push transport is not configured',
|
||||||
|
};
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
logger.log('warn', 'passport push delivery skipped because APNs is not configured');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.deliverApnsPayload(passportDeviceArg, {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: passportChallengeArg.data.metadata.notificationTitle || 'idp.global challenge',
|
||||||
|
body: `Open idp.global to review your ${passportChallengeArg.data.type} request.`,
|
||||||
|
},
|
||||||
|
sound: 'default',
|
||||||
|
},
|
||||||
|
kind: 'passport_challenge',
|
||||||
|
hintId: passportChallengeArg.data.notification?.hintId,
|
||||||
|
challengeId: passportChallengeArg.id,
|
||||||
|
severity:
|
||||||
|
passportChallengeArg.data.type === 'physical_access' ? 'high' : passportChallengeArg.data.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (response.ok) {
|
||||||
|
passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now();
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = undefined;
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`;
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
lastError: responseText || `APNs error ${response.status}`,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
logger.log('warn', `passport push delivery failed: ${responseText || response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deliverAlertHint(passportDeviceArg: PassportDevice, alertArg: Alert) {
|
||||||
|
if (!passportDeviceArg.data.pushRegistration) {
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
lastError: 'Passport device has no push registration',
|
||||||
|
};
|
||||||
|
await alertArg.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.getApnsConfig())) {
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
lastError: 'APNs push transport is not configured',
|
||||||
|
};
|
||||||
|
await alertArg.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.deliverApnsPayload(passportDeviceArg, {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: alertArg.data.title,
|
||||||
|
body: alertArg.data.body,
|
||||||
|
},
|
||||||
|
sound: 'default',
|
||||||
|
},
|
||||||
|
kind: 'passport_alert',
|
||||||
|
hintId: alertArg.data.notification.hintId,
|
||||||
|
alertId: alertArg.id,
|
||||||
|
severity: alertArg.data.severity,
|
||||||
|
eventType: alertArg.data.eventType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (response.ok) {
|
||||||
|
passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now();
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = undefined;
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await alertArg.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`;
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
lastError: responseText || `APNs error ${response.status}`,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await alertArg.save();
|
||||||
|
logger.log('warn', `passport alert push delivery failed: ${responseText || response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { logger } from './logging.js';
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
import { JwtManager } from './classes.jwtmanager.js';
|
import { JwtManager } from './classes.jwtmanager.js';
|
||||||
@@ -17,6 +16,11 @@ import { AppManager } from './classes.appmanager.js';
|
|||||||
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||||
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||||
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||||
|
import { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||||
|
import { AlertManager } from './classes.alertmanager.js';
|
||||||
|
import { PassportManager } from './classes.passportmanager.js';
|
||||||
|
import { PassportPushManager } from './classes.passportpushmanager.js';
|
||||||
|
|
||||||
export interface IReceptionOptions {
|
export interface IReceptionOptions {
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +33,6 @@ export interface IReceptionOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Reception {
|
export class Reception {
|
||||||
public projectinfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
||||||
public szPlatformClient = new plugins.szPlatformClient.SzPlatformClient();
|
public szPlatformClient = new plugins.szPlatformClient.SzPlatformClient();
|
||||||
@@ -48,7 +51,12 @@ export class Reception {
|
|||||||
public appManager = new AppManager(this);
|
public appManager = new AppManager(this);
|
||||||
public appConnectionManager = new AppConnectionManager(this);
|
public appConnectionManager = new AppConnectionManager(this);
|
||||||
public activityLogManager = new ActivityLogManager(this);
|
public activityLogManager = new ActivityLogManager(this);
|
||||||
|
public alertManager = new AlertManager(this);
|
||||||
public userInvitationManager = new UserInvitationManager(this);
|
public userInvitationManager = new UserInvitationManager(this);
|
||||||
|
public abuseProtectionManager = new AbuseProtectionManager(this);
|
||||||
|
public passportPushManager = new PassportPushManager(this);
|
||||||
|
public passportManager = new PassportManager(this);
|
||||||
|
public oidcManager = new OidcManager(this);
|
||||||
housekeeping = new ReceptionHousekeeping(this);
|
housekeeping = new ReceptionHousekeeping(this);
|
||||||
|
|
||||||
constructor(public options: IReceptionOptions) {
|
constructor(public options: IReceptionOptions) {
|
||||||
@@ -64,13 +72,15 @@ export class Reception {
|
|||||||
* starts the reception instance
|
* starts the reception instance
|
||||||
*/
|
*/
|
||||||
public async start() {
|
public async start() {
|
||||||
await this.szPlatformClient.init(await this.serviceQenv.getEnvVarOnDemand('SERVEZONE_PLATFROM_AUTHORIZATION'));
|
const serveZoneAuthorization = await this.serviceQenv.getEnvVarOnDemand('SERVEZONE_PLATFORM_AUTHORIZATION');
|
||||||
|
await this.szPlatformClient.init(serveZoneAuthorization || 'test');
|
||||||
logger.log('info', 'starting reception');
|
logger.log('info', 'starting reception');
|
||||||
logger.log('info', 'adding typedrouter to website server');
|
logger.log('info', 'adding typedrouter to website server');
|
||||||
this.options.websiteServer.typedrouter.addTypedRouter(this.typedrouter);
|
this.options.websiteServer.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
logger.log('info', 'starting database');
|
logger.log('info', 'starting database');
|
||||||
await this.db.start();
|
await this.db.start();
|
||||||
await this.jwtManager.start();
|
await this.jwtManager.start();
|
||||||
|
await this.housekeeping.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +88,7 @@ export class Reception {
|
|||||||
*/
|
*/
|
||||||
public async stop() {
|
public async stop() {
|
||||||
await this.housekeeping.stop();
|
await this.housekeeping.stop();
|
||||||
|
await this.oidcManager.stop();
|
||||||
console.log('stopped serviceserver!');
|
console.log('stopped serviceserver!');
|
||||||
await this.db.stop();
|
await this.db.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export class ReceptionDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
console.log(this.receptionRef.options.mongoDescriptor);
|
|
||||||
this.smartdataDb = new plugins.smartdata.SmartdataDb(this.receptionRef.options.mongoDescriptor);
|
this.smartdataDb = new plugins.smartdata.SmartdataDb(this.receptionRef.options.mongoDescriptor);
|
||||||
await this.smartdataDb.init();
|
await this.smartdataDb.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,71 +5,63 @@ import { logger } from './logging.js';
|
|||||||
import { User } from './classes.user.js';
|
import { User } from './classes.user.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a RegistrationSession is a in memory session for signing up
|
* a RegistrationSession persists a sign up flow across restarts
|
||||||
*/
|
*/
|
||||||
export class RegistrationSession {
|
@plugins.smartdata.Manager()
|
||||||
// ======
|
export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
|
||||||
// STATIC
|
RegistrationSession,
|
||||||
// ======
|
plugins.idpInterfaces.data.IRegistrationSession,
|
||||||
|
RegistrationSessionManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
public static async createRegistrationSessionForEmail(
|
public static async createRegistrationSessionForEmail(
|
||||||
registrationSessionManageremailArg: RegistrationSessionManager,
|
|
||||||
emailArg: string
|
emailArg: string
|
||||||
) {
|
) {
|
||||||
const newRegistrationSession = new RegistrationSession(
|
const newRegistrationSession = new RegistrationSession();
|
||||||
registrationSessionManageremailArg,
|
newRegistrationSession.id = plugins.smartunique.shortId();
|
||||||
emailArg
|
newRegistrationSession.data.emailAddress = emailArg;
|
||||||
);
|
newRegistrationSession.data.validUntil =
|
||||||
const emailValidationResult = await newRegistrationSession
|
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
|
||||||
.validateEMailAddress()
|
newRegistrationSession.data.createdAt = Date.now();
|
||||||
.catch((error) => {
|
|
||||||
|
const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Error occured during email provider & dns validation'
|
'Error occured during email provider & dns validation'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!emailValidationResult?.valid) {
|
if (!emailValidationResult?.valid) {
|
||||||
newRegistrationSession.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Email Address is not valid. Please use a correctly formated email address'
|
'Email Address is not valid. Please use a correctly formated email address'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (emailValidationResult.disposable) {
|
if (emailValidationResult.disposable) {
|
||||||
newRegistrationSession.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Email is disposable. Please use a non disposable email address.'
|
'Email is disposable. Please use a non disposable email address.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!`
|
const validationToken = await newRegistrationSession.sendTokenValidationEmail();
|
||||||
);
|
newRegistrationSession.unhashedEmailToken = validationToken;
|
||||||
await newRegistrationSession.sendTokenValidationEmail();
|
|
||||||
console.log(`Successfully sent email validation email`);
|
|
||||||
return newRegistrationSession;
|
return newRegistrationSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========
|
@plugins.smartdata.unI()
|
||||||
// INSTANCE
|
public id: string;
|
||||||
// ========
|
|
||||||
public registrationSessionManagerRef: RegistrationSessionManager;
|
|
||||||
|
|
||||||
public emailAddress: string;
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IRegistrationSession['data'] = {
|
||||||
/**
|
emailAddress: '',
|
||||||
* only used during testing
|
hashedEmailToken: '',
|
||||||
*/
|
smsCodeHash: null,
|
||||||
public unhashedEmailToken?: string;
|
smsvalidationCounter: 0,
|
||||||
public hashedEmailToken: string;
|
status: 'announced',
|
||||||
private smsvalidationCounter = 0;
|
validUntil: 0,
|
||||||
public smsCode: string;
|
createdAt: 0,
|
||||||
|
collectedData: {
|
||||||
/**
|
|
||||||
* the status of the registration. should progress in a linear fashion.
|
|
||||||
*/
|
|
||||||
public status: 'announced' | 'emailValidated' | 'mobileVerified' | 'registered' | 'failed' =
|
|
||||||
'announced';
|
|
||||||
|
|
||||||
public collectedData: {
|
|
||||||
userData: plugins.idpInterfaces.data.IUser['data'];
|
|
||||||
} = {
|
|
||||||
userData: {
|
userData: {
|
||||||
username: null,
|
username: null,
|
||||||
connectedOrgs: [],
|
connectedOrgs: [],
|
||||||
@@ -80,116 +72,120 @@ export class RegistrationSession {
|
|||||||
password: null,
|
password: null,
|
||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
/**
|
||||||
registrationSessionManagerRefArg: RegistrationSessionManager,
|
* only used during testing
|
||||||
emailAddressArg: string
|
*/
|
||||||
) {
|
public unhashedEmailToken?: string;
|
||||||
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
|
|
||||||
this.emailAddress = emailAddressArg;
|
|
||||||
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
|
|
||||||
|
|
||||||
// lets destroy this after 10 minutes,
|
public get emailAddress() {
|
||||||
// works in unrefed mode so not blocking node exiting.
|
return this.data.emailAddress;
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy());
|
}
|
||||||
|
|
||||||
|
public get status() {
|
||||||
|
return this.data.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
|
||||||
|
this.data.status = statusArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get collectedData() {
|
||||||
|
return this.data.collectedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.validUntil < Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validates a token by comparing its hash against the stored hashed token
|
* validates a token by comparing its hash against the stored hashed token
|
||||||
* @param tokenArg
|
|
||||||
*/
|
*/
|
||||||
public validateEmailToken(tokenArg: string): boolean {
|
public async validateEmailToken(tokenArg: string): Promise<boolean> {
|
||||||
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg);
|
if (this.isExpired()) {
|
||||||
if (result && this.status === 'announced') {
|
await this.destroy();
|
||||||
this.status = 'emailValidated';
|
return false;
|
||||||
this.collectedData.userData.email = this.emailAddress;
|
|
||||||
}
|
}
|
||||||
if (!result && this.status === 'announced') {
|
|
||||||
this.status = 'failed';
|
const result = this.data.hashedEmailToken === RegistrationSession.hashToken(tokenArg);
|
||||||
|
if (result && this.data.status === 'announced') {
|
||||||
|
this.data.status = 'emailValidated';
|
||||||
|
this.data.collectedData.userData.email = this.data.emailAddress;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
if (!result && this.data.status === 'announced') {
|
||||||
|
this.data.status = 'failed';
|
||||||
|
await this.save();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** validates the sms code */
|
/** validates the sms code */
|
||||||
public validateSmsCode(smsCodeArg: string) {
|
public async validateSmsCode(smsCodeArg: string) {
|
||||||
this.smsvalidationCounter++;
|
this.data.smsvalidationCounter++;
|
||||||
const result = this.smsCode === smsCodeArg;
|
const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
|
||||||
if (this.status === 'emailValidated' && result) {
|
if (this.data.status === 'emailValidated' && result) {
|
||||||
this.status = 'mobileVerified';
|
this.data.status = 'mobileVerified';
|
||||||
|
await this.save();
|
||||||
return result;
|
return result;
|
||||||
} else {
|
}
|
||||||
if (this.smsvalidationCounter === 5) {
|
|
||||||
this.destroy();
|
if (this.data.smsvalidationCounter >= 5) {
|
||||||
|
await this.destroy();
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Registration cancelled due to repeated wrong verification code submission'
|
'Registration cancelled due to repeated wrong verification code submission'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.save();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* validate the email address with provider and dns sanity checks
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
||||||
console.log(`validating email ${this.emailAddress}`);
|
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
|
||||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* send the validation email
|
|
||||||
*/
|
|
||||||
public async sendTokenValidationEmail() {
|
public async sendTokenValidationEmail() {
|
||||||
const uuidToSend = plugins.smartunique.uuid4();
|
const uuidToSend = plugins.smartunique.uuid4();
|
||||||
this.unhashedEmailToken = uuidToSend;
|
this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
|
||||||
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend);
|
await this.save();
|
||||||
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail(
|
this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
|
||||||
this,
|
logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
|
||||||
uuidToSend
|
return uuidToSend;
|
||||||
);
|
|
||||||
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* validate the mobile number of someone
|
|
||||||
*/
|
|
||||||
public async sendValidationSms() {
|
public async sendValidationSms() {
|
||||||
this.smsCode =
|
const smsCode =
|
||||||
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation(
|
await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
|
||||||
{
|
fromName: this.manager.receptionRef.options.name,
|
||||||
fromName: this.registrationSessionManagerRef.receptionRef.options.name,
|
toNumber: parseInt(this.data.collectedData.userData.mobileNumber),
|
||||||
toNumber: parseInt(this.collectedData.userData.mobileNumber),
|
});
|
||||||
}
|
this.data.smsCodeHash = RegistrationSession.hashToken(smsCode);
|
||||||
);
|
await this.save();
|
||||||
|
return smsCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* this method can be called when this registrationsession is validated
|
|
||||||
* and all data has been set
|
|
||||||
*/
|
|
||||||
public async manifestUserWithAccountData(): Promise<User> {
|
public async manifestUserWithAccountData(): Promise<User> {
|
||||||
if (this.status !== 'mobileVerified') {
|
if (this.data.status !== 'mobileVerified') {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'You can only manifest user that have a validated email Address and Mobile Number'
|
'You can only manifest user that have a validated email Address and Mobile Number'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!this.collectedData) {
|
if (!this.data.collectedData) {
|
||||||
throw new Error('You have to set the accountdata first');
|
throw new Error('You have to set the accountdata first');
|
||||||
}
|
}
|
||||||
const manifestedUser =
|
const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||||
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData(
|
this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
|
||||||
this.collectedData.userData
|
|
||||||
);
|
);
|
||||||
|
this.data.status = 'registered';
|
||||||
|
await this.save();
|
||||||
return manifestedUser;
|
return manifestedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async destroy() {
|
||||||
* destroys the registrationsession
|
await this.delete();
|
||||||
*/
|
|
||||||
public destroy() {
|
|
||||||
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { logger } from './logging.js';
|
|||||||
|
|
||||||
export class RegistrationSessionManager {
|
export class RegistrationSessionManager {
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
|
|
||||||
public registrationSessions = new plugins.lik.FastMap<RegistrationSession>();
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession);
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
@@ -29,17 +33,16 @@ export class RegistrationSessionManager {
|
|||||||
`We sent you an Email with more information.`
|
`We sent you an Email with more information.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// check for exiting SignupSession
|
|
||||||
const existingSession = this.registrationSessions.getByKey(requestData.email);
|
const existingSessions = await this.CRegistrationSession.getInstances({
|
||||||
if (existingSession) {
|
'data.emailAddress': requestData.email,
|
||||||
|
});
|
||||||
|
for (const existingSession of existingSessions) {
|
||||||
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
||||||
existingSession.destroy();
|
await existingSession.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets check the email before we create a signup session
|
|
||||||
|
|
||||||
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
||||||
this,
|
|
||||||
requestData.email
|
requestData.email
|
||||||
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
||||||
console.log(e.errorText);
|
console.log(e.errorText);
|
||||||
@@ -63,10 +66,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||||
'afterRegistrationEmailClicked',
|
'afterRegistrationEmailClicked',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
console.log(requestData);
|
const signupSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
const signupSession = await this.registrationSessions.find(async (itemArg) =>
|
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (signupSession) {
|
if (signupSession) {
|
||||||
return {
|
return {
|
||||||
email: signupSession.emailAddress,
|
email: signupSession.emailAddress,
|
||||||
@@ -86,9 +86,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||||
'setDataForRegistration',
|
'setDataForRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -114,9 +112,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||||
'mobileVerificationForRegistration',
|
'mobileVerificationForRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -131,17 +127,16 @@ export class RegistrationSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requestData.mobileNumber) {
|
if (requestData.mobileNumber) {
|
||||||
registrationSession.status = 'emailValidated';
|
|
||||||
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
||||||
await registrationSession.sendValidationSms();
|
const smsCode = await registrationSession.sendValidationSms();
|
||||||
return {
|
return {
|
||||||
messageSent: true,
|
messageSent: true,
|
||||||
testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null,
|
testOnlySmsCode: process.env.TEST_MODE ? smsCode : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestData.verificationCode) {
|
if (requestData.verificationCode) {
|
||||||
const validationResult = registrationSession.validateSmsCode(
|
const validationResult = await registrationSession.validateSmsCode(
|
||||||
requestData.verificationCode
|
requestData.verificationCode
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -160,9 +155,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||||
'finishRegistration',
|
'finishRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -170,7 +163,7 @@ export class RegistrationSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
||||||
registrationSession.destroy();
|
await registrationSession.destroy();
|
||||||
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
||||||
return {
|
return {
|
||||||
accountData: {
|
accountData: {
|
||||||
@@ -187,4 +180,17 @@ export class RegistrationSessionManager {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findRegistrationSessionByToken(tokenArg: string) {
|
||||||
|
const registrationSession = await this.CRegistrationSession.getInstance({
|
||||||
|
'data.hashedEmailToken': RegistrationSession.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registrationSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await registrationSession.validateEmailToken(tokenArg);
|
||||||
|
return isValid ? registrationSession : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class User extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
const newUser = new User();
|
const newUser = new User();
|
||||||
newUser.id = plugins.smartunique.shortId();
|
newUser.id = plugins.smartunique.shortId();
|
||||||
newUser.data = {
|
newUser.data = {
|
||||||
connectedOrgs: null,
|
connectedOrgs: [],
|
||||||
status: 'new',
|
status: 'new',
|
||||||
name: userDataArg.name,
|
name: userDataArg.name,
|
||||||
username: userDataArg.username,
|
username: userDataArg.username,
|
||||||
@@ -31,8 +31,26 @@ export class User extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
return newUser;
|
return newUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static hashPassword(passwordArg: string) {
|
public static async hashPassword(passwordArg: string) {
|
||||||
return plugins.smarthash.sha256FromString(passwordArg);
|
return plugins.argon2.hash(passwordArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isLegacyPasswordHash(passwordHashArg?: string) {
|
||||||
|
return !!passwordHashArg && !passwordHashArg.startsWith('$argon2');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static shouldUpgradePasswordHash(passwordHashArg?: string) {
|
||||||
|
return this.isLegacyPasswordHash(passwordHashArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async verifyPassword(passwordArg: string, passwordHashArg?: string) {
|
||||||
|
if (!passwordHashArg) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.isLegacyPasswordHash(passwordHashArg)) {
|
||||||
|
return passwordHashArg === (await plugins.smarthash.sha256FromString(passwordArg));
|
||||||
|
}
|
||||||
|
return plugins.argon2.verify(passwordHashArg, passwordArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
|
|||||||
@@ -14,6 +14,36 @@ export class UserInvitationManager {
|
|||||||
|
|
||||||
public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation);
|
public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation);
|
||||||
|
|
||||||
|
private async emitOrganizationAlert(optionsArg: {
|
||||||
|
organizationId: string;
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actorUserId: string;
|
||||||
|
relatedEntityId?: string;
|
||||||
|
relatedEntityType?: string;
|
||||||
|
}) {
|
||||||
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
severity: optionsArg.severity,
|
||||||
|
title: optionsArg.title,
|
||||||
|
body: optionsArg.body,
|
||||||
|
actorUserId: optionsArg.actorUserId,
|
||||||
|
relatedEntityId: optionsArg.relatedEntityId,
|
||||||
|
relatedEntityType: optionsArg.relatedEntityType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrganizationName(organizationIdArg: string) {
|
||||||
|
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||||
|
id: organizationIdArg,
|
||||||
|
});
|
||||||
|
return organization?.data.name || 'this organization';
|
||||||
|
}
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
@@ -29,6 +59,10 @@ export class UserInvitationManager {
|
|||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||||
|
const roles = await this.receptionRef.organizationmanager.assertRoleKeysAreValid(
|
||||||
|
requestArg.organizationId,
|
||||||
|
requestArg.roles
|
||||||
|
);
|
||||||
|
|
||||||
const email = requestArg.email.toLowerCase().trim();
|
const email = requestArg.email.toLowerCase().trim();
|
||||||
|
|
||||||
@@ -56,7 +90,7 @@ export class UserInvitationManager {
|
|||||||
action: 'create',
|
action: 'create',
|
||||||
userId: existingUser.id,
|
userId: existingUser.id,
|
||||||
organizationId: requestArg.organizationId,
|
organizationId: requestArg.organizationId,
|
||||||
roles: requestArg.roles,
|
roles,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -73,14 +107,14 @@ export class UserInvitationManager {
|
|||||||
let isNew = false;
|
let isNew = false;
|
||||||
if (invitation) {
|
if (invitation) {
|
||||||
// Add org to existing invitation
|
// Add org to existing invitation
|
||||||
await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles);
|
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
|
||||||
} else {
|
} else {
|
||||||
// Create new invitation
|
// Create new invitation
|
||||||
invitation = await UserInvitation.createNewInvitation(
|
invitation = await UserInvitation.createNewInvitation(
|
||||||
email,
|
email,
|
||||||
requestArg.organizationId,
|
requestArg.organizationId,
|
||||||
user.id,
|
user.id,
|
||||||
requestArg.roles
|
roles
|
||||||
);
|
);
|
||||||
isNew = true;
|
isNew = true;
|
||||||
}
|
}
|
||||||
@@ -88,6 +122,19 @@ export class UserInvitationManager {
|
|||||||
// Send invitation email
|
// Send invitation email
|
||||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_invitation_created',
|
||||||
|
severity: 'low',
|
||||||
|
title: 'Organization invitation created',
|
||||||
|
body: `${user.data.email} invited ${email} to ${await this.getOrganizationName(
|
||||||
|
requestArg.organizationId
|
||||||
|
)}.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: invitation.id,
|
||||||
|
relatedEntityType: 'invitation',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
invitation: await invitation.createSavableObject(),
|
invitation: await invitation.createSavableObject(),
|
||||||
@@ -189,6 +236,17 @@ export class UserInvitationManager {
|
|||||||
await invitation.regenerateToken();
|
await invitation.regenerateToken();
|
||||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_invitation_resent',
|
||||||
|
severity: 'low',
|
||||||
|
title: 'Organization invitation resent',
|
||||||
|
body: `${user.data.email} resent an invitation to ${invitation.data.email}.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: invitation.id,
|
||||||
|
relatedEntityType: 'invitation',
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, message: 'Invitation resent.' };
|
return { success: true, message: 'Invitation resent.' };
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -231,10 +289,12 @@ export class UserInvitationManager {
|
|||||||
|
|
||||||
await role.delete();
|
await role.delete();
|
||||||
|
|
||||||
// Remove org from user's connectedOrgs
|
const removedUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
|
||||||
id: requestArg.userId,
|
id: requestArg.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove org from user's connectedOrgs
|
||||||
|
const memberUser = removedUser;
|
||||||
if (memberUser && memberUser.data.connectedOrgs) {
|
if (memberUser && memberUser.data.connectedOrgs) {
|
||||||
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||||
orgId => orgId !== requestArg.organizationId
|
orgId => orgId !== requestArg.organizationId
|
||||||
@@ -242,6 +302,19 @@ export class UserInvitationManager {
|
|||||||
await memberUser.save();
|
await memberUser.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_member_removed',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Organization member removed',
|
||||||
|
body: `${user.data.email} removed ${removedUser?.data?.email || requestArg.userId} from ${await this.getOrganizationName(
|
||||||
|
requestArg.organizationId
|
||||||
|
)}.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: requestArg.userId,
|
||||||
|
relatedEntityType: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -254,6 +327,10 @@ export class UserInvitationManager {
|
|||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||||
|
const roles = await this.receptionRef.organizationmanager.assertRoleKeysAreValid(
|
||||||
|
requestArg.organizationId,
|
||||||
|
requestArg.roles
|
||||||
|
);
|
||||||
|
|
||||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -267,7 +344,7 @@ export class UserInvitationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If removing owner role, check we're not removing the last owner
|
// If removing owner role, check we're not removing the last owner
|
||||||
if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) {
|
if (role.data.roles.includes('owner') && !roles.includes('owner')) {
|
||||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||||
data: { organizationId: requestArg.organizationId },
|
data: { organizationId: requestArg.organizationId },
|
||||||
});
|
});
|
||||||
@@ -280,9 +357,23 @@ export class UserInvitationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
role.data.roles = requestArg.roles;
|
role.data.roles = roles;
|
||||||
await role.save();
|
await role.save();
|
||||||
|
|
||||||
|
const updatedUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
id: requestArg.userId,
|
||||||
|
});
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_member_roles_updated',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Organization member roles updated',
|
||||||
|
body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${roles.join(', ')}.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: requestArg.userId,
|
||||||
|
relatedEntityType: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, role: await role.createSavableObject() };
|
return { success: true, role: await role.createSavableObject() };
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -308,6 +399,18 @@ export class UserInvitationManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||||
|
id: requestArg.organizationId,
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization not found.');
|
||||||
|
}
|
||||||
|
if ((requestArg.confirmationText || '').trim() !== `transfer ${organization.data.slug}`) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
`Confirmation text must be exactly "transfer ${organization.data.slug}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get new owner's role
|
// Get new owner's role
|
||||||
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
|
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -332,6 +435,29 @@ export class UserInvitationManager {
|
|||||||
}
|
}
|
||||||
await currentUserRole.save();
|
await currentUserRole.save();
|
||||||
|
|
||||||
|
const newOwner = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
id: requestArg.newOwnerId,
|
||||||
|
});
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
user.id,
|
||||||
|
'org_ownership_transferred',
|
||||||
|
`${user.data.email} transferred ownership of ${organization.data.name} to ${newOwner?.data?.email || requestArg.newOwnerId}.`,
|
||||||
|
{
|
||||||
|
targetId: requestArg.organizationId,
|
||||||
|
targetType: 'organization',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.emitOrganizationAlert({
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: 'org_ownership_transferred',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Organization ownership transferred',
|
||||||
|
body: `${user.data.email} transferred ownership to ${newOwner?.data?.email || requestArg.newOwnerId}.`,
|
||||||
|
actorUserId: user.id,
|
||||||
|
relatedEntityId: requestArg.newOwnerId,
|
||||||
|
relatedEntityType: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class UserManager {
|
|||||||
new plugins.typedrequest.TypedHandler('getRolesAndOrganizationsForUserId', async reqArg => {
|
new plugins.typedrequest.TypedHandler('getRolesAndOrganizationsForUserId', async reqArg => {
|
||||||
console.log('user manager: getting roles and orgs');
|
console.log('user manager: getting roles and orgs');
|
||||||
const user = await this.getUserByJwtValidation(reqArg.jwt);
|
const user = await this.getUserByJwtValidation(reqArg.jwt);
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(
|
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(
|
||||||
user
|
user
|
||||||
);
|
);
|
||||||
@@ -49,8 +52,7 @@ export class UserManager {
|
|||||||
email: user.data.email,
|
email: user.data.email,
|
||||||
mobileNumber: user.data.mobileNumber,
|
mobileNumber: user.data.mobileNumber,
|
||||||
connectedOrgs: user.data.connectedOrgs,
|
connectedOrgs: user.data.connectedOrgs,
|
||||||
status: null,
|
status: user.data.status,
|
||||||
password: null,
|
|
||||||
isGlobalAdmin: user.data.isGlobalAdmin,
|
isGlobalAdmin: user.data.isGlobalAdmin,
|
||||||
} as plugins.idpInterfaces.data.IUser['data']
|
} as plugins.idpInterfaces.data.IUser['data']
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,9 @@ export class UserManager {
|
|||||||
*/
|
*/
|
||||||
public async getUserByJwt(jwtString: string) {
|
public async getUserByJwt(jwtString: string) {
|
||||||
const jwtInstance = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtString);
|
const jwtInstance = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtString);
|
||||||
|
if (!jwtInstance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const user = await this.CUser.getInstance({
|
const user = await this.CUser.getInstance({
|
||||||
id: jwtInstance.data.userId
|
id: jwtInstance.data.userId
|
||||||
});
|
});
|
||||||
@@ -75,7 +80,10 @@ export class UserManager {
|
|||||||
* faster than the "getUserByJwt"
|
* faster than the "getUserByJwt"
|
||||||
*/
|
*/
|
||||||
public async getUserByJwtValidation(jwtStringArg: string) {
|
public async getUserByJwtValidation(jwtStringArg: string) {
|
||||||
const jwtDataArg: plugins.idpInterfaces.data.IJwt = await this.receptionRef.jwtManager.smartjwtInstance.verifyJWTAndGetData(jwtStringArg);
|
const jwtDataArg = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtStringArg);
|
||||||
|
if (!jwtDataArg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const resultingUser = await this.CUser.getInstance({
|
const resultingUser = await this.CUser.getInstance({
|
||||||
id: jwtDataArg.data.userId
|
id: jwtDataArg.data.userId
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
|
||||||
|
|
||||||
const projectinfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
|
|
||||||
|
|
||||||
export const logger = new plugins.smartlog.ConsoleLog();
|
export const logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
@@ -193,6 +193,7 @@ export class IdpCli {
|
|||||||
this.storeCredentials({
|
this.storeCredentials({
|
||||||
...credentials,
|
...credentials,
|
||||||
jwt: response.jwt,
|
jwt: response.jwt,
|
||||||
|
refreshToken: response.refreshToken || credentials.refreshToken,
|
||||||
});
|
});
|
||||||
return response.jwt;
|
return response.jwt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import * as typedsocket from '@api.global/typedsocket';
|
|||||||
|
|
||||||
export { typedrequest, typedsocket };
|
export { typedrequest, typedsocket };
|
||||||
|
|
||||||
// local
|
// idp.global scope
|
||||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
import * as idpInterfaces from '@idp.global/interfaces';
|
||||||
|
|
||||||
export { idpInterfaces };
|
export { idpInterfaces };
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# @idp.global/cli
|
||||||
|
|
||||||
|
Terminal client for `idp.global`.
|
||||||
|
|
||||||
|
It wraps the same typed backend used by the web app and SDK, but stores credentials on disk so you can inspect accounts, sessions, orgs, and admin state from the shell.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -g @idp.global/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
idp login
|
||||||
|
idp whoami
|
||||||
|
idp orgs
|
||||||
|
idp sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `idp login` | Prompt for email and password |
|
||||||
|
| `idp login-token` | Prompt for an API token |
|
||||||
|
| `idp logout` | Remove local credentials and try server-side logout |
|
||||||
|
| `idp whoami` | Print the current user |
|
||||||
|
| `idp sessions` | List active sessions |
|
||||||
|
| `idp revoke --session <session-id>` | Revoke a session |
|
||||||
|
| `idp orgs` | List organizations for the current user |
|
||||||
|
| `idp orgs-create` | Interactively create an organization |
|
||||||
|
| `idp members --org <org-id>` | List members for an organization |
|
||||||
|
| `idp invite --org <org-id> --email user@example.com` | Invite a member |
|
||||||
|
| `idp admin-check` | Check global admin status |
|
||||||
|
| `idp admin-apps` | List global app stats |
|
||||||
|
| `idp admin-suspend --user <user-id>` | Suspend a user |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The CLI reads `IDP_URL` and defaults to `https://idp.global`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IDP_URL=http://localhost:2999 idp whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials are stored in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.idp-global/credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Programmatic Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { IdpCli } from '@idp.global/cli';
|
||||||
|
|
||||||
|
const cli = new IdpCli({
|
||||||
|
idpBaseUrl: 'http://localhost:2999',
|
||||||
|
});
|
||||||
|
|
||||||
|
await cli.loginWithPassword('user@example.com', 'secret');
|
||||||
|
|
||||||
|
const me = await cli.whoami();
|
||||||
|
const orgs = await cli.getOrganizations();
|
||||||
|
|
||||||
|
console.log(me?.data?.email);
|
||||||
|
console.log(orgs?.organizations.length);
|
||||||
|
|
||||||
|
await cli.disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## What The Class Exposes
|
||||||
|
|
||||||
|
- `loginWithPassword()` and `loginWithApiToken()`
|
||||||
|
- `refreshJwt()` and `logout()`
|
||||||
|
- `whoami()`, `getSessions()`, and `revokeSession()`
|
||||||
|
- `getOrganizations()`, `createOrganization()`, `getOrgMembers()`, and `inviteMember()`
|
||||||
|
- `checkGlobalAdmin()`, `getGlobalAppStats()`, and `suspendUser()`
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- The CLI connects to the backend websocket surface at `/typedrequest`.
|
||||||
|
- It uses file-based credentials instead of browser storage.
|
||||||
|
- `orgs-create` first checks availability, then creates the organization.
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "@idp.global/cli",
|
||||||
|
"order": 4
|
||||||
|
}
|
||||||
@@ -29,9 +29,9 @@ export class IdpClient {
|
|||||||
appDataArg = {
|
appDataArg = {
|
||||||
id: '', // TODO
|
id: '', // TODO
|
||||||
appUrl: `https://${window.location.host}/`,
|
appUrl: `https://${window.location.host}/`,
|
||||||
description: null,
|
description: '',
|
||||||
logoUrl: null,
|
logoUrl: '',
|
||||||
name: null,
|
name: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.appData = appDataArg;
|
this.appData = appDataArg;
|
||||||
@@ -67,10 +67,14 @@ export class IdpClient {
|
|||||||
await this.storeJwt(jwtStringArg);
|
await this.storeJwt(jwtStringArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setRefreshToken(refreshTokenArg: string) {
|
||||||
|
await this.storeRefreshToken(refreshTokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a typedsocket for going reactive
|
* a typedsocket for going reactive
|
||||||
*/
|
*/
|
||||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
public typedsocket!: plugins.typedsocket.TypedSocket;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a typed router to go reactive
|
* a typed router to go reactive
|
||||||
@@ -89,16 +93,30 @@ export class IdpClient {
|
|||||||
await this.ssoStore.set('idpJwt', jwtString);
|
await this.ssoStore.set('idpJwt', jwtString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async storeRefreshToken(refreshToken: string) {
|
||||||
|
await this.ssoStore.set('idpRefreshToken', refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async getJwt(): Promise<string> {
|
public async getJwt(): Promise<string> {
|
||||||
return await this.ssoStore.get('idpJwt');
|
return await this.ssoStore.get('idpJwt');
|
||||||
}
|
}
|
||||||
|
public async getRefreshToken(): Promise<string> {
|
||||||
|
return await this.ssoStore.get('idpRefreshToken');
|
||||||
|
}
|
||||||
public async getJwtData(): Promise<plugins.idpInterfaces.data.IJwt> {
|
public async getJwtData(): Promise<plugins.idpInterfaces.data.IJwt> {
|
||||||
return this.helpers.extractDataFromJwtString(await this.getJwt());
|
return this.helpers.extractDataFromJwtString(await this.getJwt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteJwt() {
|
public async deleteJwt() {
|
||||||
await this.ssoStore.delete('idpJwt');
|
await this.ssoStore.delete('idpJwt');
|
||||||
console.log('removed jwt');
|
}
|
||||||
|
|
||||||
|
public async deleteRefreshToken() {
|
||||||
|
await this.ssoStore.delete('idpRefreshToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearAuthState() {
|
||||||
|
await Promise.all([this.deleteJwt(), this.deleteRefreshToken()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,47 +133,63 @@ export class IdpClient {
|
|||||||
if (extractedJwt.data.refreshFrom < Date.now() && Date.now() < extractedJwt.data.validUntil) {
|
if (extractedJwt.data.refreshFrom < Date.now() && Date.now() < extractedJwt.data.validUntil) {
|
||||||
jwt = await this.refreshJwt();
|
jwt = await this.refreshJwt();
|
||||||
} else if (Date.now() > extractedJwt.data.validUntil) {
|
} else if (Date.now() > extractedJwt.data.validUntil) {
|
||||||
this.deleteJwt();
|
await this.deleteJwt();
|
||||||
|
jwt = await this.refreshJwt();
|
||||||
}
|
}
|
||||||
return jwt;
|
return jwt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshJwt(refreshTokenArg?: string): Promise<string> {
|
public async refreshJwt(refreshTokenArg?: string): Promise<string | null> {
|
||||||
let extractedJwt: plugins.idpInterfaces.data.IJwt;
|
const refreshToken = refreshTokenArg || (await this.getRefreshToken());
|
||||||
|
|
||||||
if (!refreshTokenArg) {
|
if (!refreshToken) {
|
||||||
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.typedsocketDeferred.promise;
|
await this.typedsocketDeferred.promise;
|
||||||
const refreshJwtReq =
|
const refreshJwtReq =
|
||||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||||
'refreshJwt'
|
'refreshJwt'
|
||||||
);
|
);
|
||||||
const response = await refreshJwtReq.fire({
|
const response = await refreshJwtReq
|
||||||
refreshToken: refreshTokenArg || extractedJwt.data.refreshToken,
|
.fire({
|
||||||
|
refreshToken,
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
await this.clearAuthState();
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
if (response.jwt) {
|
|
||||||
await this.storeJwt(response.jwt);
|
if (!response?.jwt) {
|
||||||
} else {
|
await this.clearAuthState();
|
||||||
await this.deleteJwt();
|
this.statusObservable.next(response?.status || 'loggedOut');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.refreshToken) {
|
||||||
|
await this.storeRefreshToken(response.refreshToken);
|
||||||
|
}
|
||||||
|
await this.storeJwt(response.jwt);
|
||||||
this.statusObservable.next(response.status);
|
this.statusObservable.next(response.status);
|
||||||
return await this.getJwt();
|
return response.jwt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* can be used to switch between pages
|
* can be used to switch between pages
|
||||||
*/
|
*/
|
||||||
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string> {
|
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string | null> {
|
||||||
const jwt = await this.performJwtHousekeeping();
|
await this.performJwtHousekeeping();
|
||||||
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
const refreshToken = await this.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
await this.typedsocketDeferred.promise;
|
await this.typedsocketDeferred.promise;
|
||||||
const getTransferToken =
|
const getTransferToken =
|
||||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||||
'exchangeRefreshTokenAndTransferToken'
|
'exchangeRefreshTokenAndTransferToken'
|
||||||
);
|
);
|
||||||
const response = await getTransferToken.fire({
|
const response = await getTransferToken.fire({
|
||||||
refreshToken: extractedJwt.data.refreshToken,
|
refreshToken,
|
||||||
appData: appDataArg || this.appData,
|
appData: appDataArg || this.appData,
|
||||||
});
|
});
|
||||||
return response.transferToken;
|
return response.transferToken;
|
||||||
@@ -230,6 +264,13 @@ export class IdpClient {
|
|||||||
const jwt = await this.performJwtHousekeeping();
|
const jwt = await this.performJwtHousekeeping();
|
||||||
return !!jwt;
|
return !!jwt;
|
||||||
} else {
|
} else {
|
||||||
|
const refreshToken = await this.getRefreshToken();
|
||||||
|
if (refreshToken) {
|
||||||
|
const jwt = await this.refreshJwt(refreshToken);
|
||||||
|
if (jwt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const transferTokenResult = await this.processTransferToken();
|
const transferTokenResult = await this.processTransferToken();
|
||||||
if (transferTokenResult) {
|
if (transferTokenResult) {
|
||||||
// we are in the clear
|
// we are in the clear
|
||||||
@@ -258,12 +299,18 @@ export class IdpClient {
|
|||||||
*/
|
*/
|
||||||
public async logout() {
|
public async logout() {
|
||||||
const idpLogoutUrl = this.parsedReceptionUrl.clone().set('path', '/logout');
|
const idpLogoutUrl = this.parsedReceptionUrl.clone().set('path', '/logout');
|
||||||
|
const refreshToken = await this.getRefreshToken();
|
||||||
if (!globalThis.location.href.startsWith(idpLogoutUrl.origin)) {
|
if (!globalThis.location.href.startsWith(idpLogoutUrl.origin)) {
|
||||||
// we are somewhere in an app
|
// we are somewhere in an app
|
||||||
await this.deleteJwt();
|
await this.clearAuthState();
|
||||||
globalThis.location.href = idpLogoutUrl.toString();
|
globalThis.location.href = idpLogoutUrl.toString();
|
||||||
} else {
|
} else {
|
||||||
// we are in the sso page
|
// we are in the sso page
|
||||||
|
if (!refreshToken) {
|
||||||
|
await this.clearAuthState();
|
||||||
|
window.location.href = this.parsedReceptionUrl.origin;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.enableTypedSocket();
|
await this.enableTypedSocket();
|
||||||
console.log(`logging out against ${this.parsedReceptionUrl.toString()}`);
|
console.log(`logging out against ${this.parsedReceptionUrl.toString()}`);
|
||||||
const logoutTr =
|
const logoutTr =
|
||||||
@@ -271,9 +318,9 @@ export class IdpClient {
|
|||||||
'logout'
|
'logout'
|
||||||
);
|
);
|
||||||
await logoutTr.fire({
|
await logoutTr.fire({
|
||||||
refreshToken: (await this.getJwtData()).data.refreshToken,
|
refreshToken,
|
||||||
});
|
});
|
||||||
await this.deleteJwt();
|
await this.clearAuthState();
|
||||||
const appData = await this.getAppDataOnSsoDomain();
|
const appData = await this.getAppDataOnSsoDomain();
|
||||||
if (appData) {
|
if (appData) {
|
||||||
console.log(`redirecting to app after logout: ${appData.appUrl}`);
|
console.log(`redirecting to app after logout: ${appData.appUrl}`);
|
||||||
@@ -287,7 +334,7 @@ export class IdpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public typedsocketDeferred = plugins.smartpromise.defer();
|
public typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
|
||||||
public async enableTypedSocket() {
|
public async enableTypedSocket() {
|
||||||
if (this.typedsocketDeferred.claimed) {
|
if (this.typedsocketDeferred.claimed) {
|
||||||
return this.typedsocketDeferred.promise;
|
return this.typedsocketDeferred.promise;
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ export class IdpRequests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get completeOidcAuthorization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||||
|
'completeOidcAuthorization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get prepareOidcAuthorization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||||
|
'prepareOidcAuthorization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public get resetPassword() {
|
public get resetPassword() {
|
||||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||||
'resetPassword'
|
'resetPassword'
|
||||||
@@ -160,6 +172,30 @@ export class IdpRequests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get deleteOrganization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrganization>(
|
||||||
|
'deleteOrganization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get getOrgRoleDefinitions() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgRoleDefinitions>(
|
||||||
|
'getOrgRoleDefinitions'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get upsertOrgRoleDefinition() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>(
|
||||||
|
'upsertOrgRoleDefinition'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get deleteOrgRoleDefinition() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>(
|
||||||
|
'deleteOrgRoleDefinition'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Member & Invitation Management
|
// Member & Invitation Management
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -230,6 +266,12 @@ export class IdpRequests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get updateAppRoleMappings() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>(
|
||||||
|
'updateAppRoleMappings'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Billing
|
// Billing
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// losslessone_private scope
|
// idp.global scope
|
||||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
import * as idpInterfaces from '@idp.global/interfaces';
|
||||||
|
|
||||||
export { idpInterfaces };
|
export { idpInterfaces };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# @idp.global/client
|
||||||
|
|
||||||
|
Browser-facing TypeScript client for talking to an `idp.global` server over `typedrequest` and `typedsocket`.
|
||||||
|
|
||||||
|
It handles login state, refresh tokens, JWT housekeeping, cross-app transfer tokens, and direct access to the typed request surface.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @idp.global/client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { IdpClient } from '@idp.global/client';
|
||||||
|
|
||||||
|
const idpClient = new IdpClient('https://idp.global');
|
||||||
|
await idpClient.enableTypedSocket();
|
||||||
|
|
||||||
|
const loggedIn = await idpClient.determineLoginStatus();
|
||||||
|
|
||||||
|
if (!loggedIn) {
|
||||||
|
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
|
username: 'user@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResult.refreshToken) {
|
||||||
|
await idpClient.refreshJwt(loginResult.refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whoIs = await idpClient.whoIs();
|
||||||
|
console.log(whoIs.user.data.email);
|
||||||
|
```
|
||||||
|
|
||||||
|
## What The Client Handles
|
||||||
|
|
||||||
|
- Normalizes the base URL to the server's `/typedrequest` endpoint.
|
||||||
|
- Stores JWT and refresh token state in a browser `WebStore`.
|
||||||
|
- Refreshes expiring JWTs via `performJwtHousekeeping()`.
|
||||||
|
- Redirects to `/login` when `determineLoginStatus(true)` is used.
|
||||||
|
- Exchanges refresh tokens for cross-app transfer tokens.
|
||||||
|
- Exposes the low-level typed requests through `idpClient.requests`.
|
||||||
|
|
||||||
|
## Common Flows
|
||||||
|
|
||||||
|
### Password Login
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const result = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
|
username: 'user@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.refreshToken) {
|
||||||
|
await idpClient.refreshJwt(result.refreshToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magic Link Login
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await idpClient.requests.loginWithEmail.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
token: 'token-from-email',
|
||||||
|
});
|
||||||
|
|
||||||
|
await idpClient.refreshJwt(result.refreshToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session and Identity
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await idpClient.performJwtHousekeeping();
|
||||||
|
|
||||||
|
const jwt = await idpClient.getJwt();
|
||||||
|
const jwtData = await idpClient.getJwtData();
|
||||||
|
const whoIs = await idpClient.whoIs();
|
||||||
|
|
||||||
|
console.log(jwtData.id, whoIs.user.data.username);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organizations
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const rolesAndOrganizations = await idpClient.getRolesAndOrganizations();
|
||||||
|
|
||||||
|
const created = await idpClient.createOrganization(
|
||||||
|
'Acme',
|
||||||
|
'acme',
|
||||||
|
'manifest'
|
||||||
|
);
|
||||||
|
|
||||||
|
const members = await idpClient.requests.getOrgMembers.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: created.resultingOrganization.id,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-App Transfer
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const transferToken = await idpClient.getTransferToken();
|
||||||
|
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typed Request Surface
|
||||||
|
|
||||||
|
`IdpRequests` exposes typed request getters for:
|
||||||
|
|
||||||
|
- authentication
|
||||||
|
- registration
|
||||||
|
- user/session queries
|
||||||
|
- org and invitation management
|
||||||
|
- billing requests
|
||||||
|
- JWT validation key requests
|
||||||
|
- admin requests
|
||||||
|
- OIDC authorization preparation and completion
|
||||||
|
- passport device enrollment, challenge approval, alert, and push-token requests
|
||||||
|
|
||||||
|
Use these when you want full control instead of the higher-level helper methods on `IdpClient`.
|
||||||
|
|
||||||
|
## Important Runtime Notes
|
||||||
|
|
||||||
|
- The default fallback `appData` uses `window.location`, so this package is primarily browser-oriented.
|
||||||
|
- The client expects the backend `typedrequest` websocket surface to be reachable.
|
||||||
|
- Auth state is persisted in browser storage under the `idpglobalStore` store name.
|
||||||
|
- Passport, alert, and OIDC helper flows are available through `idpClient.requests` even when there is no higher-level convenience method on `IdpClient` yet.
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "@idp.global/client",
|
||||||
|
"order": 3
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
|
||||||
*/
|
|
||||||
export const commitinfo = {
|
|
||||||
name: '@losslessone_private/loint-reception',
|
|
||||||
version: '1.0.122',
|
|
||||||
description: 'an interface package for the reception service at Lossless'
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export * from './loint-reception.activity.js';
|
|
||||||
export * from './loint-reception.app.js';
|
|
||||||
export * from './loint-reception.appconnection.js';
|
|
||||||
export * from './loint-reception.billingplan.js';
|
|
||||||
export * from './loint-reception.device.js';
|
|
||||||
export * from './loint-reception.jwt.js';
|
|
||||||
export * from './loint-reception.loginsession.js';
|
|
||||||
export * from './loint-reception.organization.js';
|
|
||||||
export * from './loint-reception.paddlecheckoutdata.js';
|
|
||||||
export * from './loint-reception.role.js';
|
|
||||||
export * from './loint-reception.user.js';
|
|
||||||
export * from './loint-reception.userinvitation.js';
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export type TActivityAction =
|
|
||||||
| 'login'
|
|
||||||
| 'logout'
|
|
||||||
| 'session_created'
|
|
||||||
| 'session_revoked'
|
|
||||||
| 'org_created'
|
|
||||||
| 'org_joined'
|
|
||||||
| 'org_left'
|
|
||||||
| 'role_changed'
|
|
||||||
| 'profile_updated'
|
|
||||||
| 'app_connected'
|
|
||||||
| 'app_disconnected';
|
|
||||||
|
|
||||||
export interface IActivityLog {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
userId: string;
|
|
||||||
action: TActivityAction;
|
|
||||||
timestamp: number;
|
|
||||||
metadata: {
|
|
||||||
ip?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
targetId?: string;
|
|
||||||
targetType?: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// App Types
|
|
||||||
export type TAppType = 'global' | 'partner' | 'custom_oidc';
|
|
||||||
export type TAppApprovalStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'suspended';
|
|
||||||
|
|
||||||
// OAuth Credentials
|
|
||||||
export interface IOAuthCredentials {
|
|
||||||
clientId: string;
|
|
||||||
clientSecretHash: string;
|
|
||||||
redirectUris: string[];
|
|
||||||
allowedScopes: string[];
|
|
||||||
grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base app data shared by all app types
|
|
||||||
export interface IAppBaseData {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
logoUrl: string;
|
|
||||||
appUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global App - First-party apps managed by platform (foss.global, task.vc, etc.)
|
|
||||||
export interface IGlobalApp {
|
|
||||||
id: string;
|
|
||||||
type: 'global';
|
|
||||||
data: IAppBaseData & {
|
|
||||||
oauthCredentials: IOAuthCredentials;
|
|
||||||
isActive: boolean;
|
|
||||||
category: string;
|
|
||||||
createdAt: number;
|
|
||||||
createdByUserId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partner App - Third-party apps submitted to AppStore
|
|
||||||
export interface IPartnerApp {
|
|
||||||
id: string;
|
|
||||||
type: 'partner';
|
|
||||||
data: IAppBaseData & {
|
|
||||||
ownerOrganizationId: string;
|
|
||||||
oauthCredentials: IOAuthCredentials;
|
|
||||||
appStoreMetadata: {
|
|
||||||
shortDescription: string;
|
|
||||||
longDescription: string;
|
|
||||||
screenshots: string[];
|
|
||||||
category: string;
|
|
||||||
tags: string[];
|
|
||||||
pricing: { model: 'free' | 'paid' | 'freemium' };
|
|
||||||
};
|
|
||||||
approvalStatus: TAppApprovalStatus;
|
|
||||||
isPublished: boolean;
|
|
||||||
installCount: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom OIDC App - Organization-created OAuth clients
|
|
||||||
export interface ICustomOidcApp {
|
|
||||||
id: string;
|
|
||||||
type: 'custom_oidc';
|
|
||||||
data: IAppBaseData & {
|
|
||||||
ownerOrganizationId: string;
|
|
||||||
oauthCredentials: IOAuthCredentials;
|
|
||||||
oidcSettings: {
|
|
||||||
accessTokenLifetime: number; // seconds
|
|
||||||
refreshTokenLifetime: number; // seconds
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Union type for all app types
|
|
||||||
export type IApp = IGlobalApp | IPartnerApp | ICustomOidcApp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy interface for backwards compatibility with existing code
|
|
||||||
* that expects a flat app structure (e.g., idpclient, transfermanager)
|
|
||||||
*/
|
|
||||||
export interface IAppLegacy {
|
|
||||||
/**
|
|
||||||
* must be unique
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* should be unique
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
logoUrl: string;
|
|
||||||
appUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage interface for SmartData documents
|
|
||||||
* Uses the discriminated union approach with a 'type' field
|
|
||||||
*/
|
|
||||||
export interface IAppDocument {
|
|
||||||
id: string;
|
|
||||||
type: TAppType;
|
|
||||||
data: IGlobalApp['data'] | IPartnerApp['data'] | ICustomOidcApp['data'];
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { TAppType } from './loint-reception.app.js';
|
|
||||||
|
|
||||||
export type TAppConnectionStatus = 'active' | 'disconnected';
|
|
||||||
|
|
||||||
export interface IAppConnection {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
organizationId: string;
|
|
||||||
appId: string;
|
|
||||||
appType: TAppType;
|
|
||||||
status: TAppConnectionStatus;
|
|
||||||
connectedAt: number;
|
|
||||||
connectedByUserId: string;
|
|
||||||
grantedScopes: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export type TSupportedCurrency = 'EUR';
|
|
||||||
|
|
||||||
export interface IBillableItem {
|
|
||||||
name: string;
|
|
||||||
monthlyPrice: number;
|
|
||||||
currency: TSupportedCurrency;
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
factoredOn30DayMonth: number;
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBillingPlan {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
type: 'Paddle' | 'AppSumo' | 'FairUsageFree' | 'Enterprise' | 'Internal' | 'Testing';
|
|
||||||
proEnabled: boolean;
|
|
||||||
organizationId: string;
|
|
||||||
lastProcessed: number;
|
|
||||||
seats: number;
|
|
||||||
status: 'active' | 'activeOverdue' | 'pausedOverdue' | 'inactive' | 'suspended';
|
|
||||||
paddleData?: {
|
|
||||||
checkoutId: string;
|
|
||||||
};
|
|
||||||
alternativePaymentData?: {
|
|
||||||
enterprise: boolean;
|
|
||||||
appSumoCode: string;
|
|
||||||
};
|
|
||||||
nextBilling: {
|
|
||||||
items: Array<IBillableItem>;
|
|
||||||
method: 'paddle';
|
|
||||||
ontrack: boolean;
|
|
||||||
errorText?: string;
|
|
||||||
selectedBillingDate: number;
|
|
||||||
};
|
|
||||||
billingEvents: Array<{
|
|
||||||
timestamp: number;
|
|
||||||
amount: number;
|
|
||||||
currency: TSupportedCurrency;
|
|
||||||
billedItems: Array<IBillableItem>;
|
|
||||||
checkoutLink?: string;
|
|
||||||
}>;
|
|
||||||
communications: Array<any>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export interface IDevice extends plugins.tsclass.network.IDevice {}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export type TLoginStatus = 'loggedIn' | 'loggedOut' | 'invalidated' | 'not found' | 'transfer';
|
|
||||||
export type TLoginAction = 'login' | 'logout' | 'manage';
|
|
||||||
|
|
||||||
export interface IJwt {
|
|
||||||
id: string;
|
|
||||||
blocked: boolean;
|
|
||||||
data: {
|
|
||||||
/**
|
|
||||||
* the user id of the jwt
|
|
||||||
*/
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the latest point of
|
|
||||||
*/
|
|
||||||
validUntil: number;
|
|
||||||
/**
|
|
||||||
* hold off from refreshing before
|
|
||||||
*/
|
|
||||||
refreshFrom: number;
|
|
||||||
/**
|
|
||||||
* an interval in millis to recheck token invalidation
|
|
||||||
*/
|
|
||||||
refreshEvery: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the refresh token to obtain a new jwt for a session
|
|
||||||
*/
|
|
||||||
refreshToken: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* just for looks/debugging
|
|
||||||
*/
|
|
||||||
justForLooks: {
|
|
||||||
validUntilIsoString: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export interface ILoginSession {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
userId: string;
|
|
||||||
validUntil: number;
|
|
||||||
invalidated: boolean;
|
|
||||||
refreshToken: string;
|
|
||||||
/**
|
|
||||||
* a device id that can be used to share the login session
|
|
||||||
* in different contexts on the same device
|
|
||||||
*/
|
|
||||||
deviceId: string;
|
|
||||||
/**
|
|
||||||
* Device metadata for session display
|
|
||||||
*/
|
|
||||||
deviceInfo?: {
|
|
||||||
deviceName: string;
|
|
||||||
browser: string;
|
|
||||||
os: string;
|
|
||||||
ip: string;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* When this session was created
|
|
||||||
*/
|
|
||||||
createdAt?: number;
|
|
||||||
/**
|
|
||||||
* Last time this session was active (e.g., refreshed)
|
|
||||||
*/
|
|
||||||
lastActive?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IBillingPlan } from './loint-reception.billingplan.js';
|
|
||||||
import { type IRole } from './loint-reception.role.js';
|
|
||||||
|
|
||||||
export interface IOrganization {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
billingPlanId: string;
|
|
||||||
roleIds: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
export interface IPaddleCheckoutData<TPassthrough = null> {
|
|
||||||
checkout: {
|
|
||||||
created_at: string;
|
|
||||||
completed: boolean;
|
|
||||||
id: string;
|
|
||||||
coupon: {
|
|
||||||
coupon_code?: string;
|
|
||||||
};
|
|
||||||
passthrough?: TPassthrough;
|
|
||||||
prices: {
|
|
||||||
customer: {
|
|
||||||
currency: string;
|
|
||||||
unit: string;
|
|
||||||
unit_tax: string;
|
|
||||||
total: string;
|
|
||||||
total_tax: string;
|
|
||||||
items: Array<{
|
|
||||||
checkout_product_id: number;
|
|
||||||
product_id: number;
|
|
||||||
name: string;
|
|
||||||
custom_message: string;
|
|
||||||
quantity: number;
|
|
||||||
allow_quantity: false;
|
|
||||||
icon_url: string;
|
|
||||||
min_quantity: number;
|
|
||||||
max_quantity: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
/**
|
|
||||||
* factorised, not percentage, so looks like 0.19 for Germany.
|
|
||||||
*/
|
|
||||||
tax_rate: number;
|
|
||||||
recurring: {
|
|
||||||
period: string;
|
|
||||||
interval: number;
|
|
||||||
trial_days: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
vendor: {
|
|
||||||
currency: string;
|
|
||||||
unit: string;
|
|
||||||
unit_tax: string;
|
|
||||||
total: string;
|
|
||||||
total_tax: string;
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
checkout_product_id: number;
|
|
||||||
product_id: number;
|
|
||||||
name: string;
|
|
||||||
custom_message: string;
|
|
||||||
quantity: number;
|
|
||||||
allow_quantity: false;
|
|
||||||
icon_url: string;
|
|
||||||
min_quantity: number;
|
|
||||||
max_quantity: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
recurring: {
|
|
||||||
period: string;
|
|
||||||
interval: number;
|
|
||||||
trial_days: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
redirect_url: null;
|
|
||||||
test_variant: 'newCheckout';
|
|
||||||
recurring_prices: {
|
|
||||||
customer: {
|
|
||||||
currency: string;
|
|
||||||
unit: string;
|
|
||||||
unit_tax: string;
|
|
||||||
total: string;
|
|
||||||
total_tax: string;
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
checkout_product_id: number;
|
|
||||||
product_id: number;
|
|
||||||
name: string;
|
|
||||||
custom_message: string;
|
|
||||||
quantity: number;
|
|
||||||
allow_quantity: false;
|
|
||||||
icon_url: string;
|
|
||||||
min_quantity: number;
|
|
||||||
max_quantity: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
recurring: {
|
|
||||||
period: string;
|
|
||||||
interval: number;
|
|
||||||
trial_days: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
interval: {
|
|
||||||
length: number;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
vendor: {
|
|
||||||
currency: string;
|
|
||||||
unit: string;
|
|
||||||
unit_tax: string;
|
|
||||||
total: string;
|
|
||||||
total_tax: string;
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
checkout_product_id: number;
|
|
||||||
product_id: number;
|
|
||||||
name: string;
|
|
||||||
custom_message: string;
|
|
||||||
quantity: number;
|
|
||||||
allow_quantity: false;
|
|
||||||
icon_url: string;
|
|
||||||
min_quantity: number;
|
|
||||||
max_quantity: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
recurring: {
|
|
||||||
period: string;
|
|
||||||
interval: number;
|
|
||||||
trial_days: number;
|
|
||||||
currency: string;
|
|
||||||
unit_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
line_price: {
|
|
||||||
net: number;
|
|
||||||
gross: number;
|
|
||||||
net_discount: number;
|
|
||||||
gross_discount: number;
|
|
||||||
net_after_discount: number;
|
|
||||||
gross_after_discount: number;
|
|
||||||
tax: number;
|
|
||||||
tax_after_discount: number;
|
|
||||||
};
|
|
||||||
discounts: [];
|
|
||||||
tax_rate: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
product: {
|
|
||||||
quantity: number;
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IRole } from './loint-reception.role.js';
|
|
||||||
|
|
||||||
export interface ISubOrgProperty {
|
|
||||||
name: string;
|
|
||||||
domain: string;
|
|
||||||
roles: IRole[];
|
|
||||||
/**
|
|
||||||
* contains the ids of all the apps that show the property
|
|
||||||
*/
|
|
||||||
attributedAppIds: string[];
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
/** Standard role types available in all organizations */
|
|
||||||
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A role describes a user's permissions within an organization.
|
|
||||||
* Users can have multiple roles (e.g., ['owner', 'billing-admin']).
|
|
||||||
*/
|
|
||||||
export interface IRole {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
userId: string;
|
|
||||||
organizationId: string;
|
|
||||||
/** Array of roles - supports standard roles and custom role names */
|
|
||||||
roles: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IRole } from './loint-reception.role.js';
|
|
||||||
|
|
||||||
export interface IUser {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mobile number used for verification
|
|
||||||
*/
|
|
||||||
mobileNumber?: string;
|
|
||||||
/**
|
|
||||||
* only used during initial password setting
|
|
||||||
*/
|
|
||||||
password?: string;
|
|
||||||
/**
|
|
||||||
* used for validation of passwords
|
|
||||||
*/
|
|
||||||
passwordHash?: string;
|
|
||||||
status: 'new' | 'active' | 'deleted' | 'suspended';
|
|
||||||
/**
|
|
||||||
* a quick ref for which organizations might have roles for this user
|
|
||||||
* speeds up lookup
|
|
||||||
*/
|
|
||||||
connectedOrgs: string[];
|
|
||||||
/**
|
|
||||||
* Platform-level admin flag
|
|
||||||
* Users with this flag can access the global admin panel
|
|
||||||
* to manage global apps, view platform stats, etc.
|
|
||||||
*/
|
|
||||||
isGlobalAdmin?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A UserInvitation represents an invitation to join an organization.
|
|
||||||
* Key characteristics:
|
|
||||||
* - Unique by email (multiple orgs can share the same invitation)
|
|
||||||
* - Converts to real User on registration or folds into existing user
|
|
||||||
* - Auto-expires after 90 days
|
|
||||||
*/
|
|
||||||
export interface IUserInvitation {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
/** The invited email address - unique key for sharing across orgs */
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
/** Secure token for invitation link validation */
|
|
||||||
token: string;
|
|
||||||
|
|
||||||
/** Current status of the invitation */
|
|
||||||
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
|
|
||||||
|
|
||||||
/** When the invitation was first created */
|
|
||||||
createdAt: number;
|
|
||||||
|
|
||||||
/** When the invitation expires (createdAt + 90 days) */
|
|
||||||
expiresAt: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Organizations that have invited this email.
|
|
||||||
* Multiple orgs can link to the same invitation.
|
|
||||||
*/
|
|
||||||
organizationRefs: IOrganizationInvitationRef[];
|
|
||||||
|
|
||||||
/** When the invitation was accepted (user registered/folded) */
|
|
||||||
acceptedAt?: number;
|
|
||||||
|
|
||||||
/** The User ID after conversion (when accepted) */
|
|
||||||
convertedToUserId?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents one organization's invitation to the user.
|
|
||||||
* Stored as part of IUserInvitation.organizationRefs array.
|
|
||||||
*/
|
|
||||||
export interface IOrganizationInvitationRef {
|
|
||||||
/** The organization that sent this invitation */
|
|
||||||
organizationId: string;
|
|
||||||
|
|
||||||
/** The user who sent the invitation */
|
|
||||||
invitedByUserId: string;
|
|
||||||
|
|
||||||
/** When this org invited the user */
|
|
||||||
invitedAt: number;
|
|
||||||
|
|
||||||
/** Roles to assign when the invitation is accepted */
|
|
||||||
roles: string[];
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// requests
|
|
||||||
import * as request from './request/index.js';
|
|
||||||
import * as data from './data/index.js';
|
|
||||||
import * as tags from './tags/index.js';
|
|
||||||
|
|
||||||
export { request, data, tags };
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// @apiglobal scope
|
|
||||||
import * as typedRequestInterfaces from '@api.global/typedrequest-interfaces';
|
|
||||||
|
|
||||||
export { typedRequestInterfaces };
|
|
||||||
|
|
||||||
// @tsclass scope
|
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
|
||||||
|
|
||||||
export { tsclass };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export * from './loint-reception.admin.js';
|
|
||||||
export * from './loint-reception.apitoken.js';
|
|
||||||
export * from './loint-reception.app.js';
|
|
||||||
export * from './loint-reception.authorization.js';
|
|
||||||
export * from './loint-reception.billingplan.js';
|
|
||||||
export * from './loint-reception.jwt.js';
|
|
||||||
export * from './loint-reception.login.js';
|
|
||||||
export * from './loint-reception.organization.js';
|
|
||||||
export * from './loint-reception.plan.js';
|
|
||||||
export * from './loint-reception.registration.js';
|
|
||||||
export * from './loint-reception.user.js';
|
|
||||||
export * from './loint-reception.userinvitation.js';
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import * as data from '../data/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is a global admin
|
|
||||||
*/
|
|
||||||
export interface IReq_CheckGlobalAdmin
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_CheckGlobalAdmin
|
|
||||||
> {
|
|
||||||
method: 'checkGlobalAdmin';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
isGlobalAdmin: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all global apps with statistics (admin only)
|
|
||||||
*/
|
|
||||||
export interface IReq_GetGlobalAppStats
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetGlobalAppStats
|
|
||||||
> {
|
|
||||||
method: 'getGlobalAppStats';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
apps: Array<{
|
|
||||||
app: data.IGlobalApp;
|
|
||||||
connectionCount: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new global app (admin only)
|
|
||||||
*/
|
|
||||||
export interface IReq_CreateGlobalApp
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_CreateGlobalApp
|
|
||||||
> {
|
|
||||||
method: 'createGlobalApp';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
logoUrl: string;
|
|
||||||
appUrl: string;
|
|
||||||
category: string;
|
|
||||||
redirectUris: string[];
|
|
||||||
allowedScopes: string[];
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
app: data.IGlobalApp;
|
|
||||||
clientSecret: string; // Only shown once on creation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing global app (admin only)
|
|
||||||
*/
|
|
||||||
export interface IReq_UpdateGlobalApp
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_UpdateGlobalApp
|
|
||||||
> {
|
|
||||||
method: 'updateGlobalApp';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
appId: string;
|
|
||||||
updates: {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
logoUrl?: string;
|
|
||||||
appUrl?: string;
|
|
||||||
category?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
redirectUris?: string[];
|
|
||||||
allowedScopes?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
app: data.IGlobalApp;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a global app (admin only)
|
|
||||||
*/
|
|
||||||
export interface IReq_DeleteGlobalApp
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_DeleteGlobalApp
|
|
||||||
> {
|
|
||||||
method: 'deleteGlobalApp';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
appId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
disconnectedOrganizations: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regenerate OAuth credentials for a global app (admin only)
|
|
||||||
*/
|
|
||||||
export interface IReq_RegenerateAppCredentials
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_RegenerateAppCredentials
|
|
||||||
> {
|
|
||||||
method: 'regenerateAppCredentials';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
appId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string; // Only shown once
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import * as data from '../data/index.js';
|
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
// Get all global apps
|
|
||||||
export interface IReq_GetGlobalApps
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetGlobalApps
|
|
||||||
> {
|
|
||||||
method: 'getGlobalApps';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
apps: data.IGlobalApp[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get app connections for an organization
|
|
||||||
export interface IReq_GetAppConnections
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetAppConnections
|
|
||||||
> {
|
|
||||||
method: 'getAppConnections';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
connections: data.IAppConnection[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect/disconnect an app for an organization
|
|
||||||
export interface IReq_ToggleAppConnection
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_ToggleAppConnection
|
|
||||||
> {
|
|
||||||
method: 'toggleAppConnection';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
appId: string;
|
|
||||||
action: 'connect' | 'disconnect';
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
connection?: data.IAppConnection;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IUser, type IRole } from '../data/index.js';
|
|
||||||
|
|
||||||
export interface IReq_InternalAuthorization
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_InternalAuthorization
|
|
||||||
> {
|
|
||||||
method: '';
|
|
||||||
request: {
|
|
||||||
accountData: IUser;
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
accountData: IUser;
|
|
||||||
jwt: string;
|
|
||||||
relevantRoles: IRole[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import * as data from '../data/index.js';
|
|
||||||
|
|
||||||
export interface IReq_UpdatePaymentMethod
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_UpdatePaymentMethod
|
|
||||||
> {
|
|
||||||
method: 'updatePaymentMethod';
|
|
||||||
request: {
|
|
||||||
jwtString: string;
|
|
||||||
orgId: string;
|
|
||||||
paddle?: {
|
|
||||||
checkoutId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
billingPlan: plugins.tsclass.typeFest.PartialDeep<data.IBillingPlan>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* allows getting the billing plan for a user
|
|
||||||
*/
|
|
||||||
export interface IReq_GetBillingPlan
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetBillingPlan
|
|
||||||
> {
|
|
||||||
method: 'getBillingPlan';
|
|
||||||
request: {
|
|
||||||
jwtString: string;
|
|
||||||
orgId: string;
|
|
||||||
billingPlanId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
billingPlan: data.IBillingPlan;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns Paddle configuration from environment variables
|
|
||||||
*/
|
|
||||||
export interface IReq_GetPaddleConfig
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetPaddleConfig
|
|
||||||
> {
|
|
||||||
method: 'getPaddleConfig';
|
|
||||||
request: {};
|
|
||||||
response: {
|
|
||||||
paddleToken: string;
|
|
||||||
paddlePriceId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import * as data from '../data/index.js';
|
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export interface IReq_GetPublicKeyForValidation
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetPublicKeyForValidation
|
|
||||||
> {
|
|
||||||
method: 'getPublicKeyForValidation';
|
|
||||||
request: {
|
|
||||||
backendToken: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
publicKeyPem: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_PushPublicKeyForValidation
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_PushPublicKeyForValidation
|
|
||||||
> {
|
|
||||||
method: 'pushPublicKeyForValidation';
|
|
||||||
request: {
|
|
||||||
publicKeyPem: string;
|
|
||||||
};
|
|
||||||
response: {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* allows getting or pushing a blocklist of jwt ids
|
|
||||||
*/
|
|
||||||
export interface IReq_PushOrGetJwtIdBlocklist
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_PushOrGetJwtIdBlocklist
|
|
||||||
> {
|
|
||||||
method: 'pushOrGetJwtIdBlocklist';
|
|
||||||
request: {
|
|
||||||
blockedJwtIds?: string[];
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
blockedJwtIds?: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import * as data from '../data/index.js';
|
|
||||||
|
|
||||||
export interface IReq_LoginWithEmailOrUsernameAndPassword
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_LoginWithEmailOrUsernameAndPassword
|
|
||||||
> {
|
|
||||||
method: 'loginWithEmailOrUsernameAndPassword';
|
|
||||||
request: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
refreshToken?: string;
|
|
||||||
twoFaNeeded: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_LoginWithEmail
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_LoginWithEmailOrUsernameAndPassword
|
|
||||||
> {
|
|
||||||
method: 'loginWithEmail';
|
|
||||||
request: {
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
testOnlyToken?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_LoginWithEmailAfterEmailTokenAquired
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_LoginWithEmailOrUsernameAndPassword
|
|
||||||
> {
|
|
||||||
method: 'loginWithEmailAfterEmailTokenAquired';
|
|
||||||
request: {
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
refreshToken: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* in case you authenticate with a long lived api token
|
|
||||||
*/
|
|
||||||
export interface IReq_LoginWithApiToken
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_LoginWithApiToken
|
|
||||||
> {
|
|
||||||
method: 'loginWithApiToken';
|
|
||||||
request: {
|
|
||||||
apiToken: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
jwt?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILogoutRequest
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
ILogoutRequest
|
|
||||||
> {
|
|
||||||
method: 'logout';
|
|
||||||
request: {
|
|
||||||
refreshToken: string;
|
|
||||||
};
|
|
||||||
response: {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_RefreshJwt
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_RefreshJwt
|
|
||||||
> {
|
|
||||||
method: 'refreshJwt';
|
|
||||||
request: {
|
|
||||||
refreshToken: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: data.TLoginStatus;
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* allows the exchange between refreshToken and transferTokens
|
|
||||||
*/
|
|
||||||
export interface IReq_ExchangeRefreshTokenAndTransferToken
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_ExchangeRefreshTokenAndTransferToken
|
|
||||||
> {
|
|
||||||
method: 'exchangeRefreshTokenAndTransferToken';
|
|
||||||
request: {
|
|
||||||
transferToken?: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
appData: data.IAppLegacy;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
refreshToken?: string;
|
|
||||||
transferToken?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* in case you authenticate with a long lived api token
|
|
||||||
*/
|
|
||||||
export interface IReq_ResetPassword
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_ResetPassword
|
|
||||||
> {
|
|
||||||
method: 'resetPassword';
|
|
||||||
request: {
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* in cse you authenticate with a long lived api token
|
|
||||||
*/
|
|
||||||
export interface IReq_SetNewPassword
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_SetNewPassword
|
|
||||||
> {
|
|
||||||
method: 'setNewPassword';
|
|
||||||
request: {
|
|
||||||
email: string;
|
|
||||||
oldPassword?: string;
|
|
||||||
tokenArg?: string;
|
|
||||||
newPassword: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_ObtainDeviceId
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_ObtainDeviceId
|
|
||||||
> {
|
|
||||||
method: 'obtainDeviceId';
|
|
||||||
request: {};
|
|
||||||
response: {
|
|
||||||
deviceId: data.IDevice;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* allows attaching a device id to a login session
|
|
||||||
* to share a login session across contexts
|
|
||||||
*/
|
|
||||||
export interface IReq_AttachDeviceId
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_AttachDeviceId
|
|
||||||
> {
|
|
||||||
method: 'attachDeviceId';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
deviceId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
ok: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import * as data from '../data/index.js';
|
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export interface IReq_GetOrganizationById
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetOrganizationById
|
|
||||||
> {
|
|
||||||
method: 'getOrganizationById';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
organization: data.IOrganization;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_CreateOrganization
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_CreateOrganization
|
|
||||||
> {
|
|
||||||
method: 'createOrganization';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
userId: string;
|
|
||||||
organizationName: string;
|
|
||||||
organizationSlug: string;
|
|
||||||
action: 'checkAvailability' | 'manifest';
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
nameAvailable: boolean;
|
|
||||||
resultingOrganization?: data.IOrganization;
|
|
||||||
role?: data.IRole;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_UpdateOrganization
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_UpdateOrganization
|
|
||||||
> {
|
|
||||||
method: 'updateOrganization';
|
|
||||||
request: {
|
|
||||||
organization: data.IOrganization;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
organization: data.IOrganization;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import * as data from '../data/index.js';
|
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export interface IReq_GetPlansForOrganizationId
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetPlansForOrganizationId
|
|
||||||
> {
|
|
||||||
method: 'getBillingPlansForOrganizationId';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
billingPlans: data.IBillingPlan[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IUser } from '../data/index.js';
|
|
||||||
|
|
||||||
export interface IReq_FirstRegistration
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_FirstRegistration
|
|
||||||
> {
|
|
||||||
method: 'firstRegistrationRequest';
|
|
||||||
request: {
|
|
||||||
email: string;
|
|
||||||
productSlugOfInterest: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
testOnlyToken?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_AfterRegistrationEmailClicked
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_AfterRegistrationEmailClicked
|
|
||||||
> {
|
|
||||||
method: 'afterRegistrationEmailClicked';
|
|
||||||
request: {
|
|
||||||
/**
|
|
||||||
* the token that has been sent with the registation email to verify access
|
|
||||||
*/
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
/**
|
|
||||||
* the email thats associated with the given request token
|
|
||||||
*/
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_SetDataForRegistration
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_SetDataForRegistration
|
|
||||||
> {
|
|
||||||
method: 'setDataForRegistration';
|
|
||||||
request: {
|
|
||||||
token: string;
|
|
||||||
userData: IUser['data'];
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should be used to verify a mobile number for an verifcation
|
|
||||||
*/
|
|
||||||
export interface IReq_MobileVerificationForRegistration
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_MobileVerificationForRegistration
|
|
||||||
> {
|
|
||||||
method: 'mobileVerificationForRegistration';
|
|
||||||
request: {
|
|
||||||
token: string;
|
|
||||||
mobileNumber?: string;
|
|
||||||
verificationCode?: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
messageSent?: boolean;
|
|
||||||
verficationCodeOk?: boolean;
|
|
||||||
testOnlySmsCode?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_FinishRegistration
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_FinishRegistration
|
|
||||||
> {
|
|
||||||
method: 'finishRegistration';
|
|
||||||
request: {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
status: 'ok' | 'not ok';
|
|
||||||
userData?: IUser['data'];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import * as data from '../data/index.js';
|
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export interface IReq_GetUserData
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetUserData
|
|
||||||
> {
|
|
||||||
method: 'getUserData';
|
|
||||||
request: {
|
|
||||||
refreshToken: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_SetUserData
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_SetUserData
|
|
||||||
> {
|
|
||||||
method: 'setUserData';
|
|
||||||
request: {
|
|
||||||
refreshToken: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
oneTimeTransferCode: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_SuspendUser
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_SuspendUser
|
|
||||||
> {
|
|
||||||
method: 'suspendUser';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
publicKeyPem: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IDeleteSuspendedUser
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IDeleteSuspendedUser
|
|
||||||
> {
|
|
||||||
method: 'deleteSuspendedUser';
|
|
||||||
request: {
|
|
||||||
backendToken: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
ok: boolean;
|
|
||||||
errorText?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_GetRolesAndOrganizationsForUserId
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetRolesAndOrganizationsForUserId
|
|
||||||
> {
|
|
||||||
method: 'getRolesAndOrganizationsForUserId';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
roles: data.IRole[];
|
|
||||||
organizations: data.IOrganization[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_WhoIs {
|
|
||||||
method: 'whoIs';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
user: data.IUser;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_GetUserSessions
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetUserSessions
|
|
||||||
> {
|
|
||||||
method: 'getUserSessions';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
sessions: Array<{
|
|
||||||
id: string;
|
|
||||||
deviceId: string;
|
|
||||||
deviceName: string;
|
|
||||||
browser: string;
|
|
||||||
os: string;
|
|
||||||
ip: string;
|
|
||||||
lastActive: number;
|
|
||||||
createdAt: number;
|
|
||||||
isCurrent: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_RevokeSession
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_RevokeSession
|
|
||||||
> {
|
|
||||||
method: 'revokeSession';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
sessionId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IReq_GetUserActivity
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetUserActivity
|
|
||||||
> {
|
|
||||||
method: 'getUserActivity';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
activities: data.IActivityLog[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import * as data from '../data/index.js';
|
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an invitation to join an organization
|
|
||||||
*/
|
|
||||||
export interface IReq_CreateInvitation
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_CreateInvitation
|
|
||||||
> {
|
|
||||||
method: 'createInvitation';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
email: string;
|
|
||||||
roles: string[];
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
invitation?: data.IUserInvitation;
|
|
||||||
message?: string;
|
|
||||||
/** True if a new invitation was created, false if email was added to existing */
|
|
||||||
isNew: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get pending invitations for an organization
|
|
||||||
*/
|
|
||||||
export interface IReq_GetOrgInvitations
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetOrgInvitations
|
|
||||||
> {
|
|
||||||
method: 'getOrgInvitations';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
invitations: data.IUserInvitation[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get members of an organization (users with roles)
|
|
||||||
*/
|
|
||||||
export interface IReq_GetOrgMembers
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetOrgMembers
|
|
||||||
> {
|
|
||||||
method: 'getOrgMembers';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
members: Array<{
|
|
||||||
user: data.IUser;
|
|
||||||
role: data.IRole;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a pending invitation
|
|
||||||
*/
|
|
||||||
export interface IReq_CancelInvitation
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_CancelInvitation
|
|
||||||
> {
|
|
||||||
method: 'cancelInvitation';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
invitationId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend invitation email
|
|
||||||
*/
|
|
||||||
export interface IReq_ResendInvitation
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_ResendInvitation
|
|
||||||
> {
|
|
||||||
method: 'resendInvitation';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
invitationId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a member from an organization
|
|
||||||
*/
|
|
||||||
export interface IReq_RemoveMember
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_RemoveMember
|
|
||||||
> {
|
|
||||||
method: 'removeMember';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a member's roles
|
|
||||||
*/
|
|
||||||
export interface IReq_UpdateMemberRoles
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_UpdateMemberRoles
|
|
||||||
> {
|
|
||||||
method: 'updateMemberRoles';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
userId: string;
|
|
||||||
roles: string[];
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
role?: data.IRole;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transfer organization ownership to another member
|
|
||||||
*/
|
|
||||||
export interface IReq_TransferOwnership
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_TransferOwnership
|
|
||||||
> {
|
|
||||||
method: 'transferOwnership';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
newOwnerId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept an invitation (called during registration or email verification)
|
|
||||||
*/
|
|
||||||
export interface IReq_AcceptInvitation
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_AcceptInvitation
|
|
||||||
> {
|
|
||||||
method: 'acceptInvitation';
|
|
||||||
request: {
|
|
||||||
token: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
organizations?: data.IOrganization[];
|
|
||||||
roles?: data.IRole[];
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get invitation by token (for invitation landing page)
|
|
||||||
*/
|
|
||||||
export interface IReq_GetInvitationByToken
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_GetInvitationByToken
|
|
||||||
> {
|
|
||||||
method: 'getInvitationByToken';
|
|
||||||
request: {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
invitation?: data.IUserInvitation;
|
|
||||||
organizations?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
isExpired: boolean;
|
|
||||||
requiresRegistration: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk create invitations from a list (typically from CSV import)
|
|
||||||
*/
|
|
||||||
export interface IReq_BulkCreateInvitations
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_BulkCreateInvitations
|
|
||||||
> {
|
|
||||||
method: 'bulkCreateInvitations';
|
|
||||||
request: {
|
|
||||||
jwt: string;
|
|
||||||
organizationId: string;
|
|
||||||
invitations: Array<{
|
|
||||||
email: string;
|
|
||||||
roles?: string[];
|
|
||||||
}>;
|
|
||||||
defaultRoles: string[];
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
success: boolean;
|
|
||||||
results: Array<{
|
|
||||||
email: string;
|
|
||||||
success: boolean;
|
|
||||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
|
||||||
message?: string;
|
|
||||||
}>;
|
|
||||||
summary: {
|
|
||||||
total: number;
|
|
||||||
invited: number;
|
|
||||||
alreadyMembers: number;
|
|
||||||
invalid: number;
|
|
||||||
errors: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
|
|
||||||
export interface ITag_LolePubapi
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTag<
|
|
||||||
plugins.typedRequestInterfaces.ITag,
|
|
||||||
ITag_LolePubapi
|
|
||||||
> {
|
|
||||||
name: 'lole-reception';
|
|
||||||
payload: {
|
|
||||||
backendToken: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { App } from '../ts/reception/classes.app.js';
|
||||||
|
import { Organization } from '../ts/reception/classes.organization.js';
|
||||||
|
import { Role } from '../ts/reception/classes.role.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
|
||||||
|
export type TSeedScenario = 'admin' | 'workspace' | 'globalApps';
|
||||||
|
|
||||||
|
export interface ISeedOptions {
|
||||||
|
scenario: TSeedScenario;
|
||||||
|
adminEmail: string;
|
||||||
|
adminPassword: string;
|
||||||
|
adminName: string;
|
||||||
|
organizationName: string;
|
||||||
|
organizationSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SeedRunner {
|
||||||
|
public qenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
||||||
|
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||||
|
|
||||||
|
public CUser = plugins.smartdata.setDefaultManagerForDoc(this, User);
|
||||||
|
public COrganization = plugins.smartdata.setDefaultManagerForDoc(this, Organization);
|
||||||
|
public CRole = plugins.smartdata.setDefaultManagerForDoc(this, Role);
|
||||||
|
public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App);
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
const mongoDbUrl = await this.qenv.getEnvVarOnDemandStrict('MONGODB_URL');
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({ mongoDbUrl });
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async seed(optionsArg: ISeedOptions) {
|
||||||
|
if (optionsArg.scenario === 'globalApps') {
|
||||||
|
await this.seedGlobalApps();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = await this.seedAdminUser(optionsArg);
|
||||||
|
const organization = await this.seedOrganization(optionsArg, adminUser.id);
|
||||||
|
await this.seedOwnerRole(adminUser.id, organization.id);
|
||||||
|
await this.seedGlobalApps();
|
||||||
|
|
||||||
|
if (optionsArg.scenario === 'workspace') {
|
||||||
|
await this.seedWorkspaceUsers(organization.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedAdminUser(optionsArg: ISeedOptions) {
|
||||||
|
let adminUser = await this.CUser.getInstance({
|
||||||
|
data: {
|
||||||
|
email: optionsArg.adminEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
adminUser = await this.CUser.createNewUserForUserData({
|
||||||
|
name: optionsArg.adminName,
|
||||||
|
username: optionsArg.adminEmail,
|
||||||
|
email: optionsArg.adminEmail,
|
||||||
|
password: optionsArg.adminPassword,
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser.data.name = optionsArg.adminName;
|
||||||
|
adminUser.data.username = optionsArg.adminEmail;
|
||||||
|
adminUser.data.email = optionsArg.adminEmail;
|
||||||
|
adminUser.data.status = 'active';
|
||||||
|
adminUser.data.isGlobalAdmin = true;
|
||||||
|
adminUser.data.passwordHash = await this.CUser.hashPassword(optionsArg.adminPassword);
|
||||||
|
await adminUser.save();
|
||||||
|
|
||||||
|
return adminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedOrganization(optionsArg: ISeedOptions, adminUserIdArg: string) {
|
||||||
|
let organization = await this.COrganization.getInstance({
|
||||||
|
data: {
|
||||||
|
slug: optionsArg.organizationSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
organization = await this.COrganization.createNewOrganizationForUser(
|
||||||
|
this as any,
|
||||||
|
adminUserIdArg,
|
||||||
|
optionsArg.organizationName,
|
||||||
|
optionsArg.organizationSlug,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
organization.data.name = optionsArg.organizationName;
|
||||||
|
organization.data.slug = optionsArg.organizationSlug;
|
||||||
|
organization.data.roleIds = organization.data.roleIds || [];
|
||||||
|
this.seedDefaultOrgRoleDefinitions(organization);
|
||||||
|
await organization.save();
|
||||||
|
|
||||||
|
const adminUser = await this.CUser.getInstance({ id: adminUserIdArg });
|
||||||
|
if (adminUser && !adminUser.data.connectedOrgs.includes(organization.id)) {
|
||||||
|
adminUser.data.connectedOrgs.push(organization.id);
|
||||||
|
await adminUser.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedDefaultOrgRoleDefinitions(organizationArg: Organization) {
|
||||||
|
const now = Date.now();
|
||||||
|
const defaultRoleDefinitions = [
|
||||||
|
{ key: 'finance', name: 'Finance', description: 'Billing, invoice, and procurement access.' },
|
||||||
|
{ key: 'engineering', name: 'Engineering', description: 'Developer and infrastructure access.' },
|
||||||
|
{ key: 'support', name: 'Support', description: 'Customer and incident support access.' },
|
||||||
|
{ key: 'contractor', name: 'Contractor', description: 'Limited temporary external access.' },
|
||||||
|
];
|
||||||
|
const roleDefinitions = organizationArg.data.roleDefinitions || [];
|
||||||
|
for (const defaultRoleDefinition of defaultRoleDefinitions) {
|
||||||
|
const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === defaultRoleDefinition.key);
|
||||||
|
if (existingRoleDefinition) {
|
||||||
|
existingRoleDefinition.name = defaultRoleDefinition.name;
|
||||||
|
existingRoleDefinition.description = defaultRoleDefinition.description;
|
||||||
|
existingRoleDefinition.updatedAt = now;
|
||||||
|
} else {
|
||||||
|
roleDefinitions.push({
|
||||||
|
...defaultRoleDefinition,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
organizationArg.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedOwnerRole(userIdArg: string, organizationIdArg: string) {
|
||||||
|
let role = await this.CRole.getInstance({
|
||||||
|
data: {
|
||||||
|
userId: userIdArg,
|
||||||
|
organizationId: organizationIdArg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
role = new this.CRole();
|
||||||
|
role.id = plugins.smartunique.shortId();
|
||||||
|
role.data = {
|
||||||
|
userId: userIdArg,
|
||||||
|
organizationId: organizationIdArg,
|
||||||
|
roles: ['owner', 'admin'],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
role.data.roles = [...new Set([...role.data.roles, 'owner', 'admin'])];
|
||||||
|
}
|
||||||
|
await role.save();
|
||||||
|
|
||||||
|
const organization = await this.COrganization.getInstance({ id: organizationIdArg });
|
||||||
|
if (organization && !organization.data.roleIds.includes(role.id)) {
|
||||||
|
organization.data.roleIds.push(role.id);
|
||||||
|
await organization.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedWorkspaceUsers(organizationIdArg: string) {
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
email: 'alex@idp.global',
|
||||||
|
name: 'Alex Mercer',
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'jane@idp.global',
|
||||||
|
name: 'Jane Doe',
|
||||||
|
roles: ['editor'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'sam@idp.global',
|
||||||
|
name: 'Sam Chen',
|
||||||
|
roles: ['viewer'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const userData of users) {
|
||||||
|
let user = await this.CUser.getInstance({
|
||||||
|
data: {
|
||||||
|
email: userData.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
user = await this.CUser.createNewUserForUserData({
|
||||||
|
name: userData.name,
|
||||||
|
username: userData.email,
|
||||||
|
email: userData.email,
|
||||||
|
password: 'idp.global',
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
user.data.name = userData.name;
|
||||||
|
user.data.username = userData.email;
|
||||||
|
user.data.status = 'active';
|
||||||
|
user.data.passwordHash = await this.CUser.hashPassword('idp.global');
|
||||||
|
if (!user.data.connectedOrgs.includes(organizationIdArg)) {
|
||||||
|
user.data.connectedOrgs.push(organizationIdArg);
|
||||||
|
}
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
let role = await this.CRole.getInstance({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: organizationIdArg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
role = new this.CRole();
|
||||||
|
role.id = plugins.smartunique.shortId();
|
||||||
|
}
|
||||||
|
role.data = {
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: organizationIdArg,
|
||||||
|
roles: userData.roles,
|
||||||
|
};
|
||||||
|
await role.save();
|
||||||
|
|
||||||
|
const organization = await this.COrganization.getInstance({ id: organizationIdArg });
|
||||||
|
if (organization && !organization.data.roleIds.includes(role.id)) {
|
||||||
|
organization.data.roleIds.push(role.id);
|
||||||
|
await organization.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedGlobalApps() {
|
||||||
|
const defaultGlobalApps: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
logoUrl: string;
|
||||||
|
appUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
category: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'app-foss-global',
|
||||||
|
name: 'foss.global',
|
||||||
|
description: 'Open Source Package Registry and Collaboration Platform',
|
||||||
|
logoUrl: 'https://foss.global/assets/logo.png',
|
||||||
|
appUrl: 'https://foss.global',
|
||||||
|
clientId: 'foss-global-client',
|
||||||
|
redirectUris: ['https://foss.global/auth/callback'],
|
||||||
|
category: 'Development',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-task-vc',
|
||||||
|
name: 'task.vc',
|
||||||
|
description: 'Task Management and Project Collaboration',
|
||||||
|
logoUrl: 'https://task.vc/assets/logo.png',
|
||||||
|
appUrl: 'https://task.vc',
|
||||||
|
clientId: 'task-vc-client',
|
||||||
|
redirectUris: ['https://task.vc/auth/callback'],
|
||||||
|
category: 'Productivity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-hetzner-cloud',
|
||||||
|
name: 'Hetzner Cloud',
|
||||||
|
description: 'Cloud infrastructure console access',
|
||||||
|
logoUrl: 'https://www.hetzner.com/favicon.ico',
|
||||||
|
appUrl: 'https://console.hetzner.cloud',
|
||||||
|
clientId: 'hetzner-cloud-client',
|
||||||
|
redirectUris: ['https://console.hetzner.cloud/oauth/callback'],
|
||||||
|
category: 'Infrastructure',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const appData of defaultGlobalApps) {
|
||||||
|
let app = await this.CApp.getInstance({ id: appData.id });
|
||||||
|
if (!app) {
|
||||||
|
app = new this.CApp();
|
||||||
|
app.id = appData.id;
|
||||||
|
app.type = 'global';
|
||||||
|
}
|
||||||
|
app.data = {
|
||||||
|
name: appData.name,
|
||||||
|
description: appData.description,
|
||||||
|
logoUrl: appData.logoUrl,
|
||||||
|
appUrl: appData.appUrl,
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: appData.clientId,
|
||||||
|
clientSecretHash: '',
|
||||||
|
redirectUris: appData.redirectUris,
|
||||||
|
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
||||||
|
grantTypes: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
category: appData.category,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
createdByUserId: 'seed',
|
||||||
|
};
|
||||||
|
await app.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { runCli } from './index.js';
|
||||||
|
|
||||||
|
await runCli();
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { SeedRunner, type ISeedOptions, type TSeedScenario } from './classes.seedrunner.js';
|
||||||
|
|
||||||
|
export { SeedRunner } from './classes.seedrunner.js';
|
||||||
|
|
||||||
|
const defaults: ISeedOptions = {
|
||||||
|
scenario: 'workspace',
|
||||||
|
adminEmail: 'admin@idp.global',
|
||||||
|
adminPassword: 'idp.global',
|
||||||
|
adminName: 'IDP Global Admin',
|
||||||
|
organizationName: 'Lossless GmbH',
|
||||||
|
organizationSlug: 'lossless',
|
||||||
|
};
|
||||||
|
|
||||||
|
const scenarios: TSeedScenario[] = ['admin', 'workspace', 'globalApps'];
|
||||||
|
|
||||||
|
const getArgValue = (nameArg: string) => {
|
||||||
|
const prefix = `--${nameArg}=`;
|
||||||
|
const prefixedArg = plugins.process.argv.find((arg) => arg.startsWith(prefix));
|
||||||
|
if (prefixedArg) {
|
||||||
|
return prefixedArg.slice(prefix.length);
|
||||||
|
}
|
||||||
|
const argIndex = plugins.process.argv.indexOf(`--${nameArg}`);
|
||||||
|
return argIndex >= 0 ? plugins.process.argv[argIndex + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScenarioFromArgs = (): TSeedScenario | null => {
|
||||||
|
const scenarioArg = getArgValue('scenario') as TSeedScenario | undefined;
|
||||||
|
return scenarioArg && scenarios.includes(scenarioArg) ? scenarioArg : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runCli = async () => {
|
||||||
|
const skipPrompts = plugins.process.argv.includes('--yes') || plugins.process.argv.includes('-y');
|
||||||
|
if (skipPrompts) {
|
||||||
|
const scenario = getScenarioFromArgs() || defaults.scenario;
|
||||||
|
const runner = new SeedRunner();
|
||||||
|
await runner.start();
|
||||||
|
try {
|
||||||
|
await runner.seed({
|
||||||
|
...defaults,
|
||||||
|
scenario,
|
||||||
|
adminEmail: getArgValue('adminEmail') || plugins.process.env.IDP_DEMO_ADMIN_EMAIL || defaults.adminEmail,
|
||||||
|
adminPassword: getArgValue('adminPassword') || plugins.process.env.IDP_DEMO_ADMIN_PASSWORD || defaults.adminPassword,
|
||||||
|
adminName: getArgValue('adminName') || plugins.process.env.IDP_DEMO_ADMIN_NAME || defaults.adminName,
|
||||||
|
organizationName: getArgValue('organizationName') || plugins.process.env.IDP_DEMO_ORG_NAME || defaults.organizationName,
|
||||||
|
organizationSlug: getArgValue('organizationSlug') || plugins.process.env.IDP_DEMO_ORG_SLUG || defaults.organizationSlug,
|
||||||
|
});
|
||||||
|
console.log('Seed complete.');
|
||||||
|
} finally {
|
||||||
|
await runner.stop();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interact = new plugins.smartinteract.SmartInteract();
|
||||||
|
|
||||||
|
const scenarioAnswer = await interact.askQuestion({
|
||||||
|
name: 'scenario',
|
||||||
|
type: 'list',
|
||||||
|
message: 'Which seed scenario do you want to apply?',
|
||||||
|
default: defaults.scenario,
|
||||||
|
choices: [
|
||||||
|
{ name: 'Demo workspace (admin, org, demo users, global apps)', value: 'workspace' },
|
||||||
|
{ name: 'Admin only (admin, org, global apps)', value: 'admin' },
|
||||||
|
{ name: 'Global apps only', value: 'globalApps' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenario = scenarioAnswer.value as TSeedScenario;
|
||||||
|
const options: ISeedOptions = {
|
||||||
|
...defaults,
|
||||||
|
scenario,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scenario !== 'globalApps') {
|
||||||
|
options.adminEmail = (await interact.askQuestion({
|
||||||
|
name: 'adminEmail',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Admin email:',
|
||||||
|
default: defaults.adminEmail,
|
||||||
|
})).value as string;
|
||||||
|
|
||||||
|
options.adminPassword = (await interact.askQuestion({
|
||||||
|
name: 'adminPassword',
|
||||||
|
type: 'password',
|
||||||
|
message: 'Admin password:',
|
||||||
|
default: defaults.adminPassword,
|
||||||
|
})).value as string;
|
||||||
|
|
||||||
|
options.adminName = (await interact.askQuestion({
|
||||||
|
name: 'adminName',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Admin display name:',
|
||||||
|
default: defaults.adminName,
|
||||||
|
})).value as string;
|
||||||
|
|
||||||
|
options.organizationName = (await interact.askQuestion({
|
||||||
|
name: 'organizationName',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Organization name:',
|
||||||
|
default: defaults.organizationName,
|
||||||
|
})).value as string;
|
||||||
|
|
||||||
|
options.organizationSlug = (await interact.askQuestion({
|
||||||
|
name: 'organizationSlug',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Organization slug:',
|
||||||
|
default: defaults.organizationSlug,
|
||||||
|
})).value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmAnswer = await interact.askQuestion({
|
||||||
|
name: 'confirm',
|
||||||
|
type: 'confirm',
|
||||||
|
message: `Apply ${scenario} seed data to the configured database?`,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmAnswer.value) {
|
||||||
|
console.log('Seed cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runner = new SeedRunner();
|
||||||
|
await runner.start();
|
||||||
|
try {
|
||||||
|
await runner.seed(options);
|
||||||
|
console.log('Seed complete.');
|
||||||
|
if (scenario !== 'globalApps') {
|
||||||
|
console.log(`Admin email: ${options.adminEmail}`);
|
||||||
|
console.log(`Admin password: ${options.adminPassword}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runner.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user