@mantiq/auth 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/auth",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Session & token auth, guards, providers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,10 +1,13 @@
1
- import { ServiceProvider, ConfigRepository } from '@mantiq/core'
1
+ import { ServiceProvider, ConfigRepository, HttpKernel } from '@mantiq/core'
2
2
  import { AuthManager } from './AuthManager.ts'
3
3
  import { AUTH_MANAGER } from './helpers/auth.ts'
4
4
  import { Authenticate } from './middleware/Authenticate.ts'
5
5
  import { RedirectIfAuthenticated } from './middleware/RedirectIfAuthenticated.ts'
6
6
  import { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
7
7
  import { ConfirmPassword } from './middleware/ConfirmPassword.ts'
8
+ import { Authorize } from './middleware/Authorize.ts'
9
+ import { GateManager } from './authorization/GateManager.ts'
10
+ import { setGateManager, GATE_MANAGER } from './helpers/gate.ts'
8
11
  import type { AuthConfig } from './contracts/AuthConfig.ts'
9
12
 
10
13
  const DEFAULT_CONFIG: AuthConfig = {
@@ -18,7 +21,7 @@ const DEFAULT_CONFIG: AuthConfig = {
18
21
  }
19
22
 
20
23
  /**
21
- * Registers authentication bindings in the container.
24
+ * Registers authentication and authorization bindings in the container.
22
25
  *
23
26
  * Config file: config/auth.ts
24
27
  * Required config: guards, providers, defaults.guard
@@ -32,10 +35,32 @@ export class AuthServiceProvider extends ServiceProvider {
32
35
  })
33
36
  this.app.alias(AuthManager, AUTH_MANAGER)
34
37
 
38
+ // GateManager — singleton
39
+ this.app.singleton(GateManager, () => {
40
+ const g = new GateManager()
41
+ setGateManager(g)
42
+ return g
43
+ })
44
+ this.app.alias(GateManager, GATE_MANAGER)
45
+
35
46
  // Middleware bindings
36
47
  this.app.bind(Authenticate, (c) => new Authenticate(c.make(AuthManager)))
37
48
  this.app.bind(RedirectIfAuthenticated, (c) => new RedirectIfAuthenticated(c.make(AuthManager)))
38
49
  this.app.bind(EnsureEmailIsVerified, () => new EnsureEmailIsVerified())
39
50
  this.app.bind(ConfirmPassword, () => new ConfirmPassword())
51
+ this.app.bind(Authorize, () => new Authorize())
52
+ }
53
+
54
+ override boot(): void {
55
+ // Resolve the GateManager so setGateManager() is called
56
+ this.app.make(GateManager)
57
+
58
+ // Register the 'can' middleware alias
59
+ try {
60
+ const kernel = this.app.make(HttpKernel)
61
+ kernel.registerMiddleware('can', Authorize)
62
+ } catch {
63
+ // HttpKernel may not be available in non-HTTP contexts (e.g., CLI)
64
+ }
40
65
  }
41
66
  }
@@ -0,0 +1,22 @@
1
+ import { gate } from './helpers/gate.ts'
2
+
3
+ /**
4
+ * Mixin that adds `can()` / `cannot()` to model instances.
5
+ *
6
+ * Apply to any model class to allow authorization checks directly
7
+ * on the model instance:
8
+ *
9
+ * @example
10
+ * applyAuthorizable(User)
11
+ * const user = await User.find(1)
12
+ * if (await user.can('edit', post)) { ... }
13
+ */
14
+ export function applyAuthorizable(ModelClass: any): void {
15
+ ModelClass.prototype.can = async function (ability: string, ...args: any[]): Promise<boolean> {
16
+ return gate().allows(ability, this, ...args)
17
+ }
18
+
19
+ ModelClass.prototype.cannot = async function (ability: string, ...args: any[]): Promise<boolean> {
20
+ return gate().denies(ability, this, ...args)
21
+ }
22
+ }
@@ -0,0 +1,35 @@
1
+ export class AuthorizationResponse {
2
+ private _allowed: boolean
3
+ private _message: string | null
4
+ private _code: number | null
5
+
6
+ constructor(allowed: boolean, message?: string, code?: number) {
7
+ this._allowed = allowed
8
+ this._message = message ?? null
9
+ this._code = code ?? null
10
+ }
11
+
12
+ allowed(): boolean {
13
+ return this._allowed
14
+ }
15
+
16
+ denied(): boolean {
17
+ return !this._allowed
18
+ }
19
+
20
+ message(): string | null {
21
+ return this._message
22
+ }
23
+
24
+ code(): number | null {
25
+ return this._code
26
+ }
27
+
28
+ static allow(message?: string): AuthorizationResponse {
29
+ return new AuthorizationResponse(true, message)
30
+ }
31
+
32
+ static deny(message?: string, code?: number): AuthorizationResponse {
33
+ return new AuthorizationResponse(false, message ?? 'This action is unauthorized.', code ?? 403)
34
+ }
35
+ }
@@ -0,0 +1,247 @@
1
+ import { ForbiddenError } from '@mantiq/core'
2
+ import { AuthorizationResponse } from './AuthorizationResponse.ts'
3
+ import type { Policy } from './Policy.ts'
4
+ import { UserGate } from './UserGate.ts'
5
+
6
+ type GateCallback = (user: any, ...args: any[]) => boolean | AuthorizationResponse | Promise<boolean | AuthorizationResponse>
7
+ type BeforeCallback = (user: any, ability: string, ...args: any[]) => boolean | null | undefined | Promise<boolean | null | undefined>
8
+ type AfterCallback = (user: any, ability: string, result: boolean, ...args: any[]) => void | Promise<void>
9
+
10
+ /**
11
+ * Central authorization manager — Laravel-style Gates & Policies.
12
+ *
13
+ * Gate closures handle simple ability checks.
14
+ * Policies handle model-based authorization — each policy maps to a model class.
15
+ *
16
+ * Resolution order:
17
+ * 1. Global `before` callbacks (short-circuit on non-null)
18
+ * 2. Policy `before()` hook (if a policy matches)
19
+ * 3. Policy method matching the ability name
20
+ * 4. Gate closure matching the ability name
21
+ * 5. Deny by default
22
+ * 6. Global `after` callbacks (for auditing, cannot change result)
23
+ */
24
+ export class GateManager {
25
+ private gates = new Map<string, GateCallback>()
26
+ private policies = new Map<any, new () => Policy>()
27
+ private beforeCallbacks: BeforeCallback[] = []
28
+ private afterCallbacks: AfterCallback[] = []
29
+
30
+ // ── Registration ─────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Define a gate closure for the given ability.
34
+ */
35
+ define(ability: string, callback: GateCallback): this {
36
+ this.gates.set(ability, callback)
37
+ return this
38
+ }
39
+
40
+ /**
41
+ * Register a policy class for a model constructor.
42
+ */
43
+ policy(modelClass: any, policyClass: new () => Policy): this {
44
+ this.policies.set(modelClass, policyClass)
45
+ return this
46
+ }
47
+
48
+ /**
49
+ * Register a global before callback.
50
+ * If the callback returns `true` or `false`, the check short-circuits.
51
+ * Return `null` or `undefined` to continue to the gate/policy check.
52
+ */
53
+ before(callback: BeforeCallback): this {
54
+ this.beforeCallbacks.push(callback)
55
+ return this
56
+ }
57
+
58
+ /**
59
+ * Register a global after callback (for logging/auditing).
60
+ * After callbacks cannot change the authorization result.
61
+ */
62
+ after(callback: AfterCallback): this {
63
+ this.afterCallbacks.push(callback)
64
+ return this
65
+ }
66
+
67
+ // ── Checking ─────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Check if the given ability is allowed for the user.
71
+ */
72
+ async allows(ability: string, user: any, ...args: any[]): Promise<boolean> {
73
+ const response = await this.resolve(ability, user, ...args)
74
+ return response.allowed()
75
+ }
76
+
77
+ /**
78
+ * Check if the given ability is denied for the user.
79
+ */
80
+ async denies(ability: string, user: any, ...args: any[]): Promise<boolean> {
81
+ return !(await this.allows(ability, user, ...args))
82
+ }
83
+
84
+ /**
85
+ * Authorize the ability or throw ForbiddenError.
86
+ * Returns the AuthorizationResponse if allowed.
87
+ */
88
+ async authorize(ability: string, user: any, ...args: any[]): Promise<AuthorizationResponse> {
89
+ const response = await this.resolve(ability, user, ...args)
90
+
91
+ if (response.denied()) {
92
+ throw new ForbiddenError(response.message() ?? 'This action is unauthorized.')
93
+ }
94
+
95
+ return response
96
+ }
97
+
98
+ /**
99
+ * Check multiple abilities — all must pass.
100
+ */
101
+ async check(abilities: string[], user: any, ...args: any[]): Promise<boolean> {
102
+ for (const ability of abilities) {
103
+ if (!(await this.allows(ability, user, ...args))) {
104
+ return false
105
+ }
106
+ }
107
+ return true
108
+ }
109
+
110
+ /**
111
+ * Check multiple abilities — at least one must pass.
112
+ */
113
+ async any(abilities: string[], user: any, ...args: any[]): Promise<boolean> {
114
+ for (const ability of abilities) {
115
+ if (await this.allows(ability, user, ...args)) {
116
+ return true
117
+ }
118
+ }
119
+ return false
120
+ }
121
+
122
+ /**
123
+ * Returns a UserGate scoped to a specific user for convenience.
124
+ */
125
+ forUser(user: any): UserGate {
126
+ return new UserGate(this, user)
127
+ }
128
+
129
+ // ── Policy resolution ──────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Resolve the policy class for a given model instance.
133
+ * Returns null if no policy is registered for this model's constructor.
134
+ */
135
+ getPolicyFor(model: any): Policy | null {
136
+ if (model === null || model === undefined) return null
137
+
138
+ const constructor = model.constructor
139
+ const PolicyClass = this.policies.get(constructor)
140
+
141
+ if (!PolicyClass) return null
142
+ return new PolicyClass()
143
+ }
144
+
145
+ // ── Internal resolution ────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Core resolution logic.
149
+ */
150
+ private async resolve(ability: string, user: any, ...args: any[]): Promise<AuthorizationResponse> {
151
+ // 1. Run global before callbacks
152
+ for (const cb of this.beforeCallbacks) {
153
+ const result = await cb(user, ability, ...args)
154
+ if (result === true) {
155
+ const response = AuthorizationResponse.allow()
156
+ await this.runAfterCallbacks(user, ability, true, ...args)
157
+ return response
158
+ }
159
+ if (result === false) {
160
+ const response = AuthorizationResponse.deny()
161
+ await this.runAfterCallbacks(user, ability, false, ...args)
162
+ return response
163
+ }
164
+ // null/undefined → continue
165
+ }
166
+
167
+ // 2. Try policy-based authorization
168
+ const firstArg = args[0]
169
+ const policyResult = await this.tryPolicy(ability, user, firstArg, ...args)
170
+ if (policyResult !== null) {
171
+ const allowed = policyResult.allowed()
172
+ await this.runAfterCallbacks(user, ability, allowed, ...args)
173
+ return policyResult
174
+ }
175
+
176
+ // 3. Try gate closure
177
+ const gateResult = await this.tryGate(ability, user, ...args)
178
+ if (gateResult !== null) {
179
+ const allowed = gateResult.allowed()
180
+ await this.runAfterCallbacks(user, ability, allowed, ...args)
181
+ return gateResult
182
+ }
183
+
184
+ // 4. Deny by default
185
+ const denied = AuthorizationResponse.deny()
186
+ await this.runAfterCallbacks(user, ability, false, ...args)
187
+ return denied
188
+ }
189
+
190
+ /**
191
+ * Attempt policy-based authorization.
192
+ * Returns null if no matching policy or method.
193
+ */
194
+ private async tryPolicy(ability: string, user: any, firstArg: any, ...allArgs: any[]): Promise<AuthorizationResponse | null> {
195
+ if (firstArg === null || firstArg === undefined) return null
196
+
197
+ const policy = this.getPolicyFor(firstArg)
198
+ if (!policy) return null
199
+
200
+ // Policy before() hook
201
+ if (typeof policy.before === 'function') {
202
+ const beforeResult = await policy.before(user, ability, ...allArgs)
203
+ if (beforeResult === true) return AuthorizationResponse.allow()
204
+ if (beforeResult === false) return AuthorizationResponse.deny()
205
+ // null/undefined → continue to method
206
+ }
207
+
208
+ // Check if the policy has a method matching the ability name
209
+ const method = (policy as any)[ability]
210
+ if (typeof method !== 'function') {
211
+ // Policy exists but no matching method → deny
212
+ return AuthorizationResponse.deny(`Policy method "${ability}" not defined.`)
213
+ }
214
+
215
+ const result = await method.call(policy, user, ...allArgs)
216
+ return this.normalizeResult(result)
217
+ }
218
+
219
+ /**
220
+ * Attempt gate closure authorization.
221
+ * Returns null if no gate is defined for this ability.
222
+ */
223
+ private async tryGate(ability: string, user: any, ...args: any[]): Promise<AuthorizationResponse | null> {
224
+ const callback = this.gates.get(ability)
225
+ if (!callback) return null
226
+
227
+ const result = await callback(user, ...args)
228
+ return this.normalizeResult(result)
229
+ }
230
+
231
+ /**
232
+ * Normalize a boolean or AuthorizationResponse return value.
233
+ */
234
+ private normalizeResult(result: boolean | AuthorizationResponse): AuthorizationResponse {
235
+ if (result instanceof AuthorizationResponse) return result
236
+ return result ? AuthorizationResponse.allow() : AuthorizationResponse.deny()
237
+ }
238
+
239
+ /**
240
+ * Run all global after callbacks.
241
+ */
242
+ private async runAfterCallbacks(user: any, ability: string, result: boolean, ...args: any[]): Promise<void> {
243
+ for (const cb of this.afterCallbacks) {
244
+ await cb(user, ability, result, ...args)
245
+ }
246
+ }
247
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Base class for authorization policies.
3
+ *
4
+ * Subclass and define methods matching ability names.
5
+ * Each method receives the user + optional model arguments and returns
6
+ * a boolean or AuthorizationResponse.
7
+ *
8
+ * The optional `before()` hook is called before any policy method and
9
+ * can short-circuit the check: return `true` to allow, `false` to deny,
10
+ * or `null`/`undefined` to continue to the actual policy method.
11
+ */
12
+ export abstract class Policy {
13
+ /**
14
+ * Called before any policy method.
15
+ * Return true to allow, false to deny, null/undefined to continue.
16
+ */
17
+ before?(user: any, ability: string, ...args: any[]): boolean | null | undefined | Promise<boolean | null | undefined>
18
+ }
@@ -0,0 +1,28 @@
1
+ import type { GateManager } from './GateManager.ts'
2
+ import type { AuthorizationResponse } from './AuthorizationResponse.ts'
3
+
4
+ /**
5
+ * A gate scoped to a specific user for convenience.
6
+ *
7
+ * @example
8
+ * const userGate = gate().forUser(user)
9
+ * if (await userGate.can('edit', post)) { ... }
10
+ */
11
+ export class UserGate {
12
+ constructor(
13
+ private readonly gate: GateManager,
14
+ private readonly user: any,
15
+ ) {}
16
+
17
+ async can(ability: string, ...args: any[]): Promise<boolean> {
18
+ return this.gate.allows(ability, this.user, ...args)
19
+ }
20
+
21
+ async cannot(ability: string, ...args: any[]): Promise<boolean> {
22
+ return this.gate.denies(ability, this.user, ...args)
23
+ }
24
+
25
+ async authorize(ability: string, ...args: any[]): Promise<AuthorizationResponse> {
26
+ return this.gate.authorize(ability, this.user, ...args)
27
+ }
28
+ }
@@ -3,6 +3,7 @@ import type { Constructor } from '@mantiq/core'
3
3
  export interface GuardConfig {
4
4
  driver: string // 'session' | custom driver name
5
5
  provider: string // Name referencing a provider in config.providers
6
+ trackLastUsed?: boolean | undefined
6
7
  }
7
8
 
8
9
  export interface ProviderConfig {
@@ -0,0 +1,22 @@
1
+ import type { GateManager } from '../authorization/GateManager.ts'
2
+
3
+ let _gate: GateManager | null = null
4
+
5
+ export const GATE_MANAGER = Symbol('GateManager')
6
+
7
+ export function setGateManager(g: GateManager): void {
8
+ _gate = g
9
+ }
10
+
11
+ /**
12
+ * Access the gate manager singleton.
13
+ *
14
+ * @example gate().allows('edit-post', user, post)
15
+ * @example gate().forUser(user).can('edit-post', post)
16
+ */
17
+ export function gate(): GateManager {
18
+ if (!_gate) {
19
+ throw new Error('Gate manager not initialized. Register AuthServiceProvider.')
20
+ }
21
+ return _gate
22
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,12 @@ export type { NewAccessToken } from './contracts/NewAccessToken.ts'
10
10
  export { AuthManager } from './AuthManager.ts'
11
11
  export { AuthServiceProvider } from './AuthServiceProvider.ts'
12
12
 
13
+ // ── Authorization (Gates & Policies) ─────────────────────────────────────────
14
+ export { GateManager } from './authorization/GateManager.ts'
15
+ export { AuthorizationResponse } from './authorization/AuthorizationResponse.ts'
16
+ export { Policy } from './authorization/Policy.ts'
17
+ export { UserGate } from './authorization/UserGate.ts'
18
+
13
19
  // ── Guards ────────────────────────────────────────────────────────────────────
14
20
  export { SessionGuard } from './guards/SessionGuard.ts'
15
21
  export { RequestGuard } from './guards/RequestGuard.ts'
@@ -25,6 +31,7 @@ export { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
25
31
  export { ConfirmPassword } from './middleware/ConfirmPassword.ts'
26
32
  export { CheckAbilities } from './middleware/CheckAbilities.ts'
27
33
  export { CheckForAnyAbility } from './middleware/CheckForAnyAbility.ts'
34
+ export { Authorize } from './middleware/Authorize.ts'
28
35
 
29
36
  // ── Errors ────────────────────────────────────────────────────────────────────
30
37
  export { AuthenticationError } from './errors/AuthenticationError.ts'
@@ -37,6 +44,7 @@ export { PersonalAccessToken } from './models/PersonalAccessToken.ts'
37
44
 
38
45
  // ── Mixins ────────────────────────────────────────────────────────────────────
39
46
  export { applyHasApiTokens } from './HasApiTokens.ts'
47
+ export { applyAuthorizable } from './Authorizable.ts'
40
48
 
41
49
  // ── Migrations ────────────────────────────────────────────────────────────────
42
50
  export { CreatePersonalAccessTokensTable } from './migrations/CreatePersonalAccessTokensTable.ts'
@@ -44,3 +52,4 @@ export { CreatePersonalAccessTokensTable } from './migrations/CreatePersonalAcce
44
52
  // ── Helpers ───────────────────────────────────────────────────────────────────
45
53
  export { sha256 } from './helpers/hash.ts'
46
54
  export { auth, AUTH_MANAGER } from './helpers/auth.ts'
55
+ export { gate, setGateManager, GATE_MANAGER } from './helpers/gate.ts'
@@ -0,0 +1,41 @@
1
+ import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
2
+ import { gate } from '../helpers/gate.ts'
3
+
4
+ /**
5
+ * Authorization middleware for routes.
6
+ *
7
+ * Checks that the authenticated user is authorized for the given ability.
8
+ * Returns 401 if unauthenticated, or throws ForbiddenError (via gate().authorize())
9
+ * if the user is not authorized.
10
+ *
11
+ * Usage: `.middleware('can:update,post')`
12
+ *
13
+ * - params[0] = ability name
14
+ * - params[1..] = optional extra arguments passed to the gate/policy
15
+ */
16
+ export class Authorize implements Middleware {
17
+ private params: string[] = []
18
+
19
+ setParameters(params: string[]): void {
20
+ this.params = params
21
+ }
22
+
23
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
24
+ const user = request.user()
25
+ if (!user) {
26
+ return new Response(
27
+ JSON.stringify({ message: 'Unauthenticated.' }),
28
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
29
+ )
30
+ }
31
+
32
+ const ability = this.params[0]
33
+ if (!ability) return next()
34
+
35
+ const gateManager = gate()
36
+ // authorize() throws ForbiddenError if denied
37
+ await gateManager.authorize(ability, user, ...this.params.slice(1))
38
+
39
+ return next()
40
+ }
41
+ }