@rblez/authly 0.2.0 → 0.4.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/bin/authly.js +3 -1
- package/dist/dashboard/app.js +5 -2
- package/dist/dashboard/authorize.html +42 -5
- package/dist/dashboard/index.html +2 -1
- package/package.json +3 -2
- package/src/commands/ext.js +107 -0
- package/src/commands/serve.js +138 -1
- package/src/generators/migrations.js +16 -0
- package/src/lib/supabase-api.js +152 -0
- package/src/lib/supabase-oauth.js +200 -0
package/bin/authly.js
CHANGED
|
@@ -3,13 +3,15 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import { cmdServe } from "../src/commands/serve.js";
|
|
4
4
|
import { cmdInit } from "../src/commands/init.js";
|
|
5
5
|
import { cmdAudit } from "../src/commands/audit.js";
|
|
6
|
+
import { cmdExt } from "../src/commands/ext.js";
|
|
6
7
|
import chalk from "chalk";
|
|
7
8
|
|
|
8
9
|
const COMMANDS = {
|
|
9
10
|
serve: { description: "Start the local auth dashboard", handler: cmdServe },
|
|
10
11
|
init: { description: "Initialize authly in your project", handler: cmdInit },
|
|
12
|
+
ext: { description: "Manage extensions (add, remove)", handler: cmdExt },
|
|
11
13
|
audit: { description: "Check auth configuration for issues", handler: cmdAudit },
|
|
12
|
-
version: { description: "Show version", handler: () => console.log("0.
|
|
14
|
+
version: { description: "Show version", handler: () => console.log("0.4.0") },
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
async function main() {
|
package/dist/dashboard/app.js
CHANGED
|
@@ -7,11 +7,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
7
7
|
const statusText = document.getElementById("statusText");
|
|
8
8
|
const statusDot = document.querySelector(".header__dot");
|
|
9
9
|
|
|
10
|
+
// All API calls go to the hosted authly instance
|
|
11
|
+
const API_URL = "https://authly.rblez.com/api";
|
|
12
|
+
|
|
10
13
|
checkHealth();
|
|
11
14
|
|
|
12
15
|
// ── Helpers ─────────────────────────────────────────
|
|
13
16
|
async function api(endpoint, opts = {}) {
|
|
14
|
-
const res = await fetch(
|
|
17
|
+
const res = await fetch(`${API_URL}${endpoint}`, {
|
|
15
18
|
headers: { "Content-Type": "application/json" },
|
|
16
19
|
...opts,
|
|
17
20
|
});
|
|
@@ -40,7 +43,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
40
43
|
// ── Health ──────────────────────────────────────────
|
|
41
44
|
async function checkHealth() {
|
|
42
45
|
try {
|
|
43
|
-
const res = await fetch(
|
|
46
|
+
const res = await fetch(`${API_URL}/health`);
|
|
44
47
|
if (res.ok) {
|
|
45
48
|
statusText.textContent = "Connected";
|
|
46
49
|
statusDot.style.background = "#22c55e";
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
padding: 32px 24px; text-align: center;
|
|
15
15
|
}
|
|
16
16
|
.auth-card h1 { font-size: 1.3rem; color: #fff; margin: 0 0 4px; }
|
|
17
|
-
.auth-card p { color: #888; font-size: .85rem; margin: 0 0
|
|
17
|
+
.auth-card p { color: #888; font-size: .85rem; margin: 0 0 12px; }
|
|
18
|
+
.section-label {
|
|
19
|
+
font-size: .7rem; color: #555; text-transform: uppercase;
|
|
20
|
+
letter-spacing: 1px; margin: 16px 0 8px; text-align: left;
|
|
21
|
+
}
|
|
18
22
|
.provider-btn {
|
|
19
23
|
display: flex; align-items: center; gap: 12px; width: 100%;
|
|
20
24
|
padding: 12px 16px; margin-bottom: 8px; border: 1px solid #222;
|
|
@@ -26,6 +30,15 @@
|
|
|
26
30
|
.provider-btn img { width: 20px; height: 20px; flex-shrink: 0; }
|
|
27
31
|
.provider-btn .label { flex: 1; text-align: left; text-transform: capitalize; }
|
|
28
32
|
.provider-btn .status { font-size: .7rem; color: #555; text-transform: uppercase; }
|
|
33
|
+
.platform-connect {
|
|
34
|
+
display: flex; align-items: center; gap: 12px; width: 100%;
|
|
35
|
+
padding: 12px 16px; margin-bottom: 8px; border: 1px solid #1d355e;
|
|
36
|
+
border-radius: 8px; background: #0d1b3e; color: #58a6ff;
|
|
37
|
+
font-size: .9rem; cursor: pointer; transition: border-color .2s;
|
|
38
|
+
text-decoration: none;
|
|
39
|
+
}
|
|
40
|
+
.platform-connect:hover { border-color: #58a6ff; }
|
|
41
|
+
.platform-connect.connected { border-color: #22c55e44; background: #0a1a0a; color: #22c55e; }
|
|
29
42
|
.back-link {
|
|
30
43
|
display: inline-block; margin-top: 16px; color: #555;
|
|
31
44
|
font-size: .8rem; text-decoration: none;
|
|
@@ -37,17 +50,30 @@
|
|
|
37
50
|
<div class="auth-container">
|
|
38
51
|
<div class="auth-card">
|
|
39
52
|
<h1><i class="ri-shield-keyhole-line"></i> Sign in</h1>
|
|
40
|
-
<p>
|
|
53
|
+
<p>Connect your Supabase project to enable authentication</p>
|
|
54
|
+
|
|
55
|
+
<!-- Platform: Connect Supabase (OAuth to Supabase API) -->
|
|
56
|
+
<div class="section-label">Platform connection</div>
|
|
57
|
+
<a href="https://authly.rblez.com/api/auth/supabase/authorize" id="supabasePlatformBtn" class="platform-connect">
|
|
58
|
+
<img src="https://cdn.simpleicons.org/supabase/fff" width="20" height="20" alt="Supabase" />
|
|
59
|
+
<span class="label">Connect Supabase</span>
|
|
60
|
+
<span class="status" id="sbStatus">Not connected</span>
|
|
61
|
+
</a>
|
|
62
|
+
|
|
63
|
+
<!-- Regular OAuth providers -->
|
|
64
|
+
<div class="section-label">App authentication</div>
|
|
41
65
|
<div id="providerList"></div>
|
|
42
66
|
</div>
|
|
43
67
|
<a href="/" class="back-link"><i class="ri-arrow-left-line"></i> Back to dashboard</a>
|
|
44
68
|
</div>
|
|
45
69
|
|
|
46
70
|
<script>
|
|
71
|
+
const API_URL = "https://authly.rblez.com/api";
|
|
72
|
+
|
|
47
73
|
async function loadProviders() {
|
|
48
74
|
const container = document.getElementById("providerList");
|
|
49
75
|
try {
|
|
50
|
-
const res = await fetch(
|
|
76
|
+
const res = await fetch(`${API_URL}/providers`);
|
|
51
77
|
const data = await res.json();
|
|
52
78
|
if (!data.providers) { container.innerHTML = "<p style='color:#555'>—</p>"; return; }
|
|
53
79
|
|
|
@@ -62,18 +88,29 @@
|
|
|
62
88
|
</div>`;
|
|
63
89
|
}).join("");
|
|
64
90
|
|
|
65
|
-
// Attach click handlers
|
|
66
91
|
container.querySelectorAll(".provider-btn[data-enabled='true']").forEach(el => {
|
|
67
92
|
el.addEventListener("click", () => {
|
|
68
93
|
const provider = el.dataset.provider;
|
|
69
|
-
window.location.href =
|
|
94
|
+
window.location.href = `${API_URL}/auth/${provider}/authorize`;
|
|
70
95
|
});
|
|
71
96
|
});
|
|
72
97
|
} catch {
|
|
73
98
|
container.innerHTML = "<p style='color:#555'>Failed to load providers</p>";
|
|
74
99
|
}
|
|
75
100
|
}
|
|
101
|
+
|
|
102
|
+
async function checkSupabaseConnected() {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${API_URL}/health`);
|
|
105
|
+
if (res.ok) {
|
|
106
|
+
document.getElementById("sbStatus").textContent = "Connected";
|
|
107
|
+
document.getElementById("supabasePlatformBtn").classList.add("connected");
|
|
108
|
+
}
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
76
112
|
loadProviders();
|
|
113
|
+
checkSupabaseConnected();
|
|
77
114
|
</script>
|
|
78
115
|
</body>
|
|
79
116
|
</html>
|
|
@@ -185,8 +185,9 @@
|
|
|
185
185
|
<h2>Supabase connection</h2>
|
|
186
186
|
<div id="integrationStatus" style="font-size:.85rem;color:#888">Checking…</div>
|
|
187
187
|
<div id="integrationDetail" class="hidden" style="margin-top:12px"></div>
|
|
188
|
-
<div style="margin-top:16px">
|
|
188
|
+
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
|
|
189
189
|
<button class="btn btn--primary btn--sm" id="reconnectBtn"><i class="ri-refresh-line"></i> Re-scan & reconnect</button>
|
|
190
|
+
<a href="https://authly.rblez.com/api/auth/supabase/authorize" target="_blank" class="btn btn--sm" style="background:#0d1b3e;border:1px solid #1d355e;color:#58a6ff;text-decoration:none" id="connectSbPlatformBtn"><i class="ri-plug-line"></i> Connect via OAuth</a>
|
|
190
191
|
</div>
|
|
191
192
|
</div>
|
|
192
193
|
</section>
|
package/package.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rblez/authly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Local auth dashboard for Next.js + Supabase",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"authly": "./bin/authly.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
+
"start": "node bin/authly.js serve",
|
|
10
11
|
"dev": "node bin/authly.js serve",
|
|
11
12
|
"lint": "prettier --check src/",
|
|
12
13
|
"format": "prettier --write src/"
|
|
13
14
|
},
|
|
14
15
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
16
|
+
"node": ">=20.0.0"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"bin/",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { detectFramework } from "../lib/framework.js";
|
|
6
|
+
import { scanSupabase } from "../integrations/supabase.js";
|
|
7
|
+
import { generateEnv } from "../generators/env.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `authly ext add <name>` — add an extension to the project.
|
|
11
|
+
* Currently supports: supabase
|
|
12
|
+
*/
|
|
13
|
+
export async function cmdExt(args) {
|
|
14
|
+
const subcmd = args[0];
|
|
15
|
+
const name = args[1];
|
|
16
|
+
|
|
17
|
+
if (!subcmd || subcmd === "add") {
|
|
18
|
+
if (!name) {
|
|
19
|
+
console.log(chalk.bold("\n Authly Extensions — add\n"));
|
|
20
|
+
console.log(chalk.bold(" Usage:"));
|
|
21
|
+
console.log(` npx @rblez/authly ext add <name>\n`);
|
|
22
|
+
console.log(chalk.bold(" Available:"));
|
|
23
|
+
console.log(` ${chalk.cyan("supabase")} Auto-detect & connect Supabase, show auth URL\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (name === "supabase") {
|
|
28
|
+
return cmdExtAddSupabase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.error(chalk.red(`\n Unknown extension: ${name}\n`));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.error(chalk.red(`\n Unknown subcommand: ${subcmd}\n`));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function cmdExtAddSupabase() {
|
|
40
|
+
console.log(chalk.bold("\n Authly — Supabase Extension\n"));
|
|
41
|
+
|
|
42
|
+
// Detect framework
|
|
43
|
+
const framework = detectFramework();
|
|
44
|
+
if (!framework) {
|
|
45
|
+
console.log(chalk.yellow(" ⚠ No framework detected, but continuing anyway.\n"));
|
|
46
|
+
} else {
|
|
47
|
+
console.log(`${chalk.green("✔")} Detected framework: ${chalk.cyan(framework.name ?? "unknown")}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const projectRoot = path.resolve(process.cwd());
|
|
51
|
+
|
|
52
|
+
// Scan for Supabase credentials
|
|
53
|
+
const spinner = ora("Scanning project for Supabase credentials").start();
|
|
54
|
+
const scan = scanSupabase(projectRoot);
|
|
55
|
+
|
|
56
|
+
if (scan.detected) {
|
|
57
|
+
let detail = [];
|
|
58
|
+
if (scan.url) detail.push(chalk.green("URL found"));
|
|
59
|
+
if (scan.anonKey) detail.push(chalk.green("Anon key found"));
|
|
60
|
+
if (scan.serviceKey) detail.push(chalk.green("Service key found"));
|
|
61
|
+
if (scan.projectRef) detail.push(`Ref: ${scan.projectRef}`);
|
|
62
|
+
spinner.succeed(`Found credentials: ${detail.join(" · ")}`);
|
|
63
|
+
} else {
|
|
64
|
+
spinner.warn("No Supabase credentials found in env files");
|
|
65
|
+
console.log(chalk.yellow("\n You can still connect manually. Run `authly serve` for the dashboard.\n"));
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Write authly.config.json if not exists
|
|
70
|
+
const configPath = path.join(projectRoot, "authly.config.json");
|
|
71
|
+
if (!fs.existsSync(configPath)) {
|
|
72
|
+
const config = {
|
|
73
|
+
$schema: "https://raw.githubusercontent.com/rblez/authly/main/schema/config.json",
|
|
74
|
+
framework: framework?.name ?? "unknown",
|
|
75
|
+
port: 1284,
|
|
76
|
+
supabase: {
|
|
77
|
+
url: scan.url ?? "",
|
|
78
|
+
anonKey: scan.anonKey ? "set" : "",
|
|
79
|
+
serviceRoleKey: scan.serviceKey ? "set" : "",
|
|
80
|
+
projectRef: scan.projectRef ?? "",
|
|
81
|
+
},
|
|
82
|
+
providers: {},
|
|
83
|
+
roles: ["admin", "user", "guest"],
|
|
84
|
+
};
|
|
85
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
86
|
+
console.log(`${chalk.green("✔")} Created authly.config.json`);
|
|
87
|
+
} else {
|
|
88
|
+
console.log(`${chalk.yellow("•")} authly.config.json already exists`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generate .env.local if not exists
|
|
92
|
+
const envPath = path.join(projectRoot, ".env.local");
|
|
93
|
+
if (!fs.existsSync(envPath)) {
|
|
94
|
+
const envSpinner = ora("Generating .env.local").start();
|
|
95
|
+
await generateEnv(envPath);
|
|
96
|
+
envSpinner.succeed("Generated .env.local with authly variables");
|
|
97
|
+
} else {
|
|
98
|
+
console.log(`${chalk.yellow("•")} .env.local already exists`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Show auth URL
|
|
102
|
+
const port = process.env.AUTHLY_PORT || 1284;
|
|
103
|
+
const authUrl = `http://localhost:${port}/authorize`;
|
|
104
|
+
console.log(chalk.bold(`\n Ready! Start the dashboard and go to:`));
|
|
105
|
+
console.log(` ${chalk.cyan.underline(authUrl)}`);
|
|
106
|
+
console.log(chalk.dim(`\n Run: npx @rblez/authly serve\n`));
|
|
107
|
+
}
|
package/src/commands/serve.js
CHANGED
|
@@ -23,15 +23,35 @@ import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
|
|
|
23
23
|
import { generateEnv } from "../generators/env.js";
|
|
24
24
|
import { mountMcp } from "../mcp/server.js";
|
|
25
25
|
import { scanSupabase } from "../integrations/supabase.js";
|
|
26
|
+
import { generatePKCE, buildSupabaseAuthorizeUrl, exchangeSupabaseToken, saveSupabaseTokens } from "../lib/supabase-oauth.js";
|
|
27
|
+
import { ensureValidAccessToken, autoConfigureFromToken, getProjects, getProjectApiKeys } from "../lib/supabase-api.js";
|
|
26
28
|
|
|
27
|
-
const PORT = process.env.AUTHLY_PORT || 1284;
|
|
29
|
+
const PORT = process.env.PORT || process.env.AUTHLY_PORT || 1284;
|
|
28
30
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* In-memory PKCE state store (server-side, dev-only).
|
|
34
|
+
* Key: state token, Value: { verifier, codeChallenge }
|
|
35
|
+
* Survives no restart — fine for local dev.
|
|
36
|
+
*/
|
|
37
|
+
const pkceState = new Map();
|
|
38
|
+
|
|
30
39
|
export async function cmdServe() {
|
|
31
40
|
const spinner = ora("Starting authly dashboard…").start();
|
|
32
41
|
|
|
33
42
|
const app = new Hono();
|
|
34
43
|
|
|
44
|
+
// ── CORS — allow localhost to call the hosted API ──
|
|
45
|
+
app.use("/api/*", (c) => {
|
|
46
|
+
c.header("Access-Control-Allow-Origin", "*");
|
|
47
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
48
|
+
c.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
49
|
+
if (c.req.method === "OPTIONS") {
|
|
50
|
+
return new Response(null, { status: 204 });
|
|
51
|
+
}
|
|
52
|
+
return c.next();
|
|
53
|
+
});
|
|
54
|
+
|
|
35
55
|
// ── Static file serving ────────────────────────────
|
|
36
56
|
const dashboardPath = path.join(__dirname, "../../dist/dashboard");
|
|
37
57
|
const hasDashboard = fs.existsSync(dashboardPath);
|
|
@@ -95,6 +115,123 @@ export async function cmdServe() {
|
|
|
95
115
|
return c.json({ providers: listProviderStatus() });
|
|
96
116
|
});
|
|
97
117
|
|
|
118
|
+
// ── Supabase Platform OAuth with PKCE ───────────────
|
|
119
|
+
|
|
120
|
+
/** GET /api/auth/supabase/authorize — start Supabase OAuth flow */
|
|
121
|
+
app.get("/api/auth/supabase/authorize", async (c) => {
|
|
122
|
+
const query = c.req.query();
|
|
123
|
+
const { verifier, challenge } = generatePKCE();
|
|
124
|
+
const state = randomBytes(16).toString("hex");
|
|
125
|
+
|
|
126
|
+
// Store verifier for later exchange
|
|
127
|
+
pkceState.set(state, { verifier, challenge });
|
|
128
|
+
|
|
129
|
+
// Auto-expire state after 10 min
|
|
130
|
+
setTimeout(() => pkceState.delete(state), 10 * 60 * 1000);
|
|
131
|
+
|
|
132
|
+
const result = buildSupabaseAuthorizeUrl({
|
|
133
|
+
state,
|
|
134
|
+
codeChallenge: challenge,
|
|
135
|
+
organizationSlug: query.organization,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return c.redirect(result.url);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** GET /api/auth/supabase/callback — OAuth callback with PKCE */
|
|
142
|
+
app.get("/api/auth/supabase/callback", async (c) => {
|
|
143
|
+
const query = c.req.query();
|
|
144
|
+
const code = query.code;
|
|
145
|
+
const state = query.state;
|
|
146
|
+
|
|
147
|
+
// Validate state and get stored verifier
|
|
148
|
+
const stored = pkceState.get(state);
|
|
149
|
+
if (!stored) {
|
|
150
|
+
return c.html(`<h1>Invalid or expired state token</h1>
|
|
151
|
+
<p>Return to <a href="/">dashboard</a></p>`, 400);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
pkceState.delete(state);
|
|
155
|
+
|
|
156
|
+
if (!code) {
|
|
157
|
+
return c.html(`<h1>Authorization failed</h1>
|
|
158
|
+
<p>Return to <a href="/">dashboard</a></p>`, 400);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Exchange code for tokens
|
|
163
|
+
const tokens = await exchangeSupabaseToken({
|
|
164
|
+
code,
|
|
165
|
+
verifier: stored.verifier,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Save tokens to DB
|
|
169
|
+
await saveSupabaseTokens({
|
|
170
|
+
accessToken: tokens.access_token,
|
|
171
|
+
refreshToken: tokens.refresh_token,
|
|
172
|
+
expiresIn: tokens.expires_in,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Auto-configure: get project, extract keys, fill .env.local
|
|
176
|
+
const config = await autoConfigureFromToken(tokens.access_token);
|
|
177
|
+
|
|
178
|
+
if (config) {
|
|
179
|
+
return c.html(`<div style="font-family:sans-serif;max-width:500px;margin:60px auto;text-align:center">
|
|
180
|
+
<h1 style="color:#22c55e">Connected to Supabase</h1>
|
|
181
|
+
<p>Project: <strong>${config.projectName}</strong></p>
|
|
182
|
+
<p><code style="background:#eee;padding:2px 8px;border-radius:4px">${config.projectRef}</code></p>
|
|
183
|
+
<p style="color:#666">.env.local and authly.config.json have been updated.</p>
|
|
184
|
+
<a href="/" style="display:inline-block;margin-top:16px;padding:8px 24px;background:#111;color:#fff;text-decoration:none;border-radius:6px">Go to Dashboard</a>
|
|
185
|
+
</div>`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return c.html(`<div style="font-family:sans-serif;max-width:500px;margin:60px auto;text-align:center">
|
|
189
|
+
<h1 style="color:#22c55e">Authenticated</h1>
|
|
190
|
+
<p>Supabase tokens saved successfully.</p>
|
|
191
|
+
<p>Return to <a href="/">dashboard</a>.</p>
|
|
192
|
+
</div>`);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return c.html(`<h1>Token exchange failed</h1><p>${e.message}</p>
|
|
195
|
+
<p>Return to <a href="/">dashboard</a></p>`, 400);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** GET /api/supabase/projects — list Supabase projects */
|
|
200
|
+
app.get("/api/supabase/projects", async (c) => {
|
|
201
|
+
try {
|
|
202
|
+
const token = await ensureValidAccessToken();
|
|
203
|
+
if (!token) return c.json({ error: "Not connected to Supabase — visit /api/auth/supabase/authorize" }, 401);
|
|
204
|
+
const projects = await getProjects(token);
|
|
205
|
+
return c.json({ success: true, projects });
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return c.json({ error: e.message }, 500);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** GET /api/supabase/projects/:ref/keys — get API keys for a project */
|
|
212
|
+
app.get("/api/supabase/projects/:ref/keys", async (c) => {
|
|
213
|
+
const { ref } = c.req.param();
|
|
214
|
+
try {
|
|
215
|
+
const token = await ensureValidAccessToken();
|
|
216
|
+
if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
|
|
217
|
+
const keys = await getProjectApiKeys(token, ref);
|
|
218
|
+
return c.json({ success: true, keys });
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return c.json({ error: e.message }, 500);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/** POST /api/auth/supabase/refresh — refresh expired token */
|
|
225
|
+
app.post("/api/auth/supabase/refresh", async (c) => {
|
|
226
|
+
try {
|
|
227
|
+
const token = await ensureValidAccessToken();
|
|
228
|
+
if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
|
|
229
|
+
return c.json({ success: true });
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return c.json({ error: e.message }, 500);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
98
235
|
/** POST /api/auth/:provider/authorize — get OAuth URL as JSON */
|
|
99
236
|
app.post("/api/auth/:provider/authorize", async (c) => {
|
|
100
237
|
const { provider } = c.req.param();
|
|
@@ -142,6 +142,22 @@ CREATE TABLE IF NOT EXISTS public.authly_magic_links (
|
|
|
142
142
|
|
|
143
143
|
CREATE INDEX idx_magic_links_token ON public.authly_magic_links(token_hash);
|
|
144
144
|
CREATE INDEX idx_magic_links_user ON public.authly_magic_links(user_id);
|
|
145
|
+
`,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "008_create_supabase_tokens_table",
|
|
149
|
+
description: "Supabase Platform OAuth tokens — manage user's Supabase projects",
|
|
150
|
+
sql: `
|
|
151
|
+
CREATE TABLE IF NOT EXISTS public.authly_supabase_tokens (
|
|
152
|
+
user_id uuid PRIMARY KEY REFERENCES public.authly_users(id) ON DELETE CASCADE,
|
|
153
|
+
access_token text NOT NULL,
|
|
154
|
+
refresh_token text NOT NULL,
|
|
155
|
+
expires_at timestamptz NOT NULL,
|
|
156
|
+
project_ref text,
|
|
157
|
+
project_name text,
|
|
158
|
+
created_at timestamptz DEFAULT now(),
|
|
159
|
+
updated_at timestamptz DEFAULT now()
|
|
160
|
+
);
|
|
145
161
|
`,
|
|
146
162
|
},
|
|
147
163
|
];
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Management API client.
|
|
3
|
+
*
|
|
4
|
+
* Uses tokens from the Supabase OAuth flow to manage
|
|
5
|
+
* user's Supabase projects — get projects, extract API keys,
|
|
6
|
+
* auto-configure authly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getSupabaseClient } from "./supabase.js";
|
|
12
|
+
import { getSupabaseTokens, refreshSupabaseToken, saveSupabaseTokens } from "./supabase-oauth.js";
|
|
13
|
+
|
|
14
|
+
const SUPABASE_API = "https://api.supabase.com";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get a valid access token — refreshes if expired.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} userId
|
|
20
|
+
* @returns {Promise<string|null>}
|
|
21
|
+
*/
|
|
22
|
+
export async function ensureValidAccessToken(userId = "00000000-0000-0000-0000-000000000000") {
|
|
23
|
+
const tokens = await getSupabaseTokens(userId);
|
|
24
|
+
if (!tokens) return null;
|
|
25
|
+
|
|
26
|
+
// Check if expired (with 5 min buffer)
|
|
27
|
+
if (tokens.expiresAt && tokens.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
|
|
28
|
+
const refreshed = await refreshSupabaseToken(tokens.refreshToken);
|
|
29
|
+
await saveSupabaseTokens({
|
|
30
|
+
userId,
|
|
31
|
+
accessToken: refreshed.access_token,
|
|
32
|
+
refreshToken: refreshed.refresh_token,
|
|
33
|
+
expiresIn: refreshed.expires_in,
|
|
34
|
+
});
|
|
35
|
+
return refreshed.access_token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return tokens.accessToken;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List all Supabase projects for the authenticated user.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} accessToken
|
|
45
|
+
* @returns {Promise<Array<{ id: string; name: string; organization_id: string }>>}
|
|
46
|
+
*/
|
|
47
|
+
export async function getProjects(accessToken) {
|
|
48
|
+
const res = await fetch(`${SUPABASE_API}/v1/projects`, {
|
|
49
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const err = await res.text();
|
|
54
|
+
throw new Error(`Failed to get projects (${res.status}): ${err.slice(0, 200)}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get API keys for a specific project.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} accessToken
|
|
64
|
+
* @param {string} projectRef
|
|
65
|
+
* @returns {Promise<Array<{ name: string; type: string; api_key: string }>>}
|
|
66
|
+
*/
|
|
67
|
+
export async function getProjectApiKeys(accessToken, projectRef) {
|
|
68
|
+
const res = await fetch(`${SUPABASE_API}/v1/projects/${projectRef}/api-keys`, {
|
|
69
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const err = await res.text();
|
|
74
|
+
throw new Error(`Failed to get API keys (${res.status}): ${err.slice(0, 200)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return res.json();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Auto-configure authly from Supabase connection.
|
|
82
|
+
* 1. Get projects → pick the first one
|
|
83
|
+
* 2. Get API keys → extract anon key + service_role key
|
|
84
|
+
* 3. Fill .env.local and authly.config.json
|
|
85
|
+
*
|
|
86
|
+
* @param {string} accessToken
|
|
87
|
+
* @returns {Promise<{ projectRef: string; projectName: string; supabaseUrl: string } | null>}
|
|
88
|
+
*/
|
|
89
|
+
export async function autoConfigureFromToken(accessToken) {
|
|
90
|
+
const projects = await getProjects(accessToken);
|
|
91
|
+
if (!projects || projects.length === 0) return null;
|
|
92
|
+
|
|
93
|
+
const project = projects[0];
|
|
94
|
+
const projectRef = project.id;
|
|
95
|
+
const projectName = project.name;
|
|
96
|
+
const supabaseUrl = `https://${projectRef}.supabase.co`;
|
|
97
|
+
|
|
98
|
+
// Get API keys
|
|
99
|
+
const keys = await getProjectApiKeys(accessToken, projectRef);
|
|
100
|
+
|
|
101
|
+
const anonKey = keys.find((k) => k.type === "anon")?.api_key || "";
|
|
102
|
+
const serviceKey = keys.find((k) => k.type === "service_role")?.api_key || "";
|
|
103
|
+
|
|
104
|
+
// Update .env.local
|
|
105
|
+
const envPath = path.join(process.cwd(), ".env.local");
|
|
106
|
+
let envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "";
|
|
107
|
+
|
|
108
|
+
envContent = _updateEnvVar(envContent, "SUPABASE_URL", supabaseUrl);
|
|
109
|
+
envContent = _updateEnvVar(envContent, "SUPABASE_ANON_KEY", anonKey);
|
|
110
|
+
envContent = _updateEnvVar(envContent, "SUPABASE_SERVICE_ROLE_KEY", serviceKey);
|
|
111
|
+
|
|
112
|
+
fs.writeFileSync(envPath, envContent);
|
|
113
|
+
|
|
114
|
+
// Update authly.config.json
|
|
115
|
+
const configPath = path.join(process.cwd(), "authly.config.json");
|
|
116
|
+
if (fs.existsSync(configPath)) {
|
|
117
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
118
|
+
config.supabase = config.supabase || {};
|
|
119
|
+
config.supabase.url = supabaseUrl;
|
|
120
|
+
config.supabase.projectRef = projectRef;
|
|
121
|
+
config.supabase.autoDetected = false;
|
|
122
|
+
config.supabase.fromOAuth = true;
|
|
123
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Save project ref in tokens table
|
|
127
|
+
const { client } = getSupabaseClient();
|
|
128
|
+
if (client) {
|
|
129
|
+
await client
|
|
130
|
+
.from("authly_supabase_tokens")
|
|
131
|
+
.update({ project_ref: projectRef, project_name: projectName })
|
|
132
|
+
.eq("user_id", "00000000-0000-0000-0000-000000000000");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { projectRef, projectName, supabaseUrl };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update or add an env var in .env file content.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} content
|
|
142
|
+
* @param {string} key
|
|
143
|
+
* @param {string} value
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
function _updateEnvVar(content, key, value) {
|
|
147
|
+
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
148
|
+
if (regex.test(content)) {
|
|
149
|
+
return content.replace(regex, `${key}="${value}"`);
|
|
150
|
+
}
|
|
151
|
+
return content + `\n${key}="${value}"`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Platform OAuth with PKCE.
|
|
3
|
+
*
|
|
4
|
+
* Allows authly to authenticate a user's Supabase account
|
|
5
|
+
* and manage their projects via the Management API.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* 1. generatePKCE() → { verifier, challenge }
|
|
9
|
+
* 2. buildSupabaseAuthorizeUrl() → redirect to Supabase
|
|
10
|
+
* 3. exchangeSupabaseToken() → get access_token + refresh_token
|
|
11
|
+
* 4. saveSupabaseTokens() → store in authly_supabase_tokens table
|
|
12
|
+
* 5. refreshSupabaseToken() → get new access_token when expired
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
+
import { createClient } from "@supabase/supabase-js";
|
|
17
|
+
import { getSupabaseClient } from "./supabase.js";
|
|
18
|
+
|
|
19
|
+
const SUPABASE_API = "https://api.supabase.com";
|
|
20
|
+
const APP_URL = process.env.APP_URL || "http://localhost:1284";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate PKCE verifier and challenge.
|
|
24
|
+
*
|
|
25
|
+
* @returns {{ verifier: string; challenge: string }}
|
|
26
|
+
*/
|
|
27
|
+
export function generatePKCE() {
|
|
28
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
29
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
30
|
+
return { verifier, challenge };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the Supabase OAuth authorization URL with PKCE.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ clientId: string; state: string; codeChallenge: string; organizationSlug?: string }} opts
|
|
37
|
+
* @returns {{ url: string; state: string }}
|
|
38
|
+
*/
|
|
39
|
+
export function buildSupabaseAuthorizeUrl(opts) {
|
|
40
|
+
const clientId = opts.clientId || process.env.SUPABASE_OAUTH_CLIENT_ID;
|
|
41
|
+
if (!clientId) throw new Error("SUPABASE_OAUTH_CLIENT_ID is not configured");
|
|
42
|
+
|
|
43
|
+
const redirectUri = `${APP_URL}/api/auth/supabase/callback`;
|
|
44
|
+
const state = opts.state || randomBytes(16).toString("hex");
|
|
45
|
+
|
|
46
|
+
const params = new URLSearchParams({
|
|
47
|
+
response_type: "code",
|
|
48
|
+
client_id: clientId,
|
|
49
|
+
redirect_uri: redirectUri,
|
|
50
|
+
scope: "all",
|
|
51
|
+
state,
|
|
52
|
+
code_challenge: opts.codeChallenge,
|
|
53
|
+
code_challenge_method: "S256",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (opts.organizationSlug) {
|
|
57
|
+
params.set("organization_slug", opts.organizationSlug);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { url: `${SUPABASE_API}/v1/oauth/authorize?${params.toString()}`, state };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Exchange authorization code for tokens.
|
|
65
|
+
*
|
|
66
|
+
* @param {{ code: string; verifier: string }} opts
|
|
67
|
+
* @returns {Promise<{ access_token: string; refresh_token: string; expires_in: number }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function exchangeSupabaseToken(opts) {
|
|
70
|
+
const clientId = process.env.SUPABASE_OAUTH_CLIENT_ID;
|
|
71
|
+
const clientSecret = process.env.SUPABASE_OAUTH_CLIENT_SECRET;
|
|
72
|
+
|
|
73
|
+
if (!clientId || !clientSecret) {
|
|
74
|
+
throw new Error("SUPABASE_OAUTH_CLIENT_ID and SUPABASE_OAUTH_CLIENT_SECRET are not configured");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const redirectUri = `${APP_URL}/api/auth/supabase/callback`;
|
|
78
|
+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
79
|
+
|
|
80
|
+
const body = new URLSearchParams({
|
|
81
|
+
grant_type: "authorization_code",
|
|
82
|
+
code: opts.code,
|
|
83
|
+
redirect_uri: redirectUri,
|
|
84
|
+
code_verifier: opts.verifier,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const res = await fetch(`${SUPABASE_API}/v1/oauth/token`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
91
|
+
Authorization: `Basic ${credentials}`,
|
|
92
|
+
},
|
|
93
|
+
body: body.toString(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const err = await res.text();
|
|
98
|
+
throw new Error(`Token exchange failed (${res.status}): ${err.slice(0, 300)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
|
|
103
|
+
if (data.error) {
|
|
104
|
+
throw new Error(`OAuth error: ${data.error} — ${data.error_description || ""}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
access_token: data.access_token,
|
|
109
|
+
refresh_token: data.refresh_token,
|
|
110
|
+
expires_in: data.expires_in,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Refresh an expired access token.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} refreshToken
|
|
118
|
+
* @returns {Promise<{ access_token: string; refresh_token: string; expires_in: number }>}
|
|
119
|
+
*/
|
|
120
|
+
export async function refreshSupabaseToken(refreshToken) {
|
|
121
|
+
const clientId = process.env.SUPABASE_OAUTH_CLIENT_ID;
|
|
122
|
+
const clientSecret = process.env.SUPABASE_OAUTH_CLIENT_SECRET;
|
|
123
|
+
|
|
124
|
+
if (!clientId || !clientSecret) {
|
|
125
|
+
throw new Error("SUPABASE_OAUTH_CLIENT_ID and SUPABASE_OAUTH_CLIENT_SECRET are not configured");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
129
|
+
|
|
130
|
+
const body = new URLSearchParams({
|
|
131
|
+
grant_type: "refresh_token",
|
|
132
|
+
refresh_token: refreshToken,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const res = await fetch(`${SUPABASE_API}/v1/oauth/token`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
139
|
+
Authorization: `Basic ${credentials}`,
|
|
140
|
+
},
|
|
141
|
+
body: body.toString(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
throw new Error(`Token refresh failed (${res.status}) — user may have revoked access`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return res.json();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Save Supabase tokens to the database.
|
|
153
|
+
*
|
|
154
|
+
* @param {{ userId?: string; accessToken: string; refreshToken: string; expiresIn: number }} opts
|
|
155
|
+
* @returns {Promise<void>}
|
|
156
|
+
*/
|
|
157
|
+
export async function saveSupabaseTokens(opts) {
|
|
158
|
+
const { client, errors } = getSupabaseClient();
|
|
159
|
+
if (!client) throw new Error(`Supabase not configured: ${errors.join(", ")}`);
|
|
160
|
+
|
|
161
|
+
const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
|
|
162
|
+
const userId = opts.userId || "00000000-0000-0000-0000-000000000000";
|
|
163
|
+
|
|
164
|
+
const { error } = await client.from("authly_supabase_tokens").upsert({
|
|
165
|
+
user_id: userId,
|
|
166
|
+
access_token: opts.accessToken,
|
|
167
|
+
refresh_token: opts.refreshToken,
|
|
168
|
+
expires_at: expiresAt,
|
|
169
|
+
updated_at: new Date().toISOString(),
|
|
170
|
+
}, { onConflict: "user_id" });
|
|
171
|
+
|
|
172
|
+
if (error) throw new Error(`Failed to save tokens: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get stored Supabase tokens for a user.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} userId
|
|
179
|
+
* @returns {Promise<{ accessToken: string; refreshToken: string; expiresAt: Date | null } | null>}
|
|
180
|
+
*/
|
|
181
|
+
export async function getSupabaseTokens(userId) {
|
|
182
|
+
const { client, errors } = getSupabaseClient();
|
|
183
|
+
if (!client) return null;
|
|
184
|
+
|
|
185
|
+
const { data, error } = await client
|
|
186
|
+
.from("authly_supabase_tokens")
|
|
187
|
+
.select("access_token, refresh_token, expires_at, project_ref, project_name")
|
|
188
|
+
.eq("user_id", userId)
|
|
189
|
+
.single();
|
|
190
|
+
|
|
191
|
+
if (error || !data) return null;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
accessToken: data.access_token,
|
|
195
|
+
refreshToken: data.refresh_token,
|
|
196
|
+
expiresAt: data.expires_at ? new Date(data.expires_at) : null,
|
|
197
|
+
projectRef: data.project_ref,
|
|
198
|
+
projectName: data.project_name,
|
|
199
|
+
};
|
|
200
|
+
}
|