@mantiq/auth 0.0.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/README.md +19 -0
- package/package.json +56 -0
- package/src/AuthManager.ts +219 -0
- package/src/AuthServiceProvider.ts +41 -0
- package/src/contracts/AuthConfig.ts +19 -0
- package/src/contracts/Authenticatable.ts +29 -0
- package/src/contracts/Guard.ts +31 -0
- package/src/contracts/StatefulGuard.ts +22 -0
- package/src/contracts/UserProvider.ts +25 -0
- package/src/errors/AuthenticationError.ts +15 -0
- package/src/events/AuthEvents.ts +86 -0
- package/src/guards/RequestGuard.ts +81 -0
- package/src/guards/SessionGuard.ts +309 -0
- package/src/helpers/auth.ts +19 -0
- package/src/index.ts +32 -0
- package/src/middleware/Authenticate.ts +113 -0
- package/src/middleware/ConfirmPassword.ts +40 -0
- package/src/middleware/EnsureEmailIsVerified.ts +22 -0
- package/src/middleware/RedirectIfAuthenticated.ts +36 -0
- package/src/providers/DatabaseUserProvider.ts +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mantiq/auth
|
|
2
|
+
|
|
3
|
+
Session & token authentication, guards, providers, and middleware for MantiqJS.
|
|
4
|
+
|
|
5
|
+
Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mantiq/auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/auth",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Session & token auth, guards, providers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/auth",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/abdullahkhan/mantiq.git",
|
|
12
|
+
"directory": "packages/auth"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/abdullahkhan/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript",
|
|
22
|
+
"framework",
|
|
23
|
+
"auth"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"package.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"clean": "rm -rf dist"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mantiq/core": "workspace:*",
|
|
50
|
+
"@mantiq/database": "workspace:*"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"bun-types": "latest",
|
|
54
|
+
"typescript": "^5.7.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { DriverManager, Container, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { HashManager, ENCRYPTER } from '@mantiq/core'
|
|
3
|
+
import type { Guard } from './contracts/Guard.ts'
|
|
4
|
+
import type { StatefulGuard } from './contracts/StatefulGuard.ts'
|
|
5
|
+
import type { UserProvider } from './contracts/UserProvider.ts'
|
|
6
|
+
import type { Authenticatable } from './contracts/Authenticatable.ts'
|
|
7
|
+
import type { AuthConfig } from './contracts/AuthConfig.ts'
|
|
8
|
+
import { SessionGuard } from './guards/SessionGuard.ts'
|
|
9
|
+
import { RequestGuard } from './guards/RequestGuard.ts'
|
|
10
|
+
import { DatabaseUserProvider } from './providers/DatabaseUserProvider.ts'
|
|
11
|
+
|
|
12
|
+
type RequestGuardCallback = (
|
|
13
|
+
request: MantiqRequest,
|
|
14
|
+
provider: UserProvider,
|
|
15
|
+
) => Authenticatable | null | Promise<Authenticatable | null>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Multi-driver authentication manager (Laravel-style).
|
|
19
|
+
*
|
|
20
|
+
* Manages guards (session, request, custom) and user providers.
|
|
21
|
+
* Also proxies Guard/StatefulGuard methods to the default guard
|
|
22
|
+
* for convenience (e.g. `auth().check()`, `auth().attempt()`).
|
|
23
|
+
*/
|
|
24
|
+
export class AuthManager implements DriverManager<Guard> {
|
|
25
|
+
private readonly guards = new Map<string, Guard>()
|
|
26
|
+
private readonly customCreators = new Map<string, (name: string, config: any) => Guard>()
|
|
27
|
+
private readonly requestGuards = new Map<string, RequestGuardCallback>()
|
|
28
|
+
private readonly userProviders = new Map<string, UserProvider>()
|
|
29
|
+
private defaultGuardName: string
|
|
30
|
+
private currentRequest: MantiqRequest | null = null
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly config: AuthConfig,
|
|
34
|
+
private readonly container: Container,
|
|
35
|
+
) {
|
|
36
|
+
this.defaultGuardName = config.defaults.guard
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── DriverManager ───────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
driver(name?: string): Guard {
|
|
42
|
+
return this.guard(name)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
extend(name: string, factory: () => Guard): void {
|
|
46
|
+
this.customCreators.set(name, () => factory())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getDefaultDriver(): string {
|
|
50
|
+
return this.defaultGuardName
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Guard resolution ────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
guard(name?: string): Guard {
|
|
56
|
+
const guardName = name ?? this.defaultGuardName
|
|
57
|
+
|
|
58
|
+
if (!this.guards.has(guardName)) {
|
|
59
|
+
const newGuard = this.createGuard(guardName)
|
|
60
|
+
if (this.currentRequest) {
|
|
61
|
+
newGuard.setRequest(this.currentRequest)
|
|
62
|
+
}
|
|
63
|
+
this.guards.set(guardName, newGuard)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return this.guards.get(guardName)!
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Set the default guard for the current request.
|
|
71
|
+
*/
|
|
72
|
+
shouldUse(name: string): void {
|
|
73
|
+
this.defaultGuardName = name
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a closure-based guard via `viaRequest()`.
|
|
78
|
+
*/
|
|
79
|
+
viaRequest(name: string, callback: RequestGuardCallback): void {
|
|
80
|
+
this.requestGuards.set(name, callback)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Set the request on all resolved guards (call per-request from middleware).
|
|
85
|
+
*/
|
|
86
|
+
setRequest(request: MantiqRequest): void {
|
|
87
|
+
this.currentRequest = request
|
|
88
|
+
for (const guard of this.guards.values()) {
|
|
89
|
+
guard.setRequest(request)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clear all cached guard instances (useful for testing).
|
|
95
|
+
*/
|
|
96
|
+
forgetGuards(): void {
|
|
97
|
+
this.guards.clear()
|
|
98
|
+
this.defaultGuardName = this.config.defaults.guard
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── User provider resolution ────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
createUserProvider(providerName: string): UserProvider {
|
|
104
|
+
if (this.userProviders.has(providerName)) {
|
|
105
|
+
return this.userProviders.get(providerName)!
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const providerConfig = this.config.providers[providerName]
|
|
109
|
+
if (!providerConfig) {
|
|
110
|
+
throw new Error(`Auth provider "${providerName}" is not configured.`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let provider: UserProvider
|
|
114
|
+
|
|
115
|
+
switch (providerConfig.driver) {
|
|
116
|
+
case 'database': {
|
|
117
|
+
const hasher = this.container.make(HashManager)
|
|
118
|
+
provider = new DatabaseUserProvider(providerConfig.model as any, hasher)
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
throw new Error(`Unsupported auth provider driver: ${providerConfig.driver}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.userProviders.set(providerName, provider)
|
|
126
|
+
return provider
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Proxied Guard methods (delegate to default guard) ───────────────────
|
|
130
|
+
|
|
131
|
+
async check(): Promise<boolean> {
|
|
132
|
+
return this.guard().check()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async guest(): Promise<boolean> {
|
|
136
|
+
return this.guard().guest()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async user(): Promise<Authenticatable | null> {
|
|
140
|
+
return this.guard().user()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async id(): Promise<string | number | null> {
|
|
144
|
+
return this.guard().id()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async validate(credentials: Record<string, any>): Promise<boolean> {
|
|
148
|
+
return this.guard().validate(credentials)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Attempt to authenticate (only works with StatefulGuard — the default is usually session).
|
|
153
|
+
*/
|
|
154
|
+
async attempt(credentials: Record<string, any>, remember = false): Promise<boolean> {
|
|
155
|
+
const g = this.guard() as StatefulGuard
|
|
156
|
+
if (!g.attempt) {
|
|
157
|
+
throw new Error(`The "${this.defaultGuardName}" guard does not support attempt(). Use a stateful guard.`)
|
|
158
|
+
}
|
|
159
|
+
return g.attempt(credentials, remember)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async login(user: Authenticatable, remember = false): Promise<void> {
|
|
163
|
+
const g = this.guard() as StatefulGuard
|
|
164
|
+
if (!g.login) {
|
|
165
|
+
throw new Error(`The "${this.defaultGuardName}" guard does not support login(). Use a stateful guard.`)
|
|
166
|
+
}
|
|
167
|
+
return g.login(user, remember)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async logout(): Promise<void> {
|
|
171
|
+
const g = this.guard() as StatefulGuard
|
|
172
|
+
if (!g.logout) {
|
|
173
|
+
throw new Error(`The "${this.defaultGuardName}" guard does not support logout(). Use a stateful guard.`)
|
|
174
|
+
}
|
|
175
|
+
return g.logout()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Internal ────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
private createGuard(name: string): Guard {
|
|
181
|
+
// Check custom creators first
|
|
182
|
+
const custom = this.customCreators.get(name)
|
|
183
|
+
if (custom) return custom(name, {})
|
|
184
|
+
|
|
185
|
+
// Check viaRequest guards
|
|
186
|
+
const requestCallback = this.requestGuards.get(name)
|
|
187
|
+
if (requestCallback) {
|
|
188
|
+
const guardConfig = this.config.guards[name]
|
|
189
|
+
const providerName = guardConfig?.provider ?? Object.keys(this.config.providers)[0]!
|
|
190
|
+
const provider = this.createUserProvider(providerName)
|
|
191
|
+
return new RequestGuard(requestCallback, provider)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Built-in guard from config
|
|
195
|
+
const guardConfig = this.config.guards[name]
|
|
196
|
+
if (!guardConfig) {
|
|
197
|
+
throw new Error(`Auth guard "${name}" is not configured. Check your auth config.`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (guardConfig.driver) {
|
|
201
|
+
case 'session': {
|
|
202
|
+
const provider = this.createUserProvider(guardConfig.provider)
|
|
203
|
+
let encrypter: any = undefined
|
|
204
|
+
try {
|
|
205
|
+
encrypter = this.container.make(ENCRYPTER)
|
|
206
|
+
} catch {
|
|
207
|
+
// Encrypter not available — remember cookies won't be encrypted
|
|
208
|
+
}
|
|
209
|
+
return new SessionGuard(name, provider, encrypter)
|
|
210
|
+
}
|
|
211
|
+
default: {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Unsupported auth guard driver: "${guardConfig.driver}". ` +
|
|
214
|
+
'Use extend() or viaRequest() to register custom guard drivers.',
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ServiceProvider, ConfigRepository } from '@mantiq/core'
|
|
2
|
+
import { AuthManager } from './AuthManager.ts'
|
|
3
|
+
import { AUTH_MANAGER } from './helpers/auth.ts'
|
|
4
|
+
import { Authenticate } from './middleware/Authenticate.ts'
|
|
5
|
+
import { RedirectIfAuthenticated } from './middleware/RedirectIfAuthenticated.ts'
|
|
6
|
+
import { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
|
|
7
|
+
import { ConfirmPassword } from './middleware/ConfirmPassword.ts'
|
|
8
|
+
import type { AuthConfig } from './contracts/AuthConfig.ts'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG: AuthConfig = {
|
|
11
|
+
defaults: { guard: 'web' },
|
|
12
|
+
guards: {
|
|
13
|
+
web: { driver: 'session', provider: 'users' },
|
|
14
|
+
},
|
|
15
|
+
providers: {
|
|
16
|
+
users: { driver: 'database', model: class {} as any },
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registers authentication bindings in the container.
|
|
22
|
+
*
|
|
23
|
+
* Config file: config/auth.ts
|
|
24
|
+
* Required config: guards, providers, defaults.guard
|
|
25
|
+
*/
|
|
26
|
+
export class AuthServiceProvider extends ServiceProvider {
|
|
27
|
+
override register(): void {
|
|
28
|
+
// AuthManager — singleton
|
|
29
|
+
this.app.singleton(AuthManager, (c) => {
|
|
30
|
+
const config = c.make(ConfigRepository).get<AuthConfig>('auth', DEFAULT_CONFIG)
|
|
31
|
+
return new AuthManager(config, c)
|
|
32
|
+
})
|
|
33
|
+
this.app.alias(AuthManager, AUTH_MANAGER)
|
|
34
|
+
|
|
35
|
+
// Middleware bindings
|
|
36
|
+
this.app.bind(Authenticate, (c) => new Authenticate(c.make(AuthManager)))
|
|
37
|
+
this.app.bind(RedirectIfAuthenticated, (c) => new RedirectIfAuthenticated(c.make(AuthManager)))
|
|
38
|
+
this.app.bind(EnsureEmailIsVerified, () => new EnsureEmailIsVerified())
|
|
39
|
+
this.app.bind(ConfirmPassword, () => new ConfirmPassword())
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Constructor } from '@mantiq/core'
|
|
2
|
+
|
|
3
|
+
export interface GuardConfig {
|
|
4
|
+
driver: string // 'session' | custom driver name
|
|
5
|
+
provider: string // Name referencing a provider in config.providers
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ProviderConfig {
|
|
9
|
+
driver: string // 'database' | custom driver name
|
|
10
|
+
model: Constructor<any> // The User model class
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AuthConfig {
|
|
14
|
+
defaults: {
|
|
15
|
+
guard: string
|
|
16
|
+
}
|
|
17
|
+
guards: Record<string, GuardConfig>
|
|
18
|
+
providers: Record<string, ProviderConfig>
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract for authenticatable entities (typically the User model).
|
|
3
|
+
*
|
|
4
|
+
* Implement this interface on your Model subclass. For conventional
|
|
5
|
+
* column names (id, password, remember_token), the methods are
|
|
6
|
+
* straightforward one-liners.
|
|
7
|
+
*/
|
|
8
|
+
export interface Authenticatable {
|
|
9
|
+
/** Return the name of the unique identifier column (e.g. 'id'). */
|
|
10
|
+
getAuthIdentifierName(): string
|
|
11
|
+
|
|
12
|
+
/** Return the unique identifier value. */
|
|
13
|
+
getAuthIdentifier(): string | number
|
|
14
|
+
|
|
15
|
+
/** Return the name of the password column (e.g. 'password'). */
|
|
16
|
+
getAuthPasswordName(): string
|
|
17
|
+
|
|
18
|
+
/** Return the hashed password. */
|
|
19
|
+
getAuthPassword(): string
|
|
20
|
+
|
|
21
|
+
/** Return the remember me token value. */
|
|
22
|
+
getRememberToken(): string | null
|
|
23
|
+
|
|
24
|
+
/** Set the remember me token value. */
|
|
25
|
+
setRememberToken(token: string | null): void
|
|
26
|
+
|
|
27
|
+
/** Return the column name for the remember token (e.g. 'remember_token'). */
|
|
28
|
+
getRememberTokenName(): string
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import type { Authenticatable } from './Authenticatable.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base guard contract — read-only authentication checks.
|
|
6
|
+
*/
|
|
7
|
+
export interface Guard {
|
|
8
|
+
/** Determine if the current user is authenticated. */
|
|
9
|
+
check(): Promise<boolean>
|
|
10
|
+
|
|
11
|
+
/** Determine if the current user is a guest. */
|
|
12
|
+
guest(): Promise<boolean>
|
|
13
|
+
|
|
14
|
+
/** Get the currently authenticated user. */
|
|
15
|
+
user(): Promise<Authenticatable | null>
|
|
16
|
+
|
|
17
|
+
/** Get the ID of the currently authenticated user. */
|
|
18
|
+
id(): Promise<string | number | null>
|
|
19
|
+
|
|
20
|
+
/** Validate credentials without logging in. */
|
|
21
|
+
validate(credentials: Record<string, any>): Promise<boolean>
|
|
22
|
+
|
|
23
|
+
/** Set the current user explicitly. */
|
|
24
|
+
setUser(user: Authenticatable): void
|
|
25
|
+
|
|
26
|
+
/** Check if a user instance is already resolved (without hitting provider). */
|
|
27
|
+
hasUser(): boolean
|
|
28
|
+
|
|
29
|
+
/** Set the current request on the guard (resets per-request state). */
|
|
30
|
+
setRequest(request: MantiqRequest): void
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Guard } from './Guard.ts'
|
|
2
|
+
import type { Authenticatable } from './Authenticatable.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Contract for stateful (session-based) guards that support login/logout.
|
|
6
|
+
*/
|
|
7
|
+
export interface StatefulGuard extends Guard {
|
|
8
|
+
/** Attempt to authenticate using credentials. Returns true on success. */
|
|
9
|
+
attempt(credentials: Record<string, any>, remember?: boolean): Promise<boolean>
|
|
10
|
+
|
|
11
|
+
/** Log a user in (set session, optionally set remember cookie). */
|
|
12
|
+
login(user: Authenticatable, remember?: boolean): Promise<void>
|
|
13
|
+
|
|
14
|
+
/** Log a user in by their identifier. */
|
|
15
|
+
loginUsingId(id: string | number, remember?: boolean): Promise<Authenticatable | null>
|
|
16
|
+
|
|
17
|
+
/** Log the user out (flush session, clear remember cookie). */
|
|
18
|
+
logout(): Promise<void>
|
|
19
|
+
|
|
20
|
+
/** Determine if the user was authenticated via remember me cookie. */
|
|
21
|
+
viaRemember(): boolean
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Authenticatable } from './Authenticatable.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract for user retrieval and credential validation.
|
|
5
|
+
* Each provider implementation resolves users from a different source.
|
|
6
|
+
*/
|
|
7
|
+
export interface UserProvider {
|
|
8
|
+
/** Retrieve a user by their unique identifier. */
|
|
9
|
+
retrieveById(identifier: string | number): Promise<Authenticatable | null>
|
|
10
|
+
|
|
11
|
+
/** Retrieve a user by their identifier and remember token. */
|
|
12
|
+
retrieveByToken(identifier: string | number, token: string): Promise<Authenticatable | null>
|
|
13
|
+
|
|
14
|
+
/** Update the remember me token on the user. */
|
|
15
|
+
updateRememberToken(user: Authenticatable, token: string): Promise<void>
|
|
16
|
+
|
|
17
|
+
/** Retrieve a user by credentials (e.g. email). Does NOT check password. */
|
|
18
|
+
retrieveByCredentials(credentials: Record<string, any>): Promise<Authenticatable | null>
|
|
19
|
+
|
|
20
|
+
/** Validate a user against the given credentials (checks password). */
|
|
21
|
+
validateCredentials(user: Authenticatable, credentials: Record<string, any>): Promise<boolean>
|
|
22
|
+
|
|
23
|
+
/** Re-hash the password if the hasher's cost has changed. */
|
|
24
|
+
rehashPasswordIfRequired(user: Authenticatable, credentials: Record<string, any>): Promise<void>
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { UnauthorizedError } from '@mantiq/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when authentication fails.
|
|
5
|
+
* For web routes, includes a redirect URL. For API routes, returns 401.
|
|
6
|
+
*/
|
|
7
|
+
export class AuthenticationError extends UnauthorizedError {
|
|
8
|
+
constructor(
|
|
9
|
+
message = 'Unauthenticated.',
|
|
10
|
+
public readonly redirectTo: string = '/login',
|
|
11
|
+
public readonly guards: string[] = [],
|
|
12
|
+
) {
|
|
13
|
+
super(message)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Event } from '@mantiq/core'
|
|
2
|
+
import type { Authenticatable } from '../contracts/Authenticatable.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fired when an authentication attempt begins.
|
|
6
|
+
*/
|
|
7
|
+
export class Attempting extends Event {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly guard: string,
|
|
10
|
+
public readonly credentials: Record<string, any>,
|
|
11
|
+
public readonly remember: boolean,
|
|
12
|
+
) {
|
|
13
|
+
super()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fired when a user is successfully authenticated (session or remember cookie).
|
|
19
|
+
*/
|
|
20
|
+
export class Authenticated extends Event {
|
|
21
|
+
constructor(
|
|
22
|
+
public readonly guard: string,
|
|
23
|
+
public readonly user: Authenticatable,
|
|
24
|
+
) {
|
|
25
|
+
super()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fired when a user logs in via attempt() or login().
|
|
31
|
+
*/
|
|
32
|
+
export class Login extends Event {
|
|
33
|
+
constructor(
|
|
34
|
+
public readonly guard: string,
|
|
35
|
+
public readonly user: Authenticatable,
|
|
36
|
+
public readonly remember: boolean,
|
|
37
|
+
) {
|
|
38
|
+
super()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fired when an authentication attempt fails (wrong credentials).
|
|
44
|
+
*/
|
|
45
|
+
export class Failed extends Event {
|
|
46
|
+
constructor(
|
|
47
|
+
public readonly guard: string,
|
|
48
|
+
public readonly credentials: Record<string, any>,
|
|
49
|
+
) {
|
|
50
|
+
super()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fired when a user logs out.
|
|
56
|
+
*/
|
|
57
|
+
export class Logout extends Event {
|
|
58
|
+
constructor(
|
|
59
|
+
public readonly guard: string,
|
|
60
|
+
public readonly user: Authenticatable | null,
|
|
61
|
+
) {
|
|
62
|
+
super()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fired when a new user registers.
|
|
68
|
+
*/
|
|
69
|
+
export class Registered extends Event {
|
|
70
|
+
constructor(
|
|
71
|
+
public readonly user: Authenticatable,
|
|
72
|
+
) {
|
|
73
|
+
super()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fired when a user is locked out due to too many failed attempts.
|
|
79
|
+
*/
|
|
80
|
+
export class Lockout extends Event {
|
|
81
|
+
constructor(
|
|
82
|
+
public readonly request: any,
|
|
83
|
+
) {
|
|
84
|
+
super()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Guard } from '../contracts/Guard.ts'
|
|
2
|
+
import type { Authenticatable } from '../contracts/Authenticatable.ts'
|
|
3
|
+
import type { UserProvider } from '../contracts/UserProvider.ts'
|
|
4
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
5
|
+
|
|
6
|
+
type RequestGuardCallback = (
|
|
7
|
+
request: MantiqRequest,
|
|
8
|
+
provider: UserProvider,
|
|
9
|
+
) => Authenticatable | null | Promise<Authenticatable | null>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Closure-based guard for custom authentication logic.
|
|
13
|
+
*
|
|
14
|
+
* Register via `auth().viaRequest('custom', (request, provider) => { ... })`.
|
|
15
|
+
*/
|
|
16
|
+
export class RequestGuard implements Guard {
|
|
17
|
+
private _user: Authenticatable | null = null
|
|
18
|
+
private _resolved = false
|
|
19
|
+
private _request: MantiqRequest | null = null
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly callback: RequestGuardCallback,
|
|
23
|
+
private readonly provider: UserProvider,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async check(): Promise<boolean> {
|
|
27
|
+
return (await this.user()) !== null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async guest(): Promise<boolean> {
|
|
31
|
+
return !(await this.check())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async user(): Promise<Authenticatable | null> {
|
|
35
|
+
if (this._resolved) return this._user
|
|
36
|
+
|
|
37
|
+
const request = this.getRequest()
|
|
38
|
+
this._user = await this.callback(request, this.provider) ?? null
|
|
39
|
+
this._resolved = true
|
|
40
|
+
|
|
41
|
+
if (this._user) {
|
|
42
|
+
request.setUser(this._user as any)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this._user
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async id(): Promise<string | number | null> {
|
|
49
|
+
const user = await this.user()
|
|
50
|
+
return user?.getAuthIdentifier() ?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async validate(credentials: Record<string, any>): Promise<boolean> {
|
|
54
|
+
const user = await this.provider.retrieveByCredentials(credentials)
|
|
55
|
+
if (!user) return false
|
|
56
|
+
return this.provider.validateCredentials(user, credentials)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setUser(user: Authenticatable): void {
|
|
60
|
+
this._user = user
|
|
61
|
+
this._resolved = true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
hasUser(): boolean {
|
|
65
|
+
return this._user !== null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setRequest(request: MantiqRequest): void {
|
|
69
|
+
this._request = request
|
|
70
|
+
// Reset per-request state
|
|
71
|
+
this._user = null
|
|
72
|
+
this._resolved = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private getRequest(): MantiqRequest {
|
|
76
|
+
if (!this._request) {
|
|
77
|
+
throw new Error('No request set on the guard.')
|
|
78
|
+
}
|
|
79
|
+
return this._request
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { StatefulGuard } from '../contracts/StatefulGuard.ts'
|
|
2
|
+
import type { Authenticatable } from '../contracts/Authenticatable.ts'
|
|
3
|
+
import type { UserProvider } from '../contracts/UserProvider.ts'
|
|
4
|
+
import type { MantiqRequest, EventDispatcher } from '@mantiq/core'
|
|
5
|
+
import type { Encrypter } from '@mantiq/core'
|
|
6
|
+
import { Attempting, Authenticated, Login as LoginEvent, Failed, Logout as LogoutEvent } from '../events/AuthEvents.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Session-based authentication guard.
|
|
10
|
+
*
|
|
11
|
+
* Resolves the authenticated user from the session. Supports login/logout,
|
|
12
|
+
* remember me cookies, and session fixation prevention via regeneration.
|
|
13
|
+
*
|
|
14
|
+
* Remember me cookie handling: the guard stores flags that middleware reads
|
|
15
|
+
* after the response to set/clear cookies. Guards never touch Response directly.
|
|
16
|
+
*/
|
|
17
|
+
export class SessionGuard implements StatefulGuard {
|
|
18
|
+
private _user: Authenticatable | null = null
|
|
19
|
+
private _loggedOut = false
|
|
20
|
+
private _viaRemember = false
|
|
21
|
+
private _recallAttempted = false
|
|
22
|
+
private _request: MantiqRequest | null = null
|
|
23
|
+
|
|
24
|
+
/** Pending remember cookie data (set during login, read by middleware). */
|
|
25
|
+
private _pendingRememberCookie: { id: string | number; token: string; hash: string } | null = null
|
|
26
|
+
/** Flag to clear the remember cookie (set during logout). */
|
|
27
|
+
private _clearRememberCookie = false
|
|
28
|
+
|
|
29
|
+
/** Optional event dispatcher. Set by @mantiq/events when installed. */
|
|
30
|
+
static _dispatcher: EventDispatcher | null = null
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly name: string,
|
|
34
|
+
private readonly provider: UserProvider,
|
|
35
|
+
private readonly encrypter?: Encrypter,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
// ── Guard contract ──────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
async check(): Promise<boolean> {
|
|
41
|
+
return (await this.user()) !== null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async guest(): Promise<boolean> {
|
|
45
|
+
return !(await this.check())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async user(): Promise<Authenticatable | null> {
|
|
49
|
+
if (this._loggedOut) return null
|
|
50
|
+
if (this._user !== null) return this._user
|
|
51
|
+
|
|
52
|
+
const request = this.getRequest()
|
|
53
|
+
|
|
54
|
+
// 1. Try to resolve from session
|
|
55
|
+
const userId = request.session().get<string | number>(this.sessionKey())
|
|
56
|
+
if (userId !== undefined && userId !== null) {
|
|
57
|
+
this._user = await this.provider.retrieveById(userId)
|
|
58
|
+
if (this._user) {
|
|
59
|
+
request.setUser(this._user as any)
|
|
60
|
+
await SessionGuard._dispatcher?.emit(new Authenticated(this.name, this._user))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Try to recall from remember cookie
|
|
65
|
+
if (this._user === null && !this._recallAttempted) {
|
|
66
|
+
this._recallAttempted = true
|
|
67
|
+
this._user = await this.recallFromCookie()
|
|
68
|
+
if (this._user) {
|
|
69
|
+
this._viaRemember = true
|
|
70
|
+
// Re-store in session so subsequent requests don't need the cookie
|
|
71
|
+
this.updateSession(this._user.getAuthIdentifier())
|
|
72
|
+
request.setUser(this._user as any)
|
|
73
|
+
await SessionGuard._dispatcher?.emit(new Authenticated(this.name, this._user))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return this._user
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async id(): Promise<string | number | null> {
|
|
81
|
+
const user = await this.user()
|
|
82
|
+
return user?.getAuthIdentifier() ?? null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async validate(credentials: Record<string, any>): Promise<boolean> {
|
|
86
|
+
const user = await this.provider.retrieveByCredentials(credentials)
|
|
87
|
+
if (!user) return false
|
|
88
|
+
return this.provider.validateCredentials(user, credentials)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setUser(user: Authenticatable): void {
|
|
92
|
+
this._user = user
|
|
93
|
+
this._loggedOut = false
|
|
94
|
+
if (this._request) {
|
|
95
|
+
this._request.setUser(user as any)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
hasUser(): boolean {
|
|
100
|
+
return this._user !== null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setRequest(request: MantiqRequest): void {
|
|
104
|
+
this._request = request
|
|
105
|
+
// Reset per-request state
|
|
106
|
+
this._user = null
|
|
107
|
+
this._loggedOut = false
|
|
108
|
+
this._viaRemember = false
|
|
109
|
+
this._recallAttempted = false
|
|
110
|
+
this._pendingRememberCookie = null
|
|
111
|
+
this._clearRememberCookie = false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── StatefulGuard contract ──────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async attempt(credentials: Record<string, any>, remember = false): Promise<boolean> {
|
|
117
|
+
await SessionGuard._dispatcher?.emit(new Attempting(this.name, credentials, remember))
|
|
118
|
+
|
|
119
|
+
const user = await this.provider.retrieveByCredentials(credentials)
|
|
120
|
+
if (!user) {
|
|
121
|
+
await SessionGuard._dispatcher?.emit(new Failed(this.name, credentials))
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (await this.provider.validateCredentials(user, credentials)) {
|
|
126
|
+
await this.provider.rehashPasswordIfRequired(user, credentials)
|
|
127
|
+
await this.login(user, remember)
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await SessionGuard._dispatcher?.emit(new Failed(this.name, credentials))
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async login(user: Authenticatable, remember = false): Promise<void> {
|
|
136
|
+
const request = this.getRequest()
|
|
137
|
+
|
|
138
|
+
// Regenerate session to prevent fixation
|
|
139
|
+
await request.session().regenerate(true)
|
|
140
|
+
|
|
141
|
+
// Store user ID in session
|
|
142
|
+
this.updateSession(user.getAuthIdentifier())
|
|
143
|
+
|
|
144
|
+
// Handle remember me
|
|
145
|
+
if (remember) {
|
|
146
|
+
await this.ensureRememberTokenIsSet(user)
|
|
147
|
+
this.queueRememberCookie(user)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this._user = user
|
|
151
|
+
this._loggedOut = false
|
|
152
|
+
request.setUser(user as any)
|
|
153
|
+
|
|
154
|
+
await SessionGuard._dispatcher?.emit(new LoginEvent(this.name, user, remember))
|
|
155
|
+
await SessionGuard._dispatcher?.emit(new Authenticated(this.name, user))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async loginUsingId(id: string | number, remember = false): Promise<Authenticatable | null> {
|
|
159
|
+
const user = await this.provider.retrieveById(id)
|
|
160
|
+
if (!user) return null
|
|
161
|
+
await this.login(user, remember)
|
|
162
|
+
return user
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async logout(): Promise<void> {
|
|
166
|
+
const user = this._user
|
|
167
|
+
|
|
168
|
+
// Cycle the remember token to invalidate any existing remember cookies
|
|
169
|
+
if (user) {
|
|
170
|
+
await this.cycleRememberToken(user)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Clear session
|
|
174
|
+
const request = this.getRequest()
|
|
175
|
+
request.session().forget(this.sessionKey())
|
|
176
|
+
await request.session().invalidate()
|
|
177
|
+
|
|
178
|
+
// Flag remember cookie for clearing
|
|
179
|
+
this._clearRememberCookie = true
|
|
180
|
+
|
|
181
|
+
await SessionGuard._dispatcher?.emit(new LogoutEvent(this.name, user))
|
|
182
|
+
|
|
183
|
+
// Reset state
|
|
184
|
+
this._user = null
|
|
185
|
+
this._loggedOut = true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
viaRemember(): boolean {
|
|
189
|
+
return this._viaRemember
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Remember me ─────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the name of the remember me cookie for this guard.
|
|
196
|
+
*/
|
|
197
|
+
getRememberCookieName(): string {
|
|
198
|
+
return `remember_${this.name}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get pending remember cookie data (read by middleware to set cookie).
|
|
203
|
+
*/
|
|
204
|
+
getPendingRememberCookie(): { id: string | number; token: string; hash: string } | null {
|
|
205
|
+
return this._pendingRememberCookie
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if the remember cookie should be cleared (read by middleware).
|
|
210
|
+
*/
|
|
211
|
+
shouldClearRememberCookie(): boolean {
|
|
212
|
+
return this._clearRememberCookie
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get the guard name.
|
|
217
|
+
*/
|
|
218
|
+
getName(): string {
|
|
219
|
+
return this.name
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Internal ────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
private getRequest(): MantiqRequest {
|
|
225
|
+
if (!this._request) {
|
|
226
|
+
throw new Error('No request set on the guard. Ensure Authenticate middleware is active.')
|
|
227
|
+
}
|
|
228
|
+
return this._request
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private sessionKey(): string {
|
|
232
|
+
return `login_${this.name}`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private updateSession(userId: string | number): void {
|
|
236
|
+
this.getRequest().session().put(this.sessionKey(), userId)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Ensure the user has a remember token. If not, generate and persist one.
|
|
241
|
+
*/
|
|
242
|
+
private async ensureRememberTokenIsSet(user: Authenticatable): Promise<void> {
|
|
243
|
+
if (!user.getRememberToken()) {
|
|
244
|
+
await this.cycleRememberToken(user)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Generate a new remember token and save it to the database.
|
|
250
|
+
*/
|
|
251
|
+
private async cycleRememberToken(user: Authenticatable): Promise<void> {
|
|
252
|
+
const token = generateRandomToken(60)
|
|
253
|
+
await this.provider.updateRememberToken(user, token)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Queue the remember cookie for the middleware to set.
|
|
258
|
+
* Cookie value format: userId|rememberToken|passwordHash
|
|
259
|
+
*/
|
|
260
|
+
private queueRememberCookie(user: Authenticatable): void {
|
|
261
|
+
this._pendingRememberCookie = {
|
|
262
|
+
id: user.getAuthIdentifier(),
|
|
263
|
+
token: user.getRememberToken()!,
|
|
264
|
+
hash: user.getAuthPassword(),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Attempt to recall the user from the remember me cookie.
|
|
270
|
+
*/
|
|
271
|
+
private async recallFromCookie(): Promise<Authenticatable | null> {
|
|
272
|
+
const request = this.getRequest()
|
|
273
|
+
const cookieValue = request.cookie(this.getRememberCookieName())
|
|
274
|
+
if (!cookieValue) return null
|
|
275
|
+
|
|
276
|
+
// Cookie format: userId|rememberToken|passwordHash
|
|
277
|
+
const parts = cookieValue.split('|')
|
|
278
|
+
if (parts.length !== 3) return null
|
|
279
|
+
|
|
280
|
+
const [userId, token, hash] = parts
|
|
281
|
+
|
|
282
|
+
if (!userId || !token) return null
|
|
283
|
+
|
|
284
|
+
const user = await this.provider.retrieveByToken(
|
|
285
|
+
isNaN(Number(userId)) ? userId : Number(userId),
|
|
286
|
+
token,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if (!user) return null
|
|
290
|
+
|
|
291
|
+
// Validate the password hash hasn't changed (tamper detection)
|
|
292
|
+
if (hash !== user.getAuthPassword()) return null
|
|
293
|
+
|
|
294
|
+
return user
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Generate a random hex token of the given length.
|
|
300
|
+
*/
|
|
301
|
+
function generateRandomToken(length: number): string {
|
|
302
|
+
const bytes = new Uint8Array(Math.ceil(length / 2))
|
|
303
|
+
crypto.getRandomValues(bytes)
|
|
304
|
+
let token = ''
|
|
305
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
306
|
+
token += bytes[i]!.toString(16).padStart(2, '0')
|
|
307
|
+
}
|
|
308
|
+
return token.slice(0, length)
|
|
309
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Application } from '@mantiq/core'
|
|
2
|
+
import type { AuthManager } from '../AuthManager.ts'
|
|
3
|
+
import type { Guard } from '../contracts/Guard.ts'
|
|
4
|
+
|
|
5
|
+
export const AUTH_MANAGER = Symbol('AuthManager')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Access the auth manager or a specific guard.
|
|
9
|
+
*
|
|
10
|
+
* @example auth() // AuthManager (proxies to default guard)
|
|
11
|
+
* @example auth('api') // Specific guard instance
|
|
12
|
+
*/
|
|
13
|
+
export function auth(): AuthManager
|
|
14
|
+
export function auth(guard: string): Guard
|
|
15
|
+
export function auth(guard?: string): AuthManager | Guard {
|
|
16
|
+
const manager = Application.getInstance().make<AuthManager>(AUTH_MANAGER)
|
|
17
|
+
if (guard === undefined) return manager
|
|
18
|
+
return manager.guard(guard)
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ── Contracts ─────────────────────────────────────────────────────────────────
|
|
2
|
+
export type { Authenticatable } from './contracts/Authenticatable.ts'
|
|
3
|
+
export type { Guard } from './contracts/Guard.ts'
|
|
4
|
+
export type { StatefulGuard } from './contracts/StatefulGuard.ts'
|
|
5
|
+
export type { UserProvider } from './contracts/UserProvider.ts'
|
|
6
|
+
export type { AuthConfig, GuardConfig, ProviderConfig } from './contracts/AuthConfig.ts'
|
|
7
|
+
|
|
8
|
+
// ── Core ──────────────────────────────────────────────────────────────────────
|
|
9
|
+
export { AuthManager } from './AuthManager.ts'
|
|
10
|
+
export { AuthServiceProvider } from './AuthServiceProvider.ts'
|
|
11
|
+
|
|
12
|
+
// ── Guards ────────────────────────────────────────────────────────────────────
|
|
13
|
+
export { SessionGuard } from './guards/SessionGuard.ts'
|
|
14
|
+
export { RequestGuard } from './guards/RequestGuard.ts'
|
|
15
|
+
|
|
16
|
+
// ── Providers ─────────────────────────────────────────────────────────────────
|
|
17
|
+
export { DatabaseUserProvider } from './providers/DatabaseUserProvider.ts'
|
|
18
|
+
|
|
19
|
+
// ── Middleware ─────────────────────────────────────────────────────────────────
|
|
20
|
+
export { Authenticate } from './middleware/Authenticate.ts'
|
|
21
|
+
export { RedirectIfAuthenticated } from './middleware/RedirectIfAuthenticated.ts'
|
|
22
|
+
export { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
|
|
23
|
+
export { ConfirmPassword } from './middleware/ConfirmPassword.ts'
|
|
24
|
+
|
|
25
|
+
// ── Errors ────────────────────────────────────────────────────────────────────
|
|
26
|
+
export { AuthenticationError } from './errors/AuthenticationError.ts'
|
|
27
|
+
|
|
28
|
+
// ── Events ────────────────────────────────────────────────────────────────────
|
|
29
|
+
export { Attempting, Authenticated, Login, Failed, Logout, Registered, Lockout } from './events/AuthEvents.ts'
|
|
30
|
+
|
|
31
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
32
|
+
export { auth, AUTH_MANAGER } from './helpers/auth.ts'
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { UnauthorizedError, serializeCookie } from '@mantiq/core'
|
|
3
|
+
import type { AuthManager } from '../AuthManager.ts'
|
|
4
|
+
import { SessionGuard } from '../guards/SessionGuard.ts'
|
|
5
|
+
import { AuthenticationError } from '../errors/AuthenticationError.ts'
|
|
6
|
+
|
|
7
|
+
const REMEMBER_DURATION = 60 * 60 * 24 * 365 * 5 // 5 years in seconds
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Authentication middleware.
|
|
11
|
+
*
|
|
12
|
+
* Verifies that the request is authenticated via one of the specified guards.
|
|
13
|
+
* Usage: `auth` (default guard) or `auth:web,api` (try specific guards).
|
|
14
|
+
*
|
|
15
|
+
* For web routes (non-JSON): throws AuthenticationError with redirect to /login.
|
|
16
|
+
* For API routes (JSON): throws UnauthorizedError (401).
|
|
17
|
+
*
|
|
18
|
+
* Also handles setting/clearing the remember me cookie on the response.
|
|
19
|
+
*/
|
|
20
|
+
export class Authenticate implements Middleware {
|
|
21
|
+
private guardNames: string[] = []
|
|
22
|
+
|
|
23
|
+
constructor(private readonly authManager: AuthManager) {}
|
|
24
|
+
|
|
25
|
+
setParameters(params: string[]): void {
|
|
26
|
+
this.guardNames = params
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
30
|
+
// Set request on all guards (resets per-request state)
|
|
31
|
+
this.authManager.setRequest(request)
|
|
32
|
+
|
|
33
|
+
const guards = this.guardNames.length > 0
|
|
34
|
+
? this.guardNames
|
|
35
|
+
: [this.authManager.getDefaultDriver()]
|
|
36
|
+
|
|
37
|
+
let authenticatedGuard: string | null = null
|
|
38
|
+
|
|
39
|
+
for (const guardName of guards) {
|
|
40
|
+
const guard = this.authManager.guard(guardName)
|
|
41
|
+
|
|
42
|
+
if (await guard.check()) {
|
|
43
|
+
authenticatedGuard = guardName
|
|
44
|
+
this.authManager.shouldUse(guardName)
|
|
45
|
+
|
|
46
|
+
const user = await guard.user()
|
|
47
|
+
if (user) request.setUser(user as any)
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (authenticatedGuard === null) {
|
|
53
|
+
this.unauthenticated(request, guards)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Process request
|
|
57
|
+
const response = await next()
|
|
58
|
+
|
|
59
|
+
// Handle remember me cookies
|
|
60
|
+
return this.handleRememberCookie(response, authenticatedGuard!)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private unauthenticated(request: MantiqRequest, guards: string[]): never {
|
|
64
|
+
if (request.expectsJson()) {
|
|
65
|
+
throw new UnauthorizedError('Unauthenticated.')
|
|
66
|
+
}
|
|
67
|
+
throw new AuthenticationError('Unauthenticated.', '/login', guards)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private handleRememberCookie(response: Response, guardName: string): Response {
|
|
71
|
+
const guard = this.authManager.guard(guardName)
|
|
72
|
+
|
|
73
|
+
if (!(guard instanceof SessionGuard)) return response
|
|
74
|
+
|
|
75
|
+
const headers = new Headers(response.headers)
|
|
76
|
+
|
|
77
|
+
// Set remember cookie
|
|
78
|
+
const pending = guard.getPendingRememberCookie()
|
|
79
|
+
if (pending) {
|
|
80
|
+
const cookieValue = `${pending.id}|${pending.token}|${pending.hash}`
|
|
81
|
+
headers.append(
|
|
82
|
+
'Set-Cookie',
|
|
83
|
+
serializeCookie(guard.getRememberCookieName(), cookieValue, {
|
|
84
|
+
path: '/',
|
|
85
|
+
httpOnly: true,
|
|
86
|
+
sameSite: 'Lax',
|
|
87
|
+
maxAge: REMEMBER_DURATION,
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Clear remember cookie
|
|
93
|
+
if (guard.shouldClearRememberCookie()) {
|
|
94
|
+
headers.append(
|
|
95
|
+
'Set-Cookie',
|
|
96
|
+
serializeCookie(guard.getRememberCookieName(), '', {
|
|
97
|
+
path: '/',
|
|
98
|
+
httpOnly: true,
|
|
99
|
+
sameSite: 'Lax',
|
|
100
|
+
maxAge: 0, // Expire immediately
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!pending && !guard.shouldClearRememberCookie()) return response
|
|
106
|
+
|
|
107
|
+
return new Response(response.body, {
|
|
108
|
+
status: response.status,
|
|
109
|
+
statusText: response.statusText,
|
|
110
|
+
headers,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { MantiqResponse } from '@mantiq/core'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT = 10800 // 3 hours in seconds
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Requires the user to confirm their password before proceeding.
|
|
8
|
+
*
|
|
9
|
+
* Checks `auth.password_confirmed_at` in the session against a timeout.
|
|
10
|
+
* Usage: `password.confirm` or `password.confirm:7200` (custom timeout).
|
|
11
|
+
*/
|
|
12
|
+
export class ConfirmPassword implements Middleware {
|
|
13
|
+
private timeoutSeconds = DEFAULT_TIMEOUT
|
|
14
|
+
|
|
15
|
+
setParameters(params: string[]): void {
|
|
16
|
+
if (params[0]) {
|
|
17
|
+
this.timeoutSeconds = parseInt(params[0], 10) || DEFAULT_TIMEOUT
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
22
|
+
if (this.shouldConfirm(request)) {
|
|
23
|
+
if (request.expectsJson()) {
|
|
24
|
+
return new Response(JSON.stringify({ message: 'Password confirmation required.' }), {
|
|
25
|
+
status: 423,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return MantiqResponse.redirect('/confirm-password')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return next()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private shouldConfirm(request: MantiqRequest): boolean {
|
|
36
|
+
const confirmedAt = request.session().get<number>('auth.password_confirmed_at', 0)
|
|
37
|
+
const elapsed = Math.floor(Date.now() / 1000) - confirmedAt
|
|
38
|
+
return elapsed > this.timeoutSeconds
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { ForbiddenError, MantiqResponse } from '@mantiq/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ensures the authenticated user has a verified email address.
|
|
6
|
+
*
|
|
7
|
+
* The user model must have a `hasVerifiedEmail()` method.
|
|
8
|
+
*/
|
|
9
|
+
export class EnsureEmailIsVerified implements Middleware {
|
|
10
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
11
|
+
const user = request.user<any>()
|
|
12
|
+
|
|
13
|
+
if (!user || typeof user.hasVerifiedEmail !== 'function' || !user.hasVerifiedEmail()) {
|
|
14
|
+
if (request.expectsJson()) {
|
|
15
|
+
throw new ForbiddenError('Your email address is not verified.')
|
|
16
|
+
}
|
|
17
|
+
return MantiqResponse.redirect('/email/verify')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return next()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { MantiqResponse } from '@mantiq/core'
|
|
3
|
+
import type { AuthManager } from '../AuthManager.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Guest middleware — redirects authenticated users away (e.g. from login page).
|
|
7
|
+
*
|
|
8
|
+
* Usage: `guest` (default guard) or `guest:web,api` (check specific guards).
|
|
9
|
+
*/
|
|
10
|
+
export class RedirectIfAuthenticated implements Middleware {
|
|
11
|
+
private guardNames: string[] = []
|
|
12
|
+
private redirectTo = '/dashboard'
|
|
13
|
+
|
|
14
|
+
constructor(private readonly authManager: AuthManager) {}
|
|
15
|
+
|
|
16
|
+
setParameters(params: string[]): void {
|
|
17
|
+
this.guardNames = params
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
21
|
+
this.authManager.setRequest(request)
|
|
22
|
+
|
|
23
|
+
const guards = this.guardNames.length > 0
|
|
24
|
+
? this.guardNames
|
|
25
|
+
: [this.authManager.getDefaultDriver()]
|
|
26
|
+
|
|
27
|
+
for (const guardName of guards) {
|
|
28
|
+
const guard = this.authManager.guard(guardName)
|
|
29
|
+
if (await guard.check()) {
|
|
30
|
+
return MantiqResponse.redirect(this.redirectTo)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return next()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { UserProvider } from '../contracts/UserProvider.ts'
|
|
2
|
+
import type { Authenticatable } from '../contracts/Authenticatable.ts'
|
|
3
|
+
import type { Constructor } from '@mantiq/core'
|
|
4
|
+
import type { HashManager } from '@mantiq/core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Retrieves users from the database via a Model class.
|
|
8
|
+
* The model must implement the Authenticatable interface.
|
|
9
|
+
*/
|
|
10
|
+
export class DatabaseUserProvider implements UserProvider {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly modelClass: Constructor<any> & { where: any; query: any },
|
|
13
|
+
private readonly hasher: HashManager,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async retrieveById(identifier: string | number): Promise<Authenticatable | null> {
|
|
17
|
+
const model = this.modelClass as any
|
|
18
|
+
const instance = await model.find(identifier)
|
|
19
|
+
return instance ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async retrieveByToken(identifier: string | number, token: string): Promise<Authenticatable | null> {
|
|
23
|
+
const model = this.modelClass as any
|
|
24
|
+
// Use a fresh instance to get the remember token column name
|
|
25
|
+
const instance = new this.modelClass() as Authenticatable
|
|
26
|
+
const identifierName = instance.getAuthIdentifierName()
|
|
27
|
+
const tokenName = instance.getRememberTokenName()
|
|
28
|
+
|
|
29
|
+
const result = await model
|
|
30
|
+
.where(identifierName, identifier)
|
|
31
|
+
.where(tokenName, token)
|
|
32
|
+
.first()
|
|
33
|
+
|
|
34
|
+
return result ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async updateRememberToken(user: Authenticatable, token: string): Promise<void> {
|
|
38
|
+
user.setRememberToken(token)
|
|
39
|
+
// The user is a Model instance — forceFill bypasses guarded, then save
|
|
40
|
+
const model = user as any
|
|
41
|
+
model.forceFill({ [user.getRememberTokenName()]: token })
|
|
42
|
+
await model.save()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async retrieveByCredentials(credentials: Record<string, any>): Promise<Authenticatable | null> {
|
|
46
|
+
const model = this.modelClass as any
|
|
47
|
+
|
|
48
|
+
// Filter out password — we only query by non-password fields
|
|
49
|
+
const query = Object.entries(credentials)
|
|
50
|
+
.filter(([key]) => key !== 'password')
|
|
51
|
+
.reduce((q, [key, value]) => q.where(key, value), model.query())
|
|
52
|
+
|
|
53
|
+
const result = await query.first()
|
|
54
|
+
return result ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async validateCredentials(user: Authenticatable, credentials: Record<string, any>): Promise<boolean> {
|
|
58
|
+
const password = credentials.password
|
|
59
|
+
if (!password) return false
|
|
60
|
+
return this.hasher.check(password, user.getAuthPassword())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async rehashPasswordIfRequired(user: Authenticatable, credentials: Record<string, any>): Promise<void> {
|
|
64
|
+
const password = credentials.password
|
|
65
|
+
if (!password) return
|
|
66
|
+
|
|
67
|
+
if (this.hasher.needsRehash(user.getAuthPassword())) {
|
|
68
|
+
const newHash = await this.hasher.make(password)
|
|
69
|
+
const model = user as any
|
|
70
|
+
model.forceFill({ [user.getAuthPasswordName()]: newHash })
|
|
71
|
+
await model.save()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|