@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 +5 -3
- package/src/AuthManager.ts +5 -0
- package/src/HasApiTokens.ts +58 -0
- package/src/contracts/NewAccessToken.ts +6 -0
- package/src/guards/TokenGuard.ts +108 -0
- package/src/helpers/hash.ts +5 -0
- package/src/index.ts +14 -0
- package/src/middleware/CheckAbilities.ts +24 -0
- package/src/middleware/CheckForAnyAbility.ts +23 -0
- package/src/migrations/CreatePersonalAccessTokensTable.ts +22 -0
- package/src/models/PersonalAccessToken.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mantiq/auth",
|
|
3
|
-
"version": "0.1
|
|
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",
|
package/src/AuthManager.ts
CHANGED
|
@@ -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,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
|
+
}
|