Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525a72b73b | |||
| d913dfaeb1 | |||
| fe9da65437 | |||
| 28d30fe392 | |||
| 1532c9704b | |||
| 76efcb835f | |||
| 2d1e6ea6e1 | |||
| 98e614a945 | |||
| ad3e51a9e8 | |||
| d8f72d620a | |||
| 53b36e506c | |||
| 7d5ad29a27 |
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 |
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -50,5 +50,40 @@
|
||||
"registries": ["https://verdaccio.lossless.digital"],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tswatch": {
|
||||
"preset": "service",
|
||||
"server": {
|
||||
"enabled": false
|
||||
},
|
||||
"watchers": [
|
||||
{
|
||||
"name": "backend",
|
||||
"watch": "./ts/**/*",
|
||||
"command": "npm run startTs",
|
||||
"restart": true,
|
||||
"debounce": 300,
|
||||
"runOnStart": true
|
||||
}
|
||||
],
|
||||
"bundles": [
|
||||
{
|
||||
"name": "website",
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"watchPatterns": ["./ts_web/**/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+29
-27
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@idp.global/idp.global",
|
||||
"version": "1.14.1",
|
||||
"version": "1.19.0",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "npm run build",
|
||||
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle website --production",
|
||||
"watch": "tswatch website",
|
||||
"test": "pnpm run build && tstest test/",
|
||||
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
|
||||
"watch": "tswatch",
|
||||
"start": "(node cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"buildDocs": "tsdoc"
|
||||
@@ -16,49 +16,51 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.1.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@consent.software/catalog": "^2.0.1",
|
||||
"@design.estate/dees-catalog": "^3.4.0",
|
||||
"@design.estate/dees-domtools": "^2.3.6",
|
||||
"@design.estate/dees-element": "^2.1.3",
|
||||
"@git.zone/tspublish": "^1.11.0",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-domtools": "^2.5.4",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@git.zone/tspublish": "^1.11.5",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartcli": "^4.0.19",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartcli": "^4.0.20",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartfile": "^13.1.0",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartinteract": "^2.0.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.27",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smarturl": "^3.1.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.9",
|
||||
"@push.rocks/websetup": "^3.0.15",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
"@push.rocks/webstore": "^2.0.21",
|
||||
"@serve.zone/platformclient": "^1.1.2",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"@uptime.link/webwidget": "^1.2.6"
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@uptime.link/webwidget": "^1.2.6",
|
||||
"argon2": "^0.44.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.0.2",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tswatch": "^2.3.13",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@types/node": "^25.0.3"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
Generated
+3777
-2181
File diff suppressed because it is too large
Load Diff
@@ -1,314 +1,194 @@
|
||||
# @idp.global/idp.global
|
||||
|
||||
🔐 **A modern, open-source Identity Provider (IdP) SaaS platform** for managing user authentication, registrations, sessions, and organization-based access control.
|
||||
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, and OpenID Connect in one TypeScript codebase.
|
||||
|
||||
Built with TypeScript and designed for modern web applications, idp.global provides a complete identity management solution that you can self-host or use as a service.
|
||||
This repository ships the `idp.global` server, the browser/client SDK, the CLI, shared request/data interfaces, and the web UI used by the hosted service.
|
||||
|
||||
## 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.
|
||||
|
||||
## ✨ Features
|
||||
## What It Does
|
||||
|
||||
### 🔑 Authentication & Authorization
|
||||
- **Multiple Login Methods**: Email/password, email magic links, API tokens
|
||||
- **JWT-Based Sessions**: Secure token management with automatic refresh
|
||||
- **Two-Factor Authentication**: Enhanced security with 2FA support
|
||||
- **Password Reset**: Secure password recovery flow
|
||||
- **Device Management**: Track and manage authenticated devices
|
||||
- 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.
|
||||
- Includes a reusable browser client and a terminal CLI for common account and org workflows.
|
||||
|
||||
### 🏢 Organization Management
|
||||
- **Multi-Tenant Architecture**: Support multiple organizations per user
|
||||
- **Role-Based Access Control (RBAC)**: Fine-grained permissions system
|
||||
- **Organization Roles**: Admin, member, and custom role support
|
||||
- **Member Invitations**: Bulk invite and manage team members
|
||||
- **Ownership Transfer**: Seamlessly transfer organization ownership
|
||||
## Monorepo Modules
|
||||
|
||||
### 🔗 Third-Party Integration
|
||||
- **OpenID Connect (OIDC) Provider**: Full OIDC compliance for third-party apps
|
||||
- Discovery endpoint (`/.well-known/openid-configuration`)
|
||||
- JWKS endpoint for token verification
|
||||
- Authorization code flow with PKCE
|
||||
- Token refresh and revocation
|
||||
- **OAuth 2.0**: Standard OAuth flows for app authorization
|
||||
- **Supported Scopes**: `openid`, `profile`, `email`, `organizations`, `roles`
|
||||
| Folder | Purpose |
|
||||
| --- | --- |
|
||||
| `ts/` | Backend service entrypoint and the core `Reception` managers |
|
||||
| `ts_interfaces/` | Shared request and data contracts used by server, client, CLI, and UI |
|
||||
| `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 |
|
||||
|
||||
### 💳 Billing Integration
|
||||
- **Paddle Integration**: Built-in payment processing support
|
||||
- **Billing Plans**: Flexible subscription management
|
||||
- **Checkout Flows**: Streamlined payment experiences
|
||||
## Core Backend Pieces
|
||||
|
||||
### 🎨 Modern Web UI
|
||||
- **Responsive Design**: Beautiful UI components built with `@design.estate/dees-catalog`
|
||||
- **Account Management**: User profile, settings, and preferences
|
||||
- **Organization Dashboard**: Manage members, roles, and apps
|
||||
- **Admin Panel**: Global administration interface
|
||||
`Reception` wires the service together and starts these managers:
|
||||
|
||||
### 📡 Real-Time Communication
|
||||
- **WebSocket Support**: Real-time updates via TypedSocket
|
||||
- **Typed API Requests**: Type-safe client-server communication
|
||||
- **Public Key Distribution**: Automatic JWT key rotation notifications
|
||||
- `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.
|
||||
- `OidcManager` for the OIDC/OAuth provider surface.
|
||||
|
||||
## 🏗️ Architecture
|
||||
## Quick Start
|
||||
|
||||
idp.global is built as a modular TypeScript monorepo:
|
||||
### Prerequisites
|
||||
|
||||
```
|
||||
├── ts/ # Server-side code (Node.js)
|
||||
│ └── reception/ # Core identity management logic
|
||||
├── ts_interfaces/ # Shared TypeScript interfaces (published as @idp.global/interfaces)
|
||||
├── ts_idpclient/ # Browser/Node client library (published as @idp.global/idpclient)
|
||||
├── ts_idpcli/ # Command-line interface tool
|
||||
└── ts_web/ # Web frontend (published as @idp.global/web)
|
||||
```
|
||||
- Node.js 20+
|
||||
- `pnpm`
|
||||
- MongoDB
|
||||
|
||||
### Core Managers
|
||||
|
||||
| Manager | Responsibility |
|
||||
|---------|----------------|
|
||||
| `JwtManager` | JWT generation, validation, and key management |
|
||||
| `LoginSessionManager` | Session creation and authentication |
|
||||
| `UserManager` | User CRUD and profile management |
|
||||
| `OrganizationManager` | Organization lifecycle management |
|
||||
| `RoleManager` | RBAC and permission management |
|
||||
| `OidcManager` | OpenID Connect provider functionality |
|
||||
| `AppManager` | OAuth client app registration |
|
||||
| `BillingPlanManager` | Subscription and payment handling |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 🐳 Docker Deployment (Recommended)
|
||||
|
||||
The easiest way to run idp.global is using Docker:
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull code.foss.global/idp.global/idp.global
|
||||
|
||||
# Run with environment variables
|
||||
docker run -d \
|
||||
-p 2999:2999 \
|
||||
-e MONGODB_URL=mongodb://your-mongo:27017/idp \
|
||||
-e IDP_BASEURL=https://your-domain.com \
|
||||
-e INSTANCE_NAME=idp.global \
|
||||
code.foss.global/idp.global/idp.global
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
### Required Environment
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `MONGODB_URL` | MongoDB connection string | ✅ Yes |
|
||||
| `IDP_BASEURL` | Public URL of your idp.global instance | ✅ Yes |
|
||||
| `INSTANCE_NAME` | Name for this IDP instance | No (default: `idp.global`) |
|
||||
| `SERVEZONE_PLATFROM_AUTHORIZATION` | ServeZone platform auth token | No |
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
idp:
|
||||
image: code.foss.global/idp.global/idp.global
|
||||
ports:
|
||||
- "2999:2999"
|
||||
environment:
|
||||
MONGODB_URL: mongodb://mongo:27017/idp
|
||||
IDP_BASEURL: https://idp.yourdomain.com
|
||||
INSTANCE_NAME: my-idp
|
||||
depends_on:
|
||||
- mongo
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
```bash
|
||||
export MONGODB_URL=mongodb://localhost:27017/idp-dev
|
||||
export IDP_BASEURL=http://localhost:2999
|
||||
export INSTANCE_NAME=idp-dev
|
||||
```
|
||||
|
||||
The server listens on port 2999 by default.
|
||||
Optional:
|
||||
|
||||
## 📦 Published Packages
|
||||
- `SERVEZONE_PLATFROM_AUTHORIZATION`
|
||||
- `PADDLE_TOKEN`
|
||||
- `PADDLE_PRICE_ID`
|
||||
|
||||
This monorepo publishes the following npm packages:
|
||||
### Build
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@idp.global/interfaces` | TypeScript interfaces for API contracts |
|
||||
| `@idp.global/idpclient` | Client library for browser and Node.js |
|
||||
| `@idp.global/web` | Web UI components |
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 💻 Client Usage
|
||||
### Run Locally
|
||||
|
||||
### Browser Client
|
||||
```bash
|
||||
pnpm watch
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { IdpClient } from '@idp.global/idpclient';
|
||||
This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`. The service listens on port `2999`.
|
||||
|
||||
## Runtime Surface
|
||||
|
||||
### Web Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` | Welcome page |
|
||||
| `/login` | Login flow |
|
||||
| `/register` | Registration flow |
|
||||
| `/finishregistration` | Multi-step registration completion |
|
||||
| `/account` | Signed-in account area |
|
||||
|
||||
### 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`.
|
||||
|
||||
## SDK Example
|
||||
|
||||
The browser SDK lives in `ts_idpclient/` and is published as `@idp.global/client`.
|
||||
|
||||
```ts
|
||||
import { IdpClient } from '@idp.global/client';
|
||||
|
||||
// Initialize the client
|
||||
const idpClient = new IdpClient('https://idp.global');
|
||||
|
||||
// Enable WebSocket connection
|
||||
await idpClient.enableTypedSocket();
|
||||
|
||||
// Check login status
|
||||
const isLoggedIn = await idpClient.determineLoginStatus();
|
||||
|
||||
// Login with email and password
|
||||
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||
if (!isLoggedIn) {
|
||||
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||
username: 'user@example.com',
|
||||
password: 'securepassword'
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
if (response.refreshToken) {
|
||||
await idpClient.refreshJwt(response.refreshToken);
|
||||
console.log('✅ Login successful!');
|
||||
if (loginResult.refreshToken) {
|
||||
await idpClient.refreshJwt(loginResult.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current user info
|
||||
const userInfo = await idpClient.whoIs();
|
||||
console.log('User:', userInfo.user);
|
||||
|
||||
// Get user's organizations
|
||||
const orgs = await idpClient.getRolesAndOrganizations();
|
||||
console.log('Organizations:', orgs.organizations);
|
||||
const whoIs = await idpClient.whoIs();
|
||||
console.log(whoIs.user.data.email);
|
||||
```
|
||||
|
||||
### Organization Management
|
||||
## CLI Example
|
||||
|
||||
```typescript
|
||||
// Create a new organization
|
||||
const result = await idpClient.createOrganization('My Company', 'my-company', 'manifest');
|
||||
console.log('Created:', result.resultingOrganization);
|
||||
|
||||
// Invite members
|
||||
await idpClient.requests.createInvitation.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
email: 'newmember@example.com',
|
||||
roles: ['member']
|
||||
});
|
||||
```
|
||||
|
||||
### CLI Tool
|
||||
|
||||
The `ts_idpcli` module provides a command-line interface:
|
||||
The terminal client lives in `ts_idpcli/` and is published as `@idp.global/cli`.
|
||||
|
||||
```bash
|
||||
# Login
|
||||
idp login
|
||||
|
||||
# Show current user
|
||||
idp whoami
|
||||
|
||||
# List organizations
|
||||
idp orgs
|
||||
|
||||
# List organization members
|
||||
idp members --org <org-id>
|
||||
|
||||
# Invite a user
|
||||
idp invite --org <org-id> --email user@example.com
|
||||
```
|
||||
|
||||
## 🔐 OIDC Integration
|
||||
The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_URL` to override the target server.
|
||||
|
||||
idp.global implements a full OpenID Connect provider. Third-party applications can use it for SSO:
|
||||
## Shared Interfaces
|
||||
|
||||
### Discovery Document
|
||||
`ts_interfaces/` exports the type contracts shared across the stack:
|
||||
|
||||
```
|
||||
GET /.well-known/openid-configuration
|
||||
```
|
||||
- `data/*` for users, orgs, roles, JWTs, sessions, devices, billing plans, apps, and OIDC payloads.
|
||||
- `request/*` for auth, registration, user, org, invitation, app, admin, billing, and JWT request contracts.
|
||||
- `tags/*` for shared tag exports.
|
||||
|
||||
### Authorization Flow
|
||||
## Frontend
|
||||
|
||||
```
|
||||
GET /oauth/authorize?
|
||||
client_id=your-client-id&
|
||||
redirect_uri=https://yourapp.com/callback&
|
||||
response_type=code&
|
||||
scope=openid profile email organizations&
|
||||
state=random-state&
|
||||
code_challenge=PKCE_CHALLENGE&
|
||||
code_challenge_method=S256
|
||||
```
|
||||
`ts_web/` is the web application bundle. It contains:
|
||||
|
||||
### Token Exchange
|
||||
- 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.
|
||||
|
||||
```
|
||||
POST /oauth/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
## Package Scripts
|
||||
|
||||
grant_type=authorization_code&
|
||||
code=AUTHORIZATION_CODE&
|
||||
redirect_uri=https://yourapp.com/callback&
|
||||
client_id=your-client-id&
|
||||
client_secret=your-client-secret&
|
||||
code_verifier=PKCE_VERIFIER
|
||||
```
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
|
||||
### UserInfo
|
||||
## Repository Notes
|
||||
|
||||
```
|
||||
GET /oauth/userinfo
|
||||
Authorization: Bearer ACCESS_TOKEN
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"sub": "user-id",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"email_verified": true,
|
||||
"organizations": [
|
||||
{ "id": "org-1", "name": "Acme Corp", "slug": "acme", "roles": ["admin"] }
|
||||
],
|
||||
"roles": ["user"]
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Runtime**: Node.js with ES Modules
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **Database**: MongoDB via `@push.rocks/smartdata`
|
||||
- **Web Server**: `@api.global/typedserver`
|
||||
- **Real-time**: `@api.global/typedsocket` (WebSocket)
|
||||
- **JWT**: `@push.rocks/smartjwt` (RS256 signing)
|
||||
- **Frontend**: `@design.estate/dees-element` (Web Components)
|
||||
- **Build**: `@git.zone/tsbuild` + `@git.zone/tsbundle`
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### Request Interfaces
|
||||
|
||||
All API requests are type-safe. See `ts_interfaces/request/` for the complete API:
|
||||
|
||||
- **Authentication**: `IReq_LoginWithEmail`, `IReq_LoginWithApiToken`, `IReq_RefreshJwt`
|
||||
- **Registration**: `IReq_FirstRegistration`, `IReq_FinishRegistration`
|
||||
- **User Management**: `IReq_GetUserData`, `IReq_SetUserData`, `IReq_GetUserSessions`
|
||||
- **Organizations**: `IReq_CreateOrganization`, `IReq_GetOrgMembers`, `IReq_CreateInvitation`
|
||||
- **Apps & OAuth**: `IReq_GetGlobalApps`, `IReq_CreateGlobalApp`
|
||||
- **Billing**: `IReq_GetBillingPlan`, `IReq_UpdatePaymentMethod`
|
||||
|
||||
### Data Models
|
||||
|
||||
See `ts_interfaces/data/` for all data structures:
|
||||
|
||||
- `IUser` - User profile and credentials
|
||||
- `IOrganization` - Organization entity
|
||||
- `IRole` - User roles within organizations
|
||||
- `IJwt` - JWT token structure
|
||||
- `IApp` - OAuth application definitions
|
||||
- `IOidcAccessToken`, `IAuthorizationCode` - OIDC tokens
|
||||
- Package manager: `pnpm`
|
||||
- Main backend entrypoint: `ts/index.ts`
|
||||
- Frontend entrypoint: `ts_web/index.ts`
|
||||
- Browser SDK entrypoint: `ts_idpclient/index.ts`
|
||||
- CLI entrypoint: `ts_idpcli/index.ts`
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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,76 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
|
||||
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js';
|
||||
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.14.1',
|
||||
version: '1.19.0',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
// Native scope
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as path from 'path';
|
||||
export { path };
|
||||
export { crypto, path };
|
||||
|
||||
// Project scope
|
||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
||||
@@ -32,8 +33,10 @@ import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smarttime from '@push.rocks/smarttime';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
export {
|
||||
argon2,
|
||||
lik,
|
||||
projectinfo,
|
||||
qenv,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# `ts/` Backend Module
|
||||
|
||||
The `ts/` folder contains the server runtime for `idp.global`: startup, website server wiring, typed routes, OIDC endpoints, 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, 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_PLATFROM_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 |
|
||||
| `OidcManager` | OIDC discovery, auth code flow, token exchange, userinfo, revoke |
|
||||
|
||||
## 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,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,46 @@ export class ReceptionHousekeeping {
|
||||
'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.start();
|
||||
logger.log('info', 'housekeeping started');
|
||||
}
|
||||
|
||||
+36
-14
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { JwtManager } from './classes.jwtmanager.js';
|
||||
import type { LoginSession } from './classes.loginsession.js';
|
||||
|
||||
/**
|
||||
* 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(
|
||||
jwtManagerInstance: JwtManager,
|
||||
refreshTokenArg: string
|
||||
) {
|
||||
const loginSession =
|
||||
await jwtManagerInstance.receptionRef.loginSessionManager.CLoginSession.getLoginSessionByRefreshToken(
|
||||
): Promise<string | null> {
|
||||
const sessionLookup =
|
||||
await jwtManagerInstance.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
|
||||
refreshTokenArg
|
||||
);
|
||||
if (!loginSession) {
|
||||
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
|
||||
return null;
|
||||
}
|
||||
const refreshTokenValid = await loginSession.validateRefreshToken(refreshTokenArg);
|
||||
if (!refreshTokenValid) {
|
||||
return null;
|
||||
return this.createJwtForLoginSession(jwtManagerInstance, sessionLookup.loginSession);
|
||||
}
|
||||
|
||||
public static async createJwtForLoginSession(
|
||||
jwtManagerInstance: JwtManager,
|
||||
loginSession: LoginSession
|
||||
): Promise<string | null> {
|
||||
const user = await jwtManagerInstance.receptionRef.userManager.CUser.getInstance({
|
||||
id: loginSession.data.userId,
|
||||
});
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const validUntil = plugins.smarttime.ExtendedDate.fromMillis(
|
||||
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.data = {
|
||||
userId: user.id,
|
||||
sessionId: loginSession.id,
|
||||
validUntil: validUntil.getTime(),
|
||||
refreshEvery: 1000000,
|
||||
refreshFrom: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 0.5 }),
|
||||
refreshToken: await loginSession.getRefreshToken(), // TODO: handle multiple refresh tokens
|
||||
justForLooks: {
|
||||
validUntilIsoString: validUntil.toISOString(),
|
||||
}
|
||||
@@ -46,7 +53,7 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
|
||||
|
||||
const jwtString = await jwtManagerInstance.smartjwtInstance.createJWT({
|
||||
id: jwt.id,
|
||||
blocked: null,
|
||||
blocked: false,
|
||||
data: jwt.data,
|
||||
} as plugins.idpInterfaces.data.IJwt);
|
||||
return jwtString;
|
||||
@@ -68,11 +75,26 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
|
||||
}
|
||||
|
||||
public async getLoginSession() {
|
||||
const loginSession = await this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
|
||||
data: {
|
||||
refreshToken: this.data.refreshToken,
|
||||
}
|
||||
if (this.data.sessionId) {
|
||||
return this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
|
||||
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(
|
||||
'refreshJwt',
|
||||
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 {
|
||||
status: 'loggedIn',
|
||||
jwt: resultJwt,
|
||||
refreshToken: rotatedRefreshToken,
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -120,19 +151,24 @@ export class JwtManager {
|
||||
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 jwt = await this.CJwt.getInstance({
|
||||
id: jwtData.id,
|
||||
});
|
||||
if (!jwt) {
|
||||
return null;
|
||||
}
|
||||
if (jwt.blocked) {
|
||||
return null;
|
||||
}
|
||||
if (jwt) {
|
||||
const loginSession = await jwt.getLoginSession();
|
||||
if (!loginSession) {
|
||||
if (!loginSession || loginSession.data.invalidated) {
|
||||
await jwt.block();
|
||||
if (!this.blockedJwtIdList.includes(jwt.id)) {
|
||||
this.blockedJwtIdList.push(jwt.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as plugins from '../plugins.js';
|
||||
import { LoginSessionManager } from './classes.loginsessionmanager.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
|
||||
*/
|
||||
@@ -40,7 +42,14 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
}
|
||||
|
||||
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: {
|
||||
refreshToken: refreshTokenArg,
|
||||
},
|
||||
@@ -48,6 +57,14 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
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
|
||||
// ========
|
||||
@@ -60,13 +77,17 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
|
||||
invalidated: false,
|
||||
refreshToken: null,
|
||||
refreshTokenHash: null,
|
||||
rotatedRefreshTokenHashes: [],
|
||||
transferTokenHash: null,
|
||||
transferTokenExpiresAt: null,
|
||||
deviceId: null,
|
||||
deviceInfo: null,
|
||||
createdAt: Date.now(),
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
|
||||
public transferToken: string;
|
||||
public transferToken: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -77,40 +98,99 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
*/
|
||||
public async invalidate() {
|
||||
this.data.invalidated = true;
|
||||
this.data.refreshToken = null;
|
||||
this.data.refreshTokenHash = null;
|
||||
this.data.transferTokenHash = null;
|
||||
this.data.transferTokenExpiresAt = null;
|
||||
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
|
||||
*/
|
||||
public async getRefreshToken() {
|
||||
if (this.data.invalidated) {
|
||||
console.log('login session is invalidated. no refresh token can be generated.');
|
||||
return null;
|
||||
}
|
||||
if (!this.data.refreshToken) {
|
||||
this.data.refreshToken = plugins.smartunique.uni('refresh_');
|
||||
const previousRefreshTokenHash =
|
||||
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();
|
||||
return this.data.refreshToken;
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public async validateRefreshToken(refreshTokenArg: string) {
|
||||
return this.data.refreshToken === refreshTokenArg;
|
||||
public async validateRefreshToken(
|
||||
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) {
|
||||
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
|
||||
if (result) {
|
||||
this.transferToken = null;
|
||||
this.data.transferTokenHash = null;
|
||||
this.data.transferTokenExpiresAt = null;
|
||||
await this.save();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { logger } from './logging.js';
|
||||
|
||||
@@ -10,18 +11,11 @@ export class LoginSessionManager {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken);
|
||||
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
||||
|
||||
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public emailTokenMap = new plugins.lik.ObjectMap<{
|
||||
email: string;
|
||||
token: string;
|
||||
action: 'emailLogin' | 'passwordReset';
|
||||
}>();
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
@@ -32,9 +26,6 @@ export class LoginSessionManager {
|
||||
let user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
username: requestData.username,
|
||||
passwordHash: await this.receptionRef.userManager.CUser.hashPassword(
|
||||
requestData.password
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,33 +33,29 @@ export class LoginSessionManager {
|
||||
user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
email: requestData.username,
|
||||
passwordHash: await this.receptionRef.userManager.CUser.hashPassword(
|
||||
requestData.password
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// lets recheck
|
||||
if (
|
||||
(user.data.username !== requestData.username &&
|
||||
user.data.email !== requestData.username) ||
|
||||
user.data.passwordHash !==
|
||||
(await this.receptionRef.userManager.CUser.hashPassword(requestData.password))
|
||||
) {
|
||||
throw new Error(
|
||||
'database returned a user that does not match wanted criterea. CRITICAL!'
|
||||
if (user && (await this.receptionRef.userManager.CUser.verifyPassword(
|
||||
requestData.password,
|
||||
user.data.passwordHash
|
||||
))) {
|
||||
if (this.receptionRef.userManager.CUser.shouldUpgradePasswordHash(user.data.passwordHash)) {
|
||||
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
|
||||
requestData.password
|
||||
);
|
||||
await user.save();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
refreshToken: refreshToken,
|
||||
refreshToken,
|
||||
twoFaNeeded: false,
|
||||
};
|
||||
} else {
|
||||
@@ -90,31 +77,21 @@ export class LoginSessionManager {
|
||||
});
|
||||
if (existingUser) {
|
||||
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
||||
this.emailTokenMap.findOneAndRemoveSync(
|
||||
(itemArg) => itemArg.email === existingUser.data.email
|
||||
const loginEmailToken = await this.createEmailActionToken(
|
||||
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);
|
||||
return {
|
||||
status: 'ok',
|
||||
testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined,
|
||||
};
|
||||
} else {
|
||||
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
testOnlyToken: process.env.TEST_MODE
|
||||
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
||||
.token
|
||||
: null,
|
||||
testOnlyToken: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -124,19 +101,27 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||
'loginWithEmailAfterEmailTokenAquired',
|
||||
async (requestArg) => {
|
||||
const tokenObject = this.emailTokenMap.findSync((itemArg) => {
|
||||
return itemArg.email === requestArg.email && itemArg.token === requestArg.token;
|
||||
});
|
||||
const tokenObject = await this.consumeEmailActionToken(
|
||||
requestArg.email,
|
||||
requestArg.token,
|
||||
'emailLogin'
|
||||
);
|
||||
if (tokenObject) {
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
email: requestArg.email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
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');
|
||||
}
|
||||
return {
|
||||
refreshToken: await loginSession.getRefreshToken(),
|
||||
refreshToken,
|
||||
};
|
||||
} else {
|
||||
throw new plugins.typedrequest.TypedResponseError('Validation Token not found');
|
||||
@@ -147,8 +132,11 @@ export class LoginSessionManager {
|
||||
|
||||
this.typedRouter.addTypedHandler<plugins.idpInterfaces.request.ILogoutRequest>(
|
||||
new plugins.typedrequest.TypedHandler('logout', async (requestDataArg) => {
|
||||
const loginSession = await this.CLoginSession.getLoginSessionByRefreshToken(requestDataArg.refreshToken);
|
||||
await loginSession.invalidate();
|
||||
const sessionLookup = await this.findLoginSessionByRefreshToken(requestDataArg.refreshToken);
|
||||
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid refresh token');
|
||||
}
|
||||
await sessionLookup.loginSession.invalidate();
|
||||
return {}
|
||||
})
|
||||
);
|
||||
@@ -158,31 +146,39 @@ export class LoginSessionManager {
|
||||
'exchangeRefreshTokenAndTransferToken',
|
||||
async (requestDataArg) => {
|
||||
switch (true) {
|
||||
case !!requestDataArg.refreshToken:
|
||||
const loginSession = await this.loginSessions.find(async (loginSessionArg) => {
|
||||
return loginSessionArg.validateRefreshToken(requestDataArg.refreshToken);
|
||||
});
|
||||
if (!loginSession) {
|
||||
case !!requestDataArg.refreshToken: {
|
||||
const sessionLookup = await this.findLoginSessionByRefreshToken(
|
||||
requestDataArg.refreshToken
|
||||
);
|
||||
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
|
||||
if (sessionLookup?.validationStatus === 'reused') {
|
||||
await sessionLookup.loginSession.invalidate();
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('your refresh token is invalid');
|
||||
}
|
||||
return {
|
||||
transferToken: await loginSession.getTransferToken(),
|
||||
transferToken: await sessionLookup.loginSession.getTransferToken(),
|
||||
};
|
||||
break;
|
||||
case !!requestDataArg.transferToken:
|
||||
let transferToken: string;
|
||||
const loginSession2 = await this.loginSessions.find(async (loginSessionArg) => {
|
||||
return loginSessionArg.validateTransferToken(requestDataArg.transferToken);
|
||||
});
|
||||
}
|
||||
case !!requestDataArg.transferToken: {
|
||||
const loginSession2 = await this.findLoginSessionByTransferToken(
|
||||
requestDataArg.transferToken
|
||||
);
|
||||
if (!loginSession2) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Your transfer token is not valid.'
|
||||
);
|
||||
}
|
||||
const refreshToken = await loginSession2.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||
}
|
||||
return {
|
||||
refreshToken: await loginSession2.getRefreshToken(),
|
||||
refreshToken,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid token exchange request');
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -199,23 +195,13 @@ export class LoginSessionManager {
|
||||
},
|
||||
});
|
||||
if (existingUser) {
|
||||
this.emailTokenMap.findOneAndRemoveSync(
|
||||
(itemArg) => itemArg.email === existingUser.data.email
|
||||
const resetToken = await this.createEmailActionToken(
|
||||
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(
|
||||
existingUser,
|
||||
this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
||||
.token
|
||||
resetToken
|
||||
);
|
||||
}
|
||||
// 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 +216,43 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||
'setNewPassword',
|
||||
async (requestData) => {
|
||||
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();
|
||||
return {
|
||||
status: 'ok',
|
||||
};
|
||||
@@ -271,8 +294,7 @@ export class LoginSessionManager {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||
}
|
||||
|
||||
// Get the current session's refresh token to identify the current session
|
||||
const currentRefreshToken = jwt.data.refreshToken;
|
||||
const currentLoginSession = await jwt.getLoginSession();
|
||||
|
||||
// Get all sessions for this user
|
||||
const sessions = await this.CLoginSession.getInstances({
|
||||
@@ -290,7 +312,7 @@ export class LoginSessionManager {
|
||||
ip: session.data.deviceInfo?.ip || 'Unknown',
|
||||
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
|
||||
createdAt: session.data.createdAt || Date.now(),
|
||||
isCurrent: session.data.refreshToken === currentRefreshToken,
|
||||
isCurrent: session.id === currentLoginSession?.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -317,8 +339,10 @@ export class LoginSessionManager {
|
||||
throw new plugins.typedrequest.TypedResponseError('Session not found');
|
||||
}
|
||||
|
||||
const currentLoginSession = await jwt.getLoginSession();
|
||||
|
||||
// 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(
|
||||
'Cannot revoke current session. Use logout instead.'
|
||||
);
|
||||
@@ -338,4 +362,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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -12,25 +16,31 @@ export class OidcManager {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
||||
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
||||
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||
this,
|
||||
OidcAuthorizationCode
|
||||
);
|
||||
|
||||
// In-memory store for access tokens (for validation)
|
||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
||||
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||
|
||||
// In-memory store for refresh tokens
|
||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
||||
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||
|
||||
// In-memory store for user consents (should be persisted later)
|
||||
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
||||
public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent);
|
||||
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
|
||||
// Start cleanup task for expired codes/tokens
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC Discovery Document
|
||||
*/
|
||||
@@ -174,9 +184,11 @@ export class OidcManager {
|
||||
codeChallenge?: string,
|
||||
nonce?: string
|
||||
): Promise<string> {
|
||||
const code = plugins.smartunique.shortId(32);
|
||||
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
||||
code,
|
||||
const code = this.createOpaqueToken();
|
||||
const authCode = new OidcAuthorizationCode();
|
||||
authCode.id = plugins.smartunique.shortId(12);
|
||||
authCode.data = {
|
||||
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
@@ -184,11 +196,13 @@ export class OidcManager {
|
||||
codeChallenge,
|
||||
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||
nonce,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
issuedAt: Date.now(),
|
||||
used: false,
|
||||
};
|
||||
|
||||
this.authorizationCodes.set(code, authCode);
|
||||
await authCode.save();
|
||||
await this.upsertUserConsent(userId, clientId, scopes);
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -261,50 +275,48 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// Find and validate authorization code
|
||||
const authCode = this.authorizationCodes.get(code);
|
||||
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||
if (!authCode) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||
}
|
||||
|
||||
if (authCode.used) {
|
||||
// Code reuse attack - revoke all tokens for this code
|
||||
this.authorizationCodes.delete(code);
|
||||
if (authCode.data.used) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||
}
|
||||
|
||||
if (authCode.expiresAt < Date.now()) {
|
||||
this.authorizationCodes.delete(code);
|
||||
if (authCode.isExpired()) {
|
||||
await authCode.delete();
|
||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
||||
}
|
||||
|
||||
if (authCode.clientId !== app.data.oauthCredentials.clientId) {
|
||||
if (authCode.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||
}
|
||||
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
if (authCode.data.redirectUri !== redirectUri) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||
}
|
||||
|
||||
// Verify PKCE if code challenge was used
|
||||
if (authCode.codeChallenge) {
|
||||
if (authCode.data.codeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||
}
|
||||
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||
if (expectedChallenge !== authCode.codeChallenge) {
|
||||
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
authCode.used = true;
|
||||
await authCode.markUsed();
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(
|
||||
authCode.userId,
|
||||
authCode.data.userId,
|
||||
app.data.oauthCredentials.clientId,
|
||||
authCode.scopes,
|
||||
authCode.nonce
|
||||
authCode.data.scopes,
|
||||
authCode.data.nonce
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(tokens), {
|
||||
@@ -330,31 +342,30 @@ export class OidcManager {
|
||||
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||
}
|
||||
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||
const storedToken = this.refreshTokens.get(tokenHash);
|
||||
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||
|
||||
if (!storedToken) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
||||
}
|
||||
|
||||
if (storedToken.revoked) {
|
||||
if (storedToken.data.revoked) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < Date.now()) {
|
||||
this.refreshTokens.delete(tokenHash);
|
||||
if (storedToken.isExpired()) {
|
||||
await storedToken.delete();
|
||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
||||
}
|
||||
|
||||
if (storedToken.clientId !== app.data.oauthCredentials.clientId) {
|
||||
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.userId,
|
||||
storedToken.clientId,
|
||||
storedToken.scopes,
|
||||
storedToken.data.userId,
|
||||
storedToken.data.clientId,
|
||||
storedToken.data.scopes,
|
||||
undefined,
|
||||
false // Don't generate new refresh token
|
||||
);
|
||||
@@ -384,18 +395,18 @@ export class OidcManager {
|
||||
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||
|
||||
// Generate access token
|
||||
const accessToken = plugins.smartunique.shortId(32);
|
||||
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
||||
id: plugins.smartunique.shortId(8),
|
||||
tokenHash: accessTokenHash,
|
||||
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,
|
||||
};
|
||||
this.accessTokens.set(accessTokenHash, accessTokenData);
|
||||
await accessTokenData.save();
|
||||
|
||||
// Generate ID token (JWT)
|
||||
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||
@@ -410,11 +421,11 @@ export class OidcManager {
|
||||
|
||||
// Generate refresh token if requested
|
||||
if (includeRefreshToken) {
|
||||
const refreshToken = plugins.smartunique.shortId(48);
|
||||
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
||||
id: plugins.smartunique.shortId(8),
|
||||
tokenHash: refreshTokenHash,
|
||||
const refreshToken = this.createOpaqueToken(48);
|
||||
const refreshTokenData = new OidcRefreshToken();
|
||||
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||
refreshTokenData.data = {
|
||||
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
@@ -422,7 +433,7 @@ export class OidcManager {
|
||||
issuedAt: now,
|
||||
revoked: false,
|
||||
};
|
||||
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
||||
await refreshTokenData.save();
|
||||
response.refresh_token = refreshToken;
|
||||
}
|
||||
|
||||
@@ -482,8 +493,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
const accessToken = authHeader.substring(7);
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||
const tokenData = this.accessTokens.get(tokenHash);
|
||||
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||
|
||||
if (!tokenData) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||
@@ -495,8 +505,8 @@ export class OidcManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenData.expiresAt < Date.now()) {
|
||||
this.accessTokens.delete(tokenHash);
|
||||
if (tokenData.isExpired()) {
|
||||
await tokenData.delete();
|
||||
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
@@ -507,7 +517,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// Get user claims based on token scopes
|
||||
const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes);
|
||||
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes);
|
||||
|
||||
return new Response(JSON.stringify(userInfo), {
|
||||
status: 200,
|
||||
@@ -583,21 +593,20 @@ export class OidcManager {
|
||||
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||
}
|
||||
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
||||
|
||||
// Try to revoke as refresh token
|
||||
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||
const refreshToken = this.refreshTokens.get(tokenHash);
|
||||
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||
if (refreshToken) {
|
||||
refreshToken.revoked = true;
|
||||
await refreshToken.revoke();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Try to revoke as access token
|
||||
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||
if (this.accessTokens.has(tokenHash)) {
|
||||
this.accessTokens.delete(tokenHash);
|
||||
const accessToken = await this.getAccessTokenByToken(token);
|
||||
if (accessToken) {
|
||||
await accessToken.delete();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -616,6 +625,53 @@ export class OidcManager {
|
||||
return apps[0] || null;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -655,29 +711,45 @@ export class OidcManager {
|
||||
* Start cleanup task for expired tokens/codes
|
||||
*/
|
||||
private startCleanupTask(): void {
|
||||
setInterval(() => {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
void this.cleanupExpiredOidcState();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
private async cleanupExpiredOidcState() {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up expired authorization codes
|
||||
for (const [code, data] of this.authorizationCodes) {
|
||||
if (data.expiresAt < now) {
|
||||
this.authorizationCodes.delete(code);
|
||||
}
|
||||
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const authCode of expiredAuthorizationCodes) {
|
||||
await authCode.delete();
|
||||
}
|
||||
|
||||
// Clean up expired access tokens
|
||||
for (const [hash, data] of this.accessTokens) {
|
||||
if (data.expiresAt < now) {
|
||||
this.accessTokens.delete(hash);
|
||||
}
|
||||
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const accessToken of expiredAccessTokens) {
|
||||
await accessToken.delete();
|
||||
}
|
||||
|
||||
// Clean up expired refresh tokens
|
||||
for (const [hash, data] of this.refreshTokens) {
|
||||
if (data.expiresAt < now) {
|
||||
this.refreshTokens.delete(hash);
|
||||
const expiredRefreshTokens = await this.COidcRefreshToken.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const refreshToken of expiredRefreshTokens) {
|
||||
await refreshToken.delete();
|
||||
}
|
||||
}
|
||||
}, 60 * 1000); // Run every minute
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
import { JwtManager } from './classes.jwtmanager.js';
|
||||
@@ -30,7 +29,6 @@ export interface IReceptionOptions {
|
||||
}
|
||||
|
||||
export class Reception {
|
||||
public projectinfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
||||
public szPlatformClient = new plugins.szPlatformClient.SzPlatformClient();
|
||||
@@ -80,6 +78,7 @@ export class Reception {
|
||||
*/
|
||||
public async stop() {
|
||||
await this.housekeeping.stop();
|
||||
await this.oidcManager.stop();
|
||||
console.log('stopped serviceserver!');
|
||||
await this.db.stop();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export class ReceptionDb {
|
||||
}
|
||||
|
||||
public async start() {
|
||||
console.log(this.receptionRef.options.mongoDescriptor);
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb(this.receptionRef.options.mongoDescriptor);
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
@@ -5,71 +5,63 @@ import { logger } from './logging.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 {
|
||||
// ======
|
||||
// STATIC
|
||||
// ======
|
||||
@plugins.smartdata.Manager()
|
||||
export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
RegistrationSession,
|
||||
plugins.idpInterfaces.data.IRegistrationSession,
|
||||
RegistrationSessionManager
|
||||
> {
|
||||
public static hashToken(tokenArg: string) {
|
||||
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||
}
|
||||
|
||||
public static async createRegistrationSessionForEmail(
|
||||
registrationSessionManageremailArg: RegistrationSessionManager,
|
||||
emailArg: string
|
||||
) {
|
||||
const newRegistrationSession = new RegistrationSession(
|
||||
registrationSessionManageremailArg,
|
||||
emailArg
|
||||
);
|
||||
const emailValidationResult = await newRegistrationSession
|
||||
.validateEMailAddress()
|
||||
.catch((error) => {
|
||||
const newRegistrationSession = new RegistrationSession();
|
||||
newRegistrationSession.id = plugins.smartunique.shortId();
|
||||
newRegistrationSession.data.emailAddress = emailArg;
|
||||
newRegistrationSession.data.validUntil =
|
||||
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
|
||||
newRegistrationSession.data.createdAt = Date.now();
|
||||
|
||||
const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Error occured during email provider & dns validation'
|
||||
);
|
||||
});
|
||||
|
||||
if (!emailValidationResult?.valid) {
|
||||
newRegistrationSession.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Email Address is not valid. Please use a correctly formated email address'
|
||||
);
|
||||
}
|
||||
if (emailValidationResult.disposable) {
|
||||
newRegistrationSession.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Email is disposable. Please use a non disposable email address.'
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!`
|
||||
);
|
||||
await newRegistrationSession.sendTokenValidationEmail();
|
||||
console.log(`Successfully sent email validation email`);
|
||||
|
||||
const validationToken = await newRegistrationSession.sendTokenValidationEmail();
|
||||
newRegistrationSession.unhashedEmailToken = validationToken;
|
||||
return newRegistrationSession;
|
||||
}
|
||||
|
||||
// ========
|
||||
// INSTANCE
|
||||
// ========
|
||||
public registrationSessionManagerRef: RegistrationSessionManager;
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
public emailAddress: string;
|
||||
|
||||
/**
|
||||
* only used during testing
|
||||
*/
|
||||
public unhashedEmailToken?: string;
|
||||
public hashedEmailToken: string;
|
||||
private smsvalidationCounter = 0;
|
||||
public smsCode: string;
|
||||
|
||||
/**
|
||||
* 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'];
|
||||
} = {
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IRegistrationSession['data'] = {
|
||||
emailAddress: '',
|
||||
hashedEmailToken: '',
|
||||
smsCodeHash: null,
|
||||
smsvalidationCounter: 0,
|
||||
status: 'announced',
|
||||
validUntil: 0,
|
||||
createdAt: 0,
|
||||
collectedData: {
|
||||
userData: {
|
||||
username: null,
|
||||
connectedOrgs: [],
|
||||
@@ -80,116 +72,120 @@ export class RegistrationSession {
|
||||
password: null,
|
||||
passwordHash: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
registrationSessionManagerRefArg: RegistrationSessionManager,
|
||||
emailAddressArg: string
|
||||
) {
|
||||
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
|
||||
this.emailAddress = emailAddressArg;
|
||||
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
|
||||
/**
|
||||
* only used during testing
|
||||
*/
|
||||
public unhashedEmailToken?: string;
|
||||
|
||||
// lets destroy this after 10 minutes,
|
||||
// works in unrefed mode so not blocking node exiting.
|
||||
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy());
|
||||
public get emailAddress() {
|
||||
return this.data.emailAddress;
|
||||
}
|
||||
|
||||
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
|
||||
* @param tokenArg
|
||||
*/
|
||||
public validateEmailToken(tokenArg: string): boolean {
|
||||
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||
if (result && this.status === 'announced') {
|
||||
this.status = 'emailValidated';
|
||||
this.collectedData.userData.email = this.emailAddress;
|
||||
public async validateEmailToken(tokenArg: string): Promise<boolean> {
|
||||
if (this.isExpired()) {
|
||||
await this.destroy();
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** validates the sms code */
|
||||
public validateSmsCode(smsCodeArg: string) {
|
||||
this.smsvalidationCounter++;
|
||||
const result = this.smsCode === smsCodeArg;
|
||||
if (this.status === 'emailValidated' && result) {
|
||||
this.status = 'mobileVerified';
|
||||
public async validateSmsCode(smsCodeArg: string) {
|
||||
this.data.smsvalidationCounter++;
|
||||
const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
|
||||
if (this.data.status === 'emailValidated' && result) {
|
||||
this.data.status = 'mobileVerified';
|
||||
await this.save();
|
||||
return result;
|
||||
} else {
|
||||
if (this.smsvalidationCounter === 5) {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
if (this.data.smsvalidationCounter >= 5) {
|
||||
await this.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Registration cancelled due to repeated wrong verification code submission'
|
||||
);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validate the email address with provider and dns sanity checks
|
||||
* @returns
|
||||
*/
|
||||
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
||||
console.log(`validating email ${this.emailAddress}`);
|
||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
|
||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* send the validation email
|
||||
*/
|
||||
public async sendTokenValidationEmail() {
|
||||
const uuidToSend = plugins.smartunique.uuid4();
|
||||
this.unhashedEmailToken = uuidToSend;
|
||||
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend);
|
||||
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail(
|
||||
this,
|
||||
uuidToSend
|
||||
);
|
||||
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
|
||||
this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
|
||||
await this.save();
|
||||
this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
|
||||
logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
|
||||
return uuidToSend;
|
||||
}
|
||||
|
||||
/**
|
||||
* validate the mobile number of someone
|
||||
*/
|
||||
public async sendValidationSms() {
|
||||
this.smsCode =
|
||||
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation(
|
||||
{
|
||||
fromName: this.registrationSessionManagerRef.receptionRef.options.name,
|
||||
toNumber: parseInt(this.collectedData.userData.mobileNumber),
|
||||
}
|
||||
);
|
||||
const smsCode =
|
||||
await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
|
||||
fromName: this.manager.receptionRef.options.name,
|
||||
toNumber: parseInt(this.data.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> {
|
||||
if (this.status !== 'mobileVerified') {
|
||||
if (this.data.status !== 'mobileVerified') {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'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');
|
||||
}
|
||||
const manifestedUser =
|
||||
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||
this.collectedData.userData
|
||||
const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||
this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
|
||||
);
|
||||
this.data.status = 'registered';
|
||||
await this.save();
|
||||
return manifestedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* destroys the registrationsession
|
||||
*/
|
||||
public destroy() {
|
||||
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
|
||||
public async destroy() {
|
||||
await this.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import { logger } from './logging.js';
|
||||
|
||||
export class RegistrationSessionManager {
|
||||
public receptionRef: Reception;
|
||||
|
||||
public registrationSessions = new plugins.lik.FastMap<RegistrationSession>();
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
@@ -29,17 +33,16 @@ export class RegistrationSessionManager {
|
||||
`We sent you an Email with more information.`
|
||||
);
|
||||
}
|
||||
// check for exiting SignupSession
|
||||
const existingSession = this.registrationSessions.getByKey(requestData.email);
|
||||
if (existingSession) {
|
||||
|
||||
const existingSessions = await this.CRegistrationSession.getInstances({
|
||||
'data.emailAddress': requestData.email,
|
||||
});
|
||||
for (const existingSession of existingSessions) {
|
||||
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(
|
||||
this,
|
||||
requestData.email
|
||||
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
||||
console.log(e.errorText);
|
||||
@@ -63,10 +66,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
'afterRegistrationEmailClicked',
|
||||
async (requestData) => {
|
||||
console.log(requestData);
|
||||
const signupSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const signupSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (signupSession) {
|
||||
return {
|
||||
email: signupSession.emailAddress,
|
||||
@@ -86,9 +86,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
'setDataForRegistration',
|
||||
async (requestData) => {
|
||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (!registrationSession) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'could not find a matching signupsession'
|
||||
@@ -114,9 +112,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
'mobileVerificationForRegistration',
|
||||
async (requestData) => {
|
||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (!registrationSession) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'could not find a matching signupsession'
|
||||
@@ -131,17 +127,16 @@ export class RegistrationSessionManager {
|
||||
}
|
||||
|
||||
if (requestData.mobileNumber) {
|
||||
registrationSession.status = 'emailValidated';
|
||||
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
||||
await registrationSession.sendValidationSms();
|
||||
const smsCode = await registrationSession.sendValidationSms();
|
||||
return {
|
||||
messageSent: true,
|
||||
testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null,
|
||||
testOnlySmsCode: process.env.TEST_MODE ? smsCode : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestData.verificationCode) {
|
||||
const validationResult = registrationSession.validateSmsCode(
|
||||
const validationResult = await registrationSession.validateSmsCode(
|
||||
requestData.verificationCode
|
||||
);
|
||||
return {
|
||||
@@ -160,9 +155,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
'finishRegistration',
|
||||
async (requestData) => {
|
||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (!registrationSession) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'could not find a matching signupsession'
|
||||
@@ -170,7 +163,7 @@ export class RegistrationSessionManager {
|
||||
}
|
||||
|
||||
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
||||
registrationSession.destroy();
|
||||
await registrationSession.destroy();
|
||||
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
||||
return {
|
||||
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();
|
||||
newUser.id = plugins.smartunique.shortId();
|
||||
newUser.data = {
|
||||
connectedOrgs: null,
|
||||
connectedOrgs: [],
|
||||
status: 'new',
|
||||
name: userDataArg.name,
|
||||
username: userDataArg.username,
|
||||
@@ -31,8 +31,26 @@ export class User extends plugins.smartdata.SmartDataDbDoc<
|
||||
return newUser;
|
||||
}
|
||||
|
||||
public static hashPassword(passwordArg: string) {
|
||||
return plugins.smarthash.sha256FromString(passwordArg);
|
||||
public static async hashPassword(passwordArg: string) {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,9 @@ export class UserManager {
|
||||
new plugins.typedrequest.TypedHandler('getRolesAndOrganizationsForUserId', async reqArg => {
|
||||
console.log('user manager: getting roles and orgs');
|
||||
const user = await this.getUserByJwtValidation(reqArg.jwt);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(
|
||||
user
|
||||
);
|
||||
@@ -49,8 +52,7 @@ export class UserManager {
|
||||
email: user.data.email,
|
||||
mobileNumber: user.data.mobileNumber,
|
||||
connectedOrgs: user.data.connectedOrgs,
|
||||
status: null,
|
||||
password: null,
|
||||
status: user.data.status,
|
||||
isGlobalAdmin: user.data.isGlobalAdmin,
|
||||
} as plugins.idpInterfaces.data.IUser['data']
|
||||
}
|
||||
@@ -64,6 +66,9 @@ export class UserManager {
|
||||
*/
|
||||
public async getUserByJwt(jwtString: string) {
|
||||
const jwtInstance = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtString);
|
||||
if (!jwtInstance) {
|
||||
return null;
|
||||
}
|
||||
const user = await this.CUser.getInstance({
|
||||
id: jwtInstance.data.userId
|
||||
});
|
||||
@@ -75,7 +80,10 @@ export class UserManager {
|
||||
* faster than the "getUserByJwt"
|
||||
*/
|
||||
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({
|
||||
id: jwtDataArg.data.userId
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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();
|
||||
|
||||
@@ -193,6 +193,7 @@ export class IdpCli {
|
||||
this.storeCredentials({
|
||||
...credentials,
|
||||
jwt: response.jwt,
|
||||
refreshToken: response.refreshToken || credentials.refreshToken,
|
||||
});
|
||||
return response.jwt;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -29,9 +29,9 @@ export class IdpClient {
|
||||
appDataArg = {
|
||||
id: '', // TODO
|
||||
appUrl: `https://${window.location.host}/`,
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
name: null,
|
||||
description: '',
|
||||
logoUrl: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
this.appData = appDataArg;
|
||||
@@ -67,10 +67,14 @@ export class IdpClient {
|
||||
await this.storeJwt(jwtStringArg);
|
||||
}
|
||||
|
||||
public async setRefreshToken(refreshTokenArg: string) {
|
||||
await this.storeRefreshToken(refreshTokenArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* a typedsocket for going reactive
|
||||
*/
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedsocket!: plugins.typedsocket.TypedSocket;
|
||||
|
||||
/**
|
||||
* a typed router to go reactive
|
||||
@@ -89,16 +93,30 @@ export class IdpClient {
|
||||
await this.ssoStore.set('idpJwt', jwtString);
|
||||
}
|
||||
|
||||
public async storeRefreshToken(refreshToken: string) {
|
||||
await this.ssoStore.set('idpRefreshToken', refreshToken);
|
||||
}
|
||||
|
||||
public async getJwt(): Promise<string> {
|
||||
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> {
|
||||
return this.helpers.extractDataFromJwtString(await this.getJwt());
|
||||
}
|
||||
|
||||
public async deleteJwt() {
|
||||
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) {
|
||||
jwt = await this.refreshJwt();
|
||||
} else if (Date.now() > extractedJwt.data.validUntil) {
|
||||
this.deleteJwt();
|
||||
await this.deleteJwt();
|
||||
jwt = await this.refreshJwt();
|
||||
}
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public async refreshJwt(refreshTokenArg?: string): Promise<string> {
|
||||
let extractedJwt: plugins.idpInterfaces.data.IJwt;
|
||||
public async refreshJwt(refreshTokenArg?: string): Promise<string | null> {
|
||||
const refreshToken = refreshTokenArg || (await this.getRefreshToken());
|
||||
|
||||
if (!refreshTokenArg) {
|
||||
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.typedsocketDeferred.promise;
|
||||
const refreshJwtReq =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
const response = await refreshJwtReq.fire({
|
||||
refreshToken: refreshTokenArg || extractedJwt.data.refreshToken,
|
||||
const response = await refreshJwtReq
|
||||
.fire({
|
||||
refreshToken,
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.clearAuthState();
|
||||
return null;
|
||||
});
|
||||
if (response.jwt) {
|
||||
await this.storeJwt(response.jwt);
|
||||
} else {
|
||||
await this.deleteJwt();
|
||||
|
||||
if (!response?.jwt) {
|
||||
await this.clearAuthState();
|
||||
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);
|
||||
return await this.getJwt();
|
||||
return response.jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* can be used to switch between pages
|
||||
*/
|
||||
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string> {
|
||||
const jwt = await this.performJwtHousekeeping();
|
||||
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
||||
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string | null> {
|
||||
await this.performJwtHousekeeping();
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
await this.typedsocketDeferred.promise;
|
||||
const getTransferToken =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
refreshToken: extractedJwt.data.refreshToken,
|
||||
refreshToken,
|
||||
appData: appDataArg || this.appData,
|
||||
});
|
||||
return response.transferToken;
|
||||
@@ -230,6 +264,13 @@ export class IdpClient {
|
||||
const jwt = await this.performJwtHousekeeping();
|
||||
return !!jwt;
|
||||
} else {
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
const jwt = await this.refreshJwt(refreshToken);
|
||||
if (jwt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const transferTokenResult = await this.processTransferToken();
|
||||
if (transferTokenResult) {
|
||||
// we are in the clear
|
||||
@@ -258,12 +299,18 @@ export class IdpClient {
|
||||
*/
|
||||
public async logout() {
|
||||
const idpLogoutUrl = this.parsedReceptionUrl.clone().set('path', '/logout');
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!globalThis.location.href.startsWith(idpLogoutUrl.origin)) {
|
||||
// we are somewhere in an app
|
||||
await this.deleteJwt();
|
||||
await this.clearAuthState();
|
||||
globalThis.location.href = idpLogoutUrl.toString();
|
||||
} else {
|
||||
// we are in the sso page
|
||||
if (!refreshToken) {
|
||||
await this.clearAuthState();
|
||||
window.location.href = this.parsedReceptionUrl.origin;
|
||||
return;
|
||||
}
|
||||
await this.enableTypedSocket();
|
||||
console.log(`logging out against ${this.parsedReceptionUrl.toString()}`);
|
||||
const logoutTr =
|
||||
@@ -271,9 +318,9 @@ export class IdpClient {
|
||||
'logout'
|
||||
);
|
||||
await logoutTr.fire({
|
||||
refreshToken: (await this.getJwtData()).data.refreshToken,
|
||||
refreshToken,
|
||||
});
|
||||
await this.deleteJwt();
|
||||
await this.clearAuthState();
|
||||
const appData = await this.getAppDataOnSsoDomain();
|
||||
if (appData) {
|
||||
console.log(`redirecting to app after logout: ${appData.appUrl}`);
|
||||
|
||||
+86
-302
@@ -1,71 +1,61 @@
|
||||
# @idp.global/idpclient
|
||||
# @idp.global/client
|
||||
|
||||
A TypeScript client library for integrating with the idp.global Identity Provider. Works in both browser and Node.js environments.
|
||||
Browser-facing TypeScript client for talking to an `idp.global` server over `typedrequest` and `typedsocket`.
|
||||
|
||||
## Overview
|
||||
It handles login state, refresh tokens, JWT housekeeping, cross-app transfer tokens, and direct access to the typed request surface.
|
||||
|
||||
The IdpClient provides a complete API for authentication, session management, and organization operations. It uses WebSocket connections via TypedSocket for real-time, type-safe communication with the IdP server.
|
||||
## Issue Reporting and Security
|
||||
|
||||
## Installation
|
||||
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
|
||||
npm install @idp.global/idpclient
|
||||
# or
|
||||
pnpm add @idp.global/idpclient
|
||||
pnpm add @idp.global/client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { IdpClient } from '@idp.global/idpclient';
|
||||
```ts
|
||||
import { IdpClient } from '@idp.global/client';
|
||||
|
||||
// Initialize the client
|
||||
const idpClient = new IdpClient('https://idp.global');
|
||||
|
||||
// Enable WebSocket connection
|
||||
await idpClient.enableTypedSocket();
|
||||
|
||||
// Check login status
|
||||
const isLoggedIn = await idpClient.determineLoginStatus();
|
||||
const loggedIn = await idpClient.determineLoginStatus();
|
||||
|
||||
if (isLoggedIn) {
|
||||
const userInfo = await idpClient.whoIs();
|
||||
console.log('Logged in as:', userInfo.user.data.name);
|
||||
}
|
||||
```
|
||||
|
||||
## Core Features
|
||||
|
||||
### Authentication
|
||||
|
||||
#### Password Login
|
||||
|
||||
```typescript
|
||||
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||
if (!loggedIn) {
|
||||
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||
username: 'user@example.com',
|
||||
password: 'securepassword',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
if (response.refreshToken) {
|
||||
await idpClient.refreshJwt(response.refreshToken);
|
||||
console.log('Login successful!');
|
||||
} else if (response.twoFaNeeded) {
|
||||
console.log('2FA verification required');
|
||||
if (loginResult.refreshToken) {
|
||||
await idpClient.refreshJwt(loginResult.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
const whoIs = await idpClient.whoIs();
|
||||
console.log(whoIs.user.data.email);
|
||||
```
|
||||
|
||||
#### Magic Link Login
|
||||
## What The Client Handles
|
||||
|
||||
```typescript
|
||||
// Request magic link
|
||||
await idpClient.requests.loginWithEmail.fire({
|
||||
email: 'user@example.com',
|
||||
});
|
||||
- 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`.
|
||||
|
||||
// After clicking the email link
|
||||
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
|
||||
email: 'user@example.com',
|
||||
token: 'token-from-email-link',
|
||||
## Common Flows
|
||||
|
||||
### Password Login
|
||||
|
||||
```ts
|
||||
const result = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
if (result.refreshToken) {
|
||||
@@ -73,300 +63,94 @@ if (result.refreshToken) {
|
||||
}
|
||||
```
|
||||
|
||||
#### API Token Login
|
||||
### Magic Link Login
|
||||
|
||||
```typescript
|
||||
const result = await idpClient.requests.loginWithApiToken.fire({
|
||||
apiToken: 'your-api-token',
|
||||
```ts
|
||||
await idpClient.requests.loginWithEmail.fire({
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
if (result.jwt) {
|
||||
await idpClient.setJwt(result.jwt);
|
||||
}
|
||||
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
|
||||
email: 'user@example.com',
|
||||
token: 'token-from-email',
|
||||
});
|
||||
|
||||
await idpClient.refreshJwt(result.refreshToken);
|
||||
```
|
||||
|
||||
### Session Management
|
||||
### Session and Identity
|
||||
|
||||
```typescript
|
||||
// Get current JWT
|
||||
const jwt = await idpClient.getJwt();
|
||||
|
||||
// Get parsed JWT data
|
||||
const jwtData = await idpClient.getJwtData();
|
||||
console.log('User ID:', jwtData.id);
|
||||
|
||||
// Refresh JWT (automatic housekeeping)
|
||||
```ts
|
||||
await idpClient.performJwtHousekeeping();
|
||||
|
||||
// Manual refresh
|
||||
await idpClient.refreshJwt();
|
||||
const jwt = await idpClient.getJwt();
|
||||
const jwtData = await idpClient.getJwtData();
|
||||
const whoIs = await idpClient.whoIs();
|
||||
|
||||
// Logout
|
||||
await idpClient.logout();
|
||||
console.log(jwtData.id, whoIs.user.data.username);
|
||||
```
|
||||
|
||||
### User Information
|
||||
### Organizations
|
||||
|
||||
```typescript
|
||||
// Get current user details
|
||||
const whoIsResponse = await idpClient.whoIs();
|
||||
console.log('Name:', whoIsResponse.user.data.name);
|
||||
console.log('Email:', whoIsResponse.user.data.email);
|
||||
```ts
|
||||
const rolesAndOrganizations = await idpClient.getRolesAndOrganizations();
|
||||
|
||||
// Get user data
|
||||
const userData = await idpClient.requests.getUserData.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
userId: jwtData.id,
|
||||
});
|
||||
|
||||
// Update user data
|
||||
await idpClient.requests.setUserData.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
userId: jwtData.id,
|
||||
name: 'New Name',
|
||||
});
|
||||
```
|
||||
|
||||
### Organization Management
|
||||
|
||||
```typescript
|
||||
// Get user's organizations and roles
|
||||
const orgsAndRoles = await idpClient.getRolesAndOrganizations();
|
||||
console.log('Organizations:', orgsAndRoles.organizations);
|
||||
console.log('Roles:', orgsAndRoles.roles);
|
||||
|
||||
// Create a new organization
|
||||
const result = await idpClient.createOrganization(
|
||||
'My Company', // name
|
||||
'my-company', // slug
|
||||
'manifest' // mode: 'checkAvailability' or 'manifest'
|
||||
const created = await idpClient.createOrganization(
|
||||
'Acme',
|
||||
'acme',
|
||||
'manifest'
|
||||
);
|
||||
|
||||
if (result.resultingOrganization) {
|
||||
console.log('Created:', result.resultingOrganization.id);
|
||||
}
|
||||
|
||||
// Get organization details
|
||||
const orgDetails = await idpClient.requests.getOrganizationById.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
});
|
||||
```
|
||||
|
||||
### Member & Invitation Management
|
||||
|
||||
```typescript
|
||||
// Get organization members
|
||||
const members = await idpClient.requests.getOrgMembers.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
});
|
||||
|
||||
// Invite a new member
|
||||
await idpClient.requests.createInvitation.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
email: 'newmember@example.com',
|
||||
roles: ['member'],
|
||||
});
|
||||
|
||||
// Bulk invite members
|
||||
await idpClient.requests.bulkCreateInvitations.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
invitations: [
|
||||
{ email: 'user1@example.com', roles: ['member'] },
|
||||
{ email: 'user2@example.com', roles: ['admin'] },
|
||||
],
|
||||
});
|
||||
|
||||
// Accept an invitation
|
||||
await idpClient.requests.acceptInvitation.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
invitationToken: 'token-from-invite-email',
|
||||
});
|
||||
|
||||
// Remove a member
|
||||
await idpClient.requests.removeMember.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
userId: 'user-id',
|
||||
});
|
||||
|
||||
// Transfer ownership
|
||||
await idpClient.requests.transferOwnership.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
newOwnerId: 'new-owner-user-id',
|
||||
organizationId: created.resultingOrganization.id,
|
||||
});
|
||||
```
|
||||
|
||||
### Password Management
|
||||
### Cross-App Transfer
|
||||
|
||||
```typescript
|
||||
// Request password reset
|
||||
await idpClient.requests.resetPassword.fire({
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
// Set new password (with token from email)
|
||||
await idpClient.requests.setNewPassword.fire({
|
||||
email: 'user@example.com',
|
||||
tokenArg: 'reset-token',
|
||||
newPassword: 'newsecurepassword',
|
||||
});
|
||||
|
||||
// Change password (when logged in)
|
||||
await idpClient.requests.setNewPassword.fire({
|
||||
email: 'user@example.com',
|
||||
oldPassword: 'currentpassword',
|
||||
newPassword: 'newsecurepassword',
|
||||
});
|
||||
```
|
||||
|
||||
### Session & Device Management
|
||||
|
||||
```typescript
|
||||
// Get active sessions
|
||||
const sessions = await idpClient.requests.getUserSessions.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
userId: jwtData.id,
|
||||
});
|
||||
|
||||
// Revoke a session
|
||||
await idpClient.requests.revokeSession.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
sessionId: 'session-id',
|
||||
});
|
||||
|
||||
// Get device ID
|
||||
const deviceInfo = await idpClient.requests.obtainDeviceId.fire({});
|
||||
|
||||
// Attach device to session
|
||||
await idpClient.requests.attachDeviceId.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
deviceId: deviceInfo.deviceId.id,
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-Domain Authentication
|
||||
|
||||
```typescript
|
||||
// Get transfer token for SSO between apps
|
||||
```ts
|
||||
const transferToken = await idpClient.getTransferToken();
|
||||
|
||||
// Switch to another app with authentication
|
||||
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
|
||||
|
||||
// Process incoming transfer token (in target app)
|
||||
const success = await idpClient.processTransferToken();
|
||||
if (success) {
|
||||
console.log('Cross-domain login successful');
|
||||
}
|
||||
```
|
||||
|
||||
### Billing Integration
|
||||
## Typed Request Surface
|
||||
|
||||
```typescript
|
||||
// Get billing plan for an organization
|
||||
const billingPlan = await idpClient.requests.getBillingPlan.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
organizationId: 'org-id',
|
||||
});
|
||||
`IdpRequests` exposes typed request getters for:
|
||||
|
||||
// Get Paddle configuration
|
||||
const paddleConfig = await idpClient.requests.getPaddleConfig.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
});
|
||||
- authentication
|
||||
- registration
|
||||
- user/session queries
|
||||
- org and invitation management
|
||||
- billing requests
|
||||
- JWT validation key requests
|
||||
- admin requests
|
||||
|
||||
// Update payment method
|
||||
await idpClient.updatePaddleCheckoutId('org-id', 'checkout-id');
|
||||
```
|
||||
Use these when you want full control instead of the higher-level helper methods on `IdpClient`.
|
||||
|
||||
### Admin Operations (Global Admins Only)
|
||||
## Important Runtime Notes
|
||||
|
||||
```typescript
|
||||
// Check if user is global admin
|
||||
const isAdmin = await idpClient.requests.checkGlobalAdmin.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
});
|
||||
- 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.
|
||||
|
||||
// Get platform statistics
|
||||
const stats = await idpClient.requests.getGlobalAppStats.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
});
|
||||
## License and Legal Information
|
||||
|
||||
// Create a global app
|
||||
await idpClient.requests.createGlobalApp.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
name: 'My App',
|
||||
description: 'App description',
|
||||
});
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
// Suspend a user
|
||||
await idpClient.requests.suspendUser.fire({
|
||||
jwt: await idpClient.getJwt(),
|
||||
userId: 'user-id',
|
||||
});
|
||||
```
|
||||
**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.
|
||||
|
||||
## Reactive Subscriptions
|
||||
### Trademarks
|
||||
|
||||
The client provides RxJS subjects for reactive updates:
|
||||
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.
|
||||
|
||||
```typescript
|
||||
// Subscribe to login status changes
|
||||
idpClient.statusObservable.subscribe((status) => {
|
||||
console.log('Login status changed:', status);
|
||||
});
|
||||
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.
|
||||
|
||||
// Subscribe to roles updates
|
||||
idpClient.rolesReplaySubject.subscribe((roles) => {
|
||||
console.log('Roles updated:', roles);
|
||||
});
|
||||
### Company Information
|
||||
|
||||
// Subscribe to organizations updates
|
||||
idpClient.organizationsReplaySubject.subscribe((orgs) => {
|
||||
console.log('Organizations updated:', orgs);
|
||||
});
|
||||
```
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
## API Reference
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
### IdpClient Class
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `enableTypedSocket()` | Initialize WebSocket connection |
|
||||
| `determineLoginStatus(requireLogin?)` | Check if user is logged in |
|
||||
| `getJwt()` | Get stored JWT string |
|
||||
| `getJwtData()` | Get parsed JWT data |
|
||||
| `setJwt(jwt)` | Store JWT |
|
||||
| `deleteJwt()` | Remove stored JWT |
|
||||
| `refreshJwt(refreshToken?)` | Refresh the JWT |
|
||||
| `performJwtHousekeeping()` | Auto-refresh JWT if needed |
|
||||
| `logout()` | End session and redirect |
|
||||
| `whoIs()` | Get current user info |
|
||||
| `getRolesAndOrganizations()` | Get user's orgs and roles |
|
||||
| `createOrganization(name, slug, mode)` | Create new organization |
|
||||
| `getTransferToken(appData?)` | Get SSO transfer token |
|
||||
| `processTransferToken()` | Process incoming transfer token |
|
||||
| `stop()` | Close WebSocket connection |
|
||||
|
||||
### IdpRequests Class
|
||||
|
||||
Access via `idpClient.requests.*`:
|
||||
|
||||
**Authentication**: `loginWithUserNameAndPassword`, `loginWithEmail`, `loginWithEmailAfterToken`, `loginWithApiToken`, `resetPassword`, `setNewPassword`
|
||||
|
||||
**User**: `getUserData`, `setUserData`, `getUserSessions`, `revokeSession`, `getUserActivity`
|
||||
|
||||
**Organization**: `getOrganizationById`, `updateOrganization`, `createInvitation`, `bulkCreateInvitations`, `getOrgMembers`, `getOrgInvitations`, `acceptInvitation`, `cancelInvitation`, `resendInvitation`, `removeMember`, `updateMemberRoles`, `transferOwnership`
|
||||
|
||||
**Billing**: `getBillingPlan`, `getPaddleConfig`
|
||||
|
||||
**Admin**: `checkGlobalAdmin`, `getGlobalAppStats`, `createGlobalApp`, `updateGlobalApp`, `deleteGlobalApp`, `suspendUser`, `deleteSuspendedUser`
|
||||
|
||||
## License
|
||||
|
||||
MIT - See the main repository for full license details.
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './loint-reception.activity.js';
|
||||
export * from './loint-reception.app.js';
|
||||
export * from './loint-reception.emailactiontoken.js';
|
||||
export * from './loint-reception.oidc.js';
|
||||
export * from './loint-reception.appconnection.js';
|
||||
export * from './loint-reception.billingplan.js';
|
||||
@@ -8,6 +9,7 @@ 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.registrationsession.js';
|
||||
export * from './loint-reception.role.js';
|
||||
export * from './loint-reception.user.js';
|
||||
export * from './loint-reception.userinvitation.js';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type TEmailActionTokenAction = 'emailLogin' | 'passwordReset';
|
||||
|
||||
export interface IEmailActionToken {
|
||||
id: string;
|
||||
data: {
|
||||
email: string;
|
||||
action: TEmailActionTokenAction;
|
||||
tokenHash: string;
|
||||
validUntil: number;
|
||||
createdAt: number;
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,11 @@ export interface IJwt {
|
||||
*/
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* the login session backing this jwt
|
||||
*/
|
||||
sessionId?: string;
|
||||
|
||||
/**
|
||||
* the latest point of
|
||||
*/
|
||||
@@ -24,9 +29,9 @@ export interface IJwt {
|
||||
refreshEvery: number;
|
||||
|
||||
/**
|
||||
* the refresh token to obtain a new jwt for a session
|
||||
* legacy field kept for compatibility with already-issued jwt documents
|
||||
*/
|
||||
refreshToken: string;
|
||||
refreshToken?: string;
|
||||
|
||||
/**
|
||||
* just for looks/debugging
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
export interface ILoginSession {
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
userId: string | null;
|
||||
validUntil: number;
|
||||
invalidated: boolean;
|
||||
refreshToken: string;
|
||||
/**
|
||||
* legacy plaintext refresh token field kept so existing sessions can migrate on first use
|
||||
*/
|
||||
refreshToken?: string | null;
|
||||
refreshTokenHash?: string | null;
|
||||
rotatedRefreshTokenHashes?: string[];
|
||||
transferTokenHash?: string | null;
|
||||
transferTokenExpiresAt?: number | null;
|
||||
/**
|
||||
* a device id that can be used to share the login session
|
||||
* in different contexts on the same device
|
||||
*/
|
||||
deviceId: string;
|
||||
deviceId?: string | null;
|
||||
/**
|
||||
* Device metadata for session display
|
||||
*/
|
||||
@@ -18,7 +25,7 @@ export interface ILoginSession {
|
||||
browser: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
};
|
||||
} | null;
|
||||
/**
|
||||
* When this session was created
|
||||
*/
|
||||
|
||||
@@ -11,8 +11,10 @@ export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'rol
|
||||
* Authorization code for OAuth 2.0 authorization code flow
|
||||
*/
|
||||
export interface IAuthorizationCode {
|
||||
/** The authorization code string */
|
||||
code: string;
|
||||
id: string;
|
||||
data: {
|
||||
/** Hashed authorization code string */
|
||||
codeHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID who authorized */
|
||||
@@ -29,17 +31,20 @@ export interface IAuthorizationCode {
|
||||
nonce?: string;
|
||||
/** Expiration timestamp (10 minutes from creation) */
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
/** Whether the code has been used (single-use) */
|
||||
used: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Access Token (opaque or JWT)
|
||||
*/
|
||||
export interface IOidcAccessToken {
|
||||
/** Token identifier */
|
||||
id: string;
|
||||
/** The access token string (or hash for storage) */
|
||||
data: {
|
||||
/** The access token string hash for storage */
|
||||
tokenHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
@@ -51,15 +56,16 @@ export interface IOidcAccessToken {
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Refresh Token
|
||||
*/
|
||||
export interface IOidcRefreshToken {
|
||||
/** Token identifier */
|
||||
id: string;
|
||||
/** The refresh token string (or hash for storage) */
|
||||
data: {
|
||||
/** The refresh token string hash for storage */
|
||||
tokenHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
@@ -73,14 +79,15 @@ export interface IOidcRefreshToken {
|
||||
issuedAt: number;
|
||||
/** Whether the token has been revoked */
|
||||
revoked: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User consent record for an OAuth client
|
||||
*/
|
||||
export interface IUserConsent {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
data: {
|
||||
/** User who gave consent */
|
||||
userId: string;
|
||||
/** OAuth client ID */
|
||||
@@ -91,6 +98,7 @@ export interface IUserConsent {
|
||||
grantedAt: number;
|
||||
/** When consent was last updated */
|
||||
updatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export type TRegistrationSessionStatus =
|
||||
| 'announced'
|
||||
| 'emailValidated'
|
||||
| 'mobileVerified'
|
||||
| 'registered'
|
||||
| 'failed';
|
||||
|
||||
export interface IRegistrationSession {
|
||||
id: string;
|
||||
data: {
|
||||
emailAddress: string;
|
||||
hashedEmailToken: string;
|
||||
smsCodeHash?: string | null;
|
||||
smsvalidationCounter: number;
|
||||
status: TRegistrationSessionStatus;
|
||||
validUntil: number;
|
||||
createdAt: number;
|
||||
collectedData: {
|
||||
userData: {
|
||||
username?: string | null;
|
||||
connectedOrgs: string[];
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
status?: 'new' | 'active' | 'deleted' | 'suspended' | null;
|
||||
mobileNumber?: string | null;
|
||||
password?: string | null;
|
||||
passwordHash?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+81
-265
@@ -1,312 +1,128 @@
|
||||
# @idp.global/interfaces
|
||||
|
||||
TypeScript interfaces and type definitions for the idp.global Identity Provider platform.
|
||||
Shared TypeScript contracts for the `idp.global` backend, browser client, CLI, and frontend.
|
||||
|
||||
## Overview
|
||||
Use this package when you want typed request/response payloads and shared data models for users, sessions, organizations, apps, billing, and OIDC.
|
||||
|
||||
This package provides the complete type system for idp.global, including data models, API request/response interfaces, and OIDC definitions. Use this package when building applications that integrate with idp.global or when you need type-safe interactions with the IdP API.
|
||||
## Issue Reporting and Security
|
||||
|
||||
## Installation
|
||||
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
|
||||
npm install @idp.global/interfaces
|
||||
# or
|
||||
pnpm add @idp.global/interfaces
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { data, request, tags } from '@idp.global/interfaces';
|
||||
|
||||
// Data interfaces
|
||||
const user: data.IUser = {
|
||||
id: 'user_123',
|
||||
data: {
|
||||
name: 'John Doe',
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
status: 'active',
|
||||
connectedOrgs: ['org_1', 'org_2'],
|
||||
},
|
||||
const loginRequest: request.IReq_LoginWithEmailOrUsernameAndPassword['request'] = {
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
};
|
||||
|
||||
// Organization interface
|
||||
const org: data.IOrganization = {
|
||||
const organization: data.IOrganization = {
|
||||
id: 'org_1',
|
||||
data: {
|
||||
name: 'Acme Corp',
|
||||
name: 'Acme',
|
||||
slug: 'acme',
|
||||
billingPlanId: 'plan_free',
|
||||
roleIds: ['role_admin', 'role_member'],
|
||||
roleIds: [],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Package Structure
|
||||
## Exports
|
||||
|
||||
```
|
||||
ts_interfaces/
|
||||
├── data/ # Data model interfaces
|
||||
│ ├── loint-reception.user.ts # User profiles
|
||||
│ ├── loint-reception.organization.ts # Organizations
|
||||
│ ├── loint-reception.role.ts # RBAC roles
|
||||
│ ├── loint-reception.app.ts # OAuth applications
|
||||
│ ├── loint-reception.oidc.ts # OIDC tokens & flows
|
||||
│ ├── loint-reception.jwt.ts # JWT structures
|
||||
│ ├── loint-reception.loginsession.ts # Login sessions
|
||||
│ ├── loint-reception.billingplan.ts # Billing plans
|
||||
│ ├── loint-reception.device.ts # Device management
|
||||
│ ├── loint-reception.activity.ts # Activity logs
|
||||
│ ├── loint-reception.userinvitation.ts # Invitations
|
||||
│ └── loint-reception.appconnection.ts # App connections
|
||||
├── request/ # API request/response interfaces
|
||||
│ ├── loint-reception.login.ts # Authentication
|
||||
│ ├── loint-reception.registration.ts # User registration
|
||||
│ ├── loint-reception.user.ts # User management
|
||||
│ ├── loint-reception.organization.ts # Org management
|
||||
│ ├── loint-reception.jwt.ts # JWT operations
|
||||
│ ├── loint-reception.apitoken.ts # API tokens
|
||||
│ ├── loint-reception.app.ts # App management
|
||||
│ ├── loint-reception.billingplan.ts # Billing
|
||||
│ └── loint-reception.admin.ts # Admin operations
|
||||
└── tags/ # Tag definitions
|
||||
```
|
||||
### `data`
|
||||
|
||||
## Data Interfaces
|
||||
The `data` export includes types for:
|
||||
|
||||
### User (`IUser`)
|
||||
- users
|
||||
- organizations
|
||||
- roles
|
||||
- JWT payloads
|
||||
- login sessions
|
||||
- devices
|
||||
- activity logs
|
||||
- apps and app connections
|
||||
- billing plans and Paddle checkout data
|
||||
- OIDC data structures
|
||||
- invitations
|
||||
|
||||
```typescript
|
||||
interface IUser {
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
mobileNumber?: string;
|
||||
password?: string; // Only during initial setting
|
||||
passwordHash?: string; // For validation
|
||||
status: 'new' | 'active' | 'deleted' | 'suspended';
|
||||
connectedOrgs: string[]; // Organization IDs
|
||||
isGlobalAdmin?: boolean; // Platform admin flag
|
||||
### `request`
|
||||
|
||||
The `request` export includes typed request contracts for:
|
||||
|
||||
- login, logout, refresh, password reset, and device attachment
|
||||
- registration flow requests
|
||||
- user and session queries
|
||||
- organization CRUD-style requests
|
||||
- invitations and membership changes
|
||||
- app and admin actions
|
||||
- billing and JWT validation support
|
||||
|
||||
### `tags`
|
||||
|
||||
Shared tag exports live under `tags/`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `data/index.ts` | Re-exports all shared data interfaces |
|
||||
| `request/index.ts` | Re-exports all typed request contracts |
|
||||
| `tags/index.ts` | Re-exports shared tags |
|
||||
|
||||
## Examples
|
||||
|
||||
### Login Contract
|
||||
|
||||
```ts
|
||||
type TLogin = request.IReq_LoginWithEmailOrUsernameAndPassword;
|
||||
|
||||
const payload: TLogin['request'] = {
|
||||
username: 'user@example.com',
|
||||
password: 'secret',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Organization (`IOrganization`)
|
||||
### Session Contract
|
||||
|
||||
```typescript
|
||||
interface IOrganization {
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
billingPlanId: string;
|
||||
roleIds: string[];
|
||||
};
|
||||
}
|
||||
```ts
|
||||
type TSessions = request.IReq_GetUserSessions['response']['sessions'];
|
||||
```
|
||||
|
||||
### Role (`IRole`)
|
||||
### OIDC Contract
|
||||
|
||||
```typescript
|
||||
interface IRole {
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}
|
||||
```ts
|
||||
type TUserInfo = data.IUserInfoResponse;
|
||||
```
|
||||
|
||||
### OAuth Application Types
|
||||
## Scope
|
||||
|
||||
```typescript
|
||||
// Global platform apps (maintained by platform admins)
|
||||
interface IGlobalApp {
|
||||
id: string;
|
||||
type: 'globalApp';
|
||||
data: {
|
||||
name: string;
|
||||
description: string;
|
||||
iconBase64?: string;
|
||||
oauthCredentials?: IOAuthCredentials;
|
||||
};
|
||||
}
|
||||
This package is intentionally contract-only. It does not open sockets, store auth state, or perform HTTP/websocket communication by itself.
|
||||
|
||||
// Partner apps (third-party integrations)
|
||||
interface IPartnerApp {
|
||||
id: string;
|
||||
type: 'partnerApp';
|
||||
data: {
|
||||
name: string;
|
||||
description: string;
|
||||
ownerOrganizationId: string;
|
||||
oauthCredentials?: IOAuthCredentials;
|
||||
};
|
||||
}
|
||||
## License and Legal Information
|
||||
|
||||
// Custom OIDC clients
|
||||
interface ICustomOidcApp {
|
||||
id: string;
|
||||
type: 'customOidcApp';
|
||||
data: {
|
||||
name: string;
|
||||
description: string;
|
||||
ownerOrganizationId: string;
|
||||
oauthCredentials: IOAuthCredentials;
|
||||
};
|
||||
}
|
||||
```
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
### OAuth Credentials
|
||||
**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.
|
||||
|
||||
```typescript
|
||||
interface IOAuthCredentials {
|
||||
clientId: string;
|
||||
clientSecretHash: string;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[];
|
||||
}
|
||||
```
|
||||
### Trademarks
|
||||
|
||||
## OIDC Interfaces
|
||||
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.
|
||||
|
||||
### Authorization Code
|
||||
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.
|
||||
|
||||
```typescript
|
||||
interface IAuthorizationCode {
|
||||
code: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
scopes: string[];
|
||||
redirectUri: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: 'S256';
|
||||
expiresAt: number;
|
||||
used: boolean;
|
||||
}
|
||||
```
|
||||
### Company Information
|
||||
|
||||
### Token Response
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
```typescript
|
||||
interface ITokenResponse {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
}
|
||||
```
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
### UserInfo Response
|
||||
|
||||
```typescript
|
||||
interface IUserInfoResponse {
|
||||
sub: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
organizations?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
roles: string[];
|
||||
}>;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### ID Token Claims
|
||||
|
||||
```typescript
|
||||
interface IIdTokenClaims {
|
||||
iss: string; // Issuer
|
||||
sub: string; // Subject (user ID)
|
||||
aud: string; // Audience (client ID)
|
||||
exp: number; // Expiration time
|
||||
iat: number; // Issued at
|
||||
nonce?: string; // Replay protection
|
||||
name?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
organizations?: Array<{...}>;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## Request Interfaces
|
||||
|
||||
All API requests follow the TypedRequest pattern:
|
||||
|
||||
```typescript
|
||||
interface IReq_LoginWithEmailOrUsernameAndPassword {
|
||||
method: 'loginWithEmailOrUsernameAndPassword';
|
||||
request: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
response: {
|
||||
refreshToken?: string;
|
||||
twoFaNeeded: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Requests
|
||||
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_LoginWithEmailOrUsernameAndPassword` | `loginWithEmailOrUsernameAndPassword` | Password login |
|
||||
| `IReq_LoginWithEmail` | `loginWithEmail` | Magic link request |
|
||||
| `IReq_LoginWithEmailAfterEmailTokenAquired` | `loginWithEmailAfterEmailTokenAquired` | Magic link verification |
|
||||
| `IReq_LoginWithApiToken` | `loginWithApiToken` | API token login |
|
||||
| `IReq_RefreshJwt` | `refreshJwt` | Refresh access token |
|
||||
| `ILogoutRequest` | `logout` | End session |
|
||||
|
||||
### User Management Requests
|
||||
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetUserData` | `getUserData` | Get current user |
|
||||
| `IReq_SetUserData` | `setUserData` | Update user profile |
|
||||
| `IReq_GetUserSessions` | `getUserSessions` | List active sessions |
|
||||
| `IReq_ResetPassword` | `resetPassword` | Request password reset |
|
||||
| `IReq_SetNewPassword` | `setNewPassword` | Set new password |
|
||||
|
||||
### Organization Requests
|
||||
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_CreateOrganization` | `createOrganization` | Create new org |
|
||||
| `IReq_GetOrgMembers` | `getOrgMembers` | List org members |
|
||||
| `IReq_CreateInvitation` | `createInvitation` | Invite user |
|
||||
| `IReq_AcceptInvitation` | `acceptInvitation` | Accept invite |
|
||||
|
||||
### JWT Operations
|
||||
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_GetPublicKeyForValidation` | `getPublicKeyForValidation` | Get JWT public key |
|
||||
| `IReq_GetJwtIdBlocklist` | `getJwtIdBlocklist` | Get revoked token IDs |
|
||||
|
||||
## Supported OIDC Scopes
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `openid` | Required for OIDC flows |
|
||||
| `profile` | User's name and username |
|
||||
| `email` | User's email address |
|
||||
| `organizations` | User's organization memberships |
|
||||
| `roles` | User's roles within organizations |
|
||||
|
||||
## License
|
||||
|
||||
MIT - See the main repository for full license details.
|
||||
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.
|
||||
|
||||
@@ -87,7 +87,8 @@ export interface IReq_RefreshJwt
|
||||
};
|
||||
response: {
|
||||
status: data.TLoginStatus;
|
||||
jwt: string;
|
||||
jwt?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.14.1',
|
||||
version: '1.19.0',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { accountDesignTokens } from './sharedstyles.js';
|
||||
import * as views from './views/index.js';
|
||||
import * as accountstate from '../../states/accountstate.js';
|
||||
|
||||
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
|
||||
import { commitinfo } from '../../../ts/00_commitinfo_data.js';
|
||||
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { accountDesignTokens } from './sharedstyles.js';
|
||||
import { CreateOrgModal } from './create-org-modal.js';
|
||||
import { OrgSelectModal } from './org-select-modal.js';
|
||||
|
||||
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
|
||||
import { commitinfo } from '../../../ts/00_commitinfo_data.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
query,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { commitinfo } from '../../dist_ts/00_commitinfo_data.js';
|
||||
import { commitinfo } from '../../ts/00_commitinfo_data.js';
|
||||
import { IdpState } from '../states/idp.state.js';
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -207,21 +207,14 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
}
|
||||
|
||||
public async handleRefreshToken(refreshTokenArg: string, delayDispatchMillisArg = 0) {
|
||||
// a refreshToken binds directly to a session.
|
||||
// the refresh token is used on a continuous basis to get fresh and short-lived jwts
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const refreshJwt = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
const responseJwt = await refreshJwt.fire({
|
||||
refreshToken: refreshTokenArg,
|
||||
});
|
||||
const jwt = await idpState.idpClient.refreshJwt(refreshTokenArg);
|
||||
|
||||
if (responseJwt.jwt) {
|
||||
if (jwt) {
|
||||
this.domtools.convenience.smartdelay.delayFor(delayDispatchMillisArg).then(() => {
|
||||
this.dispatchJwt(responseJwt.jwt);
|
||||
this.dispatchJwt(jwt);
|
||||
});
|
||||
return responseJwt.jwt;
|
||||
return jwt;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -488,15 +488,15 @@ export class IdpRegistrationStepper extends DeesElement {
|
||||
username: this.storedData.email,
|
||||
password: eventArg.detail.data.password,
|
||||
});
|
||||
this.storedData.refreshToken = loginResponse.refreshToken;
|
||||
|
||||
deesForm.setStatus('pending', 'Obtaining JWT...');
|
||||
const jwtResponse = await idpState.idpClient.requests.obtainJwt.fire({
|
||||
refreshToken: this.storedData.refreshToken,
|
||||
});
|
||||
const jwt = await idpState.idpClient.refreshJwt(loginResponse.refreshToken);
|
||||
|
||||
if (!jwt) {
|
||||
deesForm.setStatus('error', 'Failed to establish a login session.');
|
||||
return;
|
||||
}
|
||||
|
||||
deesForm.setStatus('success', 'Ok! Lets Go!');
|
||||
await idpState.idpClient.setJwt(jwtResponse.jwt);
|
||||
idpState.domtools.router.pushUrl('/account');
|
||||
}, { signal });
|
||||
},
|
||||
|
||||
+72
-245
@@ -1,256 +1,83 @@
|
||||
# @idp.global/web
|
||||
# `ts_web/` Web App Module
|
||||
|
||||
Web Components and UI elements for the idp.global Identity Provider platform. Built with `@design.estate/dees-element` and the dees-catalog component library.
|
||||
The `ts_web/` folder contains the frontend for `idp.global`: login, registration, account management, org management, billing, and admin UI.
|
||||
|
||||
## Overview
|
||||
It is built with `@design.estate/dees-element`, `@design.estate/dees-domtools`, and the shared `idp.global` client and interface packages.
|
||||
|
||||
This package provides the complete web interface for idp.global, including authentication flows, account management, and organization administration. All components are built as Web Components using the Lit-based `dees-element` framework.
|
||||
## Issue Reporting and Security
|
||||
|
||||
## Installation
|
||||
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
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `index.ts` | Frontend entrypoint and initial render |
|
||||
| `views/viewcontainer.ts` | View switching for welcome, login, register, finishregistration, and account |
|
||||
| `elements/` | Web components for prompts, layout, and account UI |
|
||||
| `elements/account/views/` | Account subviews including org, apps, subscriptions, paddle setup, and admin |
|
||||
| `states/` | App-level and account-level state containers |
|
||||
|
||||
## UI Surface
|
||||
|
||||
The module currently includes:
|
||||
|
||||
- a welcome page
|
||||
- login and registration prompts
|
||||
- a multi-step registration flow
|
||||
- an account area with navigation
|
||||
- organization selection and creation flows
|
||||
- bulk member invitation UI
|
||||
- app and subscription views
|
||||
- a global admin view
|
||||
|
||||
## Routing
|
||||
|
||||
`IdpViewcontainer` switches between these frontend states:
|
||||
|
||||
| View | Route |
|
||||
| --- | --- |
|
||||
| `welcome` | `/` |
|
||||
| `login` | `/login` |
|
||||
| `register` | `/register` |
|
||||
| `finishregistration` | `/finishregistration` |
|
||||
| `account` | `/account` |
|
||||
|
||||
## Build And Run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm install @idp.global/web
|
||||
# or
|
||||
pnpm add @idp.global/web
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ts_web/
|
||||
├── index.ts # Application entry point
|
||||
├── plugins.ts # Plugin imports
|
||||
├── views/
|
||||
│ ├── viewcontainer.ts # Main view router
|
||||
│ └── index.ts
|
||||
├── elements/ # Web Components
|
||||
│ ├── idp-loginprompt.ts # Login form
|
||||
│ ├── idp-registerprompt.ts # Registration form
|
||||
│ ├── idp-registration-stepper.ts # Multi-step registration
|
||||
│ ├── idp-centercontainer.ts # Centered layout container
|
||||
│ ├── idp-transfermanager.ts # SSO transfer handling
|
||||
│ ├── idp-welcome.ts # Welcome/landing page
|
||||
│ └── account/ # Account dashboard components
|
||||
│ ├── content.ts # Main account layout
|
||||
│ ├── navigation.ts # Sidebar navigation
|
||||
│ ├── org-select-modal.ts # Organization switcher
|
||||
│ ├── create-org-modal.ts # Create organization dialog
|
||||
│ ├── bulk-invite-modal.ts # Bulk member invite dialog
|
||||
│ └── views/ # Account sub-views
|
||||
│ ├── baseview.ts # Base view class
|
||||
│ ├── usersview.ts # User profile view
|
||||
│ ├── orgview.ts # Organization details
|
||||
│ ├── orgsetup.ts # Organization setup
|
||||
│ ├── appsview.ts # Connected apps
|
||||
│ ├── adminview.ts # Global admin panel
|
||||
│ ├── subscriptions.ts # Billing subscriptions
|
||||
│ └── paddlesetup.ts # Payment setup
|
||||
└── states/
|
||||
├── idp.state.ts # Main application state
|
||||
└── accountstate.ts # Account dashboard state
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Authentication Components
|
||||
|
||||
#### `<idp-loginprompt>`
|
||||
|
||||
Login form supporting password and magic link authentication.
|
||||
|
||||
```html
|
||||
<idp-loginprompt></idp-loginprompt>
|
||||
```
|
||||
|
||||
Features:
|
||||
- Email/username + password login
|
||||
- Magic link (passwordless) authentication
|
||||
- Automatic button text based on password presence
|
||||
- Form validation and error handling
|
||||
- Redirect to registration
|
||||
|
||||
#### `<idp-registerprompt>`
|
||||
|
||||
Initial registration form for new users.
|
||||
|
||||
```html
|
||||
<idp-registerprompt></idp-registerprompt>
|
||||
```
|
||||
|
||||
#### `<idp-registration-stepper>`
|
||||
|
||||
Multi-step registration wizard for completing user profile.
|
||||
|
||||
```html
|
||||
<idp-registration-stepper></idp-registration-stepper>
|
||||
```
|
||||
|
||||
Steps include:
|
||||
- Profile information
|
||||
- Email verification
|
||||
- Mobile verification (optional)
|
||||
- Password setup
|
||||
|
||||
### Layout Components
|
||||
|
||||
#### `<idp-viewcontainer>`
|
||||
|
||||
Main view container that handles routing between views.
|
||||
|
||||
```html
|
||||
<idp-viewcontainer></idp-viewcontainer>
|
||||
```
|
||||
|
||||
Supported views:
|
||||
- `welcome` - Landing page
|
||||
- `login` - Login form
|
||||
- `register` - Registration form
|
||||
- `finishregistration` - Registration stepper
|
||||
- `account` - Account dashboard
|
||||
|
||||
#### `<idp-centercontainer>`
|
||||
|
||||
Centered container with animation support for forms.
|
||||
|
||||
```html
|
||||
<idp-centercontainer>
|
||||
<h2>Your Content</h2>
|
||||
<form>...</form>
|
||||
</idp-centercontainer>
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `show()` - Animate container into view
|
||||
- `hide()` - Animate container out of view
|
||||
|
||||
### Account Dashboard Components
|
||||
|
||||
#### `<idp-account-content>`
|
||||
|
||||
Main account dashboard layout with navigation.
|
||||
|
||||
```html
|
||||
<idp-account-content></idp-account-content>
|
||||
```
|
||||
|
||||
#### Navigation Views
|
||||
|
||||
| Component | Route | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `<idp-usersview>` | `/account/users` | User profile management |
|
||||
| `<idp-orgview>` | `/account/org` | Organization details |
|
||||
| `<idp-orgsetup>` | `/account/orgsetup` | Organization configuration |
|
||||
| `<idp-appsview>` | `/account/apps` | Connected applications |
|
||||
| `<idp-adminview>` | `/account/admin` | Global admin panel |
|
||||
| `<idp-subscriptions>` | `/account/subscriptions` | Billing management |
|
||||
| `<idp-paddlesetup>` | `/account/paddle` | Payment method setup |
|
||||
|
||||
### Modal Components
|
||||
|
||||
#### `<idp-org-select-modal>`
|
||||
|
||||
Organization switcher modal for users with multiple organizations.
|
||||
|
||||
#### `<idp-create-org-modal>`
|
||||
|
||||
Dialog for creating new organizations with slug validation.
|
||||
|
||||
#### `<idp-bulk-invite-modal>`
|
||||
|
||||
Bulk invitation dialog for inviting multiple members at once.
|
||||
|
||||
## State Management
|
||||
|
||||
### IdpState
|
||||
|
||||
Central application state using `@push.rocks/smartstate`.
|
||||
|
||||
```typescript
|
||||
import { IdpState } from '@idp.global/web';
|
||||
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
|
||||
// Access IdP client
|
||||
const isLoggedIn = await idpState.idpClient.determineLoginStatus();
|
||||
|
||||
// Access router
|
||||
idpState.domtools.router.pushUrl('/login');
|
||||
|
||||
// Subscribe to view changes
|
||||
idpState.mainStatePart.select(s => s.view).subscribe(view => {
|
||||
console.log('Current view:', view);
|
||||
});
|
||||
```
|
||||
|
||||
### AccountState
|
||||
|
||||
State for the account dashboard section.
|
||||
|
||||
```typescript
|
||||
import { AccountState } from '@idp.global/web';
|
||||
|
||||
const accountState = await AccountState.getSingletonInstance();
|
||||
|
||||
// Access current organization
|
||||
const currentOrg = accountState.currentOrganization;
|
||||
|
||||
// Access user roles
|
||||
const roles = accountState.userRoles;
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Components use CSS custom properties for theming:
|
||||
|
||||
```css
|
||||
:host {
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--background-accent: #303f9f;
|
||||
}
|
||||
```
|
||||
|
||||
All components include:
|
||||
- Dark mode by default
|
||||
- Geist Sans font family
|
||||
- Smooth animations
|
||||
- Responsive layouts
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@design.estate/dees-element` - Web Component base class
|
||||
- `@design.estate/dees-catalog` - UI component library
|
||||
- `@design.estate/dees-domtools` - DOM utilities and routing
|
||||
- `@idp.global/idpclient` - IdP client library
|
||||
- `@idp.global/interfaces` - TypeScript interfaces
|
||||
- `@push.rocks/smartstate` - State management
|
||||
- `@uptime.link/webwidget` - Status widget
|
||||
|
||||
## Views and Routes
|
||||
|
||||
| Route | View | Component |
|
||||
|-------|------|-----------|
|
||||
| `/` | `welcome` | `IdpWelcome` |
|
||||
| `/login` | `login` | `IdpLoginPrompt` |
|
||||
| `/register` | `register` | `IdpRegistrationPrompt` |
|
||||
| `/finishregistration` | `finishregistration` | `IdpRegistrationStepper` |
|
||||
| `/account` | `account` | `IdpAccountContent` |
|
||||
| `/logout` | - | Logout handler |
|
||||
|
||||
## Building
|
||||
|
||||
The web module is bundled using `@git.zone/tsbundle`:
|
||||
|
||||
```bash
|
||||
# Development with hot reload
|
||||
pnpm watch
|
||||
|
||||
# Production build
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm watch
|
||||
```
|
||||
|
||||
The bundled output is served from `dist_ts_web/` by the TypedServer.
|
||||
`pnpm watch` rebuilds the frontend bundle from `ts_web/index.ts` into `dist_serve/bundle.js` while the backend serves the app.
|
||||
|
||||
## License
|
||||
## Notes
|
||||
|
||||
MIT - See the main repository for full license details.
|
||||
- The app metadata in `ts_web/index.ts` identifies the site as `idp.global`.
|
||||
- The frontend uses the shared client package for auth state and backend communication.
|
||||
- Account-related UI is split into reusable elements plus state containers in `states/`.
|
||||
|
||||
## 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.
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"],
|
||||
"strict": false
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user