feat(sdk): add initial browser and server authentication SDK exports
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user