@technomoron/apicore-server 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cjs/api-module.cjs +34 -0
- package/dist/cjs/api-module.d.ts +45 -0
- package/dist/cjs/apicore-server.cjs +1561 -0
- package/dist/cjs/apicore-server.d.ts +288 -0
- package/dist/cjs/auth-api/auth-module.cjs +1248 -0
- package/dist/cjs/auth-api/auth-module.d.ts +116 -0
- package/dist/cjs/auth-api/compat-auth-storage.cjs +128 -0
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +57 -0
- package/dist/cjs/auth-api/mem-auth-store.cjs +121 -0
- package/dist/cjs/auth-api/mem-auth-store.d.ts +68 -0
- package/dist/cjs/auth-api/module.cjs +25 -0
- package/dist/cjs/auth-api/module.d.ts +20 -0
- package/dist/cjs/auth-api/schemas.cjs +171 -0
- package/dist/cjs/auth-api/schemas.d.ts +21 -0
- package/dist/cjs/auth-api/sql-auth-store.cjs +179 -0
- package/dist/cjs/auth-api/sql-auth-store.d.ts +87 -0
- package/dist/cjs/auth-api/storage.cjs +102 -0
- package/dist/cjs/auth-api/storage.d.ts +38 -0
- package/dist/cjs/auth-api/types.cjs +2 -0
- package/dist/cjs/auth-api/types.d.ts +34 -0
- package/dist/cjs/auth-api/user-id.cjs +47 -0
- package/dist/cjs/auth-api/user-id.d.ts +5 -0
- package/dist/cjs/auth-cookie-options.cjs +66 -0
- package/dist/cjs/auth-cookie-options.d.ts +13 -0
- package/dist/cjs/base/client-info.cjs +285 -0
- package/dist/cjs/base/client-info.d.ts +27 -0
- package/dist/cjs/base/error-utils.cjs +50 -0
- package/dist/cjs/base/error-utils.d.ts +16 -0
- package/dist/cjs/base/request-utils.cjs +27 -0
- package/dist/cjs/base/request-utils.d.ts +8 -0
- package/dist/cjs/index.cjs +51 -0
- package/dist/cjs/index.d.ts +34 -0
- package/dist/cjs/limiter/auth-rate-limiter.cjs +35 -0
- package/dist/cjs/limiter/auth-rate-limiter.d.ts +12 -0
- package/dist/cjs/limiter/fixed-window.cjs +41 -0
- package/dist/cjs/limiter/fixed-window.d.ts +11 -0
- package/dist/cjs/oauth/base.cjs +7 -0
- package/dist/cjs/oauth/base.d.ts +17 -0
- package/dist/cjs/oauth/memory.cjs +135 -0
- package/dist/cjs/oauth/memory.d.ts +22 -0
- package/dist/cjs/oauth/models.cjs +47 -0
- package/dist/cjs/oauth/models.d.ts +50 -0
- package/dist/cjs/oauth/sequelize.cjs +159 -0
- package/dist/cjs/oauth/sequelize.d.ts +30 -0
- package/dist/cjs/oauth/types.cjs +3 -0
- package/dist/cjs/oauth/types.d.ts +51 -0
- package/dist/cjs/passkey/base.cjs +7 -0
- package/dist/cjs/passkey/base.d.ts +28 -0
- package/dist/cjs/passkey/config.cjs +26 -0
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/memory.cjs +123 -0
- package/dist/cjs/passkey/memory.d.ts +34 -0
- package/dist/cjs/passkey/models.cjs +142 -0
- package/dist/cjs/passkey/models.d.ts +34 -0
- package/dist/cjs/passkey/sequelize.cjs +126 -0
- package/dist/cjs/passkey/sequelize.d.ts +42 -0
- package/dist/cjs/passkey/service.cjs +413 -0
- package/dist/cjs/passkey/service.d.ts +21 -0
- package/dist/cjs/passkey/types.cjs +2 -0
- package/dist/cjs/passkey/types.d.ts +84 -0
- package/dist/cjs/sequelize-utils.cjs +56 -0
- package/dist/cjs/sequelize-utils.d.ts +8 -0
- package/dist/cjs/token/base.cjs +120 -0
- package/dist/cjs/token/base.d.ts +46 -0
- package/dist/cjs/token/memory.cjs +234 -0
- package/dist/cjs/token/memory.d.ts +29 -0
- package/dist/cjs/token/sequelize.cjs +400 -0
- package/dist/cjs/token/sequelize.d.ts +58 -0
- package/dist/cjs/token/types.cjs +2 -0
- package/dist/cjs/token/types.d.ts +34 -0
- package/dist/cjs/upload/memory.cjs +92 -0
- package/dist/cjs/upload/memory.d.ts +17 -0
- package/dist/cjs/upload/tus-module.cjs +270 -0
- package/dist/cjs/upload/tus-module.d.ts +38 -0
- package/dist/cjs/upload/types.cjs +2 -0
- package/dist/cjs/upload/types.d.ts +28 -0
- package/dist/cjs/user/base.cjs +53 -0
- package/dist/cjs/user/base.d.ts +36 -0
- package/dist/cjs/user/memory.cjs +194 -0
- package/dist/cjs/user/memory.d.ts +37 -0
- package/dist/cjs/user/sequelize.cjs +194 -0
- package/dist/cjs/user/sequelize.d.ts +46 -0
- package/dist/cjs/user/types.cjs +2 -0
- package/dist/cjs/user/types.d.ts +11 -0
- package/dist/esm/api-module.d.ts +45 -0
- package/dist/esm/api-module.js +30 -0
- package/dist/esm/apicore-server.d.ts +288 -0
- package/dist/esm/apicore-server.js +1552 -0
- package/dist/esm/auth-api/auth-module.d.ts +116 -0
- package/dist/esm/auth-api/auth-module.js +1246 -0
- package/dist/esm/auth-api/compat-auth-storage.d.ts +57 -0
- package/dist/esm/auth-api/compat-auth-storage.js +124 -0
- package/dist/esm/auth-api/mem-auth-store.d.ts +68 -0
- package/dist/esm/auth-api/mem-auth-store.js +117 -0
- package/dist/esm/auth-api/module.d.ts +20 -0
- package/dist/esm/auth-api/module.js +21 -0
- package/dist/esm/auth-api/schemas.d.ts +21 -0
- package/dist/esm/auth-api/schemas.js +168 -0
- package/dist/esm/auth-api/sql-auth-store.d.ts +87 -0
- package/dist/esm/auth-api/sql-auth-store.js +175 -0
- package/dist/esm/auth-api/storage.d.ts +38 -0
- package/dist/esm/auth-api/storage.js +98 -0
- package/dist/esm/auth-api/types.d.ts +34 -0
- package/dist/esm/auth-api/types.js +1 -0
- package/dist/esm/auth-api/user-id.d.ts +5 -0
- package/dist/esm/auth-api/user-id.js +41 -0
- package/dist/esm/auth-cookie-options.d.ts +13 -0
- package/dist/esm/auth-cookie-options.js +63 -0
- package/dist/esm/base/client-info.d.ts +27 -0
- package/dist/esm/base/client-info.js +282 -0
- package/dist/esm/base/error-utils.d.ts +16 -0
- package/dist/esm/base/error-utils.js +44 -0
- package/dist/esm/base/request-utils.d.ts +8 -0
- package/dist/esm/base/request-utils.js +23 -0
- package/dist/esm/index.d.ts +34 -0
- package/dist/esm/index.js +21 -0
- package/dist/esm/limiter/auth-rate-limiter.d.ts +12 -0
- package/dist/esm/limiter/auth-rate-limiter.js +32 -0
- package/dist/esm/limiter/fixed-window.d.ts +11 -0
- package/dist/esm/limiter/fixed-window.js +37 -0
- package/dist/esm/oauth/base.d.ts +17 -0
- package/dist/esm/oauth/base.js +3 -0
- package/dist/esm/oauth/memory.d.ts +22 -0
- package/dist/esm/oauth/memory.js +128 -0
- package/dist/esm/oauth/models.d.ts +50 -0
- package/dist/esm/oauth/models.js +38 -0
- package/dist/esm/oauth/sequelize.d.ts +30 -0
- package/dist/esm/oauth/sequelize.js +148 -0
- package/dist/esm/oauth/types.d.ts +51 -0
- package/dist/esm/oauth/types.js +2 -0
- package/dist/esm/passkey/base.d.ts +28 -0
- package/dist/esm/passkey/base.js +3 -0
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.d.ts +34 -0
- package/dist/esm/passkey/memory.js +119 -0
- package/dist/esm/passkey/models.d.ts +34 -0
- package/dist/esm/passkey/models.js +135 -0
- package/dist/esm/passkey/sequelize.d.ts +42 -0
- package/dist/esm/passkey/sequelize.js +122 -0
- package/dist/esm/passkey/service.d.ts +21 -0
- package/dist/esm/passkey/service.js +376 -0
- package/dist/esm/passkey/types.d.ts +84 -0
- package/dist/esm/passkey/types.js +1 -0
- package/dist/esm/sequelize-utils.d.ts +8 -0
- package/dist/esm/sequelize-utils.js +47 -0
- package/dist/esm/token/base.d.ts +46 -0
- package/dist/esm/token/base.js +113 -0
- package/dist/esm/token/memory.d.ts +29 -0
- package/dist/esm/token/memory.js +230 -0
- package/dist/esm/token/sequelize.d.ts +58 -0
- package/dist/esm/token/sequelize.js +396 -0
- package/dist/esm/token/types.d.ts +34 -0
- package/dist/esm/token/types.js +1 -0
- package/dist/esm/upload/memory.d.ts +17 -0
- package/dist/esm/upload/memory.js +86 -0
- package/dist/esm/upload/tus-module.d.ts +38 -0
- package/dist/esm/upload/tus-module.js +266 -0
- package/dist/esm/upload/types.d.ts +28 -0
- package/dist/esm/upload/types.js +1 -0
- package/dist/esm/user/base.d.ts +36 -0
- package/dist/esm/user/base.js +46 -0
- package/dist/esm/user/memory.d.ts +37 -0
- package/dist/esm/user/memory.js +190 -0
- package/dist/esm/user/sequelize.d.ts +46 -0
- package/dist/esm/user/sequelize.js +188 -0
- package/dist/esm/user/types.d.ts +11 -0
- package/dist/esm/user/types.js +1 -0
- package/docs/swagger/openapi.json +2162 -0
- package/package.json +131 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
2
|
+
import { normalizeComparableUserId } from '../auth-api/user-id.js';
|
|
3
|
+
import { OAuthStore } from './base.js';
|
|
4
|
+
function cloneClient(client) {
|
|
5
|
+
if (!client) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
clientId: client.clientId,
|
|
10
|
+
hasSecret: Boolean(client.clientSecret),
|
|
11
|
+
name: client.name,
|
|
12
|
+
redirectUris: [...client.redirectUris],
|
|
13
|
+
scope: client.scope ? [...client.scope] : undefined,
|
|
14
|
+
metadata: client.metadata ? JSON.parse(JSON.stringify(client.metadata)) : undefined,
|
|
15
|
+
firstParty: client.firstParty
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function cloneCode(code) {
|
|
19
|
+
return {
|
|
20
|
+
...code,
|
|
21
|
+
userId: normalizeComparableUserId(code.userId),
|
|
22
|
+
scope: code.scope ? [...code.scope] : undefined,
|
|
23
|
+
expiresAt: new Date(code.expiresAt),
|
|
24
|
+
metadata: code.metadata ? { ...code.metadata } : undefined
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export class MemoryOAuthStore extends OAuthStore {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
super();
|
|
30
|
+
this.clients = new Map();
|
|
31
|
+
this.codes = new Map();
|
|
32
|
+
this.bcryptRounds = options.bcryptRounds ?? 12;
|
|
33
|
+
this.maxClients =
|
|
34
|
+
typeof options.maxClients === 'number' && Number.isFinite(options.maxClients) && options.maxClients > 0
|
|
35
|
+
? Math.floor(options.maxClients)
|
|
36
|
+
: undefined;
|
|
37
|
+
this.maxAuthCodes =
|
|
38
|
+
typeof options.maxAuthCodes === 'number' &&
|
|
39
|
+
Number.isFinite(options.maxAuthCodes) &&
|
|
40
|
+
options.maxAuthCodes > 0
|
|
41
|
+
? Math.floor(options.maxAuthCodes)
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
async getClient(clientId) {
|
|
45
|
+
return cloneClient(this.clients.get(clientId));
|
|
46
|
+
}
|
|
47
|
+
async createClient(input) {
|
|
48
|
+
const clientSecret = input.clientSecret ? await bcrypt.hash(input.clientSecret, this.bcryptRounds) : '';
|
|
49
|
+
const stored = {
|
|
50
|
+
clientId: input.clientId,
|
|
51
|
+
clientSecret,
|
|
52
|
+
name: input.name,
|
|
53
|
+
redirectUris: [...input.redirectUris],
|
|
54
|
+
scope: input.scope ? [...input.scope] : undefined,
|
|
55
|
+
metadata: input.metadata ? { ...input.metadata } : undefined,
|
|
56
|
+
firstParty: input.firstParty
|
|
57
|
+
};
|
|
58
|
+
this.clients.set(stored.clientId, stored);
|
|
59
|
+
this.enforceClientCapacity();
|
|
60
|
+
return cloneClient(stored);
|
|
61
|
+
}
|
|
62
|
+
async verifyClientSecret(clientId, secret) {
|
|
63
|
+
const client = this.clients.get(clientId);
|
|
64
|
+
if (!client) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (!client.clientSecret) {
|
|
68
|
+
return !secret || secret.length === 0;
|
|
69
|
+
}
|
|
70
|
+
if (!secret) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return bcrypt.compare(secret, client.clientSecret);
|
|
74
|
+
}
|
|
75
|
+
async createAuthCode(code) {
|
|
76
|
+
const record = {
|
|
77
|
+
...code,
|
|
78
|
+
userId: normalizeComparableUserId(code.userId),
|
|
79
|
+
scope: code.scope ? [...code.scope] : undefined,
|
|
80
|
+
expiresAt: code.expiresAt,
|
|
81
|
+
metadata: code.metadata ? { ...code.metadata } : undefined
|
|
82
|
+
};
|
|
83
|
+
this.codes.set(record.code, record);
|
|
84
|
+
this.enforceCodeCapacity();
|
|
85
|
+
}
|
|
86
|
+
async consumeAuthCode(code, clientId) {
|
|
87
|
+
const record = this.codes.get(code);
|
|
88
|
+
if (!record) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (clientId && record.clientId !== clientId) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (record.expiresAt.getTime() <= Date.now()) {
|
|
95
|
+
this.codes.delete(code);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
this.codes.delete(code);
|
|
99
|
+
return cloneCode(record);
|
|
100
|
+
}
|
|
101
|
+
async close() {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
enforceClientCapacity() {
|
|
105
|
+
if (!this.maxClients) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
while (this.clients.size > this.maxClients) {
|
|
109
|
+
const oldest = this.clients.keys().next().value;
|
|
110
|
+
if (!oldest) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.clients.delete(oldest);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
enforceCodeCapacity() {
|
|
117
|
+
if (!this.maxAuthCodes) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
while (this.codes.size > this.maxAuthCodes) {
|
|
121
|
+
const oldest = this.codes.keys().next().value;
|
|
122
|
+
if (!oldest) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.codes.delete(oldest);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Model, type Optional, type Sequelize } from 'sequelize';
|
|
2
|
+
export { integerIdType, tableOptions } from '../sequelize-utils.js';
|
|
3
|
+
export interface OAuthClientAttributes {
|
|
4
|
+
client_id: string;
|
|
5
|
+
client_secret: string;
|
|
6
|
+
name: string | null;
|
|
7
|
+
redirect_uris: string;
|
|
8
|
+
scope: string;
|
|
9
|
+
metadata: string | null;
|
|
10
|
+
first_party: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type OAuthClientCreationAttributes = Optional<OAuthClientAttributes, 'client_secret' | 'name' | 'scope' | 'metadata' | 'first_party'>;
|
|
13
|
+
export declare class OAuthClientModel extends Model<OAuthClientAttributes, OAuthClientCreationAttributes> implements OAuthClientAttributes {
|
|
14
|
+
client_id: string;
|
|
15
|
+
client_secret: string;
|
|
16
|
+
name: string | null;
|
|
17
|
+
redirect_uris: string;
|
|
18
|
+
scope: string;
|
|
19
|
+
metadata: string | null;
|
|
20
|
+
first_party: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function initOAuthClientModel(sequelize: Sequelize, options?: {
|
|
23
|
+
tablePrefix?: string;
|
|
24
|
+
}): typeof OAuthClientModel;
|
|
25
|
+
export interface OAuthCodeAttributes {
|
|
26
|
+
code: string;
|
|
27
|
+
client_id: string;
|
|
28
|
+
user_id: number;
|
|
29
|
+
redirect_uri: string;
|
|
30
|
+
scope: string;
|
|
31
|
+
code_challenge: string | null;
|
|
32
|
+
code_challenge_method: 'plain' | 'S256' | null;
|
|
33
|
+
expires: Date;
|
|
34
|
+
metadata: string | null;
|
|
35
|
+
}
|
|
36
|
+
export type OAuthCodeCreationAttributes = Optional<OAuthCodeAttributes, 'code_challenge' | 'code_challenge_method' | 'metadata'>;
|
|
37
|
+
export declare class OAuthCodeModel extends Model<OAuthCodeAttributes, OAuthCodeCreationAttributes> implements OAuthCodeAttributes {
|
|
38
|
+
code: string;
|
|
39
|
+
client_id: string;
|
|
40
|
+
user_id: number;
|
|
41
|
+
redirect_uri: string;
|
|
42
|
+
scope: string;
|
|
43
|
+
code_challenge: string | null;
|
|
44
|
+
code_challenge_method: 'plain' | 'S256' | null;
|
|
45
|
+
expires: Date;
|
|
46
|
+
metadata: string | null;
|
|
47
|
+
}
|
|
48
|
+
export declare function initOAuthCodeModel(sequelize: Sequelize, options?: {
|
|
49
|
+
tablePrefix?: string;
|
|
50
|
+
}): typeof OAuthCodeModel;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DataTypes, Model } from 'sequelize';
|
|
2
|
+
import { integerIdType, tableOptions } from '../sequelize-utils.js';
|
|
3
|
+
export { integerIdType, tableOptions } from '../sequelize-utils.js';
|
|
4
|
+
export class OAuthClientModel extends Model {
|
|
5
|
+
}
|
|
6
|
+
export function initOAuthClientModel(sequelize, options = {}) {
|
|
7
|
+
OAuthClientModel.init({
|
|
8
|
+
client_id: { type: DataTypes.STRING(128), allowNull: false, primaryKey: true },
|
|
9
|
+
client_secret: { type: DataTypes.STRING(255), allowNull: false, defaultValue: '' },
|
|
10
|
+
name: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null },
|
|
11
|
+
redirect_uris: { type: DataTypes.TEXT, allowNull: false, defaultValue: '[]' },
|
|
12
|
+
scope: { type: DataTypes.TEXT, allowNull: false, defaultValue: '[]' },
|
|
13
|
+
metadata: { type: DataTypes.TEXT, allowNull: true, defaultValue: null },
|
|
14
|
+
first_party: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }
|
|
15
|
+
}, {
|
|
16
|
+
...tableOptions(sequelize, 'oauth_clients', options.tablePrefix, { timestamps: false })
|
|
17
|
+
});
|
|
18
|
+
return OAuthClientModel;
|
|
19
|
+
}
|
|
20
|
+
export class OAuthCodeModel extends Model {
|
|
21
|
+
}
|
|
22
|
+
export function initOAuthCodeModel(sequelize, options = {}) {
|
|
23
|
+
const idType = integerIdType(sequelize);
|
|
24
|
+
OAuthCodeModel.init({
|
|
25
|
+
code: { type: DataTypes.STRING(128), allowNull: false, primaryKey: true },
|
|
26
|
+
client_id: { type: DataTypes.STRING(128), allowNull: false },
|
|
27
|
+
user_id: { type: idType, allowNull: false },
|
|
28
|
+
redirect_uri: { type: DataTypes.TEXT, allowNull: false },
|
|
29
|
+
scope: { type: DataTypes.TEXT, allowNull: false, defaultValue: '[]' },
|
|
30
|
+
code_challenge: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null },
|
|
31
|
+
code_challenge_method: { type: DataTypes.STRING(10), allowNull: true, defaultValue: null },
|
|
32
|
+
expires: { type: DataTypes.DATE, allowNull: false },
|
|
33
|
+
metadata: { type: DataTypes.TEXT, allowNull: true, defaultValue: null }
|
|
34
|
+
}, {
|
|
35
|
+
...tableOptions(sequelize, 'oauth_codes', options.tablePrefix, { timestamps: false })
|
|
36
|
+
});
|
|
37
|
+
return OAuthCodeModel;
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { OAuthStore, type AuthCode, type OAuthClient } from './base.js';
|
|
2
|
+
import { OAuthClientModel, OAuthCodeModel } from './models.js';
|
|
3
|
+
export interface SequelizeOAuthStoreOptions {
|
|
4
|
+
sequelize: import('sequelize').Sequelize;
|
|
5
|
+
tablePrefix?: string;
|
|
6
|
+
clientModel?: typeof OAuthClientModel;
|
|
7
|
+
codeModel?: typeof OAuthCodeModel;
|
|
8
|
+
clientModelFactory?: (sequelize: import('sequelize').Sequelize, options?: {
|
|
9
|
+
tablePrefix?: string;
|
|
10
|
+
}) => typeof OAuthClientModel;
|
|
11
|
+
codeModelFactory?: (sequelize: import('sequelize').Sequelize, options?: {
|
|
12
|
+
tablePrefix?: string;
|
|
13
|
+
}) => typeof OAuthCodeModel;
|
|
14
|
+
bcryptRounds?: number;
|
|
15
|
+
}
|
|
16
|
+
export { OAuthClientModel, OAuthCodeModel, initOAuthClientModel, initOAuthCodeModel, type OAuthClientAttributes, type OAuthClientCreationAttributes, type OAuthCodeAttributes, type OAuthCodeCreationAttributes } from './models.js';
|
|
17
|
+
export declare class SequelizeOAuthStore extends OAuthStore {
|
|
18
|
+
private readonly clients;
|
|
19
|
+
private readonly codes;
|
|
20
|
+
private readonly bcryptRounds;
|
|
21
|
+
constructor(options: SequelizeOAuthStoreOptions);
|
|
22
|
+
getClient(clientId: string): Promise<OAuthClient | null>;
|
|
23
|
+
createClient(input: OAuthClient): Promise<OAuthClient>;
|
|
24
|
+
verifyClientSecret(clientId: string, clientSecret: string | null): Promise<boolean>;
|
|
25
|
+
createAuthCode(code: AuthCode): Promise<void>;
|
|
26
|
+
consumeAuthCode(code: string, clientId?: string): Promise<AuthCode | null>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
private toOAuthClient;
|
|
29
|
+
private toAuthCode;
|
|
30
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
2
|
+
import { Transaction } from 'sequelize';
|
|
3
|
+
import { normalizeNumericUserId } from '../auth-api/user-id.js';
|
|
4
|
+
import { decodeStringArray, encodeStringArray } from '../sequelize-utils.js';
|
|
5
|
+
import { OAuthStore } from './base.js';
|
|
6
|
+
import { initOAuthClientModel, initOAuthCodeModel } from './models.js';
|
|
7
|
+
export { OAuthClientModel, OAuthCodeModel, initOAuthClientModel, initOAuthCodeModel } from './models.js';
|
|
8
|
+
function serializeMetadata(metadata) {
|
|
9
|
+
if (!metadata) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return JSON.stringify(metadata);
|
|
13
|
+
}
|
|
14
|
+
function parseMetadata(raw) {
|
|
15
|
+
if (!raw) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (parsed && typeof parsed === 'object') {
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// ignore
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
export class SequelizeOAuthStore extends OAuthStore {
|
|
30
|
+
constructor(options) {
|
|
31
|
+
super();
|
|
32
|
+
if (!options?.sequelize) {
|
|
33
|
+
throw new Error('SequelizeOAuthStore requires an initialised Sequelize instance');
|
|
34
|
+
}
|
|
35
|
+
this.clients =
|
|
36
|
+
options.clientModel ??
|
|
37
|
+
(options.clientModelFactory ?? initOAuthClientModel)(options.sequelize, {
|
|
38
|
+
tablePrefix: options.tablePrefix
|
|
39
|
+
});
|
|
40
|
+
this.codes =
|
|
41
|
+
options.codeModel ??
|
|
42
|
+
(options.codeModelFactory ?? initOAuthCodeModel)(options.sequelize, {
|
|
43
|
+
tablePrefix: options.tablePrefix
|
|
44
|
+
});
|
|
45
|
+
this.bcryptRounds = options.bcryptRounds ?? 12;
|
|
46
|
+
}
|
|
47
|
+
async getClient(clientId) {
|
|
48
|
+
const model = await this.clients.findByPk(clientId);
|
|
49
|
+
return model ? this.toOAuthClient(model) : null;
|
|
50
|
+
}
|
|
51
|
+
async createClient(input) {
|
|
52
|
+
const existing = await this.clients.findByPk(input.clientId);
|
|
53
|
+
const hashedSecret = input.clientSecret !== undefined && input.clientSecret !== null
|
|
54
|
+
? await bcrypt.hash(input.clientSecret, this.bcryptRounds)
|
|
55
|
+
: (existing?.client_secret ?? '');
|
|
56
|
+
const redirectUris = input.redirectUris ?? (existing ? decodeStringArray(existing.redirect_uris) : undefined);
|
|
57
|
+
const scope = input.scope ?? (existing ? decodeStringArray(existing.scope) : undefined);
|
|
58
|
+
const metadata = input.metadata ?? (existing ? parseMetadata(existing.metadata) : undefined);
|
|
59
|
+
await this.clients.upsert({
|
|
60
|
+
client_id: input.clientId,
|
|
61
|
+
client_secret: hashedSecret,
|
|
62
|
+
name: input.name ?? existing?.name ?? null,
|
|
63
|
+
redirect_uris: encodeStringArray(redirectUris),
|
|
64
|
+
scope: encodeStringArray(scope),
|
|
65
|
+
metadata: serializeMetadata(metadata),
|
|
66
|
+
first_party: input.firstParty ?? existing?.first_party ?? false
|
|
67
|
+
});
|
|
68
|
+
const model = await this.clients.findByPk(input.clientId);
|
|
69
|
+
if (!model) {
|
|
70
|
+
throw new Error(`Unable to persist OAuth client ${input.clientId}`);
|
|
71
|
+
}
|
|
72
|
+
return this.toOAuthClient(model);
|
|
73
|
+
}
|
|
74
|
+
async verifyClientSecret(clientId, clientSecret) {
|
|
75
|
+
const model = await this.clients.findByPk(clientId);
|
|
76
|
+
if (!model) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (!model.client_secret) {
|
|
80
|
+
return !clientSecret || clientSecret.length === 0;
|
|
81
|
+
}
|
|
82
|
+
if (!clientSecret) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return bcrypt.compare(clientSecret, model.client_secret);
|
|
86
|
+
}
|
|
87
|
+
async createAuthCode(code) {
|
|
88
|
+
await this.codes.create({
|
|
89
|
+
code: code.code,
|
|
90
|
+
client_id: code.clientId,
|
|
91
|
+
user_id: normalizeNumericUserId(code.userId),
|
|
92
|
+
redirect_uri: code.redirectUri ?? '',
|
|
93
|
+
scope: encodeStringArray(code.scope),
|
|
94
|
+
code_challenge: code.codeChallenge ?? null,
|
|
95
|
+
code_challenge_method: code.codeChallengeMethod ?? null,
|
|
96
|
+
expires: code.expiresAt,
|
|
97
|
+
metadata: serializeMetadata(code.metadata)
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async consumeAuthCode(code, clientId) {
|
|
101
|
+
const sequelize = this.codes.sequelize;
|
|
102
|
+
if (!sequelize) {
|
|
103
|
+
throw new Error('Code model is not bound to a Sequelize instance');
|
|
104
|
+
}
|
|
105
|
+
return sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED }, async (transaction) => {
|
|
106
|
+
const where = { code };
|
|
107
|
+
if (clientId) {
|
|
108
|
+
where.client_id = clientId;
|
|
109
|
+
}
|
|
110
|
+
const model = await this.codes.findOne({ where, transaction, lock: true });
|
|
111
|
+
if (!model) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
await model.destroy({ transaction });
|
|
115
|
+
if (model.expires.getTime() <= Date.now()) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return this.toAuthCode(model);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async close() {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
toOAuthClient(model) {
|
|
125
|
+
return {
|
|
126
|
+
clientId: model.client_id,
|
|
127
|
+
hasSecret: Boolean(model.client_secret),
|
|
128
|
+
name: model.name ?? undefined,
|
|
129
|
+
redirectUris: decodeStringArray(model.redirect_uris),
|
|
130
|
+
scope: decodeStringArray(model.scope),
|
|
131
|
+
metadata: parseMetadata(model.metadata),
|
|
132
|
+
firstParty: model.first_party ?? false
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
toAuthCode(model) {
|
|
136
|
+
return {
|
|
137
|
+
code: model.code,
|
|
138
|
+
clientId: model.client_id,
|
|
139
|
+
userId: String(model.user_id),
|
|
140
|
+
redirectUri: model.redirect_uri,
|
|
141
|
+
scope: decodeStringArray(model.scope),
|
|
142
|
+
codeChallenge: model.code_challenge ?? undefined,
|
|
143
|
+
codeChallengeMethod: model.code_challenge_method ?? undefined,
|
|
144
|
+
expiresAt: model.expires,
|
|
145
|
+
metadata: parseMetadata(model.metadata)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AuthIdentifier } from '../auth-api/types.js';
|
|
2
|
+
export interface OAuthClient {
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret?: string;
|
|
5
|
+
hasSecret?: boolean;
|
|
6
|
+
firstParty?: boolean;
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
name?: string;
|
|
9
|
+
redirectUris: string[];
|
|
10
|
+
scope?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface AuthCodeData {
|
|
13
|
+
code: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
codeChallenge?: string;
|
|
16
|
+
codeChallengeMethod?: 'plain' | 'S256';
|
|
17
|
+
expiresAt: Date;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
redirectUri?: string;
|
|
20
|
+
scope?: string[];
|
|
21
|
+
userId: AuthIdentifier;
|
|
22
|
+
}
|
|
23
|
+
export type AuthCode = AuthCodeData;
|
|
24
|
+
export type AuthCodeRequest = Omit<AuthCodeData, 'code' | 'expiresAt'> & {
|
|
25
|
+
code?: string;
|
|
26
|
+
expiresInSeconds?: number;
|
|
27
|
+
};
|
|
28
|
+
export interface OAuthStartParams {
|
|
29
|
+
provider: string;
|
|
30
|
+
redirectUri?: string;
|
|
31
|
+
scope?: string | string[];
|
|
32
|
+
state?: string;
|
|
33
|
+
extras?: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
export interface OAuthStartResult extends Record<string, unknown> {
|
|
36
|
+
url: string;
|
|
37
|
+
state?: string;
|
|
38
|
+
codeVerifier?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface OAuthCallbackParams {
|
|
41
|
+
provider: string;
|
|
42
|
+
query: Record<string, string | string[]>;
|
|
43
|
+
body: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export interface OAuthCallbackResult<PublicUser> extends Record<string, unknown> {
|
|
46
|
+
user: PublicUser;
|
|
47
|
+
tokens?: {
|
|
48
|
+
accessToken: string;
|
|
49
|
+
refreshToken: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PasskeyChallengeRecord, PasskeyStorageAdapter, PasskeyUserDescriptor, StoredPasskeyCredential } from './types.js';
|
|
2
|
+
import type { AuthIdentifier } from '../auth-api/types.js';
|
|
3
|
+
/** Base contract for passkey credential/challenge persistence backends. */
|
|
4
|
+
export declare abstract class PasskeyStore implements PasskeyStorageAdapter {
|
|
5
|
+
/** Resolve a passkey user descriptor by id/login. */
|
|
6
|
+
abstract resolveUser(params: {
|
|
7
|
+
userId?: AuthIdentifier;
|
|
8
|
+
login?: string;
|
|
9
|
+
}): Promise<PasskeyUserDescriptor | null>;
|
|
10
|
+
/** List passkey credentials for a user. */
|
|
11
|
+
abstract listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
|
|
12
|
+
/** Delete a credential by binary/base64url id. */
|
|
13
|
+
abstract deleteCredential(credentialId: Buffer | string): Promise<boolean>;
|
|
14
|
+
/** Find a credential by binary id. */
|
|
15
|
+
abstract findCredentialById(credentialId: Buffer): Promise<StoredPasskeyCredential | null>;
|
|
16
|
+
/** Save a credential record. */
|
|
17
|
+
abstract saveCredential(record: StoredPasskeyCredential): Promise<void>;
|
|
18
|
+
/** Update signature counter for a credential. */
|
|
19
|
+
abstract updateCredentialCounter(credentialId: Buffer, counter: number): Promise<void>;
|
|
20
|
+
/** Save a challenge record. */
|
|
21
|
+
abstract saveChallenge(record: PasskeyChallengeRecord): Promise<void>;
|
|
22
|
+
/** Read a challenge without consuming it. */
|
|
23
|
+
abstract getChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
24
|
+
/** Consume and return a challenge. */
|
|
25
|
+
abstract consumeChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
26
|
+
/** Cleanup expired challenges. */
|
|
27
|
+
abstract cleanupChallenges(now: Date): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const DEFAULT_PASSKEY_CONFIG = {
|
|
2
|
+
rpId: 'localhost',
|
|
3
|
+
rpName: 'API Server',
|
|
4
|
+
origins: ['http://localhost:5173'],
|
|
5
|
+
timeoutMs: 5 * 60 * 1000,
|
|
6
|
+
userVerification: 'preferred'
|
|
7
|
+
};
|
|
8
|
+
function isOriginString(origin) {
|
|
9
|
+
return typeof origin === 'string' && origin.trim().length > 0;
|
|
10
|
+
}
|
|
11
|
+
export function normalizePasskeyConfig(config = {}) {
|
|
12
|
+
const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
|
|
13
|
+
return {
|
|
14
|
+
rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
|
|
15
|
+
rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
|
|
16
|
+
origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
|
|
17
|
+
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
18
|
+
? config.timeoutMs
|
|
19
|
+
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
20
|
+
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
21
|
+
debug: Boolean(config.debug)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PasskeyStore } from './base.js';
|
|
2
|
+
import type { PasskeyChallengeRecord, PasskeyUserDescriptor, StoredPasskeyCredential } from './types.js';
|
|
3
|
+
import type { AuthIdentifier } from '../auth-api/types.js';
|
|
4
|
+
export interface MemoryPasskeyStoreOptions {
|
|
5
|
+
resolveUser: (params: {
|
|
6
|
+
userId?: AuthIdentifier;
|
|
7
|
+
login?: string;
|
|
8
|
+
}) => Promise<PasskeyUserDescriptor | null>;
|
|
9
|
+
maxCredentials?: number;
|
|
10
|
+
maxChallenges?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class MemoryPasskeyStore extends PasskeyStore {
|
|
13
|
+
private readonly resolveUserFn;
|
|
14
|
+
private readonly credentials;
|
|
15
|
+
private readonly challenges;
|
|
16
|
+
private readonly maxCredentials?;
|
|
17
|
+
private readonly maxChallenges?;
|
|
18
|
+
constructor(options: MemoryPasskeyStoreOptions);
|
|
19
|
+
resolveUser(params: {
|
|
20
|
+
userId?: AuthIdentifier;
|
|
21
|
+
login?: string;
|
|
22
|
+
}): Promise<PasskeyUserDescriptor | null>;
|
|
23
|
+
listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
|
|
24
|
+
deleteCredential(credentialId: Buffer | string): Promise<boolean>;
|
|
25
|
+
findCredentialById(credentialId: Buffer): Promise<StoredPasskeyCredential | null>;
|
|
26
|
+
saveCredential(record: StoredPasskeyCredential): Promise<void>;
|
|
27
|
+
updateCredentialCounter(credentialId: Buffer, counter: number): Promise<void>;
|
|
28
|
+
saveChallenge(record: PasskeyChallengeRecord): Promise<void>;
|
|
29
|
+
getChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
30
|
+
consumeChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
31
|
+
cleanupChallenges(now: Date): Promise<void>;
|
|
32
|
+
private enforceCredentialCapacity;
|
|
33
|
+
private enforceChallengeCapacity;
|
|
34
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { normalizeComparableUserId } from '../auth-api/user-id.js';
|
|
2
|
+
import { PasskeyStore } from './base.js';
|
|
3
|
+
function encodeCredentialId(value) {
|
|
4
|
+
return Buffer.isBuffer(value) ? value.toString('base64') : value;
|
|
5
|
+
}
|
|
6
|
+
function cloneCredential(record) {
|
|
7
|
+
return {
|
|
8
|
+
...record,
|
|
9
|
+
credentialId: Buffer.isBuffer(record.credentialId) ? Buffer.from(record.credentialId) : record.credentialId,
|
|
10
|
+
publicKey: Buffer.from(record.publicKey),
|
|
11
|
+
transports: record.transports ? [...record.transports] : undefined
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export class MemoryPasskeyStore extends PasskeyStore {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
super();
|
|
17
|
+
this.credentials = new Map();
|
|
18
|
+
this.challenges = new Map();
|
|
19
|
+
this.resolveUserFn = options.resolveUser;
|
|
20
|
+
this.maxCredentials =
|
|
21
|
+
typeof options.maxCredentials === 'number' &&
|
|
22
|
+
Number.isFinite(options.maxCredentials) &&
|
|
23
|
+
options.maxCredentials > 0
|
|
24
|
+
? Math.floor(options.maxCredentials)
|
|
25
|
+
: undefined;
|
|
26
|
+
this.maxChallenges =
|
|
27
|
+
typeof options.maxChallenges === 'number' &&
|
|
28
|
+
Number.isFinite(options.maxChallenges) &&
|
|
29
|
+
options.maxChallenges > 0
|
|
30
|
+
? Math.floor(options.maxChallenges)
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
async resolveUser(params) {
|
|
34
|
+
return this.resolveUserFn(params);
|
|
35
|
+
}
|
|
36
|
+
async listUserCredentials(userId) {
|
|
37
|
+
const normalizedUserId = normalizeComparableUserId(userId);
|
|
38
|
+
return [...this.credentials.values()]
|
|
39
|
+
.filter((record) => normalizeComparableUserId(record.userId) === normalizedUserId)
|
|
40
|
+
.map((record) => cloneCredential(record));
|
|
41
|
+
}
|
|
42
|
+
async deleteCredential(credentialId) {
|
|
43
|
+
const key = encodeCredentialId(credentialId);
|
|
44
|
+
return this.credentials.delete(key);
|
|
45
|
+
}
|
|
46
|
+
async findCredentialById(credentialId) {
|
|
47
|
+
const record = this.credentials.get(encodeCredentialId(credentialId));
|
|
48
|
+
return record ? cloneCredential(record) : null;
|
|
49
|
+
}
|
|
50
|
+
async saveCredential(record) {
|
|
51
|
+
this.credentials.set(encodeCredentialId(record.credentialId), {
|
|
52
|
+
...record,
|
|
53
|
+
userId: normalizeComparableUserId(record.userId),
|
|
54
|
+
credentialId: Buffer.isBuffer(record.credentialId) ? Buffer.from(record.credentialId) : record.credentialId,
|
|
55
|
+
transports: record.transports ? [...record.transports] : undefined
|
|
56
|
+
});
|
|
57
|
+
this.enforceCredentialCapacity();
|
|
58
|
+
}
|
|
59
|
+
async updateCredentialCounter(credentialId, counter) {
|
|
60
|
+
const key = encodeCredentialId(credentialId);
|
|
61
|
+
const existing = this.credentials.get(key);
|
|
62
|
+
if (existing) {
|
|
63
|
+
existing.counter = counter;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async saveChallenge(record) {
|
|
67
|
+
this.challenges.set(record.challenge, {
|
|
68
|
+
challenge: record.challenge,
|
|
69
|
+
action: record.action,
|
|
70
|
+
userId: record.userId !== undefined ? normalizeComparableUserId(record.userId) : undefined,
|
|
71
|
+
login: record.login ?? undefined,
|
|
72
|
+
expiresAt: record.expiresAt
|
|
73
|
+
});
|
|
74
|
+
this.enforceChallengeCapacity();
|
|
75
|
+
}
|
|
76
|
+
async getChallenge(challenge) {
|
|
77
|
+
const record = this.challenges.get(challenge);
|
|
78
|
+
return record ? { ...record } : null;
|
|
79
|
+
}
|
|
80
|
+
async consumeChallenge(challenge) {
|
|
81
|
+
const record = this.challenges.get(challenge);
|
|
82
|
+
if (!record) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
this.challenges.delete(challenge);
|
|
86
|
+
return { ...record };
|
|
87
|
+
}
|
|
88
|
+
async cleanupChallenges(now) {
|
|
89
|
+
for (const [challenge, record] of this.challenges.entries()) {
|
|
90
|
+
if (record.expiresAt && new Date(record.expiresAt) <= now) {
|
|
91
|
+
this.challenges.delete(challenge);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
enforceCredentialCapacity() {
|
|
96
|
+
if (!this.maxCredentials) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
while (this.credentials.size > this.maxCredentials) {
|
|
100
|
+
const oldest = this.credentials.keys().next().value;
|
|
101
|
+
if (!oldest) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.credentials.delete(oldest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
enforceChallengeCapacity() {
|
|
108
|
+
if (!this.maxChallenges) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
while (this.challenges.size > this.maxChallenges) {
|
|
112
|
+
const oldest = this.challenges.keys().next().value;
|
|
113
|
+
if (!oldest) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.challenges.delete(oldest);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|