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.
- package/CHANGELOG.md +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- 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;
|