diff --git a/hydra-nextjs/README.md b/hydra-nextjs/README.md index eca36fa0..19b03753 100644 --- a/hydra-nextjs/README.md +++ b/hydra-nextjs/README.md @@ -1,17 +1,20 @@ # ORY Hydra Next.js Reference Implementation -This is a Next.js app which sets up a OAuth 2.0 and OpenID Connect Provider using [Ory Hydra](https://www.ory.sh/docs/hydra/) as a backend. It has an unstyled UI and doesn't implement user management but can be easily modified to fit into your existing system. +This is a Next.js app which sets up a OAuth 2.0 and OpenID Connect Provider +using [Ory Hydra](https://www.ory.sh/docs/hydra/) as a backend. It has an +unstyled UI and doesn't implement user management but can be easily modified to +fit into your existing system. # Features - * User login, logout, registration, consent - * CSRF protection with [edge-csrf](https://github.com/amorey/edge-csrf) - * Super-strict HTTP security headers (configurable) - * Client-side JavaScript disabled by default - * Unit tests with Jest - * E2E tests with Cypress - * Start/stop Hydra in development using docker-compose - * Easily customizable +- User login, logout, registration, consent +- CSRF protection with [edge-csrf](https://github.com/amorey/edge-csrf) +- Super-strict HTTP security headers (configurable) +- Client-side JavaScript disabled by default +- Unit tests with Jest +- E2E tests with Cypress +- Start/stop Hydra in development using docker-compose +- Easily customizable ## Configuration @@ -36,7 +39,8 @@ To run the Next.js app server in development mode: yarn dev ``` -To start/stop hydra in development you can use the docker-compose file found in the `ory/` directory: +To start/stop hydra in development you can use the docker-compose file found in +the `ory/` directory: ```sh # start diff --git a/hydra-nextjs/cypress.config.js b/hydra-nextjs/cypress.config.js index da68d745..89e3d73a 100644 --- a/hydra-nextjs/cypress.config.js +++ b/hydra-nextjs/cypress.config.js @@ -1,10 +1,13 @@ -const { defineConfig } = require('cypress'); +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +const { defineConfig } = require("cypress") module.exports = defineConfig({ screenshotOnRunFailure: false, video: false, e2e: { - baseUrl: 'http://localhost:3000', - supportFile: false - } -}); + baseUrl: "http://localhost:3000", + supportFile: false, + }, +}) diff --git a/hydra-nextjs/cypress/e2e/csrf.cy.js b/hydra-nextjs/cypress/e2e/csrf.cy.js index 77175408..0555818c 100644 --- a/hydra-nextjs/cypress/e2e/csrf.cy.js +++ b/hydra-nextjs/cypress/e2e/csrf.cy.js @@ -1,38 +1,36 @@ -describe('CSRF protection tests', () => { - const authPaths = [ - '/sign-in', - '/sign-up', - '/sign-out', - '/consent' - ]; +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 - context('should reject POST request without csrf token', () => { +describe("CSRF protection tests", () => { + const authPaths = ["/sign-in", "/sign-up", "/sign-out", "/consent"] + + context("should reject POST request without csrf token", () => { authPaths.forEach((path) => { it(path, async () => { const response = await cy.request({ - method: 'POST', - url: '/auth' + path, - failOnStatusCode: false - }); - expect(response.status).to.equal(403); - }); - }); - }); + method: "POST", + url: "/auth" + path, + failOnStatusCode: false, + }) + expect(response.status).to.equal(403) + }) + }) + }) - context('should reject POST request with invalid csrf token', () => { + context("should reject POST request with invalid csrf token", () => { authPaths.forEach((path) => { it(path, async () => { const response = await cy.request({ - method: 'POST', - url: '/auth' + path, + method: "POST", + url: "/auth" + path, form: true, body: { - csrf_token: 'invalid-token' + csrf_token: "invalid-token", }, - failOnStatusCode: false - }); - expect(response.status).to.equal(403); - }); - }); - }); -}); + failOnStatusCode: false, + }) + expect(response.status).to.equal(403) + }) + }) + }) +}) diff --git a/hydra-nextjs/cypress/e2e/headers.cy.js b/hydra-nextjs/cypress/e2e/headers.cy.js index 9071cb06..ee4ecf77 100644 --- a/hydra-nextjs/cypress/e2e/headers.cy.js +++ b/hydra-nextjs/cypress/e2e/headers.cy.js @@ -1,27 +1,32 @@ -describe('HTTP response header tests', () => { - it('should have performance headers present', async () => { - const response = await cy.request('GET', '/'); - expect(response.headers).to.have.property('x-dns-prefetch-control', 'on'); - }); - - it('should have security headers present', async () => { +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +describe("HTTP response header tests", () => { + it("should have performance headers present", async () => { + const response = await cy.request("GET", "/") + expect(response.headers).to.have.property("x-dns-prefetch-control", "on") + }) + + it("should have security headers present", async () => { const securityHeaders = { - 'strict-transport-security': 'max-age=31536000 ; includeSubDomains', - 'x-frame-options': 'deny', - 'x-content-type-options': 'nosniff', - 'content-security-policy': "default-src 'self'; object-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content", - 'x-permitted-cross-domain-policies': 'none', - 'referrer-policy': 'no-referrer', - 'cross-origin-embedder-policy': 'require-corp', - 'cross-origin-opener-policy': 'same-origin', - 'cross-origin-resource-policy': 'same-origin', - 'permissions-policy': 'accelerometer=(),autoplay=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()' - }; + "strict-transport-security": "max-age=31536000 ; includeSubDomains", + "x-frame-options": "deny", + "x-content-type-options": "nosniff", + "content-security-policy": + "default-src 'self'; object-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content", + "x-permitted-cross-domain-policies": "none", + "referrer-policy": "no-referrer", + "cross-origin-embedder-policy": "require-corp", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": + "accelerometer=(),autoplay=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()", + } - const response = await cy.request('GET', '/'); + const response = await cy.request("GET", "/") for (const [key, val] of Object.entries(securityHeaders)) { - cy.log(key); - expect(response.headers).to.have.property(key, val); + cy.log(key) + expect(response.headers).to.have.property(key, val) } - }); -}); + }) +}) diff --git a/hydra-nextjs/jest.config.js b/hydra-nextjs/jest.config.js index df20bfd6..2797023c 100644 --- a/hydra-nextjs/jest.config.js +++ b/hydra-nextjs/jest.config.js @@ -1,12 +1,15 @@ -const nextJest = require("next/jest"); +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +const nextJest = require("next/jest") const createJestConfig = nextJest({ dir: "./", -}); +}) const customJestConfig = { moduleDirectories: ["node_modules", "/"], testEnvironment: "jest-environment-jsdom", -}; +} -module.exports = createJestConfig(customJestConfig); +module.exports = createJestConfig(customJestConfig) diff --git a/hydra-nextjs/lib/hydra.js b/hydra-nextjs/lib/hydra.js index 2ba81021..423a4add 100644 --- a/hydra-nextjs/lib/hydra.js +++ b/hydra-nextjs/lib/hydra.js @@ -1,9 +1,12 @@ -import { AdminApi, Configuration } from '@ory/hydra-client'; +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { AdminApi, Configuration } from "@ory/hydra-client" const hydraAdmin = new AdminApi( new Configuration({ - basePath: process.env.HYDRA_ADMIN_URL - }) -); + basePath: process.env.HYDRA_ADMIN_URL, + }), +) -export { hydraAdmin }; +export { hydraAdmin } diff --git a/hydra-nextjs/lib/ssr-helpers.js b/hydra-nextjs/lib/ssr-helpers.js index 2a764569..8e1a32d7 100644 --- a/hydra-nextjs/lib/ssr-helpers.js +++ b/hydra-nextjs/lib/ssr-helpers.js @@ -1,4 +1,7 @@ -import { parseBody as nextParseBody } from 'next/dist/server/api-utils/node'; +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { parseBody as nextParseBody } from "next/dist/server/api-utils/node" export function redirect(statusCode, url) { return { @@ -6,15 +9,15 @@ export function redirect(statusCode, url) { statusCode: statusCode, destination: url, }, - }; + } } export function parseBody(req, limit) { // check cache - if (req.body) return req.body; + if (req.body) return req.body // parse and cache - const body = nextParseBody(req, limit || '1mb'); - req.body = body; - return body; + const body = nextParseBody(req, limit || "1mb") + req.body = body + return body } diff --git a/hydra-nextjs/middleware.js b/hydra-nextjs/middleware.js index 463d4fc6..cbc77656 100644 --- a/hydra-nextjs/middleware.js +++ b/hydra-nextjs/middleware.js @@ -1,22 +1,24 @@ -import csrf from 'edge-csrf'; -import { NextResponse } from 'next/server'; +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import csrf from "edge-csrf" +import { NextResponse } from "next/server" // initialize csrf protection function -const csrfProtect = csrf(); +const csrfProtect = csrf() export async function middleware(request) { - const response = NextResponse.next(); + const response = NextResponse.next() // csrf protection - const csrfError = await csrfProtect(request, response); + const csrfError = await csrfProtect(request, response) // check result if (csrfError) { - const url = request.nextUrl.clone(); - url.pathname = '/api/csrf-invalid'; - return NextResponse.rewrite(url); + const url = request.nextUrl.clone() + url.pathname = "/api/csrf-invalid" + return NextResponse.rewrite(url) } - return response; + return response } - diff --git a/hydra-nextjs/next.config.mjs b/hydra-nextjs/next.config.mjs index d6d20e5c..761d96a6 100644 --- a/hydra-nextjs/next.config.mjs +++ b/hydra-nextjs/next.config.mjs @@ -1,57 +1,61 @@ /** @type {import('next').NextConfig} */ -import yn from 'yn'; +import yn from "yn" let headers = [ { - key: 'X-DNS-Prefetch-Control', - value: 'on' + key: "X-DNS-Prefetch-Control", + value: "on", }, -]; +] // security headers (https://owasp.org/www-project-secure-headers/) if (yn(process.env.SECURITY_HEADERS_ENABLE) === true) { - headers.push(...[ - { - key: 'Strict-Transport-Security', - value: 'max-age=31536000 ; includeSubDomains' - }, - { - key: 'X-Frame-Options', - value: 'deny' - }, - { - key: 'X-Content-Type-Options', - value: 'nosniff' - }, - { - key: 'Content-Security-Policy', - value: "default-src 'self'; object-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content" - }, - { - key: 'X-Permitted-Cross-Domain-Policies', - value: 'none' - }, - { - key: 'Referrer-Policy', - value: 'no-referrer' - }, - { - key: 'Cross-Origin-Embedder-Policy', - value: 'require-corp' - }, - { - key: 'Cross-Origin-Opener-Policy', - value: 'same-origin' - }, - { - key: 'Cross-Origin-Resource-Policy', - value: 'same-origin' - }, - { - key: 'Permissions-Policy', - value: 'accelerometer=(),autoplay=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()' - }, - ]); + headers.push( + ...[ + { + key: "Strict-Transport-Security", + value: "max-age=31536000 ; includeSubDomains", + }, + { + key: "X-Frame-Options", + value: "deny", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Content-Security-Policy", + value: + "default-src 'self'; object-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content", + }, + { + key: "X-Permitted-Cross-Domain-Policies", + value: "none", + }, + { + key: "Referrer-Policy", + value: "no-referrer", + }, + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp", + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Cross-Origin-Resource-Policy", + value: "same-origin", + }, + { + key: "Permissions-Policy", + value: + "accelerometer=(),autoplay=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()", + }, + ], + ) } const nextConfig = { @@ -59,17 +63,17 @@ const nextConfig = { webpack5: true, webpack: (config) => { // fixes npm packages that depend on `fs` module - config.resolve.fallback = { fs: false }; - return config; + config.resolve.fallback = { fs: false } + return config }, async headers() { return [ { - source: '/:path*', - headers: headers - } - ]; - } -}; + source: "/:path*", + headers: headers, + }, + ] + }, +} -export default nextConfig; +export default nextConfig diff --git a/hydra-nextjs/ory/docker-compose.yml b/hydra-nextjs/ory/docker-compose.yml index 9b26d8e1..1d1c3fea 100644 --- a/hydra-nextjs/ory/docker-compose.yml +++ b/hydra-nextjs/ory/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: hydra-migrate: diff --git a/hydra-nextjs/ory/hydra/hydra.yml b/hydra-nextjs/ory/hydra/hydra.yml index 91a240dd..060a72ab 100644 --- a/hydra-nextjs/ory/hydra/hydra.yml +++ b/hydra-nextjs/ory/hydra/hydra.yml @@ -2,7 +2,7 @@ serve: public: host: 0.0.0.0 port: 4444 - + admin: host: 0.0.0.0 port: 4445 diff --git a/hydra-nextjs/pages/404.js b/hydra-nextjs/pages/404.js index a77c3e35..870b3faf 100644 --- a/hydra-nextjs/pages/404.js +++ b/hydra-nextjs/pages/404.js @@ -1,3 +1,6 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + export default function NotFoundPage() { - return

404 - Page Not Found

; + return

404 - Page Not Found

} diff --git a/hydra-nextjs/pages/api/csrf-invalid.js b/hydra-nextjs/pages/api/csrf-invalid.js index a781e4b4..d40c9eb7 100644 --- a/hydra-nextjs/pages/api/csrf-invalid.js +++ b/hydra-nextjs/pages/api/csrf-invalid.js @@ -1,3 +1,6 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + export default function handler(req, res) { - res.status(403).send('invalid csrf token'); + res.status(403).send("invalid csrf token") } diff --git a/hydra-nextjs/pages/auth/consent.jsx b/hydra-nextjs/pages/auth/consent.jsx index 0d8a2bd6..ccb9af41 100644 --- a/hydra-nextjs/pages/auth/consent.jsx +++ b/hydra-nextjs/pages/auth/consent.jsx @@ -1,151 +1,185 @@ -import { parseBody } from 'next/dist/server/api-utils/node'; -import React from 'react'; +import { parseBody } from "next/dist/server/api-utils/node" +import React from "react" -import { hydraAdmin } from '../../lib/hydra'; -import { redirect } from '../../lib/ssr-helpers'; +import { hydraAdmin } from "../../lib/hydra" +import { redirect } from "../../lib/ssr-helpers" export const config = { - unstable_runtimeJS: false // disable client-side javascript (in production) -}; + unstable_runtimeJS: false, // disable client-side javascript (in production) +} const sessionData = (consentRequest) => { return { access_token: { - key1: 'val1', + key1: "val1", }, id_token: { - key2: 'val2', + key2: "val2", email: consentRequest.subject, }, - }; -}; + } +} export async function getServerSideProps({ req, res, query }) { // only accept GET and POST requests - if (!['GET', 'POST'].includes(req.method)) return {notFound: true}; + if (!["GET", "POST"].includes(req.method)) return { notFound: true } let props = { action: req.url, consentRequest: {}, - csrfToken: res.getHeader('x-csrf-token'), - message: '', - }; + csrfToken: res.getHeader("x-csrf-token"), + message: "", + } // get oauth consent challenge from url - const { consent_challenge } = query; + const { consent_challenge } = query if (!consent_challenge) { // render form - res.statusCode = 400; - props.message = 'Consent challenge is missing'; - return { props }; + res.statusCode = 400 + props.message = "Consent challenge is missing" + return { props } } - + // get consent request from hydra - let consentRequestResponse; + let consentRequestResponse try { - consentRequestResponse = await hydraAdmin.getConsentRequest(consent_challenge); + consentRequestResponse = await hydraAdmin.getConsentRequest( + consent_challenge, + ) } catch (e) { if (e.response?.status === 404) { // render form - res.statusCode = 400; - props.message = 'Consent challenge not found'; - return { props }; + res.statusCode = 400 + props.message = "Consent challenge not found" + return { props } } - throw e; + throw e } - const consentRequest = consentRequestResponse.data; + const consentRequest = consentRequestResponse.data // if user has granted application requested scope, hydra will tell us not to show ui if (consentRequest.skip) { - const acceptConsentRequestResponse = await hydraAdmin.acceptConsentRequest(consent_challenge, { - grant_scope: consentRequest.requested_scope, - grant_access_token_audience: consentRequest.requested_access_token_audience, - session: sessionData(consentRequest), - }); - + const acceptConsentRequestResponse = await hydraAdmin.acceptConsentRequest( + consent_challenge, + { + grant_scope: consentRequest.requested_scope, + grant_access_token_audience: + consentRequest.requested_access_token_audience, + session: sessionData(consentRequest), + }, + ) + // mission accomplished! redirect user back to hydra - return redirect(303, acceptConsentRequestResponse.data.redirect_to); - } + return redirect(303, acceptConsentRequestResponse.data.redirect_to) + } // process submission - if (req.method === 'POST') { - const formData = await parseBody(req, '1mb'); + if (req.method === "POST") { + const formData = await parseBody(req, "1mb") // check if user canceled request - if (formData.button === 'reject') { - const rejectConsentRequestResponse = await hydraAdmin.rejectConsentRequest(consent_challenge, { - error: 'access_denied', - error_description: 'The resource owner denied the request' - }); - + if (formData.button === "reject") { + const rejectConsentRequestResponse = + await hydraAdmin.rejectConsentRequest(consent_challenge, { + error: "access_denied", + error_description: "The resource owner denied the request", + }) + // redirect user back to hydra - return redirect(303, rejectConsentRequestResponse.data.redirect_to); + return redirect(303, rejectConsentRequestResponse.data.redirect_to) } // listify grant_scope - let grant_scope = formData.grant_scope || []; - grant_scope = Array.isArray(grant_scope) ? grant_scope : [grant_scope]; + let grant_scope = formData.grant_scope || [] + grant_scope = Array.isArray(grant_scope) ? grant_scope : [grant_scope] // tell hydra user has accepted consent request - const acceptConsentRequestResponse = await hydraAdmin.acceptConsentRequest(consent_challenge, { - grant_scope: grant_scope, - grant_access_token_audience: consentRequest.requested_access_token_audience, - session: sessionData(consentRequest), - remember: Boolean(formData.remember), - remember_for: 3600, - }); - + const acceptConsentRequestResponse = await hydraAdmin.acceptConsentRequest( + consent_challenge, + { + grant_scope: grant_scope, + grant_access_token_audience: + consentRequest.requested_access_token_audience, + session: sessionData(consentRequest), + remember: Boolean(formData.remember), + remember_for: 3600, + }, + ) + // mission accomplished! redirect user back to hydra - return redirect(303, acceptConsentRequestResponse.data.redirect_to); + return redirect(303, acceptConsentRequestResponse.data.redirect_to) } - + // render form - props.consentRequest = consentRequest; - return { props }; + props.consentRequest = consentRequest + return { props } } -export default function Consent({ action, consentRequest, csrfToken, message }) { +export default function Consent({ + action, + consentRequest, + csrfToken, + message, +}) { return ( <>

Give consent:

-
- {message &&
{message}
} + + {message &&
{message}
} +

Hello {consentRequest.subject}!

- Hello {consentRequest.subject}! -

-

- Client {consentRequest.client?.client_name || consentRequest.client?.client_id} wants to access your resources on your behalf and to: + Client{" "} + + {consentRequest.client?.client_name || + consentRequest.client?.client_id} + {" "} + wants to access your resources on your behalf and to:

{consentRequest.requested_scope?.map((scope, k) => (
- +
))}

- Do you want to be asked next time when this application wants to access your data? The application will - not be able to ask for more permissions without your consent. + Do you want to be asked next time when this application wants to + access your data? The application will not be able to ask for more + permissions without your consent.

- - + +
- ); + ) } diff --git a/hydra-nextjs/pages/auth/sign-in.jsx b/hydra-nextjs/pages/auth/sign-in.jsx index 386b3ce1..ba65295a 100644 --- a/hydra-nextjs/pages/auth/sign-in.jsx +++ b/hydra-nextjs/pages/auth/sign-in.jsx @@ -1,153 +1,190 @@ -import Nope from 'nope-validator'; -import React from 'react'; +import Nope from "nope-validator" +import React from "react" -import { hydraAdmin } from '../../lib/hydra'; -import { redirect, parseBody } from '../../lib/ssr-helpers'; +import { hydraAdmin } from "../../lib/hydra" +import { redirect, parseBody } from "../../lib/ssr-helpers" export const config = { - unstable_runtimeJS: false // disable client-side javascript (in production) -}; + unstable_runtimeJS: false, // disable client-side javascript (in production) +} const SignInForm = Nope.object().shape({ - email: Nope.string().required('Please enter an email address'), - password: Nope.string().required('Please enter a password'), + email: Nope.string().required("Please enter an email address"), + password: Nope.string().required("Please enter a password"), remember: Nope.boolean(), -}); +}) export async function getServerSideProps({ req, res, query }) { // only accept GET and POST requests - if (!['GET', 'POST'].includes(req.method)) return {notFound: true}; + if (!["GET", "POST"].includes(req.method)) return { notFound: true } let props = { action: req.url, - csrfToken: res.getHeader('x-csrf-token'), - message: '', + csrfToken: res.getHeader("x-csrf-token"), + message: "", formData: {}, formErrors: {}, - }; - + } + // get oauth login challenge from url - const { login_challenge } = query; + const { login_challenge } = query // login_challenge is required if (!login_challenge) { // render form - res.statusCode = 400; - props.message = 'Login challenge is missing'; - return { props }; + res.statusCode = 400 + props.message = "Login challenge is missing" + return { props } } - + // get login request from hydra - let loginRequestResponse; + let loginRequestResponse try { - loginRequestResponse = await hydraAdmin.getLoginRequest(login_challenge); + loginRequestResponse = await hydraAdmin.getLoginRequest(login_challenge) } catch (e) { if (e.response?.status === 404) { // render form - res.statusCode = 400; - props.message = 'Login challenge not found'; - return { props }; + res.statusCode = 400 + props.message = "Login challenge not found" + return { props } } - throw e; + throw e } // if hydra was able to authenticate the user, skip will be true if (loginRequestResponse.data.skip) { // grant login request - const acceptLoginRequestResponse = await hydraAdmin.acceptLoginRequest(login_challenge, { - subject: loginRequestResponse.data.subject, - }); - + const acceptLoginRequestResponse = await hydraAdmin.acceptLoginRequest( + login_challenge, + { + subject: loginRequestResponse.data.subject, + }, + ) + // mission accomplished! redirect user back to hydra - return redirect(303, acceptLoginRequestResponse.data.redirect_to); + return redirect(303, acceptLoginRequestResponse.data.redirect_to) } // process submission - if (req.method === 'POST') { - res.statusCode = 400; // set default to 400 - - const formData = await parseBody(req); + if (req.method === "POST") { + res.statusCode = 400 // set default to 400 + + const formData = await parseBody(req) // check if user canceled request - if (formData.button === 'cancel') { - const rejectLoginRequestResponse = await hydraAdmin.rejectLoginRequest(login_challenge, { - error: 'access_denied', - error_description: 'The resource owner denied the request' - }); + if (formData.button === "cancel") { + const rejectLoginRequestResponse = await hydraAdmin.rejectLoginRequest( + login_challenge, + { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + ) // redirect user back to hydra - return redirect(303, rejectLoginRequestResponse.data.redirect_to); + return redirect(303, rejectLoginRequestResponse.data.redirect_to) } - + // validate form - const formErrors = SignInForm.validate(formData); + const formErrors = SignInForm.validate(formData) // check credentials if (!formErrors) { - if (formData.email === 'foo@bar.com' && formData.password === 'foobar') { + if (formData.email === "foo@bar.com" && formData.password === "foobar") { // tell hydra user has authenticated - const acceptLoginRequestResponse = await hydraAdmin.acceptLoginRequest(login_challenge, { - subject: 'foo@bar.com', - remember: Boolean(formData.remember), - remember_for: 3600, - acr: '0' - }); + const acceptLoginRequestResponse = await hydraAdmin.acceptLoginRequest( + login_challenge, + { + subject: "foo@bar.com", + remember: Boolean(formData.remember), + remember_for: 3600, + acr: "0", + }, + ) // mission accomplished! redirect user back to hydra - return redirect(303, acceptLoginRequestResponse.data.redirect_to); + return redirect(303, acceptLoginRequestResponse.data.redirect_to) } - props.message = 'Email/Password combination not found'; + props.message = "Email/Password combination not found" } // render form - props.formData = formData; - props.formErrors = formErrors || {}; - return { props }; + props.formData = formData + props.formErrors = formErrors || {} + return { props } } // render form - return { props }; -}; + return { props } +} -export default function SignIn({ action, csrfToken, message, formData, formErrors }) { +export default function SignIn({ + action, + csrfToken, + message, + formData, + formErrors, +}) { return ( <>

Sign In:

-
- {message &&
{message}
} + + {message &&
{message}
}
- - {' '}(it's foo@bar.com) - {formErrors.email &&
{formErrors.email}
} + {" "} + (it's foo@bar.com) + {formErrors.email && ( +
{formErrors.email}
+ )}
- - {' '}(it's foobar) - {formErrors.password &&
{formErrors.password}
} + (it's + foobar) + {formErrors.password && ( +
{formErrors.password}
+ )}
- + - {formErrors.remember &&
{formErrors.remember}
} + {formErrors.remember && ( +
{formErrors.remember}
+ )}
- - + +

- {'Or'} + {"Or"}

-
Create a new account »
+
+ + Create a new account » + +
- ); + ) } diff --git a/hydra-nextjs/pages/auth/sign-out.jsx b/hydra-nextjs/pages/auth/sign-out.jsx index a5d08cfd..9836fe59 100644 --- a/hydra-nextjs/pages/auth/sign-out.jsx +++ b/hydra-nextjs/pages/auth/sign-out.jsx @@ -1,83 +1,86 @@ -import { parseBody } from 'next/dist/server/api-utils/node'; -import React from 'react'; +import { parseBody } from "next/dist/server/api-utils/node" +import React from "react" -import { hydraAdmin } from '../../lib/hydra'; -import { redirect } from '../../lib/ssr-helpers'; +import { hydraAdmin } from "../../lib/hydra" +import { redirect } from "../../lib/ssr-helpers" export const config = { - unstable_runtimeJS: false // disable client-side javascript (in production) -}; + unstable_runtimeJS: false, // disable client-side javascript (in production) +} export async function getServerSideProps({ req, res, query }) { // only accept GET and POST requests - if (!['GET', 'POST'].includes(req.method)) return {notFound: true}; + if (!["GET", "POST"].includes(req.method)) return { notFound: true } let props = { action: req.url, - csrfToken: res.getHeader('x-csrf-token'), - message: '', - }; + csrfToken: res.getHeader("x-csrf-token"), + message: "", + } // get oauth logout challenge from url - const { logout_challenge } = query; + const { logout_challenge } = query // logout_challenge is required if (!logout_challenge) { // render form - res.statusCode = 400; - props.message = 'Logout challenge is missing'; - return { props }; + res.statusCode = 400 + props.message = "Logout challenge is missing" + return { props } } // get logout request from hydra try { - await hydraAdmin.getLogoutRequest(logout_challenge); + await hydraAdmin.getLogoutRequest(logout_challenge) } catch (e) { if (e.response?.status === 404) { // render form - res.statusCode = 400; - props.message = 'Logout challenge not found'; - return { props }; + res.statusCode = 400 + props.message = "Logout challenge not found" + return { props } } - throw e; + throw e } // process submission - if (req.method === 'POST') { - const formData = await parseBody(req, '1mb'); + if (req.method === "POST") { + const formData = await parseBody(req, "1mb") // check if user canceled request - if (formData.button === 'cancel') { + if (formData.button === "cancel") { // reject request and redirect somewhere - await hydraAdmin.rejectLogoutRequest(logout_challenge); - return redirect(303, 'https://www.google.com/'); + await hydraAdmin.rejectLogoutRequest(logout_challenge) + return redirect(303, "https://www.google.com/") } // accept the logout request - const acceptLogoutRequestResponse = await hydraAdmin.acceptLogoutRequest(logout_challenge); - return redirect(303, acceptLogoutRequestResponse.data.redirect_to); + const acceptLogoutRequestResponse = await hydraAdmin.acceptLogoutRequest( + logout_challenge, + ) + return redirect(303, acceptLogoutRequestResponse.data.redirect_to) } - + // render form - return { props }; + return { props } } export default function SignOut({ action, csrfToken, message }) { return ( <>

Sign Out:

-
- {message &&
{message}
} + + {message &&
{message}
}

Do you want to log out?

- - + +
- ); + ) } diff --git a/hydra-nextjs/pages/auth/sign-up.jsx b/hydra-nextjs/pages/auth/sign-up.jsx index 4ebc79ba..a1763de9 100644 --- a/hydra-nextjs/pages/auth/sign-up.jsx +++ b/hydra-nextjs/pages/auth/sign-up.jsx @@ -1,143 +1,186 @@ -import { parseBody } from 'next/dist/server/api-utils/node'; -import Nope from 'nope-validator'; -import React from 'react'; +import { parseBody } from "next/dist/server/api-utils/node" +import Nope from "nope-validator" +import React from "react" -import { hydraAdmin } from '../../lib/hydra'; -import { redirect } from '../../lib/ssr-helpers'; +import { hydraAdmin } from "../../lib/hydra" +import { redirect } from "../../lib/ssr-helpers" const SignUpForm = Nope.object().shape({ - email: Nope.string().required('Please enter your email address'), - password: Nope.string().required('Please choose a password'), - password_confirm: Nope.string().oneOf([Nope.ref('password')], 'Passwords must match'), + email: Nope.string().required("Please enter your email address"), + password: Nope.string().required("Please choose a password"), + password_confirm: Nope.string().oneOf( + [Nope.ref("password")], + "Passwords must match", + ), remember: Nope.boolean(), -}); +}) export async function getServerSideProps({ req, res, query }) { // only accept GET and POST requests - if (!['GET', 'POST'].includes(req.method)) return {notFound: true}; + if (!["GET", "POST"].includes(req.method)) return { notFound: true } let props = { action: req.url, - csrfToken: res.getHeader('x-csrf-token'), - message: '', + csrfToken: res.getHeader("x-csrf-token"), + message: "", formData: {}, formErrors: {}, - }; + } // get oauth login challenge from url - const { login_challenge } = query; + const { login_challenge } = query // login_challenge is required if (!login_challenge) { // render form - res.statusCode = 400; - props.message = 'Login challenge is missing'; - return { props }; + res.statusCode = 400 + props.message = "Login challenge is missing" + return { props } } // get login request from hydra - let loginRequestResponse; + let loginRequestResponse try { - loginRequestResponse = await hydraAdmin.getLoginRequest(login_challenge); + loginRequestResponse = await hydraAdmin.getLoginRequest(login_challenge) } catch (e) { if (e.response?.status === 404) { // render form - res.statusCode = 400; - props.message = 'Login challenge not found'; - return { props }; + res.statusCode = 400 + props.message = "Login challenge not found" + return { props } } - throw e; + throw e } // process submission - if (req.method === 'POST') { - res.statusCode = 400; // set default to 400 - - const formData = await parseBody(req, '1mb'); + if (req.method === "POST") { + res.statusCode = 400 // set default to 400 + + const formData = await parseBody(req, "1mb") // check if user canceled request - if (formData.button === 'cancel') { - const rejectLoginRequestResponse = await hydraAdmin.rejectLoginRequest(login_challenge, { - error: 'access_denied', - error_description: 'The resource owner denied the request' - }); + if (formData.button === "cancel") { + const rejectLoginRequestResponse = await hydraAdmin.rejectLoginRequest( + login_challenge, + { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + ) // redirect user back to hydra - return redirect(303, rejectLoginRequestResponse.data.redirect_to); + return redirect(303, rejectLoginRequestResponse.data.redirect_to) } // validate form - const formErrors = SignUpForm.validate(formData); + const formErrors = SignUpForm.validate(formData) if (!formErrors) { // TODO: create user account - if (formData.email ==='foo@bar.com') { - props.message = 'Email is already in use'; + if (formData.email === "foo@bar.com") { + props.message = "Email is already in use" } else { // tell hydra user account has been created - const acceptLoginRequestResponse = await hydraAdmin.acceptLoginRequest(login_challenge, { - subject: formData.email, - remember: Boolean(formData.remember), - remember_for: 3600, - acr: '0' - }); - + const acceptLoginRequestResponse = await hydraAdmin.acceptLoginRequest( + login_challenge, + { + subject: formData.email, + remember: Boolean(formData.remember), + remember_for: 3600, + acr: "0", + }, + ) + // mission accomplished! redirect user back to hydra - return redirect(303, acceptLoginRequestResponse.data.redirect_to); + return redirect(303, acceptLoginRequestResponse.data.redirect_to) } } - + // render form - props.formData = formData; - props.formErrors = formErrors || {}; - return { props }; + props.formData = formData + props.formErrors = formErrors || {} + return { props } } - - return { props }; + + return { props } } -export default function SignUp({ action, csrfToken, message, formData, formErrors }) { +export default function SignUp({ + action, + csrfToken, + message, + formData, + formErrors, +}) { return ( <>

Sign Up:

-
- {message &&
{message}
} + + {message &&
{message}
}
- - {' '}(it's not foo@bar.com) - {formErrors.email &&
{formErrors.email}
} + {" "} + (it's not foo@bar.com) + {formErrors.email && ( +
{formErrors.email}
+ )}
- {formErrors.password &&
{formErrors.password}
} + {formErrors.password && ( +
{formErrors.password}
+ )}
- - {formErrors.password_confirm &&
{formErrors.password_confirm}
} + + {formErrors.password_confirm && ( +
{formErrors.password_confirm}
+ )}
- - - {formErrors.remember &&
{formErrors.remember}
} + + + {formErrors.remember && ( +
{formErrors.remember}
+ )}
- - + +

- {'Or'} + {"Or"}

-
Sign into your account »
+
+ + Sign into your account » + +
- ); + ) } diff --git a/hydra-nextjs/pages/index.jsx b/hydra-nextjs/pages/index.jsx index ab4517b5..78e0acc3 100644 --- a/hydra-nextjs/pages/index.jsx +++ b/hydra-nextjs/pages/index.jsx @@ -1,19 +1,31 @@ -import Link from 'next/link'; -import React from 'react'; +import Link from "next/link" +import React from "react" export const config = { - unstable_runtimeJS: false // disable client-side javascript (in production) -}; + unstable_runtimeJS: false, // disable client-side javascript (in production) +} export default function Home() { return ( <>
Pages:
- ); + ) } diff --git a/hydra-nextjs/tests/auth/consent.test.jsx b/hydra-nextjs/tests/auth/consent.test.jsx index 3fae5149..67561c95 100644 --- a/hydra-nextjs/tests/auth/consent.test.jsx +++ b/hydra-nextjs/tests/auth/consent.test.jsx @@ -1,318 +1,327 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" -import { hydraAdmin } from '../../lib/hydra'; -import Consent, { getServerSideProps } from '../../pages/auth/consent'; +import { hydraAdmin } from "../../lib/hydra" +import Consent, { getServerSideProps } from "../../pages/auth/consent" -import { mockRequest, mockResponse } from '../helpers'; +import { mockRequest, mockResponse } from "../helpers" -jest.mock('../../lib/hydra'); +jest.mock("../../lib/hydra") -describe('ContentPage', () => { - it('renders all form input elements', () => { +describe("ContentPage", () => { + it("renders all form input elements", () => { const result = render( - ); - - const inputs = result.container.querySelectorAll('input'); - expect(inputs.length).toEqual(4); - expect(inputs[0].name).toEqual('csrf_token'); - expect(inputs[1].name).toEqual('grant_scope'); - expect(inputs[1].value).toEqual('scope_1'); - expect(inputs[2].name).toEqual('grant_scope'); - expect(inputs[2].value).toEqual('scope_2'); - expect(inputs[3].name).toEqual('remember'); - }); -}); - -describe('getServerSideProps', () => { - afterEach(() => jest.resetAllMocks()); - - it('Rejects non-GET/POST requests', async () => { - const req = mockRequest({method: 'OTHER'}); - const result = await getServerSideProps({ req }); - expect(result.notFound).toEqual(true); - }); - - describe('GET and POST', () => { - const methods = ['GET', 'POST']; - - it.each(methods)('%s: Requires `consent_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Consent challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Rejects empty `consent_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {consent_challenge: ''}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Consent challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Requires valid `consent_challenge`', async (method) => { - hydraAdmin.getConsentRequest = jest.fn(() => { - let e = new Error(); - e.response = {status: 404}; - throw e; - }); - - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {consent_challenge: 'invalid'}; - const result = await getServerSideProps({ req, res, query }); - - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith('invalid'); - expect(result.props.message).toEqual('Consent challenge not found'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Accepts consent request when skip is true', async (method) => { - hydraAdmin.getConsentRequest.mockReturnValue({ - data: { - skip: true, - subject: 'name@example.com', - requested_scope: ['scope_1', 'scope_2'], - requested_access_token_audience: ['audience_1', 'audience_2'], - } - }); - - hydraAdmin.acceptConsentRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); - - const req = mockRequest({ method }); - const res = mockResponse(); - const query = { consent_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); - - // check getConsentRequest() - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith('valid'); - - // check acceptConsentRequest() - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledWith('valid', { - grant_scope: ['scope_1', 'scope_2'], - grant_access_token_audience: ['audience_1', 'audience_2'], - session: { - access_token: {key1: 'val1'}, - id_token: {key2: 'val2', email: 'name@example.com'} - } - }); - - // check result - expect(result).toMatchObject({ - redirect: { - statusCode: 303, - destination: '/redirect-url' - } - }); - }); - }); - - describe('GET', () => { - it('Renders form when `consent_challenge` is valid and skip is false', async () => { + />, + ) + + const inputs = result.container.querySelectorAll("input") + expect(inputs.length).toEqual(4) + expect(inputs[0].name).toEqual("csrf_token") + expect(inputs[1].name).toEqual("grant_scope") + expect(inputs[1].value).toEqual("scope_1") + expect(inputs[2].name).toEqual("grant_scope") + expect(inputs[2].value).toEqual("scope_2") + expect(inputs[3].name).toEqual("remember") + }) +}) + +describe("getServerSideProps", () => { + afterEach(() => jest.resetAllMocks()) + + it("Rejects non-GET/POST requests", async () => { + const req = mockRequest({ method: "OTHER" }) + const result = await getServerSideProps({ req }) + expect(result.notFound).toEqual(true) + }) + + describe("GET and POST", () => { + const methods = ["GET", "POST"] + + it.each(methods)("%s: Requires `consent_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = {} + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Consent challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)( + "%s: Rejects empty `consent_challenge`", + async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = { consent_challenge: "" } + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Consent challenge is missing") + expect(res.statusCode).toEqual(400) + }, + ) + + it.each(methods)( + "%s: Requires valid `consent_challenge`", + async (method) => { + hydraAdmin.getConsentRequest = jest.fn(() => { + let e = new Error() + e.response = { status: 404 } + throw e + }) + + const req = mockRequest({ method }) + const res = mockResponse() + const query = { consent_challenge: "invalid" } + const result = await getServerSideProps({ req, res, query }) + + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith("invalid") + expect(result.props.message).toEqual("Consent challenge not found") + expect(res.statusCode).toEqual(400) + }, + ) + + it.each(methods)( + "%s: Accepts consent request when skip is true", + async (method) => { + hydraAdmin.getConsentRequest.mockReturnValue({ + data: { + skip: true, + subject: "name@example.com", + requested_scope: ["scope_1", "scope_2"], + requested_access_token_audience: ["audience_1", "audience_2"], + }, + }) + + hydraAdmin.acceptConsentRequest.mockReturnValue({ + data: { redirect_to: "/redirect-url" }, + }) + + const req = mockRequest({ method }) + const res = mockResponse() + const query = { consent_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) + + // check getConsentRequest() + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith("valid") + + // check acceptConsentRequest() + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledWith("valid", { + grant_scope: ["scope_1", "scope_2"], + grant_access_token_audience: ["audience_1", "audience_2"], + session: { + access_token: { key1: "val1" }, + id_token: { key2: "val2", email: "name@example.com" }, + }, + }) + + // check result + expect(result).toMatchObject({ + redirect: { + statusCode: 303, + destination: "/redirect-url", + }, + }) + }, + ) + }) + + describe("GET", () => { + it("Renders form when `consent_challenge` is valid and skip is false", async () => { hydraAdmin.getConsentRequest.mockReturnValue({ data: { skip: false, - subject: 'name@example.com', - requested_scope: ['scope_1', 'scope_2'], - requested_access_token_audience: ['audience_1', 'audience_2'], - } - }); + subject: "name@example.com", + requested_scope: ["scope_1", "scope_2"], + requested_access_token_audience: ["audience_1", "audience_2"], + }, + }) const req = mockRequest({ - method: 'GET', - url: '/auth/consent?consent_challenge=valid' - }); - const res = mockResponse(); - const query = {consent_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + method: "GET", + url: "/auth/consent?consent_challenge=valid", + }) + const res = mockResponse() + const query = { consent_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getConsentRequest() - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith("valid") // check acceptConsentRequest() - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(0) // check result expect(result).toMatchObject({ props: { - action: '/auth/consent?consent_challenge=valid', - message: '', + action: "/auth/consent?consent_challenge=valid", + message: "", consentRequest: { skip: false, - subject: 'name@example.com', - requested_scope: ['scope_1', 'scope_2'], - requested_access_token_audience: ['audience_1', 'audience_2'], - } - } - }); - expect(res.statusCode).toEqual(200); - }); - }); - - describe('POST', () => { - it('Rejects hydra consentRequest when user clicks `reject`', async () => { + subject: "name@example.com", + requested_scope: ["scope_1", "scope_2"], + requested_access_token_audience: ["audience_1", "audience_2"], + }, + }, + }) + expect(res.statusCode).toEqual(200) + }) + }) + + describe("POST", () => { + it("Rejects hydra consentRequest when user clicks `reject`", async () => { hydraAdmin.getConsentRequest.mockReturnValue({ data: { skip: false, - subject: 'name@example.com', - requested_scope: ['scope_1', 'scope_2'], - requested_access_token_audience: ['audience_1', 'audience_2'], - } - }); + subject: "name@example.com", + requested_scope: ["scope_1", "scope_2"], + requested_access_token_audience: ["audience_1", "audience_2"], + }, + }) hydraAdmin.rejectConsentRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'button=reject' - }); - const res = mockResponse(); - const query = {consent_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "button=reject", + }) + const res = mockResponse() + const query = { consent_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getConsentRequest() - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith("valid") // check acceptConsentRequest() - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(0) // check rejectConsentRequest() - expect(hydraAdmin.rejectConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.rejectConsentRequest).toHaveBeenCalledWith('valid', { - error: 'access_denied', - error_description: 'The resource owner denied the request' - }); + expect(hydraAdmin.rejectConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.rejectConsentRequest).toHaveBeenCalledWith("valid", { + error: "access_denied", + error_description: "The resource owner denied the request", + }) // check result expect(result).toMatchObject({ - redirect: { + redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); - - it('Accepts correct submissions with one scope', async () => { + destination: "/redirect-url", + }, + }) + }) + + it("Accepts correct submissions with one scope", async () => { hydraAdmin.getConsentRequest.mockReturnValue({ data: { skip: false, - subject: 'name@example.com', - requested_scope: ['scope_1', 'scope_2'], - requested_access_token_audience: ['audience_1', 'audience_2'], - } - }); + subject: "name@example.com", + requested_scope: ["scope_1", "scope_2"], + requested_access_token_audience: ["audience_1", "audience_2"], + }, + }) hydraAdmin.acceptConsentRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'grant_scope=scope_1' - }); - const res = mockResponse(); - const query = {consent_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); - + body: "grant_scope=scope_1", + }) + const res = mockResponse() + const query = { consent_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) + // check getConsentRequest() - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith("valid") // check acceptConsentRequest() - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledWith('valid', { - grant_scope: ['scope_1'], - grant_access_token_audience: ['audience_1', 'audience_2'], + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledWith("valid", { + grant_scope: ["scope_1"], + grant_access_token_audience: ["audience_1", "audience_2"], session: { - access_token: {key1: 'val1'}, - id_token: {key2: 'val2', email: 'name@example.com'} + access_token: { key1: "val1" }, + id_token: { key2: "val2", email: "name@example.com" }, }, remember: false, - remember_for: 3600 - }); + remember_for: 3600, + }) // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); + destination: "/redirect-url", + }, + }) + }) - it('Accepts correct submissions with multiple scopes', async () => { + it("Accepts correct submissions with multiple scopes", async () => { hydraAdmin.getConsentRequest.mockReturnValue({ data: { skip: false, - subject: 'name@example.com', - requested_scope: ['scope_1', 'scope_2'], - requested_access_token_audience: ['audience_1', 'audience_2'], - } - }); + subject: "name@example.com", + requested_scope: ["scope_1", "scope_2"], + requested_access_token_audience: ["audience_1", "audience_2"], + }, + }) hydraAdmin.acceptConsentRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'grant_scope=scope_1&grant_scope=scope_2' - }); - const res = mockResponse(); - const query = {consent_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); - + body: "grant_scope=scope_1&grant_scope=scope_2", + }) + const res = mockResponse() + const query = { consent_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) + // check getConsentRequest() - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getConsentRequest).toHaveBeenCalledWith("valid") // check acceptConsentRequest() - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledWith('valid', { - grant_scope: ['scope_1', 'scope_2'], - grant_access_token_audience: ['audience_1', 'audience_2'], + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptConsentRequest).toHaveBeenCalledWith("valid", { + grant_scope: ["scope_1", "scope_2"], + grant_access_token_audience: ["audience_1", "audience_2"], session: { - access_token: {key1: 'val1'}, - id_token: {key2: 'val2', email: 'name@example.com'} + access_token: { key1: "val1" }, + id_token: { key2: "val2", email: "name@example.com" }, }, remember: false, - remember_for: 3600 - }); + remember_for: 3600, + }) // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); - }); -}); + destination: "/redirect-url", + }, + }) + }) + }) +}) diff --git a/hydra-nextjs/tests/auth/sign-in.test.jsx b/hydra-nextjs/tests/auth/sign-in.test.jsx index 757b858c..74e9f207 100644 --- a/hydra-nextjs/tests/auth/sign-in.test.jsx +++ b/hydra-nextjs/tests/auth/sign-in.test.jsx @@ -1,33 +1,28 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" -import { hydraAdmin } from '../../lib/hydra'; -import SignIn, { getServerSideProps } from '../../pages/auth/sign-in'; +import { hydraAdmin } from "../../lib/hydra" +import SignIn, { getServerSideProps } from "../../pages/auth/sign-in" -import { mockRequest, mockResponse } from '../helpers'; +import { mockRequest, mockResponse } from "../helpers" -jest.mock('../../lib/hydra'); +jest.mock("../../lib/hydra") -describe('SignInPage', () => { - it('renders all form input elements', () => { +describe("SignInPage", () => { + it("renders all form input elements", () => { const result = render( - - ); - - const inputs = result.container.querySelectorAll('input'); - expect(inputs.length).toEqual(4); - expect(inputs[0].name).toEqual('csrf_token'); - expect(inputs[1].name).toEqual('email'); - expect(inputs[2].name).toEqual('password'); - expect(inputs[3].name).toEqual('remember'); - }); - - it('renders generic error message properly', () => { + , + ) + + const inputs = result.container.querySelectorAll("input") + expect(inputs.length).toEqual(4) + expect(inputs[0].name).toEqual("csrf_token") + expect(inputs[1].name).toEqual("email") + expect(inputs[2].name).toEqual("password") + expect(inputs[3].name).toEqual("remember") + }) + + it("renders generic error message properly", () => { render( { message="Generic error message" formData={{}} formErrors={{}} - /> - ); - expect(screen.getByText("Generic error message")).toBeTruthy(); - }); + />, + ) + expect(screen.getByText("Generic error message")).toBeTruthy() + }) - it('renders email error message properly', () => { + it("renders email error message properly", () => { render( - ); - expect(screen.getByText("Email missing")).toBeTruthy(); - }); -}); - -describe('getServerSideProps', () => { - afterEach(() => jest.resetAllMocks()); - - it('Rejects non-GET/POST requests', async () => { - const req = mockRequest({method: 'OTHER'}); - const result = await getServerSideProps({ req }); - expect(result.notFound).toEqual(true); - }); - - describe('GET and POST', () => { - const methods = ['GET', 'POST']; - - it.each(methods)('%s: Requires `login_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Login challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Rejects empty `login_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {login_challenge: ''}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Login challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Requires valid `login_challenge`', async (method) => { + formErrors={{ email: "Email missing" }} + />, + ) + expect(screen.getByText("Email missing")).toBeTruthy() + }) +}) + +describe("getServerSideProps", () => { + afterEach(() => jest.resetAllMocks()) + + it("Rejects non-GET/POST requests", async () => { + const req = mockRequest({ method: "OTHER" }) + const result = await getServerSideProps({ req }) + expect(result.notFound).toEqual(true) + }) + + describe("GET and POST", () => { + const methods = ["GET", "POST"] + + it.each(methods)("%s: Requires `login_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = {} + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Login challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)("%s: Rejects empty `login_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = { login_challenge: "" } + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Login challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)("%s: Requires valid `login_challenge`", async (method) => { hydraAdmin.getLoginRequest = jest.fn(() => { - let e = new Error(); - e.response = {status: 404}; - throw e; - }); - - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {login_challenge: 'invalid'}; - const result = await getServerSideProps({ req, res, query }); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('invalid'); - expect(result.props.message).toEqual('Login challenge not found'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Accepts login request when skip is true', async (method) => { - hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: true, subject: 'my-subject'} - }); - - hydraAdmin.acceptLoginRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); - - const req = mockRequest({ method }); - const res = mockResponse(); - const query = { login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); - - // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); - - // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledWith('valid', { - subject: 'my-subject' - }); - - // check result - expect(result).toMatchObject({ - redirect: { - statusCode: 303, - destination: '/redirect-url' - } - }); - }); - }); - - describe('GET', () => { - it('Renders form when `login_challenge` is valid and skip is false', async () => { + let e = new Error() + e.response = { status: 404 } + throw e + }) + + const req = mockRequest({ method }) + const res = mockResponse() + const query = { login_challenge: "invalid" } + const result = await getServerSideProps({ req, res, query }) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("invalid") + expect(result.props.message).toEqual("Login challenge not found") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)( + "%s: Accepts login request when skip is true", + async (method) => { + hydraAdmin.getLoginRequest.mockReturnValue({ + data: { skip: true, subject: "my-subject" }, + }) + + hydraAdmin.acceptLoginRequest.mockReturnValue({ + data: { redirect_to: "/redirect-url" }, + }) + + const req = mockRequest({ method }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) + + // check getLoginRequest() + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") + + // check acceptLoginRequest() + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledWith("valid", { + subject: "my-subject", + }) + + // check result + expect(result).toMatchObject({ + redirect: { + statusCode: 303, + destination: "/redirect-url", + }, + }) + }, + ) + }) + + describe("GET", () => { + it("Renders form when `login_challenge` is valid and skip is false", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'GET', - url: '/auth/sign-in?login_challenge=valid' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + method: "GET", + url: "/auth/sign-in?login_challenge=valid", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); - + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") + // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0) // check result expect(result).toMatchObject({ props: { - action: '/auth/sign-in?login_challenge=valid', - message: '', + action: "/auth/sign-in?login_challenge=valid", + message: "", formData: {}, - formErrors: {} - } - }); - expect(res.statusCode).toEqual(200); - }); - }); - - describe('POST', () => { - it('Rejects hydra loginRequest when user clicks `cancel`', async () => { + formErrors: {}, + }, + }) + expect(res.statusCode).toEqual(200) + }) + }) + + describe("POST", () => { + it("Rejects hydra loginRequest when user clicks `cancel`", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) hydraAdmin.rejectLoginRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'button=cancel' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "button=cancel", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); - + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") + // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0) // check rejectLoginRequest() - expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledWith('valid', { - error: 'access_denied', - error_description: 'The resource owner denied the request' - }); - + expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledWith("valid", { + error: "access_denied", + error_description: "The resource owner denied the request", + }) + // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); + destination: "/redirect-url", + }, + }) + }) - it('Rejects missing email', async () => { + it("Rejects missing email", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: '' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check result expect(result).toMatchObject({ props: { formErrors: { - email: 'Please enter an email address' - } - } - }); - expect(res.statusCode).toEqual(400); - }); - - it('Rejects missing password', async () => { + email: "Please enter an email address", + }, + }, + }) + expect(res.statusCode).toEqual(400) + }) + + it("Rejects missing password", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: '' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); - + body: "", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) + // check result expect(result).toMatchObject({ props: { formErrors: { - password: 'Please enter a password' - } - } - }); - expect(res.statusCode).toEqual(400); - }); - - it('Accepts correct credentials', async () => { + password: "Please enter a password", + }, + }, + }) + expect(res.statusCode).toEqual(400) + }) + + it("Accepts correct credentials", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) hydraAdmin.acceptLoginRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); - + data: { redirect_to: "/redirect-url" }, + }) + const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'remember=1&password=foobar&email=' + encodeURIComponent('foo@bar.com') - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: + "remember=1&password=foobar&email=" + + encodeURIComponent("foo@bar.com"), + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledWith('valid', { - subject: 'foo@bar.com', + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledWith("valid", { + subject: "foo@bar.com", remember: true, remember_for: 3600, - acr: '0' - }); - + acr: "0", + }) + // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); + destination: "/redirect-url", + }, + }) + }) - it('Rejects incorrect credentials', async () => { + it("Rejects incorrect credentials", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'password=x&email=y' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "password=x&email=y", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0); - + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0) + // check result expect(result).toMatchObject({ props: { - message: 'Email/Password combination not found' - } - }); - expect(res.statusCode).toEqual(400); - }); - }); -}); + message: "Email/Password combination not found", + }, + }) + expect(res.statusCode).toEqual(400) + }) + }) +}) diff --git a/hydra-nextjs/tests/auth/sign-out.test.jsx b/hydra-nextjs/tests/auth/sign-out.test.jsx index 47c16693..91a94126 100644 --- a/hydra-nextjs/tests/auth/sign-out.test.jsx +++ b/hydra-nextjs/tests/auth/sign-out.test.jsx @@ -1,182 +1,180 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; - -import { hydraAdmin } from '../../lib/hydra'; -import SignOut, { getServerSideProps } from '../../pages/auth/sign-out'; - -import { mockRequest, mockResponse } from '../helpers'; - -jest.mock('../../lib/hydra'); - -describe('SignOutPage', () => { - it('renders all form input elements', () => { - const result = render( - - ); - - const inputs = result.container.querySelectorAll('input'); - expect(inputs.length).toEqual(1); - expect(inputs[0].name).toEqual('csrf_token'); - - const buttons = result.container.querySelectorAll('button'); - expect(buttons.length).toEqual(2); - expect(buttons[0].name).toEqual('button'); - expect(buttons[0].value).toEqual('submit'); - expect(buttons[1].name).toEqual('button'); - expect(buttons[1].value).toEqual('cancel'); - }); -}); - -describe('getServerSideProps', () => { - afterEach(() => jest.resetAllMocks()); - - it('Rejects non-GET/POST requests', async () => { - const req = mockRequest({method: 'OTHER'}); - const result = await getServerSideProps({ req }); - expect(result.notFound).toEqual(true); - }); - - describe('GET and POST', () => { - const methods = ['GET', 'POST']; - - it.each(methods)('%s: Requires `logout_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Logout challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Rejects empty `logout_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {consent_challenge: ''}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Logout challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Requires valid `logout_challenge`', async (method) => { - hydraAdmin.getLogoutRequest = jest.fn(() => { - let e = new Error(); - e.response = {status: 404}; - throw e; - }); - - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {logout_challenge: 'invalid'}; - const result = await getServerSideProps({ req, res, query }); - - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith('invalid'); - expect(result.props.message).toEqual('Logout challenge not found'); - expect(res.statusCode).toEqual(400); - }); - }); - - describe('GET', () => { - it('Renders form when `logout_challenge` is valid', async () => { - hydraAdmin.getLogoutRequest.mockReturnValue({}); +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" + +import { hydraAdmin } from "../../lib/hydra" +import SignOut, { getServerSideProps } from "../../pages/auth/sign-out" + +import { mockRequest, mockResponse } from "../helpers" + +jest.mock("../../lib/hydra") + +describe("SignOutPage", () => { + it("renders all form input elements", () => { + const result = render() + + const inputs = result.container.querySelectorAll("input") + expect(inputs.length).toEqual(1) + expect(inputs[0].name).toEqual("csrf_token") + + const buttons = result.container.querySelectorAll("button") + expect(buttons.length).toEqual(2) + expect(buttons[0].name).toEqual("button") + expect(buttons[0].value).toEqual("submit") + expect(buttons[1].name).toEqual("button") + expect(buttons[1].value).toEqual("cancel") + }) +}) + +describe("getServerSideProps", () => { + afterEach(() => jest.resetAllMocks()) + + it("Rejects non-GET/POST requests", async () => { + const req = mockRequest({ method: "OTHER" }) + const result = await getServerSideProps({ req }) + expect(result.notFound).toEqual(true) + }) + + describe("GET and POST", () => { + const methods = ["GET", "POST"] + + it.each(methods)("%s: Requires `logout_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = {} + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Logout challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)("%s: Rejects empty `logout_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = { consent_challenge: "" } + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Logout challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)( + "%s: Requires valid `logout_challenge`", + async (method) => { + hydraAdmin.getLogoutRequest = jest.fn(() => { + let e = new Error() + e.response = { status: 404 } + throw e + }) + + const req = mockRequest({ method }) + const res = mockResponse() + const query = { logout_challenge: "invalid" } + const result = await getServerSideProps({ req, res, query }) + + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith("invalid") + expect(result.props.message).toEqual("Logout challenge not found") + expect(res.statusCode).toEqual(400) + }, + ) + }) + + describe("GET", () => { + it("Renders form when `logout_challenge` is valid", async () => { + hydraAdmin.getLogoutRequest.mockReturnValue({}) const req = mockRequest({ - method: 'GET', - url: '/auth/sign-out?logout_challenge=valid' - }); - const res = mockResponse(); - const query = {logout_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + method: "GET", + url: "/auth/sign-out?logout_challenge=valid", + }) + const res = mockResponse() + const query = { logout_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLogoutRequest() - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith('valid'); - + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith("valid") + // check acceptLogoutRequest() - expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledTimes(0) // check result expect(result).toMatchObject({ props: { - action: '/auth/sign-out?logout_challenge=valid', - message: '' - } - }); - expect(res.statusCode).toEqual(200); - }); - }); - - describe('POST', () => { - it('Rejects hydra logoutRequest when user clicks `reject`', async () => { - hydraAdmin.getLogoutRequest.mockReturnValue({}); + action: "/auth/sign-out?logout_challenge=valid", + message: "", + }, + }) + expect(res.statusCode).toEqual(200) + }) + }) + + describe("POST", () => { + it("Rejects hydra logoutRequest when user clicks `reject`", async () => { + hydraAdmin.getLogoutRequest.mockReturnValue({}) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'button=cancel' - }); - const res = mockResponse(); - const query = {logout_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "button=cancel", + }) + const res = mockResponse() + const query = { logout_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLogoutRequest() - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith("valid") // check acceptLogoutRequest() - expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledTimes(0); - + expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledTimes(0) + // check rejectLogoutRequest() - expect(hydraAdmin.rejectLogoutRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.rejectLogoutRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.rejectLogoutRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.rejectLogoutRequest).toHaveBeenCalledWith("valid") // check result expect(result).toMatchObject({ redirect: { - statusCode: 303 - } - }); - }); + statusCode: 303, + }, + }) + }) - it('Accepts hydra logoutRequest when user clicks `submit`', async () => { - hydraAdmin.getLogoutRequest.mockReturnValue({}); + it("Accepts hydra logoutRequest when user clicks `submit`", async () => { + hydraAdmin.getLogoutRequest.mockReturnValue({}) hydraAdmin.acceptLogoutRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - }); - const res = mockResponse(); - const query = {logout_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + }) + const res = mockResponse() + const query = { logout_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLogoutRequest() - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLogoutRequest).toHaveBeenCalledWith("valid") // check rejectLogoutRequest() - expect(hydraAdmin.rejectLogoutRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.rejectLogoutRequest).toHaveBeenCalledTimes(0) // check acceptLogoutRequest() - expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptLogoutRequest).toHaveBeenCalledWith("valid") // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); - }); -}); + destination: "/redirect-url", + }, + }) + }) + }) +}) diff --git a/hydra-nextjs/tests/auth/sign-up.test.jsx b/hydra-nextjs/tests/auth/sign-up.test.jsx index df3d6423..343ced0a 100644 --- a/hydra-nextjs/tests/auth/sign-up.test.jsx +++ b/hydra-nextjs/tests/auth/sign-up.test.jsx @@ -1,329 +1,327 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" -import { hydraAdmin } from '../../lib/hydra'; -import SignUp, { getServerSideProps } from '../../pages/auth/sign-up'; +import { hydraAdmin } from "../../lib/hydra" +import SignUp, { getServerSideProps } from "../../pages/auth/sign-up" -import { mockRequest, mockResponse } from '../helpers'; +import { mockRequest, mockResponse } from "../helpers" -jest.mock('../../lib/hydra'); +jest.mock("../../lib/hydra") -describe('SignInPage', () => { - it('renders all form input elements', () => { - const result = render( - - ); - - const inputs = result.container.querySelectorAll('input'); - expect(inputs.length).toEqual(5); - expect(inputs[0].name).toEqual('csrf_token'); - expect(inputs[1].name).toEqual('email'); - expect(inputs[2].name).toEqual('password'); - expect(inputs[3].name).toEqual('password_confirm'); - expect(inputs[4].name).toEqual('remember'); - }); - - it('renders generic error message properly', () => { +describe("SignInPage", () => { + it("renders all form input elements", () => { + const result = render() + + const inputs = result.container.querySelectorAll("input") + expect(inputs.length).toEqual(5) + expect(inputs[0].name).toEqual("csrf_token") + expect(inputs[1].name).toEqual("email") + expect(inputs[2].name).toEqual("password") + expect(inputs[3].name).toEqual("password_confirm") + expect(inputs[4].name).toEqual("remember") + }) + + it("renders generic error message properly", () => { render( - ); - expect(screen.getByText("Generic error message")).toBeTruthy(); - }); + />, + ) + expect(screen.getByText("Generic error message")).toBeTruthy() + }) - it('renders email error message properly', () => { + it("renders email error message properly", () => { render( - ); - expect(screen.getByText("Email missing")).toBeTruthy(); - }); -}); - -describe('getServerSideProps', () => { - afterEach(() => jest.resetAllMocks()); - - it('Rejects non-GET/POST requests', async () => { - const req = mockRequest({method: 'OTHER'}); - const result = await getServerSideProps({ req }); - expect(result.notFound).toEqual(true); - }); - - describe('GET and POST', () => { - const methods = ['GET', 'POST']; - - it.each(methods)('%s: Requires `login_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Login challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Rejects empty `login_challenge`', async (method) => { - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {login_challenge: ''}; - const result = await getServerSideProps({ req, res, query }); - expect(result.props.message).toEqual('Login challenge is missing'); - expect(res.statusCode).toEqual(400); - }); - - it.each(methods)('%s: Requires valid `login_challenge`', async (method) => { + formErrors={{ email: "Email missing" }} + />, + ) + expect(screen.getByText("Email missing")).toBeTruthy() + }) +}) + +describe("getServerSideProps", () => { + afterEach(() => jest.resetAllMocks()) + + it("Rejects non-GET/POST requests", async () => { + const req = mockRequest({ method: "OTHER" }) + const result = await getServerSideProps({ req }) + expect(result.notFound).toEqual(true) + }) + + describe("GET and POST", () => { + const methods = ["GET", "POST"] + + it.each(methods)("%s: Requires `login_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = {} + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Login challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)("%s: Rejects empty `login_challenge`", async (method) => { + const req = mockRequest({ method }) + const res = mockResponse() + const query = { login_challenge: "" } + const result = await getServerSideProps({ req, res, query }) + expect(result.props.message).toEqual("Login challenge is missing") + expect(res.statusCode).toEqual(400) + }) + + it.each(methods)("%s: Requires valid `login_challenge`", async (method) => { hydraAdmin.getLoginRequest = jest.fn(() => { - let e = new Error(); - e.response = {status: 404}; - throw e; - }); - - const req = mockRequest({ method }); - const res = mockResponse(); - const query = {login_challenge: 'invalid'}; - const result = await getServerSideProps({ req, res, query }); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('invalid'); - expect(result.props.message).toEqual('Login challenge not found'); - expect(res.statusCode).toEqual(400); - }); - }); - - describe('GET', () => { - it('Renders form when `login_challenge` is valid and skip is false', async () => { + let e = new Error() + e.response = { status: 404 } + throw e + }) + + const req = mockRequest({ method }) + const res = mockResponse() + const query = { login_challenge: "invalid" } + const result = await getServerSideProps({ req, res, query }) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("invalid") + expect(result.props.message).toEqual("Login challenge not found") + expect(res.statusCode).toEqual(400) + }) + }) + + describe("GET", () => { + it("Renders form when `login_challenge` is valid and skip is false", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'GET', - url: '/auth/sign-up?login_challenge=valid' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + method: "GET", + url: "/auth/sign-up?login_challenge=valid", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); - + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") + // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0) // check result expect(result).toMatchObject({ props: { - action: '/auth/sign-up?login_challenge=valid', - message: '', + action: "/auth/sign-up?login_challenge=valid", + message: "", formData: {}, - formErrors: {} - } - }); - expect(res.statusCode).toEqual(200); - }); - }); - - describe('POST', () => { - it('Rejects hydra loginRequest when user clicks `cancel`', async () => { + formErrors: {}, + }, + }) + expect(res.statusCode).toEqual(200) + }) + }) + + describe("POST", () => { + it("Rejects hydra loginRequest when user clicks `cancel`", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) hydraAdmin.rejectLoginRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'button=cancel' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "button=cancel", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); - + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") + // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0); + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(0) // check rejectLoginRequest() - expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledWith('valid', { - error: 'access_denied', - error_description: 'The resource owner denied the request' - }); - + expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.rejectLoginRequest).toHaveBeenCalledWith("valid", { + error: "access_denied", + error_description: "The resource owner denied the request", + }) + // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); + destination: "/redirect-url", + }, + }) + }) - it('Rejects missing email', async () => { + it("Rejects missing email", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: '' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check result expect(result).toMatchObject({ props: { formErrors: { - email: 'Please enter your email address' - } - } - }); - expect(res.statusCode).toEqual(400); - }); - - it('Rejects missing password', async () => { + email: "Please enter your email address", + }, + }, + }) + expect(res.statusCode).toEqual(400) + }) + + it("Rejects missing password", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: '' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check result expect(result).toMatchObject({ props: { formErrors: { - password: 'Please choose a password' - } - } - }); - expect(res.statusCode).toEqual(400); - }); - - it('Rejects incorrect password confirmation', async () => { + password: "Please choose a password", + }, + }, + }) + expect(res.statusCode).toEqual(400) + }) + + it("Rejects incorrect password confirmation", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'password=x&password_confirm=y' - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: "password=x&password_confirm=y", + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check result expect(result).toMatchObject({ props: { formErrors: { - password_confirm: 'Passwords must match' - } - } - }); - expect(res.statusCode).toEqual(400); - }); - - it('Rejects emails already in use', async () => { + password_confirm: "Passwords must match", + }, + }, + }) + expect(res.statusCode).toEqual(400) + }) + + it("Rejects emails already in use", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'password=x&password_confirm=x&email=' + encodeURIComponent('foo@bar.com') - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: + "password=x&password_confirm=x&email=" + + encodeURIComponent("foo@bar.com"), + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check result expect(result).toMatchObject({ props: { - message: 'Email is already in use' - } - }); - expect(res.statusCode).toEqual(400); - }); + message: "Email is already in use", + }, + }) + expect(res.statusCode).toEqual(400) + }) - it('Accepts valid submission', async() => { + it("Accepts valid submission", async () => { hydraAdmin.getLoginRequest.mockReturnValue({ - data: {skip: false} - }); + data: { skip: false }, + }) hydraAdmin.acceptLoginRequest.mockReturnValue({ - data: {redirect_to: '/redirect-url'} - }); + data: { redirect_to: "/redirect-url" }, + }) const req = mockRequest({ - method: 'POST', + method: "POST", headers: { - 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", }, - body: 'remember=1&password=x&password_confirm=x&email=' + encodeURIComponent('email@example.com') - }); - const res = mockResponse(); - const query = {login_challenge: 'valid'}; - const result = await getServerSideProps({ req, res, query }); + body: + "remember=1&password=x&password_confirm=x&email=" + + encodeURIComponent("email@example.com"), + }) + const res = mockResponse() + const query = { login_challenge: "valid" } + const result = await getServerSideProps({ req, res, query }) // check getLoginRequest() - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith('valid'); + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.getLoginRequest).toHaveBeenCalledWith("valid") // check acceptLoginRequest() - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(1); - expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledWith('valid', { - subject: 'email@example.com', + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledTimes(1) + expect(hydraAdmin.acceptLoginRequest).toHaveBeenCalledWith("valid", { + subject: "email@example.com", remember: true, remember_for: 3600, - acr: '0' - }); + acr: "0", + }) // check result expect(result).toMatchObject({ redirect: { statusCode: 303, - destination: '/redirect-url' - } - }); - }); - }); -}); + destination: "/redirect-url", + }, + }) + }) + }) +}) diff --git a/hydra-nextjs/tests/helpers.js b/hydra-nextjs/tests/helpers.js index 181b2d48..dafb23e3 100644 --- a/hydra-nextjs/tests/helpers.js +++ b/hydra-nextjs/tests/helpers.js @@ -1,18 +1,21 @@ -import http from 'http'; +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import http from "http" export function mockRequest(options) { - let req = new http.IncomingMessage(); + let req = new http.IncomingMessage() - const { body, ...attrs } = options || {}; - req = Object.assign(req, attrs); + const { body, ...attrs } = options || {} + req = Object.assign(req, attrs) // write body - req.push(body || ''); - req.push(null); + req.push(body || "") + req.push(null) - return req; + return req } export function mockResponse(options) { - return new http.ServerResponse(options || {}); + return new http.ServerResponse(options || {}) } diff --git a/hydra-nextjs/tests/index.test.jsx b/hydra-nextjs/tests/index.test.jsx index 8dfe69dd..82fbefb7 100644 --- a/hydra-nextjs/tests/index.test.jsx +++ b/hydra-nextjs/tests/index.test.jsx @@ -1,15 +1,15 @@ -import '@testing-library/jest-dom'; -import { render } from '@testing-library/react'; +import "@testing-library/jest-dom" +import { render } from "@testing-library/react" -import Home from '../pages/index'; +import Home from "../pages/index" -describe('HomePage', () => { - it('displays auth links', () => { - const result = render(); - const links = result.container.querySelectorAll('a'); - expect(links.length).toEqual(3); - expect(links[0].innerHTML).toEqual('Sign-in'); - expect(links[1].innerHTML).toEqual('Sign-up'); - expect(links[2].innerHTML).toEqual('Sign-out'); - }); -}); +describe("HomePage", () => { + it("displays auth links", () => { + const result = render() + const links = result.container.querySelectorAll("a") + expect(links.length).toEqual(3) + expect(links[0].innerHTML).toEqual("Sign-in") + expect(links[1].innerHTML).toEqual("Sign-up") + expect(links[2].innerHTML).toEqual("Sign-out") + }) +}) diff --git a/ory-actions/vpncheck-py/.gitignore b/ory-actions/vpncheck-py/.gitignore new file mode 100644 index 00000000..6cf1bae5 --- /dev/null +++ b/ory-actions/vpncheck-py/.gitignore @@ -0,0 +1,5 @@ +venv +node_modules +package-lock.json +.gcloudignore + diff --git a/ory-actions/vpncheck-py/README.md b/ory-actions/vpncheck-py/README.md new file mode 100644 index 00000000..cf282928 --- /dev/null +++ b/ory-actions/vpncheck-py/README.md @@ -0,0 +1,131 @@ +# Ory Action to check IP addresses against vpnapi.io + +This is an example Action (webhook) to check client IP addresses against +vpnapi.com and block requests + +- coming from TOR clients +- coming from known VPNs +- coming from certain geographies (in this example: RU) + +It's intended for use as a post-login Action on Ory Network and returns a +message that can be parsed by Ory and displayed to the user. + +The example implementation is written in Python with Flask for deployment on GCP +Cloud Functions, and can be adapted for different scenarios. + +## Develop + +### Prerequisites + +- A Google Cloud project with Cloud Functions active (or an alternate way to + deploy) +- A vpnapi.com account +- python 3.9+ with flask, requests, google cloud logging + +To install dependencies, run e.g. + +```bash +pip3 install flask +pip3 install google-cloud-logging +``` + +### Environmental Variables + +```bash +export BEARER_TOKEN=SOME_SECRET_API_KEY_FOR_YOUR_WEBHOOK; +export VPNAPIIO_API_KEY=YOUR_VPNAPI_KEY; +python3 main.py +``` + +### Run locally + +```bash +cd ory-actions/vpncheck-py +python3 main.py +``` + +#### Send a sample request + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_WEBHOOK_API_SECRET" \ + -d '{"ip_address": "8.8.8.8"}' \ + http://localhost:5000/vpncheck -v +``` + +For blocked requests, you'll get `HTTP 400` responses with a payload like + +```json +{ "messages": [{ "messages": [{ "text": "Request blocked: VPN" }] }] } +``` + +When successful, you'll get a `HTTP 200` response. + +## Deploy + +After setting up your GCP project (see, for example, +[this guide](https://cloud.google.com/functions/docs/create-deploy-http-python)), +you can deploy the Action as a cloud function: + +```bash +gcloud functions deploy vpncheck --runtime python39 --trigger-http --allow-unauthenticated --set-env-vars BEARER_TOKEN=$SOME_SECRET_API_KEY_FOR_YOUR_WEBHOOK,VPNAPIIO_API_KEY=$VPNAPIIO_API_KEY,ENABLE_CLOUD_LOGGING=true --source=. +``` + +Note: You may need to create a `venv` for dependencies to load correctly. + +You'll receive an endpoint address, which you can plug into the `curl` command +above. On Google's Cloud Console, you can also see logs to verify it's working +as intended. + +### Integrating with Ory + +To set up your Ory Network project to use the Action, go to Ory Console > +Developers > Actions and create a new post-login webhook: + +![Console Actions Screen](docs/images/actions-console-2.png) + +Configure it for the Login flow, select "After" execution, and POST as the +method, and enter your deployed URL. + +Because we want the Action to cancel logins from disallowed IP addresses, we +need to run in synchronous. Enabling `parse response` allows us to show a nice +error message to users, rather than a system error. + +![Console Actions Screen](docs/images/actions-console-1.png) + +On the second screen, configure authentication with `Key`, select `Header` as +the transport mode and put in your API key in the format +`Bearer: $SOME_SECRET_API_KEY_FOR_YOUR_WEBHOOK` as the key value. You can of +course use other ways to authenticate - this is just how the example implemented +a basic check. + +Our webhook expects a simple payload with just an `ip_address` field. We can get +the IP address from the context with a simple JSONNET transformation: + +```javascript + function(ctx) { + ip_address: ctx.request_headers['True-Client-Ip'][0], + } +``` + +![Console Actions Screen](docs/images/actions-console-3.png) + +### Seeing it in action + +With everything set up, we can test the behavior using the Ory Account +Experience. When logging in via VPN, the request now gets blocked and the +message is shown to users! + +![Account Experience displaying error](docs/images/ax-with-message.png) + +## Contribute + +Feel free to +[open a discussion](https://github.com/ory/examples/discussions/new) to provide +feedback or talk about ideas, or +[open an issue](https://github.com/ory/examples/issues/new) if you want to add +your example to the repository or encounter a bug. You can contribute to Ory in +many ways, see the +[Ory Contributing Guidelines](https://www.ory.sh/docs/ecosystem/contributing) +for more information. diff --git a/ory-actions/vpncheck-py/docs/images/actions-console-1.png b/ory-actions/vpncheck-py/docs/images/actions-console-1.png new file mode 100644 index 00000000..08041e32 Binary files /dev/null and b/ory-actions/vpncheck-py/docs/images/actions-console-1.png differ diff --git a/ory-actions/vpncheck-py/docs/images/actions-console-2.png b/ory-actions/vpncheck-py/docs/images/actions-console-2.png new file mode 100644 index 00000000..3dfe0ad7 Binary files /dev/null and b/ory-actions/vpncheck-py/docs/images/actions-console-2.png differ diff --git a/ory-actions/vpncheck-py/docs/images/actions-console-3.png b/ory-actions/vpncheck-py/docs/images/actions-console-3.png new file mode 100644 index 00000000..d4b757ca Binary files /dev/null and b/ory-actions/vpncheck-py/docs/images/actions-console-3.png differ diff --git a/ory-actions/vpncheck-py/docs/images/ax-with-message.png b/ory-actions/vpncheck-py/docs/images/ax-with-message.png new file mode 100644 index 00000000..7e5df897 Binary files /dev/null and b/ory-actions/vpncheck-py/docs/images/ax-with-message.png differ diff --git a/ory-actions/vpncheck-py/main.py b/ory-actions/vpncheck-py/main.py new file mode 100644 index 00000000..5aff0b99 --- /dev/null +++ b/ory-actions/vpncheck-py/main.py @@ -0,0 +1,102 @@ +# Copyright © 2023 Ory Corp +# SPDX-License-Identifier: Apache-2.0 + +from flask import Flask, request, jsonify +import requests + +import os + +# load google cloud logging if running on GCP +if os.getenv('ENABLE_CLOUD_LOGGING', ''): + # set up the Google Cloud Logging python client library + import google.cloud.logging + client = google.cloud.logging.Client() + client.setup_logging() + +# use Python’s standard logging library to send logs to GCP +import logging + +app = Flask(__name__) + +# Define the bearer token for authentication +BEARER_TOKEN = os.environ.get("BEARER_TOKEN") +VPNAPIIO_API_KEY = os.environ.get("VPNAPIIO_API_KEY") + +if not BEARER_TOKEN or not VPNAPIIO_API_KEY: + raise ValueError("BEARER_TOKEN or VPNAPIIO_API_KEY not set in environment variables.") + +@app.route("/vpncheck", methods=["POST"]) +def handle_vpncheck(): + return vpncheck(request) + +def vpncheck(request): + # Check for bearer token authentication + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return jsonify({"error": "Unauthorized"}), 401 + + provided_token = auth_header.split("Bearer ")[1] + if provided_token != BEARER_TOKEN: + return jsonify({"error": "Unauthorized"}), 401 + + # Parse the JSON payload and extract the IP address + data = request.get_json() + logging.info(f"request: {data}") + ip_address = data.get("ip_address") + if not ip_address: + return error_response("Cannot determine Client IP address") + + # Call vpnapi.io to check the IP address + # if the API fails, we permit by default + try: + vpn_result = query_vpn_io(ip_address) + except Exception as e: + return jsonify({"warning": "Unable to check VPN: ", "details": str(e)}), 200 + + # Check the response from vpnapi.io + if "error" in vpn_result and vpn_result["error"] == "Blocked": + return error_response("Request blocked: Blocked by VPN API") + + if "security" in vpn_result: + security_info = vpn_result["security"] + if "vpn" in security_info and security_info["vpn"] == True: + logging.info(f"vpn block: {security_info['vpn']}") + return error_response("Request blocked: VPN") + if "tor" in security_info and security_info["tor"] == True: + logging.info(f"tor block: {security_info['tor']}") + return error_response("Request blocked: Tor") + + if ( + "location" in vpn_result + and "country_code" in vpn_result["location"] + and vpn_result["location"]["country_code"] == "RU" + ): + logging.info(f"geoblock: {vpn_result['location']['country_code']}") + return error_response("Request blocked: Geolocation") + + # Return the result as success or error details + return jsonify(vpn_result), 200 + + +def error_response(msg): + return jsonify({"messages": [{ "messages": [{ "text": msg }] }]}), 400 + + +def query_vpn_io(ip_address): + # Implement the logic to call vpnapi.io and retrieve the result + # You can use libraries like requests or httpx for making HTTP requests + # Return the response as a dictionary + # For example: + + url = f"https://vpnapi.io/api/{ip_address}?key={VPNAPIIO_API_KEY}" + response = requests.get(url, timeout=1.5) + if response.status_code != 200: + raise Exception(f"vpnapi.io returned {response.status_code}") + + result = response.json() + + return result + + +if __name__ == "__main__": + app.run() diff --git a/ory-actions/vpncheck-py/package.json b/ory-actions/vpncheck-py/package.json new file mode 100644 index 00000000..e8b2ae8d --- /dev/null +++ b/ory-actions/vpncheck-py/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "@google-cloud/functions-framework": "^3.3.0" + }, + "dependencies": { + "flask": "^0.2.10" + } +} diff --git a/ory-actions/vpncheck-py/requirements.txt b/ory-actions/vpncheck-py/requirements.txt new file mode 100644 index 00000000..e3c3f4cf --- /dev/null +++ b/ory-actions/vpncheck-py/requirements.txt @@ -0,0 +1,2 @@ +requests +google-cloud-logging