diff --git a/README.md b/README.md index fdedcbac..64027e65 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,50 @@ 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 +86,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..98a40b93 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,5 +1,11 @@ { "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..a0bc4b2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,7 @@ 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 +79,32 @@ 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..4940748c 100644 --- a/src/pkg/sdk/index.ts +++ b/src/pkg/sdk/index.ts @@ -15,12 +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 = { basePath: apiBaseFrontendUrlInternal, @@ -32,7 +26,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..47ce9ab2 100644 --- a/src/routes/consent.ts +++ b/src/routes/consent.ts @@ -10,25 +10,43 @@ 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 +55,11 @@ 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 +104,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 +123,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 @@ -267,8 +287,7 @@ 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 @@ -279,7 +298,7 @@ export const registerConsentPostRoute: RouteRegistrator = function ( app, createHelpers = defaultConfig, ) { - if (process.env.HYDRA_ADMIN_URL) { + if (isOAuthCosentEnabled()) { return app.post( "/consent", parseForm,