diff --git a/package-lock.json b/package-lock.json index 15ebd38..56c2f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "cors": "^2.8.5", "dropzone": "^6.0.0-beta.2", "express": "^4.18.2", + "express-session": "^1.17.3", + "jsonwebtoken": "^9.0.2", "lbug": "0.12.2", "moment": "^2.29.4", "monaco-editor": "^0.41.0", @@ -29,6 +31,8 @@ "multer": "^1.4.5-lts.1", "node-gyp": "^12.1.0", "openai": "^4.20.1", + "passport": "^0.7.0", + "passport-oauth2": "^1.8.0", "pinia": "^2.0.23", "pino": "^8.16.1", "pino-pretty": "^10.2.3", @@ -4928,6 +4932,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.28", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", @@ -5168,6 +5181,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7348,6 +7367,15 @@ "node": ">=6.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8072,6 +8100,55 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -9898,12 +9975,67 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/just-extend": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==", "license": "MIT" }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10080,6 +10212,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -10108,6 +10276,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -11400,6 +11574,12 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11484,7 +11664,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -11828,6 +12007,52 @@ "tslib": "^2.0.3" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11905,6 +12130,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13117,6 +13347,15 @@ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "license": "ISC" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -15561,6 +15800,24 @@ "node": ">=8" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index a5e579c..9aac31c 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,11 @@ "sqlite3": "^5.1.7", "uuid": "^9.0.1", "vue": "^3.2.13", - "node-gyp": "^12.1.0" + "node-gyp": "^12.1.0", + "express-session": "^1.17.3", + "jsonwebtoken": "^9.0.2", + "passport": "^0.7.0", + "passport-oauth2": "^1.8.0" }, "devDependencies": { "@babel/core": "^7.12.16", diff --git a/src/server/auth/README.md b/src/server/auth/README.md new file mode 100644 index 0000000..e2df0ad --- /dev/null +++ b/src/server/auth/README.md @@ -0,0 +1,163 @@ +# OIDC Authentication Module + +This module provides OpenID Connect (OIDC) authentication integration with any OIDC-compliant provider (e.g., Keycloak, Auth0, Okta, etc.). It's designed to be independent and minimally invasive to the existing codebase. + +## Features + +- OIDC authentication with any OIDC-compliant provider +- Session-based authentication (email stored in backend session) +- JWT token authentication (optional, as alternative to session-based auth) +- Middleware for protecting API routes +- Automatic redirect to login for unauthenticated users +- HTML login page with error handling +- PKCE (Proof Key for Code Exchange) support for enhanced security +- JWT token generation and validation utilities + +## Configuration + +To enable OIDC authentication, set the following environment variables: + +### Enable/Disable Authentication + +- `AUTH_ENABLED` - Explicitly enable or disable authentication (values: `true` or `false`) + - If set to `false`, authentication is disabled regardless of other settings + - If set to `true`, authentication is enabled (requires OIDC configuration) + - If not set, authentication is automatically enabled if OIDC is configured + +### Required Variables (when AUTH_ENABLED=true or not set) + +- `OIDC_DISCOVERY_URL` - OIDC discovery/well-known URL (e.g., `https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration` or `https://auth.example.com/.well-known/openid-configuration`) +- `OIDC_CLIENT_ID` - Client ID configured in your OIDC provider + +### Optional Variables + +- `OIDC_CLIENT_SECRET` - Client secret (required for confidential clients) +- `OIDC_REDIRECT_URI` - Full redirect URI for OAuth callback (defaults to `${BASE_URL}auth/callback`) +- `OIDC_PROVIDER_NAME` - Display name for the login button (defaults to "OIDC Provider") +- `SESSION_SECRET` - Secret for session encryption (defaults to "change-me-in-production") +- `JWT_SECRET` - Secret for JWT token signing and verification (defaults to "change-me-in-production") +- `BASE_URL` - Base URL path for the application (defaults to "/") + +### Example Configuration + +**Local development (Keycloak on localhost):** +```bash +export AUTH_ENABLED=true +export OIDC_DISCOVERY_URL="http://localhost:8080/realms/myrealm/.well-known/openid-configuration" +export OIDC_CLIENT_ID=myclient +export OIDC_CLIENT_SECRET="Baoidl3z3FcBGWDAZJiNLHypoOT8Enf6" +export OIDC_REDIRECT_URI="http://localhost:8081/auth/callback" +export JWT_SECRET="your-jwt-secret-key-here" +export SESSION_SECRET="your-session-secret-key-here" +``` + +**Production (Keycloak example):** +```bash +export AUTH_ENABLED=true +export OIDC_DISCOVERY_URL="https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration" +export OIDC_CLIENT_ID=my-client +export OIDC_CLIENT_SECRET=my-secret +export OIDC_REDIRECT_URI="https://myapp.example.com/auth/callback" +export OIDC_PROVIDER_NAME="My Company SSO" +export JWT_SECRET="your-strong-jwt-secret-key-here" +export SESSION_SECRET="your-strong-session-secret-key-here" +export BASE_URL=/ +``` + +**With authentication disabled:** +```bash +export AUTH_ENABLED=false +``` + +## OIDC Provider Configuration + +Configure your OIDC provider client with: + +1. **Client ID**: Match `OIDC_CLIENT_ID` +2. **Client Protocol**: `openid-connect` +3. **Access Type**: `public` (for PKCE) or `confidential` (requires client secret) +4. **Valid Redirect URIs**: Add your callback URL (e.g., `https://myapp.example.com/auth/callback`) +5. **Web Origins**: Add your application URL for CORS +6. **Standard Flow Enabled**: Yes +7. **Direct Access Grants Enabled**: Optional (for testing) + +### Discovery URL Examples + +- **Keycloak**: `https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration` +- **Auth0**: `https://{tenant}.auth0.com/.well-known/openid-configuration` +- **Okta**: `https://{domain}/.well-known/openid-configuration` +- **Generic OIDC**: `https://{provider-domain}/.well-known/openid-configuration` + +## How It Works + +1. **Unauthenticated Request**: When a user accesses a protected API route without authentication, the middleware redirects them to `/auth/login` +2. **Login Page**: The login page displays a button that redirects to your OIDC provider for authentication +3. **OAuth Flow**: User authenticates with the OIDC provider, which redirects back to `/auth/callback` +4. **Session Creation**: The callback handler extracts user information (email) and stores it in the session +5. **Authenticated Access**: Subsequent requests include the session cookie, allowing access to protected routes + +## API Protection + +All routes under `/api/*` are automatically protected by the `requireAuth` middleware. The middleware: + +- Checks if `email` exists in the session (session-based authentication) +- Checks for JWT token in `Authorization: Bearer ` header (optional JWT authentication) +- For API requests: Returns `401 Unauthorized` with a `loginUrl` if not authenticated +- For HTML requests: Redirects to the login page + +### JWT Token Authentication + +JWT tokens can be used as an alternative to session-based authentication. To authenticate with a JWT token: + +1. Generate a token by visiting `/token` (GET) or calling `/token` (POST) endpoint +2. Include the token in the `Authorization` header: `Authorization: Bearer ` +3. Tokens expire after 12 hours by default + +The middleware accepts JWT tokens as optional authentication - if a valid JWT token is provided, the request is authenticated even without a session. + +## Routes + +### Authentication Routes + +- `GET /auth/login` - Login page (HTML) +- `GET /auth/callback` - OAuth callback handler +- `GET /auth/logout` - Logout and redirect to OIDC provider logout endpoint + +### Token Routes + +- `GET /token` - Returns HTML page with newly generated JWT token and refresh button +- `POST /token` - Creates a new JWT token with 12 hours expiration (returns JSON) + +**Token Response (POST):** +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expiresAt": "2024-01-01T12:00:00.000Z", + "expiresIn": "12h" +} +``` + +## Disabling Authentication + +Authentication can be disabled in two ways: + +1. **Explicitly disable**: Set `AUTH_ENABLED=false` in your environment variables +2. **Auto-disable**: If `AUTH_ENABLED` is not set and OIDC configuration is missing, authentication is automatically disabled + +When authentication is disabled, all routes are accessible without authentication. + +## Session Storage + +By default, sessions are stored in memory. For production, consider using a persistent session store (e.g., Redis) by modifying the session configuration in `src/server/auth/index.js`. + +## Security Notes + +- Always use HTTPS in production +- Set a strong `SESSION_SECRET` in production +- Set a strong `JWT_SECRET` in production (different from `SESSION_SECRET`) +- Configure secure session cookies (automatically enabled in production mode) +- Use PKCE for public clients (automatically enabled) +- Regularly rotate client secrets and JWT secrets +- JWT tokens expire after 12 hours - regenerate as needed +- Store JWT tokens securely on the client side + diff --git a/src/server/auth/index.js b/src/server/auth/index.js new file mode 100644 index 0000000..9351b35 --- /dev/null +++ b/src/server/auth/index.js @@ -0,0 +1,74 @@ +const session = require('express-session'); +const passport = require('passport'); +const logger = require('../utils/Logger'); +const baseUrl = require('../utils/BaseURL'); +const { initializeOIDC } = require('./oidc'); +const authRoutes = require('./routes'); +const { requireAuth, optionalAuth } = require('./middleware'); +const { isAuthEnabled } = require('./utils'); + +let sessionMiddleware = null; +let authMiddleware = null; + +/** + * Initialize authentication module + * @param {Express} app - Express app instance + * @returns {Promise} - Middleware functions + */ +async function initializeAuth(app) { + if (!isAuthEnabled()) { + logger.info('Authentication is disabled. Skipping auth initialization.'); + return { + sessionMiddleware: (req, res, next) => next(), + requireAuth: (req, res, next) => next(), + optionalAuth: (req, res, next) => next(), + }; + } + + if (!process.env.OIDC_DISCOVERY_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) { + throw new Error('OIDC configuration is incomplete. Skipping auth initialization.'); + } + + // Configure session + const sessionConfig = { + secret: process.env.SESSION_SECRET || 'change-me-in-production', + resave: true, + saveUninitialized: true, + cookie: { + // secure: process.env.NODE_ENV === 'production', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + sameSite: 'lax', + }, + }; + + // Use memory store (can be replaced with Redis, etc.) + sessionMiddleware = session(sessionConfig); + app.use(sessionMiddleware); + app.use(requireAuth); + + // Initialize Passport + app.use(passport.initialize()); + app.use(passport.session()); + + // Initialize OIDC strategy + await initializeOIDC(); + + // Mount auth routes + app.use(`${baseUrl}auth`, authRoutes); + + logger.info('Authentication module initialized with Passport'); + + return { + sessionMiddleware, + requireAuth, + optionalAuth, + }; +} + +module.exports = { + initializeAuth, + requireAuth, + optionalAuth, +}; + diff --git a/src/server/auth/middleware.js b/src/server/auth/middleware.js new file mode 100644 index 0000000..18776d7 --- /dev/null +++ b/src/server/auth/middleware.js @@ -0,0 +1,83 @@ +const logger = require('../utils/Logger'); +const baseUrl = require('../utils/BaseURL'); +const { isAuthEnabled } = require('./utils'); +const { validateJWTToken } = require('../token'); + +/** + * Middleware to check if user is authenticated + * Checks for email in session + */ +function requireAuth(req, res, next) { + console.log('checking requireAuth', req.path); + // Skip auth check for auth routes + const pathToCheck = req.originalUrl || req.path; + if (pathToCheck.includes('/auth/') || pathToCheck.endsWith('/auth')) { + return next(); + } + + // Check if authentication is enabled + if (!isAuthEnabled()) { + // Auth not enabled, allow access + return next(); + } + + // Check if email exists in session + if (req.session && req.session.email) { + console.log('user is authenticated', req.session.email); + // User is authenticated + return next(); + } + + // Check for JWT token as optional auth + const authHeader = req.get('Authorization'); + if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + if (validateJWTToken(token)) { + console.log('user is authenticated via JWT token'); + // User is authenticated via JWT token + return next(); + } + } + + // User not authenticated, redirect to login + const loginUrl = `${baseUrl}auth/login`; + logger.debug(`Unauthenticated request to ${req.path}, redirecting to ${loginUrl}`); + + // Check if this is an API request (by path or Accept header) + const isApiRequest = req.path.startsWith('/api/') || + req.get('Accept')?.includes('application/json') || + req.get('Content-Type')?.includes('application/json'); + + // For API requests, return 401 with loginUrl in JSON + if (isApiRequest) { + logger.debug('Returning 401 for API request', loginUrl); + return res.status(401).json({ + error: 'Unauthorized', + loginUrl: loginUrl + }); + } + + // For HTML/browser requests, redirect to login + logger.debug('Redirecting to login', loginUrl); + return res.redirect(loginUrl); +} + +/** + * Middleware to optionally check auth (doesn't redirect, just sets req.user) + */ +function optionalAuth(req, res, next) { + if (req.session && req.session.email) { + req.user = { + email: req.session.email, + name: req.session.name, + preferred_username: req.session.preferred_username, + }; + } + next(); +} + +module.exports = { + requireAuth, + optionalAuth, +}; + diff --git a/src/server/auth/oidc.js b/src/server/auth/oidc.js new file mode 100644 index 0000000..2d5ec82 --- /dev/null +++ b/src/server/auth/oidc.js @@ -0,0 +1,141 @@ +const passport = require('passport'); +const OAuth2Strategy = require('passport-oauth2'); +const axios = require('axios'); +const logger = require('../utils/Logger'); + +let strategy = null; +let issuerMetadata = null; + +/** + * Initialize OIDC strategy with Passport OAuth2 + */ +async function initializeOIDC() { + try { + const { + OIDC_DISCOVERY_URL, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_REDIRECT_URI, + } = process.env; + + if (!OIDC_DISCOVERY_URL || !OIDC_CLIENT_ID) { + logger.warn('OIDC configuration incomplete. OIDC authentication will be disabled.'); + return null; + } + + // Fetch issuer metadata from discovery URL + logger.info(`Discovering OIDC issuer at: ${OIDC_DISCOVERY_URL}`); + + try { + const response = await axios.get(OIDC_DISCOVERY_URL); + issuerMetadata = response.data; + logger.info(`Discovered issuer: ${issuerMetadata.issuer}`); + } catch (error) { + logger.error(`Failed to fetch issuer metadata: ${error.message}`); + return null; + } + + // Build redirect URI + const redirectUri = OIDC_REDIRECT_URI || '/auth/callback'; + + // Configure Passport OAuth2 strategy for OIDC + strategy = new OAuth2Strategy( + { + authorizationURL: issuerMetadata.authorization_endpoint, + tokenURL: issuerMetadata.token_endpoint, + clientID: OIDC_CLIENT_ID, + clientSecret: OIDC_CLIENT_SECRET, + callbackURL: redirectUri, + scope: 'openid email profile', + }, + async (accessToken, refreshToken, profile, done) => { + try { + // Fetch user info from userinfo endpoint + const userInfoResponse = await axios.get(issuerMetadata.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const userInfo = userInfoResponse.data; + + // Return user object + return done(null, { + email: userInfo.email || userInfo.preferred_username, + name: userInfo.name, + preferred_username: userInfo.preferred_username, + sub: userInfo.sub, + issuer: issuerMetadata.issuer, + accessToken: accessToken, + refreshToken: refreshToken, + }); + } catch (error) { + logger.error(`Failed to fetch user info: ${error.message}`); + return done(error, null); + } + } + ); + + // Override the userProfile method to fetch from userinfo endpoint + strategy.userProfile = async function(accessToken, done) { + try { + const response = await axios.get(issuerMetadata.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return done(null, response.data); + } catch (error) { + return done(error); + } + }; + + // Serialize user for session + passport.serializeUser((user, done) => { + done(null, user); + }); + + // Deserialize user from session + passport.deserializeUser((user, done) => { + done(null, user); + }); + + // Register strategy with Passport + passport.use('oidc', strategy); + + logger.info('OIDC Passport OAuth2 strategy initialized successfully'); + + return strategy; + } catch (error) { + logger.error(`Failed to initialize OIDC strategy: ${error.message}`); + return null; + } +} + +/** + * Get Passport instance + */ +function getPassport() { + return passport; +} + +/** + * Get OIDC strategy instance + */ +function getStrategy() { + return strategy; +} + +/** + * Get issuer metadata + */ +function getIssuerMetadata() { + return issuerMetadata; +} + +module.exports = { + initializeOIDC, + getPassport, + getStrategy, + getIssuerMetadata, +}; diff --git a/src/server/auth/routes.js b/src/server/auth/routes.js new file mode 100644 index 0000000..9bc0a46 --- /dev/null +++ b/src/server/auth/routes.js @@ -0,0 +1,256 @@ +const express = require('express'); +const router = express.Router(); +const logger = require('../utils/Logger'); +const baseUrl = require('../utils/BaseURL'); +const { getPassport, getIssuerMetadata } = require('./oidc'); +const { isAuthEnabled } = require('./utils'); + +const passport = getPassport(); + +/** + * Login page - returns HTML + */ +router.get('/login', (req, res) => { + if (!isAuthEnabled()) { + return res.status(503).send('Authentication is not enabled'); + } + + const issuerMetadata = getIssuerMetadata(); + if (!issuerMetadata) { + return res.status(503).send('OIDC provider not initialized'); + } + + const errorMessages = req.query.error ? [req.query.error] : []; + const providerDisplayName = process.env.OIDC_PROVIDER_NAME || 'OIDC Provider'; + + // Build authorization URL manually for the login page + const redirectUri = process.env.OIDC_REDIRECT_URI || + `${req.protocol}://${req.get('host')}${baseUrl}auth/callback`; + + const authUrl = new URL(issuerMetadata.authorization_endpoint); + authUrl.searchParams.set('client_id', process.env.OIDC_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid email profile'); + + // Render login HTML + const html = ` + + + + + + Login + + + +
+
+

Authentication required:

+

+
+
+ ${errorMessages.length > 0 ? ` +
+
    + ${errorMessages.map(msg => ` +
  • + + ${escapeHtml(msg)} +
  • + `).join('')} +
+
+ + ` : ''} + + + `; + + res.send(html); +}); + +/** + * OAuth callback handler - uses Passport authentication + */ +router.get('/callback', + passport.authenticate('oidc', { + failureRedirect: `${baseUrl}auth/login?error=${encodeURIComponent('Authentication failed')}`, + failureMessage: true + }), + (req, res) => { + try { + // User is authenticated at this point, req.user is set by Passport + const user = req.user; + + // Store user info in session for requireAuth middleware to check + req.session.email = user.email; + req.session.name = user.name; + req.session.preferred_username = user.preferred_username; + req.session.accessToken = user.accessToken; + req.session.refreshToken = user.refreshToken; + + // Explicitly save the session to ensure it's persisted + req.session.save((err) => { + if (err) { + logger.error(`Session save error: ${err.message}`); + return res.redirect(`${baseUrl}auth/login?error=${encodeURIComponent('Session save failed. Please try again.')}`); + } + + logger.info(`User authenticated: ${req.session.email}`); + + // Redirect to home or original destination + const returnTo = req.session.returnTo || baseUrl; + delete req.session.returnTo; + res.redirect(returnTo); + }); + } catch (error) { + logger.error(`Authentication callback error: ${error.message}`); + res.redirect(`${baseUrl}auth/login?error=${encodeURIComponent('Authentication failed. Please try again.')}`); + } + } +); + +/** + * Logout handler + */ +router.get('/logout', (req, res) => { + const issuerMetadata = getIssuerMetadata(); + const email = req.session?.email; + + // Destroy session + req.session.destroy((err) => { + if (err) { + logger.error(`Session destroy error: ${err.message}`); + } + }); + + // Logout from Passport + req.logout((err) => { + if (err) { + logger.error(`Passport logout error: ${err.message}`); + } + }); + + if (issuerMetadata && issuerMetadata.end_session_endpoint && email) { + // Redirect to OIDC provider logout endpoint + const redirectUri = `${req.protocol}://${req.get('host')}${baseUrl}`; + const logoutUrl = new URL(issuerMetadata.end_session_endpoint); + logoutUrl.searchParams.set('redirect_uri', redirectUri); + res.redirect(logoutUrl.toString()); + } else { + res.redirect(baseUrl); + } +}); + +/** + * Helper function to escape HTML + */ +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} + +module.exports = router; diff --git a/src/server/auth/utils.js b/src/server/auth/utils.js new file mode 100644 index 0000000..eb1bcba --- /dev/null +++ b/src/server/auth/utils.js @@ -0,0 +1,15 @@ +/** + * Check if authentication is enabled + * Checks AUTH_ENABLED env var first, then falls back to OIDC config + * @returns {boolean} True if authentication is enabled, false otherwise + */ +function isAuthEnabled() { + // Explicit enable/disable flag takes precedence + if (process.env.AUTH_ENABLED !== undefined) { + return process.env.AUTH_ENABLED.toLowerCase() === 'true'; + } +} + +module.exports = { + isAuthEnabled, +}; diff --git a/src/server/index.js b/src/server/index.js index e3d1060..9211f34 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -6,6 +6,7 @@ const process = require("process"); const database = require("./utils/Database"); const logger = require("./utils/Logger"); const baseUrl = require("./utils/BaseURL"); +const { initializeAuth } = require("./auth"); const CROSS_ORIGIN = process.env.CROSS_ORIGIN ? process.env.CROSS_ORIGIN.toLowerCase() === "true" @@ -28,16 +29,28 @@ if (CROSS_ORIGIN) { } const PORT = process.env.PORT ? parseInt(process.env.PORT) : 8000; app.use(express.json({ limit: "128mb" })); -app.use(`${baseUrl}api`, api); + const distPath = path.join(__dirname, "..", "..", "dist"); app.use(`${baseUrl}`, express.static(distPath, { maxAge: "30d" })); const isWasmMode = process.env.LBUG_WASM && process.env.LBUG_WASM.toLowerCase() === "true"; -if (!isWasmMode) { - database.getDbVersion() - .then((res) => { +// Initialize authentication and start server +async function startServer() { + await initializeAuth(app); + + // Mount API routes + app.use(`${baseUrl}api`, api); + + // Mount token routes + const token = require("./token"); + app.use(`${baseUrl}token`, token); + + // Start server + if (!isWasmMode) { + try { + const res = await database.getDbVersion(); const version = res.version; const storageVersion = res.storageVersion; const isInitialDatabaseEmpty = database.isInitialDatabaseEmpty; @@ -49,12 +62,14 @@ if (!isWasmMode) { app.listen(PORT, () => { logger.info("Deployed server started on port: " + PORT); }); - }) - .catch((err) => { + } catch (err) { logger.error("Error getting version of Lbug: " + err); + } + } else { + app.listen(PORT, () => { + logger.info("Deployed server started on port: " + PORT); }); -} else { - app.listen(PORT, () => { - logger.info("Deployed server started on port: " + PORT); - }); + } } + +startServer(); diff --git a/src/server/token/index.js b/src/server/token/index.js new file mode 100644 index 0000000..b131a82 --- /dev/null +++ b/src/server/token/index.js @@ -0,0 +1,277 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const router = express.Router(); +const logger = require('../utils/Logger'); + +// JWT secret - in production, use environment variable +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; +const JWT_EXPIRATION_HOURS = 12; + +/** + * Generate a JWT token with 12 hours expiration + */ +function generateToken(email, name, preferred_username) { + const payload = { + iat: Math.floor(Date.now() / 1000), + email: email, + name: name, + preferred_username: preferred_username, + }; + + const token = jwt.sign(payload, JWT_SECRET, { + expiresIn: `${JWT_EXPIRATION_HOURS}h`, + }); + + return token; +} + +/** + * Validate JWT token + * @param {string} token - JWT token to validate + * @returns {boolean} True if token is valid, false otherwise + */ +function validateJWTToken(token) { + try { + // Decode token first to check expiration + const decoded = jwt.decode(token); + if (!decoded) { + throw new Error('Invalid token format'); + } + + // Check expiration date explicitly + if (decoded.exp) { + const currentTime = Math.floor(Date.now() / 1000); + if (decoded.exp < currentTime) { + throw new Error('Token has expired'); + } + const expirationDate = new Date(decoded.exp * 1000); + logger.debug(`JWT token expires at: ${expirationDate.toISOString()}`); + } + + // Verify token signature and expiration + jwt.verify(token, JWT_SECRET); + return true; + } catch (error) { + // JWT token is invalid or expired + logger.debug(`Invalid JWT token: ${error.message}`); + return false; + } +} + +/** + * GET /api/token + * Returns HTML page with newly generated JWT and refresh button + */ +router.get('/', (req, res) => { + try { + const token = generateToken( + req.session?.email, req.session?.name, req.session?.preferred_username); + const expirationTime = new Date(Date.now() + JWT_EXPIRATION_HOURS * 60 * 60 * 1000); + const expirationTimeISO = expirationTime.toISOString(); + + const html = ` + + + + + + JWT Token Generator + + + +
+

JWT Token Generator

+
Token expires in: Calculating...
+
Expires at: ${expirationTime.toLocaleString()}
+
${token}
+ + +
+ + + + `; + + res.send(html); + } catch (error) { + logger.error('Error generating token:', error); + res.status(500).send('Error generating token'); + } +}); + +/** + * POST /api/token + * Creates a new JWT token with 12 hours expiration + */ +router.post('/', (req, res) => { + try { + const token = generateToken( + req.session?.email, req.session?.name, req.session?.preferred_username); + const expirationTime = new Date(Date.now() + JWT_EXPIRATION_HOURS * 60 * 60 * 1000); + + res.json({ + token: token, + expiresAt: expirationTime.toISOString(), + expiresIn: `${JWT_EXPIRATION_HOURS}h`, + }); + } catch (error) { + res.status(500).json({ + error: 'Failed to generate token', + message: error.message, + }); + } +}); + +// Export router for route mounting +module.exports = router; + +// Export token utility functions +module.exports.validateJWTToken = validateJWTToken; +module.exports.generateToken = generateToken; diff --git a/src/utils/AxiosWrapper.js b/src/utils/AxiosWrapper.js index 361b85f..f00ba2a 100644 --- a/src/utils/AxiosWrapper.js +++ b/src/utils/AxiosWrapper.js @@ -9,4 +9,23 @@ const axiosInstance = axios.create({ }, }); +// Add response interceptor to handle 401 Unauthorized responses +axiosInstance.interceptors.response.use( + (response) => { + // If the request was successful, return the response + return response; + }, + (error) => { + // Handle 401 Unauthorized - redirect to login + if (error.response && error.response.status === 401) { + const loginUrl = error.response.data?.loginUrl || `${baseURL || '/'}auth/login`; + // Redirect to login page + window.location.href = loginUrl; + return Promise.reject(error); + } + // For other errors, just reject with the error + return Promise.reject(error); + } +); + export default axiosInstance;