Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f5abb49b | |||
| 68469b0740 | |||
| 525a72b73b | |||
| d913dfaeb1 | |||
| fe9da65437 | |||
| 28d30fe392 |
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@idp.global/idp.global",
|
||||
"version": "1.17.1",
|
||||
"version": "1.19.1",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { EmailActionToken } from '../ts/reception/classes.emailactiontoken.js';
|
||||
import { LoginSession } from '../ts/reception/classes.loginsession.js';
|
||||
import { RegistrationSession } from '../ts/reception/classes.registrationsession.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
@@ -12,6 +14,42 @@ const createTestLoginSession = () => {
|
||||
return loginSession;
|
||||
};
|
||||
|
||||
const createTestEmailActionToken = () => {
|
||||
const emailActionToken = new EmailActionToken();
|
||||
emailActionToken.id = 'email-action-token';
|
||||
emailActionToken.data.email = 'user@example.com';
|
||||
emailActionToken.data.action = 'emailLogin';
|
||||
emailActionToken.data.validUntil = Date.now() + 60_000;
|
||||
|
||||
let deleted = false;
|
||||
(emailActionToken as EmailActionToken & { delete: () => Promise<void> }).delete = async () => {
|
||||
deleted = true;
|
||||
};
|
||||
|
||||
return {
|
||||
emailActionToken,
|
||||
wasDeleted: () => deleted,
|
||||
};
|
||||
};
|
||||
|
||||
const createTestRegistrationSession = () => {
|
||||
const registrationSession = new RegistrationSession();
|
||||
registrationSession.id = 'registration-session';
|
||||
registrationSession.data.emailAddress = 'user@example.com';
|
||||
registrationSession.data.validUntil = Date.now() + 60_000;
|
||||
|
||||
let deleted = false;
|
||||
(registrationSession as RegistrationSession & { save: () => Promise<void> }).save = async () => undefined;
|
||||
(registrationSession as RegistrationSession & { delete: () => Promise<void> }).delete = async () => {
|
||||
deleted = true;
|
||||
};
|
||||
|
||||
return {
|
||||
registrationSession,
|
||||
wasDeleted: () => deleted,
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('hashes passwords with argon2 and verifies them', async () => {
|
||||
const passwordHash = await User.hashPassword('correct horse battery staple');
|
||||
|
||||
@@ -58,4 +96,45 @@ tap.test('persists transfer tokens as one-time hashes', async () => {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.17.1',
|
||||
version: '1.19.1',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
@@ -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,46 @@ export class ReceptionHousekeeping {
|
||||
'2 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.addAndScheduleTask(
|
||||
new plugins.taskbuffer.Task({
|
||||
name: 'expiredEmailActionTokens',
|
||||
taskFunction: async () => {
|
||||
const expiredEmailActionTokens =
|
||||
await this.receptionRef.loginSessionManager.CEmailActionToken.getInstances({
|
||||
data: {
|
||||
validUntil: {
|
||||
$lt: Date.now(),
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const emailActionToken of expiredEmailActionTokens) {
|
||||
await emailActionToken.delete();
|
||||
}
|
||||
},
|
||||
}),
|
||||
'2 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.addAndScheduleTask(
|
||||
new plugins.taskbuffer.Task({
|
||||
name: 'expiredRegistrationSessions',
|
||||
taskFunction: async () => {
|
||||
const expiredRegistrationSessions =
|
||||
await this.receptionRef.registrationSessionManager.CRegistrationSession.getInstances({
|
||||
data: {
|
||||
validUntil: {
|
||||
$lt: Date.now(),
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const registrationSession of expiredRegistrationSessions) {
|
||||
await registrationSession.delete();
|
||||
}
|
||||
},
|
||||
}),
|
||||
'2 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.start();
|
||||
logger.log('info', 'housekeeping started');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailActionToken } from './classes.emailactiontoken.js';
|
||||
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
|
||||
import { Reception } from './classes.reception.js';
|
||||
import { logger } from './logging.js';
|
||||
@@ -10,18 +11,11 @@ export class LoginSessionManager {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken);
|
||||
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
||||
|
||||
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public emailTokenMap = new plugins.lik.ObjectMap<{
|
||||
email: string;
|
||||
token: string;
|
||||
action: 'emailLogin' | 'passwordReset';
|
||||
}>();
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
@@ -55,7 +49,6 @@ export class LoginSessionManager {
|
||||
}
|
||||
|
||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||
this.loginSessions.add(loginSession);
|
||||
const refreshToken = await loginSession.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||
@@ -84,33 +77,21 @@ export class LoginSessionManager {
|
||||
});
|
||||
if (existingUser) {
|
||||
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
||||
this.emailTokenMap.findOneAndRemoveSync(
|
||||
(itemArg) => itemArg.email === existingUser.data.email
|
||||
const loginEmailToken = await this.createEmailActionToken(
|
||||
existingUser.data.email,
|
||||
'emailLogin'
|
||||
);
|
||||
const loginEmailToken = plugins.smartunique.uuid4();
|
||||
this.emailTokenMap.add({
|
||||
email: existingUser.data.email,
|
||||
token: loginEmailToken,
|
||||
action: 'emailLogin',
|
||||
});
|
||||
// lets make sure its only valid for 10 minutes
|
||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
||||
this.emailTokenMap.findOneAndRemoveSync(
|
||||
(itemArg) => itemArg.token === loginEmailToken
|
||||
);
|
||||
});
|
||||
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
||||
return {
|
||||
status: 'ok',
|
||||
testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined,
|
||||
};
|
||||
} else {
|
||||
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
||||
}
|
||||
const testOnlyToken =
|
||||
process.env.TEST_MODE && existingUser
|
||||
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
||||
?.token
|
||||
: undefined;
|
||||
return {
|
||||
status: 'ok',
|
||||
testOnlyToken,
|
||||
testOnlyToken: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -120,9 +101,11 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||
'loginWithEmailAfterEmailTokenAquired',
|
||||
async (requestArg) => {
|
||||
const tokenObject = this.emailTokenMap.findSync((itemArg) => {
|
||||
return itemArg.email === requestArg.email && itemArg.token === requestArg.token;
|
||||
});
|
||||
const tokenObject = await this.consumeEmailActionToken(
|
||||
requestArg.email,
|
||||
requestArg.token,
|
||||
'emailLogin'
|
||||
);
|
||||
if (tokenObject) {
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
@@ -133,7 +116,6 @@ export class LoginSessionManager {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||
this.loginSessions.add(loginSession);
|
||||
const refreshToken = await loginSession.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||
@@ -213,23 +195,13 @@ export class LoginSessionManager {
|
||||
},
|
||||
});
|
||||
if (existingUser) {
|
||||
this.emailTokenMap.findOneAndRemoveSync(
|
||||
(itemArg) => itemArg.email === existingUser.data.email
|
||||
const resetToken = await this.createEmailActionToken(
|
||||
existingUser.data.email,
|
||||
'passwordReset'
|
||||
);
|
||||
this.emailTokenMap.add({
|
||||
email: existingUser.data.email,
|
||||
token: plugins.smartunique.shortId(),
|
||||
action: 'passwordReset',
|
||||
});
|
||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
||||
this.emailTokenMap.findOneAndRemoveSync(
|
||||
(itemArg) => itemArg.email === existingUser.data.email
|
||||
);
|
||||
});
|
||||
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
||||
existingUser,
|
||||
this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
||||
.token
|
||||
resetToken
|
||||
);
|
||||
}
|
||||
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
||||
@@ -244,6 +216,43 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||
'setNewPassword',
|
||||
async (requestData) => {
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
email: requestData.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
if (requestData.tokenArg) {
|
||||
const tokenObject = await this.consumeEmailActionToken(
|
||||
requestData.email,
|
||||
requestData.tokenArg,
|
||||
'passwordReset'
|
||||
);
|
||||
if (!tokenObject) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Password reset token invalid');
|
||||
}
|
||||
} else if (requestData.oldPassword) {
|
||||
const passwordOk = await this.receptionRef.userManager.CUser.verifyPassword(
|
||||
requestData.oldPassword,
|
||||
user.data.passwordHash
|
||||
);
|
||||
if (!passwordOk) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Old password invalid');
|
||||
}
|
||||
} else {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Either a reset token or the old password is required'
|
||||
);
|
||||
}
|
||||
|
||||
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
|
||||
requestData.newPassword
|
||||
);
|
||||
await user.save();
|
||||
return {
|
||||
status: 'ok',
|
||||
};
|
||||
@@ -393,4 +402,50 @@ export class LoginSessionManager {
|
||||
const isValid = await loginSession.validateTransferToken(transferTokenArg);
|
||||
return isValid ? loginSession : null;
|
||||
}
|
||||
|
||||
public async createEmailActionToken(
|
||||
emailArg: string,
|
||||
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||
) {
|
||||
const existingTokens = await this.CEmailActionToken.getInstances({
|
||||
'data.email': emailArg,
|
||||
'data.action': actionArg,
|
||||
});
|
||||
|
||||
for (const existingToken of existingTokens) {
|
||||
await existingToken.delete();
|
||||
}
|
||||
|
||||
const plainToken = EmailActionToken.createOpaqueToken(actionArg);
|
||||
const emailActionToken = new EmailActionToken();
|
||||
emailActionToken.id = plugins.smartunique.shortId();
|
||||
emailActionToken.data = {
|
||||
email: emailArg,
|
||||
action: actionArg,
|
||||
tokenHash: EmailActionToken.hashToken(plainToken),
|
||||
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await emailActionToken.save();
|
||||
return plainToken;
|
||||
}
|
||||
|
||||
public async consumeEmailActionToken(
|
||||
emailArg: string,
|
||||
tokenArg: string,
|
||||
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||
) {
|
||||
const emailActionToken = await this.CEmailActionToken.getInstance({
|
||||
'data.email': emailArg,
|
||||
'data.action': actionArg,
|
||||
'data.tokenHash': EmailActionToken.hashToken(tokenArg),
|
||||
});
|
||||
|
||||
if (!emailActionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const consumed = await emailActionToken.consume(tokenArg);
|
||||
return consumed ? emailActionToken : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +1,10 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Reception } from './classes.reception.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
|
||||
@@ -12,25 +16,31 @@ export class OidcManager {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
||||
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
||||
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||
this,
|
||||
OidcAuthorizationCode
|
||||
);
|
||||
|
||||
// In-memory store for access tokens (for validation)
|
||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
||||
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||
|
||||
// In-memory store for refresh tokens
|
||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
||||
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||
|
||||
// In-memory store for user consents (should be persisted later)
|
||||
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) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
|
||||
// Start cleanup task for expired codes/tokens
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC Discovery Document
|
||||
*/
|
||||
@@ -174,9 +184,11 @@ export class OidcManager {
|
||||
codeChallenge?: string,
|
||||
nonce?: string
|
||||
): Promise<string> {
|
||||
const code = plugins.smartunique.shortId(32);
|
||||
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
||||
code,
|
||||
const code = this.createOpaqueToken();
|
||||
const authCode = new OidcAuthorizationCode();
|
||||
authCode.id = plugins.smartunique.shortId(12);
|
||||
authCode.data = {
|
||||
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
@@ -184,11 +196,13 @@ export class OidcManager {
|
||||
codeChallenge,
|
||||
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||
nonce,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
issuedAt: Date.now(),
|
||||
used: false,
|
||||
};
|
||||
|
||||
this.authorizationCodes.set(code, authCode);
|
||||
await authCode.save();
|
||||
await this.upsertUserConsent(userId, clientId, scopes);
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -261,50 +275,48 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// Find and validate authorization code
|
||||
const authCode = this.authorizationCodes.get(code);
|
||||
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||
if (!authCode) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||
}
|
||||
|
||||
if (authCode.used) {
|
||||
// Code reuse attack - revoke all tokens for this code
|
||||
this.authorizationCodes.delete(code);
|
||||
if (authCode.data.used) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||
}
|
||||
|
||||
if (authCode.expiresAt < Date.now()) {
|
||||
this.authorizationCodes.delete(code);
|
||||
if (authCode.isExpired()) {
|
||||
await authCode.delete();
|
||||
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');
|
||||
}
|
||||
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
if (authCode.data.redirectUri !== redirectUri) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||
}
|
||||
|
||||
// Verify PKCE if code challenge was used
|
||||
if (authCode.codeChallenge) {
|
||||
if (authCode.data.codeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||
}
|
||||
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||
if (expectedChallenge !== authCode.codeChallenge) {
|
||||
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
authCode.used = true;
|
||||
await authCode.markUsed();
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(
|
||||
authCode.userId,
|
||||
authCode.data.userId,
|
||||
app.data.oauthCredentials.clientId,
|
||||
authCode.scopes,
|
||||
authCode.nonce
|
||||
authCode.data.scopes,
|
||||
authCode.data.nonce
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(tokens), {
|
||||
@@ -330,31 +342,30 @@ export class OidcManager {
|
||||
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||
}
|
||||
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||
const storedToken = this.refreshTokens.get(tokenHash);
|
||||
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||
|
||||
if (!storedToken) {
|
||||
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');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < Date.now()) {
|
||||
this.refreshTokens.delete(tokenHash);
|
||||
if (storedToken.isExpired()) {
|
||||
await storedToken.delete();
|
||||
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');
|
||||
}
|
||||
|
||||
// Generate new tokens (without new refresh token by default)
|
||||
const tokens = await this.generateTokens(
|
||||
storedToken.userId,
|
||||
storedToken.clientId,
|
||||
storedToken.scopes,
|
||||
storedToken.data.userId,
|
||||
storedToken.data.clientId,
|
||||
storedToken.data.scopes,
|
||||
undefined,
|
||||
false // Don't generate new refresh token
|
||||
);
|
||||
@@ -384,18 +395,18 @@ export class OidcManager {
|
||||
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||
|
||||
// Generate access token
|
||||
const accessToken = plugins.smartunique.shortId(32);
|
||||
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
||||
id: plugins.smartunique.shortId(8),
|
||||
tokenHash: accessTokenHash,
|
||||
const accessToken = this.createOpaqueToken();
|
||||
const accessTokenData = new OidcAccessToken();
|
||||
accessTokenData.id = plugins.smartunique.shortId(12);
|
||||
accessTokenData.data = {
|
||||
tokenHash: OidcAccessToken.hashToken(accessToken),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
expiresAt: now + accessTokenLifetime * 1000,
|
||||
issuedAt: now,
|
||||
};
|
||||
this.accessTokens.set(accessTokenHash, accessTokenData);
|
||||
await accessTokenData.save();
|
||||
|
||||
// Generate ID token (JWT)
|
||||
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||
@@ -410,11 +421,11 @@ export class OidcManager {
|
||||
|
||||
// Generate refresh token if requested
|
||||
if (includeRefreshToken) {
|
||||
const refreshToken = plugins.smartunique.shortId(48);
|
||||
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
||||
id: plugins.smartunique.shortId(8),
|
||||
tokenHash: refreshTokenHash,
|
||||
const refreshToken = this.createOpaqueToken(48);
|
||||
const refreshTokenData = new OidcRefreshToken();
|
||||
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||
refreshTokenData.data = {
|
||||
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
@@ -422,7 +433,7 @@ export class OidcManager {
|
||||
issuedAt: now,
|
||||
revoked: false,
|
||||
};
|
||||
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
||||
await refreshTokenData.save();
|
||||
response.refresh_token = refreshToken;
|
||||
}
|
||||
|
||||
@@ -482,8 +493,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
const accessToken = authHeader.substring(7);
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||
const tokenData = this.accessTokens.get(tokenHash);
|
||||
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||
|
||||
if (!tokenData) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||
@@ -495,8 +505,8 @@ export class OidcManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenData.expiresAt < Date.now()) {
|
||||
this.accessTokens.delete(tokenHash);
|
||||
if (tokenData.isExpired()) {
|
||||
await tokenData.delete();
|
||||
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
@@ -507,7 +517,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// 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), {
|
||||
status: 200,
|
||||
@@ -583,21 +593,20 @@ export class OidcManager {
|
||||
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||
}
|
||||
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
||||
|
||||
// Try to revoke as refresh token
|
||||
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||
const refreshToken = this.refreshTokens.get(tokenHash);
|
||||
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||
if (refreshToken) {
|
||||
refreshToken.revoked = true;
|
||||
await refreshToken.revoke();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Try to revoke as access token
|
||||
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||
if (this.accessTokens.has(tokenHash)) {
|
||||
this.accessTokens.delete(tokenHash);
|
||||
const accessToken = await this.getAccessTokenByToken(token);
|
||||
if (accessToken) {
|
||||
await accessToken.delete();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -616,6 +625,53 @@ export class OidcManager {
|
||||
return apps[0] || null;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -655,29 +711,45 @@ export class OidcManager {
|
||||
* Start cleanup task for expired tokens/codes
|
||||
*/
|
||||
private startCleanupTask(): void {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
void this.cleanupExpiredOidcState();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
// Clean up expired authorization codes
|
||||
for (const [code, data] of this.authorizationCodes) {
|
||||
if (data.expiresAt < now) {
|
||||
this.authorizationCodes.delete(code);
|
||||
}
|
||||
}
|
||||
private async cleanupExpiredOidcState() {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up expired access tokens
|
||||
for (const [hash, data] of this.accessTokens) {
|
||||
if (data.expiresAt < now) {
|
||||
this.accessTokens.delete(hash);
|
||||
}
|
||||
}
|
||||
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const authCode of expiredAuthorizationCodes) {
|
||||
await authCode.delete();
|
||||
}
|
||||
|
||||
// Clean up expired refresh tokens
|
||||
for (const [hash, data] of this.refreshTokens) {
|
||||
if (data.expiresAt < now) {
|
||||
this.refreshTokens.delete(hash);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000); // Run every minute
|
||||
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export class Reception {
|
||||
*/
|
||||
public async stop() {
|
||||
await this.housekeeping.stop();
|
||||
await this.oidcManager.stop();
|
||||
console.log('stopped serviceserver!');
|
||||
await this.db.stop();
|
||||
}
|
||||
|
||||
@@ -5,191 +5,187 @@ import { logger } from './logging.js';
|
||||
import { User } from './classes.user.js';
|
||||
|
||||
/**
|
||||
* a RegistrationSession is a in memory session for signing up
|
||||
* a RegistrationSession persists a sign up flow across restarts
|
||||
*/
|
||||
export class RegistrationSession {
|
||||
// ======
|
||||
// STATIC
|
||||
// ======
|
||||
@plugins.smartdata.Manager()
|
||||
export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
RegistrationSession,
|
||||
plugins.idpInterfaces.data.IRegistrationSession,
|
||||
RegistrationSessionManager
|
||||
> {
|
||||
public static hashToken(tokenArg: string) {
|
||||
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||
}
|
||||
|
||||
public static async createRegistrationSessionForEmail(
|
||||
registrationSessionManageremailArg: RegistrationSessionManager,
|
||||
emailArg: string
|
||||
) {
|
||||
const newRegistrationSession = new RegistrationSession(
|
||||
registrationSessionManageremailArg,
|
||||
emailArg
|
||||
);
|
||||
const emailValidationResult = await newRegistrationSession
|
||||
.validateEMailAddress()
|
||||
.catch((error) => {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Error occured during email provider & dns validation'
|
||||
);
|
||||
});
|
||||
const newRegistrationSession = new RegistrationSession();
|
||||
newRegistrationSession.id = plugins.smartunique.shortId();
|
||||
newRegistrationSession.data.emailAddress = emailArg;
|
||||
newRegistrationSession.data.validUntil =
|
||||
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
|
||||
newRegistrationSession.data.createdAt = Date.now();
|
||||
|
||||
const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Error occured during email provider & dns validation'
|
||||
);
|
||||
});
|
||||
|
||||
if (!emailValidationResult?.valid) {
|
||||
newRegistrationSession.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Email Address is not valid. Please use a correctly formated email address'
|
||||
);
|
||||
}
|
||||
if (emailValidationResult.disposable) {
|
||||
newRegistrationSession.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Email is disposable. Please use a non disposable email address.'
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!`
|
||||
);
|
||||
await newRegistrationSession.sendTokenValidationEmail();
|
||||
console.log(`Successfully sent email validation email`);
|
||||
|
||||
const validationToken = await newRegistrationSession.sendTokenValidationEmail();
|
||||
newRegistrationSession.unhashedEmailToken = validationToken;
|
||||
return newRegistrationSession;
|
||||
}
|
||||
|
||||
// ========
|
||||
// INSTANCE
|
||||
// ========
|
||||
public registrationSessionManagerRef: RegistrationSessionManager;
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
public emailAddress: string;
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IRegistrationSession['data'] = {
|
||||
emailAddress: '',
|
||||
hashedEmailToken: '',
|
||||
smsCodeHash: null,
|
||||
smsvalidationCounter: 0,
|
||||
status: 'announced',
|
||||
validUntil: 0,
|
||||
createdAt: 0,
|
||||
collectedData: {
|
||||
userData: {
|
||||
username: null,
|
||||
connectedOrgs: [],
|
||||
email: null,
|
||||
name: null,
|
||||
status: null,
|
||||
mobileNumber: null,
|
||||
password: null,
|
||||
passwordHash: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* only used during testing
|
||||
*/
|
||||
public unhashedEmailToken?: string;
|
||||
public hashedEmailToken: string;
|
||||
private smsvalidationCounter = 0;
|
||||
public smsCode: string;
|
||||
|
||||
/**
|
||||
* the status of the registration. should progress in a linear fashion.
|
||||
*/
|
||||
public status: 'announced' | 'emailValidated' | 'mobileVerified' | 'registered' | 'failed' =
|
||||
'announced';
|
||||
public get emailAddress() {
|
||||
return this.data.emailAddress;
|
||||
}
|
||||
|
||||
public collectedData: {
|
||||
userData: plugins.idpInterfaces.data.IUser['data'];
|
||||
} = {
|
||||
userData: {
|
||||
username: null,
|
||||
connectedOrgs: [],
|
||||
email: null,
|
||||
name: null,
|
||||
status: null,
|
||||
mobileNumber: null,
|
||||
password: null,
|
||||
passwordHash: null,
|
||||
},
|
||||
};
|
||||
public get status() {
|
||||
return this.data.status;
|
||||
}
|
||||
|
||||
constructor(
|
||||
registrationSessionManagerRefArg: RegistrationSessionManager,
|
||||
emailAddressArg: string
|
||||
) {
|
||||
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
|
||||
this.emailAddress = emailAddressArg;
|
||||
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
|
||||
public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
|
||||
this.data.status = statusArg;
|
||||
}
|
||||
|
||||
// lets destroy this after 10 minutes,
|
||||
// works in unrefed mode so not blocking node exiting.
|
||||
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy());
|
||||
public get collectedData() {
|
||||
return this.data.collectedData;
|
||||
}
|
||||
|
||||
public isExpired() {
|
||||
return this.data.validUntil < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* validates a token by comparing its hash against the stored hashed token
|
||||
* @param tokenArg
|
||||
*/
|
||||
public validateEmailToken(tokenArg: string): boolean {
|
||||
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||
if (result && this.status === 'announced') {
|
||||
this.status = 'emailValidated';
|
||||
this.collectedData.userData.email = this.emailAddress;
|
||||
public async validateEmailToken(tokenArg: string): Promise<boolean> {
|
||||
if (this.isExpired()) {
|
||||
await this.destroy();
|
||||
return false;
|
||||
}
|
||||
if (!result && this.status === 'announced') {
|
||||
this.status = 'failed';
|
||||
|
||||
const result = this.data.hashedEmailToken === RegistrationSession.hashToken(tokenArg);
|
||||
if (result && this.data.status === 'announced') {
|
||||
this.data.status = 'emailValidated';
|
||||
this.data.collectedData.userData.email = this.data.emailAddress;
|
||||
await this.save();
|
||||
}
|
||||
if (!result && this.data.status === 'announced') {
|
||||
this.data.status = 'failed';
|
||||
await this.save();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** validates the sms code */
|
||||
public validateSmsCode(smsCodeArg: string) {
|
||||
this.smsvalidationCounter++;
|
||||
const result = this.smsCode === smsCodeArg;
|
||||
if (this.status === 'emailValidated' && result) {
|
||||
this.status = 'mobileVerified';
|
||||
public async validateSmsCode(smsCodeArg: string) {
|
||||
this.data.smsvalidationCounter++;
|
||||
const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
|
||||
if (this.data.status === 'emailValidated' && result) {
|
||||
this.data.status = 'mobileVerified';
|
||||
await this.save();
|
||||
return result;
|
||||
} else {
|
||||
if (this.smsvalidationCounter === 5) {
|
||||
this.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Registration cancelled due to repeated wrong verification code submission'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.data.smsvalidationCounter >= 5) {
|
||||
await this.destroy();
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Registration cancelled due to repeated wrong verification code submission'
|
||||
);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* validate the email address with provider and dns sanity checks
|
||||
* @returns
|
||||
*/
|
||||
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
||||
console.log(`validating email ${this.emailAddress}`);
|
||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
|
||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* send the validation email
|
||||
*/
|
||||
public async sendTokenValidationEmail() {
|
||||
const uuidToSend = plugins.smartunique.uuid4();
|
||||
this.unhashedEmailToken = uuidToSend;
|
||||
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend);
|
||||
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail(
|
||||
this,
|
||||
uuidToSend
|
||||
);
|
||||
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
|
||||
this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
|
||||
await this.save();
|
||||
this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
|
||||
logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
|
||||
return uuidToSend;
|
||||
}
|
||||
|
||||
/**
|
||||
* validate the mobile number of someone
|
||||
*/
|
||||
public async sendValidationSms() {
|
||||
this.smsCode =
|
||||
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation(
|
||||
{
|
||||
fromName: this.registrationSessionManagerRef.receptionRef.options.name,
|
||||
toNumber: parseInt(this.collectedData.userData.mobileNumber),
|
||||
}
|
||||
);
|
||||
const smsCode =
|
||||
await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
|
||||
fromName: this.manager.receptionRef.options.name,
|
||||
toNumber: parseInt(this.data.collectedData.userData.mobileNumber),
|
||||
});
|
||||
this.data.smsCodeHash = RegistrationSession.hashToken(smsCode);
|
||||
await this.save();
|
||||
return smsCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* this method can be called when this registrationsession is validated
|
||||
* and all data has been set
|
||||
*/
|
||||
public async manifestUserWithAccountData(): Promise<User> {
|
||||
if (this.status !== 'mobileVerified') {
|
||||
if (this.data.status !== 'mobileVerified') {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'You can only manifest user that have a validated email Address and Mobile Number'
|
||||
);
|
||||
}
|
||||
if (!this.collectedData) {
|
||||
if (!this.data.collectedData) {
|
||||
throw new Error('You have to set the accountdata first');
|
||||
}
|
||||
const manifestedUser =
|
||||
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||
this.collectedData.userData
|
||||
);
|
||||
const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||
this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
|
||||
);
|
||||
this.data.status = 'registered';
|
||||
await this.save();
|
||||
return manifestedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* destroys the registrationsession
|
||||
*/
|
||||
public destroy() {
|
||||
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
|
||||
public async destroy() {
|
||||
await this.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import { logger } from './logging.js';
|
||||
|
||||
export class RegistrationSessionManager {
|
||||
public receptionRef: Reception;
|
||||
|
||||
public registrationSessions = new plugins.lik.FastMap<RegistrationSession>();
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
@@ -29,17 +33,16 @@ export class RegistrationSessionManager {
|
||||
`We sent you an Email with more information.`
|
||||
);
|
||||
}
|
||||
// check for exiting SignupSession
|
||||
const existingSession = this.registrationSessions.getByKey(requestData.email);
|
||||
if (existingSession) {
|
||||
|
||||
const existingSessions = await this.CRegistrationSession.getInstances({
|
||||
'data.emailAddress': requestData.email,
|
||||
});
|
||||
for (const existingSession of existingSessions) {
|
||||
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
||||
existingSession.destroy();
|
||||
await existingSession.destroy();
|
||||
}
|
||||
|
||||
// lets check the email before we create a signup session
|
||||
|
||||
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
||||
this,
|
||||
requestData.email
|
||||
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
||||
console.log(e.errorText);
|
||||
@@ -63,10 +66,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
'afterRegistrationEmailClicked',
|
||||
async (requestData) => {
|
||||
console.log(requestData);
|
||||
const signupSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const signupSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (signupSession) {
|
||||
return {
|
||||
email: signupSession.emailAddress,
|
||||
@@ -86,9 +86,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
'setDataForRegistration',
|
||||
async (requestData) => {
|
||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (!registrationSession) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'could not find a matching signupsession'
|
||||
@@ -114,9 +112,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
'mobileVerificationForRegistration',
|
||||
async (requestData) => {
|
||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (!registrationSession) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'could not find a matching signupsession'
|
||||
@@ -131,17 +127,16 @@ export class RegistrationSessionManager {
|
||||
}
|
||||
|
||||
if (requestData.mobileNumber) {
|
||||
registrationSession.status = 'emailValidated';
|
||||
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
||||
await registrationSession.sendValidationSms();
|
||||
const smsCode = await registrationSession.sendValidationSms();
|
||||
return {
|
||||
messageSent: true,
|
||||
testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null,
|
||||
testOnlySmsCode: process.env.TEST_MODE ? smsCode : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestData.verificationCode) {
|
||||
const validationResult = registrationSession.validateSmsCode(
|
||||
const validationResult = await registrationSession.validateSmsCode(
|
||||
requestData.verificationCode
|
||||
);
|
||||
return {
|
||||
@@ -160,9 +155,7 @@ export class RegistrationSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
'finishRegistration',
|
||||
async (requestData) => {
|
||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
||||
itemArg.validateEmailToken(requestData.token)
|
||||
);
|
||||
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||
if (!registrationSession) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'could not find a matching signupsession'
|
||||
@@ -170,7 +163,7 @@ export class RegistrationSessionManager {
|
||||
}
|
||||
|
||||
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
||||
registrationSession.destroy();
|
||||
await registrationSession.destroy();
|
||||
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
||||
return {
|
||||
accountData: {
|
||||
@@ -187,4 +180,17 @@ export class RegistrationSessionManager {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async findRegistrationSessionByToken(tokenArg: string) {
|
||||
const registrationSession = await this.CRegistrationSession.getInstance({
|
||||
'data.hashedEmailToken': RegistrationSession.hashToken(tokenArg),
|
||||
});
|
||||
|
||||
if (!registrationSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await registrationSession.validateEmailToken(tokenArg);
|
||||
return isValid ? registrationSession : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './loint-reception.activity.js';
|
||||
export * from './loint-reception.app.js';
|
||||
export * from './loint-reception.emailactiontoken.js';
|
||||
export * from './loint-reception.oidc.js';
|
||||
export * from './loint-reception.appconnection.js';
|
||||
export * from './loint-reception.billingplan.js';
|
||||
@@ -8,6 +9,7 @@ export * from './loint-reception.jwt.js';
|
||||
export * from './loint-reception.loginsession.js';
|
||||
export * from './loint-reception.organization.js';
|
||||
export * from './loint-reception.paddlecheckoutdata.js';
|
||||
export * from './loint-reception.registrationsession.js';
|
||||
export * from './loint-reception.role.js';
|
||||
export * from './loint-reception.user.js';
|
||||
export * from './loint-reception.userinvitation.js';
|
||||
|
||||
@@ -11,86 +11,94 @@ export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'rol
|
||||
* Authorization code for OAuth 2.0 authorization code flow
|
||||
*/
|
||||
export interface IAuthorizationCode {
|
||||
/** The authorization code string */
|
||||
code: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID who authorized */
|
||||
userId: string;
|
||||
/** Scopes granted */
|
||||
scopes: TOidcScope[];
|
||||
/** Redirect URI used in authorization request */
|
||||
redirectUri: string;
|
||||
/** PKCE code challenge (S256 hashed) */
|
||||
codeChallenge?: string;
|
||||
/** PKCE code challenge method */
|
||||
codeChallengeMethod?: 'S256';
|
||||
/** Nonce from authorization request (for ID token) */
|
||||
nonce?: string;
|
||||
/** Expiration timestamp (10 minutes from creation) */
|
||||
expiresAt: number;
|
||||
/** Whether the code has been used (single-use) */
|
||||
used: boolean;
|
||||
id: string;
|
||||
data: {
|
||||
/** Hashed authorization code string */
|
||||
codeHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID who authorized */
|
||||
userId: string;
|
||||
/** Scopes granted */
|
||||
scopes: TOidcScope[];
|
||||
/** Redirect URI used in authorization request */
|
||||
redirectUri: string;
|
||||
/** PKCE code challenge (S256 hashed) */
|
||||
codeChallenge?: string;
|
||||
/** PKCE code challenge method */
|
||||
codeChallengeMethod?: 'S256';
|
||||
/** Nonce from authorization request (for ID token) */
|
||||
nonce?: string;
|
||||
/** Expiration timestamp (10 minutes from creation) */
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
/** Whether the code has been used (single-use) */
|
||||
used: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Access Token (opaque or JWT)
|
||||
*/
|
||||
export interface IOidcAccessToken {
|
||||
/** Token identifier */
|
||||
id: string;
|
||||
/** The access token string (or hash for storage) */
|
||||
tokenHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
/** Granted scopes */
|
||||
scopes: TOidcScope[];
|
||||
/** Expiration timestamp */
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
data: {
|
||||
/** The access token string hash for storage */
|
||||
tokenHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
/** Granted scopes */
|
||||
scopes: TOidcScope[];
|
||||
/** Expiration timestamp */
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Refresh Token
|
||||
*/
|
||||
export interface IOidcRefreshToken {
|
||||
/** Token identifier */
|
||||
id: string;
|
||||
/** The refresh token string (or hash for storage) */
|
||||
tokenHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
/** Granted scopes */
|
||||
scopes: TOidcScope[];
|
||||
/** Expiration timestamp */
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
/** Whether the token has been revoked */
|
||||
revoked: boolean;
|
||||
data: {
|
||||
/** The refresh token string hash for storage */
|
||||
tokenHash: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
/** Granted scopes */
|
||||
scopes: TOidcScope[];
|
||||
/** Expiration timestamp */
|
||||
expiresAt: number;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
/** Whether the token has been revoked */
|
||||
revoked: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User consent record for an OAuth client
|
||||
*/
|
||||
export interface IUserConsent {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** User who gave consent */
|
||||
userId: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** Scopes the user consented to */
|
||||
scopes: TOidcScope[];
|
||||
/** When consent was granted */
|
||||
grantedAt: number;
|
||||
/** When consent was last updated */
|
||||
updatedAt: number;
|
||||
data: {
|
||||
/** User who gave consent */
|
||||
userId: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** Scopes the user consented to */
|
||||
scopes: TOidcScope[];
|
||||
/** When consent was granted */
|
||||
grantedAt: number;
|
||||
/** When consent was last updated */
|
||||
updatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.17.1',
|
||||
version: '1.19.1',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user