@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.
@@ -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 };