@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.
@@ -8,7 +8,7 @@ import chalk from "chalk";
8
8
  import ora from "ora";
9
9
  import { getSupabaseClient, fetchUsers } from "../lib/supabase.js";
10
10
  import { createSessionToken, verifySessionToken, authMiddleware, requireRole } from "../lib/jwt.js";
11
- import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback } from "../auth/index.js";
11
+ import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback, sendMagicLink, verifyMagicLink } from "../auth/index.js";
12
12
  import { buildAuthorizeUrl, exchangeTokens, listProviderStatus } from "../lib/oauth.js";
13
13
  import {
14
14
  createRole,
@@ -22,10 +22,20 @@ import { detectFramework } from "../lib/framework.js";
22
22
  import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
23
23
  import { generateEnv } from "../generators/env.js";
24
24
  import { mountMcp } from "../mcp/server.js";
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";
25
28
 
26
- const PORT = process.env.AUTHLY_PORT || 1284;
29
+ const PORT = process.env.PORT || process.env.AUTHLY_PORT || 1284;
27
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
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
+
29
39
  export async function cmdServe() {
30
40
  const spinner = ora("Starting authly dashboard…").start();
31
41
 
@@ -49,6 +59,13 @@ export async function cmdServe() {
49
59
  // ── Public API ─────────────────────────────────────
50
60
  app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
51
61
 
62
+ /** GET /api/integrations/supabase/scan — scan local project */
63
+ app.get("/api/integrations/supabase/scan", (c) => {
64
+ const projectRoot = path.resolve(process.cwd());
65
+ const scan = scanSupabase(projectRoot);
66
+ return c.json({ success: true, ...scan });
67
+ });
68
+
52
69
  /** GET /api/users — list all users from Supabase */
53
70
  app.get("/api/users", async (c) => {
54
71
  const { client, errors } = getSupabaseClient();
@@ -63,7 +80,148 @@ export async function cmdServe() {
63
80
  c.json({ providers: listProviderStatus() }),
64
81
  );
65
82
 
66
- /** POST /api/auth/:provider/authorize — get OAuth URL */
83
+ /** GET /api/auth/:provider/authorize — redirect to provider */
84
+ app.get("/api/auth/:provider/authorize", async (c) => {
85
+ const { provider } = c.req.param();
86
+ const query = c.req.query();
87
+ const redirectUri = query.redirectUri || `${c.req.url.split("/api/")[0]}/api/auth/${provider}/callback`;
88
+
89
+ try {
90
+ const result = buildAuthorizeUrl({ provider, redirectUri, state: query.state, scope: query.scope });
91
+ return c.redirect(result.url);
92
+ } catch (e) {
93
+ // Provider not found — redirect to authorize page
94
+ return c.redirect("/authorize");
95
+ }
96
+ });
97
+
98
+ /** GET /authorize — sign-in page listing available providers */
99
+ app.get("/authorize", (c) => {
100
+ if (hasDashboard) {
101
+ const html = fs.readFileSync(path.join(dashboardPath, "authorize.html"), "utf-8");
102
+ return c.html(html);
103
+ }
104
+ return c.json({ providers: listProviderStatus() });
105
+ });
106
+
107
+ // ── Supabase Platform OAuth with PKCE ───────────────
108
+
109
+ /** GET /api/auth/supabase/authorize — start Supabase OAuth flow */
110
+ app.get("/api/auth/supabase/authorize", async (c) => {
111
+ const query = c.req.query();
112
+ const { verifier, challenge } = generatePKCE();
113
+ const state = randomBytes(16).toString("hex");
114
+
115
+ // Store verifier for later exchange
116
+ pkceState.set(state, { verifier, challenge });
117
+
118
+ // Auto-expire state after 10 min
119
+ setTimeout(() => pkceState.delete(state), 10 * 60 * 1000);
120
+
121
+ const result = buildSupabaseAuthorizeUrl({
122
+ state,
123
+ codeChallenge: challenge,
124
+ organizationSlug: query.organization,
125
+ });
126
+
127
+ return c.redirect(result.url);
128
+ });
129
+
130
+ /** GET /api/auth/supabase/callback — OAuth callback with PKCE */
131
+ app.get("/api/auth/supabase/callback", async (c) => {
132
+ const query = c.req.query();
133
+ const code = query.code;
134
+ const state = query.state;
135
+
136
+ // Validate state and get stored verifier
137
+ const stored = pkceState.get(state);
138
+ if (!stored) {
139
+ return c.html(`<h1>Invalid or expired state token</h1>
140
+ <p>Return to <a href="/">dashboard</a></p>`, 400);
141
+ }
142
+
143
+ pkceState.delete(state);
144
+
145
+ if (!code) {
146
+ return c.html(`<h1>Authorization failed</h1>
147
+ <p>Return to <a href="/">dashboard</a></p>`, 400);
148
+ }
149
+
150
+ try {
151
+ // Exchange code for tokens
152
+ const tokens = await exchangeSupabaseToken({
153
+ code,
154
+ verifier: stored.verifier,
155
+ });
156
+
157
+ // Save tokens to DB
158
+ await saveSupabaseTokens({
159
+ accessToken: tokens.access_token,
160
+ refreshToken: tokens.refresh_token,
161
+ expiresIn: tokens.expires_in,
162
+ });
163
+
164
+ // Auto-configure: get project, extract keys, fill .env.local
165
+ const config = await autoConfigureFromToken(tokens.access_token);
166
+
167
+ if (config) {
168
+ return c.html(`<div style="font-family:sans-serif;max-width:500px;margin:60px auto;text-align:center">
169
+ <h1 style="color:#22c55e">Connected to Supabase</h1>
170
+ <p>Project: <strong>${config.projectName}</strong></p>
171
+ <p><code style="background:#eee;padding:2px 8px;border-radius:4px">${config.projectRef}</code></p>
172
+ <p style="color:#666">.env.local and authly.config.json have been updated.</p>
173
+ <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>
174
+ </div>`);
175
+ }
176
+
177
+ return c.html(`<div style="font-family:sans-serif;max-width:500px;margin:60px auto;text-align:center">
178
+ <h1 style="color:#22c55e">Authenticated</h1>
179
+ <p>Supabase tokens saved successfully.</p>
180
+ <p>Return to <a href="/">dashboard</a>.</p>
181
+ </div>`);
182
+ } catch (e) {
183
+ return c.html(`<h1>Token exchange failed</h1><p>${e.message}</p>
184
+ <p>Return to <a href="/">dashboard</a></p>`, 400);
185
+ }
186
+ });
187
+
188
+ /** GET /api/supabase/projects — list Supabase projects */
189
+ app.get("/api/supabase/projects", async (c) => {
190
+ try {
191
+ const token = await ensureValidAccessToken();
192
+ if (!token) return c.json({ error: "Not connected to Supabase — visit /api/auth/supabase/authorize" }, 401);
193
+ const projects = await getProjects(token);
194
+ return c.json({ success: true, projects });
195
+ } catch (e) {
196
+ return c.json({ error: e.message }, 500);
197
+ }
198
+ });
199
+
200
+ /** GET /api/supabase/projects/:ref/keys — get API keys for a project */
201
+ app.get("/api/supabase/projects/:ref/keys", async (c) => {
202
+ const { ref } = c.req.param();
203
+ try {
204
+ const token = await ensureValidAccessToken();
205
+ if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
206
+ const keys = await getProjectApiKeys(token, ref);
207
+ return c.json({ success: true, keys });
208
+ } catch (e) {
209
+ return c.json({ error: e.message }, 500);
210
+ }
211
+ });
212
+
213
+ /** POST /api/auth/supabase/refresh — refresh expired token */
214
+ app.post("/api/auth/supabase/refresh", async (c) => {
215
+ try {
216
+ const token = await ensureValidAccessToken();
217
+ if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
218
+ return c.json({ success: true });
219
+ } catch (e) {
220
+ return c.json({ error: e.message }, 500);
221
+ }
222
+ });
223
+
224
+ /** POST /api/auth/:provider/authorize — get OAuth URL as JSON */
67
225
  app.post("/api/auth/:provider/authorize", async (c) => {
68
226
  const { provider } = c.req.param();
69
227
  const body = await c.req.json();
@@ -142,6 +300,22 @@ export async function cmdServe() {
142
300
  return c.json({ providers });
143
301
  });
144
302
 
303
+ /** POST /api/auth/magic-link/send — send magic link email */
304
+ app.post("/api/auth/magic-link/send", async (c) => {
305
+ const body = await c.req.json();
306
+ const result = await sendMagicLink({ email: body.email, callbackUrl: body.callbackUrl || "/" });
307
+ if (result.error) return c.json({ success: false, error: result.error }, 400);
308
+ return c.json({ success: true, sent: true });
309
+ });
310
+
311
+ /** POST /api/auth/magic-link/verify — verify magic link token */
312
+ app.post("/api/auth/magic-link/verify", async (c) => {
313
+ const body = await c.req.json();
314
+ const { user, token, error } = await verifyMagicLink({ token: body.token });
315
+ if (error) return c.json({ success: false, error }, 401);
316
+ return c.json({ success: true, user, token });
317
+ });
318
+
145
319
  /** GET /api/config — non-sensitive project config */
146
320
  app.get("/api/config", async (c) => {
147
321
  const fw = detectFramework();
@@ -270,16 +444,33 @@ export async function cmdServe() {
270
444
  });
271
445
 
272
446
  // ── Init ──────────────────────────────────────────
273
- /** POST /api/init/connectdetect project and generate config */
447
+ /** GET /api/init/scanautodetect Supabase config in current project */
448
+ app.get("/api/init/scan", (c) => {
449
+ const projectRoot = path.resolve(process.cwd());
450
+ const scan = scanSupabase(projectRoot);
451
+ return c.json(scan);
452
+ });
453
+
454
+ /** POST /api/init/connect — connect with autodetected or manual config */
274
455
  app.post("/api/init/connect", async (c) => {
275
456
  const body = await c.req.json().catch(() => ({}));
276
- const fw = detectFramework();
277
- if (!fw) return c.json({ success: false, error: "No Next.js project detected" }, 400);
278
457
 
279
- // Store Supabase config if provided
280
- if (body.supabaseUrl) process.env.SUPABASE_URL = body.supabaseUrl;
281
- if (body.supabaseAnonKey) process.env.SUPABASE_ANON_KEY = body.supabaseAnonKey;
282
- if (body.supabaseServiceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = body.supabaseServiceKey;
458
+ // Auto-detect first
459
+ const projectRoot = path.resolve(process.cwd());
460
+ const scan = scanSupabase(projectRoot);
461
+
462
+ // Merge scan results with any manually provided values
463
+ const url = scan.url || body.supabaseUrl || process.env.SUPABASE_URL || "";
464
+ const anonKey = scan.anonKey || body.supabaseAnonKey || process.env.SUPABASE_ANON_KEY || "";
465
+ const serviceKey = scan.serviceKey || body.supabaseServiceKey || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
466
+
467
+ // Set env vars from detected config
468
+ if (url) process.env.SUPABASE_URL = url;
469
+ if (anonKey) process.env.SUPABASE_ANON_KEY = anonKey;
470
+ if (serviceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = serviceKey;
471
+
472
+ const fw = scan.framework || detectFramework();
473
+ if (!fw && !body.supabaseUrl) return c.json({ success: false, error: "No Next.js project detected" }, 400);
283
474
 
284
475
  // Generate .env.local if needed
285
476
  if (!fs.existsSync(".env.local")) {
@@ -288,16 +479,28 @@ export async function cmdServe() {
288
479
 
289
480
  // Write authly.config.json
290
481
  const config = {
291
- framework: fw,
482
+ framework: fw || "unknown",
292
483
  supabase: {
293
- url: body.supabaseUrl || "",
294
- anonKey: body.supabaseAnonKey ? "set" : "",
295
- serviceKey: body.supabaseServiceKey ? "set" : "",
484
+ url,
485
+ projectRef: scan.projectRef || "",
486
+ anonKey: anonKey ? "set" : "",
487
+ serviceKey: serviceKey ? "set" : "",
488
+ autoDetected: scan.detected,
489
+ sources: scan.sources,
296
490
  },
297
491
  };
298
492
  fs.writeFileSync("authly.config.json", JSON.stringify(config, null, 2) + "\n");
299
493
 
300
- return c.json({ success: true, framework: fw });
494
+ return c.json({
495
+ success: true,
496
+ framework: fw,
497
+ supabase: {
498
+ url,
499
+ detected: scan.detected,
500
+ canConnect: scan.canConnect,
501
+ sources: scan.sources,
502
+ },
503
+ });
301
504
  });
302
505
 
303
506
  // ── MCP (beta) ─────────────────────────────────────
@@ -27,8 +27,9 @@ GOOGLE_CLIENT_SECRET=""
27
27
  GITHUB_CLIENT_ID=""
28
28
  GITHUB_CLIENT_SECRET=""
29
29
 
30
- # Magic Link (optional)
31
- # RESEND_API_KEY=""
30
+ # Magic Link via Resend (optional)
31
+ RESEND_API_KEY=""
32
+ RESEND_FROM="noreply@authly.dev"
32
33
 
33
34
  # Dashboard (optional overrides)
34
35
  # AUTHLY_PORT=1284
@@ -125,6 +125,39 @@ CREATE TABLE IF NOT EXISTS public.authly_sessions (
125
125
 
126
126
  CREATE INDEX idx_sessions_user ON public.authly_sessions(user_id);
127
127
  CREATE INDEX idx_sessions_token ON public.authly_sessions(token_hash);
128
+ `,
129
+ },
130
+ {
131
+ name: "007_create_magic_links_table",
132
+ description: "Magic Link auth via Resend — one-time-use tokens",
133
+ sql: `
134
+ CREATE TABLE IF NOT EXISTS public.authly_magic_links (
135
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
136
+ user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
137
+ token_hash text UNIQUE NOT NULL,
138
+ expires_at timestamptz NOT NULL,
139
+ used boolean DEFAULT false,
140
+ created_at timestamptz DEFAULT now()
141
+ );
142
+
143
+ CREATE INDEX idx_magic_links_token ON public.authly_magic_links(token_hash);
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
+ );
128
161
  `,
129
162
  },
130
163
  ];
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Supabase auto-detection integration.
3
+ *
4
+ * Scans the local Next.js project for Supabase credentials.
5
+ * No PAT or manual input needed — Authly finds them in env files.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ /**
12
+ * Read a .env file format and return key-value pairs.
13
+ * @param {string} filePath
14
+ * @returns {Record<string, string>}
15
+ */
16
+ function _parseEnv(filePath) {
17
+ const result = {};
18
+ if (!fs.existsSync(filePath)) return result;
19
+
20
+ const content = fs.readFileSync(filePath, "utf-8");
21
+ for (const line of content.split("\n")) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith("#")) continue;
24
+ const idx = trimmed.indexOf("=");
25
+ if (idx === -1) continue;
26
+ const key = trimmed.slice(0, idx).trim();
27
+ let value = trimmed.slice(idx + 1).trim();
28
+ // Remove surrounding quotes
29
+ if ((value.startsWith('"') && value.endsWith('"')) ||
30
+ (value.startsWith("'") && value.endsWith("'"))) {
31
+ value = value.slice(1, -1);
32
+ }
33
+ result[key] = value;
34
+ }
35
+ return result;
36
+ }
37
+
38
+ /**
39
+ * Parse supabase/config.toml if it exists.
40
+ * @param {string} projectRoot
41
+ * @returns {{ projectRef?: string; poolerUrl?: string }}
42
+ */
43
+ function _parseSupabaseToml(projectRoot) {
44
+ const tomlPath = path.join(projectRoot, "supabase", "config.toml");
45
+ if (!fs.existsSync(tomlPath)) return {};
46
+
47
+ const content = fs.readFileSync(tomlPath, "utf-8");
48
+ const refMatch = content.match(/project_id\s*=\s*"?([a-zA-Z0-9]{20})"?/);
49
+ return refMatch ? { projectRef: refMatch[1] } : {};
50
+ }
51
+
52
+ /**
53
+ * Try to find a Supabase URL in a local project.
54
+ * Checks: supabase/.env, .env.local, .env, supabase/config.toml
55
+ * @param {string} cwd
56
+ * @returns {string|null}
57
+ */
58
+ function _findSupabaseUrl(cwd) {
59
+ // Check supabase/.env
60
+ const supabaseEnv = _parseEnv(path.join(cwd, "supabase", ".env"));
61
+ if (supabaseEnv.SUPABASE_URL) return supabaseEnv.SUPABASE_URL;
62
+
63
+ // Check common env files in order of preference
64
+ for (const envFile of [".env.local", ".env.development.local", ".env.development", ".env"]) {
65
+ const env = _parseEnv(path.join(cwd, envFile));
66
+ if (env.NEXT_PUBLIC_SUPABASE_URL) return env.NEXT_PUBLIC_SUPABASE_URL;
67
+ if (env.SUPABASE_URL) return env.SUPABASE_URL;
68
+ if (env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
69
+ // Some projects set the ref as NEXT_PUBLIC_SUPABASE_URL
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Try to find Supabase keys in a local project.
78
+ * @param {string} cwd
79
+ * @returns {{ anonKey?: string; serviceKey?: string }}
80
+ */
81
+ function _findSupabaseKeys(cwd) {
82
+ const result = {};
83
+
84
+ for (const envFile of [".env.local", ".env", ".env.development.local", ".env.development"]) {
85
+ const env = _parseEnv(path.join(cwd, envFile));
86
+ if (env.NEXT_PUBLIC_SUPABASE_ANON_KEY) result.anonKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
87
+ if (env.SUPABASE_ANON_KEY) result.anonKey = env.SUPABASE_ANON_KEY;
88
+ if (env.SUPABASE_SERVICE_ROLE_KEY) result.serviceKey = env.SUPABASE_SERVICE_ROLE_KEY;
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Scan the given project directory for Supabase configuration.
96
+ * Returns everything found — may be partial if not all vars are configured.
97
+ *
98
+ * @param {string} cwd — Project root (where package.json lives)
99
+ * @returns {{
100
+ * detected: boolean;
101
+ * url?: string;
102
+ * anonKey?: string;
103
+ * serviceKey?: string;
104
+ * projectRef?: string;
105
+ * framework?: string;
106
+ * sources: string[];
107
+ * canConnect: boolean;
108
+ * }}
109
+ */
110
+ export function scanSupabase(cwd) {
111
+ const sources = [];
112
+
113
+ const url = _findSupabaseUrl(cwd);
114
+ if (url) sources.push("env files");
115
+
116
+ const keys = _findSupabaseKeys(cwd);
117
+ if (keys.anonKey) sources.push("env files");
118
+ if (keys.serviceKey) sources.push("env files");
119
+
120
+ const { projectRef } = _parseSupabaseToml(cwd);
121
+ if (projectRef) sources.push("supabase/config.toml");
122
+
123
+ const canConnect = !!(url && keys.anonKey && keys.serviceKey);
124
+
125
+ return {
126
+ detected: !!url || !!keys.anonKey,
127
+ url,
128
+ anonKey: keys.anonKey,
129
+ serviceKey: keys.serviceKey,
130
+ projectRef,
131
+ framework: _detectFramework(cwd),
132
+ sources: [...new Set(sources)],
133
+ canConnect,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Detect if the project is Next.js, Remit, etc.
139
+ * @param {string} cwd
140
+ * @returns {string|null}
141
+ */
142
+ function _detectFramework(cwd) {
143
+ const pkgPath = path.join(cwd, "package.json");
144
+ if (!fs.existsSync(pkgPath)) return null;
145
+
146
+ try {
147
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
148
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
149
+ if (deps.next) return "nextjs";
150
+ if (deps.remix) return "remix";
151
+ if (deps.sveltekit || deps["@sveltejs/kit"]) return "sveltekit";
152
+ if (deps.vite) return "vite";
153
+ } catch {}
154
+
155
+ return null;
156
+ }
package/src/lib/oauth.js CHANGED
@@ -255,13 +255,32 @@ export async function authWithProvider(opts) {
255
255
  };
256
256
  }
257
257
 
258
+ /**
259
+ * List all configured providers and their status.
260
+ *
261
+ * @returns {{ name: string; enabled: boolean; scopes: string }[]}
262
+ */
263
+ /**
264
+ * Include additional "providers" that are not in the PROVIDERS
265
+ * object (e.g. magic-link, password).
266
+ *
267
+ * @returns {{ name: string; enabled: boolean; scopes: string }[]}
268
+ */
269
+ function _listAdditionalProviders() {
270
+ const extras = [];
271
+ if (process.env.RESEND_API_KEY) {
272
+ extras.push({ name: "magiclink", enabled: true, scopes: "email" });
273
+ }
274
+ return extras;
275
+ }
276
+
258
277
  /**
259
278
  * List all configured providers and their status.
260
279
  *
261
280
  * @returns {{ name: string; enabled: boolean; scopes: string }[]}
262
281
  */
263
282
  export function listProviderStatus() {
264
- return Object.keys(PROVIDERS).map((name) => {
283
+ const oauthProviders = Object.keys(PROVIDERS).map((name) => {
265
284
  const upper = name.toUpperCase();
266
285
  const clientId = process.env[`${upper}_CLIENT_ID`] || "";
267
286
  const clientSecret = process.env[`${upper}_CLIENT_SECRET`] || "";
@@ -271,6 +290,9 @@ export function listProviderStatus() {
271
290
  scopes: PROVIDERS[name].scopeDefault,
272
291
  };
273
292
  });
293
+
294
+ const extras = _listAdditionalProviders();
295
+ return [...oauthProviders, ...extras];
274
296
  }
275
297
 
276
298
  /**