fhirsmith 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.
Files changed (277) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/FHIRsmith.png +0 -0
  3. package/README.md +277 -0
  4. package/config-template.json +144 -0
  5. package/library/folder-setup.js +58 -0
  6. package/library/html-server.js +166 -0
  7. package/library/html.js +835 -0
  8. package/library/i18nsupport.js +259 -0
  9. package/library/languages.js +779 -0
  10. package/library/logger-telnet.js +205 -0
  11. package/library/logger.js +279 -0
  12. package/library/package-manager.js +876 -0
  13. package/library/utilities.js +196 -0
  14. package/library/version-utilities.js +1056 -0
  15. package/npmprojector/config-example.json +13 -0
  16. package/npmprojector/indexer.js +394 -0
  17. package/npmprojector/npmprojector.js +395 -0
  18. package/npmprojector/readme.md +174 -0
  19. package/npmprojector/watcher.js +335 -0
  20. package/package.json +119 -0
  21. package/packages/package-crawler.js +846 -0
  22. package/packages/packages-template.html +126 -0
  23. package/packages/packages.js +2838 -0
  24. package/passwords.ini +2 -0
  25. package/publisher/publisher-template.html +208 -0
  26. package/publisher/publisher.js +2167 -0
  27. package/publisher/task-draft.js +458 -0
  28. package/registry/api.js +735 -0
  29. package/registry/crawler.js +637 -0
  30. package/registry/model.js +513 -0
  31. package/registry/readme.md +243 -0
  32. package/registry/registry-data.json +121015 -0
  33. package/registry/registry-template.html +126 -0
  34. package/registry/registry.js +1395 -0
  35. package/registry/test-runner.js +237 -0
  36. package/root-template.html +124 -0
  37. package/server.js +524 -0
  38. package/shl/private-key.pem +5 -0
  39. package/shl/public-key.pem +18 -0
  40. package/shl/shl.js +1125 -0
  41. package/shl/vhl.js +69 -0
  42. package/static/FHIRsmith128.png +0 -0
  43. package/static/FHIRsmith16.png +0 -0
  44. package/static/FHIRsmith32.png +0 -0
  45. package/static/FHIRsmith64.png +0 -0
  46. package/static/assets/css/bootstrap-fhir.css +5302 -0
  47. package/static/assets/css/bootstrap-glyphicons.css +2 -0
  48. package/static/assets/css/bootstrap.css +4097 -0
  49. package/static/assets/css/jquery-ui.css +523 -0
  50. package/static/assets/css/jquery-ui.structure.css +863 -0
  51. package/static/assets/css/jquery-ui.structure.min.css +5 -0
  52. package/static/assets/css/jquery-ui.theme.css +439 -0
  53. package/static/assets/css/jquery-ui.theme.min.css +5 -0
  54. package/static/assets/css/jquery.ui.all.css +7 -0
  55. package/static/assets/css/modules.css +18 -0
  56. package/static/assets/css/project.css +367 -0
  57. package/static/assets/css/pygments-manni.css +66 -0
  58. package/static/assets/css/tags.css +74 -0
  59. package/static/assets/css/xml.css +2 -0
  60. package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
  61. package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
  62. package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
  63. package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
  64. package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
  65. package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
  66. package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
  67. package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
  68. package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
  69. package/static/assets/ico/favicon.ico +0 -0
  70. package/static/assets/ico/favicon.png +0 -0
  71. package/static/assets/images/fhir-logo-www.png +0 -0
  72. package/static/assets/images/fhir-logo.png +0 -0
  73. package/static/assets/images/hl7-logo.png +0 -0
  74. package/static/assets/images/logo_ansinew.jpg +0 -0
  75. package/static/assets/images/search.png +0 -0
  76. package/static/assets/images/stripe.png +0 -0
  77. package/static/assets/images/target.png +0 -0
  78. package/static/assets/images/tx-registry-root.gif +0 -0
  79. package/static/assets/images/tx-registry.png +0 -0
  80. package/static/assets/images/tx-server.png +0 -0
  81. package/static/assets/images/tx-version.png +0 -0
  82. package/static/assets/js/bootstrap.min.js +6 -0
  83. package/static/assets/js/fhir-gw.js +259 -0
  84. package/static/assets/js/fhir.js +2 -0
  85. package/static/assets/js/html5shiv.js +8 -0
  86. package/static/assets/js/jcookie.js +96 -0
  87. package/static/assets/js/jquery-ui.min.js +6 -0
  88. package/static/assets/js/jquery.js +10716 -0
  89. package/static/assets/js/jquery.min.js +2 -0
  90. package/static/assets/js/jquery.ui.core.js +314 -0
  91. package/static/assets/js/jquery.ui.draggable.js +825 -0
  92. package/static/assets/js/jquery.ui.mouse.js +162 -0
  93. package/static/assets/js/jquery.ui.resizable.js +842 -0
  94. package/static/assets/js/jquery.ui.widget.js +268 -0
  95. package/static/assets/js/json2.js +487 -0
  96. package/static/assets/js/jtip.js +97 -0
  97. package/static/assets/js/respond.min.js +6 -0
  98. package/static/assets/js/statuspage.js +70 -0
  99. package/static/assets/js/xml.js +2 -0
  100. package/static/dist/js/bootstrap.js +1964 -0
  101. package/static/favicon.png +0 -0
  102. package/static/fhir.css +626 -0
  103. package/static/icon-fhir-16.png +0 -0
  104. package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
  105. package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
  106. package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
  107. package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
  108. package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
  109. package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  110. package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
  111. package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
  112. package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
  113. package/static/images/ui-icons_222222_256x240.png +0 -0
  114. package/static/images/ui-icons_228ef1_256x240.png +0 -0
  115. package/static/images/ui-icons_ef8c08_256x240.png +0 -0
  116. package/static/images/ui-icons_ffd27a_256x240.png +0 -0
  117. package/static/images/ui-icons_ffffff_256x240.png +0 -0
  118. package/static/js/jquery.effects.blind.js +49 -0
  119. package/static/js/jquery.effects.bounce.js +78 -0
  120. package/static/js/jquery.effects.clip.js +54 -0
  121. package/static/js/jquery.effects.core.js +763 -0
  122. package/static/js/jquery.effects.drop.js +50 -0
  123. package/static/js/jquery.effects.explode.js +79 -0
  124. package/static/js/jquery.effects.fade.js +32 -0
  125. package/static/js/jquery.effects.fold.js +56 -0
  126. package/static/js/jquery.effects.highlight.js +50 -0
  127. package/static/js/jquery.effects.pulsate.js +51 -0
  128. package/static/js/jquery.effects.scale.js +178 -0
  129. package/static/js/jquery.effects.shake.js +57 -0
  130. package/static/js/jquery.effects.slide.js +50 -0
  131. package/static/js/jquery.effects.transfer.js +45 -0
  132. package/static/js/jquery.ui.accordion.js +611 -0
  133. package/static/js/jquery.ui.autocomplete.js +612 -0
  134. package/static/js/jquery.ui.button.js +416 -0
  135. package/static/js/jquery.ui.datepicker.js +1823 -0
  136. package/static/js/jquery.ui.dialog.js +878 -0
  137. package/static/js/jquery.ui.droppable.js +296 -0
  138. package/static/js/jquery.ui.position.js +252 -0
  139. package/static/js/jquery.ui.progressbar.js +109 -0
  140. package/static/js/jquery.ui.selectable.js +266 -0
  141. package/static/js/jquery.ui.slider.js +666 -0
  142. package/static/js/jquery.ui.sortable.js +1077 -0
  143. package/static/js/jquery.ui.tabs.js +758 -0
  144. package/stats.js +80 -0
  145. package/test-cache/vsac/vsac-valuesets.db +0 -0
  146. package/token/nginx_passport_setup.md +383 -0
  147. package/token/security_guide.md +294 -0
  148. package/token/token-template.html +330 -0
  149. package/token/token.js +1300 -0
  150. package/translations/Messages.properties +1510 -0
  151. package/translations/Messages_ar.properties +1399 -0
  152. package/translations/Messages_de.properties +836 -0
  153. package/translations/Messages_es.properties +737 -0
  154. package/translations/Messages_fr.properties +1 -0
  155. package/translations/Messages_ja.properties +893 -0
  156. package/translations/Messages_nl.properties +1357 -0
  157. package/translations/Messages_pt.properties +1302 -0
  158. package/translations/Messages_ru.properties +1 -0
  159. package/translations/Messages_uz.properties +1 -0
  160. package/translations/Messages_zh.properties +1 -0
  161. package/translations/rendering-phrases.properties +1128 -0
  162. package/translations/rendering-phrases_ar.properties +1091 -0
  163. package/translations/rendering-phrases_de.properties +6 -0
  164. package/translations/rendering-phrases_es.properties +6 -0
  165. package/translations/rendering-phrases_fr.properties +624 -0
  166. package/translations/rendering-phrases_ja.properties +21 -0
  167. package/translations/rendering-phrases_nl.properties +970 -0
  168. package/translations/rendering-phrases_pt.properties +1020 -0
  169. package/translations/rendering-phrases_ru.properties +1094 -0
  170. package/translations/rendering-phrases_uz.properties +1 -0
  171. package/translations/rendering-phrases_zh.properties +1 -0
  172. package/tx/README.md +418 -0
  173. package/tx/cm/cm-api.js +110 -0
  174. package/tx/cm/cm-database.js +735 -0
  175. package/tx/cm/cm-package.js +325 -0
  176. package/tx/cs/cs-api.js +789 -0
  177. package/tx/cs/cs-areacode.js +615 -0
  178. package/tx/cs/cs-country.js +1110 -0
  179. package/tx/cs/cs-cpt.js +785 -0
  180. package/tx/cs/cs-cs.js +1579 -0
  181. package/tx/cs/cs-currency.js +539 -0
  182. package/tx/cs/cs-db.js +1321 -0
  183. package/tx/cs/cs-hgvs.js +329 -0
  184. package/tx/cs/cs-lang.js +465 -0
  185. package/tx/cs/cs-loinc.js +1485 -0
  186. package/tx/cs/cs-mimetypes.js +238 -0
  187. package/tx/cs/cs-ndc.js +704 -0
  188. package/tx/cs/cs-omop.js +1025 -0
  189. package/tx/cs/cs-provider-api.js +43 -0
  190. package/tx/cs/cs-provider-list.js +37 -0
  191. package/tx/cs/cs-rxnorm.js +808 -0
  192. package/tx/cs/cs-snomed.js +1102 -0
  193. package/tx/cs/cs-ucum.js +514 -0
  194. package/tx/cs/cs-unii.js +271 -0
  195. package/tx/cs/cs-uri.js +218 -0
  196. package/tx/cs/cs-usstates.js +305 -0
  197. package/tx/dev.fhir.org.yml +14 -0
  198. package/tx/fixtures/test-cases-setup.json +18 -0
  199. package/tx/fixtures/test-cases.yml +16 -0
  200. package/tx/html/codesystem-operations.liquid +25 -0
  201. package/tx/html/home-metrics.liquid +247 -0
  202. package/tx/html/operations-form.liquid +148 -0
  203. package/tx/html/search-form.liquid +62 -0
  204. package/tx/html/tx-template.html +133 -0
  205. package/tx/html/valueset-operations.liquid +54 -0
  206. package/tx/importers/atc-to-fhir.js +316 -0
  207. package/tx/importers/import-loinc.module.js +1536 -0
  208. package/tx/importers/import-ndc.module.js +1088 -0
  209. package/tx/importers/import-rxnorm.module.js +898 -0
  210. package/tx/importers/import-sct.module.js +2457 -0
  211. package/tx/importers/import-unii.module.js +601 -0
  212. package/tx/importers/readme.md +453 -0
  213. package/tx/importers/subset-loinc.module.js +1081 -0
  214. package/tx/importers/subset-rxnorm.module.js +938 -0
  215. package/tx/importers/tx-import-base.js +351 -0
  216. package/tx/importers/tx-import-settings.js +310 -0
  217. package/tx/importers/tx-import.js +357 -0
  218. package/tx/library/canonical-resource.js +88 -0
  219. package/tx/library/capabilitystatement.js +292 -0
  220. package/tx/library/codesystem.js +774 -0
  221. package/tx/library/conceptmap.js +568 -0
  222. package/tx/library/designations.js +932 -0
  223. package/tx/library/errors.js +77 -0
  224. package/tx/library/extensions.js +117 -0
  225. package/tx/library/namingsystem.js +322 -0
  226. package/tx/library/operation-outcome.js +127 -0
  227. package/tx/library/parameters.js +105 -0
  228. package/tx/library/renderer.js +1559 -0
  229. package/tx/library/terminologycapabilities.js +418 -0
  230. package/tx/library/ucum-parsers.js +1029 -0
  231. package/tx/library/ucum-service.js +370 -0
  232. package/tx/library/ucum-types.js +1099 -0
  233. package/tx/library/valueset.js +543 -0
  234. package/tx/library.js +676 -0
  235. package/tx/ocl/cm-ocl.js +106 -0
  236. package/tx/ocl/cs-ocl.js +39 -0
  237. package/tx/ocl/vs-ocl.js +105 -0
  238. package/tx/operation-context.js +568 -0
  239. package/tx/params.js +613 -0
  240. package/tx/provider.js +403 -0
  241. package/tx/sct/ecl.js +1560 -0
  242. package/tx/sct/expressions.js +2077 -0
  243. package/tx/sct/structures.js +1396 -0
  244. package/tx/tx-html.js +1063 -0
  245. package/tx/tx.fhir.org.yml +39 -0
  246. package/tx/tx.js +927 -0
  247. package/tx/vs/vs-api.js +112 -0
  248. package/tx/vs/vs-database.js +786 -0
  249. package/tx/vs/vs-package.js +358 -0
  250. package/tx/vs/vs-vsac.js +366 -0
  251. package/tx/workers/batch-validate.js +129 -0
  252. package/tx/workers/batch.js +361 -0
  253. package/tx/workers/closure.js +32 -0
  254. package/tx/workers/expand.js +1845 -0
  255. package/tx/workers/lookup.js +407 -0
  256. package/tx/workers/metadata.js +467 -0
  257. package/tx/workers/operations.js +34 -0
  258. package/tx/workers/read.js +164 -0
  259. package/tx/workers/search.js +384 -0
  260. package/tx/workers/subsumes.js +334 -0
  261. package/tx/workers/translate.js +492 -0
  262. package/tx/workers/validate.js +2504 -0
  263. package/tx/workers/worker.js +904 -0
  264. package/tx/xml/capabilitystatement-xml.js +63 -0
  265. package/tx/xml/codesystem-xml.js +62 -0
  266. package/tx/xml/conceptmap-xml.js +65 -0
  267. package/tx/xml/namingsystem-xml.js +65 -0
  268. package/tx/xml/operationoutcome-xml.js +127 -0
  269. package/tx/xml/parameters-xml.js +312 -0
  270. package/tx/xml/terminologycapabilities-xml.js +64 -0
  271. package/tx/xml/valueset-xml.js +64 -0
  272. package/tx/xml/xml-base.js +603 -0
  273. package/vcl/vcl-parser.js +1098 -0
  274. package/vcl/vcl.js +253 -0
  275. package/windows-install.js +19 -0
  276. package/xig/xig-template.html +124 -0
  277. package/xig/xig.js +3049 -0
package/token/token.js ADDED
@@ -0,0 +1,1300 @@
1
+ //
2
+ // Copyright 2025, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ const express = require('express');
8
+ const sqlite3 = require('sqlite3').verbose();
9
+ const bcrypt = require('bcrypt');
10
+ const crypto = require('crypto');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const session = require('express-session');
14
+ const SQLiteStore = require('connect-sqlite3')(session);
15
+ const passport = require('passport');
16
+ const GoogleStrategy = require('passport-google-oauth20').Strategy;
17
+ const FacebookStrategy = require('passport-facebook').Strategy;
18
+ const GitHubStrategy = require('passport-github2').Strategy;
19
+ const rateLimit = require('express-rate-limit');
20
+ const lusca = require('lusca');
21
+ const folders = require('../library/folder-setup');
22
+
23
+
24
+ const Logger = require('../library/logger');
25
+ const htmlServer = require('../library/html-server');
26
+
27
+ class TokenModule {
28
+ constructor(stats) {
29
+ this.router = express.Router();
30
+ this.db = null;
31
+ this.config = null;
32
+ this.log = Logger.getInstance().child({ module: 'token' });
33
+ this.stats = stats;
34
+ }
35
+
36
+ async initialize(config) {
37
+ this.config = config;
38
+ this.log.info('Initializing Token module...');
39
+
40
+ // Initialize database
41
+ await this.initializeDatabase();
42
+
43
+ // Initialize session middleware FIRST
44
+ this.initializeSession();
45
+
46
+ // Initialize security middleware
47
+ this.initializeSecurity();
48
+
49
+ // Initialize Passport
50
+ this.initializePassport();
51
+
52
+ // Initialize routes
53
+ this.initializeRoutes();
54
+
55
+ // Load HTML template
56
+ this.loadTemplate();
57
+
58
+ this.log.info('Token module initialized successfully');
59
+ }
60
+
61
+ async initializeDatabase() {
62
+ const configDb = this.config.database || 'token.db';
63
+ const dbPath = path.isAbsolute(configDb)
64
+ ? configDb
65
+ : folders.ensureFilePath('token', configDb);
66
+
67
+ return new Promise((resolve, reject) => {
68
+ this.db = new sqlite3.Database(dbPath, (err) => {
69
+ if (err) {
70
+ this.log.error('Failed to open database:', err);
71
+ reject(err);
72
+ return;
73
+ }
74
+
75
+ // Create tables
76
+ this.db.serialize(() => {
77
+ // Users table - enhanced with OAuth data
78
+ this.db.run(`
79
+ CREATE TABLE IF NOT EXISTS users (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ email TEXT UNIQUE NOT NULL,
82
+ name TEXT NOT NULL,
83
+ provider TEXT NOT NULL,
84
+ provider_id TEXT NOT NULL,
85
+ profile_data TEXT,
86
+ last_login DATETIME,
87
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
88
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
89
+ is_active BOOLEAN DEFAULT 1
90
+ )
91
+ `);
92
+
93
+ // API Keys table - enhanced with more metadata
94
+ this.db.run(`
95
+ CREATE TABLE IF NOT EXISTS api_keys (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ user_id INTEGER NOT NULL,
98
+ key_hash TEXT UNIQUE NOT NULL,
99
+ key_prefix TEXT NOT NULL,
100
+ name TEXT NOT NULL,
101
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
102
+ last_used DATETIME,
103
+ expires_at DATETIME,
104
+ is_active BOOLEAN DEFAULT 1,
105
+ created_ip TEXT,
106
+ scopes TEXT DEFAULT 'read',
107
+ FOREIGN KEY (user_id) REFERENCES users (id)
108
+ )
109
+ `);
110
+
111
+ // Usage tracking table
112
+ this.db.run(`
113
+ CREATE TABLE IF NOT EXISTS usage_stats (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ api_key_id INTEGER NOT NULL,
116
+ date DATE NOT NULL,
117
+ request_count INTEGER DEFAULT 0,
118
+ last_request_ip TEXT,
119
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
120
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
121
+ FOREIGN KEY (api_key_id) REFERENCES api_keys (id),
122
+ UNIQUE(api_key_id, date)
123
+ )
124
+ `);
125
+
126
+ // Security audit log
127
+ this.db.run(`
128
+ CREATE TABLE IF NOT EXISTS security_log (
129
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
130
+ user_id INTEGER,
131
+ event_type TEXT NOT NULL,
132
+ ip_address TEXT,
133
+ user_agent TEXT,
134
+ details TEXT,
135
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
136
+ FOREIGN KEY (user_id) REFERENCES users (id)
137
+ )
138
+ `);
139
+
140
+ // Create indexes
141
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`);
142
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id)`);
143
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active)`);
144
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`);
145
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_stats(date)`);
146
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_security_log_user ON security_log(user_id)`);
147
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_security_log_event ON security_log(event_type)`);
148
+ });
149
+
150
+ this.log.info(`Token database initialized: ${dbPath}`);
151
+ resolve();
152
+ });
153
+ });
154
+ }
155
+
156
+ initializeSession() {
157
+ const configDb = this.config.database || 'token.db';
158
+ const sessionDbPath = path.isAbsolute(configDb)
159
+ ? configDb
160
+ : folders.ensureFilePath('token', configDb);
161
+
162
+ const sessionConfig = {
163
+ store: new SQLiteStore({
164
+ dir: path.dirname(sessionDbPath),
165
+ db: path.basename(sessionDbPath),
166
+ table: 'sessions'
167
+ }),
168
+ secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
169
+ name: 'fhir.token.sid', // Don't use default session name
170
+ resave: false,
171
+ saveUninitialized: false,
172
+ rolling: true, // Reset expiration on each request
173
+ cookie: {
174
+ secure: process.env.NODE_ENV === 'production', // HTTPS only in production
175
+ httpOnly: true, // Prevent XSS
176
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
177
+ sameSite: 'lax' // CSRF protection
178
+ }
179
+ };
180
+
181
+ this.router.use(session(sessionConfig));
182
+ }
183
+
184
+ initializeSecurity() {
185
+ // CSRF protection
186
+ this.router.use(lusca({
187
+ csrf: {
188
+ angular: false,
189
+ key: 'csrf',
190
+ secret: this.config.csrfSecret || this.config.sessionSecret
191
+ },
192
+ csp: {
193
+ policy: {
194
+ 'default-src': "'self'",
195
+ 'script-src': "'self' 'unsafe-inline'",
196
+ 'style-src': "'self' 'unsafe-inline'",
197
+ 'img-src': "'self' data: https:",
198
+ 'connect-src': "'self'",
199
+ 'form-action': "'self'"
200
+ }
201
+ },
202
+ xframe: 'SAMEORIGIN',
203
+ hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
204
+ xssProtection: true,
205
+ nosniff: true
206
+ }));
207
+
208
+ // Rate limiting
209
+ const authLimiter = rateLimit({
210
+ windowMs: 15 * 60 * 1000, // 15 minutes
211
+ max: 5, // 5 attempts per window
212
+ message: 'Too many authentication attempts, please try again later.',
213
+ standardHeaders: true,
214
+ legacyHeaders: false,
215
+ skip: (req) => {
216
+ // Skip rate limiting for successful authentications
217
+ return req.user && req.isAuthenticated();
218
+ }
219
+ });
220
+
221
+ const apiLimiter = rateLimit({
222
+ windowMs: 15 * 60 * 1000, // 15 minutes
223
+ max: 100, // 100 requests per window for API calls
224
+ message: 'Too many API requests, please try again later.',
225
+ standardHeaders: true,
226
+ legacyHeaders: false
227
+ });
228
+
229
+ // Apply rate limiting to specific routes
230
+ this.router.use('/auth', authLimiter);
231
+ this.router.use('/api', apiLimiter);
232
+ }
233
+
234
+ initializePassport() {
235
+ // Initialize Passport
236
+ this.router.use(passport.initialize());
237
+ this.router.use(passport.session());
238
+
239
+ // User serialization
240
+ passport.serializeUser((user, done) => {
241
+ done(null, user.id);
242
+ });
243
+
244
+ passport.deserializeUser(async (id, done) => {
245
+ try {
246
+ const user = await this.getUserById(id);
247
+ done(null, user);
248
+ } catch (error) {
249
+ done(error, null);
250
+ }
251
+ });
252
+
253
+ // Configure OAuth strategies
254
+ this.configureOAuthStrategies();
255
+ }
256
+
257
+ configureOAuthStrategies() {
258
+ const oauth = this.config.oauth || {};
259
+
260
+ // Google OAuth Strategy
261
+ if (oauth.google) {
262
+ passport.use(new GoogleStrategy({
263
+ clientID: oauth.google.clientId,
264
+ clientSecret: oauth.google.clientSecret,
265
+ callbackURL: oauth.google.redirectUri,
266
+ scope: oauth.google.scope || ['openid', 'profile', 'email'],
267
+ passReqToCallback: true
268
+ }, async (req, accessToken, refreshToken, profile, done) => {
269
+ try {
270
+ const user = await this.handleOAuthCallback(req, 'google', profile, {
271
+ accessToken, // Don't store this in production
272
+ refreshToken
273
+ });
274
+ return done(null, user);
275
+ } catch (error) {
276
+ return done(error, null);
277
+ }
278
+ }));
279
+ }
280
+
281
+ // Facebook OAuth Strategy
282
+ if (oauth.facebook) {
283
+ passport.use(new FacebookStrategy({
284
+ clientID: oauth.facebook.clientId,
285
+ clientSecret: oauth.facebook.clientSecret,
286
+ callbackURL: oauth.facebook.redirectUri,
287
+ profileFields: ['id', 'emails', 'name'],
288
+ passReqToCallback: true
289
+ }, async (req, accessToken, refreshToken, profile, done) => {
290
+ try {
291
+ const user = await this.handleOAuthCallback(req, 'facebook', profile, {
292
+ accessToken,
293
+ refreshToken
294
+ });
295
+ return done(null, user);
296
+ } catch (error) {
297
+ return done(error, null);
298
+ }
299
+ }));
300
+ }
301
+
302
+ // GitHub OAuth Strategy
303
+ if (oauth.github) {
304
+ passport.use(new GitHubStrategy({
305
+ clientID: oauth.github.clientId,
306
+ clientSecret: oauth.github.clientSecret,
307
+ callbackURL: oauth.github.redirectUri,
308
+ scope: oauth.github.scope || ['user:email'],
309
+ passReqToCallback: true
310
+ }, async (req, accessToken, refreshToken, profile, done) => {
311
+ try {
312
+ const user = await this.handleOAuthCallback(req, 'github', profile, {
313
+ accessToken,
314
+ refreshToken
315
+ });
316
+ return done(null, user);
317
+ } catch (error) {
318
+ return done(error, null);
319
+ }
320
+ }));
321
+ }
322
+
323
+ // Log configured strategies
324
+ const configuredStrategies = Object.keys(oauth).filter(provider => oauth[provider]);
325
+ this.log.info('OAuth strategies configured:', configuredStrategies);
326
+ }
327
+
328
+ async handleOAuthCallback(req, provider, profile, tokens) {
329
+ const email = this.extractEmail(profile);
330
+ const name = this.extractName(profile);
331
+
332
+ if (!email) {
333
+ throw new Error(`No email provided by ${provider}`);
334
+ }
335
+
336
+ // Find or create user
337
+ const userData = {
338
+ email,
339
+ name,
340
+ provider,
341
+ provider_id: profile.id,
342
+ profile_data: JSON.stringify({
343
+ raw: profile._raw,
344
+ photos: profile.photos
345
+ })
346
+ };
347
+
348
+ const userId = await this.findOrCreateUser(userData);
349
+
350
+ // Update last login
351
+ await this.updateUserLastLogin(userId);
352
+
353
+ // Log successful authentication
354
+ await this.logSecurityEvent(userId, 'oauth_login', req.ip, req.get('User-Agent'), {
355
+ provider,
356
+ provider_id: profile.id
357
+ });
358
+
359
+ return await this.getUserById(userId);
360
+ }
361
+
362
+ extractEmail(profile) {
363
+ if (profile.emails && profile.emails.length > 0) {
364
+ return profile.emails[0].value;
365
+ }
366
+ if (profile._json && profile._json.email) {
367
+ return profile._json.email;
368
+ }
369
+ return null;
370
+ }
371
+
372
+ extractName(profile) {
373
+ if (profile.displayName) {
374
+ return profile.displayName;
375
+ }
376
+ if (profile.name) {
377
+ if (typeof profile.name === 'string') {
378
+ return profile.name;
379
+ }
380
+ return `${profile.name.givenName || ''} ${profile.name.familyName || ''}`.trim();
381
+ }
382
+ return profile.username || 'Unknown User';
383
+ }
384
+
385
+ loadTemplate() {
386
+ const templatePath = path.join(__dirname, 'token-template.html');
387
+ if (fs.existsSync(templatePath)) {
388
+ htmlServer.loadTemplate('token', templatePath);
389
+ this.log.info('Token template loaded');
390
+ } else {
391
+ this.log.warn('Token template not found, using default');
392
+ }
393
+ }
394
+
395
+ initializeRoutes() {
396
+ // Web interface routes
397
+ this.router.get('/', this.renderDashboard.bind(this));
398
+ this.router.get('/login', this.renderLogin.bind(this));
399
+ this.router.post('/logout', this.requireAuth.bind(this), this.handleLogout.bind(this));
400
+
401
+ // OAuth routes - using Passport
402
+ this.router.get('/auth/google',
403
+ passport.authenticate('google', {
404
+ scope: ['openid', 'profile', 'email'],
405
+ prompt: 'select_account' // Force account selection
406
+ })
407
+ );
408
+
409
+ this.router.get('/auth/google/callback',
410
+ passport.authenticate('google', {
411
+ failureRedirect: '/token/login?error=oauth_failed',
412
+ successRedirect: '/token'
413
+ })
414
+ );
415
+
416
+ this.router.get('/auth/facebook',
417
+ passport.authenticate('facebook', { scope: ['email'] })
418
+ );
419
+
420
+ this.router.get('/auth/facebook/callback',
421
+ passport.authenticate('facebook', {
422
+ failureRedirect: '/token/login?error=oauth_failed',
423
+ successRedirect: '/token'
424
+ })
425
+ );
426
+
427
+ this.router.get('/auth/github',
428
+ passport.authenticate('github', { scope: ['user:email'] })
429
+ );
430
+
431
+ this.router.get('/auth/github/callback',
432
+ passport.authenticate('github', {
433
+ failureRedirect: '/token/login?error=oauth_failed',
434
+ successRedirect: '/token'
435
+ })
436
+ );
437
+
438
+ // API Key management
439
+ this.router.post('/keys', this.requireAuth.bind(this), this.createApiKey.bind(this));
440
+ this.router.delete('/keys/:id', this.requireAuth.bind(this), this.deleteApiKey.bind(this));
441
+
442
+ // JSON API routes for other servers
443
+ this.router.get('/api/validate/:key', this.validateApiKey.bind(this));
444
+ this.router.post('/api/usage/:key', this.recordUsage.bind(this));
445
+
446
+ // Statistics
447
+ this.router.get('/api/stats/:key', this.getUsageStats.bind(this));
448
+ }
449
+
450
+ // Middleware
451
+ requireAuth(req, res, next) {
452
+ if (!req.isAuthenticated()) {
453
+ return res.redirect('/token/login');
454
+ }
455
+ next();
456
+ }
457
+
458
+ // Web interface handlers
459
+ async renderDashboard(req, res) {
460
+ const start = Date.now();
461
+ try {
462
+
463
+ try {
464
+ if (!req.isAuthenticated()) {
465
+ return res.redirect('/token/login');
466
+ }
467
+
468
+ const user = req.user;
469
+ const apiKeys = await this.getUserApiKeys(user.id);
470
+ const usageStats = await this.getUserUsageStats(user.id);
471
+
472
+ const content = this.buildDashboardContent(user, apiKeys, usageStats);
473
+
474
+ if (htmlServer.hasTemplate('token')) {
475
+ htmlServer.sendHtmlResponse(res, 'token', 'API Key Dashboard', content);
476
+ } else {
477
+ res.send(this.buildSimpleHtml('API Key Dashboard', content));
478
+ }
479
+ } catch (error) {
480
+ this.log.error('Error rendering dashboard:', error);
481
+ res.status(500).json({error: 'Internal server error'});
482
+ }
483
+ } finally {
484
+ this.stats.countRequest('dashboard', Date.now() - start);
485
+ }
486
+ }
487
+
488
+ renderLogin(req, res) {
489
+ const start = Date.now();
490
+ try {
491
+
492
+ if (req.isAuthenticated()) {
493
+ return res.redirect('/token');
494
+ }
495
+
496
+ const oauth = this.config.oauth || {};
497
+ const providers = Object.keys(oauth).filter(provider => oauth[provider]);
498
+ const error = req.query.error;
499
+
500
+ const content = this.buildLoginContent(providers, error);
501
+
502
+ if (htmlServer.hasTemplate('token')) {
503
+ htmlServer.sendHtmlResponse(res, 'token', 'Login', content);
504
+ } else {
505
+ res.send(this.buildSimpleHtml('Login', content));
506
+ }
507
+ } finally {
508
+ this.stats.countRequest('login', Date.now() - start);
509
+ }
510
+ }
511
+
512
+ async handleLogout(req, res) {
513
+ const start = Date.now();
514
+ try {
515
+
516
+ const userId = req.user ? req.user.id : null;
517
+
518
+ req.logout((err) => {
519
+ if (err) {
520
+ this.log.error('Logout error:', err);
521
+ }
522
+
523
+ req.session.destroy((err) => {
524
+ if (err) {
525
+ this.log.error('Session destruction error:', err);
526
+ }
527
+
528
+ if (userId) {
529
+ this.logSecurityEvent(userId, 'logout', req.ip, req.get('User-Agent'), {});
530
+ }
531
+
532
+ res.redirect('/token/login');
533
+ });
534
+ });
535
+ } finally {
536
+ this.stats.countRequest('logout', Date.now() - start);
537
+ }
538
+ }
539
+
540
+ // API Key management
541
+ async createApiKey(req, res) {
542
+ const start = Date.now();
543
+ try {
544
+
545
+ try {
546
+ const {name, scopes = 'read'} = req.body;
547
+ const userId = req.user.id;
548
+
549
+ if (!name || name.length < 3) {
550
+ return res.status(400).json({error: 'Key name must be at least 3 characters'});
551
+ }
552
+
553
+ // Check key limit per user
554
+ const existingKeys = await this.getUserApiKeys(userId);
555
+ const maxKeys = this.config.apiKeys?.maxKeysPerUser || 10;
556
+
557
+ if (existingKeys.length >= maxKeys) {
558
+ return res.status(400).json({
559
+ error: `Maximum ${maxKeys} API keys allowed per user`
560
+ });
561
+ }
562
+
563
+ // Generate API key
564
+ const keyData = crypto.randomBytes(32);
565
+ const keyPrefix = 'tk_' + crypto.randomBytes(4).toString('hex');
566
+ const keySuffix = keyData.toString('hex');
567
+ const fullKey = keyPrefix + keySuffix;
568
+
569
+ const keyHash = await bcrypt.hash(fullKey, 12);
570
+
571
+ // Store in database
572
+ const keyId = await this.storeApiKey(userId, keyHash, keyPrefix, name, scopes, req.ip);
573
+
574
+ // Log key creation
575
+ await this.logSecurityEvent(userId, 'api_key_created', req.ip, req.get('User-Agent'), {
576
+ key_id: keyId,
577
+ key_name: name,
578
+ scopes
579
+ });
580
+
581
+ res.json({
582
+ apiKey: fullKey,
583
+ message: 'API key created successfully',
584
+ warning: 'Please save this key now. You will not be able to see it again.'
585
+ });
586
+ } catch (error) {
587
+ this.log.error('Error creating API key:', error);
588
+ res.status(500).json({error: 'Failed to create API key'});
589
+ }
590
+ } finally {
591
+ this.stats.countRequest('createKey', Date.now() - start);
592
+ }
593
+ }
594
+
595
+ async deleteApiKey(req, res) {
596
+ const start = Date.now();
597
+ try {
598
+
599
+ try {
600
+ const keyId = parseInt(req.params.id);
601
+ const userId = req.user.id;
602
+
603
+ if (isNaN(keyId)) {
604
+ return res.status(400).json({error: 'Invalid key ID'});
605
+ }
606
+
607
+ const deleted = await this.removeApiKey(keyId, userId);
608
+
609
+ if (!deleted) {
610
+ return res.status(404).json({error: 'API key not found'});
611
+ }
612
+
613
+ // Log key deletion
614
+ await this.logSecurityEvent(userId, 'api_key_deleted', req.ip, req.get('User-Agent'), {
615
+ key_id: keyId
616
+ });
617
+
618
+ res.json({message: 'API key deleted successfully'});
619
+ } catch (error) {
620
+ this.log.error('Error deleting API key:', error);
621
+ res.status(500).json({error: 'Failed to delete API key'});
622
+ }
623
+ } finally {
624
+ this.stats.countRequest('deletekey', Date.now() - start);
625
+ }
626
+ }
627
+
628
+ // JSON API for other servers
629
+ async validateApiKey(req, res) {
630
+ const start = Date.now();
631
+ try {
632
+
633
+ try {
634
+ const apiKey = req.params.key;
635
+
636
+ if (!apiKey || !apiKey.startsWith('tk_')) {
637
+ return res.status(400).json({
638
+ valid: false,
639
+ error: 'Invalid key format'
640
+ });
641
+ }
642
+
643
+ const keyInfo = await this.findApiKeyByValue(apiKey);
644
+
645
+ if (!keyInfo) {
646
+ return res.status(404).json({
647
+ valid: false,
648
+ error: 'Key not found'
649
+ });
650
+ }
651
+
652
+ if (!keyInfo.is_active) {
653
+ return res.status(403).json({
654
+ valid: false,
655
+ error: 'Key inactive'
656
+ });
657
+ }
658
+
659
+ // Check expiration
660
+ if (keyInfo.expires_at && new Date(keyInfo.expires_at) < new Date()) {
661
+ return res.status(403).json({
662
+ valid: false,
663
+ error: 'Key expired'
664
+ });
665
+ }
666
+
667
+ // Update last used timestamp
668
+ await this.updateKeyLastUsed(keyInfo.id, req.ip);
669
+
670
+ const allowedRequests = this.config.apiKeys?.defaultAllowedRequests || 50;
671
+
672
+ res.json({
673
+ valid: true,
674
+ user: {
675
+ id: keyInfo.user_id,
676
+ email: keyInfo.email,
677
+ name: keyInfo.name
678
+ },
679
+ key: {
680
+ id: keyInfo.id,
681
+ name: keyInfo.key_name,
682
+ scopes: keyInfo.scopes ? keyInfo.scopes.split(',') : ['read'],
683
+ created_at: keyInfo.created_at
684
+ },
685
+ allowedRequests,
686
+ usage: {
687
+ today: await this.getTodayUsage(keyInfo.id)
688
+ }
689
+ });
690
+ } catch (error) {
691
+ this.log.error('Error validating API key:', error);
692
+ res.status(500).json({
693
+ valid: false,
694
+ error: 'Validation failed'
695
+ });
696
+ }
697
+ } finally {
698
+ this.stats.countRequest('validate', Date.now() - start);
699
+ }
700
+ }
701
+
702
+ async recordUsage(req, res) {
703
+ const start = Date.now();
704
+ try {
705
+
706
+ try {
707
+ const apiKey = req.params.key;
708
+ const {count = 1} = req.body;
709
+
710
+ if (!apiKey || !apiKey.startsWith('tk_')) {
711
+ return res.status(400).json({error: 'Invalid key format'});
712
+ }
713
+
714
+ if (!Number.isInteger(count) || count < 1 || count > 1000) {
715
+ return res.status(400).json({error: 'Count must be between 1 and 1000'});
716
+ }
717
+
718
+ const keyInfo = await this.findApiKeyByValue(apiKey);
719
+ if (!keyInfo || !keyInfo.is_active) {
720
+ return res.status(404).json({error: 'Key not found or inactive'});
721
+ }
722
+
723
+ await this.incrementUsage(keyInfo.id, count, req.ip);
724
+
725
+ res.json({
726
+ message: 'Usage recorded',
727
+ recorded_count: count,
728
+ timestamp: new Date().toISOString()
729
+ });
730
+ } catch (error) {
731
+ this.log.error('Error recording usage:', error);
732
+ res.status(500).json({error: 'Failed to record usage'});
733
+ }
734
+ } finally {
735
+ this.stats.countRequest('usage', Date.now() - start);
736
+ }
737
+ }
738
+
739
+ async getUsageStats(req, res) {
740
+ const start = Date.now();
741
+ try {
742
+
743
+ try {
744
+ const apiKey = req.params.key;
745
+ const days = Math.min(parseInt(req.query.days) || 30, 365); // Max 1 year
746
+
747
+ if (!apiKey || !apiKey.startsWith('tk_')) {
748
+ return res.status(400).json({error: 'Invalid key format'});
749
+ }
750
+
751
+ const keyInfo = await this.findApiKeyByValue(apiKey);
752
+ if (!keyInfo) {
753
+ return res.status(404).json({error: 'Key not found'});
754
+ }
755
+
756
+ const stats = await this.getApiKeyUsageStats(keyInfo.id, days);
757
+
758
+ res.json({
759
+ key_id: keyInfo.id,
760
+ key_name: keyInfo.key_name,
761
+ stats,
762
+ period_days: days
763
+ });
764
+ } catch (error) {
765
+ this.log.error('Error getting usage stats:', error);
766
+ res.status(500).json({error: 'Failed to get usage stats'});
767
+ }
768
+ } finally {
769
+ this.stats.countRequest('stats', Date.now() - start);
770
+ }
771
+ }
772
+
773
+ // Database helper methods
774
+ async findOrCreateUser(userData) {
775
+ return new Promise((resolve, reject) => {
776
+ // First try to find user by email OR by provider combination
777
+ this.db.get(
778
+ 'SELECT id FROM users WHERE (email = ?) OR (provider = ? AND provider_id = ?)',
779
+ [userData.email, userData.provider, userData.provider_id],
780
+ (err, row) => {
781
+ if (err) {
782
+ reject(err);
783
+ return;
784
+ }
785
+
786
+ if (row) {
787
+ // Update existing user
788
+ this.db.run(
789
+ 'UPDATE users SET email = ?, name = ?, profile_data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
790
+ [userData.email, userData.name, userData.profile_data, row.id],
791
+ (err) => {
792
+ if (err) {
793
+ reject(err);
794
+ } else {
795
+ resolve(row.id);
796
+ }
797
+ }
798
+ );
799
+ } else {
800
+ // Create new user
801
+ this.db.run(
802
+ 'INSERT INTO users (email, name, provider, provider_id, profile_data) VALUES (?, ?, ?, ?, ?)',
803
+ [userData.email, userData.name, userData.provider, userData.provider_id, userData.profile_data],
804
+ function(err) {
805
+ if (err) {
806
+ reject(err);
807
+ } else {
808
+ resolve(this.lastID);
809
+ }
810
+ }
811
+ );
812
+ }
813
+ }
814
+ );
815
+ });
816
+ }
817
+
818
+ async getUserById(userId) {
819
+ return new Promise((resolve, reject) => {
820
+ this.db.get(
821
+ 'SELECT * FROM users WHERE id = ? AND is_active = 1',
822
+ [userId],
823
+ (err, row) => {
824
+ if (err) reject(err);
825
+ else resolve(row);
826
+ }
827
+ );
828
+ });
829
+ }
830
+
831
+ async updateUserLastLogin(userId) {
832
+ return new Promise((resolve, reject) => {
833
+ this.db.run(
834
+ 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
835
+ [userId],
836
+ (err) => {
837
+ if (err) reject(err);
838
+ else resolve();
839
+ }
840
+ );
841
+ });
842
+ }
843
+
844
+ async getUserApiKeys(userId) {
845
+ return new Promise((resolve, reject) => {
846
+ this.db.all(
847
+ `SELECT id, name, key_prefix, created_at, last_used, expires_at, is_active, scopes
848
+ FROM api_keys
849
+ WHERE user_id = ?
850
+ ORDER BY created_at DESC`,
851
+ [userId],
852
+ (err, rows) => {
853
+ if (err) reject(err);
854
+ else resolve(rows || []);
855
+ }
856
+ );
857
+ });
858
+ }
859
+
860
+ async storeApiKey(userId, keyHash, keyPrefix, name, scopes, ip) {
861
+ return new Promise((resolve, reject) => {
862
+ const expiresAt = this.config.apiKeys?.keyExpiration ?
863
+ new Date(Date.now() + this.config.apiKeys.keyExpiration).toISOString() :
864
+ null;
865
+
866
+ this.db.run(
867
+ `INSERT INTO api_keys (user_id, key_hash, key_prefix, name, scopes, expires_at, created_ip)
868
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
869
+ [userId, keyHash, keyPrefix, name, scopes, expiresAt, ip],
870
+ function(err) {
871
+ if (err) reject(err);
872
+ else resolve(this.lastID);
873
+ }
874
+ );
875
+ });
876
+ }
877
+
878
+ async findApiKeyByValue(apiKey) {
879
+ return new Promise((resolve, reject) => {
880
+ const keyPrefix = apiKey.substring(0, 11); // 'tk_' + 8 hex chars
881
+
882
+ this.db.all(`
883
+ SELECT ak.*, u.email, u.name, ak.name as key_name
884
+ FROM api_keys ak
885
+ JOIN users u ON ak.user_id = u.id
886
+ WHERE ak.key_prefix = ? AND ak.is_active = 1 AND u.is_active = 1
887
+ `, [keyPrefix], async (err, rows) => {
888
+ if (err) {
889
+ reject(err);
890
+ return;
891
+ }
892
+
893
+ if (!rows || rows.length === 0) {
894
+ resolve(null);
895
+ return;
896
+ }
897
+
898
+ // Check each key hash (there should typically be only one with matching prefix)
899
+ try {
900
+ for (const row of rows) {
901
+ const match = await bcrypt.compare(apiKey, row.key_hash);
902
+ if (match) {
903
+ resolve(row);
904
+ return;
905
+ }
906
+ }
907
+ resolve(null);
908
+ } catch (error) {
909
+ reject(error);
910
+ }
911
+ });
912
+ });
913
+ }
914
+
915
+ async removeApiKey(keyId, userId) {
916
+ return new Promise((resolve, reject) => {
917
+ this.db.run(
918
+ 'UPDATE api_keys SET is_active = 0 WHERE id = ? AND user_id = ?',
919
+ [keyId, userId],
920
+ function(err) {
921
+ if (err) {
922
+ reject(err);
923
+ } else {
924
+ resolve(this.changes > 0);
925
+ }
926
+ }
927
+ );
928
+ });
929
+ }
930
+
931
+ async updateKeyLastUsed(keyId, ip = null) {
932
+ return new Promise((resolve, reject) => {
933
+ this.db.run(
934
+ 'UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?',
935
+ [keyId],
936
+ (err) => {
937
+ if (err) reject(err);
938
+ else resolve();
939
+ }
940
+ );
941
+ });
942
+ }
943
+
944
+ async incrementUsage(keyId, count = 1, ip = null) {
945
+ const today = new Date().toISOString().split('T')[0];
946
+
947
+ return new Promise((resolve, reject) => {
948
+ this.db.run(`
949
+ INSERT INTO usage_stats (api_key_id, date, request_count, last_request_ip)
950
+ VALUES (?, ?, ?, ?)
951
+ ON CONFLICT(api_key_id, date) DO UPDATE SET
952
+ request_count = request_count + ?,
953
+ last_request_ip = ?,
954
+ updated_at = CURRENT_TIMESTAMP
955
+ `, [keyId, today, count, ip, count, ip], function(err) {
956
+ if (err) reject(err);
957
+ else resolve();
958
+ });
959
+ });
960
+ }
961
+
962
+ async getTodayUsage(keyId) {
963
+ const today = new Date().toISOString().split('T')[0];
964
+
965
+ return new Promise((resolve, reject) => {
966
+ this.db.get(
967
+ 'SELECT request_count FROM usage_stats WHERE api_key_id = ? AND date = ?',
968
+ [keyId, today],
969
+ (err, row) => {
970
+ if (err) reject(err);
971
+ else resolve(row ? row.request_count : 0);
972
+ }
973
+ );
974
+ });
975
+ }
976
+
977
+ async getUserUsageStats(userId) {
978
+ return new Promise((resolve, reject) => {
979
+ this.db.all(`
980
+ SELECT ak.name, us.date, us.request_count
981
+ FROM usage_stats us
982
+ JOIN api_keys ak ON us.api_key_id = ak.id
983
+ WHERE ak.user_id = ? AND us.date >= date('now', '-30 days')
984
+ ORDER BY us.date DESC
985
+ `, [userId], (err, rows) => {
986
+ if (err) reject(err);
987
+ else resolve(rows || []);
988
+ });
989
+ });
990
+ }
991
+
992
+ async getApiKeyUsageStats(keyId, days = 30) {
993
+ return new Promise((resolve, reject) => {
994
+ this.db.all(`
995
+ SELECT date, request_count, last_request_ip
996
+ FROM usage_stats
997
+ WHERE api_key_id = ? AND date >= date('now', '-${days} days')
998
+ ORDER BY date DESC
999
+ `, [keyId], (err, rows) => {
1000
+ if (err) reject(err);
1001
+ else resolve(rows || []);
1002
+ });
1003
+ });
1004
+ }
1005
+
1006
+ async logSecurityEvent(userId, eventType, ip, userAgent, details) {
1007
+ return new Promise((resolve, reject) => {
1008
+ this.db.run(
1009
+ 'INSERT INTO security_log (user_id, event_type, ip_address, user_agent, details) VALUES (?, ?, ?, ?, ?)',
1010
+ [userId, eventType, ip, userAgent, JSON.stringify(details)],
1011
+ function(err) {
1012
+ if (err) {
1013
+ // Don't fail the main operation if logging fails
1014
+ console.error('Failed to log security event:', err);
1015
+ }
1016
+ resolve();
1017
+ }
1018
+ );
1019
+ });
1020
+ }
1021
+
1022
+ // Content builders
1023
+ buildDashboardContent(user, apiKeys, usageStats) {
1024
+ let content = `
1025
+ <div class="row mb-4">
1026
+ <div class="col-12">
1027
+ <div class="d-flex justify-content-between align-items-center">
1028
+ <div>
1029
+ <h3>Welcome, ${htmlServer.escapeHtml(user.name)}</h3>
1030
+ <p class="text-muted">Email: ${htmlServer.escapeHtml(user.email)} | Provider: ${htmlServer.escapeHtml(user.provider)}</p>
1031
+ </div>
1032
+ <form method="POST" action="/token/logout" class="d-inline">
1033
+ <button type="submit" class="btn btn-outline-secondary">Logout</button>
1034
+ </form>
1035
+ </div>
1036
+ </div>
1037
+ </div>
1038
+
1039
+ <div class="row mb-4">
1040
+ <div class="col-md-8">
1041
+ <div class="card">
1042
+ <div class="card-header">
1043
+ <h4 class="card-title mb-0">Create New API Key</h4>
1044
+ </div>
1045
+ <div class="card-body">
1046
+ <form id="create-key-form">
1047
+ <div class="mb-3">
1048
+ <label for="keyName" class="form-label">Key Name</label>
1049
+ <input type="text" class="form-control" id="keyName" name="name"
1050
+ minlength="3" maxlength="50" required
1051
+ placeholder="e.g., My FHIR App, Development Server">
1052
+ <div class="form-text">Choose a descriptive name to identify this API key</div>
1053
+ </div>
1054
+ <div class="mb-3">
1055
+ <label for="keyScopes" class="form-label">Scopes</label>
1056
+ <select class="form-control" id="keyScopes" name="scopes">
1057
+ <option value="read">Read Only</option>
1058
+ <option value="read,write">Read & Write</option>
1059
+ </select>
1060
+ <div class="form-text">Permissions this API key will have</div>
1061
+ </div>
1062
+ <button type="submit" class="btn btn-primary">Create API Key</button>
1063
+ </form>
1064
+ </div>
1065
+ </div>
1066
+ </div>
1067
+ <div class="col-md-4">
1068
+ <div class="card">
1069
+ <div class="card-header">
1070
+ <h5 class="card-title mb-0">Quick Stats</h5>
1071
+ </div>
1072
+ <div class="card-body">
1073
+ <div class="mb-2">
1074
+ <strong>Active Keys:</strong> ${apiKeys.filter(k => k.is_active).length}
1075
+ </div>
1076
+ <div class="mb-2">
1077
+ <strong>Total Keys:</strong> ${apiKeys.length}
1078
+ </div>
1079
+ <div class="mb-2">
1080
+ <strong>Last Login:</strong> ${user.last_login ? new Date(user.last_login).toLocaleDateString() : 'First time'}
1081
+ </div>
1082
+ </div>
1083
+ </div>
1084
+ </div>
1085
+ </div>
1086
+
1087
+ <div class="row mb-4">
1088
+ <div class="col-12">
1089
+ <div class="card">
1090
+ <div class="card-header">
1091
+ <h4 class="card-title mb-0">Your API Keys</h4>
1092
+ </div>
1093
+ <div class="card-body">
1094
+ <div class="table-responsive">
1095
+ <table class="table table-striped">
1096
+ <thead>
1097
+ <tr>
1098
+ <th>Name</th>
1099
+ <th>Key Prefix</th>
1100
+ <th>Scopes</th>
1101
+ <th>Created</th>
1102
+ <th>Last Used</th>
1103
+ <th>Expires</th>
1104
+ <th>Status</th>
1105
+ <th>Actions</th>
1106
+ </tr>
1107
+ </thead>
1108
+ <tbody>
1109
+ `;
1110
+
1111
+ if (apiKeys.length === 0) {
1112
+ content += `
1113
+ <tr>
1114
+ <td colspan="8" class="text-center text-muted py-4">
1115
+ No API keys created yet. Create your first API key above.
1116
+ </td>
1117
+ </tr>
1118
+ `;
1119
+ } else {
1120
+ apiKeys.forEach(key => {
1121
+ const status = key.is_active ? 'Active' : 'Inactive';
1122
+ const statusClass = key.is_active ? 'text-success' : 'text-muted';
1123
+ const lastUsed = key.last_used ? new Date(key.last_used).toLocaleDateString() : 'Never';
1124
+ const expires = key.expires_at ? new Date(key.expires_at).toLocaleDateString() : 'Never';
1125
+ const scopes = key.scopes || 'read';
1126
+
1127
+ content += `
1128
+ <tr>
1129
+ <td><strong>${htmlServer.escapeHtml(key.name)}</strong></td>
1130
+ <td><code>${htmlServer.escapeHtml(key.key_prefix)}...</code></td>
1131
+ <td><span class="badge bg-secondary">${htmlServer.escapeHtml(scopes)}</span></td>
1132
+ <td>${new Date(key.created_at).toLocaleDateString()}</td>
1133
+ <td>${lastUsed}</td>
1134
+ <td>${expires}</td>
1135
+ <td><span class="${statusClass}">${status}</span></td>
1136
+ <td>
1137
+ ${key.is_active ?
1138
+ `<button class="btn btn-sm btn-danger" onclick="deleteKey(${key.id}, '${htmlServer.escapeHtml(key.name)}')">Delete</button>` :
1139
+ '<span class="text-muted">-</span>'
1140
+ }
1141
+ </td>
1142
+ </tr>
1143
+ `;
1144
+ });
1145
+ }
1146
+
1147
+ content += `
1148
+ </tbody>
1149
+ </table>
1150
+ </div>
1151
+ </div>
1152
+ </div>
1153
+ </div>
1154
+ `;
1155
+
1156
+ return content;
1157
+ }
1158
+
1159
+ buildLoginContent(providers, error) {
1160
+ let content = `
1161
+ <div class="row justify-content-center">
1162
+ <div class="col-md-6">
1163
+ <div class="card">
1164
+ <div class="card-body">
1165
+ <h3 class="card-title text-center mb-4">Sign In to FHIR Token Server</h3>
1166
+ <p class="text-center text-muted mb-4">
1167
+ Choose a provider to sign in and manage your API keys
1168
+ </p>
1169
+ `;
1170
+
1171
+ if (error) {
1172
+ let errorMessage = 'Authentication failed. Please try again.';
1173
+ if (error === 'oauth_failed') {
1174
+ errorMessage = 'OAuth authentication failed. Please ensure you approve the requested permissions.';
1175
+ }
1176
+
1177
+ content += `
1178
+ <div class="alert alert-danger" role="alert">
1179
+ <strong>Error:</strong> ${htmlServer.escapeHtml(errorMessage)}
1180
+ </div>
1181
+ `;
1182
+ }
1183
+
1184
+ if (providers.length === 0) {
1185
+ content += `
1186
+ <div class="alert alert-warning">
1187
+ <strong>No OAuth providers configured.</strong><br>
1188
+ Please configure OAuth providers in your server configuration.
1189
+ </div>
1190
+ `;
1191
+ } else {
1192
+ providers.forEach(provider => {
1193
+ const providerName = provider.charAt(0).toUpperCase() + provider.slice(1);
1194
+ let iconClass = 'fab fa-' + provider;
1195
+ let buttonClass = 'btn-outline-primary';
1196
+
1197
+ // Provider-specific styling
1198
+ switch(provider) {
1199
+ case 'google':
1200
+ buttonClass = 'btn-outline-danger';
1201
+ break;
1202
+ case 'facebook':
1203
+ buttonClass = 'btn-outline-primary';
1204
+ break;
1205
+ case 'github':
1206
+ buttonClass = 'btn-outline-dark';
1207
+ break;
1208
+ }
1209
+
1210
+ content += `
1211
+ <div class="d-grid gap-2 mb-3">
1212
+ <a href="/token/auth/${provider}" class="btn ${buttonClass} btn-lg oauth-provider">
1213
+ <i class="${iconClass} me-2"></i>
1214
+ Sign in with ${providerName}
1215
+ </a>
1216
+ </div>
1217
+ `;
1218
+ });
1219
+ }
1220
+
1221
+ content += `
1222
+ <hr class="my-4">
1223
+ <div class="text-center">
1224
+ <small class="text-muted">
1225
+ By signing in, you agree to our terms of service and privacy policy.<br>
1226
+ We only request the minimum permissions needed to identify you.
1227
+ </small>
1228
+ </div>
1229
+ </div>
1230
+ </div>
1231
+
1232
+ <div class="card mt-4">
1233
+ <div class="card-body">
1234
+ <h5 class="card-title">Security Notice</h5>
1235
+ <ul class="list-unstyled mb-0">
1236
+ <li><i class="fas fa-shield-alt text-success me-2"></i>All API keys are securely hashed</li>
1237
+ <li><i class="fas fa-lock text-success me-2"></i>HTTPS required for all OAuth flows</li>
1238
+ <li><i class="fas fa-clock text-success me-2"></i>Sessions expire after 24 hours</li>
1239
+ <li><i class="fas fa-chart-line text-success me-2"></i>All access is logged and monitored</li>
1240
+ </ul>
1241
+ </div>
1242
+ </div>
1243
+ </div>
1244
+ </div>
1245
+ `;
1246
+
1247
+ return content;
1248
+ }
1249
+
1250
+ buildSimpleHtml(title, content) {
1251
+ return `
1252
+ <!DOCTYPE html>
1253
+ <html lang="en">
1254
+ <head>
1255
+ <title>${htmlServer.escapeHtml(title)} - FHIR Token Server</title>
1256
+ <meta charset="utf-8">
1257
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1258
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
1259
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
1260
+ </head>
1261
+ <body>
1262
+ <div class="container mt-4">
1263
+ <nav class="mb-4">
1264
+ <a href="/" class="text-decoration-none">← Back to Server Home</a>
1265
+ </nav>
1266
+ <h1 class="mb-4">${htmlServer.escapeHtml(title)}</h1>
1267
+ ${content}
1268
+ </div>
1269
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
1270
+ </body>
1271
+ </html>
1272
+ `;
1273
+ }
1274
+
1275
+ // Status and cleanup
1276
+ getStatus() {
1277
+ return {
1278
+ enabled: true,
1279
+ database: this.db ? 'Connected' : 'Disconnected',
1280
+ strategies: Object.keys(this.config.oauth || {}).filter(p => this.config.oauth[p])
1281
+ };
1282
+ }
1283
+
1284
+ async shutdown() {
1285
+ if (this.db) {
1286
+ await new Promise((resolve) => {
1287
+ this.db.close((err) => {
1288
+ if (err) {
1289
+ this.log.error('Error closing database:', err);
1290
+ } else {
1291
+ this.log.info('Database connection closed');
1292
+ }
1293
+ resolve();
1294
+ });
1295
+ });
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ module.exports = TokenModule;