Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd7499f3f | |||
| 29a21fd3b3 | |||
| 21f5abb49b | |||
| 68469b0740 | |||
| 525a72b73b | |||
| d913dfaeb1 | |||
| fe9da65437 | |||
| 28d30fe392 | |||
| 1532c9704b | |||
| 76efcb835f |
@@ -1,5 +1,45 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.20.0 - feat(auth)
|
||||||
|
add abuse protection for login and OIDC flows with consent-based authorization handling
|
||||||
|
|
||||||
|
- introduces AbuseProtectionManager and AbuseWindow storage to rate limit password login, magic link, password reset, and OIDC token exchange attempts
|
||||||
|
- adds housekeeping cleanup for expired abuse protection windows
|
||||||
|
- adds typed OIDC prepare/complete authorization requests plus consent evaluation and redirect URL generation
|
||||||
|
- updates the login prompt to support OIDC authorization continuation after user login or consent
|
||||||
|
- includes tests for abuse protection behavior and OIDC authorization preparation/completion flows
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.1 - fix(ts_interfaces)
|
||||||
|
rename generated TypeScript interface files to remove the loint-reception prefix
|
||||||
|
|
||||||
|
- Moves data and request interface files from loint-reception.* names to clean module names under ts_interfaces
|
||||||
|
- Renames the shared plugins export to ts_interfaces/plugins.ts
|
||||||
|
- Preserves interface contents while standardizing the generated file naming layout
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.0 - feat(oidc)
|
||||||
|
persist hashed OIDC tokens, authorization codes, and user consent in smartdata storage
|
||||||
|
|
||||||
|
- replace in-memory OIDC authorization code, access token, refresh token, and consent stores with SmartData document classes
|
||||||
|
- store authorization codes and tokens as hashes instead of persisting plaintext values, with helpers for matching, expiration, and revocation
|
||||||
|
- persist and merge user consent scopes when issuing authorization codes
|
||||||
|
- add cleanup lifecycle management for expired OIDC state and stop the cleanup task when reception shuts down
|
||||||
|
- add tests covering hashed code/token matching, authorization code usage, refresh token revocation, and consent scope merging
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.18.0 - feat(reception)
|
||||||
|
persist email action tokens and registration sessions for authentication and signup flows
|
||||||
|
|
||||||
|
- add persisted email action tokens for email login and password reset with one-time consumption and expiry cleanup
|
||||||
|
- store registration sessions in the database so signup state, email validation, and SMS verification survive restarts
|
||||||
|
- enforce password changes through either a valid reset token or the current password
|
||||||
|
- add housekeeping jobs and tests for token/session expiry and state persistence
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.17.1 - fix(docs)
|
||||||
|
refresh module readmes and add repository license file
|
||||||
|
|
||||||
|
- rewrite the root, backend, web, client, CLI, and interfaces README content to focus on current module responsibilities and usage
|
||||||
|
- standardize README license references to the lowercase license file path
|
||||||
|
- add the repository MIT license file
|
||||||
|
|
||||||
## 2026-04-20 - 1.17.0 - feat(auth)
|
## 2026-04-20 - 1.17.0 - feat(auth)
|
||||||
harden authentication with argon2 passwords and rotating hashed refresh tokens
|
harden authentication with argon2 passwords and rotating hashed refresh tokens
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/idp.global",
|
||||||
"version": "1.17.0",
|
"version": "1.20.0",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
|
|||||||
@@ -1,168 +1,63 @@
|
|||||||
# @idp.global/idp.global
|
# @idp.global/idp.global
|
||||||
|
|
||||||
🔐 **A modern, open-source Identity Provider (IdP) SaaS platform** for managing user authentication, registrations, sessions, and organization-based access control.
|
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, and OpenID Connect in one TypeScript codebase.
|
||||||
|
|
||||||
Built with TypeScript and designed for modern web applications, idp.global provides a complete identity management solution that you can self-host or use as a service.
|
This repository ships the `idp.global` server, the browser/client SDK, the CLI, shared request/data interfaces, and the web UI used by the hosted service.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## 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.
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
## ✨ Features
|
## What It Does
|
||||||
|
|
||||||
### 🔑 Authentication & Authorization
|
- Runs an identity provider with MongoDB-backed users, sessions, roles, organizations, invitations, API tokens, and billing plans.
|
||||||
- **Multiple Login Methods**: Email/password, email magic links, API tokens
|
- Serves a web app for login, registration, account management, org management, billing flows, and global admin views.
|
||||||
- **JWT-Based Sessions**: Secure token management with automatic refresh
|
- Exposes typed realtime APIs over `typedrequest` and `typedsocket`.
|
||||||
- **Two-Factor Authentication**: Enhanced security with 2FA support
|
- Implements OIDC/OAuth endpoints including discovery, JWKS, authorization, token, userinfo, and revoke.
|
||||||
- **Password Reset**: Secure password recovery flow
|
- Includes a reusable browser client and a terminal CLI for common account and org workflows.
|
||||||
- **Device Management**: Track and manage authenticated devices
|
|
||||||
|
|
||||||
### 🏢 Organization Management
|
## Monorepo Modules
|
||||||
- **Multi-Tenant Architecture**: Support multiple organizations per user
|
|
||||||
- **Role-Based Access Control (RBAC)**: Fine-grained permissions system
|
|
||||||
- **Organization Roles**: Admin, member, and custom role support
|
|
||||||
- **Member Invitations**: Bulk invite and manage team members
|
|
||||||
- **Ownership Transfer**: Seamlessly transfer organization ownership
|
|
||||||
|
|
||||||
### 🔗 Third-Party Integration
|
| Folder | Purpose |
|
||||||
- **OpenID Connect (OIDC) Provider**: Full OIDC compliance for third-party apps
|
| --- | --- |
|
||||||
- Discovery endpoint (`/.well-known/openid-configuration`)
|
| `ts/` | Backend service entrypoint and the core `Reception` managers |
|
||||||
- JWKS endpoint for token verification
|
| `ts_interfaces/` | Shared request and data contracts used by server, client, CLI, and UI |
|
||||||
- Authorization code flow with PKCE
|
| `ts_idpclient/` | Browser-focused SDK published as `@idp.global/client` |
|
||||||
- Token refresh and revocation
|
| `ts_idpcli/` | CLI published as `@idp.global/cli` |
|
||||||
- **OAuth 2.0**: Standard OAuth flows for app authorization
|
| `ts_web/` | Frontend bundle with login, registration, account, org, billing, and admin views |
|
||||||
- **Supported Scopes**: `openid`, `profile`, `email`, `organizations`, `roles`
|
|
||||||
|
|
||||||
### 💳 Billing Integration
|
## Core Backend Pieces
|
||||||
- **Paddle Integration**: Built-in payment processing support
|
|
||||||
- **Billing Plans**: Flexible subscription management
|
|
||||||
- **Checkout Flows**: Streamlined payment experiences
|
|
||||||
|
|
||||||
### 🎨 Modern Web UI
|
`Reception` wires the service together and starts these managers:
|
||||||
- **Responsive Design**: Beautiful UI components built with `@design.estate/dees-catalog`
|
|
||||||
- **Account Management**: User profile, settings, and preferences
|
|
||||||
- **Organization Dashboard**: Manage members, roles, and apps
|
|
||||||
- **Admin Panel**: Global administration interface
|
|
||||||
|
|
||||||
### 📡 Real-Time Communication
|
- `JwtManager` for signing, refreshing, and validating JWTs.
|
||||||
- **WebSocket Support**: Real-time updates via TypedSocket
|
- `LoginSessionManager` for login state and session lifecycle.
|
||||||
- **Typed API Requests**: Type-safe client-server communication
|
- `RegistrationSessionManager` for multi-step sign-up flows.
|
||||||
- **Public Key Distribution**: Automatic JWT key rotation notifications
|
- `UserManager` for user lookups and account data.
|
||||||
|
- `OrganizationManager` for org creation and membership lookup.
|
||||||
|
- `RoleManager` for org roles and permissions.
|
||||||
|
- `UserInvitationManager` for invites, membership updates, and ownership transfer.
|
||||||
|
- `ApiTokenManager` for long-lived token auth.
|
||||||
|
- `BillingPlanManager` for Paddle-backed billing data.
|
||||||
|
- `AppManager` and `AppConnectionManager` for app connections and admin app stats.
|
||||||
|
- `ActivityLogManager` for audit-style activity entries.
|
||||||
|
- `OidcManager` for the OIDC/OAuth provider surface.
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## Quick Start
|
||||||
|
|
||||||
idp.global is built as a modular TypeScript monorepo:
|
|
||||||
|
|
||||||
```
|
|
||||||
├── ts/ # Server-side code (Node.js)
|
|
||||||
│ └── reception/ # Core identity management logic
|
|
||||||
├── ts_interfaces/ # Shared TypeScript interfaces (published as @idp.global/interfaces)
|
|
||||||
├── ts_idpclient/ # Browser/Node client library (published as @idp.global/idpclient)
|
|
||||||
├── ts_idpcli/ # Command-line interface tool
|
|
||||||
└── ts_web/ # Web frontend (published as @idp.global/web)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Managers
|
|
||||||
|
|
||||||
| Manager | Responsibility |
|
|
||||||
|---------|----------------|
|
|
||||||
| `JwtManager` | JWT generation, validation, and key management |
|
|
||||||
| `LoginSessionManager` | Session creation and authentication |
|
|
||||||
| `UserManager` | User CRUD and profile management |
|
|
||||||
| `OrganizationManager` | Organization lifecycle management |
|
|
||||||
| `RoleManager` | RBAC and permission management |
|
|
||||||
| `OidcManager` | OpenID Connect provider functionality |
|
|
||||||
| `AppManager` | OAuth client app registration |
|
|
||||||
| `BillingPlanManager` | Subscription and payment handling |
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 🐳 Docker Deployment (Recommended)
|
|
||||||
|
|
||||||
The easiest way to run idp.global is using Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pull the latest image
|
|
||||||
docker pull code.foss.global/idp.global/idp.global
|
|
||||||
|
|
||||||
# Run with environment variables
|
|
||||||
docker run -d \
|
|
||||||
-p 2999:2999 \
|
|
||||||
-e MONGODB_URL=mongodb://your-mongo:27017/idp \
|
|
||||||
-e IDP_BASEURL=https://your-domain.com \
|
|
||||||
-e INSTANCE_NAME=idp.global \
|
|
||||||
code.foss.global/idp.global/idp.global
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Required |
|
|
||||||
|----------|-------------|----------|
|
|
||||||
| `MONGODB_URL` | MongoDB connection string | ✅ Yes |
|
|
||||||
| `IDP_BASEURL` | Public URL of your idp.global instance | ✅ Yes |
|
|
||||||
| `INSTANCE_NAME` | Name for this IDP instance | No (default: `idp.global`) |
|
|
||||||
| `SERVEZONE_PLATFROM_AUTHORIZATION` | ServeZone platform auth token | No |
|
|
||||||
|
|
||||||
### Docker Compose Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
idp:
|
|
||||||
image: code.foss.global/idp.global/idp.global
|
|
||||||
ports:
|
|
||||||
- "2999:2999"
|
|
||||||
environment:
|
|
||||||
MONGODB_URL: mongodb://mongo:27017/idp
|
|
||||||
IDP_BASEURL: https://idp.yourdomain.com
|
|
||||||
INSTANCE_NAME: my-idp
|
|
||||||
depends_on:
|
|
||||||
- mongo
|
|
||||||
|
|
||||||
mongo:
|
|
||||||
image: mongo:7
|
|
||||||
volumes:
|
|
||||||
- mongo-data:/data/db
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mongo-data:
|
|
||||||
```
|
|
||||||
|
|
||||||
The server listens on port 2999 by default.
|
|
||||||
|
|
||||||
## 🛠️ Local Development
|
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- pnpm
|
- `pnpm`
|
||||||
- MongoDB (local or remote)
|
- MongoDB
|
||||||
- SMTP server (for email verification in registration flow)
|
|
||||||
|
|
||||||
### Getting Started
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://code.foss.global/idp.global/idp.global.git
|
|
||||||
cd idp.global
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Build the project
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Start development server with hot reload
|
|
||||||
pnpm watch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The server runs on **http://localhost:2999** with:
|
### Required Environment
|
||||||
- 🔄 Auto-restart backend on changes (`ts/`)
|
|
||||||
- 📦 Automatic frontend bundle rebuilding (`ts_web/`)
|
|
||||||
|
|
||||||
### Environment Setup
|
|
||||||
|
|
||||||
Create environment variables for the backend:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export MONGODB_URL=mongodb://localhost:27017/idp-dev
|
export MONGODB_URL=mongodb://localhost:27017/idp-dev
|
||||||
@@ -170,207 +65,130 @@ export IDP_BASEURL=http://localhost:2999
|
|||||||
export INSTANCE_NAME=idp-dev
|
export INSTANCE_NAME=idp-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Routes
|
Optional:
|
||||||
|
|
||||||
| Route | Description |
|
- `SERVEZONE_PLATFROM_AUTHORIZATION`
|
||||||
|-------|-------------|
|
- `PADDLE_TOKEN`
|
||||||
| `/` | Welcome/landing page |
|
- `PADDLE_PRICE_ID`
|
||||||
| `/login` | Sign in form |
|
|
||||||
| `/register` | New user registration |
|
|
||||||
| `/account` | User dashboard (requires auth) |
|
|
||||||
|
|
||||||
### 🔑 Default Development Credentials
|
### Build
|
||||||
|
|
||||||
For local development with the test database, use:
|
```bash
|
||||||
|
pnpm build
|
||||||
| Field | Value |
|
```
|
||||||
|-------|-------|
|
|
||||||
| **Email/Username** | `admin@idp.global` or `admin` |
|
### Run Locally
|
||||||
| **Password** | `admin` |
|
|
||||||
|
```bash
|
||||||
This account has `isGlobalAdmin: true` for full platform access including the admin panel at `/account/admin`.
|
pnpm watch
|
||||||
|
```
|
||||||
> ⚠️ **Security Note**: These credentials are for local development only. Never use default credentials in production environments.
|
|
||||||
|
This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`. The service listens on port `2999`.
|
||||||
## 📦 Published Packages
|
|
||||||
|
## Runtime Surface
|
||||||
This monorepo publishes the following npm packages:
|
|
||||||
|
### Web Routes
|
||||||
| Package | Description |
|
|
||||||
|---------|-------------|
|
| Route | Purpose |
|
||||||
| `@idp.global/interfaces` | TypeScript interfaces for API contracts |
|
| --- | --- |
|
||||||
| `@idp.global/idpclient` | Client library for browser and Node.js |
|
| `/` | Welcome page |
|
||||||
| `@idp.global/web` | Web UI components |
|
| `/login` | Login flow |
|
||||||
|
| `/register` | Registration flow |
|
||||||
## 💻 Client Usage
|
| `/finishregistration` | Multi-step registration completion |
|
||||||
|
| `/account` | Signed-in account area |
|
||||||
### Browser Client
|
|
||||||
|
### OIDC and OAuth Endpoints
|
||||||
```typescript
|
|
||||||
import { IdpClient } from '@idp.global/idpclient';
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
// Initialize the client
|
| `/.well-known/openid-configuration` | Discovery document |
|
||||||
const idpClient = new IdpClient('https://idp.global');
|
| `/.well-known/jwks.json` | Public signing keys |
|
||||||
|
| `/oauth/authorize` | Authorization endpoint |
|
||||||
// Enable WebSocket connection
|
| `/oauth/token` | Token exchange |
|
||||||
await idpClient.enableTypedSocket();
|
| `/oauth/userinfo` | UserInfo endpoint |
|
||||||
|
| `/oauth/revoke` | Token revocation |
|
||||||
// Check login status
|
|
||||||
const isLoggedIn = await idpClient.determineLoginStatus();
|
Supported scopes in the OIDC manager include `openid`, `profile`, `email`, `organizations`, and `roles`.
|
||||||
|
|
||||||
// Login with email and password
|
## SDK Example
|
||||||
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
|
||||||
username: 'user@example.com',
|
The browser SDK lives in `ts_idpclient/` and is published as `@idp.global/client`.
|
||||||
password: 'securepassword'
|
|
||||||
});
|
```ts
|
||||||
|
import { IdpClient } from '@idp.global/client';
|
||||||
if (response.refreshToken) {
|
|
||||||
await idpClient.refreshJwt(response.refreshToken);
|
const idpClient = new IdpClient('https://idp.global');
|
||||||
console.log('✅ Login successful!');
|
await idpClient.enableTypedSocket();
|
||||||
}
|
|
||||||
|
const isLoggedIn = await idpClient.determineLoginStatus();
|
||||||
// Get current user info
|
|
||||||
const userInfo = await idpClient.whoIs();
|
if (!isLoggedIn) {
|
||||||
console.log('User:', userInfo.user);
|
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
|
username: 'user@example.com',
|
||||||
// Get user's organizations
|
password: 'secret',
|
||||||
const orgs = await idpClient.getRolesAndOrganizations();
|
});
|
||||||
console.log('Organizations:', orgs.organizations);
|
|
||||||
```
|
if (loginResult.refreshToken) {
|
||||||
|
await idpClient.refreshJwt(loginResult.refreshToken);
|
||||||
### Organization Management
|
}
|
||||||
|
}
|
||||||
```typescript
|
|
||||||
// Create a new organization
|
const whoIs = await idpClient.whoIs();
|
||||||
const result = await idpClient.createOrganization('My Company', 'my-company', 'manifest');
|
console.log(whoIs.user.data.email);
|
||||||
console.log('Created:', result.resultingOrganization);
|
```
|
||||||
|
|
||||||
// Invite members
|
## CLI Example
|
||||||
await idpClient.requests.createInvitation.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
The terminal client lives in `ts_idpcli/` and is published as `@idp.global/cli`.
|
||||||
organizationId: 'org-id',
|
|
||||||
email: 'newmember@example.com',
|
|
||||||
roles: ['member']
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### CLI Tool
|
|
||||||
|
|
||||||
The `ts_idpcli` module provides a command-line interface:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login
|
|
||||||
idp login
|
idp login
|
||||||
|
|
||||||
# Show current user
|
|
||||||
idp whoami
|
idp whoami
|
||||||
|
|
||||||
# List organizations
|
|
||||||
idp orgs
|
idp orgs
|
||||||
|
|
||||||
# List organization members
|
|
||||||
idp members --org <org-id>
|
idp members --org <org-id>
|
||||||
|
|
||||||
# Invite a user
|
|
||||||
idp invite --org <org-id> --email user@example.com
|
idp invite --org <org-id> --email user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 OIDC Integration
|
The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_URL` to override the target server.
|
||||||
|
|
||||||
idp.global implements a full OpenID Connect provider. Third-party applications can use it for SSO:
|
## Shared Interfaces
|
||||||
|
|
||||||
### Discovery Document
|
`ts_interfaces/` exports the type contracts shared across the stack:
|
||||||
|
|
||||||
```
|
- `data/*` for users, orgs, roles, JWTs, sessions, devices, billing plans, apps, and OIDC payloads.
|
||||||
GET /.well-known/openid-configuration
|
- `request/*` for auth, registration, user, org, invitation, app, admin, billing, and JWT request contracts.
|
||||||
```
|
- `tags/*` for shared tag exports.
|
||||||
|
|
||||||
### Authorization Flow
|
## Frontend
|
||||||
|
|
||||||
```
|
`ts_web/` is the web application bundle. It contains:
|
||||||
GET /oauth/authorize?
|
|
||||||
client_id=your-client-id&
|
|
||||||
redirect_uri=https://yourapp.com/callback&
|
|
||||||
response_type=code&
|
|
||||||
scope=openid profile email organizations&
|
|
||||||
state=random-state&
|
|
||||||
code_challenge=PKCE_CHALLENGE&
|
|
||||||
code_challenge_method=S256
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token Exchange
|
- Login and registration prompts.
|
||||||
|
- A registration stepper.
|
||||||
|
- Account navigation and account views.
|
||||||
|
- Organization creation and bulk invite modals.
|
||||||
|
- Billing and Paddle setup views.
|
||||||
|
- A global admin view.
|
||||||
|
|
||||||
```
|
## Package Scripts
|
||||||
POST /oauth/token
|
|
||||||
Content-Type: application/x-www-form-urlencoded
|
|
||||||
|
|
||||||
grant_type=authorization_code&
|
| Command | Purpose |
|
||||||
code=AUTHORIZATION_CODE&
|
| --- | --- |
|
||||||
redirect_uri=https://yourapp.com/callback&
|
| `pnpm build` | Build TypeScript output and frontend bundle |
|
||||||
client_id=your-client-id&
|
| `pnpm watch` | Run backend watch mode and frontend bundle watch |
|
||||||
client_secret=your-client-secret&
|
| `pnpm test` | Build and run the test suite |
|
||||||
code_verifier=PKCE_VERIFIER
|
|
||||||
```
|
|
||||||
|
|
||||||
### UserInfo
|
## Repository Notes
|
||||||
|
|
||||||
```
|
- Package manager: `pnpm`
|
||||||
GET /oauth/userinfo
|
- Main backend entrypoint: `ts/index.ts`
|
||||||
Authorization: Bearer ACCESS_TOKEN
|
- Frontend entrypoint: `ts_web/index.ts`
|
||||||
```
|
- Browser SDK entrypoint: `ts_idpclient/index.ts`
|
||||||
|
- CLI entrypoint: `ts_idpcli/index.ts`
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sub": "user-id",
|
|
||||||
"name": "John Doe",
|
|
||||||
"email": "john@example.com",
|
|
||||||
"email_verified": true,
|
|
||||||
"organizations": [
|
|
||||||
{ "id": "org-1", "name": "Acme Corp", "slug": "acme", "roles": ["admin"] }
|
|
||||||
],
|
|
||||||
"roles": ["user"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
|
||||||
|
|
||||||
- **Runtime**: Node.js with ES Modules
|
|
||||||
- **Language**: TypeScript (strict mode)
|
|
||||||
- **Database**: MongoDB via `@push.rocks/smartdata`
|
|
||||||
- **Web Server**: `@api.global/typedserver`
|
|
||||||
- **Real-time**: `@api.global/typedsocket` (WebSocket)
|
|
||||||
- **JWT**: `@push.rocks/smartjwt` (RS256 signing)
|
|
||||||
- **Frontend**: `@design.estate/dees-element` (Web Components)
|
|
||||||
- **Build**: `@git.zone/tsbuild` + `@git.zone/tsbundle`
|
|
||||||
|
|
||||||
## 📚 API Reference
|
|
||||||
|
|
||||||
### Request Interfaces
|
|
||||||
|
|
||||||
All API requests are type-safe. See `ts_interfaces/request/` for the complete API:
|
|
||||||
|
|
||||||
- **Authentication**: `IReq_LoginWithEmail`, `IReq_LoginWithApiToken`, `IReq_RefreshJwt`
|
|
||||||
- **Registration**: `IReq_FirstRegistration`, `IReq_FinishRegistration`
|
|
||||||
- **User Management**: `IReq_GetUserData`, `IReq_SetUserData`, `IReq_GetUserSessions`
|
|
||||||
- **Organizations**: `IReq_CreateOrganization`, `IReq_GetOrgMembers`, `IReq_CreateInvitation`
|
|
||||||
- **Apps & OAuth**: `IReq_GetGlobalApps`, `IReq_CreateGlobalApp`
|
|
||||||
- **Billing**: `IReq_GetBillingPlan`, `IReq_UpdatePaymentMethod`
|
|
||||||
|
|
||||||
### Data Models
|
|
||||||
|
|
||||||
See `ts_interfaces/data/` for all data structures:
|
|
||||||
|
|
||||||
- `IUser` - User profile and credentials
|
|
||||||
- `IOrganization` - Organization entity
|
|
||||||
- `IRole` - User roles within organizations
|
|
||||||
- `IJwt` - JWT token structure
|
|
||||||
- `IApp` - OAuth application definitions
|
|
||||||
- `IOidcAccessToken`, `IAuthorizationCode` - OIDC tokens
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**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.
|
||||||
|
|
||||||
@@ -382,7 +200,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or 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.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbuseProtectionManager,
|
||||||
|
type IAbuseProtectionConfig,
|
||||||
|
} from '../ts/reception/classes.abuseprotectionmanager.js';
|
||||||
|
import { AbuseWindow } from '../ts/reception/classes.abusewindow.js';
|
||||||
|
|
||||||
|
const createTestAbuseProtectionManager = () => {
|
||||||
|
const manager = new AbuseProtectionManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const store = new Map<string, AbuseWindow>();
|
||||||
|
const originalSave = AbuseWindow.prototype.save;
|
||||||
|
const originalDelete = AbuseWindow.prototype.delete;
|
||||||
|
|
||||||
|
(AbuseWindow.prototype as AbuseWindow & { save: () => Promise<void> }).save = async function () {
|
||||||
|
store.set(this.id, this);
|
||||||
|
};
|
||||||
|
(AbuseWindow.prototype as AbuseWindow & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
store.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CAbuseWindow = {
|
||||||
|
getInstance: async (queryArg) => store.get(queryArg.id) ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
AbuseWindow.prototype.save = originalSave;
|
||||||
|
AbuseWindow.prototype.delete = originalDelete;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
store,
|
||||||
|
restore,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConfig: IAbuseProtectionConfig = {
|
||||||
|
maxAttempts: 2,
|
||||||
|
windowMillis: 1_000,
|
||||||
|
blockDurationMillis: 2_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('blocks after too many attempts within the active window', async () => {
|
||||||
|
const { manager, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
|
||||||
|
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('resets attempts after the block and window have elapsed', async () => {
|
||||||
|
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||||
|
|
||||||
|
const abuseWindow = Array.from(store.values())[0];
|
||||||
|
abuseWindow.data.blockedUntil = Date.now() - 10;
|
||||||
|
abuseWindow.data.windowStartedAt = Date.now() - testConfig.windowMillis - 10;
|
||||||
|
abuseWindow.data.validUntil = Date.now() + 1_000;
|
||||||
|
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
expect(abuseWindow.data.attemptCount).toEqual(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('clears stored attempts after a successful action', async () => {
|
||||||
|
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
expect(store.size).toEqual(1);
|
||||||
|
|
||||||
|
await manager.clearAttempts('passwordLogin', 'phil@example.com');
|
||||||
|
expect(store.size).toEqual(0);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
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 { LoginSession } from '../ts/reception/classes.loginsession.js';
|
||||||
|
import { RegistrationSession } from '../ts/reception/classes.registrationsession.js';
|
||||||
import { User } from '../ts/reception/classes.user.js';
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
@@ -12,6 +14,42 @@ const createTestLoginSession = () => {
|
|||||||
return loginSession;
|
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 () => {
|
tap.test('hashes passwords with argon2 and verifies them', async () => {
|
||||||
const passwordHash = await User.hashPassword('correct horse battery staple');
|
const passwordHash = await User.hashPassword('correct horse battery staple');
|
||||||
|
|
||||||
@@ -58,4 +96,45 @@ tap.test('persists transfer tokens as one-time hashes', async () => {
|
|||||||
expect(await loginSession.validateTransferToken(transferToken)).toBeFalse();
|
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();
|
export default tap.start();
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcManager } from '../ts/reception/classes.oidcmanager.js';
|
||||||
|
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||||
|
|
||||||
|
const createTestOidcManager = () => {
|
||||||
|
const oidcManager = new OidcManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
options: { baseUrl: 'https://idp.example' },
|
||||||
|
} as any);
|
||||||
|
void oidcManager.stop();
|
||||||
|
return oidcManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('stores authorization codes as hashes and marks them used', async () => {
|
||||||
|
const authCode = new OidcAuthorizationCode();
|
||||||
|
authCode.id = 'oidc-auth-code';
|
||||||
|
authCode.data.codeHash = OidcAuthorizationCode.hashCode('plain-auth-code');
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(authCode as OidcAuthorizationCode & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(authCode.matchesCode('plain-auth-code')).toBeTrue();
|
||||||
|
expect(authCode.matchesCode('wrong-code')).toBeFalse();
|
||||||
|
|
||||||
|
await authCode.markUsed();
|
||||||
|
expect(authCode.data.used).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stores access tokens without plaintext persistence', async () => {
|
||||||
|
const accessToken = new OidcAccessToken();
|
||||||
|
accessToken.id = 'oidc-access-token';
|
||||||
|
accessToken.data.tokenHash = OidcAccessToken.hashToken('plain-access-token');
|
||||||
|
accessToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
expect(accessToken.matchesToken('plain-access-token')).toBeTrue();
|
||||||
|
expect(accessToken.matchesToken('different-access-token')).toBeFalse();
|
||||||
|
expect(accessToken.isExpired()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('revokes persisted refresh tokens', async () => {
|
||||||
|
const refreshToken = new OidcRefreshToken();
|
||||||
|
refreshToken.id = 'oidc-refresh-token';
|
||||||
|
refreshToken.data.tokenHash = OidcRefreshToken.hashToken('plain-refresh-token');
|
||||||
|
refreshToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(refreshToken as OidcRefreshToken & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(refreshToken.matchesToken('plain-refresh-token')).toBeTrue();
|
||||||
|
expect(refreshToken.data.revoked).toBeFalse();
|
||||||
|
|
||||||
|
await refreshToken.revoke();
|
||||||
|
expect(refreshToken.data.revoked).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('merges user consent scopes without duplicates', async () => {
|
||||||
|
const consent = new OidcUserConsent();
|
||||||
|
consent.id = 'oidc-consent';
|
||||||
|
consent.data.userId = 'user-1';
|
||||||
|
consent.data.clientId = 'client-1';
|
||||||
|
consent.data.scopes = ['openid'];
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(consent as OidcUserConsent & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
await consent.grantScopes(['openid', 'email', 'profile']);
|
||||||
|
|
||||||
|
expect(consent.data.scopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(consent.data.grantedAt).toBeGreaterThan(0);
|
||||||
|
expect(consent.data.updatedAt).toBeGreaterThan(0);
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('builds an OAuth redirect URL after successful authorization completion', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).generateAuthorizationCode = async () => 'generated-auth-code';
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(oidcManager as any).upsertUserConsent = async () => undefined;
|
||||||
|
|
||||||
|
const result = await oidcManager.completeAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
nonce: 'nonce-1',
|
||||||
|
consentApproved: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.code).toEqual('generated-auth-code');
|
||||||
|
expect(result.redirectUrl).toEqual(
|
||||||
|
'https://app.example/callback?code=generated-auth-code&state=xyz-state'
|
||||||
|
);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('prepares OAuth consent when scopes are not yet granted', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
prompt: undefined,
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toEqual('consent_required');
|
||||||
|
expect(result.requestedScopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(result.grantedScopes).toEqual(['openid']);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('prepares OAuth authorization as ready when consent already exists', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
prompt: undefined,
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toEqual('ready');
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.17.0',
|
version: '1.20.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# `ts/` Backend Module
|
||||||
|
|
||||||
|
The `ts/` folder contains the server runtime for `idp.global`: startup, website server wiring, typed routes, OIDC endpoints, and the core `Reception` managers.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## What Lives Here
|
||||||
|
|
||||||
|
- `index.ts` boots the service, loads env vars, starts the website server, and mounts OIDC endpoints.
|
||||||
|
- `reception/classes.reception.ts` creates the service container and initializes all managers.
|
||||||
|
- `reception/` contains the domain logic for users, sessions, orgs, roles, invites, apps, billing, and OIDC.
|
||||||
|
- `plugins.ts` centralizes external imports used by the backend.
|
||||||
|
|
||||||
|
## Startup Behavior
|
||||||
|
|
||||||
|
The backend startup in `ts/index.ts` does four main things:
|
||||||
|
|
||||||
|
1. Loads runtime configuration from `.nogit` and the working directory.
|
||||||
|
2. Creates a `UtilityWebsiteServer` that serves the built frontend.
|
||||||
|
3. Registers OIDC endpoints such as discovery, JWKS, authorize, token, userinfo, and revoke.
|
||||||
|
4. Creates and starts `Reception`, then starts HTTP serving on port `2999`.
|
||||||
|
|
||||||
|
## Required Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MONGODB_URL=mongodb://localhost:27017/idp-dev
|
||||||
|
export IDP_BASEURL=http://localhost:2999
|
||||||
|
export INSTANCE_NAME=idp-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `SERVEZONE_PLATFROM_AUTHORIZATION`
|
||||||
|
- `PADDLE_TOKEN`
|
||||||
|
- `PADDLE_PRICE_ID`
|
||||||
|
|
||||||
|
## Key Managers
|
||||||
|
|
||||||
|
| Class | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `JwtManager` | JWT issuance, validation, and key rotation support |
|
||||||
|
| `LoginSessionManager` | Session creation, refresh, logout, and session metadata |
|
||||||
|
| `RegistrationSessionManager` | Registration flow state |
|
||||||
|
| `UserManager` | User-centric queries and mutations |
|
||||||
|
| `OrganizationManager` | Organization creation and access checks |
|
||||||
|
| `RoleManager` | Role and permission management |
|
||||||
|
| `UserInvitationManager` | Invitations, member updates, and ownership transfer |
|
||||||
|
| `BillingPlanManager` | Billing plan state and Paddle config endpoint |
|
||||||
|
| `AppManager` | Global app administration |
|
||||||
|
| `AppConnectionManager` | App connection tracking |
|
||||||
|
| `ActivityLogManager` | User activity logging |
|
||||||
|
| `OidcManager` | OIDC discovery, auth code flow, token exchange, userinfo, revoke |
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm watch
|
||||||
|
```
|
||||||
|
|
||||||
|
The watch setup runs the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`.
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Reception } from './classes.reception.js';
|
||||||
|
import { AbuseWindow } from './classes.abusewindow.js';
|
||||||
|
|
||||||
|
export interface IAbuseProtectionConfig {
|
||||||
|
maxAttempts: number;
|
||||||
|
windowMillis: number;
|
||||||
|
blockDurationMillis: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbuseProtectionManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CAbuseWindow = plugins.smartdata.setDefaultManagerForDoc(this, AbuseWindow);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIdentifier(identifierArg: string) {
|
||||||
|
return identifierArg.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashIdentifier(identifierArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(this.normalizeIdentifier(identifierArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWindowId(actionArg: string, identifierArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(
|
||||||
|
`${actionArg}:${this.hashIdentifier(identifierArg)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWindow(actionArg: string, identifierArg: string) {
|
||||||
|
return this.CAbuseWindow.getInstance({
|
||||||
|
id: this.createWindowId(actionArg, identifierArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consumeAttempt(
|
||||||
|
actionArg: string,
|
||||||
|
identifierArg: string,
|
||||||
|
configArg: IAbuseProtectionConfig,
|
||||||
|
errorTextArg = 'Too many attempts. Please wait before trying again.'
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
let abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||||
|
|
||||||
|
if (!abuseWindow) {
|
||||||
|
abuseWindow = new AbuseWindow();
|
||||||
|
abuseWindow.id = this.createWindowId(actionArg, identifierArg);
|
||||||
|
abuseWindow.data.action = actionArg;
|
||||||
|
abuseWindow.data.identifierHash = this.hashIdentifier(identifierArg);
|
||||||
|
abuseWindow.data.createdAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abuseWindow.isBlocked(now)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abuseWindow.data.blockedUntil && abuseWindow.data.blockedUntil <= now) {
|
||||||
|
abuseWindow.data.attemptCount = 0;
|
||||||
|
abuseWindow.data.windowStartedAt = now;
|
||||||
|
abuseWindow.data.blockedUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!abuseWindow.data.windowStartedAt ||
|
||||||
|
abuseWindow.data.windowStartedAt + configArg.windowMillis <= now
|
||||||
|
) {
|
||||||
|
abuseWindow.data.attemptCount = 0;
|
||||||
|
abuseWindow.data.windowStartedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
abuseWindow.data.attemptCount += 1;
|
||||||
|
abuseWindow.data.updatedAt = now;
|
||||||
|
abuseWindow.data.validUntil = now + configArg.windowMillis;
|
||||||
|
|
||||||
|
if (abuseWindow.data.attemptCount > configArg.maxAttempts) {
|
||||||
|
abuseWindow.data.blockedUntil = now + configArg.blockDurationMillis;
|
||||||
|
abuseWindow.data.validUntil = abuseWindow.data.blockedUntil;
|
||||||
|
await abuseWindow.save();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
await abuseWindow.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearAttempts(actionArg: string, identifierArg: string) {
|
||||||
|
const abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||||
|
if (!abuseWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await abuseWindow.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class AbuseWindow extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
AbuseWindow,
|
||||||
|
plugins.idpInterfaces.data.IAbuseWindow,
|
||||||
|
AbuseProtectionManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAbuseWindow['data'] = {
|
||||||
|
action: '',
|
||||||
|
identifierHash: '',
|
||||||
|
attemptCount: 0,
|
||||||
|
windowStartedAt: 0,
|
||||||
|
blockedUntil: 0,
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isBlocked(nowArg = Date.now()) {
|
||||||
|
return this.data.blockedUntil > nowArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.validUntil < nowArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { LoginSessionManager } from './classes.loginsessionmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class EmailActionToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
EmailActionToken,
|
||||||
|
plugins.idpInterfaces.data.IEmailActionToken,
|
||||||
|
LoginSessionManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createOpaqueToken(actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction) {
|
||||||
|
return `${actionArg}_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IEmailActionToken['data'] = {
|
||||||
|
email: '',
|
||||||
|
action: 'emailLogin',
|
||||||
|
tokenHash: '',
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.validUntil < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === EmailActionToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consume(tokenArg: string) {
|
||||||
|
if (this.isExpired() || !this.matchesToken(tokenArg)) {
|
||||||
|
if (this.isExpired()) {
|
||||||
|
await this.delete();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,66 @@ export class ReceptionHousekeeping {
|
|||||||
'2 * * * * *'
|
'2 * * * * *'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredEmailActionTokens',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredEmailActionTokens =
|
||||||
|
await this.receptionRef.loginSessionManager.CEmailActionToken.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const emailActionToken of expiredEmailActionTokens) {
|
||||||
|
await emailActionToken.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredRegistrationSessions',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredRegistrationSessions =
|
||||||
|
await this.receptionRef.registrationSessionManager.CRegistrationSession.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const registrationSession of expiredRegistrationSessions) {
|
||||||
|
await registrationSession.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredAbuseWindows',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredAbuseWindows =
|
||||||
|
await this.receptionRef.abuseProtectionManager.CAbuseWindow.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const abuseWindow of expiredAbuseWindows) {
|
||||||
|
await abuseWindow.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
this.taskmanager.start();
|
this.taskmanager.start();
|
||||||
logger.log('info', 'housekeeping started');
|
logger.log('info', 'housekeeping started');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
import { EmailActionToken } from './classes.emailactiontoken.js';
|
||||||
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
|
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
|
||||||
import { Reception } from './classes.reception.js';
|
import { Reception } from './classes.reception.js';
|
||||||
import { logger } from './logging.js';
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
export class LoginSessionManager {
|
export class LoginSessionManager {
|
||||||
|
private readonly abuseProtectionConfigs = {
|
||||||
|
passwordLogin: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
emailLoginRequest: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
emailLoginToken: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
passwordResetRequest: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
passwordResetCompletion: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// refs
|
// refs
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
public get db() {
|
public get db() {
|
||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken);
|
||||||
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
||||||
|
|
||||||
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
|
|
||||||
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
public emailTokenMap = new plugins.lik.ObjectMap<{
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
action: 'emailLogin' | 'passwordReset';
|
|
||||||
}>();
|
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
@@ -29,6 +51,14 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
'loginWithEmailOrUsernameAndPassword',
|
'loginWithEmailOrUsernameAndPassword',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
|
const loginIdentifier = requestData.username;
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordLogin',
|
||||||
|
loginIdentifier,
|
||||||
|
this.abuseProtectionConfigs.passwordLogin,
|
||||||
|
'Too many login attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
|
||||||
let user = await this.receptionRef.userManager.CUser.getInstance({
|
let user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
username: requestData.username,
|
username: requestData.username,
|
||||||
@@ -55,12 +85,16 @@ export class LoginSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
this.loginSessions.add(loginSession);
|
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'passwordLogin',
|
||||||
|
loginIdentifier
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
twoFaNeeded: false,
|
twoFaNeeded: false,
|
||||||
@@ -76,6 +110,12 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||||
'loginWithEmail',
|
'loginWithEmail',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'emailLoginRequest',
|
||||||
|
requestDataArg.email,
|
||||||
|
this.abuseProtectionConfigs.emailLoginRequest,
|
||||||
|
'Too many magic link requests. Please wait before trying again.'
|
||||||
|
);
|
||||||
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
||||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -84,33 +124,21 @@ export class LoginSessionManager {
|
|||||||
});
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
const loginEmailToken = await this.createEmailActionToken(
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
existingUser.data.email,
|
||||||
|
'emailLogin'
|
||||||
);
|
);
|
||||||
const loginEmailToken = plugins.smartunique.uuid4();
|
|
||||||
this.emailTokenMap.add({
|
|
||||||
email: existingUser.data.email,
|
|
||||||
token: loginEmailToken,
|
|
||||||
action: 'emailLogin',
|
|
||||||
});
|
|
||||||
// lets make sure its only valid for 10 minutes
|
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
|
||||||
(itemArg) => itemArg.token === loginEmailToken
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
||||||
}
|
}
|
||||||
const testOnlyToken =
|
|
||||||
process.env.TEST_MODE && existingUser
|
|
||||||
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
|
||||||
?.token
|
|
||||||
: undefined;
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
testOnlyToken,
|
testOnlyToken: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -120,9 +148,17 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||||
'loginWithEmailAfterEmailTokenAquired',
|
'loginWithEmailAfterEmailTokenAquired',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
const tokenObject = this.emailTokenMap.findSync((itemArg) => {
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
return itemArg.email === requestArg.email && itemArg.token === requestArg.token;
|
'emailLoginToken',
|
||||||
});
|
requestArg.email,
|
||||||
|
this.abuseProtectionConfigs.emailLoginToken,
|
||||||
|
'Too many magic link attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
const tokenObject = await this.consumeEmailActionToken(
|
||||||
|
requestArg.email,
|
||||||
|
requestArg.token,
|
||||||
|
'emailLogin'
|
||||||
|
);
|
||||||
if (tokenObject) {
|
if (tokenObject) {
|
||||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -133,11 +169,14 @@ export class LoginSessionManager {
|
|||||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
}
|
}
|
||||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
this.loginSessions.add(loginSession);
|
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
}
|
}
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'emailLoginToken',
|
||||||
|
requestArg.email
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
};
|
};
|
||||||
@@ -206,6 +245,12 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||||
'resetPassword',
|
'resetPassword',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordResetRequest',
|
||||||
|
requestDataArg.email,
|
||||||
|
this.abuseProtectionConfigs.passwordResetRequest,
|
||||||
|
'Too many password reset requests. Please wait before trying again.'
|
||||||
|
);
|
||||||
const emailOfPasswordToReset = requestDataArg.email;
|
const emailOfPasswordToReset = requestDataArg.email;
|
||||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -213,23 +258,13 @@ export class LoginSessionManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
const resetToken = await this.createEmailActionToken(
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
existingUser.data.email,
|
||||||
|
'passwordReset'
|
||||||
);
|
);
|
||||||
this.emailTokenMap.add({
|
|
||||||
email: existingUser.data.email,
|
|
||||||
token: plugins.smartunique.shortId(),
|
|
||||||
action: 'passwordReset',
|
|
||||||
});
|
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
||||||
existingUser,
|
existingUser,
|
||||||
this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
resetToken
|
||||||
.token
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
||||||
@@ -244,6 +279,53 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||||
'setNewPassword',
|
'setNewPassword',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordResetCompletion',
|
||||||
|
requestData.email,
|
||||||
|
this.abuseProtectionConfigs.passwordResetCompletion,
|
||||||
|
'Too many password change attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
data: {
|
||||||
|
email: requestData.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestData.tokenArg) {
|
||||||
|
const tokenObject = await this.consumeEmailActionToken(
|
||||||
|
requestData.email,
|
||||||
|
requestData.tokenArg,
|
||||||
|
'passwordReset'
|
||||||
|
);
|
||||||
|
if (!tokenObject) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Password reset token invalid');
|
||||||
|
}
|
||||||
|
} else if (requestData.oldPassword) {
|
||||||
|
const passwordOk = await this.receptionRef.userManager.CUser.verifyPassword(
|
||||||
|
requestData.oldPassword,
|
||||||
|
user.data.passwordHash
|
||||||
|
);
|
||||||
|
if (!passwordOk) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Old password invalid');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Either a reset token or the old password is required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
|
||||||
|
requestData.newPassword
|
||||||
|
);
|
||||||
|
await user.save();
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'passwordResetCompletion',
|
||||||
|
requestData.email
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
};
|
};
|
||||||
@@ -393,4 +475,50 @@ export class LoginSessionManager {
|
|||||||
const isValid = await loginSession.validateTransferToken(transferTokenArg);
|
const isValid = await loginSession.validateTransferToken(transferTokenArg);
|
||||||
return isValid ? loginSession : null;
|
return isValid ? loginSession : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createEmailActionToken(
|
||||||
|
emailArg: string,
|
||||||
|
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||||
|
) {
|
||||||
|
const existingTokens = await this.CEmailActionToken.getInstances({
|
||||||
|
'data.email': emailArg,
|
||||||
|
'data.action': actionArg,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const existingToken of existingTokens) {
|
||||||
|
await existingToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainToken = EmailActionToken.createOpaqueToken(actionArg);
|
||||||
|
const emailActionToken = new EmailActionToken();
|
||||||
|
emailActionToken.id = plugins.smartunique.shortId();
|
||||||
|
emailActionToken.data = {
|
||||||
|
email: emailArg,
|
||||||
|
action: actionArg,
|
||||||
|
tokenHash: EmailActionToken.hashToken(plainToken),
|
||||||
|
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
await emailActionToken.save();
|
||||||
|
return plainToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consumeEmailActionToken(
|
||||||
|
emailArg: string,
|
||||||
|
tokenArg: string,
|
||||||
|
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||||
|
) {
|
||||||
|
const emailActionToken = await this.CEmailActionToken.getInstance({
|
||||||
|
'data.email': emailArg,
|
||||||
|
'data.action': actionArg,
|
||||||
|
'data.tokenHash': EmailActionToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailActionToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await emailActionToken.consume(tokenArg);
|
||||||
|
return consumed ? emailActionToken : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAccessToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAccessToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcAccessToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcAccessToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcAccessToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAuthorizationCode extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAuthorizationCode,
|
||||||
|
plugins.idpInterfaces.data.IAuthorizationCode,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashCode(codeArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAuthorizationCode['data'] = {
|
||||||
|
codeHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
redirectUri: '',
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesCode(codeArg: string) {
|
||||||
|
return this.data.codeHash === OidcAuthorizationCode.hashCode(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markUsed() {
|
||||||
|
this.data.used = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,85 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { Reception } from './classes.reception.js';
|
import type { Reception } from './classes.reception.js';
|
||||||
import type { App } from './classes.app.js';
|
import type { App } from './classes.app.js';
|
||||||
|
import { OidcAccessToken } from './classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from './classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcRefreshToken } from './classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from './classes.oidcuserconsent.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OidcManager handles OpenID Connect (OIDC) server functionality
|
* OidcManager handles OpenID Connect (OIDC) server functionality
|
||||||
* for third-party client authentication.
|
* for third-party client authentication.
|
||||||
*/
|
*/
|
||||||
export class OidcManager {
|
export class OidcManager {
|
||||||
|
private readonly abuseProtectionConfig = {
|
||||||
|
oidcTokenExchange: {
|
||||||
|
maxAttempts: 10,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
public get db() {
|
public get db() {
|
||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
|
||||||
|
|
||||||
// In-memory store for access tokens (for validation)
|
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
this,
|
||||||
|
OidcAuthorizationCode
|
||||||
|
);
|
||||||
|
|
||||||
// In-memory store for refresh tokens
|
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
|
||||||
|
|
||||||
// In-memory store for user consents (should be persisted later)
|
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||||
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
|
||||||
|
public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent);
|
||||||
|
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
// Start cleanup task for expired codes/tokens
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||||
|
'prepareOidcAuthorization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prepareAuthorizationForUser(jwt.data.userId, requestArg);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||||
|
'completeOidcAuthorization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.completeAuthorizationForUser(jwt.data.userId, requestArg);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
this.startCleanupTask();
|
this.startCleanupTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the OIDC Discovery Document
|
* Get the OIDC Discovery Document
|
||||||
*/
|
*/
|
||||||
@@ -118,6 +167,10 @@ export class OidcManager {
|
|||||||
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prompt && !this.isSupportedPrompt(prompt)) {
|
||||||
|
return this.errorResponse('invalid_request', 'Unsupported prompt value');
|
||||||
|
}
|
||||||
|
|
||||||
// Validate code challenge method if present
|
// Validate code challenge method if present
|
||||||
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
||||||
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
||||||
@@ -159,6 +212,9 @@ export class OidcManager {
|
|||||||
if (nonce) {
|
if (nonce) {
|
||||||
loginUrl.searchParams.set('nonce', nonce);
|
loginUrl.searchParams.set('nonce', nonce);
|
||||||
}
|
}
|
||||||
|
if (prompt) {
|
||||||
|
loginUrl.searchParams.set('prompt', prompt);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.redirect(loginUrl.toString(), 302);
|
return Response.redirect(loginUrl.toString(), 302);
|
||||||
}
|
}
|
||||||
@@ -174,9 +230,11 @@ export class OidcManager {
|
|||||||
codeChallenge?: string,
|
codeChallenge?: string,
|
||||||
nonce?: string
|
nonce?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const code = plugins.smartunique.shortId(32);
|
const code = this.createOpaqueToken();
|
||||||
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
const authCode = new OidcAuthorizationCode();
|
||||||
code,
|
authCode.id = plugins.smartunique.shortId(12);
|
||||||
|
authCode.data = {
|
||||||
|
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -184,14 +242,77 @@ export class OidcManager {
|
|||||||
codeChallenge,
|
codeChallenge,
|
||||||
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||||
nonce,
|
nonce,
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||||
|
issuedAt: Date.now(),
|
||||||
used: false,
|
used: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.authorizationCodes.set(code, authCode);
|
await authCode.save();
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async prepareAuthorizationForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
requestArg: Omit<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['request'], 'jwt'>
|
||||||
|
): Promise<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response']> {
|
||||||
|
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||||
|
const consentState = await this.evaluateConsentRequirement(
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const),
|
||||||
|
clientId: resolvedRequest.clientId,
|
||||||
|
appName: resolvedRequest.app.data.name,
|
||||||
|
appUrl: resolvedRequest.app.data.appUrl,
|
||||||
|
logoUrl: resolvedRequest.app.data.logoUrl,
|
||||||
|
requestedScopes: resolvedRequest.validScopes,
|
||||||
|
grantedScopes: consentState.grantedScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeAuthorizationForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
requestArg: Omit<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'jwt'>
|
||||||
|
) {
|
||||||
|
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||||
|
const consentState = await this.evaluateConsentRequirement(
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (consentState.consentRequired && !requestArg.consentApproved) {
|
||||||
|
throw new Error('Consent required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.consentApproved) {
|
||||||
|
await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await this.generateAuthorizationCode(
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.redirectUri,
|
||||||
|
resolvedRequest.codeChallenge,
|
||||||
|
resolvedRequest.nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectUrl = new URL(resolvedRequest.redirectUri);
|
||||||
|
redirectUrl.searchParams.set('code', code);
|
||||||
|
redirectUrl.searchParams.set('state', resolvedRequest.state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
redirectUrl: redirectUrl.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the token endpoint request
|
* Handle the token endpoint request
|
||||||
*/
|
*/
|
||||||
@@ -222,6 +343,13 @@ export class OidcManager {
|
|||||||
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'oidcTokenExchange',
|
||||||
|
clientId,
|
||||||
|
this.abuseProtectionConfig.oidcTokenExchange,
|
||||||
|
'Too many token endpoint attempts. Please wait before retrying.'
|
||||||
|
);
|
||||||
|
|
||||||
// Find and validate app
|
// Find and validate app
|
||||||
const app = await this.findAppByClientId(clientId);
|
const app = await this.findAppByClientId(clientId);
|
||||||
if (!app) {
|
if (!app) {
|
||||||
@@ -236,13 +364,20 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
if (grantType === 'authorization_code') {
|
if (grantType === 'authorization_code') {
|
||||||
return this.handleAuthorizationCodeGrant(formData, app);
|
response = await this.handleAuthorizationCodeGrant(formData, app);
|
||||||
} else if (grantType === 'refresh_token') {
|
} else if (grantType === 'refresh_token') {
|
||||||
return this.handleRefreshTokenGrant(formData, app);
|
response = await this.handleRefreshTokenGrant(formData, app);
|
||||||
} else {
|
} else {
|
||||||
return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,50 +396,48 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find and validate authorization code
|
// Find and validate authorization code
|
||||||
const authCode = this.authorizationCodes.get(code);
|
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||||
if (!authCode) {
|
if (!authCode) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.used) {
|
if (authCode.data.used) {
|
||||||
// Code reuse attack - revoke all tokens for this code
|
|
||||||
this.authorizationCodes.delete(code);
|
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.expiresAt < Date.now()) {
|
if (authCode.isExpired()) {
|
||||||
this.authorizationCodes.delete(code);
|
await authCode.delete();
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.clientId !== app.data.oauthCredentials.clientId) {
|
if (authCode.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.redirectUri !== redirectUri) {
|
if (authCode.data.redirectUri !== redirectUri) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify PKCE if code challenge was used
|
// Verify PKCE if code challenge was used
|
||||||
if (authCode.codeChallenge) {
|
if (authCode.data.codeChallenge) {
|
||||||
if (!codeVerifier) {
|
if (!codeVerifier) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||||
}
|
}
|
||||||
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||||
if (expectedChallenge !== authCode.codeChallenge) {
|
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark code as used
|
// Mark code as used
|
||||||
authCode.used = true;
|
await authCode.markUsed();
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
const tokens = await this.generateTokens(
|
const tokens = await this.generateTokens(
|
||||||
authCode.userId,
|
authCode.data.userId,
|
||||||
app.data.oauthCredentials.clientId,
|
app.data.oauthCredentials.clientId,
|
||||||
authCode.scopes,
|
authCode.data.scopes,
|
||||||
authCode.nonce
|
authCode.data.nonce
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(tokens), {
|
return new Response(JSON.stringify(tokens), {
|
||||||
@@ -330,31 +463,30 @@ export class OidcManager {
|
|||||||
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||||
const storedToken = this.refreshTokens.get(tokenHash);
|
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.revoked) {
|
if (storedToken.data.revoked) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.expiresAt < Date.now()) {
|
if (storedToken.isExpired()) {
|
||||||
this.refreshTokens.delete(tokenHash);
|
await storedToken.delete();
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.clientId !== app.data.oauthCredentials.clientId) {
|
if (storedToken.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new tokens (without new refresh token by default)
|
// Generate new tokens (without new refresh token by default)
|
||||||
const tokens = await this.generateTokens(
|
const tokens = await this.generateTokens(
|
||||||
storedToken.userId,
|
storedToken.data.userId,
|
||||||
storedToken.clientId,
|
storedToken.data.clientId,
|
||||||
storedToken.scopes,
|
storedToken.data.scopes,
|
||||||
undefined,
|
undefined,
|
||||||
false // Don't generate new refresh token
|
false // Don't generate new refresh token
|
||||||
);
|
);
|
||||||
@@ -384,18 +516,18 @@ export class OidcManager {
|
|||||||
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = plugins.smartunique.shortId(32);
|
const accessToken = this.createOpaqueToken();
|
||||||
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
const accessTokenData = new OidcAccessToken();
|
||||||
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
accessTokenData.id = plugins.smartunique.shortId(12);
|
||||||
id: plugins.smartunique.shortId(8),
|
accessTokenData.data = {
|
||||||
tokenHash: accessTokenHash,
|
tokenHash: OidcAccessToken.hashToken(accessToken),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
expiresAt: now + accessTokenLifetime * 1000,
|
expiresAt: now + accessTokenLifetime * 1000,
|
||||||
issuedAt: now,
|
issuedAt: now,
|
||||||
};
|
};
|
||||||
this.accessTokens.set(accessTokenHash, accessTokenData);
|
await accessTokenData.save();
|
||||||
|
|
||||||
// Generate ID token (JWT)
|
// Generate ID token (JWT)
|
||||||
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||||
@@ -410,11 +542,11 @@ export class OidcManager {
|
|||||||
|
|
||||||
// Generate refresh token if requested
|
// Generate refresh token if requested
|
||||||
if (includeRefreshToken) {
|
if (includeRefreshToken) {
|
||||||
const refreshToken = plugins.smartunique.shortId(48);
|
const refreshToken = this.createOpaqueToken(48);
|
||||||
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
const refreshTokenData = new OidcRefreshToken();
|
||||||
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||||
id: plugins.smartunique.shortId(8),
|
refreshTokenData.data = {
|
||||||
tokenHash: refreshTokenHash,
|
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -422,7 +554,7 @@ export class OidcManager {
|
|||||||
issuedAt: now,
|
issuedAt: now,
|
||||||
revoked: false,
|
revoked: false,
|
||||||
};
|
};
|
||||||
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
await refreshTokenData.save();
|
||||||
response.refresh_token = refreshToken;
|
response.refresh_token = refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,8 +614,7 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = authHeader.substring(7);
|
const accessToken = authHeader.substring(7);
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||||
const tokenData = this.accessTokens.get(tokenHash);
|
|
||||||
|
|
||||||
if (!tokenData) {
|
if (!tokenData) {
|
||||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
@@ -495,8 +626,8 @@ export class OidcManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenData.expiresAt < Date.now()) {
|
if (tokenData.isExpired()) {
|
||||||
this.accessTokens.delete(tokenHash);
|
await tokenData.delete();
|
||||||
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -507,7 +638,7 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user claims based on token scopes
|
// Get user claims based on token scopes
|
||||||
const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes);
|
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes);
|
||||||
|
|
||||||
return new Response(JSON.stringify(userInfo), {
|
return new Response(JSON.stringify(userInfo), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -583,21 +714,20 @@ export class OidcManager {
|
|||||||
return new Response(null, { status: 200 }); // Spec says always return 200
|
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
|
||||||
|
|
||||||
// Try to revoke as refresh token
|
// Try to revoke as refresh token
|
||||||
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||||
const refreshToken = this.refreshTokens.get(tokenHash);
|
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
refreshToken.revoked = true;
|
await refreshToken.revoke();
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to revoke as access token
|
// Try to revoke as access token
|
||||||
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||||
if (this.accessTokens.has(tokenHash)) {
|
const accessToken = await this.getAccessTokenByToken(token);
|
||||||
this.accessTokens.delete(tokenHash);
|
if (accessToken) {
|
||||||
|
await accessToken.delete();
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +746,125 @@ export class OidcManager {
|
|||||||
return apps[0] || null;
|
return apps[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' {
|
||||||
|
return ['none', 'login', 'consent'].includes(promptArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAuthorizationRequest(
|
||||||
|
requestArg: Pick<
|
||||||
|
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||||
|
'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce'
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) {
|
||||||
|
throw new Error('Missing required OAuth authorization parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) {
|
||||||
|
throw new Error('Unsupported prompt value');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') {
|
||||||
|
throw new Error('Only S256 code challenge method is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.findAppByClientId(requestArg.clientId);
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('Unknown client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) {
|
||||||
|
throw new Error('Invalid redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedScopes = requestArg.scope
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const allowedScopes =
|
||||||
|
app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg));
|
||||||
|
|
||||||
|
if (!validScopes.includes('openid')) {
|
||||||
|
throw new Error('openid scope is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
clientId: requestArg.clientId,
|
||||||
|
redirectUri: requestArg.redirectUri,
|
||||||
|
state: requestArg.state,
|
||||||
|
prompt: requestArg.prompt,
|
||||||
|
codeChallenge: requestArg.codeChallenge,
|
||||||
|
codeChallengeMethod: requestArg.codeChallengeMethod,
|
||||||
|
nonce: requestArg.nonce,
|
||||||
|
validScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateConsentRequirement(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
promptArg?: 'none' | 'login' | 'consent'
|
||||||
|
) {
|
||||||
|
const existingConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
const grantedScopes = existingConsent?.data.scopes || [];
|
||||||
|
const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg));
|
||||||
|
|
||||||
|
return {
|
||||||
|
grantedScopes,
|
||||||
|
missingScopes,
|
||||||
|
consentRequired: promptArg === 'consent' || missingScopes.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOpaqueToken(byteLength = 32): string {
|
||||||
|
return plugins.crypto.randomBytes(byteLength).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthorizationCodeByCode(codeArg: string) {
|
||||||
|
return this.COidcAuthorizationCode.getInstance({
|
||||||
|
'data.codeHash': OidcAuthorizationCode.hashCode(codeArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcAccessToken.getInstance({
|
||||||
|
'data.tokenHash': OidcAccessToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRefreshTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcRefreshToken.getInstance({
|
||||||
|
'data.tokenHash': OidcRefreshToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserConsent(userIdArg: string, clientIdArg: string) {
|
||||||
|
return this.COidcUserConsent.getInstance({
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.clientId': clientIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsertUserConsent(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[]
|
||||||
|
) {
|
||||||
|
let userConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
|
||||||
|
if (!userConsent) {
|
||||||
|
userConsent = new OidcUserConsent();
|
||||||
|
userConsent.id = plugins.smartunique.shortId(12);
|
||||||
|
userConsent.data.userId = userIdArg;
|
||||||
|
userConsent.data.clientId = clientIdArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
await userConsent.grantScopes(scopesArg);
|
||||||
|
return userConsent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate S256 PKCE challenge from verifier
|
* Generate S256 PKCE challenge from verifier
|
||||||
*/
|
*/
|
||||||
@@ -655,29 +904,45 @@ export class OidcManager {
|
|||||||
* Start cleanup task for expired tokens/codes
|
* Start cleanup task for expired tokens/codes
|
||||||
*/
|
*/
|
||||||
private startCleanupTask(): void {
|
private startCleanupTask(): void {
|
||||||
setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
void this.cleanupExpiredOidcState();
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired authorization codes
|
private async cleanupExpiredOidcState() {
|
||||||
for (const [code, data] of this.authorizationCodes) {
|
const now = Date.now();
|
||||||
if (data.expiresAt < now) {
|
|
||||||
this.authorizationCodes.delete(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up expired access tokens
|
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||||
for (const [hash, data] of this.accessTokens) {
|
data: {
|
||||||
if (data.expiresAt < now) {
|
expiresAt: {
|
||||||
this.accessTokens.delete(hash);
|
$lt: now,
|
||||||
}
|
} as any,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
for (const authCode of expiredAuthorizationCodes) {
|
||||||
|
await authCode.delete();
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired refresh tokens
|
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||||
for (const [hash, data] of this.refreshTokens) {
|
data: {
|
||||||
if (data.expiresAt < now) {
|
expiresAt: {
|
||||||
this.refreshTokens.delete(hash);
|
$lt: now,
|
||||||
}
|
} as any,
|
||||||
}
|
},
|
||||||
}, 60 * 1000); // Run every minute
|
});
|
||||||
|
for (const accessToken of expiredAccessTokens) {
|
||||||
|
await accessToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredRefreshTokens = await this.COidcRefreshToken.getInstances({
|
||||||
|
data: {
|
||||||
|
expiresAt: {
|
||||||
|
$lt: now,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const refreshToken of expiredRefreshTokens) {
|
||||||
|
await refreshToken.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcRefreshToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcRefreshToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcRefreshToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcRefreshToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcRefreshToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revoke() {
|
||||||
|
this.data.revoked = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcUserConsent extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcUserConsent,
|
||||||
|
plugins.idpInterfaces.data.IUserConsent,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IUserConsent['data'] = {
|
||||||
|
userId: '',
|
||||||
|
clientId: '',
|
||||||
|
scopes: [],
|
||||||
|
grantedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async grantScopes(scopesArg: plugins.idpInterfaces.data.TOidcScope[]) {
|
||||||
|
this.data.scopes = [...new Set([...this.data.scopes, ...scopesArg])];
|
||||||
|
if (!this.data.grantedAt) {
|
||||||
|
this.data.grantedAt = Date.now();
|
||||||
|
}
|
||||||
|
this.data.updatedAt = Date.now();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
|||||||
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||||
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||||
import { OidcManager } from './classes.oidcmanager.js';
|
import { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||||
|
|
||||||
export interface IReceptionOptions {
|
export interface IReceptionOptions {
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +49,7 @@ export class Reception {
|
|||||||
public appConnectionManager = new AppConnectionManager(this);
|
public appConnectionManager = new AppConnectionManager(this);
|
||||||
public activityLogManager = new ActivityLogManager(this);
|
public activityLogManager = new ActivityLogManager(this);
|
||||||
public userInvitationManager = new UserInvitationManager(this);
|
public userInvitationManager = new UserInvitationManager(this);
|
||||||
|
public abuseProtectionManager = new AbuseProtectionManager(this);
|
||||||
public oidcManager = new OidcManager(this);
|
public oidcManager = new OidcManager(this);
|
||||||
housekeeping = new ReceptionHousekeeping(this);
|
housekeeping = new ReceptionHousekeeping(this);
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export class Reception {
|
|||||||
*/
|
*/
|
||||||
public async stop() {
|
public async stop() {
|
||||||
await this.housekeeping.stop();
|
await this.housekeeping.stop();
|
||||||
|
await this.oidcManager.stop();
|
||||||
console.log('stopped serviceserver!');
|
console.log('stopped serviceserver!');
|
||||||
await this.db.stop();
|
await this.db.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,191 +5,187 @@ import { logger } from './logging.js';
|
|||||||
import { User } from './classes.user.js';
|
import { User } from './classes.user.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a RegistrationSession is a in memory session for signing up
|
* a RegistrationSession persists a sign up flow across restarts
|
||||||
*/
|
*/
|
||||||
export class RegistrationSession {
|
@plugins.smartdata.Manager()
|
||||||
// ======
|
export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
|
||||||
// STATIC
|
RegistrationSession,
|
||||||
// ======
|
plugins.idpInterfaces.data.IRegistrationSession,
|
||||||
|
RegistrationSessionManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
public static async createRegistrationSessionForEmail(
|
public static async createRegistrationSessionForEmail(
|
||||||
registrationSessionManageremailArg: RegistrationSessionManager,
|
|
||||||
emailArg: string
|
emailArg: string
|
||||||
) {
|
) {
|
||||||
const newRegistrationSession = new RegistrationSession(
|
const newRegistrationSession = new RegistrationSession();
|
||||||
registrationSessionManageremailArg,
|
newRegistrationSession.id = plugins.smartunique.shortId();
|
||||||
emailArg
|
newRegistrationSession.data.emailAddress = emailArg;
|
||||||
);
|
newRegistrationSession.data.validUntil =
|
||||||
const emailValidationResult = await newRegistrationSession
|
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
|
||||||
.validateEMailAddress()
|
newRegistrationSession.data.createdAt = Date.now();
|
||||||
.catch((error) => {
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
|
||||||
'Error occured during email provider & dns validation'
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
);
|
'Error occured during email provider & dns validation'
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!emailValidationResult?.valid) {
|
if (!emailValidationResult?.valid) {
|
||||||
newRegistrationSession.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Email Address is not valid. Please use a correctly formated email address'
|
'Email Address is not valid. Please use a correctly formated email address'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (emailValidationResult.disposable) {
|
if (emailValidationResult.disposable) {
|
||||||
newRegistrationSession.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Email is disposable. Please use a non disposable email address.'
|
'Email is disposable. Please use a non disposable email address.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!`
|
const validationToken = await newRegistrationSession.sendTokenValidationEmail();
|
||||||
);
|
newRegistrationSession.unhashedEmailToken = validationToken;
|
||||||
await newRegistrationSession.sendTokenValidationEmail();
|
|
||||||
console.log(`Successfully sent email validation email`);
|
|
||||||
return newRegistrationSession;
|
return newRegistrationSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========
|
@plugins.smartdata.unI()
|
||||||
// INSTANCE
|
public id: string;
|
||||||
// ========
|
|
||||||
public registrationSessionManagerRef: RegistrationSessionManager;
|
|
||||||
|
|
||||||
public emailAddress: string;
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IRegistrationSession['data'] = {
|
||||||
|
emailAddress: '',
|
||||||
|
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
|
* only used during testing
|
||||||
*/
|
*/
|
||||||
public unhashedEmailToken?: string;
|
public unhashedEmailToken?: string;
|
||||||
public hashedEmailToken: string;
|
|
||||||
private smsvalidationCounter = 0;
|
|
||||||
public smsCode: string;
|
|
||||||
|
|
||||||
/**
|
public get emailAddress() {
|
||||||
* the status of the registration. should progress in a linear fashion.
|
return this.data.emailAddress;
|
||||||
*/
|
}
|
||||||
public status: 'announced' | 'emailValidated' | 'mobileVerified' | 'registered' | 'failed' =
|
|
||||||
'announced';
|
|
||||||
|
|
||||||
public collectedData: {
|
public get status() {
|
||||||
userData: plugins.idpInterfaces.data.IUser['data'];
|
return this.data.status;
|
||||||
} = {
|
}
|
||||||
userData: {
|
|
||||||
username: null,
|
|
||||||
connectedOrgs: [],
|
|
||||||
email: null,
|
|
||||||
name: null,
|
|
||||||
status: null,
|
|
||||||
mobileNumber: null,
|
|
||||||
password: null,
|
|
||||||
passwordHash: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
|
||||||
registrationSessionManagerRefArg: RegistrationSessionManager,
|
this.data.status = statusArg;
|
||||||
emailAddressArg: string
|
}
|
||||||
) {
|
|
||||||
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
|
|
||||||
this.emailAddress = emailAddressArg;
|
|
||||||
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
|
|
||||||
|
|
||||||
// lets destroy this after 10 minutes,
|
public get collectedData() {
|
||||||
// works in unrefed mode so not blocking node exiting.
|
return this.data.collectedData;
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy());
|
}
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.validUntil < Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validates a token by comparing its hash against the stored hashed token
|
* validates a token by comparing its hash against the stored hashed token
|
||||||
* @param tokenArg
|
|
||||||
*/
|
*/
|
||||||
public validateEmailToken(tokenArg: string): boolean {
|
public async validateEmailToken(tokenArg: string): Promise<boolean> {
|
||||||
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg);
|
if (this.isExpired()) {
|
||||||
if (result && this.status === 'announced') {
|
await this.destroy();
|
||||||
this.status = 'emailValidated';
|
return false;
|
||||||
this.collectedData.userData.email = this.emailAddress;
|
|
||||||
}
|
}
|
||||||
if (!result && this.status === 'announced') {
|
|
||||||
this.status = 'failed';
|
const result = this.data.hashedEmailToken === RegistrationSession.hashToken(tokenArg);
|
||||||
|
if (result && this.data.status === 'announced') {
|
||||||
|
this.data.status = 'emailValidated';
|
||||||
|
this.data.collectedData.userData.email = this.data.emailAddress;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
if (!result && this.data.status === 'announced') {
|
||||||
|
this.data.status = 'failed';
|
||||||
|
await this.save();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** validates the sms code */
|
/** validates the sms code */
|
||||||
public validateSmsCode(smsCodeArg: string) {
|
public async validateSmsCode(smsCodeArg: string) {
|
||||||
this.smsvalidationCounter++;
|
this.data.smsvalidationCounter++;
|
||||||
const result = this.smsCode === smsCodeArg;
|
const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
|
||||||
if (this.status === 'emailValidated' && result) {
|
if (this.data.status === 'emailValidated' && result) {
|
||||||
this.status = 'mobileVerified';
|
this.data.status = 'mobileVerified';
|
||||||
|
await this.save();
|
||||||
return result;
|
return result;
|
||||||
} else {
|
|
||||||
if (this.smsvalidationCounter === 5) {
|
|
||||||
this.destroy();
|
|
||||||
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> {
|
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
||||||
console.log(`validating email ${this.emailAddress}`);
|
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
|
||||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* send the validation email
|
|
||||||
*/
|
|
||||||
public async sendTokenValidationEmail() {
|
public async sendTokenValidationEmail() {
|
||||||
const uuidToSend = plugins.smartunique.uuid4();
|
const uuidToSend = plugins.smartunique.uuid4();
|
||||||
this.unhashedEmailToken = uuidToSend;
|
this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
|
||||||
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend);
|
await this.save();
|
||||||
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail(
|
this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
|
||||||
this,
|
logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
|
||||||
uuidToSend
|
return uuidToSend;
|
||||||
);
|
|
||||||
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* validate the mobile number of someone
|
|
||||||
*/
|
|
||||||
public async sendValidationSms() {
|
public async sendValidationSms() {
|
||||||
this.smsCode =
|
const smsCode =
|
||||||
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation(
|
await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
|
||||||
{
|
fromName: this.manager.receptionRef.options.name,
|
||||||
fromName: this.registrationSessionManagerRef.receptionRef.options.name,
|
toNumber: parseInt(this.data.collectedData.userData.mobileNumber),
|
||||||
toNumber: parseInt(this.collectedData.userData.mobileNumber),
|
});
|
||||||
}
|
this.data.smsCodeHash = RegistrationSession.hashToken(smsCode);
|
||||||
);
|
await this.save();
|
||||||
|
return smsCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* this method can be called when this registrationsession is validated
|
|
||||||
* and all data has been set
|
|
||||||
*/
|
|
||||||
public async manifestUserWithAccountData(): Promise<User> {
|
public async manifestUserWithAccountData(): Promise<User> {
|
||||||
if (this.status !== 'mobileVerified') {
|
if (this.data.status !== 'mobileVerified') {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'You can only manifest user that have a validated email Address and Mobile Number'
|
'You can only manifest user that have a validated email Address and Mobile Number'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!this.collectedData) {
|
if (!this.data.collectedData) {
|
||||||
throw new Error('You have to set the accountdata first');
|
throw new Error('You have to set the accountdata first');
|
||||||
}
|
}
|
||||||
const manifestedUser =
|
const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||||
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData(
|
this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
|
||||||
this.collectedData.userData
|
);
|
||||||
);
|
this.data.status = 'registered';
|
||||||
|
await this.save();
|
||||||
return manifestedUser;
|
return manifestedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async destroy() {
|
||||||
* destroys the registrationsession
|
await this.delete();
|
||||||
*/
|
|
||||||
public destroy() {
|
|
||||||
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { logger } from './logging.js';
|
|||||||
|
|
||||||
export class RegistrationSessionManager {
|
export class RegistrationSessionManager {
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
|
|
||||||
public registrationSessions = new plugins.lik.FastMap<RegistrationSession>();
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession);
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
@@ -29,17 +33,16 @@ export class RegistrationSessionManager {
|
|||||||
`We sent you an Email with more information.`
|
`We sent you an Email with more information.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// check for exiting SignupSession
|
|
||||||
const existingSession = this.registrationSessions.getByKey(requestData.email);
|
const existingSessions = await this.CRegistrationSession.getInstances({
|
||||||
if (existingSession) {
|
'data.emailAddress': requestData.email,
|
||||||
|
});
|
||||||
|
for (const existingSession of existingSessions) {
|
||||||
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
||||||
existingSession.destroy();
|
await existingSession.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets check the email before we create a signup session
|
|
||||||
|
|
||||||
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
||||||
this,
|
|
||||||
requestData.email
|
requestData.email
|
||||||
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
||||||
console.log(e.errorText);
|
console.log(e.errorText);
|
||||||
@@ -63,10 +66,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||||
'afterRegistrationEmailClicked',
|
'afterRegistrationEmailClicked',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
console.log(requestData);
|
const signupSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
const signupSession = await this.registrationSessions.find(async (itemArg) =>
|
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (signupSession) {
|
if (signupSession) {
|
||||||
return {
|
return {
|
||||||
email: signupSession.emailAddress,
|
email: signupSession.emailAddress,
|
||||||
@@ -86,9 +86,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||||
'setDataForRegistration',
|
'setDataForRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -114,9 +112,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||||
'mobileVerificationForRegistration',
|
'mobileVerificationForRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -131,17 +127,16 @@ export class RegistrationSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requestData.mobileNumber) {
|
if (requestData.mobileNumber) {
|
||||||
registrationSession.status = 'emailValidated';
|
|
||||||
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
||||||
await registrationSession.sendValidationSms();
|
const smsCode = await registrationSession.sendValidationSms();
|
||||||
return {
|
return {
|
||||||
messageSent: true,
|
messageSent: true,
|
||||||
testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null,
|
testOnlySmsCode: process.env.TEST_MODE ? smsCode : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestData.verificationCode) {
|
if (requestData.verificationCode) {
|
||||||
const validationResult = registrationSession.validateSmsCode(
|
const validationResult = await registrationSession.validateSmsCode(
|
||||||
requestData.verificationCode
|
requestData.verificationCode
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -160,9 +155,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||||
'finishRegistration',
|
'finishRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -170,7 +163,7 @@ export class RegistrationSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
||||||
registrationSession.destroy();
|
await registrationSession.destroy();
|
||||||
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
||||||
return {
|
return {
|
||||||
accountData: {
|
accountData: {
|
||||||
@@ -187,4 +180,17 @@ export class RegistrationSessionManager {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findRegistrationSessionByToken(tokenArg: string) {
|
||||||
|
const registrationSession = await this.CRegistrationSession.getInstance({
|
||||||
|
'data.hashedEmailToken': RegistrationSession.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registrationSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await registrationSession.validateEmailToken(tokenArg);
|
||||||
|
return isValid ? registrationSession : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-133
@@ -1,181 +1,97 @@
|
|||||||
# @idp.global/cli
|
# @idp.global/cli
|
||||||
|
|
||||||
Command-line interface for interacting with the idp.global Identity Provider. A Node.js CLI tool that provides authentication, user management, and organization administration from the terminal.
|
Terminal client for `idp.global`.
|
||||||
|
|
||||||
## Overview
|
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.
|
||||||
|
|
||||||
The IdpCli module provides a complete command-line interface for managing your idp.global account and organizations. It uses file-based credential storage and WebSocket connections for real-time communication with the IdP server.
|
## Issue Reporting and Security
|
||||||
|
|
||||||
## Installation
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @idp.global/cli
|
|
||||||
# or
|
|
||||||
pnpm add -g @idp.global/cli
|
pnpm add -g @idp.global/cli
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login with email and password
|
|
||||||
idp login
|
idp login
|
||||||
|
|
||||||
# Check current user
|
|
||||||
idp whoami
|
idp whoami
|
||||||
|
|
||||||
# List your organizations
|
|
||||||
idp orgs
|
idp orgs
|
||||||
|
idp sessions
|
||||||
# Logout
|
|
||||||
idp logout
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Authentication
|
| Command | Purpose |
|
||||||
|
| --- | --- |
|
||||||
| Command | Description |
|
| `idp login` | Prompt for email and password |
|
||||||
|---------|-------------|
|
| `idp login-token` | Prompt for an API token |
|
||||||
| `idp login` | Interactive login with email and password |
|
| `idp logout` | Remove local credentials and try server-side logout |
|
||||||
| `idp login-token` | Login with an API token |
|
| `idp whoami` | Print the current user |
|
||||||
| `idp logout` | Clear stored credentials and end session |
|
| `idp sessions` | List active sessions |
|
||||||
|
| `idp revoke --session <session-id>` | Revoke a session |
|
||||||
### User Information
|
| `idp orgs` | List organizations for the current user |
|
||||||
|
| `idp orgs-create` | Interactively create an organization |
|
||||||
| Command | Description |
|
| `idp members --org <org-id>` | List members for an organization |
|
||||||
|---------|-------------|
|
| `idp invite --org <org-id> --email user@example.com` | Invite a member |
|
||||||
| `idp whoami` | Display current user information |
|
| `idp admin-check` | Check global admin status |
|
||||||
| `idp sessions` | List all active sessions |
|
| `idp admin-apps` | List global app stats |
|
||||||
| `idp revoke --session <id>` | Revoke a specific session |
|
| `idp admin-suspend --user <user-id>` | Suspend a user |
|
||||||
|
|
||||||
### Organization Management
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `idp orgs` | List all organizations you belong to |
|
|
||||||
| `idp orgs-create` | Create a new organization (interactive) |
|
|
||||||
| `idp members --org <id>` | List members of an organization |
|
|
||||||
| `idp invite --org <id> --email <email>` | Invite a user to an organization |
|
|
||||||
|
|
||||||
### Admin Commands (Global Admins Only)
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `idp admin-check` | Check if you are a global admin |
|
|
||||||
| `idp admin-apps` | List all global apps with connection stats |
|
|
||||||
| `idp admin-suspend --user <id>` | Suspend a user account |
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
The CLI reads `IDP_URL` and defaults to `https://idp.global`.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
```bash
|
||||||
|----------|-------------|---------|
|
IDP_URL=http://localhost:2999 idp whoami
|
||||||
| `IDP_URL` | Override the IdP server URL | `https://idp.global` |
|
```
|
||||||
|
|
||||||
### Credential Storage
|
Credentials are stored in:
|
||||||
|
|
||||||
Credentials are stored in `~/.idp-global/credentials.json`. This file contains your refresh token and JWT for persistent authentication across CLI sessions.
|
```text
|
||||||
|
~/.idp-global/credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
## Programmatic Usage
|
## Programmatic Usage
|
||||||
|
|
||||||
You can also use the IdpCli class programmatically:
|
```ts
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IdpCli } from '@idp.global/cli';
|
import { IdpCli } from '@idp.global/cli';
|
||||||
|
|
||||||
const cli = new IdpCli({
|
const cli = new IdpCli({
|
||||||
idpBaseUrl: 'https://idp.global',
|
idpBaseUrl: 'http://localhost:2999',
|
||||||
configDir: '/custom/config/path', // optional
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login
|
await cli.loginWithPassword('user@example.com', 'secret');
|
||||||
await cli.loginWithPassword('user@example.com', 'password');
|
|
||||||
|
|
||||||
// Get current user
|
const me = await cli.whoami();
|
||||||
const user = await cli.whoami();
|
const orgs = await cli.getOrganizations();
|
||||||
console.log('Logged in as:', user.data.name);
|
|
||||||
|
|
||||||
// Get organizations
|
console.log(me?.data?.email);
|
||||||
const { organizations, roles } = await cli.getOrganizations();
|
console.log(orgs?.organizations.length);
|
||||||
for (const org of organizations) {
|
|
||||||
console.log(`- ${org.data.name} (${org.id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect when done
|
|
||||||
await cli.disconnect();
|
await cli.disconnect();
|
||||||
```
|
```
|
||||||
|
|
||||||
### IdpCli Class Methods
|
## What The Class Exposes
|
||||||
|
|
||||||
**Authentication:**
|
- `loginWithPassword()` and `loginWithApiToken()`
|
||||||
- `loginWithPassword(email, password)` - Login with credentials
|
- `refreshJwt()` and `logout()`
|
||||||
- `loginWithApiToken(token)` - Login with API token
|
- `whoami()`, `getSessions()`, and `revokeSession()`
|
||||||
- `refreshJwt()` - Refresh the current JWT
|
- `getOrganizations()`, `createOrganization()`, `getOrgMembers()`, and `inviteMember()`
|
||||||
- `logout()` - Clear credentials and end session
|
- `checkGlobalAdmin()`, `getGlobalAppStats()`, and `suspendUser()`
|
||||||
|
|
||||||
**User:**
|
## Implementation Notes
|
||||||
- `whoami()` - Get current user info
|
|
||||||
- `getSessions()` - Get active sessions
|
|
||||||
- `revokeSession(sessionId)` - Revoke a session
|
|
||||||
|
|
||||||
**Organizations:**
|
- The CLI connects to the backend websocket surface at `/typedrequest`.
|
||||||
- `getOrganizations()` - List user's organizations
|
- It uses file-based credentials instead of browser storage.
|
||||||
- `createOrganization(name, slug, mode)` - Create new organization
|
- `orgs-create` first checks availability, then creates the organization.
|
||||||
- `getOrgMembers(orgId)` - Get organization members
|
|
||||||
- `inviteMember(orgId, email, roles)` - Invite a user
|
|
||||||
|
|
||||||
**Admin:**
|
|
||||||
- `checkGlobalAdmin()` - Check admin status
|
|
||||||
- `getGlobalAppStats()` - Get app statistics
|
|
||||||
- `suspendUser(userId)` - Suspend a user
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Create an Organization
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ idp orgs-create
|
|
||||||
Organization Name: My Company
|
|
||||||
Organization Slug: my-company
|
|
||||||
|
|
||||||
Organization created successfully!
|
|
||||||
ID: org_abc123
|
|
||||||
Name: My Company
|
|
||||||
```
|
|
||||||
|
|
||||||
### Invite Team Members
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ idp invite --org org_abc123 --email colleague@example.com
|
|
||||||
Invitation sent to colleague@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Active Sessions
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ idp sessions
|
|
||||||
|
|
||||||
Active Sessions:
|
|
||||||
- sess_xyz789
|
|
||||||
Device: MacBook Pro
|
|
||||||
Browser: Chrome
|
|
||||||
OS: macOS
|
|
||||||
Last Active: 1/29/2025, 2:30:00 PM
|
|
||||||
Current: Yes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- `@api.global/typedrequest` - Type-safe API requests
|
|
||||||
- `@api.global/typedsocket` - WebSocket communication
|
|
||||||
- `@push.rocks/smartcli` - CLI framework
|
|
||||||
- `@push.rocks/smartinteract` - Interactive prompts
|
|
||||||
- `@idp.global/interfaces` - TypeScript interfaces
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**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.
|
||||||
|
|
||||||
@@ -187,7 +103,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or 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.
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ export class IdpRequests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get completeOidcAuthorization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||||
|
'completeOidcAuthorization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get prepareOidcAuthorization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||||
|
'prepareOidcAuthorization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public get resetPassword() {
|
public get resetPassword() {
|
||||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||||
'resetPassword'
|
'resetPassword'
|
||||||
|
|||||||
+78
-311
@@ -1,71 +1,61 @@
|
|||||||
# @idp.global/idpclient
|
# @idp.global/client
|
||||||
|
|
||||||
A TypeScript client library for integrating with the idp.global Identity Provider. Works in both browser and Node.js environments.
|
Browser-facing TypeScript client for talking to an `idp.global` server over `typedrequest` and `typedsocket`.
|
||||||
|
|
||||||
## Overview
|
It handles login state, refresh tokens, JWT housekeeping, cross-app transfer tokens, and direct access to the typed request surface.
|
||||||
|
|
||||||
The IdpClient provides a complete API for authentication, session management, and organization operations. It uses WebSocket connections via TypedSocket for real-time, type-safe communication with the IdP server.
|
## Issue Reporting and Security
|
||||||
|
|
||||||
## Installation
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @idp.global/idpclient
|
pnpm add @idp.global/client
|
||||||
# or
|
|
||||||
pnpm add @idp.global/idpclient
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
import { IdpClient } from '@idp.global/idpclient';
|
import { IdpClient } from '@idp.global/client';
|
||||||
|
|
||||||
// Initialize the client
|
|
||||||
const idpClient = new IdpClient('https://idp.global');
|
const idpClient = new IdpClient('https://idp.global');
|
||||||
|
|
||||||
// Enable WebSocket connection
|
|
||||||
await idpClient.enableTypedSocket();
|
await idpClient.enableTypedSocket();
|
||||||
|
|
||||||
// Check login status
|
const loggedIn = await idpClient.determineLoginStatus();
|
||||||
const isLoggedIn = await idpClient.determineLoginStatus();
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (!loggedIn) {
|
||||||
const userInfo = await idpClient.whoIs();
|
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
console.log('Logged in as:', userInfo.user.data.name);
|
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);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Core Features
|
## What The Client Handles
|
||||||
|
|
||||||
### Authentication
|
- 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`.
|
||||||
|
|
||||||
#### Password Login
|
## Common Flows
|
||||||
|
|
||||||
```typescript
|
### Password Login
|
||||||
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
|
||||||
|
```ts
|
||||||
|
const result = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
username: 'user@example.com',
|
username: 'user@example.com',
|
||||||
password: 'securepassword',
|
password: 'secret',
|
||||||
});
|
|
||||||
|
|
||||||
if (response.refreshToken) {
|
|
||||||
await idpClient.refreshJwt(response.refreshToken);
|
|
||||||
console.log('Login successful!');
|
|
||||||
} else if (response.twoFaNeeded) {
|
|
||||||
console.log('2FA verification required');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Magic Link Login
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Request magic link
|
|
||||||
await idpClient.requests.loginWithEmail.fire({
|
|
||||||
email: 'user@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
// After clicking the email link
|
|
||||||
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
|
|
||||||
email: 'user@example.com',
|
|
||||||
token: 'token-from-email-link',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.refreshToken) {
|
if (result.refreshToken) {
|
||||||
@@ -73,303 +63,80 @@ if (result.refreshToken) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API Token Login
|
### Magic Link Login
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
const result = await idpClient.requests.loginWithApiToken.fire({
|
await idpClient.requests.loginWithEmail.fire({
|
||||||
apiToken: 'your-api-token',
|
email: 'user@example.com',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.jwt) {
|
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
|
||||||
await idpClient.setJwt(result.jwt);
|
email: 'user@example.com',
|
||||||
}
|
token: 'token-from-email',
|
||||||
|
});
|
||||||
|
|
||||||
|
await idpClient.refreshJwt(result.refreshToken);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Session Management
|
### Session and Identity
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
// Get current JWT
|
|
||||||
const jwt = await idpClient.getJwt();
|
|
||||||
|
|
||||||
// Get parsed JWT data
|
|
||||||
const jwtData = await idpClient.getJwtData();
|
|
||||||
console.log('User ID:', jwtData.id);
|
|
||||||
|
|
||||||
// Refresh JWT (automatic housekeeping)
|
|
||||||
await idpClient.performJwtHousekeeping();
|
await idpClient.performJwtHousekeeping();
|
||||||
|
|
||||||
// Manual refresh
|
const jwt = await idpClient.getJwt();
|
||||||
await idpClient.refreshJwt();
|
const jwtData = await idpClient.getJwtData();
|
||||||
|
const whoIs = await idpClient.whoIs();
|
||||||
|
|
||||||
// Logout
|
console.log(jwtData.id, whoIs.user.data.username);
|
||||||
await idpClient.logout();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### User Information
|
### Organizations
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
// Get current user details
|
const rolesAndOrganizations = await idpClient.getRolesAndOrganizations();
|
||||||
const whoIsResponse = await idpClient.whoIs();
|
|
||||||
console.log('Name:', whoIsResponse.user.data.name);
|
|
||||||
console.log('Email:', whoIsResponse.user.data.email);
|
|
||||||
|
|
||||||
// Get user data
|
const created = await idpClient.createOrganization(
|
||||||
const userData = await idpClient.requests.getUserData.fire({
|
'Acme',
|
||||||
jwt: await idpClient.getJwt(),
|
'acme',
|
||||||
userId: jwtData.id,
|
'manifest'
|
||||||
});
|
|
||||||
|
|
||||||
// Update user data
|
|
||||||
await idpClient.requests.setUserData.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
userId: jwtData.id,
|
|
||||||
name: 'New Name',
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Organization Management
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get user's organizations and roles
|
|
||||||
const orgsAndRoles = await idpClient.getRolesAndOrganizations();
|
|
||||||
console.log('Organizations:', orgsAndRoles.organizations);
|
|
||||||
console.log('Roles:', orgsAndRoles.roles);
|
|
||||||
|
|
||||||
// Create a new organization
|
|
||||||
const result = await idpClient.createOrganization(
|
|
||||||
'My Company', // name
|
|
||||||
'my-company', // slug
|
|
||||||
'manifest' // mode: 'checkAvailability' or 'manifest'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.resultingOrganization) {
|
|
||||||
console.log('Created:', result.resultingOrganization.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get organization details
|
|
||||||
const orgDetails = await idpClient.requests.getOrganizationById.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
organizationId: 'org-id',
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Member & Invitation Management
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get organization members
|
|
||||||
const members = await idpClient.requests.getOrgMembers.fire({
|
const members = await idpClient.requests.getOrgMembers.fire({
|
||||||
jwt: await idpClient.getJwt(),
|
jwt: await idpClient.getJwt(),
|
||||||
organizationId: 'org-id',
|
organizationId: created.resultingOrganization.id,
|
||||||
});
|
|
||||||
|
|
||||||
// Invite a new member
|
|
||||||
await idpClient.requests.createInvitation.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
organizationId: 'org-id',
|
|
||||||
email: 'newmember@example.com',
|
|
||||||
roles: ['member'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bulk invite members
|
|
||||||
await idpClient.requests.bulkCreateInvitations.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
organizationId: 'org-id',
|
|
||||||
invitations: [
|
|
||||||
{ email: 'user1@example.com', roles: ['member'] },
|
|
||||||
{ email: 'user2@example.com', roles: ['admin'] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Accept an invitation
|
|
||||||
await idpClient.requests.acceptInvitation.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
invitationToken: 'token-from-invite-email',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove a member
|
|
||||||
await idpClient.requests.removeMember.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
organizationId: 'org-id',
|
|
||||||
userId: 'user-id',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transfer ownership
|
|
||||||
await idpClient.requests.transferOwnership.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
organizationId: 'org-id',
|
|
||||||
newOwnerId: 'new-owner-user-id',
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Password Management
|
### Cross-App Transfer
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
// Request password reset
|
|
||||||
await idpClient.requests.resetPassword.fire({
|
|
||||||
email: 'user@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set new password (with token from email)
|
|
||||||
await idpClient.requests.setNewPassword.fire({
|
|
||||||
email: 'user@example.com',
|
|
||||||
tokenArg: 'reset-token',
|
|
||||||
newPassword: 'newsecurepassword',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change password (when logged in)
|
|
||||||
await idpClient.requests.setNewPassword.fire({
|
|
||||||
email: 'user@example.com',
|
|
||||||
oldPassword: 'currentpassword',
|
|
||||||
newPassword: 'newsecurepassword',
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Session & Device Management
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get active sessions
|
|
||||||
const sessions = await idpClient.requests.getUserSessions.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
userId: jwtData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Revoke a session
|
|
||||||
await idpClient.requests.revokeSession.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
sessionId: 'session-id',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get device ID
|
|
||||||
const deviceInfo = await idpClient.requests.obtainDeviceId.fire({});
|
|
||||||
|
|
||||||
// Attach device to session
|
|
||||||
await idpClient.requests.attachDeviceId.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
deviceId: deviceInfo.deviceId.id,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cross-Domain Authentication
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get transfer token for SSO between apps
|
|
||||||
const transferToken = await idpClient.getTransferToken();
|
const transferToken = await idpClient.getTransferToken();
|
||||||
|
|
||||||
// Switch to another app with authentication
|
|
||||||
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
|
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
|
||||||
|
|
||||||
// Process incoming transfer token (in target app)
|
|
||||||
const success = await idpClient.processTransferToken();
|
|
||||||
if (success) {
|
|
||||||
console.log('Cross-domain login successful');
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Billing Integration
|
## Typed Request Surface
|
||||||
|
|
||||||
```typescript
|
`IdpRequests` exposes typed request getters for:
|
||||||
// Get billing plan for an organization
|
|
||||||
const billingPlan = await idpClient.requests.getBillingPlan.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
organizationId: 'org-id',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get Paddle configuration
|
- authentication
|
||||||
const paddleConfig = await idpClient.requests.getPaddleConfig.fire({
|
- registration
|
||||||
jwt: await idpClient.getJwt(),
|
- user/session queries
|
||||||
});
|
- org and invitation management
|
||||||
|
- billing requests
|
||||||
|
- JWT validation key requests
|
||||||
|
- admin requests
|
||||||
|
|
||||||
// Update payment method
|
Use these when you want full control instead of the higher-level helper methods on `IdpClient`.
|
||||||
await idpClient.updatePaddleCheckoutId('org-id', 'checkout-id');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin Operations (Global Admins Only)
|
## Important Runtime Notes
|
||||||
|
|
||||||
```typescript
|
- The default fallback `appData` uses `window.location`, so this package is primarily browser-oriented.
|
||||||
// Check if user is global admin
|
- The client expects the backend `typedrequest` websocket surface to be reachable.
|
||||||
const isAdmin = await idpClient.requests.checkGlobalAdmin.fire({
|
- Auth state is persisted in browser storage under the `idpglobalStore` store name.
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get platform statistics
|
|
||||||
const stats = await idpClient.requests.getGlobalAppStats.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a global app
|
|
||||||
await idpClient.requests.createGlobalApp.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
name: 'My App',
|
|
||||||
description: 'App description',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Suspend a user
|
|
||||||
await idpClient.requests.suspendUser.fire({
|
|
||||||
jwt: await idpClient.getJwt(),
|
|
||||||
userId: 'user-id',
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reactive Subscriptions
|
|
||||||
|
|
||||||
The client provides RxJS subjects for reactive updates:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Subscribe to login status changes
|
|
||||||
idpClient.statusObservable.subscribe((status) => {
|
|
||||||
console.log('Login status changed:', status);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to roles updates
|
|
||||||
idpClient.rolesReplaySubject.subscribe((roles) => {
|
|
||||||
console.log('Roles updated:', roles);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to organizations updates
|
|
||||||
idpClient.organizationsReplaySubject.subscribe((orgs) => {
|
|
||||||
console.log('Organizations updated:', orgs);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### IdpClient Class
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `enableTypedSocket()` | Initialize WebSocket connection |
|
|
||||||
| `determineLoginStatus(requireLogin?)` | Check if user is logged in |
|
|
||||||
| `getJwt()` | Get stored JWT string |
|
|
||||||
| `getJwtData()` | Get parsed JWT data |
|
|
||||||
| `setJwt(jwt)` | Store JWT |
|
|
||||||
| `deleteJwt()` | Remove stored JWT |
|
|
||||||
| `refreshJwt(refreshToken?)` | Refresh the JWT |
|
|
||||||
| `performJwtHousekeeping()` | Auto-refresh JWT if needed |
|
|
||||||
| `logout()` | End session and redirect |
|
|
||||||
| `whoIs()` | Get current user info |
|
|
||||||
| `getRolesAndOrganizations()` | Get user's orgs and roles |
|
|
||||||
| `createOrganization(name, slug, mode)` | Create new organization |
|
|
||||||
| `getTransferToken(appData?)` | Get SSO transfer token |
|
|
||||||
| `processTransferToken()` | Process incoming transfer token |
|
|
||||||
| `stop()` | Close WebSocket connection |
|
|
||||||
|
|
||||||
### IdpRequests Class
|
|
||||||
|
|
||||||
Access via `idpClient.requests.*`:
|
|
||||||
|
|
||||||
**Authentication**: `loginWithUserNameAndPassword`, `loginWithEmail`, `loginWithEmailAfterToken`, `loginWithApiToken`, `resetPassword`, `setNewPassword`
|
|
||||||
|
|
||||||
**User**: `getUserData`, `setUserData`, `getUserSessions`, `revokeSession`, `getUserActivity`
|
|
||||||
|
|
||||||
**Organization**: `getOrganizationById`, `updateOrganization`, `createInvitation`, `bulkCreateInvitations`, `getOrgMembers`, `getOrgInvitations`, `acceptInvitation`, `cancelInvitation`, `resendInvitation`, `removeMember`, `updateMemberRoles`, `transferOwnership`
|
|
||||||
|
|
||||||
**Billing**: `getBillingPlan`, `getPaddleConfig`
|
|
||||||
|
|
||||||
**Admin**: `checkGlobalAdmin`, `getGlobalAppStats`, `createGlobalApp`, `updateGlobalApp`, `deleteGlobalApp`, `suspendUser`, `deleteSuspendedUser`
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**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.
|
||||||
|
|
||||||
@@ -381,7 +148,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or 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.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface IAbuseWindow {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
action: string;
|
||||||
|
identifierHash: string;
|
||||||
|
attemptCount: number;
|
||||||
|
windowStartedAt: number;
|
||||||
|
blockedUntil: number;
|
||||||
|
validUntil: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { TAppType } from './loint-reception.app.js';
|
import type { TAppType } from './app.js';
|
||||||
|
|
||||||
export type TAppConnectionStatus = 'active' | 'disconnected';
|
export type TAppConnectionStatus = 'active' | 'disconnected';
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export type TSupportedCurrency = 'EUR';
|
export type TSupportedCurrency = 'EUR';
|
||||||
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IDevice extends plugins.tsclass.network.IDevice {}
|
export interface IDevice extends plugins.tsclass.network.IDevice {}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-13
@@ -1,13 +1,16 @@
|
|||||||
export * from './loint-reception.activity.js';
|
export * from './abusewindow.js';
|
||||||
export * from './loint-reception.app.js';
|
export * from './activity.js';
|
||||||
export * from './loint-reception.oidc.js';
|
export * from './app.js';
|
||||||
export * from './loint-reception.appconnection.js';
|
export * from './emailactiontoken.js';
|
||||||
export * from './loint-reception.billingplan.js';
|
export * from './oidc.js';
|
||||||
export * from './loint-reception.device.js';
|
export * from './appconnection.js';
|
||||||
export * from './loint-reception.jwt.js';
|
export * from './billingplan.js';
|
||||||
export * from './loint-reception.loginsession.js';
|
export * from './device.js';
|
||||||
export * from './loint-reception.organization.js';
|
export * from './jwt.js';
|
||||||
export * from './loint-reception.paddlecheckoutdata.js';
|
export * from './loginsession.js';
|
||||||
export * from './loint-reception.role.js';
|
export * from './organization.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './paddlecheckoutdata.js';
|
||||||
export * from './loint-reception.userinvitation.js';
|
export * from './registrationsession.js';
|
||||||
|
export * from './role.js';
|
||||||
|
export * from './user.js';
|
||||||
|
export * from './userinvitation.js';
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IBillingPlan } from './loint-reception.billingplan.js';
|
|
||||||
import { type IRole } from './loint-reception.role.js';
|
|
||||||
|
|
||||||
export interface IOrganization {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
billingPlanId: string;
|
|
||||||
roleIds: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -11,86 +11,94 @@ export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'rol
|
|||||||
* Authorization code for OAuth 2.0 authorization code flow
|
* Authorization code for OAuth 2.0 authorization code flow
|
||||||
*/
|
*/
|
||||||
export interface IAuthorizationCode {
|
export interface IAuthorizationCode {
|
||||||
/** The authorization code string */
|
id: string;
|
||||||
code: string;
|
data: {
|
||||||
/** OAuth client ID */
|
/** Hashed authorization code string */
|
||||||
clientId: string;
|
codeHash: string;
|
||||||
/** User ID who authorized */
|
/** OAuth client ID */
|
||||||
userId: string;
|
clientId: string;
|
||||||
/** Scopes granted */
|
/** User ID who authorized */
|
||||||
scopes: TOidcScope[];
|
userId: string;
|
||||||
/** Redirect URI used in authorization request */
|
/** Scopes granted */
|
||||||
redirectUri: string;
|
scopes: TOidcScope[];
|
||||||
/** PKCE code challenge (S256 hashed) */
|
/** Redirect URI used in authorization request */
|
||||||
codeChallenge?: string;
|
redirectUri: string;
|
||||||
/** PKCE code challenge method */
|
/** PKCE code challenge (S256 hashed) */
|
||||||
codeChallengeMethod?: 'S256';
|
codeChallenge?: string;
|
||||||
/** Nonce from authorization request (for ID token) */
|
/** PKCE code challenge method */
|
||||||
nonce?: string;
|
codeChallengeMethod?: 'S256';
|
||||||
/** Expiration timestamp (10 minutes from creation) */
|
/** Nonce from authorization request (for ID token) */
|
||||||
expiresAt: number;
|
nonce?: string;
|
||||||
/** Whether the code has been used (single-use) */
|
/** Expiration timestamp (10 minutes from creation) */
|
||||||
used: boolean;
|
expiresAt: number;
|
||||||
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
/** Whether the code has been used (single-use) */
|
||||||
|
used: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC Access Token (opaque or JWT)
|
* OIDC Access Token (opaque or JWT)
|
||||||
*/
|
*/
|
||||||
export interface IOidcAccessToken {
|
export interface IOidcAccessToken {
|
||||||
/** Token identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The access token string (or hash for storage) */
|
data: {
|
||||||
tokenHash: string;
|
/** The access token string hash for storage */
|
||||||
/** OAuth client ID */
|
tokenHash: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** User ID */
|
clientId: string;
|
||||||
userId: string;
|
/** User ID */
|
||||||
/** Granted scopes */
|
userId: string;
|
||||||
scopes: TOidcScope[];
|
/** Granted scopes */
|
||||||
/** Expiration timestamp */
|
scopes: TOidcScope[];
|
||||||
expiresAt: number;
|
/** Expiration timestamp */
|
||||||
/** Creation timestamp */
|
expiresAt: number;
|
||||||
issuedAt: number;
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC Refresh Token
|
* OIDC Refresh Token
|
||||||
*/
|
*/
|
||||||
export interface IOidcRefreshToken {
|
export interface IOidcRefreshToken {
|
||||||
/** Token identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The refresh token string (or hash for storage) */
|
data: {
|
||||||
tokenHash: string;
|
/** The refresh token string hash for storage */
|
||||||
/** OAuth client ID */
|
tokenHash: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** User ID */
|
clientId: string;
|
||||||
userId: string;
|
/** User ID */
|
||||||
/** Granted scopes */
|
userId: string;
|
||||||
scopes: TOidcScope[];
|
/** Granted scopes */
|
||||||
/** Expiration timestamp */
|
scopes: TOidcScope[];
|
||||||
expiresAt: number;
|
/** Expiration timestamp */
|
||||||
/** Creation timestamp */
|
expiresAt: number;
|
||||||
issuedAt: number;
|
/** Creation timestamp */
|
||||||
/** Whether the token has been revoked */
|
issuedAt: number;
|
||||||
revoked: boolean;
|
/** Whether the token has been revoked */
|
||||||
|
revoked: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User consent record for an OAuth client
|
* User consent record for an OAuth client
|
||||||
*/
|
*/
|
||||||
export interface IUserConsent {
|
export interface IUserConsent {
|
||||||
/** Unique identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** User who gave consent */
|
data: {
|
||||||
userId: string;
|
/** User who gave consent */
|
||||||
/** OAuth client ID */
|
userId: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** Scopes the user consented to */
|
clientId: string;
|
||||||
scopes: TOidcScope[];
|
/** Scopes the user consented to */
|
||||||
/** When consent was granted */
|
scopes: TOidcScope[];
|
||||||
grantedAt: number;
|
/** When consent was granted */
|
||||||
/** When consent was last updated */
|
grantedAt: number;
|
||||||
updatedAt: number;
|
/** When consent was last updated */
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { type IBillingPlan } from './billingplan.js';
|
||||||
|
import { type IRole } from './role.js';
|
||||||
|
|
||||||
|
export interface IOrganization {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
billingPlanId: string;
|
||||||
|
roleIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { type IRole } from './loint-reception.role.js';
|
import { type IRole } from './role.js';
|
||||||
|
|
||||||
export interface ISubOrgProperty {
|
export interface ISubOrgProperty {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/** Standard role types available in all organizations */
|
/** Standard role types available in all organizations */
|
||||||
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { type IRole } from './loint-reception.role.js';
|
import { type IRole } from './role.js';
|
||||||
|
|
||||||
export interface IUser {
|
export interface IUser {
|
||||||
id: string;
|
id: string;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A UserInvitation represents an invitation to join an organization.
|
* A UserInvitation represents an invitation to join an organization.
|
||||||
+76
-277
@@ -1,315 +1,114 @@
|
|||||||
# @idp.global/interfaces
|
# @idp.global/interfaces
|
||||||
|
|
||||||
TypeScript interfaces and type definitions for the idp.global Identity Provider platform.
|
Shared TypeScript contracts for the `idp.global` backend, browser client, CLI, and frontend.
|
||||||
|
|
||||||
## Overview
|
Use this package when you want typed request/response payloads and shared data models for users, sessions, organizations, apps, billing, and OIDC.
|
||||||
|
|
||||||
This package provides the complete type system for idp.global, including data models, API request/response interfaces, and OIDC definitions. Use this package when building applications that integrate with idp.global or when you need type-safe interactions with the IdP API.
|
## Issue Reporting and Security
|
||||||
|
|
||||||
## Installation
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @idp.global/interfaces
|
|
||||||
# or
|
|
||||||
pnpm add @idp.global/interfaces
|
pnpm add @idp.global/interfaces
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
import { data, request, tags } from '@idp.global/interfaces';
|
import { data, request, tags } from '@idp.global/interfaces';
|
||||||
|
|
||||||
// Data interfaces
|
const loginRequest: request.IReq_LoginWithEmailOrUsernameAndPassword['request'] = {
|
||||||
const user: data.IUser = {
|
username: 'user@example.com',
|
||||||
id: 'user_123',
|
password: 'secret',
|
||||||
data: {
|
|
||||||
name: 'John Doe',
|
|
||||||
username: 'johndoe',
|
|
||||||
email: 'john@example.com',
|
|
||||||
status: 'active',
|
|
||||||
connectedOrgs: ['org_1', 'org_2'],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Organization interface
|
const organization: data.IOrganization = {
|
||||||
const org: data.IOrganization = {
|
|
||||||
id: 'org_1',
|
id: 'org_1',
|
||||||
data: {
|
data: {
|
||||||
name: 'Acme Corp',
|
name: 'Acme',
|
||||||
slug: 'acme',
|
slug: 'acme',
|
||||||
billingPlanId: 'plan_free',
|
billingPlanId: 'plan_free',
|
||||||
roleIds: ['role_admin', 'role_member'],
|
roleIds: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## Package Structure
|
## Exports
|
||||||
|
|
||||||
```
|
### `data`
|
||||||
ts_interfaces/
|
|
||||||
├── data/ # Data model interfaces
|
The `data` export includes types for:
|
||||||
│ ├── loint-reception.user.ts # User profiles
|
|
||||||
│ ├── loint-reception.organization.ts # Organizations
|
- users
|
||||||
│ ├── loint-reception.role.ts # RBAC roles
|
- organizations
|
||||||
│ ├── loint-reception.app.ts # OAuth applications
|
- roles
|
||||||
│ ├── loint-reception.oidc.ts # OIDC tokens & flows
|
- JWT payloads
|
||||||
│ ├── loint-reception.jwt.ts # JWT structures
|
- login sessions
|
||||||
│ ├── loint-reception.loginsession.ts # Login sessions
|
- devices
|
||||||
│ ├── loint-reception.billingplan.ts # Billing plans
|
- activity logs
|
||||||
│ ├── loint-reception.device.ts # Device management
|
- apps and app connections
|
||||||
│ ├── loint-reception.activity.ts # Activity logs
|
- billing plans and Paddle checkout data
|
||||||
│ ├── loint-reception.userinvitation.ts # Invitations
|
- OIDC data structures
|
||||||
│ └── loint-reception.appconnection.ts # App connections
|
- invitations
|
||||||
├── request/ # API request/response interfaces
|
|
||||||
│ ├── loint-reception.login.ts # Authentication
|
### `request`
|
||||||
│ ├── loint-reception.registration.ts # User registration
|
|
||||||
│ ├── loint-reception.user.ts # User management
|
The `request` export includes typed request contracts for:
|
||||||
│ ├── loint-reception.organization.ts # Org management
|
|
||||||
│ ├── loint-reception.jwt.ts # JWT operations
|
- login, logout, refresh, password reset, and device attachment
|
||||||
│ ├── loint-reception.apitoken.ts # API tokens
|
- registration flow requests
|
||||||
│ ├── loint-reception.app.ts # App management
|
- user and session queries
|
||||||
│ ├── loint-reception.billingplan.ts # Billing
|
- organization CRUD-style requests
|
||||||
│ └── loint-reception.admin.ts # Admin operations
|
- invitations and membership changes
|
||||||
└── tags/ # Tag definitions
|
- 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',
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Interfaces
|
### Session Contract
|
||||||
|
|
||||||
### User (`IUser`)
|
```ts
|
||||||
|
type TSessions = request.IReq_GetUserSessions['response']['sessions'];
|
||||||
```typescript
|
|
||||||
interface IUser {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
mobileNumber?: string;
|
|
||||||
password?: string; // Only during initial setting
|
|
||||||
passwordHash?: string; // For validation
|
|
||||||
status: 'new' | 'active' | 'deleted' | 'suspended';
|
|
||||||
connectedOrgs: string[]; // Organization IDs
|
|
||||||
isGlobalAdmin?: boolean; // Platform admin flag
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Organization (`IOrganization`)
|
### OIDC Contract
|
||||||
|
|
||||||
```typescript
|
```ts
|
||||||
interface IOrganization {
|
type TUserInfo = data.IUserInfoResponse;
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
billingPlanId: string;
|
|
||||||
roleIds: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Role (`IRole`)
|
## Scope
|
||||||
|
|
||||||
```typescript
|
This package is intentionally contract-only. It does not open sockets, store auth state, or perform HTTP/websocket communication by itself.
|
||||||
interface IRole {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
organizationId: string;
|
|
||||||
userId: string;
|
|
||||||
permissions: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth Application Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Global platform apps (maintained by platform admins)
|
|
||||||
interface IGlobalApp {
|
|
||||||
id: string;
|
|
||||||
type: 'globalApp';
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
iconBase64?: string;
|
|
||||||
oauthCredentials?: IOAuthCredentials;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partner apps (third-party integrations)
|
|
||||||
interface IPartnerApp {
|
|
||||||
id: string;
|
|
||||||
type: 'partnerApp';
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
ownerOrganizationId: string;
|
|
||||||
oauthCredentials?: IOAuthCredentials;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom OIDC clients
|
|
||||||
interface ICustomOidcApp {
|
|
||||||
id: string;
|
|
||||||
type: 'customOidcApp';
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
ownerOrganizationId: string;
|
|
||||||
oauthCredentials: IOAuthCredentials;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth Credentials
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IOAuthCredentials {
|
|
||||||
clientId: string;
|
|
||||||
clientSecretHash: string;
|
|
||||||
redirectUris: string[];
|
|
||||||
scopes: string[];
|
|
||||||
grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## OIDC Interfaces
|
|
||||||
|
|
||||||
### Authorization Code
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IAuthorizationCode {
|
|
||||||
code: string;
|
|
||||||
clientId: string;
|
|
||||||
userId: string;
|
|
||||||
scopes: string[];
|
|
||||||
redirectUri: string;
|
|
||||||
codeChallenge?: string;
|
|
||||||
codeChallengeMethod?: 'S256';
|
|
||||||
expiresAt: number;
|
|
||||||
used: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token Response
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ITokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
token_type: 'Bearer';
|
|
||||||
expires_in: number;
|
|
||||||
refresh_token?: string;
|
|
||||||
id_token?: string;
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UserInfo Response
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IUserInfoResponse {
|
|
||||||
sub: string;
|
|
||||||
name?: string;
|
|
||||||
preferred_username?: string;
|
|
||||||
email?: string;
|
|
||||||
email_verified?: boolean;
|
|
||||||
organizations?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
roles: string[];
|
|
||||||
}>;
|
|
||||||
roles?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ID Token Claims
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IIdTokenClaims {
|
|
||||||
iss: string; // Issuer
|
|
||||||
sub: string; // Subject (user ID)
|
|
||||||
aud: string; // Audience (client ID)
|
|
||||||
exp: number; // Expiration time
|
|
||||||
iat: number; // Issued at
|
|
||||||
nonce?: string; // Replay protection
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
email_verified?: boolean;
|
|
||||||
organizations?: Array<{...}>;
|
|
||||||
roles?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Request Interfaces
|
|
||||||
|
|
||||||
All API requests follow the TypedRequest pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IReq_LoginWithEmailOrUsernameAndPassword {
|
|
||||||
method: 'loginWithEmailOrUsernameAndPassword';
|
|
||||||
request: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
refreshToken?: string;
|
|
||||||
twoFaNeeded: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authentication Requests
|
|
||||||
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_LoginWithEmailOrUsernameAndPassword` | `loginWithEmailOrUsernameAndPassword` | Password login |
|
|
||||||
| `IReq_LoginWithEmail` | `loginWithEmail` | Magic link request |
|
|
||||||
| `IReq_LoginWithEmailAfterEmailTokenAquired` | `loginWithEmailAfterEmailTokenAquired` | Magic link verification |
|
|
||||||
| `IReq_LoginWithApiToken` | `loginWithApiToken` | API token login |
|
|
||||||
| `IReq_RefreshJwt` | `refreshJwt` | Refresh access token |
|
|
||||||
| `ILogoutRequest` | `logout` | End session |
|
|
||||||
|
|
||||||
### User Management Requests
|
|
||||||
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetUserData` | `getUserData` | Get current user |
|
|
||||||
| `IReq_SetUserData` | `setUserData` | Update user profile |
|
|
||||||
| `IReq_GetUserSessions` | `getUserSessions` | List active sessions |
|
|
||||||
| `IReq_ResetPassword` | `resetPassword` | Request password reset |
|
|
||||||
| `IReq_SetNewPassword` | `setNewPassword` | Set new password |
|
|
||||||
|
|
||||||
### Organization Requests
|
|
||||||
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_CreateOrganization` | `createOrganization` | Create new org |
|
|
||||||
| `IReq_GetOrgMembers` | `getOrgMembers` | List org members |
|
|
||||||
| `IReq_CreateInvitation` | `createInvitation` | Invite user |
|
|
||||||
| `IReq_AcceptInvitation` | `acceptInvitation` | Accept invite |
|
|
||||||
|
|
||||||
### JWT Operations
|
|
||||||
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetPublicKeyForValidation` | `getPublicKeyForValidation` | Get JWT public key |
|
|
||||||
| `IReq_GetJwtIdBlocklist` | `getJwtIdBlocklist` | Get revoked token IDs |
|
|
||||||
|
|
||||||
## Supported OIDC Scopes
|
|
||||||
|
|
||||||
| Scope | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `openid` | Required for OIDC flows |
|
|
||||||
| `profile` | User's name and username |
|
|
||||||
| `email` | User's email address |
|
|
||||||
| `organizations` | User's organization memberships |
|
|
||||||
| `roles` | User's roles within organizations |
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**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.
|
||||||
|
|
||||||
@@ -321,7 +120,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or 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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
// Get all global apps
|
// Get all global apps
|
||||||
export interface IReq_GetGlobalApps
|
export interface IReq_GetGlobalApps
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { type IUser, type IRole } from '../data/index.js';
|
||||||
|
import { type TOidcScope } from '../data/index.js';
|
||||||
|
|
||||||
|
export interface IReq_InternalAuthorization
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_InternalAuthorization
|
||||||
|
> {
|
||||||
|
method: '';
|
||||||
|
request: {
|
||||||
|
accountData: IUser;
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
accountData: IUser;
|
||||||
|
jwt: string;
|
||||||
|
relevantRoles: IRole[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CompleteOidcAuthorization
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CompleteOidcAuthorization
|
||||||
|
> {
|
||||||
|
method: 'completeOidcAuthorization';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state: string;
|
||||||
|
prompt?: 'none' | 'login' | 'consent';
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: 'S256';
|
||||||
|
nonce?: string;
|
||||||
|
consentApproved?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
code: string;
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_PrepareOidcAuthorization
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_PrepareOidcAuthorization
|
||||||
|
> {
|
||||||
|
method: 'prepareOidcAuthorization';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state: string;
|
||||||
|
prompt?: 'none' | 'login' | 'consent';
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: 'S256';
|
||||||
|
nonce?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
status: 'ready' | 'consent_required';
|
||||||
|
clientId: string;
|
||||||
|
appName: string;
|
||||||
|
appUrl: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
requestedScopes: TOidcScope[];
|
||||||
|
grantedScopes: TOidcScope[];
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
export interface IReq_UpdatePaymentMethod
|
export interface IReq_UpdatePaymentMethod
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
export * from './loint-reception.admin.js';
|
export * from './admin.js';
|
||||||
export * from './loint-reception.apitoken.js';
|
export * from './apitoken.js';
|
||||||
export * from './loint-reception.app.js';
|
export * from './app.js';
|
||||||
export * from './loint-reception.authorization.js';
|
export * from './authorization.js';
|
||||||
export * from './loint-reception.billingplan.js';
|
export * from './billingplan.js';
|
||||||
export * from './loint-reception.jwt.js';
|
export * from './jwt.js';
|
||||||
export * from './loint-reception.login.js';
|
export * from './login.js';
|
||||||
export * from './loint-reception.organization.js';
|
export * from './organization.js';
|
||||||
export * from './loint-reception.plan.js';
|
export * from './plan.js';
|
||||||
export * from './loint-reception.registration.js';
|
export * from './registration.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './user.js';
|
||||||
export * from './loint-reception.userinvitation.js';
|
export * from './userinvitation.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request to get the public key for JWT validation.
|
* Request to get the public key for JWT validation.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
export interface IReq_LoginWithEmailOrUsernameAndPassword
|
export interface IReq_LoginWithEmailOrUsernameAndPassword
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IUser, type IRole } from '../data/index.js';
|
|
||||||
|
|
||||||
export interface IReq_InternalAuthorization
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_InternalAuthorization
|
|
||||||
> {
|
|
||||||
method: '';
|
|
||||||
request: {
|
|
||||||
accountData: IUser;
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
accountData: IUser;
|
|
||||||
jwt: string;
|
|
||||||
relevantRoles: IRole[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IReq_GetOrganizationById
|
export interface IReq_GetOrganizationById
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IReq_GetPlansForOrganizationId
|
export interface IReq_GetPlansForOrganizationId
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { type IUser } from '../data/index.js';
|
import { type IUser } from '../data/index.js';
|
||||||
|
|
||||||
export interface IReq_FirstRegistration
|
export interface IReq_FirstRegistration
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IReq_GetUserData
|
export interface IReq_GetUserData
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an invitation to join an organization
|
* Create an invitation to join an organization
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface ITag_LolePubapi
|
export interface ITag_LolePubapi
|
||||||
extends plugins.typedRequestInterfaces.implementsTag<
|
extends plugins.typedRequestInterfaces.implementsTag<
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.17.0',
|
version: '1.20.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
domtools,
|
domtools,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
// third party catalogs
|
|
||||||
import '@uptime.link/webwidget';
|
import '@uptime.link/webwidget';
|
||||||
|
|
||||||
import '@design.estate/dees-catalog';
|
import '@design.estate/dees-catalog';
|
||||||
@@ -29,6 +28,12 @@ declare global {
|
|||||||
export class IdpLoginPrompt extends DeesElement {
|
export class IdpLoginPrompt extends DeesElement {
|
||||||
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor oidcConsentError = '';
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
accessor productOfInterest: string;
|
accessor productOfInterest: string;
|
||||||
|
|
||||||
@@ -48,6 +53,155 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
domtools.elementBasic.setup();
|
domtools.elementBasic.setup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getOidcAuthorizationContext(): Omit<
|
||||||
|
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||||
|
'jwt'
|
||||||
|
> | null {
|
||||||
|
const currentUrl = plugins.smarturl.Smarturl.createFromUrl(window.location.href);
|
||||||
|
|
||||||
|
if (currentUrl.searchParams.oauth !== 'true') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = currentUrl.searchParams.client_id;
|
||||||
|
const redirectUri = currentUrl.searchParams.redirect_uri;
|
||||||
|
const scope = currentUrl.searchParams.scope;
|
||||||
|
const state = currentUrl.searchParams.state;
|
||||||
|
|
||||||
|
if (!clientId || !redirectUri || !scope || !state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = ['none', 'login', 'consent'].includes(currentUrl.searchParams.prompt)
|
||||||
|
? (currentUrl.searchParams.prompt as 'none' | 'login' | 'consent')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
prompt,
|
||||||
|
codeChallenge: currentUrl.searchParams.code_challenge || undefined,
|
||||||
|
codeChallengeMethod:
|
||||||
|
currentUrl.searchParams.code_challenge_method === 'S256' ? 'S256' : undefined,
|
||||||
|
nonce: currentUrl.searchParams.nonce || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private redirectOidcError(errorArg: string, descriptionArg?: string) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = new URL(oidcContext.redirectUri);
|
||||||
|
redirectUrl.searchParams.set('error', errorArg);
|
||||||
|
redirectUrl.searchParams.set('state', oidcContext.state);
|
||||||
|
if (descriptionArg) {
|
||||||
|
redirectUrl.searchParams.set('error_description', descriptionArg);
|
||||||
|
}
|
||||||
|
window.location.href = redirectUrl.toString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOidcScopeDescription(scopeArg: plugins.idpInterfaces.data.TOidcScope) {
|
||||||
|
const scopeMap: Record<plugins.idpInterfaces.data.TOidcScope, string> = {
|
||||||
|
openid: 'Confirm your identity with this app.',
|
||||||
|
profile: 'Share your display name and username.',
|
||||||
|
email: 'Share your email address.',
|
||||||
|
organizations: 'Share your organizations and their roles.',
|
||||||
|
roles: 'Share your platform roles.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return scopeMap[scopeArg];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOidcAppHost(appUrlArg: string) {
|
||||||
|
try {
|
||||||
|
return new URL(appUrlArg).hostname;
|
||||||
|
} catch {
|
||||||
|
return appUrlArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareOidcAuthorization(jwtArg: string) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
return idpState.idpClient.requests.prepareOidcAuthorization
|
||||||
|
.fire({
|
||||||
|
jwt: jwtArg,
|
||||||
|
...oidcContext,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOidcAfterLogin(jwtArg: string) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
|
||||||
|
loginForm?.setStatus('pending', 'preparing application authorization...');
|
||||||
|
this.oidcConsentError = '';
|
||||||
|
|
||||||
|
const preparation = await this.prepareOidcAuthorization(jwtArg);
|
||||||
|
if (!preparation) {
|
||||||
|
loginForm?.setStatus('error', 'could not prepare the application authorization');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preparation.status === 'consent_required') {
|
||||||
|
if (oidcContext.prompt === 'none') {
|
||||||
|
this.redirectOidcError('consent_required');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.oidcConsentState = preparation;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.completeOidcAuthorization(jwtArg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeOidcAuthorization(jwtArg: string, consentApproved = false) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
|
||||||
|
loginForm?.setStatus('pending', 'authorizing application...');
|
||||||
|
this.oidcConsentError = '';
|
||||||
|
|
||||||
|
const response = await idpState.idpClient.requests.completeOidcAuthorization
|
||||||
|
.fire({
|
||||||
|
jwt: jwtArg,
|
||||||
|
...oidcContext,
|
||||||
|
consentApproved,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!response?.redirectUrl) {
|
||||||
|
if (this.oidcConsentState) {
|
||||||
|
this.oidcConsentError = 'Could not authorize the application.';
|
||||||
|
} else {
|
||||||
|
loginForm?.setStatus('error', 'could not authorize the application');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = response.redirectUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
@@ -103,10 +257,147 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
.form-footer a:hover {
|
.form-footer a:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.consent-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-appname {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-appurl {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scopes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope-tag {
|
||||||
|
color: #9cd67c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope-description {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-button-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-button-primary {
|
||||||
|
background: linear-gradient(135deg, #9b7bff, #5fd1ff);
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-error {
|
||||||
|
color: #ff9a9a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
|
if (this.oidcConsentState) {
|
||||||
|
return html`
|
||||||
|
<idp-centercontainer>
|
||||||
|
<div class="form-header">
|
||||||
|
<h2>Continue to ${this.oidcConsentState.appName}</h2>
|
||||||
|
<p>Review and approve the access this app is requesting.</p>
|
||||||
|
</div>
|
||||||
|
<div class="consent-card">
|
||||||
|
<div class="consent-appname">${this.oidcConsentState.appName}</div>
|
||||||
|
<div class="consent-appurl">${this.getOidcAppHost(this.oidcConsentState.appUrl)}</div>
|
||||||
|
<div class="consent-scopes">
|
||||||
|
${this.oidcConsentState.requestedScopes.map((scopeArg) => html`
|
||||||
|
<div class="consent-scope">
|
||||||
|
<div class="consent-scope-header">
|
||||||
|
<span>${scopeArg}</span>
|
||||||
|
${this.oidcConsentState.grantedScopes.includes(scopeArg)
|
||||||
|
? html`<span class="consent-scope-tag">Previously allowed</span>`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div class="consent-scope-description">${this.getOidcScopeDescription(scopeArg)}</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
${this.oidcConsentError ? html`<div class="consent-error">${this.oidcConsentError}</div>` : null}
|
||||||
|
<div class="consent-actions">
|
||||||
|
<button
|
||||||
|
class="consent-button consent-button-secondary"
|
||||||
|
@click=${() => {
|
||||||
|
this.redirectOidcError('access_denied');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="consent-button consent-button-primary"
|
||||||
|
@click=${async () => {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
if (!jwt) {
|
||||||
|
this.redirectOidcError('login_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.completeOidcAuthorization(jwt, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Allow and continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</idp-centercontainer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<idp-centercontainer>
|
<idp-centercontainer>
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
@@ -115,12 +406,12 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
</div>
|
</div>
|
||||||
<dees-form
|
<dees-form
|
||||||
id="loginForm"
|
id="loginForm"
|
||||||
@formData="${(eventArg) => {
|
@formData=${(eventArg) => {
|
||||||
this.login({
|
this.login({
|
||||||
emailAddress: eventArg.detail.data.emailAddress,
|
emailAddress: eventArg.detail.data.emailAddress,
|
||||||
passwordArg: eventArg.detail.data.password,
|
passwordArg: eventArg.detail.data.password,
|
||||||
});
|
});
|
||||||
}}"
|
}}
|
||||||
>
|
>
|
||||||
<dees-input-text
|
<dees-input-text
|
||||||
id="loginEmailInput"
|
id="loginEmailInput"
|
||||||
@@ -137,7 +428,8 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
|
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
|
||||||
</dees-form>
|
</dees-form>
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
Don't have an account? <a @click=${async () => {
|
Don't have an account?
|
||||||
|
<a @click=${async () => {
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
idpState.domtools.router.pushUrl('/register');
|
idpState.domtools.router.pushUrl('/register');
|
||||||
}}>Create one</a>
|
}}>Create one</a>
|
||||||
@@ -147,32 +439,48 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async firstUpdated() {
|
public async firstUpdated() {
|
||||||
const domtoolsInstance = await this.domtoolsPromise;
|
await this.domtoolsPromise;
|
||||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const loginPasswordInput: DeesInputText = loginForm.querySelector('#loginPasswordInput');
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
|
||||||
const loginSubmitButton: DeesFormSubmit = loginForm.querySelector('#loginSubmitButton');
|
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as DeesInputText;
|
||||||
|
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as DeesFormSubmit;
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
const setButtonText = async () => {
|
const setButtonText = async () => {
|
||||||
if (loginPasswordInput.value) {
|
if (loginPasswordInput.value) {
|
||||||
console.log('updating text of loginprompt.');
|
loginSubmitButton.text = oidcContext ? 'Sign in and continue' : 'Login';
|
||||||
loginSubmitButton.text = 'Login';
|
|
||||||
} else {
|
} else {
|
||||||
loginSubmitButton.text = 'Send magic link (or enter password)';
|
loginSubmitButton.text = 'Send magic link (or enter password)';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loginForm.changeSubject.subscribe(() => {
|
loginForm.changeSubject.subscribe(() => {
|
||||||
console.log(`checking button text ${loginPasswordInput.value}`);
|
void setButtonText();
|
||||||
setButtonText();
|
|
||||||
});
|
});
|
||||||
setButtonText();
|
await setButtonText();
|
||||||
|
|
||||||
|
if (oidcContext) {
|
||||||
|
const loggedIn = await idpState.idpClient.determineLoginStatus(false);
|
||||||
|
if (!loggedIn && oidcContext.prompt === 'none') {
|
||||||
|
this.redirectOidcError('login_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loggedIn && oidcContext.prompt !== 'login') {
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
if (jwt) {
|
||||||
|
await this.handleOidcAfterLogin(jwt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
||||||
// lets disable the submit button
|
const loginSubmitButton = this.shadowRoot.querySelector(
|
||||||
const loginSubmitButton: plugins.deesCatalog.DeesFormSubmit = this.shadowRoot.querySelector('#loginSubmitButton');
|
'#loginSubmitButton'
|
||||||
|
) as plugins.deesCatalog.DeesFormSubmit;
|
||||||
loginSubmitButton.disabled = true;
|
loginSubmitButton.disabled = true;
|
||||||
// lets define the needed requests
|
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
|
||||||
const loginRequestWithUsernameAndPassword =
|
const loginRequestWithUsernameAndPassword =
|
||||||
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
'loginWithEmailOrUsernameAndPassword'
|
'loginWithEmailOrUsernameAndPassword'
|
||||||
@@ -182,19 +490,19 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
'loginWithEmail'
|
'loginWithEmail'
|
||||||
);
|
);
|
||||||
|
|
||||||
// lets do the actual logging in
|
|
||||||
if (valueArg.emailAddress && valueArg.passwordArg) {
|
if (valueArg.emailAddress && valueArg.passwordArg) {
|
||||||
loginForm.setStatus('pending', 'logging in...');
|
loginForm.setStatus('pending', 'logging in...');
|
||||||
const response = await loginRequestWithUsernameAndPassword
|
const response = await loginRequestWithUsernameAndPassword
|
||||||
.fire({
|
.fire({
|
||||||
username: valueArg.emailAddress, // TODO: rename to emailAddress
|
username: valueArg.emailAddress,
|
||||||
password: valueArg.passwordArg,
|
password: valueArg.passwordArg,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
||||||
return;
|
return null;
|
||||||
});
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
|
loginSubmitButton.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.refreshToken) {
|
if (response.refreshToken) {
|
||||||
@@ -202,11 +510,13 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
loginForm.setStatus('success', 'obtained jwt.');
|
loginForm.setStatus('success', 'obtained jwt.');
|
||||||
idpState.domtools.router.pushUrl('/account');
|
const oidcHandled = await this.handleOidcAfterLogin(jwt);
|
||||||
|
if (!oidcHandled) {
|
||||||
|
idpState.domtools.router.pushUrl('/account');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
loginForm.setStatus('error', 'something went wrong');
|
loginForm.setStatus('error', 'something went wrong');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
||||||
loginForm.setStatus('pending', 'sending magic link...');
|
loginForm.setStatus('pending', 'sending magic link...');
|
||||||
@@ -216,13 +526,13 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
if (response.status === 'ok') {
|
if (response.status === 'ok') {
|
||||||
loginForm.setStatus('success', 'Please check your email!');
|
loginForm.setStatus('success', 'Please check your email!');
|
||||||
}
|
}
|
||||||
console.log(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginSubmitButton.disabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
public async dispatchJwt(jwtArg?: string) {
|
public async dispatchJwt(jwtArg?: string) {
|
||||||
if (jwtArg !== undefined) {
|
if (jwtArg !== undefined) {
|
||||||
console.log(`dispatching jwt from loginprompt.`);
|
|
||||||
this.jwt = jwtArg;
|
this.jwt = jwtArg;
|
||||||
await domtools.plugins.smartdelay.delayFor(200);
|
await domtools.plugins.smartdelay.delayFor(200);
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
@@ -237,9 +547,7 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async focus() {
|
public async focus() {
|
||||||
(
|
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText).focus();
|
||||||
this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText
|
|
||||||
).focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async show() {
|
public async show() {
|
||||||
|
|||||||
+55
-245
@@ -1,259 +1,69 @@
|
|||||||
# @idp.global/web
|
# `ts_web/` Web App Module
|
||||||
|
|
||||||
Web Components and UI elements for the idp.global Identity Provider platform. Built with `@design.estate/dees-element` and the dees-catalog component library.
|
The `ts_web/` folder contains the frontend for `idp.global`: login, registration, account management, org management, billing, and admin UI.
|
||||||
|
|
||||||
## Overview
|
It is built with `@design.estate/dees-element`, `@design.estate/dees-domtools`, and the shared `idp.global` client and interface packages.
|
||||||
|
|
||||||
This package provides the complete web interface for idp.global, including authentication flows, account management, and organization administration. All components are built as Web Components using the Lit-based `dees-element` framework.
|
## Issue Reporting and Security
|
||||||
|
|
||||||
## Installation
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## What Lives Here
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `index.ts` | Frontend entrypoint and initial render |
|
||||||
|
| `views/viewcontainer.ts` | View switching for welcome, login, register, finishregistration, and account |
|
||||||
|
| `elements/` | Web components for prompts, layout, and account UI |
|
||||||
|
| `elements/account/views/` | Account subviews including org, apps, subscriptions, paddle setup, and admin |
|
||||||
|
| `states/` | App-level and account-level state containers |
|
||||||
|
|
||||||
|
## UI Surface
|
||||||
|
|
||||||
|
The module currently includes:
|
||||||
|
|
||||||
|
- a welcome page
|
||||||
|
- login and registration prompts
|
||||||
|
- a multi-step registration flow
|
||||||
|
- an account area with navigation
|
||||||
|
- organization selection and creation flows
|
||||||
|
- bulk member invitation UI
|
||||||
|
- app and subscription views
|
||||||
|
- a global admin view
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
`IdpViewcontainer` switches between these frontend states:
|
||||||
|
|
||||||
|
| View | Route |
|
||||||
|
| --- | --- |
|
||||||
|
| `welcome` | `/` |
|
||||||
|
| `login` | `/login` |
|
||||||
|
| `register` | `/register` |
|
||||||
|
| `finishregistration` | `/finishregistration` |
|
||||||
|
| `account` | `/account` |
|
||||||
|
|
||||||
|
## Build And Run
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @idp.global/web
|
pnpm install
|
||||||
# or
|
|
||||||
pnpm add @idp.global/web
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
ts_web/
|
|
||||||
├── index.ts # Application entry point
|
|
||||||
├── plugins.ts # Plugin imports
|
|
||||||
├── views/
|
|
||||||
│ ├── viewcontainer.ts # Main view router
|
|
||||||
│ └── index.ts
|
|
||||||
├── elements/ # Web Components
|
|
||||||
│ ├── idp-loginprompt.ts # Login form
|
|
||||||
│ ├── idp-registerprompt.ts # Registration form
|
|
||||||
│ ├── idp-registration-stepper.ts # Multi-step registration
|
|
||||||
│ ├── idp-centercontainer.ts # Centered layout container
|
|
||||||
│ ├── idp-transfermanager.ts # SSO transfer handling
|
|
||||||
│ ├── idp-welcome.ts # Welcome/landing page
|
|
||||||
│ └── account/ # Account dashboard components
|
|
||||||
│ ├── content.ts # Main account layout
|
|
||||||
│ ├── navigation.ts # Sidebar navigation
|
|
||||||
│ ├── org-select-modal.ts # Organization switcher
|
|
||||||
│ ├── create-org-modal.ts # Create organization dialog
|
|
||||||
│ ├── bulk-invite-modal.ts # Bulk member invite dialog
|
|
||||||
│ └── views/ # Account sub-views
|
|
||||||
│ ├── baseview.ts # Base view class
|
|
||||||
│ ├── usersview.ts # User profile view
|
|
||||||
│ ├── orgview.ts # Organization details
|
|
||||||
│ ├── orgsetup.ts # Organization setup
|
|
||||||
│ ├── appsview.ts # Connected apps
|
|
||||||
│ ├── adminview.ts # Global admin panel
|
|
||||||
│ ├── subscriptions.ts # Billing subscriptions
|
|
||||||
│ └── paddlesetup.ts # Payment setup
|
|
||||||
└── states/
|
|
||||||
├── idp.state.ts # Main application state
|
|
||||||
└── accountstate.ts # Account dashboard state
|
|
||||||
```
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Authentication Components
|
|
||||||
|
|
||||||
#### `<idp-loginprompt>`
|
|
||||||
|
|
||||||
Login form supporting password and magic link authentication.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<idp-loginprompt></idp-loginprompt>
|
|
||||||
```
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Email/username + password login
|
|
||||||
- Magic link (passwordless) authentication
|
|
||||||
- Automatic button text based on password presence
|
|
||||||
- Form validation and error handling
|
|
||||||
- Redirect to registration
|
|
||||||
|
|
||||||
#### `<idp-registerprompt>`
|
|
||||||
|
|
||||||
Initial registration form for new users.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<idp-registerprompt></idp-registerprompt>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `<idp-registration-stepper>`
|
|
||||||
|
|
||||||
Multi-step registration wizard for completing user profile.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<idp-registration-stepper></idp-registration-stepper>
|
|
||||||
```
|
|
||||||
|
|
||||||
Steps include:
|
|
||||||
- Profile information
|
|
||||||
- Email verification
|
|
||||||
- Mobile verification (optional)
|
|
||||||
- Password setup
|
|
||||||
|
|
||||||
### Layout Components
|
|
||||||
|
|
||||||
#### `<idp-viewcontainer>`
|
|
||||||
|
|
||||||
Main view container that handles routing between views.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<idp-viewcontainer></idp-viewcontainer>
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported views:
|
|
||||||
- `welcome` - Landing page
|
|
||||||
- `login` - Login form
|
|
||||||
- `register` - Registration form
|
|
||||||
- `finishregistration` - Registration stepper
|
|
||||||
- `account` - Account dashboard
|
|
||||||
|
|
||||||
#### `<idp-centercontainer>`
|
|
||||||
|
|
||||||
Centered container with animation support for forms.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<idp-centercontainer>
|
|
||||||
<h2>Your Content</h2>
|
|
||||||
<form>...</form>
|
|
||||||
</idp-centercontainer>
|
|
||||||
```
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `show()` - Animate container into view
|
|
||||||
- `hide()` - Animate container out of view
|
|
||||||
|
|
||||||
### Account Dashboard Components
|
|
||||||
|
|
||||||
#### `<idp-account-content>`
|
|
||||||
|
|
||||||
Main account dashboard layout with navigation.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<idp-account-content></idp-account-content>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Navigation Views
|
|
||||||
|
|
||||||
| Component | Route | Description |
|
|
||||||
|-----------|-------|-------------|
|
|
||||||
| `<idp-usersview>` | `/account/users` | User profile management |
|
|
||||||
| `<idp-orgview>` | `/account/org` | Organization details |
|
|
||||||
| `<idp-orgsetup>` | `/account/orgsetup` | Organization configuration |
|
|
||||||
| `<idp-appsview>` | `/account/apps` | Connected applications |
|
|
||||||
| `<idp-adminview>` | `/account/admin` | Global admin panel |
|
|
||||||
| `<idp-subscriptions>` | `/account/subscriptions` | Billing management |
|
|
||||||
| `<idp-paddlesetup>` | `/account/paddle` | Payment method setup |
|
|
||||||
|
|
||||||
### Modal Components
|
|
||||||
|
|
||||||
#### `<idp-org-select-modal>`
|
|
||||||
|
|
||||||
Organization switcher modal for users with multiple organizations.
|
|
||||||
|
|
||||||
#### `<idp-create-org-modal>`
|
|
||||||
|
|
||||||
Dialog for creating new organizations with slug validation.
|
|
||||||
|
|
||||||
#### `<idp-bulk-invite-modal>`
|
|
||||||
|
|
||||||
Bulk invitation dialog for inviting multiple members at once.
|
|
||||||
|
|
||||||
## State Management
|
|
||||||
|
|
||||||
### IdpState
|
|
||||||
|
|
||||||
Central application state using `@push.rocks/smartstate`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IdpState } from '@idp.global/web';
|
|
||||||
|
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
|
||||||
|
|
||||||
// Access IdP client
|
|
||||||
const isLoggedIn = await idpState.idpClient.determineLoginStatus();
|
|
||||||
|
|
||||||
// Access router
|
|
||||||
idpState.domtools.router.pushUrl('/login');
|
|
||||||
|
|
||||||
// Subscribe to view changes
|
|
||||||
idpState.mainStatePart.select(s => s.view).subscribe(view => {
|
|
||||||
console.log('Current view:', view);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### AccountState
|
|
||||||
|
|
||||||
State for the account dashboard section.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { AccountState } from '@idp.global/web';
|
|
||||||
|
|
||||||
const accountState = await AccountState.getSingletonInstance();
|
|
||||||
|
|
||||||
// Access current organization
|
|
||||||
const currentOrg = accountState.currentOrganization;
|
|
||||||
|
|
||||||
// Access user roles
|
|
||||||
const roles = accountState.userRoles;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
Components use CSS custom properties for theming:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:host {
|
|
||||||
--foreground: hsl(0 0% 98%);
|
|
||||||
--muted-foreground: hsl(240 5% 64.9%);
|
|
||||||
--background-accent: #303f9f;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All components include:
|
|
||||||
- Dark mode by default
|
|
||||||
- Geist Sans font family
|
|
||||||
- Smooth animations
|
|
||||||
- Responsive layouts
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- `@design.estate/dees-element` - Web Component base class
|
|
||||||
- `@design.estate/dees-catalog` - UI component library
|
|
||||||
- `@design.estate/dees-domtools` - DOM utilities and routing
|
|
||||||
- `@idp.global/idpclient` - IdP client library
|
|
||||||
- `@idp.global/interfaces` - TypeScript interfaces
|
|
||||||
- `@push.rocks/smartstate` - State management
|
|
||||||
- `@uptime.link/webwidget` - Status widget
|
|
||||||
|
|
||||||
## Views and Routes
|
|
||||||
|
|
||||||
| Route | View | Component |
|
|
||||||
|-------|------|-----------|
|
|
||||||
| `/` | `welcome` | `IdpWelcome` |
|
|
||||||
| `/login` | `login` | `IdpLoginPrompt` |
|
|
||||||
| `/register` | `register` | `IdpRegistrationPrompt` |
|
|
||||||
| `/finishregistration` | `finishregistration` | `IdpRegistrationStepper` |
|
|
||||||
| `/account` | `account` | `IdpAccountContent` |
|
|
||||||
| `/logout` | - | Logout handler |
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
The web module is bundled using `@git.zone/tsbundle`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development with hot reload
|
|
||||||
pnpm watch
|
|
||||||
|
|
||||||
# Production build
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
pnpm watch
|
||||||
```
|
```
|
||||||
|
|
||||||
The bundled output is served from `dist_ts_web/` by the TypedServer.
|
`pnpm watch` rebuilds the frontend bundle from `ts_web/index.ts` into `dist_serve/bundle.js` while the backend serves the app.
|
||||||
|
|
||||||
|
## 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
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**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.
|
||||||
|
|
||||||
@@ -265,7 +75,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
|||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user