@riligar/auth-elysia 1.0.4
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 +201 -0
- package/README.md +195 -0
- package/dist/index.esm.js +404 -0
- package/dist/index.js +412 -0
- package/package.json +66 -0
- package/src/index.js +407 -0
- package/src/types.d.ts +52 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { Elysia } from 'elysia';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @riligar/auth-elysia
|
|
5
|
+
* Plugin de autenticação para ElysiaJS - Auth-as-a-Service
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuração padrão do plugin
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_CONFIG = {
|
|
13
|
+
prefix: '/auth',
|
|
14
|
+
secretKey: process.env.AUTH_SECRET_KEY || 'your-secret-key',
|
|
15
|
+
apiUrl: process.env.AUTH_API_URL || 'https://manager.myauth.click',
|
|
16
|
+
cookieName: 'auth-token',
|
|
17
|
+
cookieOptions: {
|
|
18
|
+
httpOnly: true,
|
|
19
|
+
secure: process.env.NODE_ENV === 'production',
|
|
20
|
+
sameSite: 'lax',
|
|
21
|
+
maxAge: 86400, // 24 horas
|
|
22
|
+
},
|
|
23
|
+
excludePaths: ['/auth/login', '/auth/register', '/auth/session'],
|
|
24
|
+
onUnauthorized: set => {
|
|
25
|
+
set.status = 401;
|
|
26
|
+
return { error: 'Unauthorized', message: 'Token inválido ou expirado' }
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cliente Auth com verificação JWT local + JWKS
|
|
32
|
+
*/
|
|
33
|
+
class RiLiGarAuthClient {
|
|
34
|
+
constructor(baseUrl, secretKey) {
|
|
35
|
+
this.baseUrl = baseUrl;
|
|
36
|
+
this.secretKey = secretKey;
|
|
37
|
+
// 🚀 Cache para JWKS (chaves públicas)
|
|
38
|
+
this.jwksCache = null;
|
|
39
|
+
this.jwksCacheExpiry = 0;
|
|
40
|
+
this.jwksCacheTTL = 3600000; // 1 hora em ms
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 🔑 Buscar e cachear JWKS
|
|
44
|
+
async getJWKS() {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
|
|
47
|
+
// Retorna cache se ainda válido
|
|
48
|
+
if (this.jwksCache && now < this.jwksCacheExpiry) {
|
|
49
|
+
return this.jwksCache
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
|
|
54
|
+
if (response.ok) {
|
|
55
|
+
this.jwksCache = await response.json();
|
|
56
|
+
this.jwksCacheExpiry = now + this.jwksCacheTTL;
|
|
57
|
+
return this.jwksCache
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.warn('JWKS fetch failed, falling back to remote verification:', error.message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ⚡ Verificação JWT local (usando crypto nativo do Bun)
|
|
67
|
+
async verifyJWTLocal(token) {
|
|
68
|
+
try {
|
|
69
|
+
const jwks = await this.getJWKS();
|
|
70
|
+
if (!jwks?.keys?.length) return null
|
|
71
|
+
|
|
72
|
+
// Decode JWT header para pegar kid e alg
|
|
73
|
+
const [headerB64] = token.split('.');
|
|
74
|
+
const header = JSON.parse(atob(headerB64));
|
|
75
|
+
|
|
76
|
+
// Encontrar a chave correspondente
|
|
77
|
+
const jwk = jwks.keys.find(k => k.kid === header.kid || k.alg === header.alg);
|
|
78
|
+
if (!jwk) return null
|
|
79
|
+
|
|
80
|
+
// Preparar dados para verificação
|
|
81
|
+
const [headerB64Url, payloadB64Url, signatureB64Url] = token.split('.');
|
|
82
|
+
const data = new TextEncoder().encode(`${headerB64Url}.${payloadB64Url}`);
|
|
83
|
+
|
|
84
|
+
const signature = new Uint8Array(Array.from(atob(signatureB64Url.replace(/-/g, '+').replace(/_/g, '/'))).map(c => c.charCodeAt(0)));
|
|
85
|
+
|
|
86
|
+
let key;
|
|
87
|
+
let algorithm;
|
|
88
|
+
|
|
89
|
+
// Suporte para diferentes algoritmos
|
|
90
|
+
if (header.alg === 'HS256' || jwk.kty === 'oct') {
|
|
91
|
+
// HMAC com secret key
|
|
92
|
+
const secret = new TextEncoder().encode(this.secretKey);
|
|
93
|
+
key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
94
|
+
algorithm = 'HMAC';
|
|
95
|
+
} else if (header.alg === 'RS256' || jwk.kty === 'RSA') {
|
|
96
|
+
// RSA com chave pública JWKS
|
|
97
|
+
if (!jwk.n || !jwk.e) return null
|
|
98
|
+
|
|
99
|
+
// Converter base64url para ArrayBuffer
|
|
100
|
+
const nBuffer = this.base64urlToArrayBuffer(jwk.n);
|
|
101
|
+
const eBuffer = this.base64urlToArrayBuffer(jwk.e);
|
|
102
|
+
|
|
103
|
+
key = await crypto.subtle.importKey(
|
|
104
|
+
'jwk',
|
|
105
|
+
{
|
|
106
|
+
kty: 'RSA',
|
|
107
|
+
n: jwk.n,
|
|
108
|
+
e: jwk.e,
|
|
109
|
+
alg: 'RS256',
|
|
110
|
+
use: 'sig',
|
|
111
|
+
},
|
|
112
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
113
|
+
false,
|
|
114
|
+
['verify']
|
|
115
|
+
);
|
|
116
|
+
algorithm = 'RSASSA-PKCS1-v1_5';
|
|
117
|
+
} else {
|
|
118
|
+
console.warn('Algoritmo JWT não suportado:', header.alg);
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Verificar assinatura
|
|
123
|
+
const isValid = await crypto.subtle.verify(algorithm, key, signature, data);
|
|
124
|
+
if (!isValid) return null
|
|
125
|
+
|
|
126
|
+
// Decode payload
|
|
127
|
+
const payload = JSON.parse(atob(payloadB64Url));
|
|
128
|
+
|
|
129
|
+
// Verificar expiração
|
|
130
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return payload
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.warn('JWT local verification failed:', error.message);
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Utilitário para converter base64url para ArrayBuffer
|
|
142
|
+
base64urlToArrayBuffer(base64url) {
|
|
143
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
144
|
+
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
|
145
|
+
const base64WithPadding = base64 + padding;
|
|
146
|
+
|
|
147
|
+
const binaryString = atob(base64WithPadding);
|
|
148
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
149
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
150
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
151
|
+
}
|
|
152
|
+
return bytes.buffer
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ⚡ Verificar sessão OTIMIZADA (local + fallback remoto)
|
|
156
|
+
async verifySession(sessionToken) {
|
|
157
|
+
// 🚀 Primeira tentativa: Verificação local com JWKS
|
|
158
|
+
const localResult = await this.verifyJWTLocal(sessionToken);
|
|
159
|
+
if (localResult) {
|
|
160
|
+
return {
|
|
161
|
+
user: localResult,
|
|
162
|
+
verified_locally: true,
|
|
163
|
+
cached: true,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 🔄 Fallback: Verificação remota
|
|
168
|
+
try {
|
|
169
|
+
const response = await fetch(`${this.baseUrl}/auth/session`, {
|
|
170
|
+
headers: {
|
|
171
|
+
Authorization: `Bearer ${sessionToken}`,
|
|
172
|
+
'X-API-Key': this.secretKey,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (response.ok) {
|
|
177
|
+
const result = await response.json();
|
|
178
|
+
return {
|
|
179
|
+
...result,
|
|
180
|
+
verified_locally: false,
|
|
181
|
+
cached: false,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.warn('Remote session verification failed:', error.message);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Utilitário para fazer requisições HTTP
|
|
194
|
+
*/
|
|
195
|
+
async function fetchAuth(url, options = {}) {
|
|
196
|
+
try {
|
|
197
|
+
const response = await fetch(url, {
|
|
198
|
+
headers: {
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
...options.headers,
|
|
201
|
+
},
|
|
202
|
+
...options,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return await response.json()
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Auth fetch error:', error);
|
|
212
|
+
throw error
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Middleware de autenticação JWT com verificação JWKS local
|
|
218
|
+
*/
|
|
219
|
+
function createAuthMiddleware(config, authClient) {
|
|
220
|
+
return async ({ request, set, cookie }) => {
|
|
221
|
+
// Verificar se a rota deve ser excluída da autenticação
|
|
222
|
+
const path = new URL(request.url).pathname;
|
|
223
|
+
if (config.excludePaths.some(excluded => path.startsWith(excluded))) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Buscar token no cookie ou header Authorization
|
|
228
|
+
let token = cookie[config.cookieName]?.value;
|
|
229
|
+
|
|
230
|
+
if (!token) {
|
|
231
|
+
const authHeader = request.headers.get('authorization');
|
|
232
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
233
|
+
token = authHeader.substring(7);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!token) {
|
|
238
|
+
return config.onUnauthorized(set)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// ⚡ Verificação otimizada com JWKS local + fallback remoto
|
|
243
|
+
const userSession = await authClient.verifySession(token);
|
|
244
|
+
|
|
245
|
+
if (!userSession) {
|
|
246
|
+
return config.onUnauthorized(set)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Adicionar usuário ao contexto da requisição
|
|
250
|
+
request.user = userSession.user;
|
|
251
|
+
request.authMeta = {
|
|
252
|
+
verified_locally: userSession.verified_locally,
|
|
253
|
+
cached: userSession.cached,
|
|
254
|
+
};
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return config.onUnauthorized(set)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Plugin principal de autenticação
|
|
263
|
+
*/
|
|
264
|
+
function authPlugin(userConfig = {}) {
|
|
265
|
+
const config = { ...DEFAULT_CONFIG, ...userConfig };
|
|
266
|
+
|
|
267
|
+
// Instanciar cliente Auth
|
|
268
|
+
const authClient = new RiLiGarAuthClient(config.apiUrl, config.secretKey);
|
|
269
|
+
|
|
270
|
+
return new Elysia({ name: 'auth-plugin' })
|
|
271
|
+
.derive(({ request }) => ({
|
|
272
|
+
user: request.user || null,
|
|
273
|
+
authMeta: request.authMeta || null,
|
|
274
|
+
}))
|
|
275
|
+
.onBeforeHandle(createAuthMiddleware(config, authClient))
|
|
276
|
+
.group(config.prefix, app =>
|
|
277
|
+
app
|
|
278
|
+
// Rota de login
|
|
279
|
+
.post('/login', async ({ body, set, cookie }) => {
|
|
280
|
+
try {
|
|
281
|
+
const { email, password } = body;
|
|
282
|
+
|
|
283
|
+
if (!email || !password) {
|
|
284
|
+
set.status = 400;
|
|
285
|
+
return { error: 'Email e senha são obrigatórios' }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const response = await fetchAuth(`${config.apiUrl}/auth/sign-in/email`, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
body: JSON.stringify({ email, password }),
|
|
291
|
+
headers: {
|
|
292
|
+
'X-API-Key': config.secretKey,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (response.token) {
|
|
297
|
+
cookie[config.cookieName].set({
|
|
298
|
+
value: response.token,
|
|
299
|
+
...config.cookieOptions,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
message: 'Login realizado com sucesso!',
|
|
305
|
+
user: response.user,
|
|
306
|
+
token: response.token,
|
|
307
|
+
session: response.session,
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
set.status = 401;
|
|
311
|
+
return { error: 'Login failed', message: error.message }
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Rota de registro
|
|
316
|
+
.post('/register', async ({ body, set }) => {
|
|
317
|
+
try {
|
|
318
|
+
const { email, password, name, organizationId } = body;
|
|
319
|
+
|
|
320
|
+
if (!email || !password || !name) {
|
|
321
|
+
set.status = 400;
|
|
322
|
+
return { error: 'Email, senha e nome são obrigatórios' }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const response = await fetchAuth(`${config.apiUrl}/auth/sign-up/email`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
body: JSON.stringify({
|
|
328
|
+
email,
|
|
329
|
+
password,
|
|
330
|
+
name,
|
|
331
|
+
...(organizationId && { organizationId }),
|
|
332
|
+
}),
|
|
333
|
+
headers: {
|
|
334
|
+
'X-API-Key': config.secretKey,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
message: 'Usuário registrado com sucesso!',
|
|
340
|
+
user: {
|
|
341
|
+
id: response.user?.id,
|
|
342
|
+
email: response.user?.email,
|
|
343
|
+
name: response.user?.name,
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
set.status = 400;
|
|
348
|
+
return { error: 'Registration failed', message: error.message }
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Rota de logout
|
|
353
|
+
.post('/logout', async ({ cookie, set, headers }) => {
|
|
354
|
+
try {
|
|
355
|
+
const token = headers.authorization?.replace('Bearer ', '') || cookie[config.cookieName]?.value;
|
|
356
|
+
|
|
357
|
+
if (token) {
|
|
358
|
+
// Fazer logout no servidor de auth
|
|
359
|
+
await fetchAuth(`${config.apiUrl}/auth/sign-out`, {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: {
|
|
362
|
+
Authorization: `Bearer ${token}`,
|
|
363
|
+
'X-API-Key': config.secretKey,
|
|
364
|
+
},
|
|
365
|
+
}).catch(() => {
|
|
366
|
+
// Ignorar erros do logout remoto
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Remover cookie local
|
|
371
|
+
cookie[config.cookieName].remove();
|
|
372
|
+
|
|
373
|
+
return { message: 'Logout realizado com sucesso!' }
|
|
374
|
+
} catch (error) {
|
|
375
|
+
set.status = 400;
|
|
376
|
+
return { error: 'Erro ao fazer logout', message: error.message }
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// Verificar sessão (substitui o /refresh)
|
|
381
|
+
.get('/session', ({ user, authMeta, set }) => {
|
|
382
|
+
if (!user) {
|
|
383
|
+
set.status = 401;
|
|
384
|
+
return { error: 'Not authenticated' }
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
user,
|
|
388
|
+
meta: authMeta,
|
|
389
|
+
verified_at: new Date().toISOString(),
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Status do usuário atual
|
|
394
|
+
.get('/me', ({ user, set }) => {
|
|
395
|
+
if (!user) {
|
|
396
|
+
set.status = 401;
|
|
397
|
+
return { error: 'Not authenticated' }
|
|
398
|
+
}
|
|
399
|
+
return { user }
|
|
400
|
+
})
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export { DEFAULT_CONFIG, RiLiGarAuthClient, authPlugin, authPlugin as default, fetchAuth };
|