@rblez/authly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/jwt.js ADDED
@@ -0,0 +1,107 @@
1
+ import { SignJWT, jwtVerify } from "jose";
2
+
3
+ /**
4
+ * Internal JWT utilities for Authly's own session management.
5
+ * Uses jose (native ESM, no native deps — works on Termux).
6
+ *
7
+ * Tokens are signed with AUTHLY_SECRET (HS256) and carry
8
+ * a minimal payload: { sub, role, iat, exp }.
9
+ */
10
+
11
+ const ALG = "HS256";
12
+ const DEFAULT_TTL = 1000 * 60 * 60 * 24; // 24h
13
+
14
+ /**
15
+ * Get a CryptoKey from the AUTHLY_SECRET env var.
16
+ *
17
+ * @returns {Promise<CryptoKey>}
18
+ */
19
+ async function getSigningKey() {
20
+ const secret = process.env.AUTHLY_SECRET;
21
+ if (!secret) {
22
+ throw new Error("AUTHLY_SECRET is not configured");
23
+ }
24
+ return new TextEncoder().encode(secret);
25
+ }
26
+
27
+ /**
28
+ * Create a signed JWT for internal authly sessions.
29
+ *
30
+ * @param {{ sub: string; role: string }} payload
31
+ * @param {{ ttl?: number }} [options]
32
+ * @returns {Promise<string>}
33
+ */
34
+ export async function createSessionToken(payload, options = {}) {
35
+ const secret = await getSigningKey();
36
+ const ttl = options.ttl ?? DEFAULT_TTL;
37
+
38
+ return new SignJWT({ role: payload.role })
39
+ .setProtectedHeader({ alg: ALG })
40
+ .setSubject(payload.sub)
41
+ .setIssuedAt()
42
+ .setExpirationTime(Date.now() + ttl)
43
+ .sign(secret);
44
+ }
45
+
46
+ /**
47
+ * Verify and decode an internal session token.
48
+ *
49
+ * @param {string} token
50
+ * @returns {Promise<{ sub: string; role: string } | null>}
51
+ */
52
+ export async function verifySessionToken(token) {
53
+ const secret = await getSigningKey();
54
+
55
+ try {
56
+ const { payload } = await jwtVerify(token, secret);
57
+ return {
58
+ sub: payload.sub,
59
+ role: payload.role ?? "user",
60
+ };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Create a bearer middleware for hono that validates the
68
+ * Authorization header against our internal session tokens.
69
+ *
70
+ * @returns {import("hono").MiddlewareHandler}
71
+ */
72
+ export function authMiddleware() {
73
+ return async (c, next) => {
74
+ const header = c.req.header("Authorization");
75
+ if (!header?.startsWith("Bearer ")) {
76
+ return c.json({ error: "Missing or invalid Authorization header" }, 401);
77
+ }
78
+
79
+ const session = await verifySessionToken(header.slice(7));
80
+ if (!session) {
81
+ return c.json({ error: "Invalid or expired token" }, 401);
82
+ }
83
+
84
+ c.set("session", session);
85
+ return next();
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Create a role-check wrapper for hono handlers.
91
+ * Requires a valid session with the specified role.
92
+ *
93
+ * @param {string} requiredRole
94
+ * @returns {import("hono").MiddlewareHandler}
95
+ */
96
+ export function requireRole(requiredRole) {
97
+ return async (c, next) => {
98
+ const session = c.get("session");
99
+ if (!session) {
100
+ return c.json({ error: "Unauthorized" }, 401);
101
+ }
102
+ if (session.role !== requiredRole && session.role !== "admin") {
103
+ return c.json({ error: "Forbidden: insufficient permissions" }, 403);
104
+ }
105
+ return next();
106
+ };
107
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Authly OAuth Provider SDK
3
+ *
4
+ * Handles the full OAuth2 authorization code flow directly —
5
+ * no Supabase auth dependency. Like next-auth but as a standalone
6
+ * SDK that works with Supabase for user storage.
7
+ *
8
+ * Flow:
9
+ * 1. buildAuthorizeUrl() → redirect user to provider
10
+ * 2. User authorizes → provider redirects to callback
11
+ * 3. exchangeTokens() → get access_token from provider
12
+ * 4. fetchUserInfo() → get email/name from provider
13
+ * 5. upsertUser() → create/update user in Supabase
14
+ * 6. createSessionToken() → Authly JWT session
15
+ */
16
+
17
+ import { createClient } from "@supabase/supabase-js";
18
+
19
+ const PROVIDERS = {
20
+ google: {
21
+ authorize: "https://accounts.google.com/o/oauth2/v2/auth",
22
+ token: "https://oauth2.googleapis.com/token",
23
+ userinfo: "https://www.googleapis.com/oauth2/v2/userinfo",
24
+ scopeDefault: "openid email profile",
25
+ },
26
+ github: {
27
+ authorize: "https://github.com/login/oauth/authorize",
28
+ token: "https://github.com/login/oauth/access_token",
29
+ userinfo: "https://api.github.com/user",
30
+ scopeDefault: "read:user user:email",
31
+ },
32
+ discord: {
33
+ authorize: "https://discord.com/api/oauth2/authorize",
34
+ token: "https://discord.com/api/oauth2/token",
35
+ userinfo: "https://discord.com/api/users/@me",
36
+ scopeDefault: "identify email",
37
+ },
38
+ };
39
+
40
+ /**
41
+ * Build the OAuth authorization URL.
42
+ *
43
+ * @param {{ provider: string; redirectUri: string; state?: string; scope?: string }} opts
44
+ * @returns {{ url: string; state: string }}
45
+ */
46
+ export function buildAuthorizeUrl(opts) {
47
+ const config = PROVIDERS[opts.provider.toLowerCase()];
48
+ if (!config) throw new Error(`Unknown provider: ${opts.provider}`);
49
+
50
+ const state = opts.state || crypto.randomUUID();
51
+
52
+ const clientId = process.env[`${opts.provider.toUpperCase()}_CLIENT_ID`];
53
+ if (!clientId) throw new Error(`${opts.provider} CLIENT_ID not configured`);
54
+
55
+ const params = new URLSearchParams({
56
+ client_id: clientId,
57
+ redirect_uri: opts.redirectUri,
58
+ response_type: "code",
59
+ scope: opts.scope || config.scopeDefault,
60
+ state,
61
+ });
62
+
63
+ if (opts.provider.toLowerCase() === "google") {
64
+ params.set("access_type", "offline");
65
+ params.set("prompt", "consent");
66
+ }
67
+
68
+ return { url: `${config.authorize}?${params.toString()}`, state };
69
+ }
70
+
71
+ /**
72
+ * Exchange authorization code for an access token.
73
+ *
74
+ * @param {{ provider: string; code: string; redirectUri: string }} opts
75
+ * @returns {Promise<{ accessToken: string; scope: string; extra: Record<string, unknown> }>}
76
+ */
77
+ export async function exchangeTokens(opts) {
78
+ const config = PROVIDERS[opts.provider.toLowerCase()];
79
+ if (!config) throw new Error(`Unknown provider: ${opts.provider}`);
80
+
81
+ const clientId = process.env[`${opts.provider.toUpperCase()}_CLIENT_ID`];
82
+ const clientSecret = process.env[`${opts.provider.toUpperCase()}_CLIENT_SECRET`];
83
+ if (!clientId || !clientSecret) {
84
+ throw new Error(`${opts.provider} CLIENT_ID and CLIENT_SECRET must be configured`);
85
+ }
86
+
87
+ const body = new URLSearchParams({
88
+ code: opts.code,
89
+ client_id: clientId,
90
+ client_secret: clientSecret,
91
+ grant_type: "authorization_code",
92
+ redirect_uri: opts.redirectUri,
93
+ });
94
+
95
+ const headers = {
96
+ "Content-Type": "application/x-www-form-urlencoded",
97
+ Accept: "application/json",
98
+ };
99
+
100
+ // GitHub requires Accept header; Google doesn't want it in some cases
101
+ if (opts.provider.toLowerCase() === "github") {
102
+ headers.Accept = "application/json";
103
+ }
104
+
105
+ const res = await fetch(config.token, { method: "POST", headers, body });
106
+
107
+ if (!res.ok) {
108
+ const text = await res.text();
109
+ throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 300)}`);
110
+ }
111
+
112
+ const data = await res.json();
113
+
114
+ if (data.error) {
115
+ throw new Error(`OAuth error: ${data.error} — ${data.error_description || ""}`);
116
+ }
117
+
118
+ return {
119
+ accessToken: data.access_token,
120
+ refreshToken: data.refresh_token,
121
+ expiresIn: data.expires_in,
122
+ scope: data.scope || "",
123
+ extra: data,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Fetch user info (email, name, avatar) from the provider.
129
+ *
130
+ * @param {{ provider: string; accessToken: string }} opts
131
+ * @returns {Promise<{ email: string; name?: string; avatar?: string; id?: string }>}
132
+ */
133
+ export async function fetchUserInfo(opts) {
134
+ const config = PROVIDERS[opts.provider.toLowerCase()];
135
+ if (!config) throw new Error(`Unknown provider: ${opts.provider}`);
136
+
137
+ const headers = {
138
+ Authorization: `Bearer ${opts.accessToken}`,
139
+ };
140
+
141
+ if (opts.provider.toLowerCase() === "github") {
142
+ headers.Accept = "application/vnd.github+json";
143
+ headers["X-GitHub-Api-Version"] = "2022-11-28";
144
+ }
145
+
146
+ const res = await fetch(config.userinfo, { headers });
147
+ if (!res.ok) throw new Error(`Userinfo failed (${res.status})`);
148
+
149
+ const data = await res.json();
150
+
151
+ // Normalize across providers
152
+ return {
153
+ providerId: String(data.sub ?? data.id ?? ""),
154
+ email: String(
155
+ data.email ??
156
+ (data.emails?.[0]?.email) ??
157
+ ""
158
+ ),
159
+ name: String(data.name ?? data.display_name ?? data.login ?? data.username ?? ""),
160
+ avatar: String(data.picture ?? data.avatar_url ?? data.avatar ?? ""),
161
+ raw: data,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Upsert the user into Supabase's auth.users table via the
167
+ * Management API, then return the Supabase user record.
168
+ *
169
+ * If the user doesn't exist, creates them with the provider
170
+ * linked. If they exist with the same email, links the provider.
171
+ *
172
+ * @param {{ provider: string; providerId: string; email: string; name?: string; avatar?: string }} opts
173
+ * @returns {Promise<{ userId: string; email: string; isNew: boolean }>}
174
+ */
175
+ export async function upsertUser(opts) {
176
+ const { client, errors } = _getClient();
177
+ if (!client) throw new Error(`Supabase not configured: ${errors.join(", ")}`);
178
+
179
+ // Check if user already exists by email
180
+ const { data: existing } = await client
181
+ .from("user_profiles")
182
+ .select("user_id")
183
+ .eq("email", opts.email)
184
+ .single();
185
+
186
+ if (existing) {
187
+ // Update profile
188
+ await client
189
+ .from("user_profiles")
190
+ .update({
191
+ avatar_url: opts.avatar || "",
192
+ updated_at: new Date().toISOString(),
193
+ provider: opts.provider,
194
+ provider_id: opts.providerId,
195
+ })
196
+ .eq("user_id", existing.user_id);
197
+
198
+ return { userId: existing.user_id, email: opts.email, isNew: false };
199
+ }
200
+
201
+ // Create user — since we can't directly insert into auth.users,
202
+ // we use the Supabase admin createUser method
203
+ // For now, we insert into user_profiles and let the app handle
204
+ // the auth.users linkage via a database function
205
+ const { data: newUser, error } = await client
206
+ .from("user_profiles")
207
+ .insert({
208
+ email: opts.email,
209
+ full_name: opts.name || "",
210
+ avatar_url: opts.avatar || "",
211
+ provider: opts.provider,
212
+ provider_id: opts.providerId,
213
+ })
214
+ .select("user_id")
215
+ .single();
216
+
217
+ if (error) throw new Error(`Failed to create user: ${error.message}`);
218
+
219
+ return { userId: newUser.user_id, email: opts.email, isNew: true };
220
+ }
221
+
222
+ /**
223
+ * Full OAuth login flow — all steps in one call.
224
+ * Used by the Next.js auth callback route.
225
+ *
226
+ * @param {{ provider: string; code: string; redirectUri: string }} opts
227
+ * @returns {Promise<{ userId: string; email: string; token: string; isNew: boolean }>}
228
+ */
229
+ export async function authWithProvider(opts) {
230
+ const tokens = await exchangeTokens(opts);
231
+ const info = await fetchUserInfo({ provider: opts.provider, accessToken: tokens.accessToken });
232
+
233
+ if (!info.email && !info.providerId) {
234
+ throw new Error("Provider returned no email and no ID");
235
+ }
236
+
237
+ const user = await upsertUser({
238
+ provider: opts.provider,
239
+ providerId: info.providerId,
240
+ email: info.email,
241
+ name: info.name,
242
+ avatar: info.avatar,
243
+ });
244
+
245
+ return {
246
+ userId: user.userId,
247
+ email: user.email,
248
+ isNew: user.isNew,
249
+ provider: opts.provider,
250
+ tokens,
251
+ info: {
252
+ name: info.name,
253
+ avatar: info.avatar,
254
+ },
255
+ };
256
+ }
257
+
258
+ /**
259
+ * List all configured providers and their status.
260
+ *
261
+ * @returns {{ name: string; enabled: boolean; scopes: string }[]}
262
+ */
263
+ export function listProviderStatus() {
264
+ return Object.keys(PROVIDERS).map((name) => {
265
+ const upper = name.toUpperCase();
266
+ const clientId = process.env[`${upper}_CLIENT_ID`] || "";
267
+ const clientSecret = process.env[`${upper}_CLIENT_SECRET`] || "";
268
+ return {
269
+ name,
270
+ enabled: !!(clientId && clientSecret),
271
+ scopes: PROVIDERS[name].scopeDefault,
272
+ };
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Add a custom provider at runtime.
278
+ *
279
+ * @param {{ name: string; authorize: string; token: string; userinfo: string; scope: string }} config
280
+ */
281
+ export function addProvider(config) {
282
+ PROVIDERS[config.name.toLowerCase()] = {
283
+ authorize: config.authorize,
284
+ token: config.token,
285
+ userinfo: config.userinfo,
286
+ scopeDefault: config.scope,
287
+ };
288
+ }
289
+
290
+ // ── Internal helpers ──────────────────────────────────
291
+
292
+ function _getClient() {
293
+ const url = process.env.SUPABASE_URL;
294
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
295
+
296
+ if (!url || !key) {
297
+ return { client: null, errors: ["Supabase credentials not configured"] };
298
+ }
299
+
300
+ return { client: createClient(url, key), errors: [] };
301
+ }
@@ -0,0 +1,58 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export function getSupabaseClient() {
6
+ const errors = [];
7
+ let url = process.env.SUPABASE_URL;
8
+ let anonKey = process.env.SUPABASE_ANON_KEY;
9
+ let serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
10
+
11
+ if (!url || !anonKey) {
12
+ const config = _loadConfig();
13
+ if (config) {
14
+ url ||= config.supabase?.url || "";
15
+ anonKey ||= config.supabase?.anonKey || "";
16
+ serviceKey ||= config.supabase?.serviceRoleKey || "";
17
+ }
18
+ }
19
+
20
+ if (!url) errors.push("SUPABASE_URL is not configured");
21
+ if (!anonKey) errors.push("SUPABASE_ANON_KEY is not configured");
22
+ if (errors.length > 0) return { client: null, errors };
23
+
24
+ const client = createClient(url, anonKey, {
25
+ global: {
26
+ headers: serviceKey
27
+ ? { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` }
28
+ : {},
29
+ },
30
+ });
31
+
32
+ return { client, errors: [] };
33
+ }
34
+
35
+ /**
36
+ * List users from the authly_users table.
37
+ * This is the primary user source — not Supabase auth.users.
38
+ */
39
+ export async function fetchUsers({ limit = 50 } = {}) {
40
+ const { client, errors } = getSupabaseClient();
41
+ if (!client) return { users: [], error: errors.join(", ") };
42
+
43
+ const { data, error } = await client
44
+ .from("authly_users")
45
+ .select("id, email, name, avatar_url, email_verified, created_at")
46
+ .order("created_at", { ascending: false })
47
+ .limit(limit);
48
+
49
+ if (error) return { users: [], error: error.message };
50
+ return { users: data ?? [], error: null };
51
+ }
52
+
53
+ function _loadConfig() {
54
+ const p = path.join(process.cwd(), "authly.config.json");
55
+ if (!fs.existsSync(p)) return null;
56
+ try { return JSON.parse(fs.readFileSync(p, "utf-8")); }
57
+ catch { return null; }
58
+ }