feat(sdk): add initial browser and server authentication SDK exports

This commit is contained in:
2026-05-13 23:11:56 +00:00
commit cb41ec6e6f
22 changed files with 10964 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.nogit/
# installs
node_modules/
# builds
dist/
dist_*/
# caches
.cache/
.rpt2_cache
+42
View File
@@ -0,0 +1,42 @@
{
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "idp.global",
"gitrepo": "sdk",
"description": "Reusable browser and server SDK for idp.global authentication.",
"npmPackagename": "@idp.global/sdk",
"license": "MIT",
"keywords": [
"idp.global",
"sdk",
"authentication",
"oidc",
"smartdata"
]
},
"release": {
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"npm": {
"enabled": false,
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"docker": {
"enabled": false,
"engine": "tsdocker"
}
}
}
},
"@ship.zone/szci": {}
}
+13
View File
@@ -0,0 +1,13 @@
# Changelog
## Pending
- Initial SDK package with browser and server exports.
### Features
- add initial browser and server authentication SDK exports (sdk)
- introduces browser-side IdP client and typed request wrappers for authentication and organization workflows
- adds server-side account store, password hashing, and authentication services with local and idp.global auth support
- includes tests covering explicit account creation, local password authentication, and IdP email mismatch rejection
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 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.
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@idp.global/sdk",
"version": "1.0.0",
"private": false,
"description": "Reusable browser and server SDK for idp.global authentication.",
"type": "module",
"exports": {
".": "./dist_ts/index.js",
"./browser": "./dist_ts_browser/index.js",
"./server": "./dist_ts_server/index.js"
},
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"scripts": {
"test": "pnpm run build && tstest test/ --verbose --logfile --timeout 60",
"build": "tsbuild tsfolders --web --allowimplicitany",
"buildDocs": "tsdoc"
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedsocket": "^4.1.2",
"@idp.global/interfaces": "file:../interfaces",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartjson": "^6.0.1",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smarttime": "^4.2.3",
"@push.rocks/smarturl": "^3.1.0",
"@push.rocks/webjwt": "^1.0.10",
"@push.rocks/webstore": "^2.0.22"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsdoc": "^2.0.3",
"@git.zone/tsrun": "^2.0.3",
"@git.zone/tstest": "^3.6.3",
"@push.rocks/smartdb": "^2.10.0",
"@types/node": "^25.6.0"
},
"files": [
"ts/**/*",
"ts_browser/**/*",
"ts_server/**/*",
"dist/**/*",
"dist_*/**/*",
"readme.md",
"changelog.md",
"license"
],
"repository": {
"type": "git",
"url": "git+ssh://git@code.foss.global:29419/idp.global/sdk.git"
},
"bugs": {
"url": "https://code.foss.global/idp.global/sdk/issues"
},
"homepage": "https://code.foss.global/idp.global/sdk#readme",
"keywords": [
"idp.global",
"sdk",
"authentication",
"oidc",
"smartdata"
],
"browserslist": [
"last 1 Chrome versions"
],
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
}
+9699
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# @idp.global/sdk
Reusable SDK for idp.global browser clients and server-side account authentication.
Use explicit runtime exports:
- `@idp.global/sdk/browser`
- `@idp.global/sdk/server`
The server account store never creates accounts automatically. Consumers must explicitly create persisted accounts.
+109
View File
@@ -0,0 +1,109 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartdata from '@push.rocks/smartdata';
import * as smartdb from '@push.rocks/smartdb';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { AccountAuthService, SmartdataAccountStore } from '../ts_server/index.js';
const createTestStore = async () => {
const storagePath = path.join(os.tmpdir(), `idp-sdk-account-store-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const localSmartDb = new smartdb.LocalSmartDb({ folderPath: storagePath });
const connectionInfo = await localSmartDb.start();
const smartdataDb = new smartdata.SmartdataDb({
mongoDbUrl: connectionInfo.connectionUri,
mongoDbName: `idp-sdk-test-${Date.now()}`,
});
await smartdataDb.init();
const store = new SmartdataAccountStore({ smartdataDb });
await smartdataDb.mongoDb.createCollection('__test_init');
return {
store,
async cleanup() {
await smartdataDb.close();
await localSmartDb.stop();
await fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
let testStore: Awaited<ReturnType<typeof createTestStore>>;
tap.test('setup account store test db', async () => {
testStore = await createTestStore();
});
tap.test('SmartdataAccountStore only persists explicitly created accounts', async () => {
expect(await testStore.store.hasActiveAdminAccount()).toEqual(false);
expect(await testStore.store.getAccountByEmail('admin@example.com')).toBeNull();
const account = await testStore.store.createAccount({
email: 'Admin@Example.com',
name: 'Admin User',
role: 'admin',
authSources: ['local'],
password: 'correct horse battery staple',
});
expect(account.emailNormalized).toEqual('admin@example.com');
expect(await testStore.store.hasActiveAdminAccount()).toEqual(true);
expect(account.passwordHash).not.toEqual('correct horse battery staple');
});
tap.test('AccountAuthService authenticates local accounts only with valid password', async () => {
const authService = new AccountAuthService({ store: testStore.store });
expect(await authService.authenticate({
email: 'admin@example.com',
password: 'wrong',
authSource: 'local',
})).toBeNull();
const result = await authService.authenticate({
email: 'ADMIN@example.com',
password: 'correct horse battery staple',
authSource: 'local',
});
expect(result?.account.role).toEqual('admin');
expect(result?.authSource).toEqual('local');
});
tap.test('AccountAuthService rejects IdP email mismatches', async () => {
await testStore.store.createAccount({
email: 'idp-admin@example.com',
name: 'IdP Admin User',
role: 'admin',
authSources: ['idp.global'],
});
const authService = new AccountAuthService({
store: testStore.store,
idpClient: {
loginWithEmailAndPassword: async () => ({
jwt: 'jwt',
refreshToken: 'refresh',
user: {
id: 'idp-user-1',
data: {
name: 'Other User',
username: 'other@example.com',
email: 'other@example.com',
status: 'active',
connectedOrgs: [],
},
},
}),
} as any,
});
expect(await authService.authenticate({
email: 'idp-admin@example.com',
password: 'idp-password',
authSource: 'idp.global',
})).toBeNull();
});
tap.test('cleanup account store test db', async () => {
await testStore.cleanup();
});
export default tap.start();
+1
View File
@@ -0,0 +1 @@
export {};
+318
View File
@@ -0,0 +1,318 @@
import { IdpRequests } from './classes.idprequests.js';
import * as plugins from './plugins.js';
export class IdpClient {
private helpers = {
async extractDataFromJwtString(jwtString: string): Promise<plugins.idpInterfaces.data.IJwt> {
return plugins.webjwt.getDataFromJwtString(jwtString);
},
};
public appData: plugins.idpInterfaces.data.IAppLegacy;
public rolesReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
public organizationsReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
public parsedReceptionUrl: plugins.smarturl.Smarturl;
constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IAppLegacy) {
if (receptionBaseUrlArg.endsWith('/')) {
receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1);
}
if (!receptionBaseUrlArg.endsWith('/typedrequest')) {
receptionBaseUrlArg = `${receptionBaseUrlArg}/typedrequest`;
}
this.parsedReceptionUrl = plugins.smarturl.Smarturl.createFromUrl(receptionBaseUrlArg);
if (!appDataArg) {
appDataArg = {
id: '',
appUrl: typeof window !== 'undefined' ? `https://${window.location.host}/` : '',
description: '',
logoUrl: '',
name: '',
};
}
this.appData = appDataArg;
}
public requests = new IdpRequests(this);
public checkWetherOnReceptionDomain() {
return plugins.smarturl.Smarturl.createFromUrl(window.location.href).hostname ===
this.parsedReceptionUrl.hostname;
}
public async getAppDataOnSsoDomain() {
if (!window.location.href.startsWith('https://sso.workspace.global/')) {
console.error('You are trying to access SSO appData on a non sso domain.');
return null;
}
const appDataString = plugins.smarturl.Smarturl.createFromUrl(window.location.href).searchParams
.appdata;
if (!appDataString) {
console.error('no appdata query arg detected');
return null;
}
return plugins.smartjson.parseBase64(appDataString);
}
public async setJwt(jwtStringArg: string) {
await this.storeJwt(jwtStringArg);
}
public async setRefreshToken(refreshTokenArg: string) {
await this.storeRefreshToken(refreshTokenArg);
}
public typedsocket!: plugins.typedsocket.TypedSocket;
public typedrouter: any = new plugins.typedrequest.TypedRouter();
public statusObservable = new plugins.smartrx.rxjs.Subject<plugins.idpInterfaces.data.TLoginStatus>();
public ssoStore = new plugins.webstore.WebStore({
storeName: 'idpglobalStore',
dbName: 'main',
});
public async storeJwt(jwtString: string) {
await this.ssoStore.set('idpJwt', jwtString);
}
public async storeRefreshToken(refreshToken: string) {
await this.ssoStore.set('idpRefreshToken', refreshToken);
}
public async getJwt(): Promise<string> {
return await this.ssoStore.get('idpJwt');
}
public async getRefreshToken(): Promise<string> {
return await this.ssoStore.get('idpRefreshToken');
}
public async getJwtData(): Promise<plugins.idpInterfaces.data.IJwt> {
return this.helpers.extractDataFromJwtString(await this.getJwt());
}
public async deleteJwt() {
await this.ssoStore.delete('idpJwt');
}
public async deleteRefreshToken() {
await this.ssoStore.delete('idpRefreshToken');
}
public async clearAuthState() {
await Promise.all([this.deleteJwt(), this.deleteRefreshToken()]);
}
public async performJwtHousekeeping() {
let jwt = await this.getJwt();
if (!jwt) {
return null;
}
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
if (extractedJwt.data.refreshFrom < Date.now() && Date.now() < extractedJwt.data.validUntil) {
jwt = await this.refreshJwt();
} else if (Date.now() > extractedJwt.data.validUntil) {
await this.deleteJwt();
jwt = await this.refreshJwt();
}
return jwt;
}
public async refreshJwt(refreshTokenArg?: string): Promise<string | null> {
const refreshToken = refreshTokenArg || (await this.getRefreshToken());
if (!refreshToken) {
return null;
}
await this.typedsocketDeferred.promise;
const refreshJwtReq = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>('refreshJwt');
const response = await refreshJwtReq
.fire({ refreshToken })
.catch(async () => {
await this.clearAuthState();
return null;
});
if (!response?.jwt) {
await this.clearAuthState();
this.statusObservable.next(response?.status || 'loggedOut');
return null;
}
if (response.refreshToken) {
await this.storeRefreshToken(response.refreshToken);
}
await this.storeJwt(response.jwt);
this.statusObservable.next(response.status);
return response.jwt;
}
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string | null> {
await this.performJwtHousekeeping();
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return null;
}
await this.typedsocketDeferred.promise;
const getTransferToken = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>('exchangeRefreshTokenAndTransferToken');
const response = await getTransferToken.fire({
refreshToken,
appData: appDataArg || this.appData,
});
return response.transferToken;
}
public async getTransferTokenAndSwitchToLocation(newLocationArg: string): Promise<void> {
const transferToken = await this.getTransferToken();
if (!transferToken) {
alert('failed to get transfer token!');
}
const urlInstance = plugins.smarturl.Smarturl.createFromUrl(newLocationArg, {
searchParams: { transfertoken: transferToken },
});
window.location.href = urlInstance.toString();
}
public async processTransferToken(): Promise<boolean> {
const href = window.location.href;
const url = plugins.smarturl.Smarturl.createFromUrl(href);
const transferToken = url.searchParams['transfertoken'];
if (transferToken) {
await this.typedsocketDeferred.promise;
const getTransferToken = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>('exchangeRefreshTokenAndTransferToken');
const response = await getTransferToken.fire({
transferToken,
appData: this.appData,
});
if (response.refreshToken) {
await this.refreshJwt(response.refreshToken);
} else {
globalThis.alert?.('transfer token invalid');
return false;
}
return true;
}
return false;
}
public async checkJwtPresent() {
const jwt = await this.performJwtHousekeeping();
return !!jwt;
}
public async determineLoginStatus(requireLoginArg: boolean = false): Promise<boolean> {
const jwtPresent = await this.checkJwtPresent();
if (jwtPresent) {
const jwt = await this.performJwtHousekeeping();
return !!jwt;
}
const refreshToken = await this.getRefreshToken();
if (refreshToken) {
const jwt = await this.refreshJwt(refreshToken);
if (jwt) {
return true;
}
}
const transferTokenResult = await this.processTransferToken();
if (transferTokenResult) {
return true;
}
if (requireLoginArg) {
const urlInstance = plugins.smarturl.Smarturl.createFromUrl(
this.parsedReceptionUrl.clone().set('path', '/login').toString(),
{
searchParams: {
appdata: plugins.smartjson.stringifyBase64(this.appData),
},
},
);
if (!globalThis.location.href.startsWith(this.parsedReceptionUrl.toString())) {
globalThis.location.href = urlInstance.toString();
}
}
return false;
}
public async logout() {
const idpLogoutUrl = this.parsedReceptionUrl.clone().set('path', '/logout');
const refreshToken = await this.getRefreshToken();
if (!globalThis.location.href.startsWith(idpLogoutUrl.origin)) {
await this.clearAuthState();
globalThis.location.href = idpLogoutUrl.toString();
return;
}
if (!refreshToken) {
await this.clearAuthState();
window.location.href = this.parsedReceptionUrl.origin;
return;
}
await this.enableTypedSocket();
const logoutTr = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.ILogoutRequest>('logout');
await logoutTr.fire({ refreshToken });
await this.clearAuthState();
const appData = await this.getAppDataOnSsoDomain();
if (appData) {
window.location.href = appData.appUrl;
} else if (window.location.href.startsWith(idpLogoutUrl.origin)) {
window.location.href = this.parsedReceptionUrl.origin;
}
}
public typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
public async enableTypedSocket() {
if (this.typedsocketDeferred.claimed) {
return this.typedsocketDeferred.promise;
}
this.typedsocketDeferred.claim();
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
this.typedrouter,
this.parsedReceptionUrl.toString(),
);
this.typedsocketDeferred.resolve(this.typedsocket);
return this.typedsocketDeferred.promise;
}
public async stop() {
await this.typedsocket?.stop();
}
public async createOrganization(orgNameArg: string, orgSlugArg: string, modeArg: 'checkAvailability' | 'manifest') {
await this.typedsocketDeferred.promise;
const validateOrg = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateOrganization>('createOrganization');
return validateOrg.fire({
jwt: await this.getJwt(),
action: modeArg,
organizationName: orgNameArg,
organizationSlug: orgSlugArg,
userId: (await this.getJwtData()).id,
});
}
public async getRolesAndOrganizations() {
await this.typedsocketDeferred.promise;
const rolesAndOrganizationsForUserId = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetRolesAndOrganizationsForUserId>('getRolesAndOrganizationsForUserId');
return rolesAndOrganizationsForUserId.fire({
jwt: await this.getJwt(),
userId: (await this.getJwtData()).id,
});
}
public async updatePaddleCheckoutId(orgIdArg: string, checkoutIdArg: string) {
await this.typedsocketDeferred.promise;
const updateBillingPlan = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdatePaymentMethod>('updatePaymentMethod');
return updateBillingPlan.fire({
jwtString: await this.getJwt(),
orgId: orgIdArg,
paddle: { checkoutId: checkoutIdArg },
});
}
public async whoIs() {
await this.typedsocketDeferred.promise;
const whoIs = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_WhoIs>('whoIs');
return whoIs.fire({ jwt: await this.getJwt() });
}
}
+218
View File
@@ -0,0 +1,218 @@
import * as plugins from './plugins.js';
import type { IdpClient } from './classes.idpclient.js';
export class IdpRequests {
constructor(private idpClientArg: IdpClient) {}
public get afterRegistrationEmailClicked() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>('afterRegistrationEmailClicked');
}
public get setData() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>('setDataForRegistration');
}
public get mobileNumberVerification() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>('mobileVerificationForRegistration');
}
public get finishRegistration() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>('finishRegistration');
}
public get loginWithUserNameAndPassword() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>('loginWithEmailOrUsernameAndPassword');
}
public get obtainJwt() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>('refreshJwt');
}
public get obtainOneTimeToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>('exchangeRefreshTokenAndTransferToken');
}
public get loginWithEmail() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>('loginWithEmail');
}
public get loginWithEmailAfterToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>('loginWithEmailAfterEmailTokenAquired');
}
public get loginWithApiToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>('loginWithApiToken');
}
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() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>('resetPassword');
}
public get setNewPassword() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetNewPassword>('setNewPassword');
}
public get obtainDeviceId() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ObtainDeviceId>('obtainDeviceId');
}
public get attachDeviceId() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AttachDeviceId>('attachDeviceId');
}
public get firstRegistration() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>('firstRegistrationRequest');
}
public get getUserData() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserData>('getUserData');
}
public get setUserData() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetUserData>('setUserData');
}
public get getUserSessions() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>('getUserSessions');
}
public get revokeSession() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>('revokeSession');
}
public get getUserActivity() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>('getUserActivity');
}
public get getOrganizationById() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrganizationById>('getOrganizationById');
}
public get updateOrganization() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateOrganization>('updateOrganization');
}
public get deleteOrganization() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrganization>('deleteOrganization');
}
public get getOrgRoleDefinitions() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgRoleDefinitions>('getOrgRoleDefinitions');
}
public get upsertOrgRoleDefinition() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>('upsertOrgRoleDefinition');
}
public get deleteOrgRoleDefinition() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>('deleteOrgRoleDefinition');
}
public get createInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>('createInvitation');
}
public get getOrgInvitations() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>('getOrgInvitations');
}
public get getOrgMembers() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>('getOrgMembers');
}
public get cancelInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>('cancelInvitation');
}
public get resendInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>('resendInvitation');
}
public get removeMember() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>('removeMember');
}
public get updateMemberRoles() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>('updateMemberRoles');
}
public get transferOwnership() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>('transferOwnership');
}
public get getInvitationByToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetInvitationByToken>('getInvitationByToken');
}
public get acceptInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AcceptInvitation>('acceptInvitation');
}
public get bulkCreateInvitations() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>('bulkCreateInvitations');
}
public get updateAppRoleMappings() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>('updateAppRoleMappings');
}
public get getBillingPlan() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetBillingPlan>('getBillingPlan');
}
public get getPaddleConfig() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>('getPaddleConfig');
}
public get getPublicKeyForValidation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPublicKeyForValidation>('getPublicKeyForValidation');
}
public get pushPublicKeyForValidation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushPublicKeyForValidation>('pushPublicKeyForValidation');
}
public get pushOrGetJwtIdBlocklist() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushOrGetJwtIdBlocklist>('pushOrGetJwtIdBlocklist');
}
public get suspendUser() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>('suspendUser');
}
public get deleteSuspendedUser() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IDeleteSuspendedUser>('deleteSuspendedUser');
}
public get checkGlobalAdmin() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>('checkGlobalAdmin');
}
public get getGlobalAppStats() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>('getGlobalAppStats');
}
public get createGlobalApp() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>('createGlobalApp');
}
public get updateGlobalApp() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>('updateGlobalApp');
}
public get deleteGlobalApp() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>('deleteGlobalApp');
}
public get regenerateAppCredentials() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>('regenerateAppCredentials');
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './classes.idpclient.js';
export * from './classes.idprequests.js';
+18
View File
@@ -0,0 +1,18 @@
import * as idpInterfaces from '@idp.global/interfaces';
export { idpInterfaces };
import * as typedrequest from '@api.global/typedrequest';
import * as typedsocket from '@api.global/typedsocket';
export { typedrequest, typedsocket };
import * as smartjson from '@push.rocks/smartjson';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
import * as smarttime from '@push.rocks/smarttime';
import * as smarturl from '@push.rocks/smarturl';
import * as webjwt from '@push.rocks/webjwt';
import * as webstore from '@push.rocks/webstore';
export { smartjson, smartpromise, smartrx, smarttime, smarturl, webjwt, webstore };
+61
View File
@@ -0,0 +1,61 @@
import { IdpGlobalServerClient } from './classes.idp-global-server-client.js';
import { SmartdataAccountStore } from './classes.account-store.js';
import type { IAuthenticateAccountOptions, IAuthenticatedAccountResult, IIdpSdkAccount } from './interfaces.js';
export class AccountAuthService {
constructor(private optionsArg: {
store: SmartdataAccountStore;
idpClient?: IdpGlobalServerClient;
}) {}
public async authenticate(optionsArg: IAuthenticateAccountOptions): Promise<IAuthenticatedAccountResult | null> {
const account = await this.optionsArg.store.getAccountByEmail(optionsArg.email);
if (!account || account.status !== 'active') {
return null;
}
const authSource = optionsArg.authSource || 'auto';
if ((authSource === 'local' || authSource === 'auto') && account.authSources.includes('local')) {
const localOk = await this.optionsArg.store.verifyLocalPassword(account, optionsArg.password);
if (localOk) {
const updatedAccount = await this.optionsArg.store.updateLoginState(account.id, {});
return { account: updatedAccount || account, authSource: 'local' };
}
if (authSource === 'local') {
return null;
}
}
if ((authSource === 'idp.global' || authSource === 'auto') && account.authSources.includes('idp.global')) {
return this.authenticateWithIdp(account, optionsArg.password);
}
return null;
}
private async authenticateWithIdp(accountArg: IIdpSdkAccount, passwordArg: string): Promise<IAuthenticatedAccountResult | null> {
if (!this.optionsArg.idpClient) {
return null;
}
const idpResult = await this.optionsArg.idpClient.loginWithEmailAndPassword({
email: accountArg.email,
password: passwordArg,
});
const idpEmail = this.optionsArg.store.normalizeEmail(idpResult.user.data.email);
if (idpEmail !== accountArg.emailNormalized) {
return null;
}
if (accountArg.idpSubject && accountArg.idpSubject !== idpResult.user.id) {
return null;
}
const updatedAccount = await this.optionsArg.store.updateLoginState(accountArg.id, {
idpSubject: accountArg.idpSubject || idpResult.user.id,
});
return {
account: updatedAccount || accountArg,
authSource: 'idp.global',
idpJwt: idpResult.jwt,
idpRefreshToken: idpResult.refreshToken,
};
}
}
+84
View File
@@ -0,0 +1,84 @@
import * as plugins from './plugins.js';
import type { IIdpSdkAccount, TIdpAccountAuthSource, TIdpAccountRole, TIdpAccountStatus } from './interfaces.js';
let activeSmartdataDb: plugins.smartdata.SmartdataDb | null = null;
export const setAccountDocSmartdataDb = (smartdataDbArg: plugins.smartdata.SmartdataDb) => {
activeSmartdataDb = smartdataDbArg;
};
const getDb = () => {
if (!activeSmartdataDb) {
throw new Error('IdpSdkAccountDoc has no SmartdataDb configured');
}
return activeSmartdataDb;
};
@plugins.smartdata.Collection(() => getDb())
export class IdpSdkAccountDoc extends plugins.smartdata.SmartDataDbDoc<IdpSdkAccountDoc, IdpSdkAccountDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public email!: string;
@plugins.smartdata.svDb()
public emailNormalized!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public role!: TIdpAccountRole;
@plugins.smartdata.svDb()
public status!: TIdpAccountStatus;
@plugins.smartdata.svDb()
public authSources!: TIdpAccountAuthSource[];
@plugins.smartdata.svDb()
public passwordHash?: string;
@plugins.smartdata.svDb()
public idpSubject?: string;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public lastLoginAt?: number;
public toAccount(): IIdpSdkAccount {
return {
id: this.id,
email: this.email,
emailNormalized: this.emailNormalized,
name: this.name,
role: this.role,
status: this.status,
authSources: this.authSources || [],
passwordHash: this.passwordHash,
idpSubject: this.idpSubject,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
lastLoginAt: this.lastLoginAt,
};
}
public static async findById(idArg: string): Promise<IdpSdkAccountDoc | null> {
return IdpSdkAccountDoc.getInstance({ id: idArg });
}
public static async findByEmailNormalized(emailNormalizedArg: string): Promise<IdpSdkAccountDoc | null> {
return IdpSdkAccountDoc.getInstance({ emailNormalized: emailNormalizedArg });
}
public static async findAdmins(): Promise<IdpSdkAccountDoc[]> {
return IdpSdkAccountDoc.getInstances({ role: 'admin', status: 'active' });
}
}
+94
View File
@@ -0,0 +1,94 @@
import * as plugins from './plugins.js';
import { IdpSdkAccountDoc, setAccountDocSmartdataDb } from './classes.account-doc.js';
import { PasswordHasher } from './classes.password-hasher.js';
import type { ICreateIdpSdkAccountOptions, IIdpSdkAccount, TIdpAccountAuthSource } from './interfaces.js';
export class SmartdataAccountStore {
constructor(private optionsArg: { smartdataDb: plugins.smartdata.SmartdataDb }) {
setAccountDocSmartdataDb(optionsArg.smartdataDb);
}
public normalizeEmail(emailArg: string): string {
return emailArg.trim().toLowerCase();
}
public async createAccount(optionsArg: ICreateIdpSdkAccountOptions): Promise<IIdpSdkAccount> {
const emailNormalized = this.normalizeEmail(optionsArg.email);
if (!emailNormalized || !emailNormalized.includes('@')) {
throw new Error('A valid account email is required');
}
const existing = await IdpSdkAccountDoc.findByEmailNormalized(emailNormalized);
if (existing) {
throw new Error(`Account already exists for ${emailNormalized}`);
}
const authSources = this.normalizeAuthSources(optionsArg.authSources);
if (authSources.length === 0) {
throw new Error('At least one auth source is required');
}
if (authSources.includes('local') && !optionsArg.password) {
throw new Error('A local password is required for local auth');
}
const now = Date.now();
const doc = new IdpSdkAccountDoc();
doc.id = plugins.crypto.randomUUID();
doc.email = optionsArg.email.trim();
doc.emailNormalized = emailNormalized;
doc.name = optionsArg.name.trim() || doc.email;
doc.role = optionsArg.role;
doc.status = optionsArg.status || 'active';
doc.authSources = authSources;
doc.passwordHash = optionsArg.password ? await PasswordHasher.hashPassword(optionsArg.password) : undefined;
doc.idpSubject = optionsArg.idpSubject;
doc.createdAt = now;
doc.updatedAt = now;
await doc.save();
return doc.toAccount();
}
public async getAccountByEmail(emailArg: string): Promise<IIdpSdkAccount | null> {
const doc = await IdpSdkAccountDoc.findByEmailNormalized(this.normalizeEmail(emailArg));
return doc?.toAccount() || null;
}
public async getAccountById(idArg: string): Promise<IIdpSdkAccount | null> {
const doc = await IdpSdkAccountDoc.findById(idArg);
return doc?.toAccount() || null;
}
public async listAccounts(): Promise<IIdpSdkAccount[]> {
const docs = await IdpSdkAccountDoc.getInstances({});
return docs.map((docArg) => docArg.toAccount());
}
public async hasActiveAdminAccount(): Promise<boolean> {
const admins = await IdpSdkAccountDoc.findAdmins();
return admins.length > 0;
}
public async verifyLocalPassword(accountArg: IIdpSdkAccount, passwordArg: string): Promise<boolean> {
if (accountArg.status !== 'active' || !accountArg.authSources.includes('local')) {
return false;
}
return PasswordHasher.verifyPassword(passwordArg, accountArg.passwordHash);
}
public async updateLoginState(accountIdArg: string, patchArg: { idpSubject?: string }): Promise<IIdpSdkAccount | null> {
const doc = await IdpSdkAccountDoc.findById(accountIdArg);
if (!doc) {
return null;
}
if (patchArg.idpSubject !== undefined) {
doc.idpSubject = patchArg.idpSubject;
}
doc.lastLoginAt = Date.now();
doc.updatedAt = Date.now();
await doc.save();
return doc.toAccount();
}
private normalizeAuthSources(authSourcesArg: TIdpAccountAuthSource[]): TIdpAccountAuthSource[] {
return [...new Set(authSourcesArg.filter((sourceArg) => sourceArg === 'local' || sourceArg === 'idp.global'))];
}
}
@@ -0,0 +1,74 @@
import * as plugins from './plugins.js';
export interface IIdpGlobalServerClientOptions {
baseUrl: string;
}
export interface IIdpPasswordAuthResult {
user: plugins.idpInterfaces.data.IUser;
jwt: string;
refreshToken: string;
}
export class IdpGlobalServerClient {
private typedrouter: any = new plugins.typedrequest.TypedRouter();
private typedsocket?: plugins.typedsocket.TypedSocket;
private typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
constructor(private optionsArg: IIdpGlobalServerClientOptions) {}
public async connect(): Promise<plugins.typedsocket.TypedSocket> {
if (this.typedsocketDeferred.claimed) {
return this.typedsocketDeferred.promise;
}
this.typedsocketDeferred.claim();
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
this.typedrouter,
this.getTypedRequestUrl(),
);
this.typedsocketDeferred.resolve(this.typedsocket);
return this.typedsocketDeferred.promise;
}
public async stop(): Promise<void> {
await this.typedsocket?.stop();
this.typedsocket = undefined;
}
public async loginWithEmailAndPassword(optionsArg: { email: string; password: string }): Promise<IIdpPasswordAuthResult> {
const socket = await this.connect();
const loginRequest = socket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>('loginWithEmailOrUsernameAndPassword');
const loginResponse = await loginRequest.fire({
username: optionsArg.email,
password: optionsArg.password,
});
if (!loginResponse.refreshToken || loginResponse.twoFaNeeded) {
throw new Error(loginResponse.twoFaNeeded ? 'Two-factor authentication is required' : 'IdP login failed');
}
const refreshRequest = socket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>('refreshJwt');
const refreshResponse = await refreshRequest.fire({ refreshToken: loginResponse.refreshToken });
if (!refreshResponse.jwt) {
throw new Error('IdP did not return a JWT');
}
const whoIsRequest = socket.createTypedRequest<plugins.idpInterfaces.request.IReq_WhoIs>('whoIs');
const whoIsResponse = await whoIsRequest.fire({ jwt: refreshResponse.jwt });
return {
user: whoIsResponse.user,
jwt: refreshResponse.jwt,
refreshToken: refreshResponse.refreshToken || loginResponse.refreshToken,
};
}
private getTypedRequestUrl(): string {
let baseUrl = this.optionsArg.baseUrl.trim();
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
if (!baseUrl.endsWith('/typedrequest')) {
baseUrl = `${baseUrl}/typedrequest`;
}
return baseUrl;
}
}
+39
View File
@@ -0,0 +1,39 @@
import * as plugins from './plugins.js';
const HASH_PREFIX = 'scrypt:v1';
export class PasswordHasher {
public static async hashPassword(passwordArg: string): Promise<string> {
const salt = plugins.crypto.randomBytes(16).toString('base64url');
const key = await this.scrypt(passwordArg, salt);
return `${HASH_PREFIX}:${salt}:${key.toString('base64url')}`;
}
public static async verifyPassword(passwordArg: string, passwordHashArg?: string): Promise<boolean> {
if (!passwordHashArg) {
return false;
}
const [prefix, version, salt, storedKey] = passwordHashArg.split(':');
if (`${prefix}:${version}` !== HASH_PREFIX || !salt || !storedKey) {
return false;
}
const candidate = await this.scrypt(passwordArg, salt);
const stored = Buffer.from(storedKey, 'base64url');
if (candidate.byteLength !== stored.byteLength) {
return false;
}
return plugins.crypto.timingSafeEqual(candidate, stored);
}
private static async scrypt(passwordArg: string, saltArg: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
plugins.crypto.scrypt(passwordArg, saltArg, 64, (error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(derivedKey as Buffer);
});
});
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './interfaces.js';
export * from './classes.account-auth-service.js';
export * from './classes.account-doc.js';
export * from './classes.account-store.js';
export * from './classes.idp-global-server-client.js';
export * from './classes.password-hasher.js';
+41
View File
@@ -0,0 +1,41 @@
export type TIdpAccountAuthSource = 'local' | 'idp.global';
export type TIdpAccountRole = 'admin' | 'user';
export type TIdpAccountStatus = 'active' | 'disabled';
export interface IIdpSdkAccount {
id: string;
email: string;
emailNormalized: string;
name: string;
role: TIdpAccountRole;
status: TIdpAccountStatus;
authSources: TIdpAccountAuthSource[];
passwordHash?: string;
idpSubject?: string;
createdAt: number;
updatedAt: number;
lastLoginAt?: number;
}
export interface ICreateIdpSdkAccountOptions {
email: string;
name: string;
role: TIdpAccountRole;
status?: TIdpAccountStatus;
authSources: TIdpAccountAuthSource[];
password?: string;
idpSubject?: string;
}
export interface IAuthenticateAccountOptions {
email: string;
password: string;
authSource?: TIdpAccountAuthSource | 'auto';
}
export interface IAuthenticatedAccountResult {
account: IIdpSdkAccount;
authSource: TIdpAccountAuthSource;
idpJwt?: string;
idpRefreshToken?: string;
}
+17
View File
@@ -0,0 +1,17 @@
import * as crypto from 'crypto';
export { crypto };
import * as idpInterfaces from '@idp.global/interfaces';
export { idpInterfaces };
import * as typedrequest from '@api.global/typedrequest';
import * as typedsocket from '@api.global/typedsocket';
export { typedrequest, typedsocket };
import * as smartdata from '@push.rocks/smartdata';
import * as smartpromise from '@push.rocks/smartpromise';
export { smartdata, smartpromise };
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"types": ["node"],
"strict": false
},
"exclude": [
"dist_*/**/*.d.ts"
]
}