Compare commits

..

2 Commits

Author SHA1 Message Date
jkunz 3cd7499f3f v1.20.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 09:46:13 +00:00
jkunz 29a21fd3b3 feat(auth): add abuse protection for login and OIDC flows with consent-based authorization handling 2026-04-20 09:46:13 +00:00
37 changed files with 1130 additions and 85 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 2026-04-20 - 1.20.0 - feat(auth)
add abuse protection for login and OIDC flows with consent-based authorization handling
- introduces AbuseProtectionManager and AbuseWindow storage to rate limit password login, magic link, password reset, and OIDC token exchange attempts
- adds housekeeping cleanup for expired abuse protection windows
- adds typed OIDC prepare/complete authorization requests plus consent evaluation and redirect URL generation
- updates the login prompt to support OIDC authorization continuation after user login or consent
- includes tests for abuse protection behavior and OIDC authorization preparation/completion flows
## 2026-04-20 - 1.19.1 - fix(ts_interfaces)
rename generated TypeScript interface files to remove the loint-reception prefix
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@idp.global/idp.global",
"version": "1.19.1",
"version": "1.20.0",
"description": "An identity provider software managing user authentications, registrations, and sessions.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
+94
View File
@@ -0,0 +1,94 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
AbuseProtectionManager,
type IAbuseProtectionConfig,
} from '../ts/reception/classes.abuseprotectionmanager.js';
import { AbuseWindow } from '../ts/reception/classes.abusewindow.js';
const createTestAbuseProtectionManager = () => {
const manager = new AbuseProtectionManager({
db: { smartdataDb: {} },
} as any);
const store = new Map<string, AbuseWindow>();
const originalSave = AbuseWindow.prototype.save;
const originalDelete = AbuseWindow.prototype.delete;
(AbuseWindow.prototype as AbuseWindow & { save: () => Promise<void> }).save = async function () {
store.set(this.id, this);
};
(AbuseWindow.prototype as AbuseWindow & { delete: () => Promise<void> }).delete = async function () {
store.delete(this.id);
};
(manager as any).CAbuseWindow = {
getInstance: async (queryArg) => store.get(queryArg.id) ?? null,
};
const restore = () => {
AbuseWindow.prototype.save = originalSave;
AbuseWindow.prototype.delete = originalDelete;
};
return {
manager,
store,
restore,
};
};
const testConfig: IAbuseProtectionConfig = {
maxAttempts: 2,
windowMillis: 1_000,
blockDurationMillis: 2_000,
};
tap.test('blocks after too many attempts within the active window', async () => {
const { manager, restore } = createTestAbuseProtectionManager();
try {
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
} finally {
restore();
}
});
tap.test('resets attempts after the block and window have elapsed', async () => {
const { manager, store, restore } = createTestAbuseProtectionManager();
try {
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
const abuseWindow = Array.from(store.values())[0];
abuseWindow.data.blockedUntil = Date.now() - 10;
abuseWindow.data.windowStartedAt = Date.now() - testConfig.windowMillis - 10;
abuseWindow.data.validUntil = Date.now() + 1_000;
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
expect(abuseWindow.data.attemptCount).toEqual(1);
} finally {
restore();
}
});
tap.test('clears stored attempts after a successful action', async () => {
const { manager, store, restore } = createTestAbuseProtectionManager();
try {
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
expect(store.size).toEqual(1);
await manager.clearAttempts('passwordLogin', 'phil@example.com');
expect(store.size).toEqual(0);
} finally {
restore();
}
});
export default tap.start();
+132
View File
@@ -2,9 +2,20 @@ 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 { OidcManager } from '../ts/reception/classes.oidcmanager.js';
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
const createTestOidcManager = () => {
const oidcManager = new OidcManager({
db: { smartdataDb: {} },
typedrouter: { addTypedRouter: () => undefined },
options: { baseUrl: 'https://idp.example' },
} as any);
void oidcManager.stop();
return oidcManager;
};
tap.test('stores authorization codes as hashes and marks them used', async () => {
const authCode = new OidcAuthorizationCode();
authCode.id = 'oidc-auth-code';
@@ -73,4 +84,125 @@ tap.test('merges user consent scopes without duplicates', async () => {
expect(saveCount).toEqual(1);
});
tap.test('builds an OAuth redirect URL after successful authorization completion', async () => {
const oidcManager = createTestOidcManager();
(oidcManager as any).findAppByClientId = async () => ({
data: {
name: 'Example App',
appUrl: 'https://app.example',
logoUrl: 'https://app.example/logo.png',
oauthCredentials: {
clientId: 'client-1',
redirectUris: ['https://app.example/callback'],
allowedScopes: ['openid', 'profile', 'email'],
},
},
});
(oidcManager as any).generateAuthorizationCode = async () => 'generated-auth-code';
(oidcManager as any).getUserConsent = async () => ({
data: {
scopes: ['openid', 'profile', 'email'],
},
});
(oidcManager as any).upsertUserConsent = async () => undefined;
const result = await oidcManager.completeAuthorizationForUser('user-1', {
clientId: 'client-1',
redirectUri: 'https://app.example/callback',
scope: 'openid profile email',
state: 'xyz-state',
codeChallenge: 'challenge',
codeChallengeMethod: 'S256',
nonce: 'nonce-1',
consentApproved: true,
});
expect(result.code).toEqual('generated-auth-code');
expect(result.redirectUrl).toEqual(
'https://app.example/callback?code=generated-auth-code&state=xyz-state'
);
await oidcManager.stop();
});
tap.test('prepares OAuth consent when scopes are not yet granted', async () => {
const oidcManager = createTestOidcManager();
(oidcManager as any).findAppByClientId = async () => ({
data: {
name: 'Example App',
appUrl: 'https://app.example',
logoUrl: 'https://app.example/logo.png',
oauthCredentials: {
clientId: 'client-1',
redirectUris: ['https://app.example/callback'],
allowedScopes: ['openid', 'profile', 'email'],
},
},
});
(oidcManager as any).getUserConsent = async () => ({
data: {
scopes: ['openid'],
},
});
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
clientId: 'client-1',
redirectUri: 'https://app.example/callback',
scope: 'openid profile email',
state: 'xyz-state',
prompt: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
nonce: undefined,
});
expect(result.status).toEqual('consent_required');
expect(result.requestedScopes.sort()).toEqual(['email', 'openid', 'profile']);
expect(result.grantedScopes).toEqual(['openid']);
await oidcManager.stop();
});
tap.test('prepares OAuth authorization as ready when consent already exists', async () => {
const oidcManager = createTestOidcManager();
(oidcManager as any).findAppByClientId = async () => ({
data: {
name: 'Example App',
appUrl: 'https://app.example',
logoUrl: 'https://app.example/logo.png',
oauthCredentials: {
clientId: 'client-1',
redirectUris: ['https://app.example/callback'],
allowedScopes: ['openid', 'profile', 'email'],
},
},
});
(oidcManager as any).getUserConsent = async () => ({
data: {
scopes: ['openid', 'profile', 'email'],
},
});
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
clientId: 'client-1',
redirectUri: 'https://app.example/callback',
scope: 'openid profile email',
state: 'xyz-state',
prompt: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
nonce: undefined,
});
expect(result.status).toEqual('ready');
await oidcManager.stop();
});
export default tap.start();
+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);
+12
View File
@@ -76,6 +76,18 @@ export class IdpRequests {
);
}
public get completeOidcAuthorization() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
'completeOidcAuthorization'
);
}
public get prepareOidcAuthorization() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
'prepareOidcAuthorization'
);
}
public get resetPassword() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
'resetPassword'
+13
View File
@@ -0,0 +1,13 @@
export interface IAbuseWindow {
id: string;
data: {
action: string;
identifierHash: string;
attemptCount: number;
windowStartedAt: number;
blockedUntil: number;
validUntil: number;
createdAt: number;
updatedAt: number;
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { TAppType } from './loint-reception.app.js';
import type { TAppType } from './app.js';
export type TAppConnectionStatus = 'active' | 'disconnected';
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
export type TSupportedCurrency = 'EUR';
+1 -1
View File
@@ -1,3 +1,3 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
export interface IDevice extends plugins.tsclass.network.IDevice {}
+16 -15
View File
@@ -1,15 +1,16 @@
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';
export * from './loint-reception.device.js';
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';
export * from './abusewindow.js';
export * from './activity.js';
export * from './app.js';
export * from './emailactiontoken.js';
export * from './oidc.js';
export * from './appconnection.js';
export * from './billingplan.js';
export * from './device.js';
export * from './jwt.js';
export * from './loginsession.js';
export * from './organization.js';
export * from './paddlecheckoutdata.js';
export * from './registrationsession.js';
export * from './role.js';
export * from './user.js';
export * from './userinvitation.js';
+3 -3
View File
@@ -1,6 +1,6 @@
import * as plugins from '../loint-reception.plugins.js';
import { type IBillingPlan } from './loint-reception.billingplan.js';
import { type IRole } from './loint-reception.role.js';
import * as plugins from '../plugins.js';
import { type IBillingPlan } from './billingplan.js';
import { type IRole } from './role.js';
export interface IOrganization {
id: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import * as plugins from '../loint-reception.plugins.js';
import { type IRole } from './loint-reception.role.js';
import * as plugins from '../plugins.js';
import { type IRole } from './role.js';
export interface ISubOrgProperty {
name: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
/** Standard role types available in all organizations */
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
+2 -2
View File
@@ -1,5 +1,5 @@
import * as plugins from '../loint-reception.plugins.js';
import { type IRole } from './loint-reception.role.js';
import * as plugins from '../plugins.js';
import { type IRole } from './role.js';
export interface IUser {
id: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
/**
* A UserInvitation represents an invitation to join an organization.
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
import * as data from '../data/index.js';
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
// Get all global apps
export interface IReq_GetGlobalApps
+54 -1
View File
@@ -1,5 +1,6 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
import { type IUser, type IRole } from '../data/index.js';
import { type TOidcScope } from '../data/index.js';
export interface IReq_InternalAuthorization
extends plugins.typedRequestInterfaces.implementsTR<
@@ -17,3 +18,55 @@ export interface IReq_InternalAuthorization
relevantRoles: IRole[];
};
}
export interface IReq_CompleteOidcAuthorization
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CompleteOidcAuthorization
> {
method: 'completeOidcAuthorization';
request: {
jwt: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
prompt?: 'none' | 'login' | 'consent';
codeChallenge?: string;
codeChallengeMethod?: 'S256';
nonce?: string;
consentApproved?: boolean;
};
response: {
code: string;
redirectUrl: string;
};
}
export interface IReq_PrepareOidcAuthorization
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_PrepareOidcAuthorization
> {
method: 'prepareOidcAuthorization';
request: {
jwt: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
prompt?: 'none' | 'login' | 'consent';
codeChallenge?: string;
codeChallengeMethod?: 'S256';
nonce?: string;
};
response: {
status: 'ready' | 'consent_required';
clientId: string;
appName: string;
appUrl: string;
logoUrl?: string;
requestedScopes: TOidcScope[];
grantedScopes: TOidcScope[];
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
import * as data from '../data/index.js';
export interface IReq_UpdatePaymentMethod
+12 -12
View File
@@ -1,12 +1,12 @@
export * from './loint-reception.admin.js';
export * from './loint-reception.apitoken.js';
export * from './loint-reception.app.js';
export * from './loint-reception.authorization.js';
export * from './loint-reception.billingplan.js';
export * from './loint-reception.jwt.js';
export * from './loint-reception.login.js';
export * from './loint-reception.organization.js';
export * from './loint-reception.plan.js';
export * from './loint-reception.registration.js';
export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
export * from './admin.js';
export * from './apitoken.js';
export * from './app.js';
export * from './authorization.js';
export * from './billingplan.js';
export * from './jwt.js';
export * from './login.js';
export * from './organization.js';
export * from './plan.js';
export * from './registration.js';
export * from './user.js';
export * from './userinvitation.js';
+1 -1
View File
@@ -1,5 +1,5 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
/**
* Request to get the public key for JWT validation.
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
import * as data from '../data/index.js';
export interface IReq_LoginWithEmailOrUsernameAndPassword
+1 -1
View File
@@ -1,5 +1,5 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
export interface IReq_GetOrganizationById
extends plugins.typedRequestInterfaces.implementsTR<
+1 -1
View File
@@ -1,5 +1,5 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
export interface IReq_GetPlansForOrganizationId
extends plugins.typedRequestInterfaces.implementsTR<
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
import { type IUser } from '../data/index.js';
export interface IReq_FirstRegistration
+1 -1
View File
@@ -1,5 +1,5 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
export interface IReq_GetUserData
extends plugins.typedRequestInterfaces.implementsTR<
+1 -1
View File
@@ -1,5 +1,5 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
/**
* Create an invitation to join an organization
+1 -1
View File
@@ -1,4 +1,4 @@
import * as plugins from '../loint-reception.plugins.js';
import * as plugins from '../plugins.js';
export interface ITag_LolePubapi
extends plugins.typedRequestInterfaces.implementsTag<
+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.'
}
+335 -27
View File
@@ -12,7 +12,6 @@ import {
domtools,
} from '@design.estate/dees-element';
// third party catalogs
import '@uptime.link/webwidget';
import '@design.estate/dees-catalog';
@@ -29,6 +28,12 @@ declare global {
export class IdpLoginPrompt extends DeesElement {
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
@state()
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
@state()
accessor oidcConsentError = '';
@property()
accessor productOfInterest: string;
@@ -48,6 +53,155 @@ export class IdpLoginPrompt extends DeesElement {
domtools.elementBasic.setup();
}
private getOidcAuthorizationContext(): Omit<
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
'jwt'
> | null {
const currentUrl = plugins.smarturl.Smarturl.createFromUrl(window.location.href);
if (currentUrl.searchParams.oauth !== 'true') {
return null;
}
const clientId = currentUrl.searchParams.client_id;
const redirectUri = currentUrl.searchParams.redirect_uri;
const scope = currentUrl.searchParams.scope;
const state = currentUrl.searchParams.state;
if (!clientId || !redirectUri || !scope || !state) {
return null;
}
const prompt = ['none', 'login', 'consent'].includes(currentUrl.searchParams.prompt)
? (currentUrl.searchParams.prompt as 'none' | 'login' | 'consent')
: undefined;
return {
clientId,
redirectUri,
scope,
state,
prompt,
codeChallenge: currentUrl.searchParams.code_challenge || undefined,
codeChallengeMethod:
currentUrl.searchParams.code_challenge_method === 'S256' ? 'S256' : undefined,
nonce: currentUrl.searchParams.nonce || undefined,
};
}
private redirectOidcError(errorArg: string, descriptionArg?: string) {
const oidcContext = this.getOidcAuthorizationContext();
if (!oidcContext) {
return false;
}
const redirectUrl = new URL(oidcContext.redirectUri);
redirectUrl.searchParams.set('error', errorArg);
redirectUrl.searchParams.set('state', oidcContext.state);
if (descriptionArg) {
redirectUrl.searchParams.set('error_description', descriptionArg);
}
window.location.href = redirectUrl.toString();
return true;
}
private getOidcScopeDescription(scopeArg: plugins.idpInterfaces.data.TOidcScope) {
const scopeMap: Record<plugins.idpInterfaces.data.TOidcScope, string> = {
openid: 'Confirm your identity with this app.',
profile: 'Share your display name and username.',
email: 'Share your email address.',
organizations: 'Share your organizations and their roles.',
roles: 'Share your platform roles.',
};
return scopeMap[scopeArg];
}
private getOidcAppHost(appUrlArg: string) {
try {
return new URL(appUrlArg).hostname;
} catch {
return appUrlArg;
}
}
private async prepareOidcAuthorization(jwtArg: string) {
const oidcContext = this.getOidcAuthorizationContext();
if (!oidcContext) {
return null;
}
const idpState = await IdpState.getSingletonInstance();
return idpState.idpClient.requests.prepareOidcAuthorization
.fire({
jwt: jwtArg,
...oidcContext,
})
.catch(() => null);
}
private async handleOidcAfterLogin(jwtArg: string) {
const oidcContext = this.getOidcAuthorizationContext();
if (!oidcContext) {
return false;
}
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
loginForm?.setStatus('pending', 'preparing application authorization...');
this.oidcConsentError = '';
const preparation = await this.prepareOidcAuthorization(jwtArg);
if (!preparation) {
loginForm?.setStatus('error', 'could not prepare the application authorization');
return true;
}
if (preparation.status === 'consent_required') {
if (oidcContext.prompt === 'none') {
this.redirectOidcError('consent_required');
return true;
}
this.oidcConsentState = preparation;
return true;
}
await this.completeOidcAuthorization(jwtArg);
return true;
}
private async completeOidcAuthorization(jwtArg: string, consentApproved = false) {
const oidcContext = this.getOidcAuthorizationContext();
if (!oidcContext) {
return false;
}
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
loginForm?.setStatus('pending', 'authorizing application...');
this.oidcConsentError = '';
const response = await idpState.idpClient.requests.completeOidcAuthorization
.fire({
jwt: jwtArg,
...oidcContext,
consentApproved,
})
.catch(() => null);
if (!response?.redirectUrl) {
if (this.oidcConsentState) {
this.oidcConsentError = 'Could not authorize the application.';
} else {
loginForm?.setStatus('error', 'could not authorize the application');
}
return false;
}
window.location.href = response.redirectUrl;
return true;
}
public static styles = [
cssManager.defaultStyles,
css`
@@ -103,10 +257,147 @@ export class IdpLoginPrompt extends DeesElement {
.form-footer a:hover {
opacity: 0.8;
}
.consent-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
}
.consent-appname {
font-size: 20px;
font-weight: 600;
}
.consent-appurl {
color: var(--muted-foreground);
font-size: 14px;
word-break: break-word;
}
.consent-scopes {
display: flex;
flex-direction: column;
gap: 12px;
}
.consent-scope {
padding: 14px 16px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
}
.consent-scope-header {
display: flex;
justify-content: space-between;
gap: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 12px;
}
.consent-scope-tag {
color: #9cd67c;
}
.consent-scope-description {
margin-top: 6px;
color: var(--muted-foreground);
font-size: 14px;
line-height: 1.5;
}
.consent-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.consent-button {
border: none;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
cursor: pointer;
}
.consent-button-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--foreground);
}
.consent-button-primary {
background: linear-gradient(135deg, #9b7bff, #5fd1ff);
color: #0a0a0a;
font-weight: 600;
}
.consent-error {
color: #ff9a9a;
font-size: 14px;
}
`,
];
public render(): TemplateResult {
if (this.oidcConsentState) {
return html`
<idp-centercontainer>
<div class="form-header">
<h2>Continue to ${this.oidcConsentState.appName}</h2>
<p>Review and approve the access this app is requesting.</p>
</div>
<div class="consent-card">
<div class="consent-appname">${this.oidcConsentState.appName}</div>
<div class="consent-appurl">${this.getOidcAppHost(this.oidcConsentState.appUrl)}</div>
<div class="consent-scopes">
${this.oidcConsentState.requestedScopes.map((scopeArg) => html`
<div class="consent-scope">
<div class="consent-scope-header">
<span>${scopeArg}</span>
${this.oidcConsentState.grantedScopes.includes(scopeArg)
? html`<span class="consent-scope-tag">Previously allowed</span>`
: null}
</div>
<div class="consent-scope-description">${this.getOidcScopeDescription(scopeArg)}</div>
</div>
`)}
</div>
${this.oidcConsentError ? html`<div class="consent-error">${this.oidcConsentError}</div>` : null}
<div class="consent-actions">
<button
class="consent-button consent-button-secondary"
@click=${() => {
this.redirectOidcError('access_denied');
}}
>
Cancel
</button>
<button
class="consent-button consent-button-primary"
@click=${async () => {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
if (!jwt) {
this.redirectOidcError('login_required');
return;
}
await this.completeOidcAuthorization(jwt, true);
}}
>
Allow and continue
</button>
</div>
</div>
</idp-centercontainer>
`;
}
return html`
<idp-centercontainer>
<div class="form-header">
@@ -115,12 +406,12 @@ export class IdpLoginPrompt extends DeesElement {
</div>
<dees-form
id="loginForm"
@formData="${(eventArg) => {
@formData=${(eventArg) => {
this.login({
emailAddress: eventArg.detail.data.emailAddress,
passwordArg: eventArg.detail.data.password,
});
}}"
}}
>
<dees-input-text
id="loginEmailInput"
@@ -137,7 +428,8 @@ export class IdpLoginPrompt extends DeesElement {
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
</dees-form>
<div class="form-footer">
Don't have an account? <a @click=${async () => {
Don't have an account?
<a @click=${async () => {
const idpState = await IdpState.getSingletonInstance();
idpState.domtools.router.pushUrl('/register');
}}>Create one</a>
@@ -147,32 +439,48 @@ export class IdpLoginPrompt extends DeesElement {
}
public async firstUpdated() {
const domtoolsInstance = await this.domtoolsPromise;
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
const loginPasswordInput: DeesInputText = loginForm.querySelector('#loginPasswordInput');
const loginSubmitButton: DeesFormSubmit = loginForm.querySelector('#loginSubmitButton');
await this.domtoolsPromise;
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as DeesInputText;
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as DeesFormSubmit;
const oidcContext = this.getOidcAuthorizationContext();
const setButtonText = async () => {
if (loginPasswordInput.value) {
console.log('updating text of loginprompt.');
loginSubmitButton.text = 'Login';
loginSubmitButton.text = oidcContext ? 'Sign in and continue' : 'Login';
} else {
loginSubmitButton.text = 'Send magic link (or enter password)';
}
};
loginForm.changeSubject.subscribe(() => {
console.log(`checking button text ${loginPasswordInput.value}`);
setButtonText();
void setButtonText();
});
setButtonText();
await setButtonText();
if (oidcContext) {
const loggedIn = await idpState.idpClient.determineLoginStatus(false);
if (!loggedIn && oidcContext.prompt === 'none') {
this.redirectOidcError('login_required');
return;
}
if (loggedIn && oidcContext.prompt !== 'login') {
const jwt = await idpState.idpClient.getJwt();
if (jwt) {
await this.handleOidcAfterLogin(jwt);
}
}
}
}
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
// lets disable the submit button
const loginSubmitButton: plugins.deesCatalog.DeesFormSubmit = this.shadowRoot.querySelector('#loginSubmitButton');
const loginSubmitButton = this.shadowRoot.querySelector(
'#loginSubmitButton'
) as plugins.deesCatalog.DeesFormSubmit;
loginSubmitButton.disabled = true;
// lets define the needed requests
const idpState = await IdpState.getSingletonInstance();
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
const loginRequestWithUsernameAndPassword =
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword'
@@ -182,19 +490,19 @@ export class IdpLoginPrompt extends DeesElement {
'loginWithEmail'
);
// lets do the actual logging in
if (valueArg.emailAddress && valueArg.passwordArg) {
loginForm.setStatus('pending', 'logging in...');
const response = await loginRequestWithUsernameAndPassword
.fire({
username: valueArg.emailAddress, // TODO: rename to emailAddress
username: valueArg.emailAddress,
password: valueArg.passwordArg,
})
.catch(() => {
loginForm.setStatus('error', 'could not log you in. Try Again!');
return;
return null;
});
if (!response) {
loginSubmitButton.disabled = false;
return;
}
if (response.refreshToken) {
@@ -202,11 +510,13 @@ export class IdpLoginPrompt extends DeesElement {
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
if (jwt) {
loginForm.setStatus('success', 'obtained jwt.');
idpState.domtools.router.pushUrl('/account');
const oidcHandled = await this.handleOidcAfterLogin(jwt);
if (!oidcHandled) {
idpState.domtools.router.pushUrl('/account');
}
} else {
loginForm.setStatus('error', 'something went wrong');
}
} else {
}
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
loginForm.setStatus('pending', 'sending magic link...');
@@ -216,13 +526,13 @@ export class IdpLoginPrompt extends DeesElement {
if (response.status === 'ok') {
loginForm.setStatus('success', 'Please check your email!');
}
console.log(response);
}
loginSubmitButton.disabled = false;
};
public async dispatchJwt(jwtArg?: string) {
if (jwtArg !== undefined) {
console.log(`dispatching jwt from loginprompt.`);
this.jwt = jwtArg;
await domtools.plugins.smartdelay.delayFor(200);
this.dispatchEvent(
@@ -237,9 +547,7 @@ export class IdpLoginPrompt extends DeesElement {
}
public async focus() {
(
this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText
).focus();
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText).focus();
}
public async show() {