@idevconn/create-icore 0.11.0 → 0.12.0
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/dist/cli.js +12 -1
- package/dist/index.cjs +11 -0
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11 -0
- package/package.json +1 -1
- package/templates/libs/auth-strategies/postgres/eslint.config.mjs +32 -0
- package/templates/libs/auth-strategies/postgres/package.json +23 -0
- package/templates/libs/auth-strategies/postgres/project.json +19 -0
- package/templates/libs/auth-strategies/postgres/src/index.ts +3 -0
- package/templates/libs/auth-strategies/postgres/src/lib/__tests__/postgres-auth.contract.unit.test.ts +4 -0
- package/templates/libs/auth-strategies/postgres/src/lib/__tests__/postgres-auth.module.unit.test.ts +49 -0
- package/templates/libs/auth-strategies/postgres/src/lib/postgres-auth.module.ts +39 -0
- package/templates/libs/auth-strategies/postgres/src/lib/postgres-auth.strategy.ts +210 -0
- package/templates/libs/auth-strategies/postgres/src/lib/testing/mock-postgres-auth.ts +106 -0
- package/templates/libs/auth-strategies/postgres/tsconfig.json +19 -0
- package/templates/libs/auth-strategies/postgres/tsconfig.lib.json +24 -0
- package/templates/libs/auth-strategies/postgres/tsconfig.spec.json +22 -0
- package/templates/libs/auth-strategies/postgres/vitest.config.mts +22 -0
- package/templates/tsconfig.base.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -224,6 +224,7 @@ Re-run with @latest to refresh:
|
|
|
224
224
|
{ value: "supabase", label: "Supabase" },
|
|
225
225
|
{ value: "firebase", label: "Firebase" },
|
|
226
226
|
{ value: "mongodb", label: "MongoDB (Custom Auth)" },
|
|
227
|
+
{ value: "postgres", label: "PostgreSQL (direct, postgres.js + bcrypt + JWT)" },
|
|
227
228
|
{ value: "none", label: "None \u2014 no login, open API (simple SPA)" }
|
|
228
229
|
]
|
|
229
230
|
});
|
|
@@ -411,7 +412,7 @@ async function rewriteRootPackageJson(targetDir, opts) {
|
|
|
411
412
|
const pkg = JSON.parse(raw);
|
|
412
413
|
pkg["name"] = opts.projectName;
|
|
413
414
|
pkg["version"] = "0.0.1";
|
|
414
|
-
pkg["icoreVersion"] = true ? "0.
|
|
415
|
+
pkg["icoreVersion"] = true ? "0.12.0" : "unknown";
|
|
415
416
|
pkg["private"] = true;
|
|
416
417
|
delete pkg.description;
|
|
417
418
|
const transportDeps = TRANSPORT_DEPS[opts.transport];
|
|
@@ -1477,6 +1478,16 @@ var MANIFEST = {
|
|
|
1477
1478
|
deps: { mongoose: "^9.6.3" },
|
|
1478
1479
|
tsPaths: { "@icore/auth-mongodb": ["libs/auth-strategies/mongodb/src/index.ts"] },
|
|
1479
1480
|
nestModule: { importFrom: "@icore/auth-mongodb", symbol: "MongoDbAuthModule", into: "auth" }
|
|
1481
|
+
},
|
|
1482
|
+
postgres: {
|
|
1483
|
+
libDirs: ["libs/auth-strategies/postgres"],
|
|
1484
|
+
deps: { postgres: "^3", bcrypt: "^6", jsonwebtoken: "^9" },
|
|
1485
|
+
tsPaths: { "@icore/auth-postgres": ["libs/auth-strategies/postgres/src/index.ts"] },
|
|
1486
|
+
nestModule: {
|
|
1487
|
+
importFrom: "@icore/auth-postgres",
|
|
1488
|
+
symbol: "PostgresAuthModule",
|
|
1489
|
+
into: "auth"
|
|
1490
|
+
}
|
|
1480
1491
|
}
|
|
1481
1492
|
},
|
|
1482
1493
|
storage: {
|
package/dist/index.cjs
CHANGED
|
@@ -1187,6 +1187,16 @@ var MANIFEST = {
|
|
|
1187
1187
|
deps: { mongoose: "^9.6.3" },
|
|
1188
1188
|
tsPaths: { "@icore/auth-mongodb": ["libs/auth-strategies/mongodb/src/index.ts"] },
|
|
1189
1189
|
nestModule: { importFrom: "@icore/auth-mongodb", symbol: "MongoDbAuthModule", into: "auth" }
|
|
1190
|
+
},
|
|
1191
|
+
postgres: {
|
|
1192
|
+
libDirs: ["libs/auth-strategies/postgres"],
|
|
1193
|
+
deps: { postgres: "^3", bcrypt: "^6", jsonwebtoken: "^9" },
|
|
1194
|
+
tsPaths: { "@icore/auth-postgres": ["libs/auth-strategies/postgres/src/index.ts"] },
|
|
1195
|
+
nestModule: {
|
|
1196
|
+
importFrom: "@icore/auth-postgres",
|
|
1197
|
+
symbol: "PostgresAuthModule",
|
|
1198
|
+
into: "auth"
|
|
1199
|
+
}
|
|
1190
1200
|
}
|
|
1191
1201
|
},
|
|
1192
1202
|
storage: {
|
|
@@ -2248,6 +2258,7 @@ Re-run with @latest to refresh:
|
|
|
2248
2258
|
{ value: "supabase", label: "Supabase" },
|
|
2249
2259
|
{ value: "firebase", label: "Firebase" },
|
|
2250
2260
|
{ value: "mongodb", label: "MongoDB (Custom Auth)" },
|
|
2261
|
+
{ value: "postgres", label: "PostgreSQL (direct, postgres.js + bcrypt + JWT)" },
|
|
2251
2262
|
{ value: "none", label: "None \u2014 no login, open API (simple SPA)" }
|
|
2252
2263
|
]
|
|
2253
2264
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type AuthBackend = 'supabase' | 'firebase' | 'mongodb';
|
|
1
|
+
type AuthBackend = 'supabase' | 'firebase' | 'mongodb' | 'postgres';
|
|
2
2
|
type AuthProvider = AuthBackend | 'none';
|
|
3
3
|
type DbProvider = 'supabase' | 'firebase' | 'mongodb' | 'postgres' | 'none';
|
|
4
4
|
type UploadProvider = 'supabase' | 'firebase' | 'cloudinary' | 'mongodb' | 'none';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type AuthBackend = 'supabase' | 'firebase' | 'mongodb';
|
|
1
|
+
type AuthBackend = 'supabase' | 'firebase' | 'mongodb' | 'postgres';
|
|
2
2
|
type AuthProvider = AuthBackend | 'none';
|
|
3
3
|
type DbProvider = 'supabase' | 'firebase' | 'mongodb' | 'postgres' | 'none';
|
|
4
4
|
type UploadProvider = 'supabase' | 'firebase' | 'cloudinary' | 'mongodb' | 'none';
|
package/dist/index.js
CHANGED
|
@@ -1144,6 +1144,16 @@ var MANIFEST = {
|
|
|
1144
1144
|
deps: { mongoose: "^9.6.3" },
|
|
1145
1145
|
tsPaths: { "@icore/auth-mongodb": ["libs/auth-strategies/mongodb/src/index.ts"] },
|
|
1146
1146
|
nestModule: { importFrom: "@icore/auth-mongodb", symbol: "MongoDbAuthModule", into: "auth" }
|
|
1147
|
+
},
|
|
1148
|
+
postgres: {
|
|
1149
|
+
libDirs: ["libs/auth-strategies/postgres"],
|
|
1150
|
+
deps: { postgres: "^3", bcrypt: "^6", jsonwebtoken: "^9" },
|
|
1151
|
+
tsPaths: { "@icore/auth-postgres": ["libs/auth-strategies/postgres/src/index.ts"] },
|
|
1152
|
+
nestModule: {
|
|
1153
|
+
importFrom: "@icore/auth-postgres",
|
|
1154
|
+
symbol: "PostgresAuthModule",
|
|
1155
|
+
into: "auth"
|
|
1156
|
+
}
|
|
1147
1157
|
}
|
|
1148
1158
|
},
|
|
1149
1159
|
storage: {
|
|
@@ -2205,6 +2215,7 @@ Re-run with @latest to refresh:
|
|
|
2205
2215
|
{ value: "supabase", label: "Supabase" },
|
|
2206
2216
|
{ value: "firebase", label: "Firebase" },
|
|
2207
2217
|
{ value: "mongodb", label: "MongoDB (Custom Auth)" },
|
|
2218
|
+
{ value: "postgres", label: "PostgreSQL (direct, postgres.js + bcrypt + JWT)" },
|
|
2208
2219
|
{ value: "none", label: "None \u2014 no login, open API (simple SPA)" }
|
|
2209
2220
|
]
|
|
2210
2221
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idevconn/create-icore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Bootstrap a new project from the iCore scaffold (Nx + NestJS + React + Vite + shadcn/Tailwind, swappable auth + storage providers).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "iDEVconn",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import baseConfig from '../../../eslint.config.mjs';
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
...baseConfig,
|
|
5
|
+
{
|
|
6
|
+
files: ['**/*.json'],
|
|
7
|
+
rules: {
|
|
8
|
+
'@nx/dependency-checks': [
|
|
9
|
+
'error',
|
|
10
|
+
{
|
|
11
|
+
ignoredFiles: [
|
|
12
|
+
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
|
|
13
|
+
'{projectRoot}/vitest.config.{js,ts,mjs,mts}',
|
|
14
|
+
],
|
|
15
|
+
ignoredDependencies: [
|
|
16
|
+
'@icore/shared',
|
|
17
|
+
'bcrypt',
|
|
18
|
+
'jsonwebtoken',
|
|
19
|
+
'postgres',
|
|
20
|
+
'@nestjs/common',
|
|
21
|
+
'@nestjs/config',
|
|
22
|
+
'@nestjs/testing',
|
|
23
|
+
'vitest',
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
languageOptions: {
|
|
29
|
+
parser: await import('jsonc-eslint-parser'),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@icore/auth-postgres",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@icore/shared": "*",
|
|
10
|
+
"@nestjs/common": "^11.1.27",
|
|
11
|
+
"@nestjs/config": "^4.0.4",
|
|
12
|
+
"bcrypt": "^6.0.0",
|
|
13
|
+
"jsonwebtoken": "^9.0.3",
|
|
14
|
+
"postgres": "^3.4.5",
|
|
15
|
+
"tslib": "^2.8.1"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@nestjs/testing": "^11.1.27",
|
|
19
|
+
"@types/bcrypt": "^6.0.0",
|
|
20
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
21
|
+
"vitest": "^4.1.9"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "auth-postgres",
|
|
3
|
+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "libs/auth-strategies/postgres/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"build": {
|
|
9
|
+
"executor": "@nx/js:tsc",
|
|
10
|
+
"outputs": ["{options.outputPath}"],
|
|
11
|
+
"options": {
|
|
12
|
+
"outputPath": "dist/libs/auth-strategies/postgres",
|
|
13
|
+
"main": "libs/auth-strategies/postgres/src/index.ts",
|
|
14
|
+
"tsConfig": "libs/auth-strategies/postgres/tsconfig.lib.json",
|
|
15
|
+
"assets": ["libs/auth-strategies/postgres/*.md"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/templates/libs/auth-strategies/postgres/src/lib/__tests__/postgres-auth.module.unit.test.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Global, Module } from '@nestjs/common';
|
|
3
|
+
import { Test } from '@nestjs/testing';
|
|
4
|
+
import { ConfigService } from '@nestjs/config';
|
|
5
|
+
import { PostgresAuthModule, POSTGRES_AUTH_REQUIRED_ENV } from '../postgres-auth.module.js';
|
|
6
|
+
import { PostgresAuthStrategy } from '../postgres-auth.strategy.js';
|
|
7
|
+
|
|
8
|
+
let ENV: Record<string, string | undefined> = {};
|
|
9
|
+
|
|
10
|
+
@Global()
|
|
11
|
+
@Module({
|
|
12
|
+
providers: [
|
|
13
|
+
{
|
|
14
|
+
provide: ConfigService,
|
|
15
|
+
useValue: {
|
|
16
|
+
get: (k: string) => ENV[k],
|
|
17
|
+
getOrThrow: (k: string) => ENV[k],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
exports: [ConfigService],
|
|
22
|
+
})
|
|
23
|
+
class StubConfigModule {}
|
|
24
|
+
|
|
25
|
+
describe('PostgresAuthModule', () => {
|
|
26
|
+
it('declares its required env', () => {
|
|
27
|
+
expect(POSTGRES_AUTH_REQUIRED_ENV).toEqual(['POSTGRES_URL', 'JWT_SECRET']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('provides a real PostgresAuthStrategy under AuthStrategy when env present', async () => {
|
|
31
|
+
ENV = {
|
|
32
|
+
POSTGRES_URL: 'postgresql://user:pass@localhost:5432/test',
|
|
33
|
+
JWT_SECRET: 'test-secret',
|
|
34
|
+
};
|
|
35
|
+
const ref = await Test.createTestingModule({
|
|
36
|
+
imports: [StubConfigModule, PostgresAuthModule.forRoot('.env')],
|
|
37
|
+
}).compile();
|
|
38
|
+
expect(ref.get('AuthStrategy')).toBeInstanceOf(PostgresAuthStrategy);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('provides FakeAuthStrategy when JWT_SECRET is missing', async () => {
|
|
42
|
+
ENV = { POSTGRES_URL: 'postgresql://user:pass@localhost:5432/test' };
|
|
43
|
+
const ref = await Test.createTestingModule({
|
|
44
|
+
imports: [StubConfigModule, PostgresAuthModule.forRoot('.env')],
|
|
45
|
+
}).compile();
|
|
46
|
+
const strategy = ref.get('AuthStrategy');
|
|
47
|
+
expect(strategy).not.toBeInstanceOf(PostgresAuthStrategy);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Module, DynamicModule } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { buildStrategyWithFallback, FakeAuthStrategy } from '@icore/shared';
|
|
4
|
+
import type { AuthStrategy } from '@icore/shared';
|
|
5
|
+
import { PostgresAuthStrategy } from './postgres-auth.strategy';
|
|
6
|
+
|
|
7
|
+
export const POSTGRES_AUTH_REQUIRED_ENV = ['POSTGRES_URL', 'JWT_SECRET'];
|
|
8
|
+
|
|
9
|
+
@Module({})
|
|
10
|
+
export class PostgresAuthModule {
|
|
11
|
+
static forRoot(envPath: string): DynamicModule {
|
|
12
|
+
return {
|
|
13
|
+
module: PostgresAuthModule,
|
|
14
|
+
providers: [
|
|
15
|
+
{
|
|
16
|
+
provide: 'AuthStrategy',
|
|
17
|
+
useFactory: (cfg: ConfigService): AuthStrategy =>
|
|
18
|
+
buildStrategyWithFallback<AuthStrategy>({
|
|
19
|
+
service: 'auth MS',
|
|
20
|
+
provider: 'postgres',
|
|
21
|
+
requiredEnv: POSTGRES_AUTH_REQUIRED_ENV,
|
|
22
|
+
cfg,
|
|
23
|
+
envPath,
|
|
24
|
+
build: () =>
|
|
25
|
+
new PostgresAuthStrategy({
|
|
26
|
+
url: cfg.getOrThrow<string>('POSTGRES_URL'),
|
|
27
|
+
jwtSecret: cfg.getOrThrow<string>('JWT_SECRET'),
|
|
28
|
+
jwtExpiresIn: cfg.get<string>('JWT_EXPIRES_IN'),
|
|
29
|
+
refreshExpiresIn: cfg.get<string>('JWT_REFRESH_EXPIRES_IN'),
|
|
30
|
+
}),
|
|
31
|
+
fake: () => new FakeAuthStrategy(),
|
|
32
|
+
}),
|
|
33
|
+
inject: [ConfigService],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
exports: ['AuthStrategy'],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import postgres from 'postgres';
|
|
2
|
+
import * as bcrypt from 'bcrypt';
|
|
3
|
+
import * as jwt from 'jsonwebtoken';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import type {
|
|
6
|
+
AuthSession,
|
|
7
|
+
AuthStrategy,
|
|
8
|
+
MagicLinkRequest,
|
|
9
|
+
OAuthProvider,
|
|
10
|
+
OAuthStartResult,
|
|
11
|
+
VerifiedToken,
|
|
12
|
+
} from '@icore/shared';
|
|
13
|
+
|
|
14
|
+
export interface PostgresAuthStrategyOptions {
|
|
15
|
+
url: string;
|
|
16
|
+
jwtSecret: string;
|
|
17
|
+
jwtExpiresIn?: string;
|
|
18
|
+
refreshExpiresIn?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseDurationSeconds(s: string): number {
|
|
22
|
+
const m = /^(\d+)(s|m|h|d)$/.exec(s);
|
|
23
|
+
if (!m) return 900;
|
|
24
|
+
const n = parseInt(m[1] as string, 10);
|
|
25
|
+
const unit = m[2] as string;
|
|
26
|
+
if (unit === 's') return n;
|
|
27
|
+
if (unit === 'm') return n * 60;
|
|
28
|
+
if (unit === 'h') return n * 3600;
|
|
29
|
+
return n * 86400;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseDurationMs(s: string): number {
|
|
33
|
+
return parseDurationSeconds(s) * 1000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class PostgresAuthStrategy implements AuthStrategy {
|
|
37
|
+
private readonly sql: postgres.Sql;
|
|
38
|
+
private tablesReadyPromise: Promise<void> | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly opts: PostgresAuthStrategyOptions) {
|
|
41
|
+
this.sql = postgres(opts.url);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private ensureTables(): Promise<void> {
|
|
45
|
+
if (!this.tablesReadyPromise) {
|
|
46
|
+
this.tablesReadyPromise = this._createTables();
|
|
47
|
+
}
|
|
48
|
+
return this.tablesReadyPromise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async _createTables(): Promise<void> {
|
|
52
|
+
await this.sql`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS _icore_users (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
email TEXT UNIQUE NOT NULL,
|
|
56
|
+
password_hash TEXT,
|
|
57
|
+
role TEXT,
|
|
58
|
+
last_logged_in TIMESTAMPTZ,
|
|
59
|
+
created_at TIMESTAMPTZ DEFAULT now()
|
|
60
|
+
)
|
|
61
|
+
`;
|
|
62
|
+
await this.sql`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS _icore_sessions (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
user_id TEXT NOT NULL,
|
|
66
|
+
refresh_token TEXT UNIQUE NOT NULL,
|
|
67
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
68
|
+
)
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async verifyToken(token: string): Promise<VerifiedToken> {
|
|
73
|
+
try {
|
|
74
|
+
const decoded = jwt.verify(token, this.opts.jwtSecret) as jwt.JwtPayload;
|
|
75
|
+
return {
|
|
76
|
+
uid: decoded.sub as string,
|
|
77
|
+
email: decoded['email'] as string,
|
|
78
|
+
role: decoded['role'] as string,
|
|
79
|
+
};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error('invalid_token', { cause: err });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async signIn(email: string, password: string): Promise<AuthSession> {
|
|
86
|
+
await this.ensureTables();
|
|
87
|
+
const rows = await this.sql<
|
|
88
|
+
{ id: string; email: string; password_hash: string; role: string | null }[]
|
|
89
|
+
>`
|
|
90
|
+
SELECT id, email, password_hash, role FROM _icore_users WHERE email = ${email}
|
|
91
|
+
`;
|
|
92
|
+
const user = rows[0];
|
|
93
|
+
if (!user || !user.password_hash) throw new Error('invalid_credentials');
|
|
94
|
+
const ok = await bcrypt.compare(password, user.password_hash);
|
|
95
|
+
if (!ok) throw new Error('invalid_credentials');
|
|
96
|
+
await this.sql`
|
|
97
|
+
UPDATE _icore_users SET last_logged_in = now() WHERE id = ${user.id}
|
|
98
|
+
`;
|
|
99
|
+
return this.createSession({ id: user.id, email: user.email, role: user.role ?? undefined });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async signUp(email: string, password: string): Promise<AuthSession> {
|
|
103
|
+
await this.ensureTables();
|
|
104
|
+
const id = randomUUID();
|
|
105
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
106
|
+
try {
|
|
107
|
+
await this.sql`
|
|
108
|
+
INSERT INTO _icore_users (id, email, password_hash) VALUES (${id}, ${email}, ${passwordHash})
|
|
109
|
+
`;
|
|
110
|
+
} catch (err: unknown) {
|
|
111
|
+
if (
|
|
112
|
+
err &&
|
|
113
|
+
typeof err === 'object' &&
|
|
114
|
+
'code' in err &&
|
|
115
|
+
(err as { code: string }).code === '23505'
|
|
116
|
+
) {
|
|
117
|
+
throw new Error('user_already_exists', { cause: err });
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
return this.createSession({ id, email });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async refresh(refreshToken: string): Promise<AuthSession> {
|
|
125
|
+
await this.ensureTables();
|
|
126
|
+
const sessions = await this.sql<{ id: string; user_id: string; expires_at: Date }[]>`
|
|
127
|
+
SELECT id, user_id, expires_at FROM _icore_sessions WHERE refresh_token = ${refreshToken}
|
|
128
|
+
`;
|
|
129
|
+
const session = sessions[0];
|
|
130
|
+
if (!session || session.expires_at < new Date()) {
|
|
131
|
+
if (session) {
|
|
132
|
+
await this.sql`DELETE FROM _icore_sessions WHERE id = ${session.id}`;
|
|
133
|
+
}
|
|
134
|
+
throw new Error('invalid_refresh_token');
|
|
135
|
+
}
|
|
136
|
+
const users = await this.sql<{ id: string; email: string; role: string | null }[]>`
|
|
137
|
+
SELECT id, email, role FROM _icore_users WHERE id = ${session.user_id}
|
|
138
|
+
`;
|
|
139
|
+
const user = users[0];
|
|
140
|
+
if (!user) throw new Error('user_not_found');
|
|
141
|
+
await this.sql`DELETE FROM _icore_sessions WHERE id = ${session.id}`;
|
|
142
|
+
await this.sql`
|
|
143
|
+
UPDATE _icore_users SET last_logged_in = now() WHERE id = ${user.id}
|
|
144
|
+
`;
|
|
145
|
+
return this.createSession({ id: user.id, email: user.email, role: user.role ?? undefined });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async setRole(uid: string, role: string): Promise<void> {
|
|
149
|
+
await this.ensureTables();
|
|
150
|
+
await this.sql`UPDATE _icore_users SET role = ${role} WHERE id = ${uid}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async getRole(uid: string): Promise<string | null> {
|
|
154
|
+
await this.ensureTables();
|
|
155
|
+
const rows = await this.sql<{ role: string | null }[]>`
|
|
156
|
+
SELECT role FROM _icore_users WHERE id = ${uid}
|
|
157
|
+
`;
|
|
158
|
+
return rows[0]?.role ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async sendMagicLink(_req: MagicLinkRequest): Promise<void> {
|
|
162
|
+
throw new Error('not_implemented');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async verifyMagicLink(_token: string): Promise<AuthSession> {
|
|
166
|
+
throw new Error('not_implemented');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async startOAuth(_provider: OAuthProvider, _callbackUrl: string): Promise<OAuthStartResult> {
|
|
170
|
+
throw new Error('not_implemented');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async completeOAuth(
|
|
174
|
+
_provider: OAuthProvider,
|
|
175
|
+
_code: string,
|
|
176
|
+
_state: string,
|
|
177
|
+
): Promise<AuthSession> {
|
|
178
|
+
throw new Error('not_implemented');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async createSession(user: {
|
|
182
|
+
id: string;
|
|
183
|
+
email: string;
|
|
184
|
+
role?: string;
|
|
185
|
+
}): Promise<AuthSession> {
|
|
186
|
+
const expiresIn = this.opts.jwtExpiresIn ?? '15m';
|
|
187
|
+
const accessToken = jwt.sign(
|
|
188
|
+
{ sub: user.id, email: user.email, role: user.role },
|
|
189
|
+
this.opts.jwtSecret,
|
|
190
|
+
{ expiresIn: expiresIn as jwt.SignOptions['expiresIn'] },
|
|
191
|
+
);
|
|
192
|
+
const refreshToken = randomUUID();
|
|
193
|
+
const refreshMs = parseDurationMs(this.opts.refreshExpiresIn ?? '7d');
|
|
194
|
+
const expiresAt = new Date(Date.now() + refreshMs);
|
|
195
|
+
await this.sql`
|
|
196
|
+
INSERT INTO _icore_sessions (id, user_id, refresh_token, expires_at)
|
|
197
|
+
VALUES (${randomUUID()}, ${user.id}, ${refreshToken}, ${expiresAt})
|
|
198
|
+
`;
|
|
199
|
+
return {
|
|
200
|
+
accessToken,
|
|
201
|
+
refreshToken,
|
|
202
|
+
expiresIn: parseDurationSeconds(expiresIn),
|
|
203
|
+
user: { id: user.id, email: user.email },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async end(): Promise<void> {
|
|
208
|
+
await this.sql.end();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import * as bcrypt from 'bcrypt';
|
|
3
|
+
import * as jwt from 'jsonwebtoken';
|
|
4
|
+
import type {
|
|
5
|
+
AuthSession,
|
|
6
|
+
AuthStrategy,
|
|
7
|
+
MagicLinkRequest,
|
|
8
|
+
OAuthProvider,
|
|
9
|
+
OAuthStartResult,
|
|
10
|
+
VerifiedToken,
|
|
11
|
+
} from '@icore/shared';
|
|
12
|
+
|
|
13
|
+
const MOCK_JWT_SECRET = 'mock-test-secret';
|
|
14
|
+
|
|
15
|
+
export function createMockPostgresAuth(): AuthStrategy {
|
|
16
|
+
const users = new Map<
|
|
17
|
+
string,
|
|
18
|
+
{ id: string; email: string; passwordHash: string; role?: string }
|
|
19
|
+
>();
|
|
20
|
+
const sessions = new Map<string, { userId: string; expiresAt: Date }>();
|
|
21
|
+
|
|
22
|
+
function buildSession(user: { id: string; email: string; role?: string }): AuthSession {
|
|
23
|
+
const accessToken = jwt.sign(
|
|
24
|
+
{ sub: user.id, email: user.email, role: user.role },
|
|
25
|
+
MOCK_JWT_SECRET,
|
|
26
|
+
{ expiresIn: '1h' },
|
|
27
|
+
);
|
|
28
|
+
const refreshToken = randomUUID();
|
|
29
|
+
sessions.set(refreshToken, {
|
|
30
|
+
userId: user.id,
|
|
31
|
+
expiresAt: new Date(Date.now() + 7 * 86400 * 1000),
|
|
32
|
+
});
|
|
33
|
+
return { accessToken, refreshToken, expiresIn: 3600, user: { id: user.id, email: user.email } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
async signUp(email: string, password: string): Promise<AuthSession> {
|
|
38
|
+
if ([...users.values()].find((u) => u.email === email)) {
|
|
39
|
+
throw new Error('user_already_exists');
|
|
40
|
+
}
|
|
41
|
+
const id = randomUUID();
|
|
42
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
43
|
+
users.set(id, { id, email, passwordHash });
|
|
44
|
+
return buildSession({ id, email });
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async signIn(email: string, password: string): Promise<AuthSession> {
|
|
48
|
+
const user = [...users.values()].find((u) => u.email === email);
|
|
49
|
+
if (!user) throw new Error('invalid_credentials');
|
|
50
|
+
const ok = await bcrypt.compare(password, user.passwordHash);
|
|
51
|
+
if (!ok) throw new Error('invalid_credentials');
|
|
52
|
+
return buildSession(user);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async verifyToken(token: string): Promise<VerifiedToken> {
|
|
56
|
+
try {
|
|
57
|
+
const decoded = jwt.verify(token, MOCK_JWT_SECRET) as jwt.JwtPayload;
|
|
58
|
+
return {
|
|
59
|
+
uid: decoded.sub as string,
|
|
60
|
+
email: decoded['email'] as string,
|
|
61
|
+
role: decoded['role'] as string,
|
|
62
|
+
};
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error('invalid_token', { cause: err });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async refresh(refreshToken: string): Promise<AuthSession> {
|
|
69
|
+
const session = sessions.get(refreshToken);
|
|
70
|
+
if (!session || session.expiresAt < new Date()) {
|
|
71
|
+
sessions.delete(refreshToken);
|
|
72
|
+
throw new Error('invalid_refresh_token');
|
|
73
|
+
}
|
|
74
|
+
const user = users.get(session.userId);
|
|
75
|
+
if (!user) throw new Error('user_not_found');
|
|
76
|
+
sessions.delete(refreshToken);
|
|
77
|
+
return buildSession(user);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async setRole(uid: string, role: string): Promise<void> {
|
|
81
|
+
const user = users.get(uid);
|
|
82
|
+
if (user) user.role = role;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async getRole(uid: string): Promise<string | null> {
|
|
86
|
+
return users.get(uid)?.role ?? null;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async sendMagicLink(_req: MagicLinkRequest): Promise<void> {
|
|
90
|
+
throw new Error('not_implemented');
|
|
91
|
+
},
|
|
92
|
+
async verifyMagicLink(_token: string): Promise<AuthSession> {
|
|
93
|
+
throw new Error('not_implemented');
|
|
94
|
+
},
|
|
95
|
+
async startOAuth(_provider: OAuthProvider, _callbackUrl: string): Promise<OAuthStartResult> {
|
|
96
|
+
throw new Error('not_implemented');
|
|
97
|
+
},
|
|
98
|
+
async completeOAuth(
|
|
99
|
+
_provider: OAuthProvider,
|
|
100
|
+
_code: string,
|
|
101
|
+
_state: string,
|
|
102
|
+
): Promise<AuthSession> {
|
|
103
|
+
throw new Error('not_implemented');
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "node16",
|
|
5
|
+
"moduleResolution": "node16",
|
|
6
|
+
"experimentalDecorators": true,
|
|
7
|
+
"emitDecoratorMetadata": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"importHelpers": true,
|
|
11
|
+
"noImplicitOverride": true,
|
|
12
|
+
"noImplicitReturns": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"noPropertyAccessFromIndexSignature": true
|
|
15
|
+
},
|
|
16
|
+
"files": [],
|
|
17
|
+
"include": [],
|
|
18
|
+
"references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }]
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../../dist/out-tsc",
|
|
5
|
+
"rootDir": "../../..",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"types": ["node"]
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/**/*.ts"],
|
|
10
|
+
"exclude": [
|
|
11
|
+
"vite.config.ts",
|
|
12
|
+
"vite.config.mts",
|
|
13
|
+
"vitest.config.ts",
|
|
14
|
+
"vitest.config.mts",
|
|
15
|
+
"src/**/*.test.ts",
|
|
16
|
+
"src/**/*.spec.ts",
|
|
17
|
+
"src/**/*.test.tsx",
|
|
18
|
+
"src/**/*.spec.tsx",
|
|
19
|
+
"src/**/*.test.js",
|
|
20
|
+
"src/**/*.spec.js",
|
|
21
|
+
"src/**/*.test.jsx",
|
|
22
|
+
"src/**/*.spec.jsx"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../../dist/out-tsc",
|
|
5
|
+
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
|
|
6
|
+
},
|
|
7
|
+
"include": [
|
|
8
|
+
"vite.config.ts",
|
|
9
|
+
"vite.config.mts",
|
|
10
|
+
"vitest.config.ts",
|
|
11
|
+
"vitest.config.mts",
|
|
12
|
+
"src/**/*.test.ts",
|
|
13
|
+
"src/**/*.spec.ts",
|
|
14
|
+
"src/**/*.test.tsx",
|
|
15
|
+
"src/**/*.spec.tsx",
|
|
16
|
+
"src/**/*.test.js",
|
|
17
|
+
"src/**/*.spec.js",
|
|
18
|
+
"src/**/*.test.jsx",
|
|
19
|
+
"src/**/*.spec.jsx",
|
|
20
|
+
"src/**/*.d.ts"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
|
3
|
+
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
|
4
|
+
|
|
5
|
+
export default defineConfig(() => ({
|
|
6
|
+
root: __dirname,
|
|
7
|
+
cacheDir: '../../../node_modules/.vite/libs/auth-strategies/postgres',
|
|
8
|
+
plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
|
9
|
+
test: {
|
|
10
|
+
name: 'auth-postgres',
|
|
11
|
+
watch: false,
|
|
12
|
+
globals: true,
|
|
13
|
+
environment: 'node',
|
|
14
|
+
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
|
15
|
+
passWithNoTests: true,
|
|
16
|
+
reporters: ['default'],
|
|
17
|
+
coverage: {
|
|
18
|
+
reportsDirectory: '../../../coverage/libs/auth-strategies/postgres',
|
|
19
|
+
provider: 'v8' as const,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"@icore/firebase-admin": ["./libs/firebase-admin/src/index.ts"],
|
|
36
36
|
"@icore/db-mongodb": ["./libs/db-strategies/mongodb/src/index.ts"],
|
|
37
37
|
"@icore/storage-mongodb": ["./libs/storage-strategies/mongodb/src/index.ts"],
|
|
38
|
-
"@icore/auth-mongodb": ["./libs/auth-strategies/mongodb/src/index.ts"]
|
|
38
|
+
"@icore/auth-mongodb": ["./libs/auth-strategies/mongodb/src/index.ts"],
|
|
39
|
+
"@icore/auth-postgres": ["./libs/auth-strategies/postgres/src/index.ts"]
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
"exclude": ["node_modules", "dist", ".nx", "tools/create-icore/templates"]
|