From 32232674e8782acdc465fb8e64fc469f5d0777fb Mon Sep 17 00:00:00 2001 From: Enrico Regge Date: Tue, 13 May 2025 16:21:20 +0200 Subject: [PATCH 01/13] introduces an oidc-proxy example --- auth-oidc-proxy/.ceignore | 2 + auth-oidc-proxy/.dockerignore | 2 + auth-oidc-proxy/.gitignore | 2 + auth-oidc-proxy/README.md | 100 ++ auth-oidc-proxy/auth/Dockerfile | 13 + auth-oidc-proxy/auth/index.mjs | 394 ++++++++ auth-oidc-proxy/auth/package-lock.json | 878 ++++++++++++++++++ auth-oidc-proxy/auth/package.json | 17 + .../auth/public/images/favicon.ico | Bin 0 -> 2247 bytes auth-oidc-proxy/build | 26 + .../docs/ce-oidc-proxy-overview.png | Bin 0 -> 70092 bytes auth-oidc-proxy/nginx/Dockerfile | 11 + auth-oidc-proxy/nginx/nginx.conf | 41 + auth-oidc-proxy/nginx/origin-template.conf | 39 + auth-oidc-proxy/nginx/start-nginx | 18 + auth-oidc-proxy/oidc.properties.template | 6 + auth-oidc-proxy/run | 271 ++++++ 17 files changed, 1820 insertions(+) create mode 100644 auth-oidc-proxy/.ceignore create mode 100644 auth-oidc-proxy/.dockerignore create mode 100644 auth-oidc-proxy/.gitignore create mode 100644 auth-oidc-proxy/README.md create mode 100644 auth-oidc-proxy/auth/Dockerfile create mode 100644 auth-oidc-proxy/auth/index.mjs create mode 100644 auth-oidc-proxy/auth/package-lock.json create mode 100644 auth-oidc-proxy/auth/package.json create mode 100644 auth-oidc-proxy/auth/public/images/favicon.ico create mode 100755 auth-oidc-proxy/build create mode 100644 auth-oidc-proxy/docs/ce-oidc-proxy-overview.png create mode 100644 auth-oidc-proxy/nginx/Dockerfile create mode 100644 auth-oidc-proxy/nginx/nginx.conf create mode 100644 auth-oidc-proxy/nginx/origin-template.conf create mode 100755 auth-oidc-proxy/nginx/start-nginx create mode 100644 auth-oidc-proxy/oidc.properties.template create mode 100755 auth-oidc-proxy/run diff --git a/auth-oidc-proxy/.ceignore b/auth-oidc-proxy/.ceignore new file mode 100644 index 000000000..1f071c64a --- /dev/null +++ b/auth-oidc-proxy/.ceignore @@ -0,0 +1,2 @@ +oidc*.properties +node_modules diff --git a/auth-oidc-proxy/.dockerignore b/auth-oidc-proxy/.dockerignore new file mode 100644 index 000000000..1f071c64a --- /dev/null +++ b/auth-oidc-proxy/.dockerignore @@ -0,0 +1,2 @@ +oidc*.properties +node_modules diff --git a/auth-oidc-proxy/.gitignore b/auth-oidc-proxy/.gitignore new file mode 100644 index 000000000..1f071c64a --- /dev/null +++ b/auth-oidc-proxy/.gitignore @@ -0,0 +1,2 @@ +oidc*.properties +node_modules diff --git a/auth-oidc-proxy/README.md b/auth-oidc-proxy/README.md new file mode 100644 index 000000000..6016fce66 --- /dev/null +++ b/auth-oidc-proxy/README.md @@ -0,0 +1,100 @@ +# OIDC Proxy sample + +This sample demonstrates how to configure an authentication/authorization layer that fronts any arbitrary Code Engine application. In principal, this pattern is pretty generic. To demonstrate it, we chose to implement it with OIDC, an authentication framework that is built on top of the OAuth 2.0 protocol. + +The following diagram depicts the components that are involved: +![OIDC Proxy architecture overview](./docs/ce-oidc-proxy-overview.png) + +**Note:** The origin app is not exposed to the public or private network and can only be accessed through the authentication proxy that does an auth check towards an oidc app that got installed into the same project. + + +## Setting up an OIDC SSO configuration + +### Github.com OIDC SSO + +* Create Github OIDC app through https://github.com/settings/developers + ``` + name: jupyter + homepage: https://jupyter-auth...codeengine.appdomain.cloud + callback URL: https://jupyter-auth...codeengine.appdomain.cloud/auth/callback + ``` +* Store the client id and the secret in local file called `oidc.properties` + ``` + OIDC_CLIENT_ID= + OIDC_CLIENT_SECRET= + ``` +* Generate a random cookie secret that is used to encrypt the auth cookie value and add it to the `oidc.properties` file + ``` + COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32) + ``` +* From your OIDC provider obtain the following values and add ithem to the `oidc.properties` file + ``` + OIDC_PROVIDER_AUTHORIZATION_ENDPOINT=https://github.com/login/oauth/authorize + OIDC_PROVIDER_TOKEN_ENDPOINT=https://github.com/login/oauth/access_token + OIDC_PROVIDER_USERINFO_ENDPOINT=https://api.github.com/user + ``` +* To add authorization checks one can either check for a specific user property + ``` + AUTHZ_USER_PROPERTY=login + AUTHZ_ALLOWED_USERS=< + ``` + +### IBMers-only: w3Id OIDC SSO + +* Create w3Id OIDC configuration through https://ies-provisioner.prod.identity-services.intranet.ibm.com/tools/sso/home + ``` + name: jupyter + homepage: https://jupyter-auth...codeengine.appdomain.cloud + callback URL: https://jupyter-auth...codeengine.appdomain.cloud/auth/callback + ``` +* Store the client id and the secret in local file called `oidc.properties` + ``` + OIDC_CLIENT_ID= + OIDC_CLIENT_SECRET= + ``` +* Generate a random cookie secret that is used to encrypt the auth cookie value and add it to the `oidc.properties` file + ``` + COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32) + ``` +* From your OIDC provider obtain the following values and add ithem to the `oidc.properties` file + ``` + OIDC_PROVIDER_AUTHORIZATION_ENDPOINT= + OIDC_PROVIDER_TOKEN_ENDPOINT= + OIDC_PROVIDER_USERINFO_ENDPOINT= + ``` +* To add authorization checks one can either check for a specific user property, for a group property match + ``` + AUTHZ_USER_PROPERTY=preferred_username + AUTHZ_ALLOWED_USERS= + ``` +* Or for a group property match + ``` + AUTHZ_USER_PROPERTY=blueGroups + AUTHZ_ALLOWED_USERS= + ``` + +## Installing the sample + +* Install the Code Engine projects and all required components + ``` + ./run + ``` + +* Tear down the example: + ``` + ./run clean + ``` + +* Install the example and make sure it does not get deleted right-away + ``` + CLEANUP_ON_SUCCESS=false ./run + ``` + +* Following environment variables can be used to tweak the run script + +| Name | Description | Default value | +|:----|:---|:---| +| REGION | Region of the Code Engine project | `eu-es` | +| NAME_PREFIX | Naming prefix used for all components (e.g. resource group, Code Engine project, apps) | `oidc-sample` | +| CLEANUP_ON_SUCCESS | Determines whether the setup should be deleted, right after its successful creation | `true` | +| CLEANUP_ON_ERROR | Determines whether the setup should be deleted, if the setup procedure failed | `true` | diff --git a/auth-oidc-proxy/auth/Dockerfile b/auth-oidc-proxy/auth/Dockerfile new file mode 100644 index 000000000..ef892d4f6 --- /dev/null +++ b/auth-oidc-proxy/auth/Dockerfile @@ -0,0 +1,13 @@ +FROM registry.access.redhat.com/ubi9/nodejs-22:latest AS build-env +WORKDIR /app +COPY index.mjs . +COPY package.json . +RUN npm install + +# Use a small distroless image for as runtime image +FROM gcr.io/distroless/nodejs22-debian12 +COPY --from=build-env /app /app +WORKDIR /app +COPY public/ public/ +EXPOSE 8080 +CMD ["index.mjs"] \ No newline at end of file diff --git a/auth-oidc-proxy/auth/index.mjs b/auth-oidc-proxy/auth/index.mjs new file mode 100644 index 000000000..5a9edcb9a --- /dev/null +++ b/auth-oidc-proxy/auth/index.mjs @@ -0,0 +1,394 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import crypto from "crypto"; +import NodeCache from "node-cache"; + +const requiredEnvVars = [ + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_PROVIDER_AUTHORIZATION_ENDPOINT", + "OIDC_PROVIDER_TOKEN_ENDPOINT", + "OIDC_PROVIDER_USERINFO_ENDPOINT", + "OIDC_REDIRECT_URL", + "COOKIE_SIGNING_ENCRYPTION_KEY", + "COOKIE_DOMAIN", + "REDIRECT_URL", +]; + +requiredEnvVars.forEach((envVarName) => { + if (!process.env[envVarName]) { + console.log(`Missing '${envVarName}' environment variable`); + process.exit(1); + } +}); + +const SESSION_COOKIE = process.env.COOKIE_NAME || "session_token"; +const ENCRYPTION_KEY = Buffer.from(process.env.COOKIE_SIGNING_ENCRYPTION_KEY, "base64"); +const ENCRYPTION_IV = crypto.randomBytes(16); +const ENCRYPTION_ALGORITHM = "aes-256-cbc"; + +// check whether the KEY has got 32 bytes (256-bit) +if (ENCRYPTION_KEY.length != 32) { + console.log( + `Environment variable 'COOKIE_SIGNING_ENCRYPTION_KEY' has wrong length. Current: ${ENCRYPTION_KEY.length}. Expected: 32` + ); + process.exit(1); +} + +// initialize an in-memory cache to avoid fetching the user data on each request +const cache = new NodeCache({ stdTTL: 60 * 5, checkperiod: 120 }); + +// ================================================= +// HELPER FUNCTIONS +// ================================================= + +// helper function to encrypt a string using the given encryption key and iv +function encrypt(plaintext, key, iv) { + const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv); + let ciphertext = cipher.update(plaintext, "utf8", "base64"); + ciphertext += cipher.final("base64"); + return ciphertext; +} + +// helper function to decrypt a string using the given encryption key and iv +function decrypt(ciphertext, key, iv) { + const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + let plaintext = decipher.update(ciphertext, "base64", "utf8"); + plaintext += decipher.final("utf8"); + return plaintext; +} + +// helper function to send JSON responses +function sendJSONResponse(response, returnCode, jsonObject) { + response.status(returnCode); + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(jsonObject)); +} + +// helper function that reads allowlist +function parseAllowlist(listAsStr) { + if (!listAsStr) { + return []; + } + const strArr = listAsStr.split(","); + strArr.forEach((element, index) => { + strArr[index] = element.trim(); + }); + + return strArr; +} + +// ================================================= +// AUTHN AND AUTHZ MIDDLEWARES +// ================================================= + +// +// Initialize authorization checks +const AUTHZ_USER_PROPERTY = process.env.AUTHZ_USER_PROPERTY; +const AUTHZ_ALLOWED_USERS_LIST = parseAllowlist(process.env.AUTHZ_ALLOWED_USERS); +const AUTHZ_GROUP_PROPERTY = process.env.AUTHZ_GROUP_PROPERTY; +const AUTHZ_ALLOWED_GROUPS_LIST = parseAllowlist(process.env.AUTHZ_ALLOWED_GROUPS); +let enforceUserAllowlist = false; +let enforceGroupAllowlist = false; +if (AUTHZ_USER_PROPERTY && AUTHZ_ALLOWED_USERS_LIST.length > 0) { + console.log( + `configured to perform allow list check towards the user property '${AUTHZ_USER_PROPERTY}'. ${AUTHZ_ALLOWED_USERS_LIST.length} allowed users` + ); + enforceUserAllowlist = true; +} else if (AUTHZ_GROUP_PROPERTY && AUTHZ_ALLOWED_GROUPS_LIST.length > 0) { + console.log( + `configured to perform allow list check towards the groups property '${AUTHZ_GROUP_PROPERTY}'. ${AUTHZ_ALLOWED_GROUPS_LIST.length} allowed groups` + ); + enforceGroupAllowlist = true; +} + +function getCorrelationId(req, res, next) { + req.correlationId = req.header("x-request-id") || crypto.randomBytes(8).toString("hex"); + next(); +} + +/** + * Check whether the given user is authentication properly. + * + * If the user is authenticated pass on the request to the middleware or handler + * If the user is NOT authenticated, not return a 401 response + */ +async function checkAuthn(req, res, next) { + const startTime = Date.now(); + const fn = `${req.correlationId} -`; + console.log(`${fn} performing authentication check`); + + const encryptedSessionToken = req.cookies[SESSION_COOKIE]; + + // If the user does not have a session token yet, return a 401 + // It is up to the client to trigger a login procedure + if (!encryptedSessionToken) { + console.log(`${fn} session cookie '${SESSION_COOKIE}' not found`); + return sendJSONResponse(res, 401, { reason: "no_auth" }); + } + + // Decrypt session token + let sessionToken; + try { + sessionToken = decrypt(encryptedSessionToken, ENCRYPTION_KEY, ENCRYPTION_IV); + } catch (err) { + console.log(`${fn} failed to decrypt existing sessionToken - cause: '${err.name}', reason: '${err.message}'`); + + // This error indicates that the encrypted string couldn't get decrypted using the encryption key + // maybe the cookie value has been encrypted with an old key + // full error: 'error:1C800064:Provider routines::bad decrypt' + if (err.message.indexOf("error:1C800064") > -1) { + console.log(`${fn} enryption key has been changed. Deleting existing cookie`); + res.clearCookie(SESSION_COOKIE); + return sendJSONResponse(res, 400, { reason: "invalid_session" }); + } + + // error:1C80006B:Provider routines::wrong final block length + if (err.message.indexOf("error:1C80006B") > -1) { + console.log(`${fn} enryption key has been changed. Deleting existing cookie`); + res.clearCookie(SESSION_COOKIE); + return sendJSONResponse(res, 401, { reason: "invalid_session" }); + } + + // If the decrypt mechanism failed, return a 500 + // It is up to the client to trigger a login procedure + return sendJSONResponse(res, 500, { reason: "decryption_failed" }); + } + + // + // Check whether the user data have been cached + const cachedData = cache.get(sessionToken); + if (cachedData) { + req.user = cachedData; + console.log(`${fn} passed authentication check (cache hit), duration: ${Date.now() - startTime}ms`); + return next(); + } + + // + // Obtain user information for session token (aka acess token) + try { + const fetchStart = Date.now(); + const opts = { + method: "GET", + headers: { + Authorization: `Bearer ${sessionToken}`, + }, + }; + + console.log(`${fn} fetching user data from '${process.env.OIDC_PROVIDER_USERINFO_ENDPOINT}' ...`); + const response = await fetch(process.env.OIDC_PROVIDER_USERINFO_ENDPOINT, opts); + + console.log( + `${fn} received response from '${process.env.OIDC_PROVIDER_USERINFO_ENDPOINT}' - response.ok: '${ + response.ok + }', response.status: '${response.status}', duration: ${Date.now() - fetchStart}ms` + ); + if (!response.ok) { + const errorResponse = await response.text(); + console.log(`${fn} error response: '${errorResponse}'`); + return sendJSONResponse(res, 401, { reason: "auth_failed" }); + } + + const user_data = await response.json(); + // console.log(`${fn} user_data: '${JSON.stringify(user_data)}'`); + + // store the user data in the in-memory cache + cache.set(sessionToken, user_data); + + // setting user into the request context + req.user = user_data; + } catch (err) { + console.log( + `${fn} failed to obtain user information from '${process.env.OIDC_PROVIDER_USERINFO_ENDPOINT}' for the given access token - cause: '${err.name}', reason: '${err.message}'` + ); + res.clearCookie(SESSION_COOKIE); + return sendJSONResponse(res, 401, { reason: "auth_failed" }); + } + + console.log(`${fn} passed authentication check, duration: ${Date.now() - startTime}ms`); + next(); +} + +/** + * Check whether the given user is authorized properly. + * + * If the user is authorized pass on the request to the middleware or handler + * If the user is NOT authorized, not return a 403 response + */ +async function checkAuthz(req, res, next) { + const startTime = Date.now(); + const fn = `${req.correlationId} -`; + console.log(`${fn} performing authorization check`); + + // perform an authorization check based on a user property match + if (enforceUserAllowlist) { + const userValue = req.user[AUTHZ_USER_PROPERTY]; + console.log(`${fn} checking whether given user.${AUTHZ_USER_PROPERTY}='${userValue}' is allow listed`); + + if (!AUTHZ_ALLOWED_USERS_LIST.includes(userValue)) { + console.log( + `${fn} authz denied. user.${AUTHZ_USER_PROPERTY}='${userValue}' is NOT allow listed. User: '${JSON.stringify( + req.user + )}'` + ); + return sendJSONResponse(res, 403, { reason: "forbidden" }); + } + } + + // perform an authorization check based on a user group match + if (enforceGroupAllowlist) { + const userGroups = req.user[AUTHZ_GROUP_PROPERTY] || []; + console.log(`${fn} checking whether at least one of the user.${AUTHZ_GROUP_PROPERTY} is allow listed.`); + + let authorized = false; + Array.isArray(userGroups) && + userGroups.some((group) => { + return AUTHZ_ALLOWED_GROUPS_LIST.includes(group); + }); + + if (!authorized) { + console.log( + `${fn} authz denied. None of the groups listed in user.${AUTHZ_GROUP_PROPERTY} is allow listed. User: '${JSON.stringify( + req.user + )}'` + ); + return sendJSONResponse(res, 403, { reason: "forbidden" }); + } + } + + console.log(`${fn} passed authorization check, duration: ${Date.now() - startTime}ms`); + next(); +} + +// ================================================= +// EXPRESS SETUP +// ================================================= + +const app = express(); +app.use(express.json()); +app.use(cookieParser()); + +// Use router to bundle all routes to / +const router = express.Router(); +app.use("/", router); + +// Route that initiates the login procedure by redirecting to the OIDC provider +router.get("/auth/login", (req, res) => { + console.log(`handling /auth/login`); + + // redirect to the configured OIDC provider + res.redirect( + `${process.env.OIDC_PROVIDER_AUTHORIZATION_ENDPOINT}?client_id=${ + process.env.OIDC_CLIENT_ID + }&redirect_uri=${encodeURIComponent( + process.env.OIDC_REDIRECT_URL + )}&response_type=code&scope=openid+profile&state=state` + ); +}); + +// Route that completes the login procedure by receivng a code provided by the OIDC provider. +// To verify the code, this endpoint requests an access token from the OIDC provider in exchange for the given code +router.get("/auth/callback", async (req, res) => { + const startTime = Date.now(); + console.log(`handling /auth/callback`); + + // Exchange authorization code for access token & id_token + let accessTokenData; + try { + const { code } = req.query; + const data = { + code, + redirect_uri: process.env.OIDC_REDIRECT_URL, + grant_type: "authorization_code", + }; + + console.log(`obtaining access token from '${process.env.OIDC_PROVIDER_TOKEN_ENDPOINT}' ...`); + const response = await fetch( + `${process.env.OIDC_PROVIDER_TOKEN_ENDPOINT}?${new URLSearchParams(data).toString()}`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: + "Basic " + + Buffer.from(process.env.OIDC_CLIENT_ID + ":" + process.env.OIDC_CLIENT_SECRET).toString("base64"), + }, + } + ); + + console.log( + `Received response from '${process.env.OIDC_PROVIDER_TOKEN_ENDPOINT}' - response.ok: '${ + response.ok + }', response.status: '${response.status}', duration: ${Date.now() - startTime}ms` + ); + if (!response.ok) { + const errorResponse = await response.text(); + console.log(`errorResponse: '${errorResponse}'`); + return res.redirect("/auth/failed"); + } + + accessTokenData = await response.json(); + } catch (err) { + console.log( + `Failed to obtain access token on '${process.env.OIDC_PROVIDER_TOKEN_ENDPOINT}' for the given code`, + err + ); + return res.redirect("/auth/failed"); + } + + // Encrypt the access token + const sessionCookieValue = encrypt(accessTokenData.access_token, ENCRYPTION_KEY, ENCRYPTION_IV); + + const maxAge = accessTokenData.expires_in ? 1000 * accessTokenData.expires_in : 600_000; // defaults to 10min + console.log( + `Setting session cookie '${SESSION_COOKIE}' for domain '${process.env.COOKIE_DOMAIN}' (max age: '${maxAge}ms')` + ); + res.cookie(SESSION_COOKIE, sessionCookieValue, { + maxAge, + httpOnly: true, + path: "/", + secure: true, + domain: process.env.COOKIE_DOMAIN, + }); + + // Redirect to the external redirect URL + console.log(`Redirecting to '${process.env.REDIRECT_URL}'...`); + return res.redirect(process.env.REDIRECT_URL); +}); + +// Route that renders an auth failed page +router.get("/auth/failed", (req, res) => { + console.log(`handling /auth/failed for '${req.url}'`); + sendJSONResponse(res, 401, { status: "auth_failed" }); +}); + +// Define a simple root route. +// This route does not enforce any authentication or authorization +router.get("/", (req, res) => { + console.log(`handling / for '${req.url}'`); + return sendJSONResponse(res, 200, { status: "ok" }); +}); + +// Define the auth route that actually enforces authentication and authorization +router.get("/auth", getCorrelationId, checkAuthn, checkAuthz, (req, res) => { + console.log(`${req.correlationId} - authn&authz checks passed!`); + return sendJSONResponse(res, 200, { status: "ok", authenticated: true, authorized: true }); +}); + +// Serve static files +app.use("/public", express.static("public")); + +// Start server +const port = process.env.PORT || 8080; +const server = app.listen(port, () => { + console.log(`HTTP server is up and running on port ${port}!`); +}); + +// Make sure the server terminates properly +process.on("SIGTERM", () => { + console.info("SIGTERM signal received."); + server.close(() => { + console.log("HTTP server closed."); + }); +}); diff --git a/auth-oidc-proxy/auth/package-lock.json b/auth-oidc-proxy/auth/package-lock.json new file mode 100644 index 000000000..e807e0b69 --- /dev/null +++ b/auth-oidc-proxy/auth/package-lock.json @@ -0,0 +1,878 @@ +{ + "name": "code-engine-oidc-auth-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "code-engine-oidc-auth-app", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "cookie-parser": "^1.4.7", + "express": "^4.21.2", + "node-cache": "^5.1.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "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/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/auth-oidc-proxy/auth/package.json b/auth-oidc-proxy/auth/package.json new file mode 100644 index 000000000..febb3ed5b --- /dev/null +++ b/auth-oidc-proxy/auth/package.json @@ -0,0 +1,17 @@ +{ + "name": "code-engine-oidc-auth-app", + "version": "1.0.0", + "description": "Simple Node.js OIDC auth app hosted on IBM Cloud Code Engine", + "main": "index.mjs", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "MIT", + "dependencies": { + "cookie-parser": "^1.4.7", + "express": "^4.21.2", + "node-cache": "^5.1.2" + } +} diff --git a/auth-oidc-proxy/auth/public/images/favicon.ico b/auth-oidc-proxy/auth/public/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8f688bed87fc160ec873ed29f827dab6ef62b71c GIT binary patch literal 2247 zcmV;&2srnNP)&H!#vjgP;#EPD z`C&5ANgRF%Iywquq7e;_8m*5J#TEsomPcP;q4ojN_TKi~d-h&yew1R7i*0Y)flOG* zP4+ow?Y+Nm@3p_Z_P)SbJB5kR4c&FWTh>)~`*?Pv#;f7wXbrQ)Lk!$Io&=onYB<@Z z2pTMky1zEnb8QhJ>8QhUL%XvL$yYziXP_4>7AJeB*m8`k&)QVB4iL zUh})+Z%{DjmW5yJzxIcGQ+0Jows@PF-W#gjNdDxzoN)dEn@m2t*}Y)<aH;zT$qa{G|KR8&aUG`3KyP@HZ!uN-&c;UYePS>vfi_Xrv ziM@D2X{>{?FXi_6UshE_z4UU~6!PMJRJ-aT5}FC(xCU>F_w;;OQ(o>5)#nXIci#fd zKXOF>X>;y*ohK4_zHC20O%&_hUweJ=Uq_R%Uf$4B z0zWw!jP_vr*UeQG6=vY`m96^<1A>Q@tM1joKtTND1}&+_wiL|$Dy@v}Us3Q$)84$L zHr~BL7@az|;E6sXO9#G}ymoLpiMv6>$@lxbJ4OW_0ElF3>GXPVa5o$Aj!c{wJV9Vn zSm{oh{DAoQ(vSBodwXKpZG*w5-v?sEO?OUwF_oZ&rYC-@kzoH$x=}PG-DeQUj`|7j zzCAY6OhH-hkyV7Q^#l{ihE6Vp4|Sh0cF#{}g2cB_bvZBy~wGEkSkek?L~#PC{d%7GB>} zsIqi9gnz;!5BRZ|JO*L+x7`gKM#aL%(wo*+$ksDQKVz+E}DnZTdD`OqY#Y_TS` zNu*lpmz8x7nP%74W_u>If;6onl0z!s{PyG7Ga83SASMpb>=b2%wTcAS6Nx?4o_{A| zWJU^p>gGd{u$2Ng~XW`=T*_@(I3o4v(~f~O$Z*H zHX(09DCN2ZY{?O;w`Kw!F;S7Ogd?eNcipG!+eQi=0JhGX`6@F#C6+Ev_Q->$mQfMP zU(<78Ku69KL;7f>A>lv=#ZtFnyDy{AFRsyEdxzh6Ykm)6G0t@Hhrexp&RVJxrn?vK zsJ}2RfxO3iFBL20VpMOJ&D)P(m;3CKW<#AU!H$f2ez%cZwzj13y)=5HR#j191nL>K zlxJA4IEg^`kG08CpPBH3+ z1=WWlB0|Rr5Ifhir70%ez!k_OiEqA7P$gCm@c`aUTO1tjn}_7oaPzu z5f;`Uc9PjMO8Wtcm3e?BB9%7Z=*h|HhSz0rxLyM|tuw(wQTP@Bf794kBdVVj)mnor zPMVzbn-Qgu=;*~asnfm=*x(9Iafe5`VflguRsn!zy;m%`!ZhcUHJ^UT2WstD-DG5# zPt6f=udr5qw6|hvXN-vZWz@$W5$d#-mI87LfHB6VNu- z??O}seub-s(Dc(1M$wIJ*NV=%+a!8F$UdiN%gL#P###WqV64U}nS?XQqR+Qoq!3)k zJ|_kG!J0wmhwliirQHxb37{Mn&ta7mp9$b+K6iL20^uzJXR7s*_tgKiaQhFO2ZXQ^ zk3jGT0AE4yI2dnYJ$e6Y;ODOGnJb#_B}e~;6nd3S+|zVd(F?;nk=-pT0Xz!AQ~=u{ zsDi?7FcQ!JlqC%SN<(W4=ws8Vaja~i6^_5x8i3|O<$+s=L3POzX$mWiC=?cxrYm(; zwn8%gks$f|mX(vXjqFgrN=jg{3cyVul))L~tpHL0>3+~O@Jxfsnf^ooljv4jeJBpE zZ>`?*I@X^0+Hq)2|E1)>K^ZL01rRx=MTBD`snx(Jr<;jmql{y|$KP{*8H3xfP>%B8 zpcTRm?e}L7>%W4b35XCL?GGFrKH0xCU7fN_BflPb!BSq{3LA?NEW literal 0 HcmV?d00001 diff --git a/auth-oidc-proxy/build b/auth-oidc-proxy/build new file mode 100755 index 000000000..e05b15d42 --- /dev/null +++ b/auth-oidc-proxy/build @@ -0,0 +1,26 @@ +#!/bin/bash + +# Env Vars: +# REGISTRY: name of the image registry/namespace to store the images +# NOCACHE: set this to "--no-cache" to turn off the Docker build cache +# +# NOTE: to run this you MUST set the REGISTRY environment variable to +# your own image registry/namespace otherwise the `docker push` commands +# will fail due to an auth failure. Which means, you also need to be logged +# into that registry before you run it. + +set -ex +export REGISTRY=${REGISTRY:-icr.io/codeengine} + +# Build and push the oidc-auth image +cd oidc-auth +docker build ${NOCACHE} -t ${REGISTRY}/auth-oidc-proxy/auth . --platform linux/amd64 +docker push ${REGISTRY}/auth-oidc-proxy/auth +cd .. + +# Build and push the nginx image +cd nginx +docker build ${NOCACHE} -t ${REGISTRY}/auth-oidc-proxy/nginx . --platform linux/amd64 +docker push ${REGISTRY}/auth-oidc-proxy/nginx +cd .. + diff --git a/auth-oidc-proxy/docs/ce-oidc-proxy-overview.png b/auth-oidc-proxy/docs/ce-oidc-proxy-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..5d4f1bcd116e367ccee614372a1f36723417e0a0 GIT binary patch literal 70092 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%0A(jPL3hUXExTGZ(f>i^w8Gw{I19i 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 literal 0 HcmV?d00001 diff --git a/auth-oidc-proxy/nginx/Dockerfile b/auth-oidc-proxy/nginx/Dockerfile new file mode 100644 index 000000000..1e95ce313 --- /dev/null +++ b/auth-oidc-proxy/nginx/Dockerfile @@ -0,0 +1,11 @@ +FROM registry.access.redhat.com/ubi9/nginx-124 + +# Start-nginx is a script that'll replace all environment variables in the nginx.conf file +COPY start-nginx / + +# Add the nginx configuration files +COPY origin-template.conf /tmp/origin-template.conf +ADD nginx.conf "${NGINX_CONF_PATH}" + +# At runtime, call the wrapper script to do the variable substitutions prior starting the NGINX server +ENTRYPOINT ["/start-nginx"] \ No newline at end of file diff --git a/auth-oidc-proxy/nginx/nginx.conf b/auth-oidc-proxy/nginx/nginx.conf new file mode 100644 index 000000000..3255b2901 --- /dev/null +++ b/auth-oidc-proxy/nginx/nginx.conf @@ -0,0 +1,41 @@ +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ +# * Official Russian Documentation: http://nginx.org/ru/docs/ + +worker_processes auto; +error_log /dev/stderr; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + types_hash_max_size 4096; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # https://nginx.org/en/docs/http/websocket.html + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /opt/app-root/etc/nginx.d/*.conf; +} diff --git a/auth-oidc-proxy/nginx/origin-template.conf b/auth-oidc-proxy/nginx/origin-template.conf new file mode 100644 index 000000000..e0db94706 --- /dev/null +++ b/auth-oidc-proxy/nginx/origin-template.conf @@ -0,0 +1,39 @@ +server { + + listen 8080; + server_name ${ORIGIN_APP_FQDN}; + root /opt/app-root/src; + + location / { + auth_request /auth; + error_page 401 = /auth/login; + + proxy_pass http://${ORIGIN_APP_NAME}.${CE_SUBDOMAIN}.svc.cluster.local; + proxy_set_header Host ${ORIGIN_APP_NAME}.${CE_SUBDOMAIN}.svc.cluster.local; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Auth-Request-Redirect $request_uri; + proxy_pass_request_headers on; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 86400; + } + + location /auth { + proxy_pass http://${AUTH_APP_NAME}.${CE_SUBDOMAIN}.svc.cluster.local; + proxy_set_header Host ${AUTH_APP_NAME}.${CE_SUBDOMAIN}.svc.cluster.local; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + proxy_pass_request_headers on; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 86400; + } +} + diff --git a/auth-oidc-proxy/nginx/start-nginx b/auth-oidc-proxy/nginx/start-nginx new file mode 100755 index 000000000..2afd72246 --- /dev/null +++ b/auth-oidc-proxy/nginx/start-nginx @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Replace all "CE_SUBDOMAIN" in the config file with the Code Engine subdomain (k8s ns) +# see: https://www.baeldung.com/linux/nginx-config-environment-variables +echo "Performing environment variable substitutions ..." +envsubst '\$ORIGIN_APP_FQDN \$ORIGIN_APP_NAME \$AUTH_APP_NAME \$CE_SUBDOMAIN' < /tmp/origin-template.conf > /opt/app-root/etc/nginx.d/origin.conf + +echo "Starting NGINX with the following config file '${NGINX_CONF_PATH}'" +cat ${NGINX_CONF_PATH} + +echo "Using following config '/opt/app-root/etc/nginx.d/origin.conf' to expose the Code Engine origin app:" +cat /opt/app-root/etc/nginx.d/origin.conf + +# Now run nginx +echo "Launching NGINX..." +nginx -g 'daemon off;' \ No newline at end of file diff --git a/auth-oidc-proxy/oidc.properties.template b/auth-oidc-proxy/oidc.properties.template new file mode 100644 index 000000000..b1eabc1d6 --- /dev/null +++ b/auth-oidc-proxy/oidc.properties.template @@ -0,0 +1,6 @@ +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_PROVIDER_AUTHORIZATION_ENDPOINT= +OIDC_PROVIDER_TOKEN_ENDPOINT= +OIDC_PROVIDER_USERINFO_ENDPOINT= +COOKIE_SIGNING_ENCRYPTION_KEY= \ No newline at end of file diff --git a/auth-oidc-proxy/run b/auth-oidc-proxy/run new file mode 100755 index 000000000..7d6f2f5dd --- /dev/null +++ b/auth-oidc-proxy/run @@ -0,0 +1,271 @@ +#!/bin/bash +set -eo pipefail + +# Customizable vars +CLEANUP_ON_ERROR=${CLEANUP_ON_ERROR:=true} +CLEANUP_ON_SUCCESS=${CLEANUP_ON_SUCCESS:=true} +REGION="${REGION:=eu-es}" +NAME_PREFIX="${NAME_PREFIX:=oidc-sample}" + +# Static variables +RESOURCE_GROUP_NAME="oidc-sample--rg" +CE_PROJECT_NAME="${NAME_PREFIX}-project" +CE_APP_ORIGIN="${NAME_PREFIX}-origin" +CE_APP_PROXY="${NAME_PREFIX}-proxy" +CE_APP_AUTH="${NAME_PREFIX}-auth" +CE_SECRET_AUTH="${NAME_PREFIX}-auth-credentials" + + +# ============================== +# COMMON FUNCTIONS +# ============================== +RED="\033[31m" +BLUE="\033[94m" +GREEN="\033[32m" +ENDCOLOR="\033[0m" + +function print_error { + echo -e "${RED}\n==========================================${ENDCOLOR}" + echo -e "${RED} FAILED${ENDCOLOR}" + echo -e "${RED}==========================================\n${ENDCOLOR}" + echo -e "${RED}$1${ENDCOLOR}" + echo "" +} +function print_msg { + echo -e "${BLUE}$1${ENDCOLOR}" +} +function print_success { + echo -e "${GREEN}$1${ENDCOLOR}" +} + +# Helper function to check whether prerequisites are installed +function check_prerequisites { + # Ensure that jq tool is installed + if ! command -v jq &>/dev/null; then + print_error "'jq' tool is not installed" + exit 1 + fi + echo "Done!" +} + +# Clean up previous run +function clean() { + # cleanup everything within this resource group + + ibmcloud ce project delete --name ${CE_PROJECT_NAME} --hard --force 2>/dev/null + + ibmcloud resource group $RESOURCE_GROUP_NAME --quiet 2>/dev/null + if [[ $? == 0 ]]; then + COUNTER=0 + # some resources (e.g. boot volumes) are deleted with some delay. Hence, the script waits before exiting with an error + while (($(ibmcloud resource service-instances --type all -g $RESOURCE_GROUP_NAME --output json | jq -r '. | length') > 0)); do + sleep 5 + COUNTER=$((COUNTER + 1)) + if ((COUNTER > 30)); then + print_error "Cleanup failed! Please make sure to delete remaining resources manually to avoid unwanted charges." + ibmcloud resource service-instances --type all -g $RESOURCE_GROUP_NAME + exit 1 + fi + done + fi + + ibmcloud resource group-delete $RESOURCE_GROUP_NAME --force 2>/dev/null + + echo "Done!" +} + + +function abortScript() { + if [[ "${CLEANUP_ON_ERROR}" == true ]]; then + clean + else + print_msg "\nSkipping deletion of the created IBM Cloud resources." + echo "$ ibmcloud resource service-instances --type all -g $RESOURCE_GROUP_NAME" + ibmcloud resource service-instances --type all -g $RESOURCE_GROUP_NAME + fi + exit 1 +} + +# ============================== +# MAIN SCRIPT FLOW +# ============================== + +print_msg "\n======================================================" +print_msg " Setting up \"OIDC proxy on Code Engine \" sample" +print_msg "======================================================\n" + +echo "" +echo "Please note: This script will install various IBM Cloud resources within the resource group '$RESOURCE_GROUP_NAME'." + +print_msg "\nChecking prerequisites ..." +check_prerequisites + +# Ensure that latest versions of used IBM Cloud ClI is installed +print_msg "\nPulling latest IBM Cloud CLI release ..." +#ibmcloud update --force +echo "Done!" + +# Ensure that latest versions of used IBM Cloud CLI plugins are installed +print_msg "\nInstalling required IBM Cloud CLI plugins ..." +#ibmcloud plugin install code-engine -f --quiet +echo "Done!" + + +if [[ "$1" == "clean" ]]; then + print_msg "\nCleaning up the remains of previous executions ..." + clean + print_success "\n==========================================\n DONE\n==========================================\n" + exit 0 +fi + +print_msg "\nTargetting IBM Cloud region '$REGION' ..." +ibmcloud target -r $REGION + +# +# Create the resource group, if it does not exist +if ! ibmcloud resource group $RESOURCE_GROUP_NAME --quiet >/dev/null 2>&1; then + print_msg "\nCreating resource group '$RESOURCE_GROUP_NAME' ..." + ibmcloud resource group-create $RESOURCE_GROUP_NAME +fi +print_msg "\nTargetting resource group '$RESOURCE_GROUP_NAME' ..." +ibmcloud target -g $RESOURCE_GROUP_NAME + +# +# Create the Code Engine project, if it does not exist +print_msg "\nInitializing the Code Engine project '$CE_PROJECT_NAME' ..." +if ! ibmcloud ce proj select --name $CE_PROJECT_NAME 2>/dev/null; then + print_msg "\nCreating Code Engine project '$CE_PROJECT_NAME' ..." + ibmcloud ce proj create --name $CE_PROJECT_NAME + if [ $? -ne 0 ]; then + print_error "Code Engine project create failed!" + abortScript + fi +fi +CE_PROJECT=$(ibmcloud ce project current --output json) +CE_PROJECT_GUID=$(echo "$CE_PROJECT" | jq -r '.guid') +CE_PROJECT_DOMAIN=$(echo "$CE_PROJECT" | jq -r '.domain') +CE_PROJECT_NAMESPACE=$(echo "$CE_PROJECT" | jq -r '.kube_config_context') + +# Deploy the Code Engine app to run the origin +print_msg "\nInitializing the origin app '$CE_APP_ORIGIN' ..." +if ! ibmcloud ce app get --name $CE_APP_ORIGIN >/dev/null 2>&1; then + print_msg "\nCreating the origin app '$CE_APP_ORIGIN' ..." + ibmcloud ce app create --name $CE_APP_ORIGIN \ + --image icr.io/codeengine/helloworld \ + --cpu 0.125 \ + --memory 0.25G + if [ $? -ne 0 ]; then + print_error "Code Engine origin app create/update failed!" + abortScript + fi +else + echo "Done!" +fi + +ROOT_DOMAIN=.${CE_PROJECT_NAMESPACE}.${CE_PROJECT_DOMAIN} +FQDN_ORIGIN_APP=${CE_APP_ORIGIN}${ROOT_DOMAIN} +URL_ORIGIN_APP=https://${FQDN_ORIGIN_APP} + +# ================================================ +# OPTIONAL: Configuring Authn and Authz +# ================================================ + +print_msg "\nCheck whether the authentication credentials should be configured, or not ..." +if [ ! -f oidc.properties ]; then + echo "Skipping the configuration of the authentication credentials. Specify all authz/authn properties in 'oidc.properties' to enable it." +else + echo "Authn/Authz configuration file 'oidc.properties' found!" + if ibmcloud ce secret get --name $CE_SECRET_AUTH >/dev/null 2>&1; then + ibmcloud ce secret delete --name $CE_SECRET_AUTH --force + fi + ibmcloud ce secret create \ + --name $CE_SECRET_AUTH \ + --from-env-file oidc.properties + if [ $? -ne 0 ]; then + print_error "Code Engine auth secret create/update failed!" + abortScript + fi +fi + +print_msg "\nCheck whether the authentication app should be configured, or not ..." +if ! ibmcloud ce secret get --name $CE_SECRET_AUTH >/dev/null 2>&1; then + echo "Skipping the deployment of the authentication app" +else + echo "Yes! Setting up the authentication and the proxy apps" + + URL_AUTH_APP=https://${CE_APP_AUTH}${ROOT_DOMAIN} + FQDN_ORIGIN_APP=${CE_APP_PROXY}${ROOT_DOMAIN} + URL_ORIGIN_APP=https://${FQDN_ORIGIN_APP} + + authapp_op_create_or_update=update + if ! ibmcloud ce app get --name $CE_APP_AUTH >/dev/null 2>&1; then + print_msg "\nCreating the auth app '$CE_APP_AUTH' ..." + authapp_op_create_or_update=create + else + print_msg "\nUpdating the auth app '$CE_APP_AUTH' ..." + fi + + # Deploy the Code Engine app to run the OIDC authentication + ibmcloud ce app $authapp_op_create_or_update --name $CE_APP_AUTH \ + --build-source "." \ + --build-context-dir "auth/" \ + --max-scale 1 \ + --cpu 0.125 \ + --memory 0.25G \ + --scale-down-delay 600 \ + --port 8080 \ + --env-from-secret $CE_SECRET_AUTH \ + --env COOKIE_DOMAIN="$ROOT_DOMAIN" \ + --env REDIRECT_URL="$URL_ORIGIN_APP" \ + --env OIDC_REDIRECT_URL="${URL_AUTH_APP}/auth/callback" + if [ $? -ne 0 ]; then + print_error "Code Engine auth app create/update failed!" + abortScript + fi + + # Deploy the Code Engine app to the run the nginx reverse proxy + proxyapp_op_create_or_update=update + if ! ibmcloud ce app get --name $CE_APP_PROXY >/dev/null 2>&1; then + print_msg "\nCreating the proxy app '$CE_APP_PROXY' ..." + proxyapp_op_create_or_update=create + else + print_msg "\nUpdating the proxy app '$CE_APP_PROXY' ..." + fi + ibmcloud ce app $proxyapp_op_create_or_update --name $CE_APP_PROXY \ + --build-source "." \ + --build-context-dir "nginx/" \ + --max-scale 1 \ + --cpu 1 \ + --memory 2G \ + --scale-down-delay 600 \ + --env ORIGIN_APP_FQDN=$FQDN_ORIGIN_APP \ + --env ORIGIN_APP_NAME=$CE_APP_ORIGIN \ + --env AUTH_APP_NAME=$CE_APP_AUTH \ + --port 8080 + if [ $? -ne 0 ]; then + print_error "Code Engine proxy app create/update failed!" + abortScript + fi + + print_msg "\nMake sure the app '$CE_APP_ORIGIN' is not exposed publicly ..." + ibmcloud ce app update --name $CE_APP_ORIGIN --cluster-local +fi + +print_msg "\nThis end-to-end sample created the following set of IBM Cloud resources:" +ibmcloud resource service-instances --type all -g $RESOURCE_GROUP_NAME + +echo "" +ibmcloud ce app list + +if [[ "${CLEANUP_ON_SUCCESS}" == true ]]; then + print_msg "\nCleaning up the created IBM Cloud resources ..." + clean +else + print_msg "\nFollowing commands can be used to further play around with the sample setup:" + echo "1. Open the browser and type '$URL_ORIGIN_APP' to access the origin app" + echo "2. Tear down the sample setup: './run clean'" +fi + +print_success "\n==========================================" +print_success " SUCCESS" +print_success "==========================================\n" From 30fb28a3d940a9ac3d07f6ed7ba19254a67917f4 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:27:41 +0200 Subject: [PATCH 02/13] Update auth-oidc-proxy/README.md Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/README.md b/auth-oidc-proxy/README.md index 6016fce66..0836bf2d3 100644 --- a/auth-oidc-proxy/README.md +++ b/auth-oidc-proxy/README.md @@ -1,6 +1,6 @@ # OIDC Proxy sample -This sample demonstrates how to configure an authentication/authorization layer that fronts any arbitrary Code Engine application. In principal, this pattern is pretty generic. To demonstrate it, we chose to implement it with OIDC, an authentication framework that is built on top of the OAuth 2.0 protocol. +This sample demonstrates how to configure an authentication/authorization layer that fronts any arbitrary Code Engine application. In principal, this pattern is pretty generic. To demonstrate it, we chose to implement it with OpenID Connect (OIDC), an authentication framework that is built on top of the OAuth 2.0 protocol. The following diagram depicts the components that are involved: ![OIDC Proxy architecture overview](./docs/ce-oidc-proxy-overview.png) From ce91d3f11edf34dba53491b0898f06bc6ff5900b Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:27:52 +0200 Subject: [PATCH 03/13] Update auth-oidc-proxy/README.md Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/README.md b/auth-oidc-proxy/README.md index 0836bf2d3..a7eeed4b1 100644 --- a/auth-oidc-proxy/README.md +++ b/auth-oidc-proxy/README.md @@ -27,7 +27,7 @@ The following diagram depicts the components that are involved: ``` COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32) ``` -* From your OIDC provider obtain the following values and add ithem to the `oidc.properties` file +* From your OIDC provider obtain the following values and add them to the `oidc.properties` file ``` OIDC_PROVIDER_AUTHORIZATION_ENDPOINT=https://github.com/login/oauth/authorize OIDC_PROVIDER_TOKEN_ENDPOINT=https://github.com/login/oauth/access_token From 16e8d34a0b5740495b8788b74709528c07f2f851 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:28:12 +0200 Subject: [PATCH 04/13] Update auth-oidc-proxy/README.md Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/README.md b/auth-oidc-proxy/README.md index a7eeed4b1..48f383c53 100644 --- a/auth-oidc-proxy/README.md +++ b/auth-oidc-proxy/README.md @@ -56,7 +56,7 @@ The following diagram depicts the components that are involved: ``` COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32) ``` -* From your OIDC provider obtain the following values and add ithem to the `oidc.properties` file +* From your OIDC provider obtain the following values and add them to the `oidc.properties` file ``` OIDC_PROVIDER_AUTHORIZATION_ENDPOINT= OIDC_PROVIDER_TOKEN_ENDPOINT= From 79b377cb4e1187062a6b327f43f678e48b9eb3e2 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:29:09 +0200 Subject: [PATCH 05/13] Update auth-oidc-proxy/auth/Dockerfile Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/auth-oidc-proxy/auth/Dockerfile b/auth-oidc-proxy/auth/Dockerfile index ef892d4f6..fadc47ee2 100644 --- a/auth-oidc-proxy/auth/Dockerfile +++ b/auth-oidc-proxy/auth/Dockerfile @@ -1,6 +1,5 @@ FROM registry.access.redhat.com/ubi9/nodejs-22:latest AS build-env WORKDIR /app -COPY index.mjs . COPY package.json . RUN npm install @@ -8,6 +7,6 @@ RUN npm install FROM gcr.io/distroless/nodejs22-debian12 COPY --from=build-env /app /app WORKDIR /app -COPY public/ public/ +COPY index.mjs public/. EXPOSE 8080 CMD ["index.mjs"] \ No newline at end of file From 8bc084eb8e98b52114618ab572890a3e8ab2ac2e Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:29:26 +0200 Subject: [PATCH 06/13] Update auth-oidc-proxy/auth/index.mjs Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/auth/index.mjs b/auth-oidc-proxy/auth/index.mjs index 5a9edcb9a..383c95888 100644 --- a/auth-oidc-proxy/auth/index.mjs +++ b/auth-oidc-proxy/auth/index.mjs @@ -144,7 +144,7 @@ async function checkAuthn(req, res, next) { } // error:1C80006B:Provider routines::wrong final block length - if (err.message.indexOf("error:1C80006B") > -1) { + if (err.message.includes("error:1C80006B")) { console.log(`${fn} enryption key has been changed. Deleting existing cookie`); res.clearCookie(SESSION_COOKIE); return sendJSONResponse(res, 401, { reason: "invalid_session" }); From 346970aa62510569b948e4454ef2e871d74e7ed6 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:29:53 +0200 Subject: [PATCH 07/13] Update auth-oidc-proxy/auth/index.mjs Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/auth/index.mjs b/auth-oidc-proxy/auth/index.mjs index 383c95888..167de85b7 100644 --- a/auth-oidc-proxy/auth/index.mjs +++ b/auth-oidc-proxy/auth/index.mjs @@ -137,7 +137,7 @@ async function checkAuthn(req, res, next) { // This error indicates that the encrypted string couldn't get decrypted using the encryption key // maybe the cookie value has been encrypted with an old key // full error: 'error:1C800064:Provider routines::bad decrypt' - if (err.message.indexOf("error:1C800064") > -1) { + if (err.message.includes("error:1C800064")) { console.log(`${fn} enryption key has been changed. Deleting existing cookie`); res.clearCookie(SESSION_COOKIE); return sendJSONResponse(res, 400, { reason: "invalid_session" }); From 7e012255f7ca1b0bdf3cbb68db465ca1db9f7a82 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:30:31 +0200 Subject: [PATCH 08/13] Update auth-oidc-proxy/auth/index.mjs Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/index.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/auth-oidc-proxy/auth/index.mjs b/auth-oidc-proxy/auth/index.mjs index 167de85b7..6adba09c6 100644 --- a/auth-oidc-proxy/auth/index.mjs +++ b/auth-oidc-proxy/auth/index.mjs @@ -240,8 +240,7 @@ async function checkAuthz(req, res, next) { const userGroups = req.user[AUTHZ_GROUP_PROPERTY] || []; console.log(`${fn} checking whether at least one of the user.${AUTHZ_GROUP_PROPERTY} is allow listed.`); - let authorized = false; - Array.isArray(userGroups) && + const authorized = Array.isArray(userGroups) && userGroups.some((group) => { return AUTHZ_ALLOWED_GROUPS_LIST.includes(group); }); From a633814c6a380cea67b00f15f7ebbb74c5a68839 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Fri, 16 May 2025 13:30:55 +0200 Subject: [PATCH 09/13] Update auth-oidc-proxy/auth/index.mjs Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/index.mjs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/auth-oidc-proxy/auth/index.mjs b/auth-oidc-proxy/auth/index.mjs index 6adba09c6..60def2c10 100644 --- a/auth-oidc-proxy/auth/index.mjs +++ b/auth-oidc-proxy/auth/index.mjs @@ -70,12 +70,9 @@ function parseAllowlist(listAsStr) { if (!listAsStr) { return []; } - const strArr = listAsStr.split(","); - strArr.forEach((element, index) => { - strArr[index] = element.trim(); - }); - - return strArr; + return listAsStr + .split(",") + .map((item) => item.trim()); } // ================================================= From efcc382d8a6d58df6e2e11f4d17bbde471f395f9 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Sun, 18 May 2025 21:40:17 +0200 Subject: [PATCH 10/13] Update auth-oidc-proxy/.dockerignore Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/.dockerignore | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/auth-oidc-proxy/.dockerignore b/auth-oidc-proxy/.dockerignore index 1f071c64a..24d9c3361 100644 --- a/auth-oidc-proxy/.dockerignore +++ b/auth-oidc-proxy/.dockerignore @@ -1,2 +1,12 @@ oidc*.properties +.ceignore +.dockerignore +.gitignore +auth +build +docs +Dockerfile +nginx node_modules +README.md +run From d7cb420ce627c402cd712ed36e25105b4fb1a636 Mon Sep 17 00:00:00 2001 From: Enrico Regge Date: Sun, 18 May 2025 22:03:06 +0200 Subject: [PATCH 11/13] Update README.md --- auth-oidc-proxy/README.md | 58 +++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/auth-oidc-proxy/README.md b/auth-oidc-proxy/README.md index 48f383c53..3c9eda0a4 100644 --- a/auth-oidc-proxy/README.md +++ b/auth-oidc-proxy/README.md @@ -10,67 +10,73 @@ The following diagram depicts the components that are involved: ## Setting up an OIDC SSO configuration +In order to be able to authenticate using OIDC SSO, you'll need to choose and configure a suitable OIDC provider. For this sample we demonstrate how this can be achieved by either using GitHub, or an IBM-internal provider. While many other OIDC providers will also work out-of-the-box, some may require few adjustments in the implementation of the `auth` app that we provide in this sample. + ### Github.com OIDC SSO +Github.com provides a publicly available OIDC provider, that can be used to point to Code Engine applications, which you deployed in your IBM Cloud account. Use the following steps to configure an SSO app: + * Create Github OIDC app through https://github.com/settings/developers ``` - name: jupyter - homepage: https://jupyter-auth...codeengine.appdomain.cloud - callback URL: https://jupyter-auth...codeengine.appdomain.cloud/auth/callback + name: oidc-sample + homepage: https://oidc-sample-auth...codeengine.appdomain.cloud + callback URL: https://oidc-sample-auth...codeengine.appdomain.cloud/auth/callback ``` * Store the client id and the secret in local file called `oidc.properties` ``` - OIDC_CLIENT_ID= - OIDC_CLIENT_SECRET= + echo "OIDC_CLIENT_ID=" > oidc.properties + echo "OIDC_CLIENT_SECRET=" >> oidc.properties ``` * Generate a random cookie secret that is used to encrypt the auth cookie value and add it to the `oidc.properties` file ``` - COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32) + echo "COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> oidc.properties ``` * From your OIDC provider obtain the following values and add them to the `oidc.properties` file ``` - OIDC_PROVIDER_AUTHORIZATION_ENDPOINT=https://github.com/login/oauth/authorize - OIDC_PROVIDER_TOKEN_ENDPOINT=https://github.com/login/oauth/access_token - OIDC_PROVIDER_USERINFO_ENDPOINT=https://api.github.com/user + echo "OIDC_PROVIDER_AUTHORIZATION_ENDPOINT=https://github.com/login/oauth/authorize" >> oidc.properties + echo "OIDC_PROVIDER_TOKEN_ENDPOINT=https://github.com/login/oauth/access_token" >> oidc.properties + echo "OIDC_PROVIDER_USERINFO_ENDPOINT=https://api.github.com/user" >> oidc.properties ``` -* To add authorization checks one can either check for a specific user property +* To add authorization checks one can check for a specific user property ``` - AUTHZ_USER_PROPERTY=login - AUTHZ_ALLOWED_USERS=< + echo "AUTHZ_USER_PROPERTY=login" >> oidc.properties + echo "AUTHZ_ALLOWED_USERS=<" >> oidc.properties ``` ### IBMers-only: w3Id OIDC SSO -* Create w3Id OIDC configuration through https://ies-provisioner.prod.identity-services.intranet.ibm.com/tools/sso/home +To protect IBM's workforce, the SSO Provisioner provides the ability to configure an w3Id SSO. Note: This SSO provider can only be used by IBMers + +* Create w3Id OIDC configuration through https://w3.ibm.com/security/sso-provisioner ``` - name: jupyter - homepage: https://jupyter-auth...codeengine.appdomain.cloud - callback URL: https://jupyter-auth...codeengine.appdomain.cloud/auth/callback + name: oidc-sample + homepage: https://oidc-sample-auth...codeengine.appdomain.cloud + callback URL: https://oidc-sample-auth...codeengine.appdomain.cloud/auth/callback ``` * Store the client id and the secret in local file called `oidc.properties` ``` - OIDC_CLIENT_ID= - OIDC_CLIENT_SECRET= + echo "OIDC_CLIENT_ID=" > oidc.properties + echo "OIDC_CLIENT_SECRET=" >> oidc.properties ``` * Generate a random cookie secret that is used to encrypt the auth cookie value and add it to the `oidc.properties` file ``` - COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32) + echo "COOKIE_SIGNING_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> oidc.properties ``` * From your OIDC provider obtain the following values and add them to the `oidc.properties` file ``` - OIDC_PROVIDER_AUTHORIZATION_ENDPOINT= - OIDC_PROVIDER_TOKEN_ENDPOINT= - OIDC_PROVIDER_USERINFO_ENDPOINT= + echo "OIDC_PROVIDER_AUTHORIZATION_ENDPOINT=" >> oidc.properties + echo "OIDC_PROVIDER_TOKEN_ENDPOINT=" >> oidc.properties + echo "OIDC_PROVIDER_USERINFO_ENDPOINT=" >> oidc.properties ``` * To add authorization checks one can either check for a specific user property, for a group property match ``` - AUTHZ_USER_PROPERTY=preferred_username - AUTHZ_ALLOWED_USERS= + echo "AUTHZ_USER_PROPERTY=preferred_username" >> oidc.properties + echo "AUTHZ_ALLOWED_USERS=" >> oidc.properties ``` * Or for a group property match ``` - AUTHZ_USER_PROPERTY=blueGroups - AUTHZ_ALLOWED_USERS= + echo "AUTHZ_USER_PROPERTY=blueGroups" >> oidc.properties + echo "AUTHZ_ALLOWED_USERS=" >> oidc.properties ``` ## Installing the sample From 8e8c87b2fc9c8261d2e8e4bd03471271f71238b7 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Wed, 21 May 2025 10:54:41 +0200 Subject: [PATCH 12/13] Update auth-oidc-proxy/auth/Dockerfile Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/auth/Dockerfile b/auth-oidc-proxy/auth/Dockerfile index fadc47ee2..a8221a6df 100644 --- a/auth-oidc-proxy/auth/Dockerfile +++ b/auth-oidc-proxy/auth/Dockerfile @@ -7,6 +7,6 @@ RUN npm install FROM gcr.io/distroless/nodejs22-debian12 COPY --from=build-env /app /app WORKDIR /app -COPY index.mjs public/. +COPY index.mjs public/ . EXPOSE 8080 CMD ["index.mjs"] \ No newline at end of file From e6f23c6e3c2c2ca6537368d2cc01aabf4f5a2210 Mon Sep 17 00:00:00 2001 From: Enrico Regge <36001299+reggeenr@users.noreply.github.com> Date: Wed, 21 May 2025 10:54:56 +0200 Subject: [PATCH 13/13] Update auth-oidc-proxy/auth/index.mjs Co-authored-by: Sascha Schwarze --- auth-oidc-proxy/auth/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth-oidc-proxy/auth/index.mjs b/auth-oidc-proxy/auth/index.mjs index 60def2c10..ea94b05b8 100644 --- a/auth-oidc-proxy/auth/index.mjs +++ b/auth-oidc-proxy/auth/index.mjs @@ -275,7 +275,7 @@ router.get("/auth/login", (req, res) => { // redirect to the configured OIDC provider res.redirect( `${process.env.OIDC_PROVIDER_AUTHORIZATION_ENDPOINT}?client_id=${ - process.env.OIDC_CLIENT_ID + encodeURIComponent(process.env.OIDC_CLIENT_ID) }&redirect_uri=${encodeURIComponent( process.env.OIDC_REDIRECT_URL )}&response_type=code&scope=openid+profile&state=state`