@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 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
+ }