Compare commits

...

16 Commits

Author SHA1 Message Date
jkunz fe9da65437 v1.18.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 08:27:35 +00:00
jkunz 28d30fe392 feat(reception): persist email action tokens and registration sessions for authentication and signup flows 2026-04-20 08:27:35 +00:00
jkunz 1532c9704b v1.17.1
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 08:15:42 +00:00
jkunz 76efcb835f fix(docs): refresh module readmes and add repository license file 2026-04-20 08:15:42 +00:00
jkunz 2d1e6ea6e1 v1.17.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 08:12:07 +00:00
jkunz 98e614a945 feat(auth): harden authentication with argon2 passwords and rotating hashed refresh tokens 2026-04-20 08:12:07 +00:00
jkunz ad3e51a9e8 v1.16.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-29 15:06:40 +00:00
jkunz d8f72d620a feat(dev): add local development docs, update tswatch preset and add Playwright screenshots 2026-01-29 15:06:40 +00:00
jkunz 53b36e506c v1.15.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-29 14:24:08 +00:00
jkunz 7d5ad29a27 feat(build): add tsbundle/tswatch configs, update build/watch scripts, bump dependencies, and add CLI documentation 2026-01-29 14:24:08 +00:00
jkunz 724ec2d134 v1.14.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-22 15:56:20 +00:00
jkunz 32ffc1bbaa fix(oidc): migrate OIDC endpoints and internal handlers to use typedserver IRequestContext and update dependencies 2025-12-22 15:56:20 +00:00
jkunz a91dd9dda6 v1.14.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-16 12:46:42 +00:00
jkunz 5462257398 feat(docs): add package READMEs and publish metadata; update web package publish order 2025-12-16 12:46:42 +00:00
jkunz 2ad751ecba v1.13.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-15 19:45:57 +00:00
jkunz a24b0d8be7 feat(oidc): feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces) 2025-12-15 19:45:57 +00:00
59 changed files with 6651 additions and 2749 deletions
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

+66
View File
@@ -1,5 +1,71 @@
# Changelog
## 2026-04-20 - 1.18.0 - feat(reception)
persist email action tokens and registration sessions for authentication and signup flows
- add persisted email action tokens for email login and password reset with one-time consumption and expiry cleanup
- store registration sessions in the database so signup state, email validation, and SMS verification survive restarts
- enforce password changes through either a valid reset token or the current password
- add housekeeping jobs and tests for token/session expiry and state persistence
## 2026-04-20 - 1.17.1 - fix(docs)
refresh module readmes and add repository license file
- rewrite the root, backend, web, client, CLI, and interfaces README content to focus on current module responsibilities and usage
- standardize README license references to the lowercase license file path
- add the repository MIT license file
## 2026-04-20 - 1.17.0 - feat(auth)
harden authentication with argon2 passwords and rotating hashed refresh tokens
- replace SHA-256 password hashing with argon2 while preserving verification and upgrade support for legacy hashes
- rotate refresh tokens on JWT refresh, detect token reuse, and invalidate compromised sessions
- store refresh and transfer tokens as hashes with one-time transfer token validation and expiry
- persist refresh tokens separately on the client so sessions can recover and refresh without embedding tokens in JWTs
- add authentication tests covering password verification, legacy hash migration, refresh token rotation, reuse detection, and one-time transfer tokens
## 2026-01-29 - 1.16.0 - feat(dev)
add local development docs, update tswatch preset and add Playwright screenshots
- readme.md: added a Local Development section with prerequisites, quick-start commands, environment variables, development routes, and default development credentials + security note
- npmextra.json: changed @git.zone/tswatch preset from "website" to "service" and disabled the built-in server (removed port/serveDir/liveReload and set server.enabled false); removed triggerReload from website watcher
- .playwright-mcp: added Playwright screenshots (login-page.png, register-page.png, account-dashboard.png) for visual tests / CI
## 2026-01-29 - 1.15.0 - feat(build)
add tsbundle/tswatch configs, update build/watch scripts, bump dependencies, and add CLI documentation
- Add tsbundle and tswatch configuration to npmextra.json to support bundling and a local dev server (dist_serve, liveReload, watch patterns).
- Update package.json build/watch scripts to use generic tsbundle/tswatch invocations (removed explicit 'website' target).
- Bump dependencies and devDependencies: @git.zone/tsbuild ^4.0.2 -> ^4.1.2, @git.zone/tsbundle ^2.6.3 -> ^2.8.3, @git.zone/tswatch ^2.3.13 -> ^3.0.1, @api.global/typedserver ^8.1.0 -> ^8.3.0, several @design.estate packages, @push.rocks/taskbuffer ^3.5.0 -> ^4.1.1, @types/node 25.0.3 -> 25.1.0, and other minor/patch bumps.
- Add a new CLI README (ts_idpcli/readme.md) with usage, commands, programmatic API examples and configuration.
- Update README license/Legal sections in ts_idpclient, ts_interfaces and ts_web to include license, trademark, and company information.
## 2025-12-22 - 1.14.1 - fix(oidc)
migrate OIDC endpoints and internal handlers to use typedserver IRequestContext and update dependencies
- Updated route handlers in ts/index.ts to pass ctx (IRequestContext) instead of req
- Refactored OIDC manager handlers to accept plugins.typedserver.IRequestContext and use ctx.url, ctx.headers, ctx.formData (handleAuthorize, handleToken, handleUserInfo, handleRevoke)
- Bumped dependencies to support the new typedserver API: @api.global/typedserver -> ^8.1.0
- Other dependency updates: @design.estate/dees-catalog ^3.4.0, @git.zone/tspublish ^1.11.0, @types/node ^25.0.3
- Changing public handler method signatures is a breaking API change; recommend a major version bump
## 2025-12-16 - 1.14.0 - feat(docs)
add package READMEs and publish metadata; update web package publish order
- Add comprehensive README for ts_web (web components/UI)
- Add README for ts_idpclient (TypeScript client)
- Add README for ts_interfaces (type definitions/interfaces)
- Add tspublish.json for ts_idpcli (@idp.global/cli) and ts_idpclient (@idp.global/client)
- Update ts_web/tspublish.json order from 4 to 5
## 2025-12-15 - 1.13.0 - feat(oidc)
feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces)
- Add OidcManager class implementing OpenID Connect / OAuth2 server functionality (authorization codes, access/refresh tokens, user consents, PKCE support, JWKS, ID token generation, token revocation, cleanup task).
- Expose OIDC endpoints on the website server: /.well-known/openid-configuration, /.well-known/jwks.json, /oauth/authorize, /oauth/token, /oauth/userinfo (GET/POST), and /oauth/revoke.
- Integrate OidcManager into Reception: add oidcManager property and instantiate it from ts/index.ts so routes can reference it.
- Add TypeScript interfaces for OIDC data structures (ts_interfaces/data/loint-reception.oidc.ts) and export them from the data index.
## 2025-12-15 - 1.12.1 - fix(dependencies)
fix(deps): bump @uptime.link/webwidget to ^1.2.6
+21
View File
@@ -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.
+35
View File
@@ -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
View File
@@ -1,14 +1,14 @@
{
"name": "@idp.global/idp.global",
"version": "1.12.1",
"version": "1.18.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": "^7.11.1",
"@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.3.1",
"@design.estate/dees-domtools": "^2.3.6",
"@design.estate/dees-element": "^2.1.3",
"@git.zone/tspublish": "^1.10.3",
"@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": "^24.10.1"
"@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": {
+3797 -2071
View File
File diff suppressed because it is too large Load Diff
+157 -261
View File
@@ -1,312 +1,208 @@
# @idp.global/idp.global
An identity provider software managing user authentications, registrations, and sessions.
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, and OpenID Connect in one TypeScript codebase.
## Install
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.
To install `@idp.global/idp.global`, you can run the following command in your terminal:
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What It Does
- Runs an identity provider with MongoDB-backed users, sessions, roles, organizations, invitations, API tokens, and billing plans.
- Serves a web app for login, registration, account management, org management, billing flows, and global admin views.
- Exposes typed realtime APIs over `typedrequest` and `typedsocket`.
- Implements OIDC/OAuth endpoints including discovery, JWKS, authorization, token, userinfo, and revoke.
- Includes a reusable browser client and a terminal CLI for common account and org workflows.
## Monorepo Modules
| Folder | Purpose |
| --- | --- |
| `ts/` | Backend service entrypoint and the core `Reception` managers |
| `ts_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 |
## Core Backend Pieces
`Reception` wires the service together and starts these managers:
- `JwtManager` for signing, refreshing, and validating JWTs.
- `LoginSessionManager` for login state and session lifecycle.
- `RegistrationSessionManager` for multi-step sign-up flows.
- `UserManager` for user lookups and account data.
- `OrganizationManager` for org creation and membership lookup.
- `RoleManager` for org roles and permissions.
- `UserInvitationManager` for invites, membership updates, and ownership transfer.
- `ApiTokenManager` for long-lived token auth.
- `BillingPlanManager` for Paddle-backed billing data.
- `AppManager` and `AppConnectionManager` for app connections and admin app stats.
- `ActivityLogManager` for audit-style activity entries.
- `OidcManager` for the OIDC/OAuth provider surface.
## Quick Start
### Prerequisites
- Node.js 20+
- `pnpm`
- MongoDB
### Install
```bash
npm install @idp.global/idp.global
pnpm install
```
This will download and install the necessary dependencies along with the module to your project.
### Required Environment
## Usage
```bash
export MONGODB_URL=mongodb://localhost:27017/idp-dev
export IDP_BASEURL=http://localhost:2999
export INSTANCE_NAME=idp-dev
```
To use `@idp.global/idp.global`, one needs to understand its key components and functionalities. Below, we'll guide you through setting up, logging in, registering, and managing users and organizations within an IDP (Identity Provider) environment using this package.
Optional:
### Setting Up the Environment
- `SERVEZONE_PLATFROM_AUTHORIZATION`
- `PADDLE_TOKEN`
- `PADDLE_PRICE_ID`
First, let's set up the environment:
### Build
```typescript
// Import the necessary modules
import * as serviceworker from '@api.global/typedserver/web_serviceworker_client';
import * as domtools from '@design.estate/dees-domtools';
import { html, render } from '@design.estate/dees-element';
import { IdpWelcome } from './elements/idp-welcome.js';
```bash
pnpm build
```
// Define an asynchronous run function
const run = async () => {
// Set up DOM tools
const domtoolsInstance = await domtools.DomTools.setupDomTools();
domtools.elementBasic.setup();
### Run Locally
// Configure website information
domtoolsInstance.setWebsiteInfo({
metaObject: {
title: 'idp.global',
description: 'the code that runs idp.global',
canonicalDomain: 'https://idp.global',
ldCompany: {
name: 'Task Venture Capital GmbH',
status: 'active',
contact: {
address: {
name: 'Task Venture Capital GmbH',
city: 'Grasberg',
country: 'Germany',
houseNumber: '24',
postalCode: '28879',
streetName: 'Eickedorfer Vorweide',
},
}
},
},
```bash
pnpm watch
```
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';
const idpClient = new IdpClient('https://idp.global');
await idpClient.enableTypedSocket();
const isLoggedIn = await idpClient.determineLoginStatus();
if (!isLoggedIn) {
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'secret',
});
// Set up the service worker
const serviceWorker = await serviceworker.getServiceworkerClient();
// Render the main template
const mainTemplate = html`
<style>
body {
margin: 0px;
--background-accent: #303f9f;
}
</style>
<idp-welcome></idp-welcome>
`;
render(mainTemplate, document.body);
};
// Run the function
run();
```
### Using the IDP Client
The IDP Client is essential to communicate with the IDP server. Below is a sample of how to set up and use the IDP client:
```typescript
import { IdpState } from './idp.state.js';
import * as plugins from './plugins.js';
// Instantiate IdpState which provides a singleton instance
export class IdpDemo {
private idpState = IdpState.getSingletonInstance();
// Function to initialize and use IdpClient
public async demo() {
// Fetch the client instance
const { idpClient } = this.idpState;
// Handler for login
const handleLogin = async () => {
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'password123',
});
if (response.refreshToken) {
await idpClient.storeJwt(response.jwt);
console.log("Logged in successfully, JWT stored.");
} else {
console.log("Login failed.");
}
};
// Execute login handler
await handleLogin();
if (loginResult.refreshToken) {
await idpClient.refreshJwt(loginResult.refreshToken);
}
}
// Instantiate and run demo
const demo = new IdpDemo();
demo.demo();
const whoIs = await idpClient.whoIs();
console.log(whoIs.user.data.email);
```
### Managing User Authentication
## CLI Example
Several functionalities are available for managing user authentication. These include registering, logging in, and refreshing JWTs.
The terminal client lives in `ts_idpcli/` and is published as `@idp.global/cli`.
#### Registration Process
The registration process is typically more involved and requires steps such as email validation, setting user-specific data, and verifying OTPs for additional security.
```typescript
import * as plugins from './plugins.js';
import { IdpState } from './idp.state.js';
// Registration stepper element
export class IdpRegistrationStepper extends plugins.DeesElement {
private idpState = IdpState.getSingletonInstance();
public async firstUpdated() {
await this.domtoolsPromise;
this.domtools.router.on(`/finishregistration`, async (routeArg) => {
const validationToken = routeArg.queryParams.validationtoken;
if (!validationToken) {
this.renderErrorMessage("Validation token not found.");
return;
}
const emailResponse = await this.validateEmail(validationToken);
if (!emailResponse.email) {
this.renderErrorMessage("Invalid validation token.");
return;
}
await this.renderRegistrationForm(emailResponse.email);
});
}
private async validateEmail(token: string) {
return await this.idpState.idpClient.requests.afterRegistrationEmailClicked.fire({
token
});
}
private async renderRegistrationForm(email: string) {
const template = plugins.html`
<dees-form @formData="${async (event) => await this.handleFormSubmission(event, email)}">
<dees-input-text key="First Name" label="First Name" required></dees-input-text>
<dees-input-text key="Last Name" label="Last Name" required></dees-input-text>
<dees-form-submit>Next</dees-form-submit>
</dees-form>
`;
this.render(template, this.shadowRoot);
}
private async handleFormSubmission(event: FormDataEvent, email: string) {
const formData = (event.target as any).getFormData();
await this.idpState.idpClient.requests.setData.fire({
token: this.storedData.validationTokenUrlParam,
userData: {
email,
first_name: formData.FirstName,
last_name: formData.LastName,
},
});
// Proceed to the next steps as per the registration flow
}
private renderErrorMessage(message: string) {
const template = plugins.html`<div>Error: ${message}</div>`;
this.render(template, this.shadowRoot);
}
}
```bash
idp login
idp whoami
idp orgs
idp members --org <org-id>
idp invite --org <org-id> --email user@example.com
```
### User Management
The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_URL` to override the target server.
Managing user data including roles, organizations, and billing plans is essential in any identity provider software.
## Shared Interfaces
#### Getting User Data
`ts_interfaces/` exports the type contracts shared across the stack:
```typescript
import * as plugins from './plugins.js';
- `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.
const fetchUserData = async (jwt: string) => {
const user = await plugins.typedrequest.TypedRequest<plugins.lointReception.request.IReq_GetUserData>(
`/getUserData`, 'POST').fire({jwt});
console.log(user);
};
## Frontend
fetchUserData('<JWT_TOKEN_HERE>');
```
`ts_web/` is the web application bundle. It contains:
#### Creating an Organization
- Login and registration prompts.
- A registration stepper.
- Account navigation and account views.
- Organization creation and bulk invite modals.
- Billing and Paddle setup views.
- A global admin view.
```typescript
import { IdpState } from './idp.state.js';
## Package Scripts
export class OrganizationManager {
private idpState = IdpState.getSingletonInstance();
| 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 |
public async createOrganization(name: string, slug: string, jwt: string) {
const response = await this.idpState.idpClient.requests.createOrganization.fire({
jwt: jwt,
organizationName: name,
organizationSlug: slug,
action: 'manifest',
});
if (response.resultingOrganization) {
console.log(`Organization ${name} created successfully.`);
} else {
console.log(`Organization creation failed.`);
}
}
}
## Repository Notes
// Usage
const organizationManager = new OrganizationManager();
organizationManager.createOrganization('Dev Org', 'dev-org', '<JWT_TOKEN_HERE>');
```
### Managing JWTs
The `@idp.global/idp.global` package involves managing JSON Web Tokens (JWTs) for session handling and security.
#### Refreshing JWTs
```typescript
import { IdpClient } from './idp.client.js';
export const refreshJwt = async (client: IdpClient) => {
const currentJwt = await client.getJwt();
if (!currentJwt) return null;
const response = await client.requests.refreshJwt.fire({
refreshToken: currentJwt.data.refreshToken
});
if (response.jwt) {
await client.storeJwt(response.jwt);
console.log("JWT refreshed and stored.");
return response.jwt;
} else {
console.log("JWT refresh failed.");
return null;
}
};
// Usage
const idpClient = new IdpClient('https://reception.lossless.one/typedrequest');
refreshJwt(idpClient);
```
### Handling Authentication Tokens
Handling tokens (JWTs, refresh tokens, transfer tokens) securely is crucial for maintaining session integrity.
#### Exchanging Refresh Token for Transfer Token
```typescript
import { IdpClient } from './idp.client.js';
const getTransferToken = async (client: IdpClient) => {
const refreshToken = await client.getJwt().data.refreshToken;
const response = await client.requests.obtainOneTimeToken.fire({
refreshToken
});
if(response.transferToken) {
console.log("Obtained Transfer Token: ", response.transferToken);
return response.transferToken;
} else {
console.log("Failed to obtain Transfer Token.");
return null;
}
};
// Usage
const idpClient = new IdpClient('https://reception.lossless.one/typedrequest');
getTransferToken(idpClient);
```
This comprehensive guide should help you understand the detailed setup and usage of the `@idp.global/idp.global` module effectively.
- 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 that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
+140
View 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();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.12.1',
version: '1.18.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+42 -1
View File
@@ -4,6 +4,10 @@ import { Reception } from './reception/classes.reception.js';
export const runCli = async () => {
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
// Create reception first so we can reference it in routes
let reception: Reception;
const websiteServer = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
feedMetadata: null,
domain: 'idp.global',
@@ -22,11 +26,48 @@ export const runCli = async () => {
addCustomRoutes: async (typedserver) => {
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
typedserver.options.spaFallback = true;
// OIDC Discovery endpoint
typedserver.addRoute('/.well-known/openid-configuration', 'GET', async (ctx) => {
return new Response(JSON.stringify(reception.oidcManager.getDiscoveryDocument()), {
headers: { 'Content-Type': 'application/json' },
});
});
// JWKS endpoint
typedserver.addRoute('/.well-known/jwks.json', 'GET', async (ctx) => {
return new Response(JSON.stringify(reception.oidcManager.getJwks()), {
headers: { 'Content-Type': 'application/json' },
});
});
// OAuth Authorization endpoint
typedserver.addRoute('/oauth/authorize', 'GET', async (ctx) => {
return reception.oidcManager.handleAuthorize(ctx);
});
// OAuth Token endpoint
typedserver.addRoute('/oauth/token', 'POST', async (ctx) => {
return reception.oidcManager.handleToken(ctx);
});
// OAuth UserInfo endpoint (GET and POST)
typedserver.addRoute('/oauth/userinfo', 'GET', async (ctx) => {
return reception.oidcManager.handleUserInfo(ctx);
});
typedserver.addRoute('/oauth/userinfo', 'POST', async (ctx) => {
return reception.oidcManager.handleUserInfo(ctx);
});
// OAuth Revocation endpoint
typedserver.addRoute('/oauth/revoke', 'POST', async (ctx) => {
return reception.oidcManager.handleRevoke(ctx);
});
},
});
// lets add the reception routes
const reception = new Reception({
reception = new Reception({
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
mongoDescriptor: {
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
+5 -2
View File
@@ -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,
@@ -52,4 +55,4 @@ export {
// @tsclass scope
import * as tsclass from '@tsclass/tsclass';
export { tsclass };
export { tsclass };
+87
View File
@@ -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.
+49
View File
@@ -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;
}
}
+40
View File
@@ -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');
}
+38 -16
View File
@@ -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) {
return null;
}
const refreshTokenValid = await loginSession.validateRefreshToken(refreshTokenArg);
if (!refreshTokenValid) {
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
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,
}
});
return loginSession;
if (this.data.sessionId) {
return this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
id: this.data.sessionId,
});
}
if (!this.data.refreshToken) {
return null;
}
const sessionLookup =
await this.manager.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
this.data.refreshToken
);
if (!sessionLookup) {
return null;
}
return sessionLookup.loginSession;
}
}
+40 -4
View File
@@ -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();
this.blockedJwtIdList.push(jwt.id);
if (!this.blockedJwtIdList.includes(jwt.id)) {
this.blockedJwtIdList.push(jwt.id);
}
return null;
}
}
+91 -11
View File
@@ -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;
}
+195 -85
View File
@@ -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;
}
}
+683
View File
@@ -0,0 +1,683 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import type { App } from './classes.app.js';
/**
* OidcManager handles OpenID Connect (OIDC) server functionality
* for third-party client authentication.
*/
export class OidcManager {
public receptionRef: Reception;
public get db() {
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>();
// In-memory store for access tokens (for validation)
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
// In-memory store for refresh tokens
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
// In-memory store for user consents (should be persisted later)
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
// Start cleanup task for expired codes/tokens
this.startCleanupTask();
}
/**
* Get the OIDC Discovery Document
*/
public getDiscoveryDocument(): plugins.idpInterfaces.data.IOidcDiscoveryDocument {
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
return {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
revocation_endpoint: `${baseUrl}/oauth/revoke`,
scopes_supported: ['openid', 'profile', 'email', 'organizations', 'roles'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
code_challenge_methods_supported: ['S256'],
claims_supported: [
'sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce',
'name', 'preferred_username', 'picture',
'email', 'email_verified',
'organizations', 'roles'
],
};
}
/**
* Get the JSON Web Key Set (JWKS)
*/
public getJwks(): plugins.idpInterfaces.data.IJwks {
const keypair = this.receptionRef.jwtManager.smartjwtInstance.getKeyPairAsJson();
// Convert PEM to JWK format
const jwk = this.pemToJwk(keypair.publicPem);
return {
keys: [jwk],
};
}
/**
* Convert PEM public key to JWK format
*/
private pemToJwk(publicPem: string): plugins.idpInterfaces.data.IJwk {
// For now, use a simplified approach - in production, parse the PEM properly
// The smartjwt library should provide this, or use crypto.createPublicKey
const kid = plugins.smarthash.sha256FromStringSync(publicPem).substring(0, 16);
// This is a placeholder - proper implementation would extract n and e from PEM
// For now, return a minimal structure
return {
kty: 'RSA',
use: 'sig',
alg: 'RS256',
kid: kid,
// These would be extracted from the actual public key
n: Buffer.from(publicPem).toString('base64url').substring(0, 256),
e: 'AQAB', // Standard RSA exponent (65537)
};
}
/**
* Handle the authorization endpoint request
*/
public async handleAuthorize(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
const params = ctx.url.searchParams;
// Extract authorization request parameters
const clientId = params.get('client_id');
const redirectUri = params.get('redirect_uri');
const responseType = params.get('response_type');
const scope = params.get('scope');
const state = params.get('state');
const codeChallenge = params.get('code_challenge');
const codeChallengeMethod = params.get('code_challenge_method');
const nonce = params.get('nonce');
const prompt = params.get('prompt') as 'none' | 'login' | 'consent' | null;
// Validate required parameters
if (!clientId || !redirectUri || !responseType || !scope || !state) {
return this.errorResponse('invalid_request', 'Missing required parameters');
}
if (responseType !== 'code') {
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
}
// Validate code challenge method if present
if (codeChallenge && codeChallengeMethod !== 'S256') {
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
}
// Find the app by client_id
const app = await this.findAppByClientId(clientId);
if (!app) {
return this.errorResponse('invalid_client', 'Unknown client_id');
}
// Validate redirect URI
if (!app.data.oauthCredentials.redirectUris.includes(redirectUri)) {
return this.errorResponse('invalid_request', 'Invalid redirect_uri');
}
// Parse and validate scopes
const requestedScopes = scope.split(' ') as plugins.idpInterfaces.data.TOidcScope[];
const allowedScopes = app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
const validScopes = requestedScopes.filter(s => allowedScopes.includes(s));
if (!validScopes.includes('openid')) {
return this.errorResponse('invalid_scope', 'openid scope is required');
}
// For now, redirect to login page with OAuth parameters
// The login page will handle authentication and call back to complete authorization
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
const loginUrl = new URL(`${baseUrl}/login`);
loginUrl.searchParams.set('oauth', 'true');
loginUrl.searchParams.set('client_id', clientId);
loginUrl.searchParams.set('redirect_uri', redirectUri);
loginUrl.searchParams.set('scope', validScopes.join(' '));
loginUrl.searchParams.set('state', state);
if (codeChallenge) {
loginUrl.searchParams.set('code_challenge', codeChallenge);
loginUrl.searchParams.set('code_challenge_method', codeChallengeMethod!);
}
if (nonce) {
loginUrl.searchParams.set('nonce', nonce);
}
return Response.redirect(loginUrl.toString(), 302);
}
/**
* Generate an authorization code after user authentication
*/
public async generateAuthorizationCode(
clientId: string,
userId: string,
scopes: plugins.idpInterfaces.data.TOidcScope[],
redirectUri: string,
codeChallenge?: string,
nonce?: string
): Promise<string> {
const code = plugins.smartunique.shortId(32);
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
code,
clientId,
userId,
scopes,
redirectUri,
codeChallenge,
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
nonce,
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
used: false,
};
this.authorizationCodes.set(code, authCode);
return code;
}
/**
* Handle the token endpoint request
*/
public async handleToken(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
// Parse form data
const contentType = ctx.headers.get('content-type');
if (!contentType?.includes('application/x-www-form-urlencoded')) {
return this.tokenErrorResponse('invalid_request', 'Content-Type must be application/x-www-form-urlencoded');
}
const formData = await ctx.formData();
const grantType = formData.get('grant_type') as string;
// Extract client credentials from Basic auth or form
let clientId = formData.get('client_id') as string;
let clientSecret = formData.get('client_secret') as string;
const authHeader = ctx.headers.get('authorization');
if (authHeader?.startsWith('Basic ')) {
const base64 = authHeader.substring(6);
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const [id, secret] = decoded.split(':');
clientId = clientId || id;
clientSecret = clientSecret || secret;
}
if (!clientId) {
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
}
// Find and validate app
const app = await this.findAppByClientId(clientId);
if (!app) {
return this.tokenErrorResponse('invalid_client', 'Unknown client');
}
// Validate client secret for confidential clients
if (clientSecret) {
const secretHash = await plugins.smarthash.sha256FromString(clientSecret);
if (secretHash !== app.data.oauthCredentials.clientSecretHash) {
return this.tokenErrorResponse('invalid_client', 'Invalid client credentials');
}
}
if (grantType === 'authorization_code') {
return this.handleAuthorizationCodeGrant(formData, app);
} else if (grantType === 'refresh_token') {
return this.handleRefreshTokenGrant(formData, app);
} else {
return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
}
}
/**
* Handle authorization_code grant type
*/
private async handleAuthorizationCodeGrant(
formData: FormData,
app: App
): Promise<Response> {
const code = formData.get('code') as string;
const redirectUri = formData.get('redirect_uri') as string;
const codeVerifier = formData.get('code_verifier') as string;
if (!code || !redirectUri) {
return this.tokenErrorResponse('invalid_request', 'Missing code or redirect_uri');
}
// Find and validate authorization code
const authCode = this.authorizationCodes.get(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);
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
}
if (authCode.expiresAt < Date.now()) {
this.authorizationCodes.delete(code);
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
}
if (authCode.clientId !== app.data.oauthCredentials.clientId) {
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
}
if (authCode.redirectUri !== redirectUri) {
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
}
// Verify PKCE if code challenge was used
if (authCode.codeChallenge) {
if (!codeVerifier) {
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
}
const expectedChallenge = this.generateS256Challenge(codeVerifier);
if (expectedChallenge !== authCode.codeChallenge) {
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
}
}
// Mark code as used
authCode.used = true;
// Generate tokens
const tokens = await this.generateTokens(
authCode.userId,
app.data.oauthCredentials.clientId,
authCode.scopes,
authCode.nonce
);
return new Response(JSON.stringify(tokens), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Pragma': 'no-cache',
},
});
}
/**
* Handle refresh_token grant type
*/
private async handleRefreshTokenGrant(
formData: FormData,
app: App
): Promise<Response> {
const refreshToken = formData.get('refresh_token') as string;
if (!refreshToken) {
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
}
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
const storedToken = this.refreshTokens.get(tokenHash);
if (!storedToken) {
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
}
if (storedToken.revoked) {
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
}
if (storedToken.expiresAt < Date.now()) {
this.refreshTokens.delete(tokenHash);
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
}
if (storedToken.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,
undefined,
false // Don't generate new refresh token
);
return new Response(JSON.stringify(tokens), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Pragma': 'no-cache',
},
});
}
/**
* Generate access token, ID token, and optionally refresh token
*/
private async generateTokens(
userId: string,
clientId: string,
scopes: plugins.idpInterfaces.data.TOidcScope[],
nonce?: string,
includeRefreshToken = true
): Promise<plugins.idpInterfaces.data.ITokenResponse> {
const now = Date.now();
const accessTokenLifetime = 3600; // 1 hour
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
// Generate access token
const accessToken = plugins.smartunique.shortId(32);
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
id: plugins.smartunique.shortId(8),
tokenHash: accessTokenHash,
clientId,
userId,
scopes,
expiresAt: now + accessTokenLifetime * 1000,
issuedAt: now,
};
this.accessTokens.set(accessTokenHash, accessTokenData);
// Generate ID token (JWT)
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
const response: plugins.idpInterfaces.data.ITokenResponse = {
access_token: accessToken,
token_type: 'Bearer',
expires_in: accessTokenLifetime,
id_token: idToken,
scope: scopes.join(' '),
};
// Generate refresh token if requested
if (includeRefreshToken) {
const refreshToken = plugins.smartunique.shortId(48);
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
id: plugins.smartunique.shortId(8),
tokenHash: refreshTokenHash,
clientId,
userId,
scopes,
expiresAt: now + refreshTokenLifetime * 1000,
issuedAt: now,
revoked: false,
};
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
response.refresh_token = refreshToken;
}
return response;
}
/**
* Generate an ID token (JWT)
*/
private async generateIdToken(
userId: string,
clientId: string,
scopes: plugins.idpInterfaces.data.TOidcScope[],
nonce?: string
): Promise<string> {
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
const now = Math.floor(Date.now() / 1000);
const claims: plugins.idpInterfaces.data.IIdTokenClaims = {
iss: baseUrl,
sub: userId,
aud: clientId,
exp: now + 3600, // 1 hour
iat: now,
auth_time: now,
};
if (nonce) {
claims.nonce = nonce;
}
// Add claims based on scopes
if (scopes.includes('profile') || scopes.includes('email') || scopes.includes('organizations') || scopes.includes('roles')) {
const userInfo = await this.getUserClaims(userId, scopes);
Object.assign(claims, userInfo);
}
// Sign the JWT
const idToken = await this.receptionRef.jwtManager.smartjwtInstance.createJWT(claims);
return idToken;
}
/**
* Handle the userinfo endpoint
*/
public async handleUserInfo(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
// Get access token from Authorization header
const authHeader = ctx.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'invalid_token' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer error="invalid_token"',
},
});
}
const accessToken = authHeader.substring(7);
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
const tokenData = this.accessTokens.get(tokenHash);
if (!tokenData) {
return new Response(JSON.stringify({ error: 'invalid_token' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer error="invalid_token"',
},
});
}
if (tokenData.expiresAt < Date.now()) {
this.accessTokens.delete(tokenHash);
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
status: 401,
headers: {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer error="invalid_token", error_description="Token expired"',
},
});
}
// Get user claims based on token scopes
const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes);
return new Response(JSON.stringify(userInfo), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Get user claims based on scopes
*/
private async getUserClaims(
userId: string,
scopes: plugins.idpInterfaces.data.TOidcScope[]
): Promise<plugins.idpInterfaces.data.IUserInfoResponse> {
const user = await this.receptionRef.userManager.CUser.getInstance({ id: userId });
if (!user) {
return { sub: userId };
}
const claims: plugins.idpInterfaces.data.IUserInfoResponse = {
sub: userId,
};
// Profile scope
if (scopes.includes('profile')) {
claims.name = user.data?.name;
claims.preferred_username = user.data?.username;
// claims.picture = user.data?.avatarUrl; // If avatar exists
}
// Email scope
if (scopes.includes('email')) {
claims.email = user.data?.email;
claims.email_verified = user.data?.status === 'active';
}
// Organizations scope (custom)
if (scopes.includes('organizations')) {
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user);
const roles = await this.receptionRef.roleManager.getAllRolesForUser(user);
if (organizations) {
claims.organizations = organizations.map(org => ({
id: org.id,
name: org.data?.name || '',
slug: org.data?.slug || '',
roles: roles
.find(r => r.data?.organizationId === org.id)?.data?.roles || [],
}));
}
}
// Roles scope (custom - global roles)
if (scopes.includes('roles')) {
const roles: string[] = ['user'];
if (user.data?.isGlobalAdmin) {
roles.push('admin');
}
claims.roles = roles;
}
return claims;
}
/**
* Handle the revocation endpoint
*/
public async handleRevoke(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
const formData = await ctx.formData();
const token = formData.get('token') as string;
const tokenTypeHint = formData.get('token_type_hint') as string;
if (!token) {
return new Response(null, { status: 200 }); // Spec says always return 200
}
const tokenHash = await plugins.smarthash.sha256FromString(token);
// Try to revoke as refresh token
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
const refreshToken = this.refreshTokens.get(tokenHash);
if (refreshToken) {
refreshToken.revoked = true;
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);
return new Response(null, { status: 200 });
}
}
// Token not found - still return 200 per spec
return new Response(null, { status: 200 });
}
/**
* Find an app by its OAuth client_id
*/
private async findAppByClientId(clientId: string): Promise<App | null> {
const apps = await this.receptionRef.appManager.CApp.getInstances({
'data.oauthCredentials.clientId': clientId,
});
return apps[0] || null;
}
/**
* Generate S256 PKCE challenge from verifier
*/
private generateS256Challenge(verifier: string): string {
const hash = plugins.smarthash.sha256FromStringSync(verifier);
return Buffer.from(hash, 'hex').toString('base64url');
}
/**
* Create an error response for authorization endpoint
*/
private errorResponse(error: string, description: string): Response {
return new Response(JSON.stringify({ error, error_description: description }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Create an error response for token endpoint
*/
private tokenErrorResponse(
error: plugins.idpInterfaces.data.ITokenErrorResponse['error'],
description: string
): Response {
const body: plugins.idpInterfaces.data.ITokenErrorResponse = {
error,
error_description: description,
};
return new Response(JSON.stringify(body), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Start cleanup task for expired tokens/codes
*/
private startCleanupTask(): void {
setInterval(() => {
const now = Date.now();
// Clean up expired authorization codes
for (const [code, data] of this.authorizationCodes) {
if (data.expiresAt < now) {
this.authorizationCodes.delete(code);
}
}
// Clean up expired access tokens
for (const [hash, data] of this.accessTokens) {
if (data.expiresAt < now) {
this.accessTokens.delete(hash);
}
}
// Clean up expired refresh tokens
for (const [hash, data] of this.refreshTokens) {
if (data.expiresAt < now) {
this.refreshTokens.delete(hash);
}
}
}, 60 * 1000); // Run every minute
}
}
+2 -2
View File
@@ -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';
@@ -17,6 +16,7 @@ import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
import { OidcManager } from './classes.oidcmanager.js';
export interface IReceptionOptions {
/**
@@ -29,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();
@@ -49,6 +48,7 @@ export class Reception {
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public userInvitationManager = new UserInvitationManager(this);
public oidcManager = new OidcManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {
-1
View File
@@ -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();
}
+119 -123
View File
@@ -5,191 +5,187 @@ 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) => {
throw new plugins.typedrequest.TypedResponseError(
'Error occured during email provider & dns validation'
);
});
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;
@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: [],
email: null,
name: null,
status: null,
mobileNumber: null,
password: null,
passwordHash: null,
},
},
};
/**
* 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 get emailAddress() {
return this.data.emailAddress;
}
public collectedData: {
userData: plugins.idpInterfaces.data.IUser['data'];
} = {
userData: {
username: null,
connectedOrgs: [],
email: null,
name: null,
status: null,
mobileNumber: null,
password: null,
passwordHash: null,
},
};
public get status() {
return this.data.status;
}
constructor(
registrationSessionManagerRefArg: RegistrationSessionManager,
emailAddressArg: string
) {
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
this.emailAddress = emailAddressArg;
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
this.data.status = statusArg;
}
// 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 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();
throw new plugins.typedrequest.TypedResponseError(
'Registration cancelled due to repeated wrong verification code submission'
);
}
return false;
}
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;
}
}
+21 -3
View File
@@ -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
+11 -3
View File
@@ -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
});
-3
View File
@@ -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();
+1
View File
@@ -193,6 +193,7 @@ export class IdpCli {
this.storeCredentials({
...credentials,
jwt: response.jwt,
refreshToken: response.refreshToken || credentials.refreshToken,
});
return response.jwt;
}
+111
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "@idp.global/cli",
"order": 4
}
+72 -25
View File
@@ -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,
});
if (response.jwt) {
await this.storeJwt(response.jwt);
} else {
await this.deleteJwt();
const response = await refreshJwtReq
.fire({
refreshToken,
})
.catch(async () => {
await this.clearAuthState();
return null;
});
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}`);
+156
View File
@@ -0,0 +1,156 @@
# @idp.global/client
Browser-facing TypeScript client for talking to an `idp.global` server over `typedrequest` and `typedsocket`.
It handles login state, refresh tokens, JWT housekeeping, cross-app transfer tokens, and direct access to the typed request surface.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Install
```bash
pnpm add @idp.global/client
```
## Quick Start
```ts
import { IdpClient } from '@idp.global/client';
const idpClient = new IdpClient('https://idp.global');
await idpClient.enableTypedSocket();
const loggedIn = await idpClient.determineLoginStatus();
if (!loggedIn) {
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'secret',
});
if (loginResult.refreshToken) {
await idpClient.refreshJwt(loginResult.refreshToken);
}
}
const whoIs = await idpClient.whoIs();
console.log(whoIs.user.data.email);
```
## What The Client Handles
- Normalizes the base URL to the server's `/typedrequest` endpoint.
- Stores JWT and refresh token state in a browser `WebStore`.
- Refreshes expiring JWTs via `performJwtHousekeeping()`.
- Redirects to `/login` when `determineLoginStatus(true)` is used.
- Exchanges refresh tokens for cross-app transfer tokens.
- Exposes the low-level typed requests through `idpClient.requests`.
## Common Flows
### Password Login
```ts
const result = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'secret',
});
if (result.refreshToken) {
await idpClient.refreshJwt(result.refreshToken);
}
```
### Magic Link Login
```ts
await idpClient.requests.loginWithEmail.fire({
email: 'user@example.com',
});
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
email: 'user@example.com',
token: 'token-from-email',
});
await idpClient.refreshJwt(result.refreshToken);
```
### Session and Identity
```ts
await idpClient.performJwtHousekeeping();
const jwt = await idpClient.getJwt();
const jwtData = await idpClient.getJwtData();
const whoIs = await idpClient.whoIs();
console.log(jwtData.id, whoIs.user.data.username);
```
### Organizations
```ts
const rolesAndOrganizations = await idpClient.getRolesAndOrganizations();
const created = await idpClient.createOrganization(
'Acme',
'acme',
'manifest'
);
const members = await idpClient.requests.getOrgMembers.fire({
jwt: await idpClient.getJwt(),
organizationId: created.resultingOrganization.id,
});
```
### Cross-App Transfer
```ts
const transferToken = await idpClient.getTransferToken();
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
```
## Typed Request Surface
`IdpRequests` exposes typed request getters for:
- authentication
- registration
- user/session queries
- org and invitation management
- billing requests
- JWT validation key requests
- admin requests
Use these when you want full control instead of the higher-level helper methods on `IdpClient`.
## Important Runtime Notes
- The default fallback `appData` uses `window.location`, so this package is primarily browser-oriented.
- The client expects the backend `typedrequest` websocket surface to be reachable.
- Auth state is persisted in browser storage under the `idpglobalStore` store name.
## 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.
+1
View File
@@ -1,3 +1,4 @@
{
"name": "@idp.global/client",
"order": 3
}
+3
View File
@@ -1,5 +1,7 @@
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';
export * from './loint-reception.device.js';
@@ -7,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;
};
}
+7 -2
View File
@@ -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
*/
+267
View File
@@ -0,0 +1,267 @@
/**
* OIDC (OpenID Connect) data interfaces for third-party client support
*/
/**
* Supported OIDC scopes
*/
export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'roles';
/**
* Authorization code for OAuth 2.0 authorization code flow
*/
export interface IAuthorizationCode {
/** The authorization code string */
code: string;
/** OAuth client ID */
clientId: string;
/** User ID who authorized */
userId: string;
/** Scopes granted */
scopes: TOidcScope[];
/** Redirect URI used in authorization request */
redirectUri: string;
/** PKCE code challenge (S256 hashed) */
codeChallenge?: string;
/** PKCE code challenge method */
codeChallengeMethod?: 'S256';
/** Nonce from authorization request (for ID token) */
nonce?: string;
/** Expiration timestamp (10 minutes from creation) */
expiresAt: 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) */
tokenHash: string;
/** OAuth client ID */
clientId: string;
/** User ID */
userId: string;
/** Granted scopes */
scopes: TOidcScope[];
/** Expiration timestamp */
expiresAt: number;
/** Creation timestamp */
issuedAt: number;
}
/**
* OIDC Refresh Token
*/
export interface IOidcRefreshToken {
/** Token identifier */
id: string;
/** The refresh token string (or hash for storage) */
tokenHash: string;
/** OAuth client ID */
clientId: string;
/** User ID */
userId: string;
/** Granted scopes */
scopes: TOidcScope[];
/** Expiration timestamp */
expiresAt: number;
/** Creation timestamp */
issuedAt: number;
/** Whether the token has been revoked */
revoked: boolean;
}
/**
* User consent record for an OAuth client
*/
export interface IUserConsent {
/** Unique identifier */
id: string;
/** User who gave consent */
userId: string;
/** OAuth client ID */
clientId: string;
/** Scopes the user consented to */
scopes: TOidcScope[];
/** When consent was granted */
grantedAt: number;
/** When consent was last updated */
updatedAt: number;
}
/**
* OIDC Discovery Document (OpenID Provider Configuration)
*/
export interface IOidcDiscoveryDocument {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string;
revocation_endpoint: string;
scopes_supported: TOidcScope[];
response_types_supported: string[];
grant_types_supported: string[];
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
token_endpoint_auth_methods_supported: string[];
code_challenge_methods_supported: string[];
claims_supported: string[];
}
/**
* JSON Web Key Set (JWKS) response
*/
export interface IJwks {
keys: IJwk[];
}
/**
* JSON Web Key (RSA public key)
*/
export interface IJwk {
kty: 'RSA';
use: 'sig';
alg: 'RS256';
kid: string;
n: string; // RSA modulus (base64url encoded)
e: string; // RSA exponent (base64url encoded)
}
/**
* ID Token claims (JWT payload)
*/
export interface IIdTokenClaims {
/** Issuer (idp.global URL) */
iss: string;
/** Subject (user ID) */
sub: string;
/** Audience (client ID) */
aud: string;
/** Expiration time (Unix timestamp) */
exp: number;
/** Issued at (Unix timestamp) */
iat: number;
/** Authentication time (Unix timestamp) */
auth_time?: number;
/** Nonce (if provided in authorization request) */
nonce?: string;
/** Access token hash (for hybrid flows) */
at_hash?: string;
// Profile scope claims
name?: string;
preferred_username?: string;
picture?: string;
// Email scope claims
email?: string;
email_verified?: boolean;
// Custom claims for organizations scope
organizations?: IOrganizationClaim[];
// Custom claims for roles scope
roles?: string[];
}
/**
* Organization claim in ID token / userinfo
*/
export interface IOrganizationClaim {
id: string;
name: string;
slug: string;
roles: string[];
}
/**
* UserInfo endpoint response
*/
export interface IUserInfoResponse {
/** Subject (user ID) - always included */
sub: string;
// Profile scope
name?: string;
preferred_username?: string;
picture?: string;
// Email scope
email?: string;
email_verified?: boolean;
// Organizations scope (custom)
organizations?: IOrganizationClaim[];
// Roles scope (custom)
roles?: string[];
}
/**
* Token endpoint response
*/
export interface ITokenResponse {
access_token: string;
token_type: 'Bearer';
expires_in: number;
refresh_token?: string;
id_token?: string;
scope: string;
}
/**
* Token endpoint error response
*/
export interface ITokenErrorResponse {
error: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope';
error_description?: string;
error_uri?: string;
}
/**
* Authorization request parameters
*/
export interface IAuthorizationRequest {
client_id: string;
redirect_uri: string;
response_type: 'code';
scope: string;
state: string;
code_challenge?: string;
code_challenge_method?: 'S256';
nonce?: string;
prompt?: 'none' | 'login' | 'consent';
}
/**
* Token request for authorization_code grant
*/
export interface ITokenRequestAuthCode {
grant_type: 'authorization_code';
code: string;
redirect_uri: string;
client_id: string;
client_secret?: string;
code_verifier?: string;
}
/**
* Token request for refresh_token grant
*/
export interface ITokenRequestRefresh {
grant_type: 'refresh_token';
refresh_token: string;
client_id: string;
client_secret?: string;
scope?: string;
}
/**
* Union type for token requests
*/
export type ITokenRequest = ITokenRequestAuthCode | ITokenRequestRefresh;
@@ -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;
};
};
};
}
+128
View File
@@ -0,0 +1,128 @@
# @idp.global/interfaces
Shared TypeScript contracts for the `idp.global` backend, browser client, CLI, and frontend.
Use this package when you want typed request/response payloads and shared data models for users, sessions, organizations, apps, billing, and OIDC.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Install
```bash
pnpm add @idp.global/interfaces
```
## Quick Start
```ts
import { data, request, tags } from '@idp.global/interfaces';
const loginRequest: request.IReq_LoginWithEmailOrUsernameAndPassword['request'] = {
username: 'user@example.com',
password: 'secret',
};
const organization: data.IOrganization = {
id: 'org_1',
data: {
name: 'Acme',
slug: 'acme',
billingPlanId: 'plan_free',
roleIds: [],
},
};
```
## Exports
### `data`
The `data` export includes types for:
- users
- organizations
- roles
- JWT payloads
- login sessions
- devices
- activity logs
- apps and app connections
- billing plans and Paddle checkout data
- OIDC data structures
- invitations
### `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',
};
```
### Session Contract
```ts
type TSessions = request.IReq_GetUserSessions['response']['sessions'];
```
### OIDC Contract
```ts
type TUserInfo = data.IUserInfoResponse;
```
## Scope
This package is intentionally contract-only. It does not open sockets, store auth state, or perform HTTP/websocket communication by itself.
## 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.
@@ -87,7 +87,8 @@ export interface IReq_RefreshJwt
};
response: {
status: data.TLoginStatus;
jwt: string;
jwt?: string;
refreshToken?: string;
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.12.1',
version: '1.18.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+13 -7
View File
@@ -30,6 +30,19 @@ export const cardStyles = css`
}
`;
/**
* Base styles for all view components
* Provides consistent background and foreground colors
*/
export const viewBaseStyles = css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
`;
/**
* Typography styles for consistent text hierarchy
*/
@@ -108,10 +121,3 @@ export const navigationStyles = css`
}
`;
/**
* Legacy export for backwards compatibility
*/
export default css`
${accountDesignTokens}
${typographyStyles}
`;
+3 -9
View File
@@ -11,7 +11,7 @@ import {
} from '@design.estate/dees-element';
import { IdpState } from '../../../states/idp.state.js';
import { accountDesignTokens } from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
declare global {
interface HTMLElementTagNameMap {
@@ -43,15 +43,9 @@ export class AdminView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1200px;
margin: 0 auto;
+5 -5
View File
@@ -9,7 +9,7 @@ import {
state,
} from '@design.estate/dees-element';
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
import * as accountState from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
@@ -45,12 +45,12 @@ export class AppsView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
cardStyles,
typographyStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
sharedStyles.cardStyles,
sharedStyles.typographyStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 1000px;
margin: 0 auto;
+3 -9
View File
@@ -10,7 +10,7 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
@@ -59,15 +59,9 @@ export class BaseView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1000px;
margin: 0 auto;
+3 -8
View File
@@ -10,7 +10,7 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
@@ -41,14 +41,9 @@ export class OrgView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1000px;
+5 -5
View File
@@ -9,7 +9,7 @@ import {
} from '@design.estate/dees-element';
import * as plugins from '../../../plugins.js';
import sharedStyles from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
import * as state from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
@@ -23,13 +23,13 @@ declare global {
export class PaddleSetupView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
sharedStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 900px;
margin: auto;
color: ${cssManager.bdTheme('#333', '#fff')};
margin: 0 auto;
}
`,
];
@@ -8,7 +8,7 @@ import {
css,
} from '@design.estate/dees-element';
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
import * as state from '../../../states/accountstate.js';
@@ -46,12 +46,12 @@ export class SubscriptionView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
cardStyles,
typographyStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
sharedStyles.cardStyles,
sharedStyles.typographyStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 900px;
margin: 0 auto;
+5 -5
View File
@@ -9,7 +9,7 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
import * as sharedStyles from '../sharedstyles.js';
import * as accountState from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
import { BulkInviteModal } from '../bulk-invite-modal.js';
@@ -83,12 +83,12 @@ export class UsersView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
cardStyles,
typographyStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
sharedStyles.cardStyles,
sharedStyles.typographyStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 1000px;
margin: 0 auto;
+1 -1
View File
@@ -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 {
+4 -11
View File
@@ -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;
}
+6 -6
View File
@@ -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 });
},
+83
View File
@@ -0,0 +1,83 @@
# `ts_web/` Web App Module
The `ts_web/` folder contains the frontend for `idp.global`: login, registration, account management, org management, billing, and admin UI.
It is built with `@design.estate/dees-element`, `@design.estate/dees-domtools`, and the shared `idp.global` client and interface packages.
## 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
| 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
pnpm install
pnpm build
pnpm watch
```
`pnpm watch` rebuilds the frontend bundle from `ts_web/index.ts` into `dist_serve/bundle.js` while the backend serves the app.
## Notes
- 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.
+1 -1
View File
@@ -1,3 +1,3 @@
{
"order": 4
"order": 5
}
+3 -1
View File
@@ -4,7 +4,9 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"types": ["node"],
"strict": false
},
"exclude": [
"dist_*/**/*.d.ts"