@avelonjs/neon 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/LICENSE +21 -0
- package/README.md +351 -0
- package/package.json +56 -0
- package/src/database/driver.ts +263 -0
- package/src/database/index.ts +8 -0
- package/src/identity/cookies.ts +54 -0
- package/src/identity/driver.ts +462 -0
- package/src/identity/errors.ts +206 -0
- package/src/identity/index.ts +15 -0
- package/src/identity/local-auth.ts +304 -0
- package/src/identity/types.ts +19 -0
- package/src/index.ts +6 -0
- package/src/queue/driver.ts +271 -0
- package/src/queue/index.ts +1 -0
- package/src/social/driver.ts +118 -0
- package/src/social/index.ts +8 -0
- package/src/social/local-auth.ts +55 -0
- package/src/social/types.ts +7 -0
- package/src/storage/driver.ts +182 -0
- package/src/storage/index.ts +7 -0
- package/src/storage/local-s3.ts +128 -0
- package/src/tokens/driver.ts +220 -0
- package/src/tokens/index.ts +6 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import {
|
|
3
|
+
DriverFault,
|
|
4
|
+
Unauthenticated,
|
|
5
|
+
type IssuedToken,
|
|
6
|
+
type TokenDriver,
|
|
7
|
+
type TokenIssueOptions,
|
|
8
|
+
type TokenRecord,
|
|
9
|
+
} from '@avelonjs/core'
|
|
10
|
+
|
|
11
|
+
/** Exact capability declaration for the Neon token driver. */
|
|
12
|
+
export const neonTokenCapabilities = {
|
|
13
|
+
abilities: true,
|
|
14
|
+
expiration: true,
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
/** Construction options for {@link createNeonTokens}. */
|
|
18
|
+
export interface NeonTokenOptions {
|
|
19
|
+
/** Neon or Postgres URL used to persist hashed tokens. */
|
|
20
|
+
url?: string
|
|
21
|
+
/** Configured token issuer name. */
|
|
22
|
+
instance?: string
|
|
23
|
+
/** Current actor subject stored on issued tokens. */
|
|
24
|
+
subject?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface StoredRow {
|
|
28
|
+
id: string
|
|
29
|
+
subject: string
|
|
30
|
+
name: string
|
|
31
|
+
abilities: string[]
|
|
32
|
+
created_at: Date | string
|
|
33
|
+
expires_at: Date | string | null
|
|
34
|
+
hash: string
|
|
35
|
+
revoked: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultUrl(): string {
|
|
39
|
+
return (
|
|
40
|
+
process.env.NEON_DATABASE_URL ??
|
|
41
|
+
process.env.POSTGRES_URL ??
|
|
42
|
+
process.env.DATABASE_URL ??
|
|
43
|
+
'postgresql://postgres:avelon@127.0.0.1:5432/avelon_test'
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unauthenticated(): never {
|
|
48
|
+
throw new Unauthenticated('The API token is invalid or expired.', {
|
|
49
|
+
metadata: { guard: 'tokens' },
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function hash(plainText: string): Promise<string> {
|
|
54
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(plainText))
|
|
55
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asDate(value: Date | string): Date {
|
|
59
|
+
return value instanceof Date ? value : new Date(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toRecord(row: StoredRow): TokenRecord {
|
|
63
|
+
return {
|
|
64
|
+
id: row.id,
|
|
65
|
+
subject: row.subject,
|
|
66
|
+
name: row.name,
|
|
67
|
+
abilities: [...row.abilities],
|
|
68
|
+
createdAt: asDate(row.created_at),
|
|
69
|
+
expiresAt: row.expires_at === null ? null : asDate(row.expires_at),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SCHEMA_SQL = `
|
|
74
|
+
CREATE TABLE IF NOT EXISTS avelon_signets (
|
|
75
|
+
id text PRIMARY KEY,
|
|
76
|
+
subject text NOT NULL,
|
|
77
|
+
name text NOT NULL,
|
|
78
|
+
abilities jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
79
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
80
|
+
expires_at timestamptz,
|
|
81
|
+
hash text NOT NULL UNIQUE,
|
|
82
|
+
revoked boolean NOT NULL DEFAULT false
|
|
83
|
+
);
|
|
84
|
+
`
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Postgres-backed Signet driver used by the Neon package.
|
|
88
|
+
*
|
|
89
|
+
* Neon Auth does not issue named API tokens. Hashed credentials live in `avelon_signets` so
|
|
90
|
+
* verify/list/revoke are durable and the plaintext secret is returned only at issuance.
|
|
91
|
+
*/
|
|
92
|
+
export class NeonTokens implements TokenDriver<typeof neonTokenCapabilities, SQL> {
|
|
93
|
+
readonly name = 'neon'
|
|
94
|
+
readonly instance: string
|
|
95
|
+
readonly capabilities = neonTokenCapabilities
|
|
96
|
+
|
|
97
|
+
readonly #sql: SQL
|
|
98
|
+
readonly #subject: string
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
options: Required<Omit<NeonTokenOptions, 'url' | 'subject'>> & {
|
|
102
|
+
url: string
|
|
103
|
+
subject: string
|
|
104
|
+
},
|
|
105
|
+
) {
|
|
106
|
+
this.instance = options.instance
|
|
107
|
+
this.#subject = options.subject
|
|
108
|
+
this.#sql = new SQL(options.url)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
raw(): SQL {
|
|
112
|
+
return this.#sql
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Recreates the empty signet table used by live conformance. */
|
|
116
|
+
async reset(): Promise<void> {
|
|
117
|
+
await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_signets')
|
|
118
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async close(): Promise<void> {
|
|
122
|
+
await this.#sql.close()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async verify(plainText: string): Promise<TokenRecord> {
|
|
126
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
127
|
+
const candidateHash = await hash(plainText)
|
|
128
|
+
const rows = (await this.#sql.unsafe(
|
|
129
|
+
`SELECT id, subject, name, abilities, created_at, expires_at, hash, revoked
|
|
130
|
+
FROM avelon_signets WHERE hash = $1`,
|
|
131
|
+
[candidateHash],
|
|
132
|
+
)) as StoredRow[]
|
|
133
|
+
const row = rows[0]
|
|
134
|
+
const expiresAt =
|
|
135
|
+
row?.expires_at === null || row?.expires_at === undefined ? null : asDate(row.expires_at)
|
|
136
|
+
if (
|
|
137
|
+
row === undefined ||
|
|
138
|
+
row.revoked ||
|
|
139
|
+
(expiresAt !== null && expiresAt.getTime() <= Date.now())
|
|
140
|
+
) {
|
|
141
|
+
unauthenticated()
|
|
142
|
+
}
|
|
143
|
+
return toRecord({ ...row, abilities: normalizeAbilities(row.abilities) })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async issue(name: string, options: TokenIssueOptions = {}): Promise<IssuedToken> {
|
|
147
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
148
|
+
const id = crypto.randomUUID()
|
|
149
|
+
const plainText = `avelon-signet-${id}`
|
|
150
|
+
const createdAt = new Date()
|
|
151
|
+
const expiresAt = options.expiresAt ? new Date(options.expiresAt) : null
|
|
152
|
+
const abilities = [...(options.abilities ?? [])]
|
|
153
|
+
const rows = (await this.#sql.unsafe(
|
|
154
|
+
`INSERT INTO avelon_signets (id, subject, name, abilities, created_at, expires_at, hash, revoked)
|
|
155
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, false)
|
|
156
|
+
RETURNING id, subject, name, abilities, created_at, expires_at, hash, revoked`,
|
|
157
|
+
[
|
|
158
|
+
id,
|
|
159
|
+
this.#subject,
|
|
160
|
+
name,
|
|
161
|
+
JSON.stringify(abilities),
|
|
162
|
+
createdAt.toISOString(),
|
|
163
|
+
expiresAt?.toISOString() ?? null,
|
|
164
|
+
await hash(plainText),
|
|
165
|
+
],
|
|
166
|
+
)) as StoredRow[]
|
|
167
|
+
const stored = rows[0]
|
|
168
|
+
if (stored === undefined) {
|
|
169
|
+
throw new DriverFault('Token issuance did not return a row.', {
|
|
170
|
+
metadata: { driver: 'tokens', operation: 'issue' },
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
const record = toRecord({ ...stored, abilities: normalizeAbilities(stored.abilities) })
|
|
174
|
+
return {
|
|
175
|
+
...record,
|
|
176
|
+
expiresAt: options.expiresAt ? new Date(options.expiresAt.getTime()) : record.expiresAt,
|
|
177
|
+
plainText,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async list(): Promise<readonly TokenRecord[]> {
|
|
182
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
183
|
+
const rows = (await this.#sql.unsafe(
|
|
184
|
+
`SELECT id, subject, name, abilities, created_at, expires_at, hash, revoked
|
|
185
|
+
FROM avelon_signets WHERE subject = $1 ORDER BY created_at ASC`,
|
|
186
|
+
[this.#subject],
|
|
187
|
+
)) as StoredRow[]
|
|
188
|
+
return rows.map((row) => toRecord({ ...row, abilities: normalizeAbilities(row.abilities) }))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async revoke(id: string): Promise<void> {
|
|
192
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
193
|
+
await this.#sql.unsafe(`UPDATE avelon_signets SET revoked = true WHERE id = $1`, [id])
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeAbilities(value: unknown): string[] {
|
|
198
|
+
if (Array.isArray(value))
|
|
199
|
+
return value.filter((entry): entry is string => typeof entry === 'string')
|
|
200
|
+
if (typeof value === 'string') {
|
|
201
|
+
try {
|
|
202
|
+
const parsed: unknown = JSON.parse(value)
|
|
203
|
+
return Array.isArray(parsed)
|
|
204
|
+
? parsed.filter((entry): entry is string => typeof entry === 'string')
|
|
205
|
+
: []
|
|
206
|
+
} catch {
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Creates a Postgres-backed token driver from options or environment defaults. */
|
|
214
|
+
export function createNeonTokens(options: NeonTokenOptions = {}): NeonTokens {
|
|
215
|
+
return new NeonTokens({
|
|
216
|
+
url: options.url ?? defaultUrl(),
|
|
217
|
+
instance: options.instance ?? 'default',
|
|
218
|
+
subject: options.subject ?? 'assay-subject',
|
|
219
|
+
})
|
|
220
|
+
}
|