@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 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.1.0") },
14
+ version: { description: "Show version", handler: () => console.log("0.4.0") },
13
15
  };
14
16
 
15
17
  async function main() {
@@ -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(`/api${endpoint}`, {
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("/api/health");
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 24px; }
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>Choose an authentication method</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("/api/providers");
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'>&mdash;</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 = `/api/auth/${provider}/authorize`;
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 &amp; 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.2.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": ">=18.0.0"
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
+ }
@@ -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
+ }