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
+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 };