@reinteractive/rails-insight 1.0.1
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/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- package/src/utils/yaml-parser.js +135 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Extractor (#8)
|
|
3
|
+
* Detects authentication strategy (Devise, native Rails 8, JWT, etc.)
|
|
4
|
+
* and extracts deep configuration details including actual implementation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AUTH_PATTERNS } from '../core/patterns.js'
|
|
8
|
+
|
|
9
|
+
// -------------------------------------------------------
|
|
10
|
+
// Helpers for reading native Rails 8 auth details
|
|
11
|
+
// -------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Extract method names (public) from Ruby source, grouped by purpose. */
|
|
14
|
+
function extractMethodNames(content) {
|
|
15
|
+
const methods = []
|
|
16
|
+
const lines = content.split('\n')
|
|
17
|
+
let inPrivate = false
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
if (/^\s*(private|protected)\s*$/.test(line)) {
|
|
20
|
+
inPrivate = true
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
if (!inPrivate) {
|
|
24
|
+
const m = line.match(/^\s*def\s+(\w+)/)
|
|
25
|
+
if (m) methods.push(m[1])
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return methods
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Extract cookie configuration from auth concern content. */
|
|
32
|
+
function extractCookieConfig(content) {
|
|
33
|
+
const config = {}
|
|
34
|
+
const nameMatch = content.match(/cookies\.signed\[[:'""]?(\w+)['"":]?\]/)
|
|
35
|
+
if (nameMatch) config.name = nameMatch[1]
|
|
36
|
+
|
|
37
|
+
const httponlyMatch = content.match(/httponly:\s*(true|false)/)
|
|
38
|
+
if (httponlyMatch) config.httponly = httponlyMatch[1] === 'true'
|
|
39
|
+
|
|
40
|
+
const sameSiteMatch = content.match(/same_site:\s*:?(\w+)/)
|
|
41
|
+
if (sameSiteMatch) config.same_site = sameSiteMatch[1]
|
|
42
|
+
|
|
43
|
+
const secureMatch = content.match(/secure:\s*([^,\n]+)/)
|
|
44
|
+
if (secureMatch) config.secure = secureMatch[1].trim()
|
|
45
|
+
|
|
46
|
+
// Session duration: look for things like 30.days, 2.weeks, 1.year
|
|
47
|
+
const durationMatch = content.match(/(\d+)\.(days?|weeks?|months?|years?)/)
|
|
48
|
+
if (durationMatch) config.duration = `${durationMatch[1]} ${durationMatch[2]}`
|
|
49
|
+
|
|
50
|
+
return Object.keys(config).length > 0 ? config : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extract rate limiting declarations from content (Rails 8 native rate_limit). */
|
|
54
|
+
function extractRateLimits(content) {
|
|
55
|
+
const limits = []
|
|
56
|
+
const re =
|
|
57
|
+
/rate_limit\s+to:\s*(\d+),\s*within:\s*([^,\n]+?)(?:,\s*only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/g
|
|
58
|
+
let m
|
|
59
|
+
while ((m = re.exec(content))) {
|
|
60
|
+
limits.push({
|
|
61
|
+
to: parseInt(m[1], 10),
|
|
62
|
+
within: m[2].trim(),
|
|
63
|
+
only: m[3] || m[4] || m[5] || null,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return limits
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract allow_unauthenticated_access declaration. */
|
|
70
|
+
function extractAllowUnauthenticated(content) {
|
|
71
|
+
const m = content.match(
|
|
72
|
+
/allow_unauthenticated_access(?:\s+only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/,
|
|
73
|
+
)
|
|
74
|
+
if (!m) return null
|
|
75
|
+
const only = (m[1] || m[2] || m[3] || '')
|
|
76
|
+
.replace(/[:%]/g, '')
|
|
77
|
+
.split(/\s+/)
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
return { only: only.length > 0 ? only : null }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Extract the key method calls inside each action method (first redirect_to / model call). */
|
|
83
|
+
function extractActionSummary(content, actionName) {
|
|
84
|
+
const lines = content.split('\n')
|
|
85
|
+
let inAction = false
|
|
86
|
+
let depth = 0
|
|
87
|
+
const keyCalls = []
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
if (new RegExp(`^\\s*def\\s+${actionName}\\b`).test(line)) {
|
|
90
|
+
inAction = true
|
|
91
|
+
depth = 0
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (!inAction) continue
|
|
95
|
+
if (/^\s*def\s+\w+/.test(line) && depth === 0) break // next method
|
|
96
|
+
if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b/.test(line)) depth++
|
|
97
|
+
if (/^\s*end\b/.test(line)) {
|
|
98
|
+
if (depth === 0) break
|
|
99
|
+
depth--
|
|
100
|
+
}
|
|
101
|
+
const trimmed = line.trim()
|
|
102
|
+
// Capture first few key expressions
|
|
103
|
+
if (
|
|
104
|
+
/^(redirect_to|render|head|@\w+\s*=|User\.|start_new_session|terminate_session|authenticate)/.test(
|
|
105
|
+
trimmed,
|
|
106
|
+
)
|
|
107
|
+
) {
|
|
108
|
+
keyCalls.push(trimmed.replace(/\s+/g, ' ').slice(0, 120))
|
|
109
|
+
if (keyCalls.length >= 3) break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return keyCalls.join('; ') || null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Extract all method bodies as a map of { name → body_text }. */
|
|
116
|
+
function extractMethodBodies(content) {
|
|
117
|
+
const bodies = {}
|
|
118
|
+
const lines = content.split('\n')
|
|
119
|
+
let currentMethod = null
|
|
120
|
+
let depth = 0
|
|
121
|
+
const bodyLines = []
|
|
122
|
+
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const defMatch = line.match(/^\s*def\s+(\w+)/)
|
|
125
|
+
if (defMatch && depth === 0) {
|
|
126
|
+
if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
|
|
127
|
+
currentMethod = defMatch[1]
|
|
128
|
+
bodyLines.length = 0
|
|
129
|
+
depth = 0
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (currentMethod) {
|
|
133
|
+
if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b|\bdef\b/.test(line)) depth++
|
|
134
|
+
if (/^\s*end\b/.test(line)) {
|
|
135
|
+
if (depth === 0) {
|
|
136
|
+
bodies[currentMethod] = bodyLines.join('\n').trim()
|
|
137
|
+
currentMethod = null
|
|
138
|
+
bodyLines.length = 0
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
depth--
|
|
142
|
+
}
|
|
143
|
+
bodyLines.push(line)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
|
|
147
|
+
return bodies
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Build a rich per-method detail object from the auth concern. */
|
|
151
|
+
function extractConcernMethodDetails(content) {
|
|
152
|
+
const bodies = extractMethodBodies(content)
|
|
153
|
+
const details = {}
|
|
154
|
+
|
|
155
|
+
for (const [name, body] of Object.entries(bodies)) {
|
|
156
|
+
const info = {}
|
|
157
|
+
|
|
158
|
+
// Detect purpose from method name
|
|
159
|
+
if (name === 'require_authentication') {
|
|
160
|
+
info.type = 'before_action'
|
|
161
|
+
info.purpose = 'Redirects unauthenticated users to login'
|
|
162
|
+
const redirectMatch = body.match(/redirect_to\s+([\w_]+(?:_path|_url)?)/)
|
|
163
|
+
if (redirectMatch) info.redirect_target = redirectMatch[1]
|
|
164
|
+
info.stores_url =
|
|
165
|
+
/session\[.*requested_url|request_url|store.*url|forwarding_url/.test(
|
|
166
|
+
body,
|
|
167
|
+
)
|
|
168
|
+
} else if (name === 'resume_session') {
|
|
169
|
+
info.purpose = 'Restores session from signed cookie on each request'
|
|
170
|
+
const callMatch = body.match(/(\w+)\(/)
|
|
171
|
+
if (callMatch) info.calls = callMatch[1]
|
|
172
|
+
} else if (name === 'find_session_by_cookie') {
|
|
173
|
+
info.purpose = 'Finds non-expired session record using cookie value'
|
|
174
|
+
// Cookie name
|
|
175
|
+
const cookieNameMatch =
|
|
176
|
+
body.match(/cookies\.signed(?:\.permanent)?\[[:'""]?(\w+)['"":]?\]/) ||
|
|
177
|
+
content.match(/cookies\.signed(?:\.permanent)?\[[:'""]?(\w+)['"":]?\]/)
|
|
178
|
+
if (cookieNameMatch) info.cookie_name = cookieNameMatch[1]
|
|
179
|
+
// Session duration / max age
|
|
180
|
+
const durMatch =
|
|
181
|
+
body.match(/(\d+)\.(days?|weeks?|months?|years?|hours?)/) ||
|
|
182
|
+
content.match(/(\d+)\.(days?|weeks?|months?|years?|hours?)/)
|
|
183
|
+
if (durMatch) info.session_max_age = `${durMatch[1]}.${durMatch[2]}`
|
|
184
|
+
// Cookie type
|
|
185
|
+
if (
|
|
186
|
+
/cookies\.signed\.permanent/.test(body) ||
|
|
187
|
+
/cookies\.signed\.permanent/.test(content)
|
|
188
|
+
) {
|
|
189
|
+
info.cookie_type = 'signed.permanent'
|
|
190
|
+
} else if (
|
|
191
|
+
/cookies\.signed/.test(body) ||
|
|
192
|
+
/cookies\.signed/.test(content)
|
|
193
|
+
) {
|
|
194
|
+
info.cookie_type = 'signed'
|
|
195
|
+
}
|
|
196
|
+
} else if (name === 'start_new_session_for') {
|
|
197
|
+
info.purpose =
|
|
198
|
+
'Creates new database session record and sets signed cookie'
|
|
199
|
+
const sessCreateMatch = body.match(/Session\.create[!(]?\s*([^)]+)/)
|
|
200
|
+
if (sessCreateMatch)
|
|
201
|
+
info.creates = `Session.create(${sessCreateMatch[1].substring(0, 80).trim()})`
|
|
202
|
+
// Cookie config from this method's body first, then full content
|
|
203
|
+
const cfg = extractCookieConfig(body) || extractCookieConfig(content)
|
|
204
|
+
if (cfg) info.cookie_config = cfg
|
|
205
|
+
} else if (name === 'terminate_session') {
|
|
206
|
+
info.purpose = 'Destroys session record and deletes cookie'
|
|
207
|
+
if (/destroy/.test(body)) info.destroys = 'Current.session'
|
|
208
|
+
if (/cookies\.delete/.test(body)) info.deletes_cookie = true
|
|
209
|
+
} else if (/allow_unauthenticated_access/.test(name)) {
|
|
210
|
+
info.type = 'class_method'
|
|
211
|
+
info.purpose =
|
|
212
|
+
'Controller macro to skip require_authentication for specified actions'
|
|
213
|
+
} else {
|
|
214
|
+
// Generic
|
|
215
|
+
const firstLine = body.split('\n').find((l) => l.trim().length > 0)
|
|
216
|
+
if (firstLine) info.body_start = firstLine.trim().substring(0, 100)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (Object.keys(info).length > 0) details[name] = info
|
|
220
|
+
}
|
|
221
|
+
return details
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Extract Current model detail from current.rb content. */
|
|
225
|
+
function extractCurrentModelDetail(content) {
|
|
226
|
+
if (!content) return null
|
|
227
|
+
const attrs = []
|
|
228
|
+
const attrRe = /^\s*attribute\s+:(\w+)/gm
|
|
229
|
+
let m
|
|
230
|
+
while ((m = attrRe.exec(content))) attrs.push(m[1])
|
|
231
|
+
|
|
232
|
+
const delegates = []
|
|
233
|
+
const delRe =
|
|
234
|
+
/^\s*delegate\s+:(\w+),\s*to:\s*:(\w+)(?:,\s*allow_nil:\s*(true|false))?/gm
|
|
235
|
+
while ((m = delRe.exec(content))) {
|
|
236
|
+
delegates.push({
|
|
237
|
+
method: m[1],
|
|
238
|
+
to: m[2],
|
|
239
|
+
allow_nil: m[3] === 'true' || true,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const usageParts = []
|
|
244
|
+
if (attrs.length > 0) usageParts.push(`Current.${attrs[0]}`)
|
|
245
|
+
if (delegates.length > 0) usageParts.push(`Current.${delegates[0].method}`)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
file: 'app/models/current.rb',
|
|
249
|
+
class: 'Current',
|
|
250
|
+
superclass: 'ActiveSupport::CurrentAttributes',
|
|
251
|
+
attributes: attrs,
|
|
252
|
+
delegates,
|
|
253
|
+
usage: `Provides ${usageParts.join(' and ')} throughout the request lifecycle`,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Scan all loaded content for common API auth patterns, return found/not_found map. */
|
|
258
|
+
function scanForApiAuthPatterns(provider, entries) {
|
|
259
|
+
// Scan controller entries + Gemfile only (already small, fast)
|
|
260
|
+
const controllerEntries = entries.filter(
|
|
261
|
+
(e) =>
|
|
262
|
+
e.category === 'controller' ||
|
|
263
|
+
e.categoryName === 'controllers' ||
|
|
264
|
+
e.category === 'config' ||
|
|
265
|
+
e.categoryName === 'config',
|
|
266
|
+
)
|
|
267
|
+
const contents = []
|
|
268
|
+
for (const entry of controllerEntries) {
|
|
269
|
+
const c = provider.readFile(entry.path)
|
|
270
|
+
if (c) contents.push(c)
|
|
271
|
+
}
|
|
272
|
+
const gemfileContent = provider.readFile('Gemfile') || ''
|
|
273
|
+
const allContent = [...contents, gemfileContent].join('\n')
|
|
274
|
+
|
|
275
|
+
const patterns = [
|
|
276
|
+
{
|
|
277
|
+
key: 'jwt',
|
|
278
|
+
searched: ['jwt', 'JSON::JWT', 'jwt_token'],
|
|
279
|
+
re: /\bjwt\b|json_web_token|JWT\./i,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
key: 'api_key',
|
|
283
|
+
searched: ['api_key', 'X-Api-Key', 'x_api_key'],
|
|
284
|
+
re: /api[_\-]key|x-api-key/i,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
key: 'bearer_token',
|
|
288
|
+
searched: ['bearer', 'Authorization: Bearer'],
|
|
289
|
+
re: /bearer|authenticate_with_http_token/i,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
key: 'token_authenticatable',
|
|
293
|
+
searched: ['token_authenticatable', 'has_secure_token :auth'],
|
|
294
|
+
re: /token_authenticatable|has_secure_token\s+:auth/i,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
key: 'doorkeeper_oauth',
|
|
298
|
+
searched: ['doorkeeper', 'oauth'],
|
|
299
|
+
re: /doorkeeper|oauth/i,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
key: 'devise_jwt',
|
|
303
|
+
searched: ['devise-jwt'],
|
|
304
|
+
re: /devise-jwt|devise\/jwt/i,
|
|
305
|
+
},
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
const results = {}
|
|
309
|
+
for (const { key, searched, re } of patterns) {
|
|
310
|
+
results[key] = { found: re.test(allContent), searched }
|
|
311
|
+
}
|
|
312
|
+
return results
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Derive auth-related routes from the routes extraction. */
|
|
316
|
+
function extractAuthRoutes(routesData) {
|
|
317
|
+
if (!routesData) return {}
|
|
318
|
+
const authKeywords = [
|
|
319
|
+
'session',
|
|
320
|
+
'registration',
|
|
321
|
+
'password',
|
|
322
|
+
'login',
|
|
323
|
+
'logout',
|
|
324
|
+
'signup',
|
|
325
|
+
'sign_in',
|
|
326
|
+
'sign_out',
|
|
327
|
+
]
|
|
328
|
+
const routes = routesData.routes || []
|
|
329
|
+
const authRoutes = {}
|
|
330
|
+
for (const r of routes) {
|
|
331
|
+
const path = (r.path || r.pattern || '').toLowerCase()
|
|
332
|
+
const ctrl = (r.controller || '').toLowerCase()
|
|
333
|
+
if (authKeywords.some((k) => path.includes(k) || ctrl.includes(k))) {
|
|
334
|
+
const key = `${r.verb || r.method || 'GET'} ${r.path || r.pattern}`
|
|
335
|
+
authRoutes[key] = `${r.controller}#${r.action}`
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return authRoutes
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Extract authentication information.
|
|
343
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
344
|
+
* @param {Array<{path: string, category: string}>} entries - scanned entries
|
|
345
|
+
* @param {{gems?: object}} gemInfo - extracted gem information
|
|
346
|
+
* @param {object|null} schemaData - pre-extracted schema (optional)
|
|
347
|
+
* @returns {object}
|
|
348
|
+
*/
|
|
349
|
+
export function extractAuth(
|
|
350
|
+
provider,
|
|
351
|
+
entries,
|
|
352
|
+
gemInfo = {},
|
|
353
|
+
schemaData = null,
|
|
354
|
+
) {
|
|
355
|
+
const gems = gemInfo.gems || {}
|
|
356
|
+
const result = {
|
|
357
|
+
primary_strategy: null,
|
|
358
|
+
devise: null,
|
|
359
|
+
native_auth: null,
|
|
360
|
+
jwt: null,
|
|
361
|
+
two_factor: null,
|
|
362
|
+
omniauth: null,
|
|
363
|
+
has_secure_password: false,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const hasDevise = !!gems.devise
|
|
367
|
+
const hasJwt = !!gems.jwt || !!gems['devise-jwt']
|
|
368
|
+
const hasTwoFactor =
|
|
369
|
+
!!gems['devise-two-factor'] || !!gems.rotp || !!gems.webauthn
|
|
370
|
+
const hasOmniauth = !!gems.omniauth
|
|
371
|
+
|
|
372
|
+
// Detect Devise
|
|
373
|
+
if (hasDevise) {
|
|
374
|
+
result.primary_strategy = 'devise'
|
|
375
|
+
result.devise = {
|
|
376
|
+
models: {},
|
|
377
|
+
custom_controllers: [],
|
|
378
|
+
config: {},
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Parse Devise initializer config
|
|
382
|
+
const deviseConfig = provider.readFile('config/initializers/devise.rb')
|
|
383
|
+
if (deviseConfig) {
|
|
384
|
+
const configRe = new RegExp(AUTH_PATTERNS.deviseConfig.source, 'g')
|
|
385
|
+
let m
|
|
386
|
+
while ((m = configRe.exec(deviseConfig))) {
|
|
387
|
+
const key = m[1]
|
|
388
|
+
const val = m[2].trim()
|
|
389
|
+
result.devise.config[key] = val
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Parse models for devise declarations
|
|
394
|
+
const modelEntries = entries.filter(
|
|
395
|
+
(e) => e.category === 'model' || e.categoryName === 'models',
|
|
396
|
+
)
|
|
397
|
+
for (const entry of modelEntries) {
|
|
398
|
+
const content = provider.readFile(entry.path)
|
|
399
|
+
if (!content) continue
|
|
400
|
+
|
|
401
|
+
const deviseMatch = content.match(AUTH_PATTERNS.deviseModules)
|
|
402
|
+
if (deviseMatch) {
|
|
403
|
+
const className = entry.path
|
|
404
|
+
.split('/')
|
|
405
|
+
.pop()
|
|
406
|
+
.replace('.rb', '')
|
|
407
|
+
.split('_')
|
|
408
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
409
|
+
.join('')
|
|
410
|
+
|
|
411
|
+
let fullDecl = deviseMatch[1]
|
|
412
|
+
const lines = content.split('\n')
|
|
413
|
+
const matchLine =
|
|
414
|
+
content.slice(0, deviseMatch.index).split('\n').length - 1
|
|
415
|
+
for (let li = matchLine + 1; li < lines.length; li++) {
|
|
416
|
+
const ltrim = lines[li].trim()
|
|
417
|
+
if (ltrim.startsWith(':') || /^\w+.*:/.test(ltrim)) {
|
|
418
|
+
fullDecl += ' ' + ltrim
|
|
419
|
+
} else {
|
|
420
|
+
break
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const modulePart = fullDecl.split(/\w+:\s*\[/)[0]
|
|
425
|
+
const modules =
|
|
426
|
+
modulePart.match(/:(\w+)/g)?.map((m) => m.slice(1)) || []
|
|
427
|
+
|
|
428
|
+
const model = { modules, omniauth_providers: [] }
|
|
429
|
+
|
|
430
|
+
const oaMatch = content.match(AUTH_PATTERNS.omniauthProviders)
|
|
431
|
+
if (oaMatch) {
|
|
432
|
+
model.omniauth_providers =
|
|
433
|
+
oaMatch[1].match(/:(\w+)/g)?.map((p) => p.slice(1)) || []
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
result.devise.models[className] = model
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (AUTH_PATTERNS.hasSecurePassword.test(content)) {
|
|
440
|
+
result.has_secure_password = true
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const controllerEntries = entries.filter(
|
|
445
|
+
(e) => e.category === 'controller' || e.categoryName === 'controllers',
|
|
446
|
+
)
|
|
447
|
+
for (const entry of controllerEntries) {
|
|
448
|
+
const content = provider.readFile(entry.path)
|
|
449
|
+
if (!content) continue
|
|
450
|
+
const devCtrlMatch = content.match(AUTH_PATTERNS.deviseController)
|
|
451
|
+
if (devCtrlMatch) {
|
|
452
|
+
const namespace = content.match(/class\s+(\w+)::/)
|
|
453
|
+
const name = namespace
|
|
454
|
+
? `${namespace[1]}::${devCtrlMatch[1]}`
|
|
455
|
+
: devCtrlMatch[1]
|
|
456
|
+
result.devise.custom_controllers.push(name)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Detect native Rails 8 auth
|
|
462
|
+
const currentContent = provider.readFile('app/models/current.rb')
|
|
463
|
+
if (currentContent && AUTH_PATTERNS.currentAttributes.test(currentContent)) {
|
|
464
|
+
if (!result.primary_strategy) result.primary_strategy = 'native'
|
|
465
|
+
|
|
466
|
+
// --- Deep extraction for native Rails 8 auth ---
|
|
467
|
+
const native = {
|
|
468
|
+
strategy: 'native_rails8',
|
|
469
|
+
description:
|
|
470
|
+
'Rails 8 built-in authentication with database-backed sessions, signed permanent cookies, and CurrentAttributes pattern',
|
|
471
|
+
models: {},
|
|
472
|
+
controllers: {},
|
|
473
|
+
routes: {},
|
|
474
|
+
security_features: {},
|
|
475
|
+
related_files: [],
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 1. Current model — extract full detail and expose as dedicated top-level section
|
|
479
|
+
const currentDetail = extractCurrentModelDetail(currentContent)
|
|
480
|
+
if (currentDetail) {
|
|
481
|
+
// Backward-compat shortcut
|
|
482
|
+
native.attributes = currentDetail.attributes
|
|
483
|
+
// Dedicated section so LLM doesn't need to read the file
|
|
484
|
+
native.current_attributes = currentDetail
|
|
485
|
+
// Also available in models map
|
|
486
|
+
native.models['Current'] = {
|
|
487
|
+
file: 'app/models/current.rb',
|
|
488
|
+
type: 'ActiveSupport::CurrentAttributes',
|
|
489
|
+
attributes: currentDetail.attributes,
|
|
490
|
+
delegates: currentDetail.delegates,
|
|
491
|
+
usage: currentDetail.usage,
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
native.related_files.push('app/models/current.rb')
|
|
495
|
+
|
|
496
|
+
// 2. Session model
|
|
497
|
+
const sessionContent = provider.readFile('app/models/session.rb')
|
|
498
|
+
if (sessionContent) {
|
|
499
|
+
const sessionInfo = { file: 'app/models/session.rb' }
|
|
500
|
+
const belongsMatch = sessionContent.match(/belongs_to\s+:(\w+)/)
|
|
501
|
+
if (belongsMatch) sessionInfo.belongs_to = belongsMatch[1]
|
|
502
|
+
// Pull columns from schema if available
|
|
503
|
+
if (schemaData) {
|
|
504
|
+
const table = schemaData.tables?.find((t) => t.name === 'sessions')
|
|
505
|
+
if (table) sessionInfo.columns = table.columns.map((c) => c.name)
|
|
506
|
+
}
|
|
507
|
+
native.models['Session'] = sessionInfo
|
|
508
|
+
native.related_files.push('app/models/session.rb')
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 3. User model auth features
|
|
512
|
+
const userContent = provider.readFile('app/models/user.rb')
|
|
513
|
+
if (userContent) {
|
|
514
|
+
const userInfo = { file: 'app/models/user.rb', auth_features: {} }
|
|
515
|
+
if (AUTH_PATTERNS.hasSecurePassword.test(userContent)) {
|
|
516
|
+
userInfo.auth_features.has_secure_password = true
|
|
517
|
+
result.has_secure_password = true
|
|
518
|
+
}
|
|
519
|
+
if (/authenticate_by/.test(userContent)) {
|
|
520
|
+
userInfo.auth_features.authenticate_by =
|
|
521
|
+
'User.authenticate_by(email:, password:)'
|
|
522
|
+
}
|
|
523
|
+
// Email/password validations
|
|
524
|
+
const emailValidation = userContent.match(
|
|
525
|
+
/validates?\s+:email(?:_address)?,([^\n]+)/,
|
|
526
|
+
)
|
|
527
|
+
if (emailValidation)
|
|
528
|
+
userInfo.auth_features.email_validation = emailValidation[1].trim()
|
|
529
|
+
// Email normalization
|
|
530
|
+
if (/normalize|strip|downcase/.test(userContent)) {
|
|
531
|
+
userInfo.auth_features.email_normalization =
|
|
532
|
+
'normalizes email (strips and downcases)'
|
|
533
|
+
}
|
|
534
|
+
// Roles
|
|
535
|
+
const roleEnum = userContent.match(
|
|
536
|
+
/enum\s+:?role[:\s,]+[\{|\[]([^\}\]]+)[\}|\]]/,
|
|
537
|
+
)
|
|
538
|
+
if (roleEnum) {
|
|
539
|
+
const roles =
|
|
540
|
+
roleEnum[1].match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
|
|
541
|
+
userInfo.auth_features.roles = roles
|
|
542
|
+
}
|
|
543
|
+
// Users table columns from schema
|
|
544
|
+
if (schemaData) {
|
|
545
|
+
const table = schemaData.tables?.find((t) => t.name === 'users')
|
|
546
|
+
if (table)
|
|
547
|
+
userInfo.columns = table.columns.map((c) => ({
|
|
548
|
+
name: c.name,
|
|
549
|
+
type: c.type,
|
|
550
|
+
constraints: c.constraints,
|
|
551
|
+
}))
|
|
552
|
+
}
|
|
553
|
+
native.models['User'] = userInfo
|
|
554
|
+
native.related_files.push('app/models/user.rb')
|
|
555
|
+
|
|
556
|
+
// Token generators (generates_token_for)
|
|
557
|
+
const tokenGenerators = []
|
|
558
|
+
const tokenGenRe = /generates_token_for\s+:(\w+)/g
|
|
559
|
+
let tm
|
|
560
|
+
while ((tm = tokenGenRe.exec(userContent))) {
|
|
561
|
+
tokenGenerators.push(tm[1])
|
|
562
|
+
}
|
|
563
|
+
if (tokenGenerators.length > 0) {
|
|
564
|
+
userInfo.auth_features.token_generators = tokenGenerators
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 4. Auth concern (ApplicationController includes Authentication)
|
|
569
|
+
const authConcernPaths = [
|
|
570
|
+
'app/controllers/concerns/authentication.rb',
|
|
571
|
+
'app/controllers/concerns/authenticatable.rb',
|
|
572
|
+
]
|
|
573
|
+
let authConcernContent = null
|
|
574
|
+
let authConcernFile = null
|
|
575
|
+
for (const p of authConcernPaths) {
|
|
576
|
+
const c = provider.readFile(p)
|
|
577
|
+
if (c) {
|
|
578
|
+
authConcernContent = c
|
|
579
|
+
authConcernFile = p
|
|
580
|
+
break
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Also search entries for a concern with "authentication" in path
|
|
584
|
+
if (!authConcernContent) {
|
|
585
|
+
const concernEntry = entries.find(
|
|
586
|
+
(e) =>
|
|
587
|
+
(e.categoryName === 'controllers' || e.category === 'controller') &&
|
|
588
|
+
e.path.includes('concerns') &&
|
|
589
|
+
e.path.toLowerCase().includes('auth'),
|
|
590
|
+
)
|
|
591
|
+
if (concernEntry) {
|
|
592
|
+
authConcernContent = provider.readFile(concernEntry.path)
|
|
593
|
+
authConcernFile = concernEntry.path
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (authConcernContent) {
|
|
598
|
+
const methods = extractMethodNames(authConcernContent)
|
|
599
|
+
const cookieConfig = extractCookieConfig(authConcernContent)
|
|
600
|
+
const optOut = extractAllowUnauthenticated(authConcernContent)
|
|
601
|
+
const methodDetails = extractConcernMethodDetails(authConcernContent)
|
|
602
|
+
|
|
603
|
+
native.controllers['authentication_concern'] = {
|
|
604
|
+
file: authConcernFile,
|
|
605
|
+
included_in: 'ApplicationController',
|
|
606
|
+
methods: methodDetails,
|
|
607
|
+
// Backward-compat: summary string per method (used by older callers)
|
|
608
|
+
key_methods: methods.reduce((acc, method) => {
|
|
609
|
+
const summary = extractActionSummary(authConcernContent, method)
|
|
610
|
+
acc[method] = summary || 'defined'
|
|
611
|
+
return acc
|
|
612
|
+
}, {}),
|
|
613
|
+
cookie_config: cookieConfig,
|
|
614
|
+
opt_out_method: optOut ? 'allow_unauthenticated_access' : null,
|
|
615
|
+
}
|
|
616
|
+
native.related_files.push(authConcernFile)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 5. SessionsController
|
|
620
|
+
const sessionsCtrlPaths = ['app/controllers/sessions_controller.rb']
|
|
621
|
+
let sessionsContent = null
|
|
622
|
+
let sessionsFile = null
|
|
623
|
+
for (const p of sessionsCtrlPaths) {
|
|
624
|
+
const c = provider.readFile(p)
|
|
625
|
+
if (c) {
|
|
626
|
+
sessionsContent = c
|
|
627
|
+
sessionsFile = p
|
|
628
|
+
break
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!sessionsContent) {
|
|
632
|
+
const entry = entries.find(
|
|
633
|
+
(e) =>
|
|
634
|
+
(e.categoryName === 'controllers' || e.category === 'controller') &&
|
|
635
|
+
e.path.toLowerCase().includes('sessions_controller'),
|
|
636
|
+
)
|
|
637
|
+
if (entry) {
|
|
638
|
+
sessionsContent = provider.readFile(entry.path)
|
|
639
|
+
sessionsFile = entry.path
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (sessionsContent) {
|
|
643
|
+
const rateLimits = extractRateLimits(sessionsContent)
|
|
644
|
+
const unauthAccess = extractAllowUnauthenticated(sessionsContent)
|
|
645
|
+
const actions = extractMethodNames(sessionsContent).filter(
|
|
646
|
+
(m) => !m.startsWith('_'),
|
|
647
|
+
)
|
|
648
|
+
const ctrlInfo = {
|
|
649
|
+
file: sessionsFile,
|
|
650
|
+
actions,
|
|
651
|
+
rate_limiting: rateLimits.length > 0 ? rateLimits : null,
|
|
652
|
+
allow_unauthenticated_access: unauthAccess,
|
|
653
|
+
login_flow: null,
|
|
654
|
+
}
|
|
655
|
+
// Detect authenticate_by pattern
|
|
656
|
+
if (/authenticate_by/.test(sessionsContent)) {
|
|
657
|
+
const redirectMatch = sessionsContent.match(/redirect_to\s+([^\n,]+)/)
|
|
658
|
+
ctrlInfo.login_flow = `User.authenticate_by(email:, password:) → start_new_session_for → ${redirectMatch ? redirectMatch[1].trim() : 'redirect'}`
|
|
659
|
+
}
|
|
660
|
+
native.controllers['SessionsController'] = ctrlInfo
|
|
661
|
+
native.related_files.push(sessionsFile)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 6. RegistrationsController
|
|
665
|
+
const regPaths = ['app/controllers/registrations_controller.rb']
|
|
666
|
+
let regContent = null
|
|
667
|
+
let regFile = null
|
|
668
|
+
for (const p of regPaths) {
|
|
669
|
+
const c = provider.readFile(p)
|
|
670
|
+
if (c) {
|
|
671
|
+
regContent = c
|
|
672
|
+
regFile = p
|
|
673
|
+
break
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (regContent) {
|
|
677
|
+
native.controllers['RegistrationsController'] = {
|
|
678
|
+
file: regFile,
|
|
679
|
+
actions: extractMethodNames(regContent).filter(
|
|
680
|
+
(m) => !m.startsWith('_'),
|
|
681
|
+
),
|
|
682
|
+
}
|
|
683
|
+
native.related_files.push(regFile)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// 7. PasswordsController
|
|
687
|
+
const pwdPaths = ['app/controllers/passwords_controller.rb']
|
|
688
|
+
let pwdContent = null
|
|
689
|
+
let pwdFile = null
|
|
690
|
+
for (const p of pwdPaths) {
|
|
691
|
+
const c = provider.readFile(p)
|
|
692
|
+
if (c) {
|
|
693
|
+
pwdContent = c
|
|
694
|
+
pwdFile = p
|
|
695
|
+
break
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (pwdContent) {
|
|
699
|
+
const mailerMatch = pwdContent.match(/(\w+Mailer)/)
|
|
700
|
+
const tokenMatch = pwdContent.match(
|
|
701
|
+
/password_reset_token|with_reset_token/,
|
|
702
|
+
)
|
|
703
|
+
native.controllers['PasswordsController'] = {
|
|
704
|
+
file: pwdFile,
|
|
705
|
+
actions: extractMethodNames(pwdContent).filter(
|
|
706
|
+
(m) => !m.startsWith('_'),
|
|
707
|
+
),
|
|
708
|
+
reset_flow: tokenMatch
|
|
709
|
+
? 'email → token → reset form → update password'
|
|
710
|
+
: null,
|
|
711
|
+
token_method: tokenMatch
|
|
712
|
+
? tokenMatch[1] || 'password_reset_token'
|
|
713
|
+
: null,
|
|
714
|
+
mailer: mailerMatch ? mailerMatch[1] : null,
|
|
715
|
+
}
|
|
716
|
+
native.related_files.push(pwdFile)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// 8. ApplicationController — check what it includes
|
|
720
|
+
const appCtrlContent = provider.readFile(
|
|
721
|
+
'app/controllers/application_controller.rb',
|
|
722
|
+
)
|
|
723
|
+
if (appCtrlContent) {
|
|
724
|
+
native.related_files.push('app/controllers/application_controller.rb')
|
|
725
|
+
if (/include\s+Authentication/.test(appCtrlContent)) {
|
|
726
|
+
if (native.controllers['authentication_concern']) {
|
|
727
|
+
native.controllers['authentication_concern'].included_in =
|
|
728
|
+
'ApplicationController'
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 9. Security features summary
|
|
734
|
+
native.security_features = {
|
|
735
|
+
csrf: 'Rails built-in authenticity tokens',
|
|
736
|
+
}
|
|
737
|
+
if (authConcernContent) {
|
|
738
|
+
const cookieCfg = extractCookieConfig(authConcernContent)
|
|
739
|
+
if (cookieCfg) {
|
|
740
|
+
native.security_features.cookie_security =
|
|
741
|
+
[
|
|
742
|
+
cookieCfg.httponly ? 'httponly' : null,
|
|
743
|
+
cookieCfg.same_site ? `same_site: ${cookieCfg.same_site}` : null,
|
|
744
|
+
cookieCfg.secure ? `secure: ${cookieCfg.secure}` : null,
|
|
745
|
+
]
|
|
746
|
+
.filter(Boolean)
|
|
747
|
+
.join(', ') || null
|
|
748
|
+
if (cookieCfg.duration)
|
|
749
|
+
native.security_features.session_expiry = cookieCfg.duration
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (sessionsContent) {
|
|
753
|
+
const rl = extractRateLimits(sessionsContent)
|
|
754
|
+
if (rl.length > 0) {
|
|
755
|
+
native.security_features.rate_limiting = rl
|
|
756
|
+
.map(
|
|
757
|
+
(r) =>
|
|
758
|
+
`${r.to} requests per ${r.within}${r.only ? ` (only: ${r.only})` : ''}`,
|
|
759
|
+
)
|
|
760
|
+
.join(', ')
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (
|
|
764
|
+
userContent &&
|
|
765
|
+
/password.*minimum|minimum.*\d+|length.*minimum/.test(
|
|
766
|
+
provider.readFile('app/models/user.rb') || '',
|
|
767
|
+
)
|
|
768
|
+
) {
|
|
769
|
+
native.security_features.password_requirements =
|
|
770
|
+
'minimum length validation'
|
|
771
|
+
}
|
|
772
|
+
if (sessionContent) {
|
|
773
|
+
native.security_features.session_tracking =
|
|
774
|
+
'IP address and user agent stored per session'
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Cross-reference token generators with security features
|
|
778
|
+
const userTokenGens = native.models['User']?.auth_features?.token_generators
|
|
779
|
+
if (userTokenGens?.includes('password_reset')) {
|
|
780
|
+
native.security_features.password_reset_tokens =
|
|
781
|
+
'generates_token_for :password_reset'
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Remove duplicates from related_files
|
|
785
|
+
native.related_files = [...new Set(native.related_files)]
|
|
786
|
+
|
|
787
|
+
// 10. API authentication — explicit search for token/JWT/OAuth patterns
|
|
788
|
+
const apiAuthPatterns = scanForApiAuthPatterns(provider, entries)
|
|
789
|
+
const apiAuthPresent = Object.values(apiAuthPatterns).some((v) => v.found)
|
|
790
|
+
native.api_authentication = {
|
|
791
|
+
present: apiAuthPresent,
|
|
792
|
+
searched_patterns: Object.entries(apiAuthPatterns).map(([key, v]) => ({
|
|
793
|
+
pattern: key,
|
|
794
|
+
searched: v.searched,
|
|
795
|
+
found: v.found,
|
|
796
|
+
})),
|
|
797
|
+
summary: apiAuthPresent
|
|
798
|
+
? 'Detected API authentication patterns (see searched_patterns for details)'
|
|
799
|
+
: 'No API authentication found. App uses native session-cookie auth only. All searches returned not-found.',
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
native.has_sessions_controller = !!sessionsContent
|
|
803
|
+
result.native_auth = native
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// JWT
|
|
807
|
+
if (hasJwt) {
|
|
808
|
+
result.jwt = { gem: gems['devise-jwt'] ? 'devise-jwt' : 'jwt' }
|
|
809
|
+
if (!result.primary_strategy) result.primary_strategy = 'jwt'
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Two-factor
|
|
813
|
+
if (hasTwoFactor) {
|
|
814
|
+
const gem = gems['devise-two-factor']
|
|
815
|
+
? 'devise-two-factor'
|
|
816
|
+
: gems.rotp
|
|
817
|
+
? 'rotp'
|
|
818
|
+
: 'webauthn'
|
|
819
|
+
result.two_factor = { gem }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// OmniAuth (standalone)
|
|
823
|
+
if (hasOmniauth && !hasDevise) {
|
|
824
|
+
result.omniauth = { providers: [] }
|
|
825
|
+
if (!result.primary_strategy) result.primary_strategy = 'omniauth'
|
|
826
|
+
const initContent = provider.readFile('config/initializers/omniauth.rb')
|
|
827
|
+
if (initContent) {
|
|
828
|
+
const provRe = new RegExp(AUTH_PATTERNS.omniauthProvider.source, 'g')
|
|
829
|
+
let pm
|
|
830
|
+
while ((pm = provRe.exec(initContent))) {
|
|
831
|
+
result.omniauth.providers.push(pm[1])
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Check models for has_secure_password if not yet found
|
|
837
|
+
if (!result.has_secure_password) {
|
|
838
|
+
const modelEntries = entries.filter(
|
|
839
|
+
(e) => e.category === 'model' || e.categoryName === 'models',
|
|
840
|
+
)
|
|
841
|
+
for (const entry of modelEntries) {
|
|
842
|
+
const content = provider.readFile(entry.path)
|
|
843
|
+
if (content && AUTH_PATTERNS.hasSecurePassword.test(content)) {
|
|
844
|
+
result.has_secure_password = true
|
|
845
|
+
if (!result.primary_strategy)
|
|
846
|
+
result.primary_strategy = 'has_secure_password'
|
|
847
|
+
break
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return result
|
|
853
|
+
}
|