feat(auth): add abuse protection for login and OIDC flows with consent-based authorization handling

This commit is contained in:
2026-04-20 09:46:13 +00:00
parent 21f5abb49b
commit 29a21fd3b3
36 changed files with 1129 additions and 84 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.19.1',
version: '1.20.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
@@ -0,0 +1,102 @@
import * as plugins from '../plugins.js';
import { Reception } from './classes.reception.js';
import { AbuseWindow } from './classes.abusewindow.js';
export interface IAbuseProtectionConfig {
maxAttempts: number;
windowMillis: number;
blockDurationMillis: number;
}
export class AbuseProtectionManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CAbuseWindow = plugins.smartdata.setDefaultManagerForDoc(this, AbuseWindow);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
}
private normalizeIdentifier(identifierArg: string) {
return identifierArg.trim().toLowerCase();
}
private hashIdentifier(identifierArg: string) {
return plugins.smarthash.sha256FromStringSync(this.normalizeIdentifier(identifierArg));
}
private createWindowId(actionArg: string, identifierArg: string) {
return plugins.smarthash.sha256FromStringSync(
`${actionArg}:${this.hashIdentifier(identifierArg)}`
);
}
private async getWindow(actionArg: string, identifierArg: string) {
return this.CAbuseWindow.getInstance({
id: this.createWindowId(actionArg, identifierArg),
});
}
public async consumeAttempt(
actionArg: string,
identifierArg: string,
configArg: IAbuseProtectionConfig,
errorTextArg = 'Too many attempts. Please wait before trying again.'
) {
const now = Date.now();
let abuseWindow = await this.getWindow(actionArg, identifierArg);
if (!abuseWindow) {
abuseWindow = new AbuseWindow();
abuseWindow.id = this.createWindowId(actionArg, identifierArg);
abuseWindow.data.action = actionArg;
abuseWindow.data.identifierHash = this.hashIdentifier(identifierArg);
abuseWindow.data.createdAt = now;
}
if (abuseWindow.isBlocked(now)) {
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
}
if (abuseWindow.data.blockedUntil && abuseWindow.data.blockedUntil <= now) {
abuseWindow.data.attemptCount = 0;
abuseWindow.data.windowStartedAt = now;
abuseWindow.data.blockedUntil = 0;
}
if (
!abuseWindow.data.windowStartedAt ||
abuseWindow.data.windowStartedAt + configArg.windowMillis <= now
) {
abuseWindow.data.attemptCount = 0;
abuseWindow.data.windowStartedAt = now;
}
abuseWindow.data.attemptCount += 1;
abuseWindow.data.updatedAt = now;
abuseWindow.data.validUntil = now + configArg.windowMillis;
if (abuseWindow.data.attemptCount > configArg.maxAttempts) {
abuseWindow.data.blockedUntil = now + configArg.blockDurationMillis;
abuseWindow.data.validUntil = abuseWindow.data.blockedUntil;
await abuseWindow.save();
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
}
await abuseWindow.save();
}
public async clearAttempts(actionArg: string, identifierArg: string) {
const abuseWindow = await this.getWindow(actionArg, identifierArg);
if (!abuseWindow) {
return;
}
await abuseWindow.delete();
}
}
+33
View File
@@ -0,0 +1,33 @@
import * as plugins from '../plugins.js';
import type { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
@plugins.smartdata.Manager()
export class AbuseWindow extends plugins.smartdata.SmartDataDbDoc<
AbuseWindow,
plugins.idpInterfaces.data.IAbuseWindow,
AbuseProtectionManager
> {
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IAbuseWindow['data'] = {
action: '',
identifierHash: '',
attemptCount: 0,
windowStartedAt: 0,
blockedUntil: 0,
validUntil: 0,
createdAt: 0,
updatedAt: 0,
};
public isBlocked(nowArg = Date.now()) {
return this.data.blockedUntil > nowArg;
}
public isExpired(nowArg = Date.now()) {
return this.data.validUntil < nowArg;
}
}
+20
View File
@@ -74,6 +74,26 @@ export class ReceptionHousekeeping {
'2 * * * * *'
);
this.taskmanager.addAndScheduleTask(
new plugins.taskbuffer.Task({
name: 'expiredAbuseWindows',
taskFunction: async () => {
const expiredAbuseWindows =
await this.receptionRef.abuseProtectionManager.CAbuseWindow.getInstances({
data: {
validUntil: {
$lt: Date.now(),
} as any,
},
});
for (const abuseWindow of expiredAbuseWindows) {
await abuseWindow.delete();
}
},
}),
'2 * * * * *'
);
this.taskmanager.start();
logger.log('info', 'housekeeping started');
}
@@ -5,6 +5,34 @@ import { Reception } from './classes.reception.js';
import { logger } from './logging.js';
export class LoginSessionManager {
private readonly abuseProtectionConfigs = {
passwordLogin: {
maxAttempts: 5,
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
},
emailLoginRequest: {
maxAttempts: 5,
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
},
emailLoginToken: {
maxAttempts: 5,
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
},
passwordResetRequest: {
maxAttempts: 5,
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
},
passwordResetCompletion: {
maxAttempts: 5,
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
},
};
// refs
public receptionRef: Reception;
public get db() {
@@ -23,6 +51,14 @@ export class LoginSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword',
async (requestData) => {
const loginIdentifier = requestData.username;
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'passwordLogin',
loginIdentifier,
this.abuseProtectionConfigs.passwordLogin,
'Too many login attempts. Please wait before trying again.'
);
let user = await this.receptionRef.userManager.CUser.getInstance({
data: {
username: requestData.username,
@@ -54,6 +90,11 @@ export class LoginSessionManager {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
await this.receptionRef.abuseProtectionManager.clearAttempts(
'passwordLogin',
loginIdentifier
);
return {
refreshToken,
twoFaNeeded: false,
@@ -69,6 +110,12 @@ export class LoginSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'loginWithEmail',
async (requestDataArg) => {
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'emailLoginRequest',
requestDataArg.email,
this.abuseProtectionConfigs.emailLoginRequest,
'Too many magic link requests. Please wait before trying again.'
);
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
data: {
@@ -101,6 +148,12 @@ export class LoginSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
'loginWithEmailAfterEmailTokenAquired',
async (requestArg) => {
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'emailLoginToken',
requestArg.email,
this.abuseProtectionConfigs.emailLoginToken,
'Too many magic link attempts. Please wait before trying again.'
);
const tokenObject = await this.consumeEmailActionToken(
requestArg.email,
requestArg.token,
@@ -120,6 +173,10 @@ export class LoginSessionManager {
if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
await this.receptionRef.abuseProtectionManager.clearAttempts(
'emailLoginToken',
requestArg.email
);
return {
refreshToken,
};
@@ -188,6 +245,12 @@ export class LoginSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
'resetPassword',
async (requestDataArg) => {
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'passwordResetRequest',
requestDataArg.email,
this.abuseProtectionConfigs.passwordResetRequest,
'Too many password reset requests. Please wait before trying again.'
);
const emailOfPasswordToReset = requestDataArg.email;
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
data: {
@@ -216,6 +279,12 @@ export class LoginSessionManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
'setNewPassword',
async (requestData) => {
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'passwordResetCompletion',
requestData.email,
this.abuseProtectionConfigs.passwordResetCompletion,
'Too many password change attempts. Please wait before trying again.'
);
const user = await this.receptionRef.userManager.CUser.getInstance({
data: {
email: requestData.email,
@@ -253,6 +322,10 @@ export class LoginSessionManager {
requestData.newPassword
);
await user.save();
await this.receptionRef.abuseProtectionManager.clearAttempts(
'passwordResetCompletion',
requestData.email
);
return {
status: 'ok',
};
+197 -4
View File
@@ -11,11 +11,21 @@ import { OidcUserConsent } from './classes.oidcuserconsent.js';
* for third-party client authentication.
*/
export class OidcManager {
private readonly abuseProtectionConfig = {
oidcTokenExchange: {
maxAttempts: 10,
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
},
};
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedRouter = new plugins.typedrequest.TypedRouter();
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
this,
OidcAuthorizationCode
@@ -31,6 +41,35 @@ export class OidcManager {
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
'prepareOidcAuthorization',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
return this.prepareAuthorizationForUser(jwt.data.userId, requestArg);
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
'completeOidcAuthorization',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
return this.completeAuthorizationForUser(jwt.data.userId, requestArg);
}
)
);
this.startCleanupTask();
}
@@ -128,6 +167,10 @@ export class OidcManager {
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
}
if (prompt && !this.isSupportedPrompt(prompt)) {
return this.errorResponse('invalid_request', 'Unsupported prompt value');
}
// Validate code challenge method if present
if (codeChallenge && codeChallengeMethod !== 'S256') {
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
@@ -169,6 +212,9 @@ export class OidcManager {
if (nonce) {
loginUrl.searchParams.set('nonce', nonce);
}
if (prompt) {
loginUrl.searchParams.set('prompt', prompt);
}
return Response.redirect(loginUrl.toString(), 302);
}
@@ -202,10 +248,71 @@ export class OidcManager {
};
await authCode.save();
await this.upsertUserConsent(userId, clientId, scopes);
return code;
}
public async prepareAuthorizationForUser(
userIdArg: string,
requestArg: Omit<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['request'], 'jwt'>
): Promise<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response']> {
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
const consentState = await this.evaluateConsentRequirement(
userIdArg,
resolvedRequest.clientId,
resolvedRequest.validScopes,
resolvedRequest.prompt
);
return {
status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const),
clientId: resolvedRequest.clientId,
appName: resolvedRequest.app.data.name,
appUrl: resolvedRequest.app.data.appUrl,
logoUrl: resolvedRequest.app.data.logoUrl,
requestedScopes: resolvedRequest.validScopes,
grantedScopes: consentState.grantedScopes,
};
}
public async completeAuthorizationForUser(
userIdArg: string,
requestArg: Omit<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'jwt'>
) {
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
const consentState = await this.evaluateConsentRequirement(
userIdArg,
resolvedRequest.clientId,
resolvedRequest.validScopes,
resolvedRequest.prompt
);
if (consentState.consentRequired && !requestArg.consentApproved) {
throw new Error('Consent required');
}
if (requestArg.consentApproved) {
await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes);
}
const code = await this.generateAuthorizationCode(
resolvedRequest.clientId,
userIdArg,
resolvedRequest.validScopes,
resolvedRequest.redirectUri,
resolvedRequest.codeChallenge,
resolvedRequest.nonce
);
const redirectUrl = new URL(resolvedRequest.redirectUri);
redirectUrl.searchParams.set('code', code);
redirectUrl.searchParams.set('state', resolvedRequest.state);
return {
code,
redirectUrl: redirectUrl.toString(),
};
}
/**
* Handle the token endpoint request
*/
@@ -236,6 +343,13 @@ export class OidcManager {
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
}
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'oidcTokenExchange',
clientId,
this.abuseProtectionConfig.oidcTokenExchange,
'Too many token endpoint attempts. Please wait before retrying.'
);
// Find and validate app
const app = await this.findAppByClientId(clientId);
if (!app) {
@@ -250,13 +364,20 @@ export class OidcManager {
}
}
let response: Response;
if (grantType === 'authorization_code') {
return this.handleAuthorizationCodeGrant(formData, app);
response = await this.handleAuthorizationCodeGrant(formData, app);
} else if (grantType === 'refresh_token') {
return this.handleRefreshTokenGrant(formData, app);
response = await this.handleRefreshTokenGrant(formData, app);
} else {
return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
}
if (response.status === 200) {
await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId);
}
return response;
}
/**
@@ -625,6 +746,78 @@ export class OidcManager {
return apps[0] || null;
}
private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' {
return ['none', 'login', 'consent'].includes(promptArg);
}
private async resolveAuthorizationRequest(
requestArg: Pick<
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce'
>
) {
if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) {
throw new Error('Missing required OAuth authorization parameters');
}
if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) {
throw new Error('Unsupported prompt value');
}
if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') {
throw new Error('Only S256 code challenge method is supported');
}
const app = await this.findAppByClientId(requestArg.clientId);
if (!app) {
throw new Error('Unknown client_id');
}
if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) {
throw new Error('Invalid redirect_uri');
}
const requestedScopes = requestArg.scope
.split(' ')
.filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[];
const allowedScopes =
app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg));
if (!validScopes.includes('openid')) {
throw new Error('openid scope is required');
}
return {
app,
clientId: requestArg.clientId,
redirectUri: requestArg.redirectUri,
state: requestArg.state,
prompt: requestArg.prompt,
codeChallenge: requestArg.codeChallenge,
codeChallengeMethod: requestArg.codeChallengeMethod,
nonce: requestArg.nonce,
validScopes,
};
}
private async evaluateConsentRequirement(
userIdArg: string,
clientIdArg: string,
scopesArg: plugins.idpInterfaces.data.TOidcScope[],
promptArg?: 'none' | 'login' | 'consent'
) {
const existingConsent = await this.getUserConsent(userIdArg, clientIdArg);
const grantedScopes = existingConsent?.data.scopes || [];
const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg));
return {
grantedScopes,
missingScopes,
consentRequired: promptArg === 'consent' || missingScopes.length > 0,
};
}
private createOpaqueToken(byteLength = 32): string {
return plugins.crypto.randomBytes(byteLength).toString('base64url');
}
+2
View File
@@ -17,6 +17,7 @@ import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
import { OidcManager } from './classes.oidcmanager.js';
import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
export interface IReceptionOptions {
/**
@@ -48,6 +49,7 @@ export class Reception {
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public userInvitationManager = new UserInvitationManager(this);
public abuseProtectionManager = new AbuseProtectionManager(this);
public oidcManager = new OidcManager(this);
housekeeping = new ReceptionHousekeeping(this);