diff --git a/README.md b/README.md index fdedcbac..f9440b75 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,49 @@ registration, account recovery, ... screens, please check out the ## Configuration -This application can be configured using two environment variables: +Below is a list of environment variables required by the Express.js service +to function properly. -- `KRATOS_PUBLIC_URL` (required): The URL where ORY Kratos's Public API is - located at. If this app and ORY Kratos are running in the same private - network, this should be the private network address (e.g. +In a local development run of the service using `npm run start`, +some of these values will be set by nodemon and is configured by the +`nodemon.json` file. + +When using this UI with an Ory Network project, you can use `ORY_SDK_URL` instead +of `KRATOS_PUBLIC_URL` and `HYDRA_ADMIN_URL`. + +Ory Identities requires the following variables to be set: + +- `ORY_SDK_URL` or `KRATOS_PUBLIC_URL` (required): The URL where ORY Kratos's + Public API is located at. If this app and ORY Kratos are running in the + same private network, this should be the private network address (e.g. `kratos-public.svc.cluster.local`). +- `KRATOS_BROWSER_URL` (optional) The browser accessible URL where ORY Kratos's + public API is located, only needed if it differs from `KRATOS_PUBLIC_URL` + +Ory OAuth2 requires more setup to get CSRF cookies on the `/consent` endpoint. + +- `ORY_SDK_URL` or `HYDRA_ADMIN_URL` (optional): The URL where Ory Hydra's + Public API is located at. If this app and Ory Hydra are running in the + same private network, this should be the private network address + (e.g. `hydra-admin.svc.cluster.local`) +- `COOKIE_SECRET` (required): Required for signing cookies. Must be + a string with at least 8 alphanumerical characters. +- `CSRF_COOKIE_SECRET` (optional): Required for the Consent route to set a CSRF + cookie with a hashed value. The value must be a string with at least 8 + alphanumerical characters. +- `ORY_ADMIN_API_TOKEN` (optional): When using with an Ory Network project, + you should add the `ORY_ADMIN_API_TOKEN` for OAuth2 Consent flows. +- `CSRF_COOKIE_NAME` (optional): By default the CSRF cookie will be set to `ax-x-csrf-token`. +- `DANGEROUSLY_DISABLE_SECURE_CSRF_COOKIES` (optional) This environment variables + should only be used in local development when you do not have HTTPS setup. + This sets the CSRF cookies to `secure: false`, required for running locally. + +Getting TLS working: + - `TLS_CERT_PATH` (optional): Path to certificate file. Should be set up together with `TLS_KEY_PATH` to enable HTTPS. - `TLS_KEY_PATH` (optional): Path to key file Should be set up together with `TLS_CERT_PATH` to enable HTTPS. -- `KRATOS_BROWSER_URL` (optional) The browser accessible URL where ORY Kratos's - public API is located, only needed if it differs from `KRATOS_PUBLIC_URL` This is the easiest mode as it requires no additional set up. This app runs on port `:4455` and ORY Kratos `KRATOS_PUBLIC_URL` URL. @@ -54,7 +85,7 @@ recommended. To run this app with dummy data and no real connection to ORY Kratos, use: ```shell script -$ NODE_ENV=stub npm start +NODE_ENV=stub npm start ``` ### Test with ORY Kratos diff --git a/nodemon.json b/nodemon.json index 7432e790..018a880c 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,5 +1,13 @@ { - "watch": ["src"], + "watch": [ + "src" + ], "ext": "ts", - "exec": "ts-node ./src/index.ts" + "exec": "ts-node ./src/index.ts", + "env": { + "COOKIE_SECRET": "I_AM_VERY_SECRET", + "CSRF_COOKIE_SECRET": "I_AM_VERY_SECRET_TOO", + "DANGEROUSLY_DISABLE_SECURE_CSRF_COOKIES": "true", + "ORY_SDK_URL": "http://localhost:4000" + } } diff --git a/src/index.ts b/src/index.ts index 18a5c366..bdded999 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,13 +29,15 @@ import { engine } from "express-handlebars" import * as fs from "fs" import * as https from "https" + + const baseUrl = process.env.BASE_PATH || "/" const app = express() const router = express.Router() app.use(middlewareLogger) -app.use(cookieParser()) +app.use(cookieParser(process.env.COOKIE_SECRET)) app.use(addFavicon(defaultConfig)) app.use(detectLanguage) app.set("view engine", "hbs") @@ -79,13 +81,27 @@ let listener = (proto: "http" | "https") => () => { console.log(`Listening on ${proto}://0.0.0.0:${port}`) } -if (process.env.TLS_CERT_PATH?.length && process.env.TLS_KEY_PATH?.length) { - const options = { - cert: fs.readFileSync(process.env.TLS_CERT_PATH), - key: fs.readFileSync(process.env.TLS_KEY_PATH), - } - https.createServer(options, app).listen(port, listener("https")) +// When using the Ory Admin API Token, we assume that this application is also +// handling OAuth2 Consent requests. In that case we need to ensure that the +// COOKIE_SECRET and CSRF_COOKIE_SECRET environment variables are set. +if (process.env.ORY_ADMIN_API_TOKEN && + (String(process.env.COOKIE_SECRET || "").length < 8) || + String(process.env.CSRF_COOKIE_SECRET || "").length < 8) { + console.error("Cannot start the server without the required environment variables!") + console.error("COOKIE_SECRET must be set and be at least 8 alphanumerical character `export COOKIE_SECRET=...`") + console.error("CSRF_COOKIE_SECRET must be set and be at least 8 alphanumerical character `export CSRF_COOKIE_SECRET=...`") } else { - app.listen(port, listener("http")) + + if (process.env.TLS_CERT_PATH?.length && process.env.TLS_KEY_PATH?.length) { + const options = { + cert: fs.readFileSync(process.env.TLS_CERT_PATH), + key: fs.readFileSync(process.env.TLS_KEY_PATH), + } + + https.createServer(options, app).listen(port, listener("https")) + } else { + app.listen(port, listener("http")) + } + } diff --git a/src/pkg/sdk/index.ts b/src/pkg/sdk/index.ts index cc488fe2..38366d47 100644 --- a/src/pkg/sdk/index.ts +++ b/src/pkg/sdk/index.ts @@ -15,11 +15,6 @@ const apiBaseIdentityUrl = process.env.KRATOS_ADMIN_URL || baseUrlInternal export const apiBaseUrl = process.env.KRATOS_BROWSER_URL || apiBaseFrontendUrlInternal -const hydraBaseOptions: any = {} - -if (process.env.MOCK_TLS_TERMINATION) { - hydraBaseOptions.headers = { "X-Forwarded-Proto": "https" } -} // Sets up the SDK const sdk = { @@ -32,7 +27,14 @@ const sdk = { oauth2: new OAuth2Api( new Configuration({ basePath: apiBaseOauth2UrlInternal, - baseOptions: hydraBaseOptions, + ...(process.env.ORY_ADMIN_API_TOKEN && { + accessToken: process.env.ORY_ADMIN_API_TOKEN, + }), + ...(process.env.MOCK_TLS_TERMINATION && { + baseOptions: { + "X-Forwarded-Proto": "https" + } + }) }), ), identity: new IdentityApi( diff --git a/src/routes/consent.ts b/src/routes/consent.ts index b7768100..6d38a6bc 100644 --- a/src/routes/consent.ts +++ b/src/routes/consent.ts @@ -10,25 +10,41 @@ import { } from "@ory/client" import { UserConsentCard } from "@ory/elements-markup" import bodyParser from "body-parser" -import { doubleCsrf } from "csrf-csrf" +import { doubleCsrf, DoubleCsrfCookieOptions } from "csrf-csrf" import { Request, Response, NextFunction } from "express" +const cookieOptions: DoubleCsrfCookieOptions = { + sameSite: "lax", + signed: true, + // set secure cookies by default (recommended in production) + // can be disabled through DANGEROUSLY_DISABLE_SECURE_COOKIES=true env var + secure: true, + ...(process.env.DANGEROUSLY_DISABLE_SECURE_CSRF_COOKIES && { + secure: false, + }) +} + +const cookieName = process.env.CSRF_COOKIE_NAME || "ax-x-csrf-token" +const cookieSecret = process.env.CSRF_COOKIE_SECRET + // Sets up csrf protection const { generateToken, // Use this in your routes to provide a CSRF hash + token cookie and token. invalidCsrfTokenError, doubleCsrfProtection, // This is the default CSRF protection middleware. } = doubleCsrf({ - getSecret: () => "VERY_SECRET_VALUE", // A function that optionally takes the request and returns a secret - cookieName: "ax-x-csrf-token", // The name of the cookie to be used, recommend using Host prefix. - cookieOptions: { - sameSite: "lax", // Recommend you make this strict if posible - secure: true, - }, + getSecret: () => cookieSecret || "", // A function that optionally takes the request and returns a secret + cookieName: cookieName, // The name of the cookie to be used, recommend using Host prefix. + cookieOptions, ignoredMethods: ["GET", "HEAD", "OPTIONS"], // A list of request methods that will not be protected. getTokenFromRequest: (req) => req.headers["x-csrf-token"], // A function that returns the token from the request }) +// Checks if OAuth2 consent is enabled +// This is used to determine if the consent route should be registered +// We need to check if the environment variables are set +const isOAuthCosentEnabled = () => (process.env.HYDRA_ADMIN_URL || process.env.ORY_SDK_URL) && process.env.CSRF_COOKIE_SECRET + // Error handling, validation error interception const csrfErrorHandler = ( error: unknown, @@ -37,7 +53,7 @@ const csrfErrorHandler = ( next: NextFunction, ) => { if (error == invalidCsrfTokenError) { - next(new Error("csrf validation error")) + next(new Error("A security violation was detected, please fill out the form again.")) } else { next() } @@ -82,7 +98,6 @@ async function createOAuth2ConsentRequestSession( // A simple express handler that shows the Hydra consent screen. export const createConsentRoute: RouteCreator = (createHelpers) => (req, res, next) => { - console.log("createConsentRoute") res.locals.projectName = "An application requests access to your data!" const { oauth2, identity } = createHelpers(req, res) @@ -102,7 +117,6 @@ export const createConsentRoute: RouteCreator = trustedClients = String(process.env.TRUSTED_CLIENT_IDS).split(",") } - console.log("getOAuth2ConsentRequest", challenge) // This section processes consent requests and either shows the consent UI or // accepts the consent request right away if the user has given consent to this // app before @@ -263,23 +277,22 @@ export const createConsentPostRoute: RouteCreator = var parseForm = bodyParser.urlencoded({ extended: false }) -export const registerConsentRoute: RouteRegistrator = function ( +export const registerConsentRoute: RouteRegistrator = function( app, createHelpers = defaultConfig, ) { - if (process.env.HYDRA_ADMIN_URL) { - console.log("found HYDRA_ADMIN_URL") + if (isOAuthCosentEnabled()) { return app.get("/consent", createConsentRoute(createHelpers)) } else { return register404Route } } -export const registerConsentPostRoute: RouteRegistrator = function ( +export const registerConsentPostRoute: RouteRegistrator = function( app, createHelpers = defaultConfig, ) { - if (process.env.HYDRA_ADMIN_URL) { + if (isOAuthCosentEnabled()) { return app.post( "/consent", parseForm,