@rblez/authly 0.1.0 → 0.3.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.
@@ -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
+ }