@mantiq/auth 0.1.3 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/auth",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Session & token auth, guards, providers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,14 +40,16 @@
40
40
  "LICENSE"
41
41
  ],
42
42
  "scripts": {
43
- "build": "bun build ./src/index.ts --outdir ./dist --target bun",
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun --packages=external",
44
44
  "test": "bun test",
45
45
  "typecheck": "tsc --noEmit",
46
46
  "clean": "rm -rf dist"
47
47
  },
48
48
  "devDependencies": {
49
49
  "bun-types": "latest",
50
- "typescript": "^5.7.0"
50
+ "typescript": "^5.7.0",
51
+ "@mantiq/core": "workspace:*",
52
+ "@mantiq/database": "workspace:*"
51
53
  },
52
54
  "peerDependencies": {
53
55
  "@mantiq/core": "^0.1.0",
@@ -7,6 +7,7 @@ import type { Authenticatable } from './contracts/Authenticatable.ts'
7
7
  import type { AuthConfig } from './contracts/AuthConfig.ts'
8
8
  import { SessionGuard } from './guards/SessionGuard.ts'
9
9
  import { RequestGuard } from './guards/RequestGuard.ts'
10
+ import { TokenGuard } from './guards/TokenGuard.ts'
10
11
  import { DatabaseUserProvider } from './providers/DatabaseUserProvider.ts'
11
12
 
12
13
  type RequestGuardCallback = (
@@ -208,6 +209,10 @@ export class AuthManager implements DriverManager<Guard> {
208
209
  }
209
210
  return new SessionGuard(name, provider, encrypter)
210
211
  }
212
+ case 'token': {
213
+ const provider = this.createUserProvider(guardConfig.provider)
214
+ return new TokenGuard(name, provider, guardConfig.trackLastUsed ?? false)
215
+ }
211
216
  default: {
212
217
  throw new Error(
213
218
  `Unsupported auth guard driver: "${guardConfig.driver}". ` +
@@ -0,0 +1,58 @@
1
+ import { PersonalAccessToken } from './models/PersonalAccessToken.ts'
2
+ import { sha256 } from './helpers/hash.ts'
3
+ import type { NewAccessToken } from './contracts/NewAccessToken.ts'
4
+
5
+ export function applyHasApiTokens(ModelClass: any): void {
6
+ // Store current access token on instance
7
+ const proto = ModelClass.prototype
8
+
9
+ proto.createToken = async function(name: string, abilities: string[] = ['*'], expiresAt?: Date): Promise<NewAccessToken> {
10
+ // Generate 64 random hex characters
11
+ const randomBytes = new Uint8Array(32)
12
+ crypto.getRandomValues(randomBytes)
13
+ const plaintext = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('')
14
+
15
+ const hash = await sha256(plaintext)
16
+
17
+ const ctor = this.constructor as any
18
+ const token = await PersonalAccessToken.create({
19
+ tokenable_type: ctor.table ?? ctor.name?.toLowerCase() + 's',
20
+ tokenable_id: this.getKey(),
21
+ name,
22
+ token: hash,
23
+ abilities: JSON.stringify(abilities),
24
+ expires_at: expiresAt?.toISOString() ?? null,
25
+ })
26
+
27
+ const id = token.getKey()
28
+ return {
29
+ accessToken: token,
30
+ plainTextToken: `${id}|${plaintext}`,
31
+ }
32
+ }
33
+
34
+ proto.tokens = function() {
35
+ const ctor = this.constructor as any
36
+ return PersonalAccessToken.where('tokenable_type', ctor.table ?? ctor.name?.toLowerCase() + 's')
37
+ .where('tokenable_id', this.getKey())
38
+ }
39
+
40
+ proto.currentAccessToken = function(): PersonalAccessToken | null {
41
+ return this._currentAccessToken ?? null
42
+ }
43
+
44
+ proto.withAccessToken = function(token: PersonalAccessToken): any {
45
+ this._currentAccessToken = token
46
+ return this
47
+ }
48
+
49
+ proto.tokenCan = function(ability: string): boolean {
50
+ const token = this.currentAccessToken()
51
+ if (!token) return false
52
+ return token.can(ability)
53
+ }
54
+
55
+ proto.tokenCant = function(ability: string): boolean {
56
+ return !this.tokenCan(ability)
57
+ }
58
+ }
@@ -0,0 +1,6 @@
1
+ import type { PersonalAccessToken } from '../models/PersonalAccessToken.ts'
2
+
3
+ export interface NewAccessToken {
4
+ accessToken: PersonalAccessToken
5
+ plainTextToken: string
6
+ }
@@ -0,0 +1,108 @@
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
+ import { PersonalAccessToken } from '../models/PersonalAccessToken.ts'
6
+ import { sha256 } from '../helpers/hash.ts'
7
+
8
+ export class TokenGuard implements Guard {
9
+ private _user: Authenticatable | null = null
10
+ private _request: MantiqRequest | null = null
11
+ private _resolved = false
12
+
13
+ constructor(
14
+ private readonly name: string,
15
+ private readonly provider: UserProvider,
16
+ private readonly trackLastUsed = false,
17
+ ) {}
18
+
19
+ async check(): Promise<boolean> {
20
+ return (await this.user()) !== null
21
+ }
22
+
23
+ async guest(): Promise<boolean> {
24
+ return !(await this.check())
25
+ }
26
+
27
+ async user(): Promise<Authenticatable | null> {
28
+ if (this._resolved) return this._user
29
+ this._resolved = true
30
+
31
+ if (!this._request) return null
32
+
33
+ const bearerToken = this._request.bearerToken()
34
+ if (!bearerToken) return null
35
+
36
+ const token = await this.resolveToken(bearerToken)
37
+ if (!token) return null
38
+
39
+ // Check expiration
40
+ if (token.isExpired()) return null
41
+
42
+ // Optionally track last usage (disabled by default to avoid write-per-request)
43
+ if (this.trackLastUsed) {
44
+ token.setAttribute('last_used_at', new Date().toISOString())
45
+ token.save().catch(() => {})
46
+ }
47
+
48
+ // Resolve user
49
+ const userId = token.getAttribute('tokenable_id')
50
+ const user = await this.provider.retrieveById(userId)
51
+ if (!user) return null
52
+
53
+ // Attach token to user if it supports it
54
+ if (typeof (user as any).withAccessToken === 'function') {
55
+ (user as any).withAccessToken(token)
56
+ }
57
+
58
+ this._user = user
59
+ return user
60
+ }
61
+
62
+ async id(): Promise<string | number | null> {
63
+ const user = await this.user()
64
+ return user?.getAuthIdentifier() ?? null
65
+ }
66
+
67
+ async validate(credentials: Record<string, any>): Promise<boolean> {
68
+ const user = await this.provider.retrieveByCredentials(credentials)
69
+ if (!user) return false
70
+ return this.provider.validateCredentials(user, credentials)
71
+ }
72
+
73
+ setUser(user: Authenticatable): void {
74
+ this._user = user
75
+ this._resolved = true
76
+ }
77
+
78
+ hasUser(): boolean {
79
+ return this._user !== null
80
+ }
81
+
82
+ setRequest(request: MantiqRequest): void {
83
+ this._request = request
84
+ this._user = null
85
+ this._resolved = false
86
+ }
87
+
88
+ private async resolveToken(bearerToken: string): Promise<PersonalAccessToken | null> {
89
+ const parts = bearerToken.split('|')
90
+
91
+ if (parts.length === 2) {
92
+ // Format: {id}|{plaintext}
93
+ const [id, plaintext] = parts
94
+ const token = await PersonalAccessToken.find(Number(id))
95
+ if (!token) return null
96
+
97
+ const hash = await sha256(plaintext!)
98
+ const storedHash = token.getAttribute('token') as string
99
+ if (hash !== storedHash) return null
100
+
101
+ return token
102
+ }
103
+
104
+ // Fallback: hash the entire string and search by token hash
105
+ const hash = await sha256(bearerToken)
106
+ return await PersonalAccessToken.where('token', hash).first() as PersonalAccessToken | null
107
+ }
108
+ }
@@ -0,0 +1,5 @@
1
+ export async function sha256(input: string): Promise<string> {
2
+ const data = new TextEncoder().encode(input)
3
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
4
+ return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
5
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export type { Guard } from './contracts/Guard.ts'
4
4
  export type { StatefulGuard } from './contracts/StatefulGuard.ts'
5
5
  export type { UserProvider } from './contracts/UserProvider.ts'
6
6
  export type { AuthConfig, GuardConfig, ProviderConfig } from './contracts/AuthConfig.ts'
7
+ export type { NewAccessToken } from './contracts/NewAccessToken.ts'
7
8
 
8
9
  // ── Core ──────────────────────────────────────────────────────────────────────
9
10
  export { AuthManager } from './AuthManager.ts'
@@ -12,6 +13,7 @@ export { AuthServiceProvider } from './AuthServiceProvider.ts'
12
13
  // ── Guards ────────────────────────────────────────────────────────────────────
13
14
  export { SessionGuard } from './guards/SessionGuard.ts'
14
15
  export { RequestGuard } from './guards/RequestGuard.ts'
16
+ export { TokenGuard } from './guards/TokenGuard.ts'
15
17
 
16
18
  // ── Providers ─────────────────────────────────────────────────────────────────
17
19
  export { DatabaseUserProvider } from './providers/DatabaseUserProvider.ts'
@@ -21,6 +23,8 @@ export { Authenticate } from './middleware/Authenticate.ts'
21
23
  export { RedirectIfAuthenticated } from './middleware/RedirectIfAuthenticated.ts'
22
24
  export { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
23
25
  export { ConfirmPassword } from './middleware/ConfirmPassword.ts'
26
+ export { CheckAbilities } from './middleware/CheckAbilities.ts'
27
+ export { CheckForAnyAbility } from './middleware/CheckForAnyAbility.ts'
24
28
 
25
29
  // ── Errors ────────────────────────────────────────────────────────────────────
26
30
  export { AuthenticationError } from './errors/AuthenticationError.ts'
@@ -28,5 +32,15 @@ export { AuthenticationError } from './errors/AuthenticationError.ts'
28
32
  // ── Events ────────────────────────────────────────────────────────────────────
29
33
  export { Attempting, Authenticated, Login, Failed, Logout, Registered, Lockout } from './events/AuthEvents.ts'
30
34
 
35
+ // ── Models ────────────────────────────────────────────────────────────────────
36
+ export { PersonalAccessToken } from './models/PersonalAccessToken.ts'
37
+
38
+ // ── Mixins ────────────────────────────────────────────────────────────────────
39
+ export { applyHasApiTokens } from './HasApiTokens.ts'
40
+
41
+ // ── Migrations ────────────────────────────────────────────────────────────────
42
+ export { CreatePersonalAccessTokensTable } from './migrations/CreatePersonalAccessTokensTable.ts'
43
+
31
44
  // ── Helpers ───────────────────────────────────────────────────────────────────
45
+ export { sha256 } from './helpers/hash.ts'
32
46
  export { auth, AUTH_MANAGER } from './helpers/auth.ts'
@@ -0,0 +1,24 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+
3
+ export class CheckAbilities {
4
+ private abilities: string[] = []
5
+
6
+ setParameters(...abilities: string[]): void {
7
+ this.abilities = abilities
8
+ }
9
+
10
+ async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
11
+ const user = request.user<any>()
12
+ if (!user || typeof user.tokenCan !== 'function') {
13
+ return new Response(JSON.stringify({ message: 'Unauthorized.' }), { status: 403, headers: { 'Content-Type': 'application/json' } })
14
+ }
15
+
16
+ for (const ability of this.abilities) {
17
+ if (!user.tokenCan(ability)) {
18
+ return new Response(JSON.stringify({ message: `Missing ability: ${ability}` }), { status: 403, headers: { 'Content-Type': 'application/json' } })
19
+ }
20
+ }
21
+
22
+ return next()
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+
3
+ export class CheckForAnyAbility {
4
+ private abilities: string[] = []
5
+
6
+ setParameters(...abilities: string[]): void {
7
+ this.abilities = abilities
8
+ }
9
+
10
+ async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
11
+ const user = request.user<any>()
12
+ if (!user || typeof user.tokenCan !== 'function') {
13
+ return new Response(JSON.stringify({ message: 'Unauthorized.' }), { status: 403, headers: { 'Content-Type': 'application/json' } })
14
+ }
15
+
16
+ const hasAny = this.abilities.some(ability => user.tokenCan(ability))
17
+ if (!hasAny) {
18
+ return new Response(JSON.stringify({ message: 'Insufficient abilities.' }), { status: 403, headers: { 'Content-Type': 'application/json' } })
19
+ }
20
+
21
+ return next()
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import { Migration } from '@mantiq/database'
2
+ import type { SchemaBuilder } from '@mantiq/database'
3
+
4
+ export class CreatePersonalAccessTokensTable extends Migration {
5
+ override async up(schema: SchemaBuilder): Promise<void> {
6
+ await schema.create('personal_access_tokens', (table) => {
7
+ table.id()
8
+ table.string('tokenable_type')
9
+ table.unsignedBigInteger('tokenable_id')
10
+ table.string('name')
11
+ table.string('token', 64).unique()
12
+ table.json('abilities').nullable()
13
+ table.timestamp('last_used_at').nullable()
14
+ table.timestamp('expires_at').nullable()
15
+ table.timestamps()
16
+ })
17
+ }
18
+
19
+ override async down(schema: SchemaBuilder): Promise<void> {
20
+ await schema.dropIfExists('personal_access_tokens')
21
+ }
22
+ }
@@ -0,0 +1,28 @@
1
+ import { Model } from '@mantiq/database'
2
+
3
+ export class PersonalAccessToken extends Model {
4
+ static override table = 'personal_access_tokens'
5
+ static override fillable = ['name', 'token', 'abilities', 'expires_at', 'last_used_at', 'tokenable_type', 'tokenable_id']
6
+ static override hidden = ['token']
7
+ static override casts = {
8
+ abilities: 'json' as const,
9
+ last_used_at: 'datetime' as const,
10
+ expires_at: 'datetime' as const,
11
+ }
12
+
13
+ can(ability: string): boolean {
14
+ const abilities = this.getAttribute('abilities') as string[] | null
15
+ if (!abilities) return false
16
+ return abilities.includes('*') || abilities.includes(ability)
17
+ }
18
+
19
+ cant(ability: string): boolean {
20
+ return !this.can(ability)
21
+ }
22
+
23
+ isExpired(): boolean {
24
+ const expiresAt = this.getAttribute('expires_at')
25
+ if (!expiresAt) return false
26
+ return new Date(expiresAt) < new Date()
27
+ }
28
+ }