@mostajs/auth-lite 0.1.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/LICENSE +29 -0
- package/README.md +212 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +136 -0
- package/llms.txt +101 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 19 November 2007
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
8
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
(at your option) any later version.
|
|
10
|
+
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU Affero General Public License for more details.
|
|
15
|
+
|
|
16
|
+
You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
|
|
19
|
+
COMMERCIAL LICENSE
|
|
20
|
+
|
|
21
|
+
For organizations that cannot comply with the AGPL open-source requirements,
|
|
22
|
+
a commercial license is available. Contact: drmdh@msn.com
|
|
23
|
+
|
|
24
|
+
The commercial license allows you to:
|
|
25
|
+
- Use the software in proprietary/closed-source projects
|
|
26
|
+
- Modify without publishing your source code
|
|
27
|
+
- Get priority support and SLA
|
|
28
|
+
|
|
29
|
+
Contact: Dr Hamid MADANI <drmdh@msn.com>
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @mostajs/auth-lite
|
|
2
|
+
|
|
3
|
+
> Minimal email/password + session auth for **Next.js (App Router)** on top of [`@mostajs/orm`](https://www.npmjs.com/package/@mostajs/orm).
|
|
4
|
+
> **No native addon** (no bcrypt/argon2) → it boots in **Bolt.new / StackBlitz / WebContainers / the edge**, on the first try.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/@mostajs/auth-lite)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
`auth-lite` is the **readable, dependency-free** auth brick: salted iterated SHA-256
|
|
10
|
+
password hashing (Node core `crypto`), a ready-made `Session` schema, login/signup/logout
|
|
11
|
+
Route Handlers, and a `getCurrentUser()` for Server Components. It was extracted from the
|
|
12
|
+
`mostajs-saas-starter` and hardened until it actually boots inside the StackBlitz
|
|
13
|
+
WebContainer — the lessons learned are baked into its API (see [Why "lite"](#why-lite--webcontainer-safe-by-design)).
|
|
14
|
+
|
|
15
|
+
> **Need OAuth, MFA, WebAuthn, magic links, Argon2id, refresh tokens?** Use the full
|
|
16
|
+
> [`@mostajs/auth`](https://www.npmjs.com/package/@mostajs/auth) instead. `auth-lite` and
|
|
17
|
+
> `@mostajs/auth` are alternatives — pick one (see [comparison](#auth-lite-vs-mostajsauth)).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i @mostajs/auth-lite @mostajs/orm next
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Peer requirements: `@mostajs/orm >= 2.5.2`, `next >= 14`.
|
|
28
|
+
To boot in a browser / WebContainer, use one of the ORM's WASM dialects (`sqljs` or `pglite`)
|
|
29
|
+
so there is **zero native binary** in the dependency tree.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quickstart (5 steps)
|
|
34
|
+
|
|
35
|
+
### 1 · Schemas — your `User` + the bundled `Session`
|
|
36
|
+
|
|
37
|
+
Your `User` entity must have at least `email` (unique), `passwordHash`, and `name`.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// lib/orm/schemas.ts
|
|
41
|
+
import type { EntitySchema } from '@mostajs/orm';
|
|
42
|
+
export { SessionSchema } from '@mostajs/auth-lite';
|
|
43
|
+
|
|
44
|
+
export const UserSchema: EntitySchema = {
|
|
45
|
+
name: 'User',
|
|
46
|
+
collection: 'users',
|
|
47
|
+
fields: {
|
|
48
|
+
email: { type: 'string', required: true, unique: true, lowercase: true, trim: true },
|
|
49
|
+
name: { type: 'string', required: true, trim: true },
|
|
50
|
+
passwordHash: { type: 'string', required: true },
|
|
51
|
+
},
|
|
52
|
+
indexes: [{ fields: ['email'], unique: true }],
|
|
53
|
+
timestamps: true,
|
|
54
|
+
};
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2 · Repos — expose `getRepos()`
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// lib/orm/index.ts
|
|
61
|
+
import { BaseRepository } from '@mostajs/orm';
|
|
62
|
+
import { getDialect } from '@mostajs/orm';
|
|
63
|
+
import { SessionSchema } from '@mostajs/auth-lite';
|
|
64
|
+
import { UserSchema } from './schemas';
|
|
65
|
+
|
|
66
|
+
export type User = { id: string; email: string; name: string; passwordHash: string };
|
|
67
|
+
|
|
68
|
+
export async function getRepos() {
|
|
69
|
+
const dialect = await getDialect(
|
|
70
|
+
{ dialect: 'sqljs', uri: ':memory:' }, // WASM → boots in Bolt/StackBlitz
|
|
71
|
+
[UserSchema, SessionSchema],
|
|
72
|
+
);
|
|
73
|
+
return {
|
|
74
|
+
users: new BaseRepository<User>(UserSchema, dialect),
|
|
75
|
+
sessions: new BaseRepository(SessionSchema, dialect),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 3 · Route Handlers — login / signup / logout
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// app/api/auth/login/route.ts
|
|
84
|
+
import { createAuthHandlers } from '@mostajs/auth-lite';
|
|
85
|
+
import { getRepos } from '@/lib/orm';
|
|
86
|
+
export const POST = createAuthHandlers({ getRepos }).login;
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// app/api/auth/signup/route.ts
|
|
91
|
+
import { createAuthHandlers } from '@mostajs/auth-lite';
|
|
92
|
+
import { getRepos } from '@/lib/orm';
|
|
93
|
+
export const POST = createAuthHandlers({ getRepos }).signup;
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// app/api/auth/logout/route.ts
|
|
98
|
+
import { createAuthHandlers } from '@mostajs/auth-lite';
|
|
99
|
+
import { getRepos } from '@/lib/orm';
|
|
100
|
+
export const POST = createAuthHandlers({ getRepos }).logout;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 4 · Read the session in Server Components
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// lib/auth.ts
|
|
107
|
+
import { createGetCurrentUser } from '@mostajs/auth-lite';
|
|
108
|
+
import { getRepos, type User } from '@/lib/orm';
|
|
109
|
+
export const getCurrentUser = createGetCurrentUser<User>({ getRepos });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 5 · Forms post to the handlers (works without client JS)
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
// app/login/page.tsx
|
|
116
|
+
export default function LoginPage() {
|
|
117
|
+
return (
|
|
118
|
+
<form action="/api/auth/login" method="post">
|
|
119
|
+
<input name="email" type="email" required />
|
|
120
|
+
<input name="password" type="password" required />
|
|
121
|
+
<button type="submit">Log in</button>
|
|
122
|
+
</form>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Guard each protected page:
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// app/dashboard/page.tsx
|
|
131
|
+
import { redirect } from 'next/navigation';
|
|
132
|
+
import { getCurrentUser } from '@/lib/auth';
|
|
133
|
+
|
|
134
|
+
export const dynamic = 'force-dynamic';
|
|
135
|
+
|
|
136
|
+
export default async function Dashboard() {
|
|
137
|
+
const user = await getCurrentUser();
|
|
138
|
+
if (!user) redirect('/login'); // ← per page, not only in the layout
|
|
139
|
+
return <p>Welcome, {user.name}</p>;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## API
|
|
146
|
+
|
|
147
|
+
| Export | Signature | Purpose |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `hashPassword` | `(password: string) => string` | Salted, 10 000× iterated SHA-256 → `"salt:hashHex"`. No native addon. |
|
|
150
|
+
| `verifyPassword` | `(password: string, stored: string) => boolean` | Constant-time check (`timingSafeEqual`). |
|
|
151
|
+
| `SessionSchema` | `EntitySchema` | `Session` entity (`token` unique, `expiresAt`, `user` → `User` M-1 cascade). Register it alongside `User`. |
|
|
152
|
+
| `createAuthHandlers` | `(config) => { login, signup, logout }` | Each is `(req: NextRequest) => Promise<NextResponse>`; export as `POST`. |
|
|
153
|
+
| `createGetCurrentUser` | `<TUser>(config) => () => Promise<TUser \| null>` | Resolve the logged-in user from the cookie (read before any DB call). |
|
|
154
|
+
|
|
155
|
+
### `AuthLiteConfig`
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
interface AuthLiteConfig {
|
|
159
|
+
getRepos: () => Promise<{ users: AuthRepo; sessions: AuthRepo }>; // required
|
|
160
|
+
cookieName?: string; // default "session"
|
|
161
|
+
ttlDays?: number; // default 7
|
|
162
|
+
afterAuth?: string; // default "/dashboard"
|
|
163
|
+
afterLogout?: string; // default "/"
|
|
164
|
+
loginErrorPath?: string; // default "/login?error=invalid"
|
|
165
|
+
signupErrorPath?: (kind: 'invalid' | 'exists') => string; // default "/signup?error=<kind>"
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`AuthRepo` is the minimal subset this module needs (`findOne`, `create`, `delete`,
|
|
170
|
+
`findByIdWithRelations`) — fully satisfied by `@mostajs/orm`'s `BaseRepository`.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Why "lite" — WebContainer-safe by design
|
|
175
|
+
|
|
176
|
+
Tested for real in StackBlitz; the failures it hit are now prevented by the API itself:
|
|
177
|
+
|
|
178
|
+
- **Cookies via Route Handlers, not `cookies()`.** Some runtimes (StackBlitz WebContainer)
|
|
179
|
+
lose Next's request async-context (AsyncLocalStorage) across an `await` on the DB, which
|
|
180
|
+
makes a later `cookies()` throw *"called outside a request scope"*. `createAuthHandlers`
|
|
181
|
+
sets the cookie on the **`NextResponse`** object (`res.cookies.set`) — that works everywhere.
|
|
182
|
+
- **Read the cookie before the DB.** `getCurrentUser` reads the cookie while the request
|
|
183
|
+
context is still intact, then resolves the session.
|
|
184
|
+
- **No native binary.** SHA-256 from Node core `crypto` — no bcrypt/argon2 to compile. For a
|
|
185
|
+
classic production server you can swap in argon2/scrypt (the `hashPassword`/`verifyPassword`
|
|
186
|
+
API is unchanged) or move to `@mostajs/auth`.
|
|
187
|
+
- **Guard per page.** Always `if (!user) redirect('/login')` in each protected page — a layout
|
|
188
|
+
guard alone is not enough (layout and page run in parallel → `null.id` crash).
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## `auth-lite` vs `@mostajs/auth`
|
|
193
|
+
|
|
194
|
+
| | `@mostajs/auth-lite` | `@mostajs/auth` |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| Password hash | SHA-256 iterated (core crypto) | Argon2id |
|
|
197
|
+
| Methods | email/password + sessions | email/password, OAuth/OIDC, magic link, MFA TOTP, WebAuthn/Passkeys |
|
|
198
|
+
| Stack | `@mostajs/orm` + Next Route Handlers | NextAuth v5 + `@mostajs/rbac` |
|
|
199
|
+
| Native deps | **none** (WebContainer/edge ready) | argon2 etc. (server) |
|
|
200
|
+
| Footprint | one file, readable | ~30 sub-paths, full-featured |
|
|
201
|
+
| Best for | starters, MVPs, Bolt/StackBlitz demos | production apps needing rich auth |
|
|
202
|
+
|
|
203
|
+
They are **alternatives** — depend on one, not both.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
AGPL-3.0-or-later. A commercial license is available for proprietary/closed-source use —
|
|
210
|
+
see [`LICENSE`](./LICENSE).
|
|
211
|
+
|
|
212
|
+
**Author**: Dr Hamid MADANI <drmdh@msn.com>
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mostajs/auth-lite — minimal, dependency-free email/password + session auth
|
|
3
|
+
* for Next.js (App Router) on top of @mostajs/orm.
|
|
4
|
+
*
|
|
5
|
+
* Why "lite": it boots in WebContainers (Bolt.new / StackBlitz) and the edge —
|
|
6
|
+
* no native addon (no bcrypt/argon2), and it sets the session cookie on the
|
|
7
|
+
* NextResponse object inside Route Handlers, which sidesteps the AsyncLocalStorage
|
|
8
|
+
* pitfalls of `cookies()` after a DB call in constrained runtimes.
|
|
9
|
+
*
|
|
10
|
+
* Password hashing is salted, iterated SHA-256 (works everywhere). For a
|
|
11
|
+
* production server you can swap in argon2/scrypt — the API is unchanged.
|
|
12
|
+
*
|
|
13
|
+
* @author Dr Hamid MADANI <drmdh@msn.com>
|
|
14
|
+
*/
|
|
15
|
+
import type { NextRequest } from 'next/server';
|
|
16
|
+
import type { EntitySchema } from '@mostajs/orm';
|
|
17
|
+
export declare function hashPassword(password: string): string;
|
|
18
|
+
export declare function verifyPassword(password: string, stored: string): boolean;
|
|
19
|
+
export declare const SessionSchema: EntitySchema;
|
|
20
|
+
/** Minimal repository shape this module needs (compatible with @mostajs/orm BaseRepository). */
|
|
21
|
+
export interface AuthRepo {
|
|
22
|
+
findOne(filter: Record<string, unknown>): Promise<any>;
|
|
23
|
+
create(data: Record<string, unknown>): Promise<any>;
|
|
24
|
+
delete(id: string): Promise<unknown>;
|
|
25
|
+
findByIdWithRelations(id: string, relations: string[], options?: unknown): Promise<any>;
|
|
26
|
+
}
|
|
27
|
+
export interface AuthLiteConfig {
|
|
28
|
+
/** Returns the User + Session repositories (e.g. your getRepos()). */
|
|
29
|
+
getRepos: () => Promise<{
|
|
30
|
+
users: AuthRepo;
|
|
31
|
+
sessions: AuthRepo;
|
|
32
|
+
}>;
|
|
33
|
+
/** Session cookie name (default "session"). */
|
|
34
|
+
cookieName?: string;
|
|
35
|
+
/** Session lifetime in days (default 7). */
|
|
36
|
+
ttlDays?: number;
|
|
37
|
+
/** Where to go after login/signup (default "/dashboard"). */
|
|
38
|
+
afterAuth?: string;
|
|
39
|
+
/** Where to go after logout (default "/"). */
|
|
40
|
+
afterLogout?: string;
|
|
41
|
+
/** Redirect on invalid login (default "/login?error=invalid"). */
|
|
42
|
+
loginErrorPath?: string;
|
|
43
|
+
/** Redirect on signup error (default "/signup?error=<kind>"). */
|
|
44
|
+
signupErrorPath?: (kind: 'invalid' | 'exists') => string;
|
|
45
|
+
}
|
|
46
|
+
export declare function createAuthHandlers(config: AuthLiteConfig): {
|
|
47
|
+
login: (req: NextRequest) => Promise<import("next/server").NextResponse<unknown>>;
|
|
48
|
+
signup: (req: NextRequest) => Promise<import("next/server").NextResponse<unknown>>;
|
|
49
|
+
logout: (req: NextRequest) => Promise<import("next/server").NextResponse<unknown>>;
|
|
50
|
+
};
|
|
51
|
+
export declare function createGetCurrentUser<TUser = unknown>(config: AuthLiteConfig): () => Promise<TUser | null>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { baseFromHeaders } from '@mostajs/url';
|
|
3
|
+
/**
|
|
4
|
+
* Base PUBLIQUE (`proto://host`) pour les redirects. En WebContainer
|
|
5
|
+
* (StackBlitz/Bolt) ou derrière un reverse proxy, `req.url` côté Node pointe
|
|
6
|
+
* sur le bind interne (`http://localhost:3000`) → l'utilisateur serait redirigé
|
|
7
|
+
* vers localhost. `baseFromHeaders` lit l'hôte public réel depuis
|
|
8
|
+
* `X-Forwarded-Host`/`Host` ; fallback sur l'origine de `req.url`.
|
|
9
|
+
*/
|
|
10
|
+
function reqBase(req) {
|
|
11
|
+
return baseFromHeaders(req.headers) ?? new URL(req.url).origin;
|
|
12
|
+
}
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Password hashing — salted, iterated SHA-256 (no native addon, boots anywhere)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const ITERATIONS = 10_000;
|
|
17
|
+
function derive(password, salt) {
|
|
18
|
+
let h = createHash('sha256').update(`${salt}:${password}`).digest();
|
|
19
|
+
for (let i = 0; i < ITERATIONS; i++)
|
|
20
|
+
h = createHash('sha256').update(h).digest();
|
|
21
|
+
return h;
|
|
22
|
+
}
|
|
23
|
+
export function hashPassword(password) {
|
|
24
|
+
const salt = randomBytes(16).toString('hex');
|
|
25
|
+
return `${salt}:${derive(password, salt).toString('hex')}`;
|
|
26
|
+
}
|
|
27
|
+
export function verifyPassword(password, stored) {
|
|
28
|
+
const [salt, hashHex] = stored.split(':');
|
|
29
|
+
if (!salt || !hashHex)
|
|
30
|
+
return false;
|
|
31
|
+
const expected = Buffer.from(hashHex, 'hex');
|
|
32
|
+
const actual = derive(password, salt);
|
|
33
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Default Session EntitySchema — register it alongside your own `User` entity
|
|
37
|
+
// (User must have at least `email` (unique) and `passwordHash`; `name` for signup).
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export const SessionSchema = {
|
|
40
|
+
name: 'Session',
|
|
41
|
+
collection: 'sessions',
|
|
42
|
+
fields: {
|
|
43
|
+
token: { type: 'string', required: true, unique: true },
|
|
44
|
+
expiresAt: { type: 'date', required: true },
|
|
45
|
+
},
|
|
46
|
+
relations: {
|
|
47
|
+
user: { target: 'User', type: 'many-to-one', required: true, onDelete: 'cascade' },
|
|
48
|
+
},
|
|
49
|
+
indexes: [{ fields: ['token'], unique: true }],
|
|
50
|
+
timestamps: true,
|
|
51
|
+
};
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Route Handlers — login / signup / logout (set the cookie on the response)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
export function createAuthHandlers(config) {
|
|
56
|
+
const cookie = config.cookieName ?? 'session';
|
|
57
|
+
const ttlMs = (config.ttlDays ?? 7) * 86400000;
|
|
58
|
+
const afterAuth = config.afterAuth ?? '/dashboard';
|
|
59
|
+
const afterLogout = config.afterLogout ?? '/';
|
|
60
|
+
const loginError = config.loginErrorPath ?? '/login?error=invalid';
|
|
61
|
+
const signupError = config.signupErrorPath ?? ((k) => `/signup?error=${k}`);
|
|
62
|
+
async function startSession(req, sessions, userId) {
|
|
63
|
+
const { NextResponse } = await import('next/server');
|
|
64
|
+
const token = randomBytes(32).toString('hex');
|
|
65
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
66
|
+
await sessions.create({ token, user: userId, expiresAt });
|
|
67
|
+
const res = NextResponse.redirect(new URL(afterAuth, reqBase(req)), 303);
|
|
68
|
+
res.cookies.set(cookie, token, { httpOnly: true, sameSite: 'lax', path: '/', expires: expiresAt });
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
/** POST handler — verify credentials, start a session. */
|
|
72
|
+
async function login(req) {
|
|
73
|
+
const { NextResponse } = await import('next/server');
|
|
74
|
+
const form = await req.formData();
|
|
75
|
+
const email = String(form.get('email') ?? '').toLowerCase().trim();
|
|
76
|
+
const password = String(form.get('password') ?? '');
|
|
77
|
+
const { users, sessions } = await config.getRepos();
|
|
78
|
+
const user = await users.findOne({ email });
|
|
79
|
+
if (!user || !verifyPassword(password, user.passwordHash)) {
|
|
80
|
+
return NextResponse.redirect(new URL(loginError, reqBase(req)), 303);
|
|
81
|
+
}
|
|
82
|
+
return startSession(req, sessions, user.id);
|
|
83
|
+
}
|
|
84
|
+
/** POST handler — create the account, start a session. */
|
|
85
|
+
async function signup(req) {
|
|
86
|
+
const { NextResponse } = await import('next/server');
|
|
87
|
+
const form = await req.formData();
|
|
88
|
+
const email = String(form.get('email') ?? '').toLowerCase().trim();
|
|
89
|
+
const name = String(form.get('name') ?? '').trim();
|
|
90
|
+
const password = String(form.get('password') ?? '');
|
|
91
|
+
if (!email || !name || password.length < 6) {
|
|
92
|
+
return NextResponse.redirect(new URL(signupError('invalid'), reqBase(req)), 303);
|
|
93
|
+
}
|
|
94
|
+
const { users, sessions } = await config.getRepos();
|
|
95
|
+
if (await users.findOne({ email })) {
|
|
96
|
+
return NextResponse.redirect(new URL(signupError('exists'), reqBase(req)), 303);
|
|
97
|
+
}
|
|
98
|
+
const user = await users.create({ email, name, passwordHash: hashPassword(password) });
|
|
99
|
+
return startSession(req, sessions, user.id);
|
|
100
|
+
}
|
|
101
|
+
/** POST handler — destroy the session (DB + cookie). */
|
|
102
|
+
async function logout(req) {
|
|
103
|
+
const { NextResponse } = await import('next/server');
|
|
104
|
+
const token = req.cookies.get(cookie)?.value;
|
|
105
|
+
if (token) {
|
|
106
|
+
const { sessions } = await config.getRepos();
|
|
107
|
+
const session = await sessions.findOne({ token });
|
|
108
|
+
if (session)
|
|
109
|
+
await sessions.delete(session.id);
|
|
110
|
+
}
|
|
111
|
+
const res = NextResponse.redirect(new URL(afterLogout, reqBase(req)), 303);
|
|
112
|
+
res.cookies.delete(cookie);
|
|
113
|
+
return res;
|
|
114
|
+
}
|
|
115
|
+
return { login, signup, logout };
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// getCurrentUser — read the session in Server Components (cookie read before DB)
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
export function createGetCurrentUser(config) {
|
|
121
|
+
const cookie = config.cookieName ?? 'session';
|
|
122
|
+
return async function getCurrentUser() {
|
|
123
|
+
const { cookies } = await import('next/headers');
|
|
124
|
+
const token = (await cookies()).get(cookie)?.value;
|
|
125
|
+
if (!token)
|
|
126
|
+
return null;
|
|
127
|
+
const { sessions } = await config.getRepos();
|
|
128
|
+
const session = await sessions.findOne({ token });
|
|
129
|
+
if (!session)
|
|
130
|
+
return null;
|
|
131
|
+
if (new Date(session.expiresAt) < new Date())
|
|
132
|
+
return null;
|
|
133
|
+
const populated = (await sessions.findByIdWithRelations(session.id, ['user']));
|
|
134
|
+
return populated?.user ?? null;
|
|
135
|
+
};
|
|
136
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @mostajs/auth-lite — fiche LLM
|
|
2
|
+
> Auth email/mot de passe + sessions minimale pour Next.js (App Router) sur @mostajs/orm. Zéro addon natif → boote dans Bolt.new / StackBlitz / edge.
|
|
3
|
+
|
|
4
|
+
- Version: 0.1.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
|
|
5
|
+
- Chemin: mostajs/mosta-auth-lite · Statut: scaffold initial (src/index.ts) · peer: @mostajs/orm >=2.5.2, next >=14
|
|
6
|
+
- Origine: extraction de la couche auth du `mostajs-saas-starter`, durcie jusqu'à booter réellement dans le WebContainer StackBlitz/Bolt.
|
|
7
|
+
|
|
8
|
+
## RÔLE
|
|
9
|
+
Brique d'authentification **minimale et sans dépendance native** pour les apps Next.js
|
|
10
|
+
App Router qui tournent sur `@mostajs/orm` — en particulier celles générées/exécutées
|
|
11
|
+
dans un WebContainer (Bolt.new, StackBlitz) ou à l'edge, où `bcrypt`/`argon2` (addons
|
|
12
|
+
natifs) ne se compilent pas. Fournit : hachage de mot de passe (SHA-256 salé itéré,
|
|
13
|
+
cœur Node, zéro binaire), un `EntitySchema` de session prêt à l'emploi, des Route
|
|
14
|
+
Handlers login/signup/logout, et un lecteur de session pour Server Components.
|
|
15
|
+
Ce N'EST PAS un remplaçant de `@mostajs/auth` (le module complet : Argon2id, OAuth/OIDC,
|
|
16
|
+
magic link, MFA TOTP, WebAuthn, refresh tokens, NextAuth v5). `auth-lite` vise la
|
|
17
|
+
simplicité lisible et la compatibilité WebContainer ; `@mostajs/auth` vise la production
|
|
18
|
+
riche en fonctionnalités. Choisir l'un OU l'autre selon le besoin.
|
|
19
|
+
|
|
20
|
+
## INSTALLATION
|
|
21
|
+
npm i @mostajs/auth-lite @mostajs/orm next
|
|
22
|
+
(le consumer fournit son entité `User` et ses repos via @mostajs/orm ; le module ne ship
|
|
23
|
+
aucune DB. Pour booter en navigateur/WebContainer, utiliser un dialecte WASM de l'ORM :
|
|
24
|
+
`sqljs` ou `pglite`.)
|
|
25
|
+
|
|
26
|
+
## EXPORTS (point d'entrée unique `.`)
|
|
27
|
+
- Fonctions: hashPassword, verifyPassword, createAuthHandlers, createGetCurrentUser
|
|
28
|
+
- Schéma: SessionSchema (EntitySchema)
|
|
29
|
+
- Types: AuthRepo, AuthLiteConfig
|
|
30
|
+
|
|
31
|
+
## API — SIGNATURES
|
|
32
|
+
hashPassword(password: string): string
|
|
33
|
+
// → "salt:hashHex" ; SHA-256 salé + itéré 10 000× (crypto cœur Node, aucun addon natif).
|
|
34
|
+
verifyPassword(password: string, stored: string): boolean
|
|
35
|
+
// comparaison à temps constant (timingSafeEqual). false si format invalide.
|
|
36
|
+
SessionSchema: EntitySchema
|
|
37
|
+
// name 'Session', collection 'sessions' ; fields { token (string, unique), expiresAt (date) } ;
|
|
38
|
+
// relation user → 'User' (many-to-one, required, onDelete: 'cascade') ; index unique sur token ; timestamps.
|
|
39
|
+
createAuthHandlers(config: AuthLiteConfig): { login, signup, logout }
|
|
40
|
+
// chaque membre = (req: NextRequest) => Promise<NextResponse> — à exporter comme POST dans un Route Handler.
|
|
41
|
+
// login : vérifie email+password, crée la session, pose le cookie sur la réponse, 303 → afterAuth.
|
|
42
|
+
// signup : valide (email/name requis, password ≥ 6), rejette si email existe, crée user+session, 303 → afterAuth.
|
|
43
|
+
// logout : supprime la session DB + le cookie, 303 → afterLogout.
|
|
44
|
+
createGetCurrentUser<TUser = unknown>(config: AuthLiteConfig): () => Promise<TUser | null>
|
|
45
|
+
// getCurrentUser() : lit le cookie AVANT toute requête DB, résout la session, renvoie l'utilisateur peuplé ou null.
|
|
46
|
+
// null si pas de cookie / session absente / session expirée.
|
|
47
|
+
|
|
48
|
+
## TYPES
|
|
49
|
+
AuthRepo {
|
|
50
|
+
findOne(filter): Promise<any>
|
|
51
|
+
create(data): Promise<any>
|
|
52
|
+
delete(id: string): Promise<unknown>
|
|
53
|
+
findByIdWithRelations(id: string, relations: string[], options?): Promise<any>
|
|
54
|
+
} // sous-ensemble compatible @mostajs/orm BaseRepository.
|
|
55
|
+
|
|
56
|
+
AuthLiteConfig {
|
|
57
|
+
getRepos: () => Promise<{ users: AuthRepo; sessions: AuthRepo }> // requis
|
|
58
|
+
cookieName?: string // défaut "session"
|
|
59
|
+
ttlDays?: number // défaut 7
|
|
60
|
+
afterAuth?: string // défaut "/dashboard"
|
|
61
|
+
afterLogout?: string // défaut "/"
|
|
62
|
+
loginErrorPath?: string // défaut "/login?error=invalid"
|
|
63
|
+
signupErrorPath?: (kind: 'invalid' | 'exists') => string // défaut "/signup?error=<kind>"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
## CONTRAINTES CONSUMER
|
|
67
|
+
- L'entité `User` doit exposer au minimum `email` (unique), `passwordHash`, et `name` (pour signup).
|
|
68
|
+
- Enregistrer `SessionSchema` à côté de l'entité `User` dans l'ORM.
|
|
69
|
+
- `getRepos()` renvoie des `BaseRepository` (ou objets compatibles `AuthRepo`).
|
|
70
|
+
|
|
71
|
+
## POURQUOI « lite » — leçons WebContainer (gravées dans l'API)
|
|
72
|
+
- L1 — Mutation de cookie via Route Handlers : `createAuthHandlers` pose le cookie sur l'objet
|
|
73
|
+
`NextResponse` (`res.cookies.set`), pas via `cookies()`. Certains runtimes (StackBlitz
|
|
74
|
+
WebContainer) perdent le contexte async (AsyncLocalStorage) de Next à travers un `await` DB,
|
|
75
|
+
ce qui fait planter `cookies()` ("called outside a request scope"). Poser le cookie sur la
|
|
76
|
+
réponse marche partout.
|
|
77
|
+
- L2 — Lecture de session avant DB : `getCurrentUser` lit le cookie AVANT toute requête DB
|
|
78
|
+
(contexte requête intact), puis seulement résout l'utilisateur.
|
|
79
|
+
- L3 — Zéro addon natif : hachage SHA-256 (cœur `crypto`), pas de bcrypt/argon2 → compile et
|
|
80
|
+
boote en WebContainer / edge. Pour un serveur prod classique, on peut swapper argon2/scrypt
|
|
81
|
+
(l'API hashPassword/verifyPassword reste identique) ou passer à `@mostajs/auth`.
|
|
82
|
+
- Garde-fou consumer : protéger CHAQUE page par `const u = await getCurrentUser(); if (!u) redirect('/login')`
|
|
83
|
+
— ne pas se reposer sur le seul `layout` + assertion `!` (layout et page s'exécutent en
|
|
84
|
+
parallèle → crash `null.id`).
|
|
85
|
+
|
|
86
|
+
## EXEMPLE MINIMAL (Route Handler login)
|
|
87
|
+
// app/api/auth/login/route.ts
|
|
88
|
+
import { createAuthHandlers } from '@mostajs/auth-lite';
|
|
89
|
+
import { getRepos } from '@/lib/orm';
|
|
90
|
+
const { login } = createAuthHandlers({ getRepos });
|
|
91
|
+
export const POST = login;
|
|
92
|
+
|
|
93
|
+
// lib/auth.ts (Server Components)
|
|
94
|
+
import { createGetCurrentUser } from '@mostajs/auth-lite';
|
|
95
|
+
import { getRepos } from '@/lib/orm';
|
|
96
|
+
export const getCurrentUser = createGetCurrentUser<User>({ getRepos });
|
|
97
|
+
|
|
98
|
+
## VOIR AUSSI
|
|
99
|
+
- `@mostajs/orm` — la couche données (dialectes WASM `sqljs`/`pglite` pour WebContainer).
|
|
100
|
+
- `@mostajs/auth` — auth complète (Argon2id, OAuth, MFA, WebAuthn…) pour serveurs prod.
|
|
101
|
+
- `mostajs-saas-starter` — app de référence d'où la couche est extraite.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostajs/auth-lite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal email/password + session auth for Next.js on @mostajs/orm. No native addon — boots in Bolt.new / StackBlitz / edge.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"llms.txt",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"auth",
|
|
23
|
+
"authentication",
|
|
24
|
+
"sessions",
|
|
25
|
+
"nextjs",
|
|
26
|
+
"mostajs",
|
|
27
|
+
"orm",
|
|
28
|
+
"webcontainer",
|
|
29
|
+
"lightweight",
|
|
30
|
+
"password"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/apolocine/mosta-auth-lite"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://mostajs.dev",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.18.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"dev": "tsc --watch",
|
|
43
|
+
"prepublishOnly": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@mostajs/orm": ">=2.5.2",
|
|
47
|
+
"next": ">=14.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@mostajs/orm": "^2.5.2",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"better-sqlite3": "^12.10.0",
|
|
53
|
+
"next": "^15.4.11",
|
|
54
|
+
"react": "^19.0.0",
|
|
55
|
+
"typescript": "^5.6.0"
|
|
56
|
+
},
|
|
57
|
+
"funding": {
|
|
58
|
+
"type": "github",
|
|
59
|
+
"url": "https://github.com/sponsors/apolocine"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@mostajs/url": "^0.5.0"
|
|
63
|
+
}
|
|
64
|
+
}
|