diff --git a/resources/js/oauth2/consent.js b/resources/js/oauth2/consent.js
index 898141a4..3bf7e993 100644
--- a/resources/js/oauth2/consent.js
+++ b/resources/js/oauth2/consent.js
@@ -14,6 +14,7 @@ import Typography from "@material-ui/core/Typography";
import Tooltip from '@material-ui/core/Tooltip';
import {ClickAwayListener} from "@material-ui/core";
+import {AppName, RedirectNotice} from "./consent_components";
import styles from "./consent.module.scss";
const HtmlTooltip = withStyles((theme) => ({
@@ -36,7 +37,8 @@ const ConsentPage = (
disclaimer,
formAction,
redirectURL,
- requestedScopes
+ requestedScopes,
+ website
}) => {
const formEl = useRef(null);
const trustEl = useRef(null);
@@ -74,7 +76,7 @@ const ConsentPage = (
- {appName}
+
)}
}
-
+
}
>
diff --git a/resources/js/oauth2/consent_components.js b/resources/js/oauth2/consent_components.js
new file mode 100644
index 00000000..f6914cc7
--- /dev/null
+++ b/resources/js/oauth2/consent_components.js
@@ -0,0 +1,24 @@
+import React from "react";
+
+// only plain web URLs are safe to expose as a navigable link (blocks javascript:, custom schemes, etc.)
+export const safeWebsiteUrl = (website) => {
+ if (typeof website !== 'string' || website.trim() === '') return null;
+ try {
+ const url = new URL(website.trim());
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
+ } catch (e) {
+ return null;
+ }
+};
+
+// NOTE: never link to the client's redirect_uri; for native clients (custom URI scheme)
+// navigating to it without an authorization code kills the login.
+export const AppName = ({appName, website}) => {
+ const href = safeWebsiteUrl(website);
+ if (!href) return <>{appName}>;
+ return {appName};
+};
+
+export const RedirectNotice = ({redirectURL}) => (
+ Clicking 'Accept' will redirect you to: {redirectURL}.
+);
diff --git a/resources/js/oauth2/consent_components.test.js b/resources/js/oauth2/consent_components.test.js
new file mode 100644
index 00000000..04201b67
--- /dev/null
+++ b/resources/js/oauth2/consent_components.test.js
@@ -0,0 +1,51 @@
+import React from "react";
+import {renderToStaticMarkup} from "react-dom/server";
+import {AppName, RedirectNotice, safeWebsiteUrl} from "./consent_components";
+
+const NATIVE_REDIRECT = 'com.fntech.ftnattendee://callback';
+
+describe('safeWebsiteUrl', () => {
+ it('accepts http and https', () => {
+ expect(safeWebsiteUrl('https://example.com/app')).toBe('https://example.com/app');
+ expect(safeWebsiteUrl('http://example.com/')).toBe('http://example.com/');
+ });
+
+ it('rejects empty, non-string, malformed and non-web schemes', () => {
+ expect(safeWebsiteUrl(null)).toBeNull();
+ expect(safeWebsiteUrl(undefined)).toBeNull();
+ expect(safeWebsiteUrl(' ')).toBeNull();
+ expect(safeWebsiteUrl('not a url')).toBeNull();
+ expect(safeWebsiteUrl('javascript:alert(1)')).toBeNull();
+ expect(safeWebsiteUrl(NATIVE_REDIRECT)).toBeNull();
+ });
+});
+
+describe('AppName', () => {
+ it('links to the website in a new tab when configured', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('href="https://example.com/"');
+ expect(html).toContain('target="_blank"');
+ expect(html).toContain('rel="noopener noreferrer"');
+ expect(html).toContain('My App');
+ });
+
+ it('is plain text without a website', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toBe('My App');
+ expect(html).not.toContain(' {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain(' {
+ it('shows a native redirect_uri as text, never as a link', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain(NATIVE_REDIRECT);
+ expect(html).not.toContain('