@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.
- package/bin/authly.js +3 -1
- package/dist/dashboard/app.js +182 -32
- package/dist/dashboard/authorize.html +117 -0
- package/dist/dashboard/index.html +192 -15
- package/dist/dashboard/styles.css +10 -0
- package/package.json +3 -2
- package/src/auth/index.js +98 -0
- package/src/commands/ext.js +107 -0
- package/src/commands/serve.js +218 -15
- package/src/generators/env.js +3 -2
- package/src/generators/migrations.js +33 -0
- package/src/integrations/supabase.js +156 -0
- package/src/lib/oauth.js +23 -1
- package/src/lib/supabase-api.js +152 -0
- package/src/lib/supabase-oauth.js +200 -0
|
@@ -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
|
+
}
|