@rblez/authly 0.1.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/README.md +215 -0
- package/bin/authly.js +53 -0
- package/dist/dashboard/app.js +326 -0
- package/dist/dashboard/index.html +238 -0
- package/dist/dashboard/styles.css +742 -0
- package/package.json +48 -0
- package/src/auth/index.js +134 -0
- package/src/commands/audit.js +82 -0
- package/src/commands/init.js +67 -0
- package/src/commands/serve.js +383 -0
- package/src/generators/env.js +37 -0
- package/src/generators/migrations.js +138 -0
- package/src/generators/roles.js +67 -0
- package/src/generators/ui.js +619 -0
- package/src/lib/framework.js +29 -0
- package/src/lib/jwt.js +107 -0
- package/src/lib/oauth.js +301 -0
- package/src/lib/supabase.js +58 -0
- package/src/mcp/server.js +281 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import { getSupabaseClient, fetchUsers } from "../lib/supabase.js";
|
|
10
|
+
import { createSessionToken, verifySessionToken, authMiddleware, requireRole } from "../lib/jwt.js";
|
|
11
|
+
import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback } from "../auth/index.js";
|
|
12
|
+
import { buildAuthorizeUrl, exchangeTokens, listProviderStatus } from "../lib/oauth.js";
|
|
13
|
+
import {
|
|
14
|
+
createRole,
|
|
15
|
+
assignRoleToUser,
|
|
16
|
+
revokeRoleFromUser,
|
|
17
|
+
getUserRoles,
|
|
18
|
+
listRoles,
|
|
19
|
+
} from "../generators/roles.js";
|
|
20
|
+
import { listMigrations, getMigration, migrations } from "../generators/migrations.js";
|
|
21
|
+
import { detectFramework } from "../lib/framework.js";
|
|
22
|
+
import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
|
|
23
|
+
import { generateEnv } from "../generators/env.js";
|
|
24
|
+
import { mountMcp } from "../mcp/server.js";
|
|
25
|
+
|
|
26
|
+
const PORT = process.env.AUTHLY_PORT || 1284;
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
export async function cmdServe() {
|
|
30
|
+
const spinner = ora("Starting authly dashboard…").start();
|
|
31
|
+
|
|
32
|
+
const app = new Hono();
|
|
33
|
+
|
|
34
|
+
// ── Static file serving ────────────────────────────
|
|
35
|
+
const dashboardPath = path.join(__dirname, "../../dist/dashboard");
|
|
36
|
+
const hasDashboard = fs.existsSync(dashboardPath);
|
|
37
|
+
|
|
38
|
+
if (hasDashboard) {
|
|
39
|
+
app.use("/*", async (c, next) => {
|
|
40
|
+
const filePath = path.join(dashboardPath, c.req.path.replace(/^\//, ""));
|
|
41
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
42
|
+
c.header("Content-Type", getContentType(filePath));
|
|
43
|
+
return c.body(fs.readFileSync(filePath));
|
|
44
|
+
}
|
|
45
|
+
return next();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Public API ─────────────────────────────────────
|
|
50
|
+
app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
|
|
51
|
+
|
|
52
|
+
/** GET /api/users — list all users from Supabase */
|
|
53
|
+
app.get("/api/users", async (c) => {
|
|
54
|
+
const { client, errors } = getSupabaseClient();
|
|
55
|
+
if (!client) return c.json({ success: false, errors }, 503);
|
|
56
|
+
const { users, error } = await fetchUsers(client);
|
|
57
|
+
if (error) return c.json({ success: false, error }, 500);
|
|
58
|
+
return c.json({ success: true, users, count: users.length });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** GET /api/providers — list OAuth providers and their status */
|
|
62
|
+
app.get("/api/providers", (c) =>
|
|
63
|
+
c.json({ providers: listProviderStatus() }),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** POST /api/auth/:provider/authorize — get OAuth URL */
|
|
67
|
+
app.post("/api/auth/:provider/authorize", async (c) => {
|
|
68
|
+
const { provider } = c.req.param();
|
|
69
|
+
const body = await c.req.json();
|
|
70
|
+
const result = buildAuthorizeUrl({
|
|
71
|
+
provider,
|
|
72
|
+
redirectUri: body.redirectUri,
|
|
73
|
+
state: body.state,
|
|
74
|
+
scope: body.scope,
|
|
75
|
+
});
|
|
76
|
+
if (result.error) return c.json({ error: result.error }, 400);
|
|
77
|
+
return c.json(result);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** POST /api/auth/ :provider/callback — exchange code for session */
|
|
81
|
+
app.post("/api/auth/:provider/callback", async (c) => {
|
|
82
|
+
const { provider } = c.req.param();
|
|
83
|
+
const body = await c.req.json();
|
|
84
|
+
const { session, error } = await exchangeOAuthCode({
|
|
85
|
+
provider,
|
|
86
|
+
code: body.code,
|
|
87
|
+
redirectUri: body.redirectUri,
|
|
88
|
+
});
|
|
89
|
+
if (error) return c.json({ success: false, error }, 400);
|
|
90
|
+
|
|
91
|
+
// Create an internal Authly session token too
|
|
92
|
+
const token = await createSessionToken({
|
|
93
|
+
sub: session.user?.id ?? "unknown",
|
|
94
|
+
role: session.user?.user_metadata?.role ?? "user",
|
|
95
|
+
});
|
|
96
|
+
return c.json({ success: true, session, token });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** POST /api/auth/session — create a session token from credentials */
|
|
100
|
+
app.post("/api/auth/session", async (c) => {
|
|
101
|
+
const body = await c.req.json();
|
|
102
|
+
if (!body.sub) return c.json({ error: "Missing 'sub' field" }, 400);
|
|
103
|
+
const token = await createSessionToken({
|
|
104
|
+
sub: body.sub,
|
|
105
|
+
role: body.role ?? "user",
|
|
106
|
+
});
|
|
107
|
+
return c.json({ success: true, token });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** GET /api/auth/me — verify session token from Authorization header */
|
|
111
|
+
app.get("/api/auth/me", authMiddleware(), (c) =>
|
|
112
|
+
c.json({ success: true, session: c.get("session") }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// ── Password Auth ──────────────────────────────────
|
|
116
|
+
/** POST /api/auth/register — sign up with email + password */
|
|
117
|
+
app.post("/api/auth/register", async (c) => {
|
|
118
|
+
const body = await c.req.json();
|
|
119
|
+
const { user, token, error } = await signUp({
|
|
120
|
+
email: body.email,
|
|
121
|
+
password: body.password,
|
|
122
|
+
name: body.name ?? "",
|
|
123
|
+
});
|
|
124
|
+
if (error) return c.json({ success: false, error }, 400);
|
|
125
|
+
return c.json({ success: true, user, token });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** POST /api/auth/login — sign in with email + password */
|
|
129
|
+
app.post("/api/auth/login", async (c) => {
|
|
130
|
+
const body = await c.req.json();
|
|
131
|
+
const { user, token, error } = await signIn({
|
|
132
|
+
email: body.email,
|
|
133
|
+
password: body.password,
|
|
134
|
+
});
|
|
135
|
+
if (error) return c.json({ success: false, error }, 401);
|
|
136
|
+
return c.json({ success: true, user, token });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** GET /api/auth/providers — list available and enabled providers */
|
|
140
|
+
app.get("/api/auth/providers", (c) => {
|
|
141
|
+
const providers = getProviders();
|
|
142
|
+
return c.json({ providers });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** GET /api/config — non-sensitive project config */
|
|
146
|
+
app.get("/api/config", async (c) => {
|
|
147
|
+
const fw = detectFramework();
|
|
148
|
+
const { roles = [], error } = await listRoles();
|
|
149
|
+
return c.json({
|
|
150
|
+
framework: fw ?? null,
|
|
151
|
+
providers: listProviderStatus(),
|
|
152
|
+
roles,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── Role management (protected) ────────────────────
|
|
157
|
+
/** GET /api/roles — list all roles */
|
|
158
|
+
app.get("/api/roles", async (c) => {
|
|
159
|
+
const { roles, error } = await listRoles();
|
|
160
|
+
if (error) return c.json({ success: false, error }, 500);
|
|
161
|
+
return c.json({ success: true, roles });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** POST /api/roles — create a new role */
|
|
165
|
+
app.post("/api/roles", async (c) => {
|
|
166
|
+
const { name, description } = await c.req.json();
|
|
167
|
+
if (!name) return c.json({ error: "'name' is required" }, 400);
|
|
168
|
+
const result = await createRole(name, description ?? "");
|
|
169
|
+
if (!result.success) return c.json(result, 400);
|
|
170
|
+
return c.json(result);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** POST /api/roles/:roleId/users/:userId/assign — assign role to user */
|
|
174
|
+
app.post("/api/roles/:roleName/users/:userId/assign", async (c) => {
|
|
175
|
+
const { roleName, userId } = c.req.param();
|
|
176
|
+
const result = await assignRoleToUser(userId, roleName);
|
|
177
|
+
if (!result.success) return c.json(result, 400);
|
|
178
|
+
return c.json(result);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** DELETE /api/roles/:roleId/users/:userId/revoke — revoke role from user */
|
|
182
|
+
app.delete("/api/roles/:roleName/users/:userId/revoke", async (c) => {
|
|
183
|
+
const { roleName, userId } = c.req.param();
|
|
184
|
+
const result = await revokeRoleFromUser(userId, roleName);
|
|
185
|
+
if (!result.success) return c.json(result, 400);
|
|
186
|
+
return c.json(result);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/** GET /api/users/:userId/roles — get roles for a user */
|
|
190
|
+
app.get("/api/users/:userId/roles", async (c) => {
|
|
191
|
+
const { userId } = c.req.param();
|
|
192
|
+
const { roles, error } = await getUserRoles(userId);
|
|
193
|
+
if (error) return c.json({ success: false, error }, 500);
|
|
194
|
+
return c.json({ success: true, userId, roles });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── API Keys ───────────────────────────────────────
|
|
198
|
+
/** POST /api/keys — generate a new API key */
|
|
199
|
+
app.post("/api/keys", async (c) => {
|
|
200
|
+
const { client, errors } = getSupabaseClient();
|
|
201
|
+
if (!client) return c.json({ success: false, errors }, 503);
|
|
202
|
+
|
|
203
|
+
const body = await c.req.json();
|
|
204
|
+
if (!body.name) return c.json({ error: "'name' is required" }, 400);
|
|
205
|
+
|
|
206
|
+
const rawKey = `authly_${randomBytes(24).toString("base64url")}`;
|
|
207
|
+
const keyHash = createHash("sha256").update(rawKey).digest("hex");
|
|
208
|
+
|
|
209
|
+
const { error } = await client.from("api_keys").insert({
|
|
210
|
+
key_hash: keyHash,
|
|
211
|
+
name: body.name,
|
|
212
|
+
scopes: body.scopes ?? ["read"],
|
|
213
|
+
user_id: body.userId ?? null,
|
|
214
|
+
expires_at: body.expiresAt ?? null,
|
|
215
|
+
});
|
|
216
|
+
if (error) return c.json({ success: false, error: error.message }, 400);
|
|
217
|
+
|
|
218
|
+
// Return raw key ONCE — it cannot be retrieved later
|
|
219
|
+
return c.json({ success: true, key: rawKey, hashesTo: keyHash });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Migrations ─────────────────────────────────────
|
|
223
|
+
/** GET /api/migrations — list available migrations */
|
|
224
|
+
app.get("/api/migrations", (c) =>
|
|
225
|
+
c.json({ success: true, migrations: listMigrations() }),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
/** GET /api/migrations/:name/sql — get SQL for a migration */
|
|
229
|
+
app.get("/api/migrations/:name/sql", (c) => {
|
|
230
|
+
const { name } = c.req.param();
|
|
231
|
+
const sql = getMigration(name);
|
|
232
|
+
if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
|
|
233
|
+
return c.json({ success: true, name, sql });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/** POST /api/migrations/:name/run — execute a migration against Supabase */
|
|
237
|
+
app.post("/api/migrations/:name/run", async (c) => {
|
|
238
|
+
const { client, errors } = getSupabaseClient();
|
|
239
|
+
if (!client) return c.json({ success: false, errors }, 503);
|
|
240
|
+
|
|
241
|
+
const { name } = c.req.param();
|
|
242
|
+
const sql = getMigration(name);
|
|
243
|
+
if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
|
|
244
|
+
|
|
245
|
+
const { error } = await client.rpc("exec_sql", { sql_query: sql });
|
|
246
|
+
if (error) return c.json({ success: false, error: error.message }, 400);
|
|
247
|
+
return c.json({ success: true, migration: name });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── Scaffold ───────────────────────────────────────
|
|
251
|
+
/** POST /api/scaffold/preview — preview generated code without writing */
|
|
252
|
+
app.post("/api/scaffold/preview", async (c) => {
|
|
253
|
+
const { type } = await c.req.json();
|
|
254
|
+
if (!["login", "signup", "middleware", "route-login", "route-signup"].includes(type)) {
|
|
255
|
+
return c.json({ error: `Unknown type '${type}'. Valid: login, signup, middleware, route-login, route-signup` }, 400);
|
|
256
|
+
}
|
|
257
|
+
return c.json({ success: true, type, code: previewGenerated(type) });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/** POST /api/scaffold/generate — write auth files to a project */
|
|
261
|
+
app.post("/api/scaffold/generate", async (c) => {
|
|
262
|
+
const { targetDir } = await c.req.json();
|
|
263
|
+
if (!targetDir) return c.json({ error: "'targetDir' is required" }, 400);
|
|
264
|
+
try {
|
|
265
|
+
const { files } = await scaffoldAuth(targetDir, { apiRoutes: true });
|
|
266
|
+
c.json({ success: true, files });
|
|
267
|
+
} catch (e) {
|
|
268
|
+
c.json({ success: false, error: e.message }, 500);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── Init ──────────────────────────────────────────
|
|
273
|
+
/** POST /api/init/connect — detect project and generate config */
|
|
274
|
+
app.post("/api/init/connect", async (c) => {
|
|
275
|
+
const body = await c.req.json().catch(() => ({}));
|
|
276
|
+
const fw = detectFramework();
|
|
277
|
+
if (!fw) return c.json({ success: false, error: "No Next.js project detected" }, 400);
|
|
278
|
+
|
|
279
|
+
// Store Supabase config if provided
|
|
280
|
+
if (body.supabaseUrl) process.env.SUPABASE_URL = body.supabaseUrl;
|
|
281
|
+
if (body.supabaseAnonKey) process.env.SUPABASE_ANON_KEY = body.supabaseAnonKey;
|
|
282
|
+
if (body.supabaseServiceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = body.supabaseServiceKey;
|
|
283
|
+
|
|
284
|
+
// Generate .env.local if needed
|
|
285
|
+
if (!fs.existsSync(".env.local")) {
|
|
286
|
+
await generateEnv(".env.local");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Write authly.config.json
|
|
290
|
+
const config = {
|
|
291
|
+
framework: fw,
|
|
292
|
+
supabase: {
|
|
293
|
+
url: body.supabaseUrl || "",
|
|
294
|
+
anonKey: body.supabaseAnonKey ? "set" : "",
|
|
295
|
+
serviceKey: body.supabaseServiceKey ? "set" : "",
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
fs.writeFileSync("authly.config.json", JSON.stringify(config, null, 2) + "\n");
|
|
299
|
+
|
|
300
|
+
return c.json({ success: true, framework: fw });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── MCP (beta) ─────────────────────────────────────
|
|
304
|
+
mountMcp(app);
|
|
305
|
+
|
|
306
|
+
// ── Audit ──────────────────────────────────────────
|
|
307
|
+
app.post("/api/audit", async (c) => {
|
|
308
|
+
const issues = [];
|
|
309
|
+
|
|
310
|
+
// Env vars
|
|
311
|
+
for (const key of ["SUPABASE_URL", "SUPABASE_ANON_KEY", "AUTHLY_SECRET"]) {
|
|
312
|
+
if (!process.env[key]) issues.push({ check: key, status: "fail", detail: "not set" });
|
|
313
|
+
else issues.push({ check: key, status: "ok" });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Supabase connection
|
|
317
|
+
const { client } = getSupabaseClient();
|
|
318
|
+
if (client) {
|
|
319
|
+
const { error } = await client
|
|
320
|
+
.from("auth.users")
|
|
321
|
+
.select("count", { count: "exact", head: true });
|
|
322
|
+
if (error)
|
|
323
|
+
issues.push({ check: "supabase_connection", status: "fail", detail: error.message });
|
|
324
|
+
else
|
|
325
|
+
issues.push({ check: "supabase_connection", status: "ok" });
|
|
326
|
+
} else {
|
|
327
|
+
issues.push({ check: "supabase_connection", status: "fail", detail: "not configured" });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const allOk = issues.every((i) => i.status === "ok");
|
|
331
|
+
return c.json({ success: allOk, issues });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── Root ───────────────────────────────────────────
|
|
335
|
+
app.get("/", (c) => {
|
|
336
|
+
if (hasDashboard) {
|
|
337
|
+
const html = fs.readFileSync(path.join(dashboardPath, "index.html"), "utf-8");
|
|
338
|
+
return c.html(html);
|
|
339
|
+
}
|
|
340
|
+
return c.json({
|
|
341
|
+
name: "authly",
|
|
342
|
+
version: "0.1.0",
|
|
343
|
+
docs: "/api/health",
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── Start server ───────────────────────────────────
|
|
348
|
+
if (hasDashboard) {
|
|
349
|
+
spinner.succeed(
|
|
350
|
+
`Authly dashboard running at ${chalk.cyan(`http://localhost:${PORT}`)}`,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
spinner.succeed(
|
|
354
|
+
`Authly API running at ${chalk.cyan(`http://localhost:${PORT}`)}${chalk.dim(" (no dashboard UI)")}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
console.log(chalk.dim(" Press Ctrl+C to stop\n"));
|
|
358
|
+
|
|
359
|
+
const server = serve({
|
|
360
|
+
fetch: app.fetch,
|
|
361
|
+
port: Number(PORT),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
process.on("SIGINT", () => {
|
|
365
|
+
spinner.info("Shutting down authly dashboard");
|
|
366
|
+
server.close();
|
|
367
|
+
process.exit(0);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function getContentType(filePath) {
|
|
372
|
+
const ext = path.extname(filePath);
|
|
373
|
+
const types = {
|
|
374
|
+
".html": "text/html",
|
|
375
|
+
".css": "text/css",
|
|
376
|
+
".js": "text/javascript",
|
|
377
|
+
".json": "application/json",
|
|
378
|
+
".png": "image/png",
|
|
379
|
+
".svg": "image/svg+xml",
|
|
380
|
+
".ico": "image/x-icon",
|
|
381
|
+
};
|
|
382
|
+
return types[ext] || "application/octet-stream";
|
|
383
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a .env.local template with all authly-related variables.
|
|
5
|
+
*/
|
|
6
|
+
export async function generateEnv(envPath) {
|
|
7
|
+
const template = `# Authly Configuration - Generated by authly init
|
|
8
|
+
# https://github.com/rblez/authly
|
|
9
|
+
|
|
10
|
+
# Supabase (required)
|
|
11
|
+
# Get these from your Supabase project settings
|
|
12
|
+
SUPABASE_URL=""
|
|
13
|
+
SUPABASE_ANON_KEY=""
|
|
14
|
+
SUPABASE_SERVICE_ROLE_KEY=""
|
|
15
|
+
|
|
16
|
+
# Authly (required)
|
|
17
|
+
# Random secret for signing internal JWT tokens
|
|
18
|
+
# Generate with: openssl rand -hex 32
|
|
19
|
+
AUTHLY_SECRET=""
|
|
20
|
+
|
|
21
|
+
# OAuth Providers (optional)
|
|
22
|
+
# Google OAuth
|
|
23
|
+
GOOGLE_CLIENT_ID=""
|
|
24
|
+
GOOGLE_CLIENT_SECRET=""
|
|
25
|
+
|
|
26
|
+
# GitHub OAuth
|
|
27
|
+
GITHUB_CLIENT_ID=""
|
|
28
|
+
GITHUB_CLIENT_SECRET=""
|
|
29
|
+
|
|
30
|
+
# Magic Link (optional)
|
|
31
|
+
# RESEND_API_KEY=""
|
|
32
|
+
|
|
33
|
+
# Dashboard (optional overrides)
|
|
34
|
+
# AUTHLY_PORT=1284
|
|
35
|
+
`;
|
|
36
|
+
fs.writeFileSync(envPath, template, "utf-8");
|
|
37
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL migration templates for Authly's Supabase schema.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const migrations = [
|
|
6
|
+
{
|
|
7
|
+
name: "001_create_roles_table",
|
|
8
|
+
description: "Core RBAC — role definitions in database",
|
|
9
|
+
sql: `
|
|
10
|
+
CREATE TABLE IF NOT EXISTS public.roles (
|
|
11
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
12
|
+
name text UNIQUE NOT NULL,
|
|
13
|
+
description text DEFAULT '',
|
|
14
|
+
created_at timestamptz DEFAULT now(),
|
|
15
|
+
updated_at timestamptz DEFAULT now()
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
INSERT INTO public.roles (name, description) VALUES
|
|
19
|
+
('admin', 'Full access to all resources'),
|
|
20
|
+
('user', 'Standard authenticated user'),
|
|
21
|
+
('guest', 'Limited access, no write permissions')
|
|
22
|
+
ON CONFLICT (name) DO NOTHING;
|
|
23
|
+
`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "002_create_authly_users_table",
|
|
27
|
+
description: "Authly user pool — manages users independent of Supabase auth",
|
|
28
|
+
sql: `
|
|
29
|
+
CREATE TABLE IF NOT EXISTS public.authly_users (
|
|
30
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
31
|
+
email text UNIQUE NOT NULL,
|
|
32
|
+
password_hash text, -- NULL for OAuth-only users
|
|
33
|
+
name text DEFAULT '',
|
|
34
|
+
avatar_url text DEFAULT '',
|
|
35
|
+
email_verified boolean DEFAULT false,
|
|
36
|
+
created_at timestamptz DEFAULT now(),
|
|
37
|
+
updated_at timestamptz DEFAULT now()
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE INDEX idx_authly_users_email ON public.authly_users(email);
|
|
41
|
+
`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "003_create_oauth_accounts_table",
|
|
45
|
+
description: "Links OAuth providers to authly_users",
|
|
46
|
+
sql: `
|
|
47
|
+
CREATE TABLE IF NOT EXISTS public.authly_oauth_accounts (
|
|
48
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
49
|
+
user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
|
|
50
|
+
provider text NOT NULL, -- 'google', 'github', 'discord'
|
|
51
|
+
provider_id text NOT NULL, -- provider's user ID
|
|
52
|
+
access_token text,
|
|
53
|
+
refresh_token text,
|
|
54
|
+
expires_at timestamptz,
|
|
55
|
+
created_at timestamptz DEFAULT now(),
|
|
56
|
+
updated_at timestamptz DEFAULT now(),
|
|
57
|
+
UNIQUE(provider, provider_id)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE INDEX idx_oauth_user_id ON public.authly_oauth_accounts(user_id);
|
|
61
|
+
`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "004_create_user_roles_table",
|
|
65
|
+
description: "RBAC — link authly_users to roles",
|
|
66
|
+
sql: `
|
|
67
|
+
CREATE TABLE IF NOT EXISTS public.authly_user_roles (
|
|
68
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
69
|
+
user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
|
|
70
|
+
role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
|
|
71
|
+
assigned_at timestamptz DEFAULT now(),
|
|
72
|
+
UNIQUE(user_id, role_id)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
-- Trigger: auto-assign 'user' role on new user
|
|
76
|
+
CREATE OR REPLACE FUNCTION public.authly_assign_default_role()
|
|
77
|
+
RETURNS trigger AS $$
|
|
78
|
+
BEGIN
|
|
79
|
+
INSERT INTO public.authly_user_roles (user_id, role_id)
|
|
80
|
+
SELECT NEW.id, r.id
|
|
81
|
+
FROM public.roles r
|
|
82
|
+
WHERE r.name = 'user'
|
|
83
|
+
ON CONFLICT DO NOTHING;
|
|
84
|
+
RETURN NEW;
|
|
85
|
+
END;
|
|
86
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
87
|
+
|
|
88
|
+
CREATE OR REPLACE TRIGGER on_authly_user_created
|
|
89
|
+
AFTER INSERT ON public.authly_users
|
|
90
|
+
FOR EACH ROW
|
|
91
|
+
EXECUTE FUNCTION public.authly_assign_default_role();
|
|
92
|
+
`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "005_create_api_keys_table",
|
|
96
|
+
description: "Programmatic access keys with scopes",
|
|
97
|
+
sql: `
|
|
98
|
+
CREATE TABLE IF NOT EXISTS public.authly_api_keys (
|
|
99
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
100
|
+
key_hash text UNIQUE NOT NULL,
|
|
101
|
+
name text NOT NULL,
|
|
102
|
+
scopes text[] DEFAULT '{read}',
|
|
103
|
+
user_id uuid REFERENCES public.authly_users(id) ON DELETE SET NULL,
|
|
104
|
+
created_at timestamptz DEFAULT now(),
|
|
105
|
+
expires_at timestamptz,
|
|
106
|
+
revoked_at timestamptz
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
CREATE INDEX idx_api_keys_hash ON public.authly_api_keys(key_hash);
|
|
110
|
+
`,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "006_create_user_sessions_table",
|
|
114
|
+
description: "Session tracking for active users",
|
|
115
|
+
sql: `
|
|
116
|
+
CREATE TABLE IF NOT EXISTS public.authly_sessions (
|
|
117
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
118
|
+
user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
|
|
119
|
+
token_hash text UNIQUE NOT NULL,
|
|
120
|
+
ip_address text,
|
|
121
|
+
user_agent text,
|
|
122
|
+
expires_at timestamptz NOT NULL,
|
|
123
|
+
created_at timestamptz DEFAULT now()
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE INDEX idx_sessions_user ON public.authly_sessions(user_id);
|
|
127
|
+
CREATE INDEX idx_sessions_token ON public.authly_sessions(token_hash);
|
|
128
|
+
`,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export function getMigration(name) {
|
|
133
|
+
return migrations.find((m) => m.name === name)?.sql ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function listMigrations() {
|
|
137
|
+
return migrations.map(({ name, description }) => ({ name, description }));
|
|
138
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role management — authly_users + roles tables.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createClient } from "@supabase/supabase-js";
|
|
6
|
+
|
|
7
|
+
function _getClient() {
|
|
8
|
+
const url = process.env.SUPABASE_URL;
|
|
9
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
|
|
10
|
+
if (!url || !key) return { client: null, errors: ["Supabase not configured"] };
|
|
11
|
+
return { client: createClient(url, key), errors: [] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function createRole(name, description = "") {
|
|
15
|
+
const { client, errors } = _getClient();
|
|
16
|
+
if (!client) return { success: false, error: errors.join("; ") };
|
|
17
|
+
|
|
18
|
+
const { error } = await client.from("roles").insert({ name, description });
|
|
19
|
+
if (error) return { success: false, error: error.message };
|
|
20
|
+
return { success: true, error: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function assignRoleToUser(userId, roleName) {
|
|
24
|
+
const { client, errors } = _getClient();
|
|
25
|
+
if (!client) return { success: false, error: errors.join("; ") };
|
|
26
|
+
|
|
27
|
+
const { data: role } = await client.from("roles").select("id").eq("name", roleName).single();
|
|
28
|
+
if (!role) return { success: false, error: `Role '${roleName}' not found` };
|
|
29
|
+
|
|
30
|
+
const { error } = await client.from("authly_user_roles").upsert({ user_id: userId, role_id: role.id });
|
|
31
|
+
if (error) return { success: false, error: error.message };
|
|
32
|
+
return { success: true, error: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function revokeRoleFromUser(userId, roleName) {
|
|
36
|
+
const { client, errors } = _getClient();
|
|
37
|
+
if (!client) return { success: false, error: errors.join("; ") };
|
|
38
|
+
|
|
39
|
+
const { data: role } = await client.from("roles").select("id").eq("name", roleName).single();
|
|
40
|
+
if (!role) return { success: false, error: `Role '${roleName}' not found` };
|
|
41
|
+
|
|
42
|
+
const { error } = await client.from("authly_user_roles").delete().eq("user_id", userId).eq("role_id", role.id);
|
|
43
|
+
if (error) return { success: false, error: error.message };
|
|
44
|
+
return { success: true, error: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getUserRoles(userId) {
|
|
48
|
+
const { client, errors } = _getClient();
|
|
49
|
+
if (!client) return { roles: [], error: errors.join("; ") };
|
|
50
|
+
|
|
51
|
+
const { data, error } = await client
|
|
52
|
+
.from("authly_user_roles")
|
|
53
|
+
.select("roles(name)")
|
|
54
|
+
.eq("user_id", userId);
|
|
55
|
+
|
|
56
|
+
if (error) return { roles: [], error: error.message };
|
|
57
|
+
return { roles: data.map((d) => d.roles?.name ?? "").filter(Boolean), error: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function listRoles() {
|
|
61
|
+
const { client, errors } = _getClient();
|
|
62
|
+
if (!client) return { roles: [], error: errors.join("; ") };
|
|
63
|
+
|
|
64
|
+
const { data, error } = await client.from("roles").select("name, description").order("name");
|
|
65
|
+
if (error) return { roles: [], error: error.message };
|
|
66
|
+
return { roles: data ?? [], error: null };
|
|
67
|
+
}
|