diff --git a/assets/hanyuu.js/csrfp.js b/assets/hanyuu.js/csrfp.js
new file mode 100644
index 0000000..effb880
--- /dev/null
+++ b/assets/hanyuu.js/csrfp.js
@@ -0,0 +1,44 @@
+const HanyuuCSRFP = (() => {
+ let elem;
+ const getElement = () => {
+ if(elem === undefined)
+ elem = document.querySelector('meta[name="csrfp-token"]');
+ return elem;
+ };
+
+ const getToken = () => {
+ const elem = getElement();
+ return typeof elem.content === 'string' ? elem.content : '';
+ };
+
+ const setToken = token => {
+ if(typeof token !== 'string')
+ throw 'token must be a string';
+
+ let elem = getElement();
+ if(typeof elem.content === 'string')
+ elem.content = token;
+ else {
+ elem = document.createElement('meta');
+ elem.name = 'csrfp-token';
+ elem.content = token;
+ document.head.appendChild(elem);
+ }
+ };
+
+ return {
+ getToken: getToken,
+ setToken: setToken,
+ setFromHeaders: result => {
+ if(typeof result.headers !== 'function')
+ throw 'result.headers is not a function';
+
+ const headers = result.headers();
+ if(!(headers instanceof Map))
+ throw 'result of result.headers does not return a map';
+
+ if(headers.has('x-csrfp-token'))
+ setToken(headers.get('x-csrfp-token'));
+ },
+ };
+})();
diff --git a/assets/hanyuu.js/html.js b/assets/hanyuu.js/html.js
new file mode 100644
index 0000000..286d560
--- /dev/null
+++ b/assets/hanyuu.js/html.js
@@ -0,0 +1,116 @@
+const $e = function(info, attrs, child, created) {
+ info = info || {};
+
+ if(typeof info === 'string') {
+ info = {tag: info};
+ if(attrs)
+ info.attrs = attrs;
+ if(child)
+ info.child = child;
+ if(created)
+ info.created = created;
+ }
+
+ const elem = document.createElement(info.tag || 'div');
+
+ if(info.attrs) {
+ const attrs = info.attrs;
+
+ for(let key in attrs) {
+ const attr = attrs[key];
+ if(attr === undefined || attr === null)
+ continue;
+
+ switch(typeof attr) {
+ case 'function':
+ if(key.substring(0, 2) === 'on')
+ key = key.substring(2).toLowerCase();
+ elem.addEventListener(key, attr);
+ break;
+
+ case 'object':
+ if(attr instanceof Array) {
+ if(key === 'class')
+ key = 'classList';
+
+ const prop = elem[key];
+ let addFunc = null;
+
+ if(prop instanceof Array)
+ addFunc = prop.push.bind(prop);
+ else if(prop instanceof DOMTokenList)
+ addFunc = prop.add.bind(prop);
+
+ if(addFunc !== null) {
+ for(let j = 0; j < attr.length; ++j)
+ addFunc(attr[j]);
+ } else {
+ if(key === 'classList')
+ key = 'class';
+ elem.setAttribute(key, attr.toString());
+ }
+ } else {
+ for(const attrKey in attr)
+ elem[key][attrKey] = attr[attrKey];
+ }
+ break;
+
+ case 'boolean':
+ if(attr)
+ elem.setAttribute(key, '');
+ break;
+
+ default:
+ if(key === 'className')
+ key = 'class';
+ elem.setAttribute(key, attr.toString());
+ break;
+ }
+ }
+ }
+
+ if(info.child) {
+ let children = info.child;
+
+ if(!Array.isArray(children))
+ children = [children];
+
+ for(const child of children) {
+ switch(typeof child) {
+ case 'string':
+ elem.appendChild(document.createTextNode(child));
+ break;
+
+ case 'object':
+ if(child instanceof Element) {
+ elem.appendChild(child);
+ } else if('element' in child) {
+ const childElem = child.element;
+ if(childElem instanceof Element)
+ elem.appendChild(childElem);
+ else
+ elem.appendChild($e(child));
+ } else if('getElement' in child) {
+ const childElem = child.getElement();
+ if(childElem instanceof Element)
+ elem.appendChild(childElem);
+ else
+ elem.appendChild($e(child));
+ } else {
+ elem.appendChild($e(child));
+ }
+ break;
+
+ default:
+ elem.appendChild(document.createTextNode(child.toString()));
+ break;
+ }
+ }
+ }
+
+ if(info.created)
+ info.created(elem);
+
+ return elem;
+};
+const $er = (type, props, ...children) => $e({ tag: type, attrs: props, child: children });
diff --git a/assets/hanyuu.js/main.js b/assets/hanyuu.js/main.js
new file mode 100644
index 0000000..f9a3deb
--- /dev/null
+++ b/assets/hanyuu.js/main.js
@@ -0,0 +1,3 @@
+#include csrfp.js
+#include html.js
+#include xhr.js
diff --git a/assets/hanyuu.js/xhr.js b/assets/hanyuu.js/xhr.js
new file mode 100644
index 0000000..c6759f7
--- /dev/null
+++ b/assets/hanyuu.js/xhr.js
@@ -0,0 +1,114 @@
+const $x = (function() {
+ const send = function(method, url, options, body) {
+ if(options === undefined)
+ options = {};
+ else if(typeof options !== 'object')
+ throw 'options must be undefined or an object';
+
+ const xhr = new XMLHttpRequest;
+ const requestHeaders = new Map;
+
+ if('headers' in options && typeof options.headers === 'object')
+ for(const name in options.headers)
+ if(options.headers.hasOwnProperty(name))
+ requestHeaders.set(name.toLowerCase(), options.headers[name]);
+
+ if(typeof options.download === 'function') {
+ xhr.onloadstart = ev => options.download(ev);
+ xhr.onprogress = ev => options.download(ev);
+ xhr.onloadend = ev => options.download(ev);
+ }
+
+ if(typeof options.upload === 'function') {
+ xhr.upload.onloadstart = ev => options.upload(ev);
+ xhr.upload.onprogress = ev => options.upload(ev);
+ xhr.upload.onloadend = ev => options.upload(ev);
+ }
+
+ if(options.authed)
+ xhr.withCredentials = true;
+
+ if(typeof options.timeout === 'number')
+ xhr.timeout = options.timeout;
+
+ if(typeof options.type === 'string')
+ xhr.responseType = options.type;
+
+ if(typeof options.abort === 'function')
+ options.abort(() => xhr.abort());
+
+ if(typeof options.xhr === 'function')
+ options.xhr(() => xhr);
+
+ if(typeof body === 'object') {
+ if(body instanceof URLSearchParams) {
+ requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
+ } else if(body instanceof FormData) {
+ // content-type is implicitly set
+ } else if(body instanceof Blob || body instanceof ArrayBuffer || body instanceof DataView) {
+ if(!requestHeaders.has('content-type'))
+ requestHeaders.set('content-type', 'application/octet-stream');
+ } else if(!requestHeaders.has('content-type')) {
+ const bodyParts = [];
+ for(const name in body)
+ if(body.hasOwnProperty(name))
+ bodyParts.push(encodeURIComponent(name) + '=' + encodeURIComponent(body[name]));
+ body = bodyParts.join('&');
+ requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
+ }
+ }
+
+ return new Promise((resolve, reject) => {
+ let responseHeaders = undefined;
+
+ xhr.onload = ev => resolve({
+ status: xhr.status,
+ body: () => xhr.response,
+ text: () => xhr.responseText,
+ headers: () => {
+ if(responseHeaders !== undefined)
+ return responseHeaders;
+
+ responseHeaders = new Map;
+
+ const raw = xhr.getAllResponseHeaders().trim().split(/[\r\n]+/);
+ for(const name in raw)
+ if(raw.hasOwnProperty(name)) {
+ const parts = raw[name].split(': ');
+ responseHeaders.set(parts.shift(), parts.join(': '));
+ }
+
+ return responseHeaders;
+ },
+ xhr: xhr,
+ ev: ev,
+ });
+
+ xhr.onabort = ev => reject({
+ abort: true,
+ xhr: xhr,
+ ev: ev,
+ });
+
+ xhr.onerror = ev => reject({
+ abort: false,
+ xhr: xhr,
+ ev: ev,
+ });
+
+ xhr.open(method, url);
+ for(const [name, value] of requestHeaders)
+ xhr.setRequestHeader(name, value);
+ xhr.send(body);
+ });
+ };
+
+ return {
+ send: send,
+ get: (url, options, body) => send('GET', url, options, body),
+ post: (url, options, body) => send('POST', url, options, body),
+ delete: (url, options, body) => send('DELETE', url, options, body),
+ patch: (url, options, body) => send('PATCH', url, options, body),
+ put: (url, options, body) => send('PUT', url, options, body),
+ };
+})();
diff --git a/assets/oauth2.css/approval.css b/assets/oauth2.css/approval.css
new file mode 100644
index 0000000..598a10b
--- /dev/null
+++ b/assets/oauth2.css/approval.css
@@ -0,0 +1,35 @@
+.oauth2-approval {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.oauth2-approval-icon {
+ flex: 0 0 auto;
+ background-color: #fff;
+ width: 40px;
+ height: 40px;
+ margin: 10px;
+}
+.oauth2-approval-icon-approved {
+ mask: url('/images/circle-check-solid.svg') no-repeat center;
+}
+.oauth2-approval-icon-denied {
+ mask: url('/images/circle-xmark-solid.svg') no-repeat center;
+}
+
+.oauth2-approval-header {
+ text-align: center;
+ font-size: 1.2em;
+ line-height: 1.5em;
+}
+
+.oauth2-approval-text {
+ width: 100%;
+}
+
+.oauth2-approval-text p {
+ margin: .5em 0;
+ font-size: .8em;
+ line-height: 1.5em;
+}
diff --git a/assets/oauth2.css/authorise.css b/assets/oauth2.css/authorise.css
index 533b71a..b04a03d 100644
--- a/assets/oauth2.css/authorise.css
+++ b/assets/oauth2.css/authorise.css
@@ -7,6 +7,14 @@
margin: .5em 0;
}
+.oauth2-authorise-device {
+ font-size: .8em;
+ line-height: 1.4em;
+}
+.oauth2-authorise-device p {
+ margin: .5em 0;
+}
+
.oauth2-authorise-buttons {
margin-top: 10px;
display: flex;
@@ -30,6 +38,13 @@
align-items: center;
justify-content: center;
text-decoration: none;
+ color: #8559a5;
+ border-color: #8559a5;
+}
+.oauth2-authorise-button:hover,
+.oauth2-authorise-button:focus {
+ color: #191919;
+ background-color: #8559a5;
}
.oauth2-authorise-button[disabled] {
diff --git a/assets/oauth2.css/device.css b/assets/oauth2.css/device.css
new file mode 100644
index 0000000..2e653fa
--- /dev/null
+++ b/assets/oauth2.css/device.css
@@ -0,0 +1,43 @@
+.oauth2-devicehead {
+ display: flex;
+ align-items: center;
+}
+
+.oauth2-devicehead-icon {
+ flex: 0 0 auto;
+ background-color: #fff;
+ mask: url('/images/mobile-screen-solid.svg') no-repeat center;
+ width: 40px;
+ height: 40px;
+ margin: 10px;
+}
+
+.oauth2-devicehead-text {
+ font-size: 1.8em;
+ line-height: 1.4em;
+}
+
+.oauth2-device-form {
+ display: flex;
+ justify-content: center;
+ margin: 10px;
+}
+
+.oauth2-device-code {
+ font-size: 1.4em;
+ border: 1px solid #222;
+ padding: 5px 10px;
+ background: #222;
+ color: #fff;
+ border-radius: 2px;
+ box-shadow: inset 0 0 4px #111;
+ transition: border-color .2s;
+ text-align: center;
+ font-family: var(--font-monospace);
+ min-width: 0;
+ max-width: 200px;
+ width: 100%;
+}
+.oauth2-device-code:focus {
+ border-color: #8559a5;
+}
diff --git a/assets/oauth2.css/loading.css b/assets/oauth2.css/loading.css
new file mode 100644
index 0000000..925d947
--- /dev/null
+++ b/assets/oauth2.css/loading.css
@@ -0,0 +1,36 @@
+.oauth2-loading {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ min-height: 200px;
+}
+
+.oauth2-loading-frame {
+ display: flex;
+ justify-content: center;
+ flex: 0 0 auto;
+}
+
+.oauth2-loading-icon {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ gap: 2px;
+ margin: 20px;
+}
+
+.oauth2-loading-icon-block {
+ background: #fff;
+ width: 20px;
+ height: 20px;
+}
+
+.oauth2-loading-icon-block-hidden {
+ opacity: 0;
+}
+
+.oauth2-loading-text {
+ text-align: center;
+ font-size: 1.2em;
+ line-height: 1.5em;
+}
diff --git a/assets/oauth2.css/main.css b/assets/oauth2.css/main.css
index 928a75b..690ecbd 100644
--- a/assets/oauth2.css/main.css
+++ b/assets/oauth2.css/main.css
@@ -35,10 +35,13 @@
margin: 10px;
}
+@include loading.css;
@include banner.css;
@include error.css;
@include login.css;
+@include device.css;
@include userhead.css;
@include appinfo.css;
@include scope.css;
@include authorise.css;
+@include approval.css;
diff --git a/assets/oauth2.js/app/info.jsx b/assets/oauth2.js/app/info.jsx
new file mode 100644
index 0000000..43bf5e3
--- /dev/null
+++ b/assets/oauth2.js/app/info.jsx
@@ -0,0 +1,30 @@
+const HanyuuOAuth2AppInfoLink = function(info) {
+ const element =
+
+ {info.display}
+ ;
+
+ return {
+ get element() { return element; },
+ };
+};
+
+const HanyuuOAuth2AppInfo = function(info) {
+ const linksElem =
;
+ if(Array.isArray(info.links))
+ for(const link of info.links)
+ linksElem.appendChild((new HanyuuOAuth2AppInfoLink(link)).element);
+
+ // TODO: author should be listed
+ const element =
+
{info.name}
+ {linksElem}
+
+
;
+
+ return {
+ get element() { return element; },
+ };
+};
diff --git a/assets/oauth2.js/app/scope.jsx b/assets/oauth2.js/app/scope.jsx
new file mode 100644
index 0000000..328cf0b
--- /dev/null
+++ b/assets/oauth2.js/app/scope.jsx
@@ -0,0 +1,29 @@
+const HanyuuOAuth2AppScopeEntry = function(text) {
+ const element = ;
+
+ return {
+ get element() { return element; },
+ };
+};
+
+const HanyuuOAuth2AppScopeList = function() {
+ // TODO: actual scope listing
+ const permsElem =
+ {new HanyuuOAuth2AppScopeEntry('Do anything because I have not made up scopes yet.')}
+ {new HanyuuOAuth2AppScopeEntry('Eat soup.')}
+ {new HanyuuOAuth2AppScopeEntry('These are placeholders.')}
+ {new HanyuuOAuth2AppScopeEntry('This one is really long because I want to test wrapping and how the chevron icon thing will handle it so there will be a lot of text here, the app will not be gaining anything from it but yeah sometimes you just need to explode seventy times.')}
+
;
+
+ const element =
+
+ {permsElem}
+
;
+
+ return {
+ get element() { return element; },
+ };
+};
diff --git a/assets/oauth2.js/device/verify.js b/assets/oauth2.js/device/verify.js
new file mode 100644
index 0000000..eb29ac1
--- /dev/null
+++ b/assets/oauth2.js/device/verify.js
@@ -0,0 +1,150 @@
+#include loading.jsx
+#include app/info.jsx
+#include app/scope.jsx
+#include header/header.js
+#include header/user.jsx
+
+const HanyuuOAuth2DeviceVerify = () => {
+ const queryParams = new URLSearchParams(window.location.search);
+ const loading = new HanyuuOAuth2Loading('.js-loading');
+ const header = new HanyuuOAuth2Header;
+
+ const fAuths = document.querySelector('.js-verify-authorise');
+ const eAuthsInfo = document.querySelector('.js-verify-authorise-info');
+ const eAuthsScope = document.querySelector('.js-verify-authorise-scope');
+
+ const rApproved = document.querySelector('.js-verify-approved');
+ const rDenied = document.querySelector('.js-verify-denied');
+
+ let userCode = '';
+ let userHeader;
+ const verifyDeviceRequest = async approve => {
+ return await $x.post('/oauth2/device/verify', { type: 'json' }, {
+ _csrfp: HanyuuCSRFP.getToken(),
+ code: userCode,
+ approve: approve === true ? 'yes' : 'no',
+ });
+ };
+
+ const handleVerifyDeviceResponse = result => {
+ const response = result.body();
+
+ if(!response || typeof response.error === 'string') {
+ // TODO: nicer errors
+ if(response.error === 'auth')
+ alert('You are not logged in.');
+ else if(response.error === 'csrf')
+ alert('Request verification failed, please refresh and try again.');
+ else if(response.error === 'code')
+ alert('This code is not associated with any device authorisation request.');
+ else if(response.error === 'approval')
+ alert('The device authorisation request associated with this code is not pending approval.');
+ else if(response.error === 'invalid')
+ alert('Invalid approval state specified.');
+ else
+ alert(`An unknown error occurred: ${response.error}`);
+
+ loading.visible = false;
+ fAuths.classList.remove('hidden');
+ return;
+ }
+
+ loading.visible = false;
+ if(response.approval === 'approved')
+ rApproved.classList.remove('hidden');
+ else
+ rDenied.classList.remove('hidden');
+ };
+
+ fAuths.onsubmit = ev => {
+ ev.preventDefault();
+
+ loading.visible = true;
+ fAuths.classList.add('hidden');
+
+ if(userHeader)
+ userHeader.guiseVisible = false;
+
+ verifyDeviceRequest(ev.submitter.value === 'yes')
+ .then(handleVerifyDeviceResponse);
+ };
+
+ const fCode = document.querySelector('.js-verify-code');
+ const eUserCode = fCode.elements.namedItem('code');
+
+ fCode.onsubmit = ev => {
+ ev.preventDefault();
+
+ loading.visible = true;
+ fCode.classList.add('hidden');
+
+ $x.get(`/oauth2/device/resolve?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${encodeURIComponent(eUserCode.value)}`, { type: 'json' })
+ .then(result => {
+ const response = result.body();
+
+ if(!response || typeof response.error === 'string') {
+ // TODO: nicer errors
+ if(response.error === 'auth')
+ alert('You are not logged in.');
+ else if(response.error === 'csrf')
+ alert('Request verification failed, please refresh and try again.');
+ else if(response.error === 'code')
+ alert('This code is not associated with any device authorisation request.');
+ else if(response.error === 'approval')
+ alert('The device authorisation request associated with this code is not pending approval.');
+ else
+ alert(`An unknown error occurred: ${response.error}`);
+
+ loading.visible = false;
+ fCode.classList.remove('hidden');
+ return;
+ }
+
+ userCode = response.device.code;
+
+ userHeader = new HanyuuOAuth2UserHeader(response.user);
+ header.setElement(userHeader);
+
+ if(response.app.trusted && response.user.guise === undefined) {
+ if(userHeader)
+ userHeader.guiseVisible = false;
+
+ verifyDeviceRequest(true).then(handleVerifyDeviceResponse);
+ return;
+ }
+
+ const appElem = new HanyuuOAuth2AppInfo(response.app);
+ eAuthsInfo.replaceWith(appElem.element);
+
+ const scopeElem = new HanyuuOAuth2AppScopeList();
+ eAuthsScope.replaceWith(scopeElem.element);
+
+ loading.visible = false;
+ fAuths.classList.remove('hidden');
+ });
+ };
+
+ const validateCodeInput = () => {
+ // [A-Za-z0-8]{3}\-[A-Za-z0-8]{3}\-[A-Za-z0-8]{3}
+ // 0 -> O, 1 -> I, 8 -> B
+
+ const eCode = eUserCode.value;
+
+ return true;
+ };
+
+ eUserCode.oninput = () => {
+ validateCodeInput();
+ console.warn(eUserCode.value);
+ };
+
+ if(queryParams.has('code') && eUserCode.value === '')
+ eUserCode.value = queryParams.get('code');
+
+ if(validateCodeInput()) {
+ fCode.requestSubmit();
+ } else {
+ loading.visible = false;
+ fCode.classList.remove('hidden');
+ }
+};
diff --git a/assets/oauth2.js/header/header.js b/assets/oauth2.js/header/header.js
new file mode 100644
index 0000000..4161293
--- /dev/null
+++ b/assets/oauth2.js/header/header.js
@@ -0,0 +1,55 @@
+const HanyuuOAuth2Header = function(element = '.js-oauth2-header', simpleElement = '.js-oauth2-header-simple') {
+ if(typeof element === 'string')
+ element = document.querySelector(element);
+ if(!(element instanceof HTMLElement))
+ throw 'element must be a valid query selector or an instance of HTMLElement';
+
+ if(typeof simpleElement === 'string')
+ simpleElement = element.querySelector(simpleElement);
+
+ const simpleElementIcon = simpleElement?.querySelector('.js-oauth2-header-simple-icon');
+ const simpleElementText = simpleElement?.querySelector('.js-oauth2-header-simple-text');
+
+ const hasSimpleElement = simpleElement instanceof HTMLElement;
+ const setSimpleVisible = state => {
+ if(hasSimpleElement)
+ simpleElement.classList.toggle('hidden', !state);
+ };
+ const setSimpleData = (name, text) => {
+ if(hasSimpleElement) {
+ simpleElement.className = `oauth2-${name}`;
+ simpleElementIcon.className = `oauth2-${name}-icon`;
+ simpleElementText.className = `oauth2-${name}-text`;
+ simpleElementText.textContent = text;
+ }
+ };
+
+ const removeElement = (forceSimple = true) => {
+ while(element.childElementCount > 1)
+ element.lastElementChild.remove();
+
+ if(typeof forceSimple === 'boolean')
+ setSimpleVisible(forceSimple);
+ };
+
+ return {
+ get element() { return element; },
+
+ get simpleVisible() { return hasSimpleElement && !simpleElement.classList.contains('hidden'); },
+ set simpleVisible(state) { setSimpleVisible(state); },
+
+ setSimpleData: setSimpleData,
+
+ setElement: elementInfo => {
+ removeElement(false);
+
+ if(elementInfo instanceof Element)
+ element.appendChild(elementInfo);
+ else if('element' in elementInfo)
+ element.appendChild(elementInfo.element);
+ else
+ throw 'elementInfo must be an instance of Element or contain an object with an element property';
+ },
+ removeElement: removeElement,
+ };
+};
diff --git a/assets/oauth2.js/header/user.jsx b/assets/oauth2.js/header/user.jsx
new file mode 100644
index 0000000..3dd06dc
--- /dev/null
+++ b/assets/oauth2.js/header/user.jsx
@@ -0,0 +1,53 @@
+const HanyuuOAuth2UserGuiseHeader = function(guise) {
+ const element =
+
+
+
+
+
+
+
Are you {guise.name} and did you mean to use your own account?
+
Click here and reload this page.
+
+
;
+
+ return {
+ get element() { return element; },
+
+ get visible() { return !element.classList.contains('hidden'); },
+ set visible(state) {
+ element.classList.toggle('hidden', !state);
+ },
+ };
+};
+
+const HanyuuOAuth2UserHeader = function(user) {
+ const element =
+
+
+
+
+
+
+
+
+
;
+
+ let guiseInfo;
+ if(user.guise) {
+ guiseInfo = new HanyuuOAuth2UserGuiseHeader(user.guise);
+ element.appendChild(guiseInfo.element);
+ }
+
+ return {
+ get element() { return element; },
+
+ get guiseVisible() { return guiseInfo?.visible === true; },
+ set guiseVisible(state) {
+ if(guiseInfo !== undefined)
+ guiseInfo.visible = state;
+ },
+ };
+};
diff --git a/assets/oauth2.js/loading.jsx b/assets/oauth2.js/loading.jsx
new file mode 100644
index 0000000..39093ce
--- /dev/null
+++ b/assets/oauth2.js/loading.jsx
@@ -0,0 +1,84 @@
+const HanyuuOAuth2LoadingIcon = function() {
+ const element =
;
+ for(let i = 0; i < 9; ++i)
+ element.appendChild(
);
+
+ // this is moderately cursed but it'll do
+ const blocks = [
+ element.children[3],
+ element.children[0],
+ element.children[1],
+ element.children[2],
+ element.children[5],
+ element.children[8],
+ element.children[7],
+ element.children[6],
+ ];
+
+ let tsLastUpdate;
+ let counter = 0;
+ let playing = false;
+
+ const update = tsCurrent => {
+ try {
+ if(tsLastUpdate !== undefined && (tsCurrent - tsLastUpdate) < 50)
+ return;
+ tsLastUpdate = tsCurrent;
+
+ for(let i = 0; i < blocks.length; ++i)
+ blocks[(counter + i) % blocks.length].classList.toggle('oauth2-loading-icon-block-hidden', i < 3);
+
+ ++counter;
+ } finally {
+ if(playing)
+ requestAnimationFrame(update);
+ }
+ };
+
+ const play = () => {
+ if(playing)
+ return;
+ playing = true;
+ requestAnimationFrame(update);
+ };
+ const pause = () => { playing = false; };
+ const stop = () => { pause(); counter = 0; };
+ const restart = () => { stop(); play(); };
+
+ return {
+ get element() { return element; },
+ get playing() { return playing; },
+
+ play: play,
+ pause: pause,
+ stop: stop,
+ restart: restart,
+ };
+};
+
+const HanyuuOAuth2Loading = function(element) {
+ if(typeof element === 'string')
+ element = document.querySelector(element);
+ if(!(element instanceof HTMLElement))
+ element =
;
+
+ if(!element.classList.contains('oauth2-loading'))
+ element.classList.add('oauth2-loading');
+
+ let icon;
+ if(element.childElementCount < 1) {
+ icon = new HanyuuOAuth2LoadingIcon;
+ icon.play();
+ element.appendChild({icon}
);
+ }
+
+ return {
+ get element() { return element; },
+
+ get hasIcon() { return icon !== undefined; },
+ get icon() { return icon; },
+
+ get visible() { return !element.classList.contains('hidden'); },
+ set visible(state) { element.classList.toggle('hidden', !state); },
+ };
+};
diff --git a/assets/oauth2.js/main.js b/assets/oauth2.js/main.js
index 503dc17..457b19c 100644
--- a/assets/oauth2.js/main.js
+++ b/assets/oauth2.js/main.js
@@ -1,3 +1,5 @@
+#include device/verify.js
+
(() => {
const authoriseButtons = document.querySelectorAll('.js-authorise-action');
@@ -29,4 +31,7 @@
xhr.send(body.join('&'));
};
}
+
+ if(location.pathname === '/oauth2/device/verify')
+ HanyuuOAuth2DeviceVerify();
})();
diff --git a/build.js b/build.js
index eb496a2..541ba09 100644
--- a/build.js
+++ b/build.js
@@ -17,6 +17,7 @@ const fs = require('fs');
const tasks = {
js: [
+ { source: 'hanyuu.js', target: '/assets', name: 'hanyuu.{hash}.js', },
{ source: 'oauth2.js', target: '/assets', name: 'oauth2.{hash}.js', },
],
css: [
diff --git a/database/2024_07_27_201438_nine_character_device_user_code.php b/database/2024_07_27_201438_nine_character_device_user_code.php
new file mode 100644
index 0000000..262372e
--- /dev/null
+++ b/database/2024_07_27_201438_nine_character_device_user_code.php
@@ -0,0 +1,9 @@
+execute('ALTER TABLE hau_oauth2_device CHANGE COLUMN dev_user_code dev_user_code CHAR(9) NOT NULL COLLATE ascii_general_ci AFTER dev_code');
+ }
+}
diff --git a/database/2024_07_30_001441_removed_device_attempts_field.php b/database/2024_07_30_001441_removed_device_attempts_field.php
new file mode 100644
index 0000000..a40cf6e
--- /dev/null
+++ b/database/2024_07_30_001441_removed_device_attempts_field.php
@@ -0,0 +1,9 @@
+execute('ALTER TABLE hau_oauth2_device DROP COLUMN dev_attempts');
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 6e8cefa..36dd238 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -80,14 +80,14 @@
}
},
"node_modules/@swc/core": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.6.13.tgz",
- "integrity": "sha512-eailUYex6fkfaQTev4Oa3mwn0/e3mQU4H8y1WPuImYQESOQDtVrowwUGDSc19evpBbHpKtwM+hw8nLlhIsF+Tw==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-HHAlbXjWI6Kl9JmmUW1LSygT1YbblXgj2UvvDzMkTBPRzYMhW6xchxdO8HbtMPtFYRt/EQq9u1z7j4ttRSrFsA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
- "@swc/types": "^0.1.9"
+ "@swc/types": "^0.1.12"
},
"engines": {
"node": ">=10"
@@ -97,16 +97,16 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
- "@swc/core-darwin-arm64": "1.6.13",
- "@swc/core-darwin-x64": "1.6.13",
- "@swc/core-linux-arm-gnueabihf": "1.6.13",
- "@swc/core-linux-arm64-gnu": "1.6.13",
- "@swc/core-linux-arm64-musl": "1.6.13",
- "@swc/core-linux-x64-gnu": "1.6.13",
- "@swc/core-linux-x64-musl": "1.6.13",
- "@swc/core-win32-arm64-msvc": "1.6.13",
- "@swc/core-win32-ia32-msvc": "1.6.13",
- "@swc/core-win32-x64-msvc": "1.6.13"
+ "@swc/core-darwin-arm64": "1.7.3",
+ "@swc/core-darwin-x64": "1.7.3",
+ "@swc/core-linux-arm-gnueabihf": "1.7.3",
+ "@swc/core-linux-arm64-gnu": "1.7.3",
+ "@swc/core-linux-arm64-musl": "1.7.3",
+ "@swc/core-linux-x64-gnu": "1.7.3",
+ "@swc/core-linux-x64-musl": "1.7.3",
+ "@swc/core-win32-arm64-msvc": "1.7.3",
+ "@swc/core-win32-ia32-msvc": "1.7.3",
+ "@swc/core-win32-x64-msvc": "1.7.3"
},
"peerDependencies": {
"@swc/helpers": "*"
@@ -118,9 +118,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.6.13.tgz",
- "integrity": "sha512-SOF4buAis72K22BGJ3N8y88mLNfxLNprTuJUpzikyMGrvkuBFNcxYtMhmomO0XHsgLDzOJ+hWzcgjRNzjMsUcQ==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.3.tgz",
+ "integrity": "sha512-CTkHa6MJdov9t41vuV2kmQIMu+Q19LrEHGIR/UiJYH06SC/sOu35ZZH8DyfLp9ZoaCn21gwgWd61ixOGQlwzTw==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.6.13.tgz",
- "integrity": "sha512-AW8akFSC+tmPE6YQQvK9S2A1B8pjnXEINg+gGgw0KRUUXunvu1/OEOeC5L2Co1wAwhD7bhnaefi06Qi9AiwOag==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.3.tgz",
+ "integrity": "sha512-mun623y6rCoZ2EFIYfIRqXYRFufJOopoYSJcxYhZUrfTpAvQ1zLngjQpWCUU1krggXR2U0PQj+ls0DfXUTraNg==",
"cpu": [
"x64"
],
@@ -150,9 +150,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.6.13.tgz",
- "integrity": "sha512-f4gxxvDXVUm2HLYXRd311mSrmbpQF2MZ4Ja6XCQz1hWAxXdhRl1gpnZ+LH/xIfGSwQChrtLLVrkxdYUCVuIjFg==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.3.tgz",
+ "integrity": "sha512-4Jz4UcIcvZNMp9qoHbBx35bo3rjt8hpYLPqnR4FFq6gkAsJIMFC56UhRZwdEQoDuYiOFMBnnrsg31Fyo6YQypA==",
"cpu": [
"arm"
],
@@ -166,9 +166,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.6.13.tgz",
- "integrity": "sha512-Nf/eoW2CbG8s+9JoLtjl9FByBXyQ5cjdBsA4efO7Zw4p+YSuXDgc8HRPC+E2+ns0praDpKNZtLvDtmF2lL+2Gg==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.3.tgz",
+ "integrity": "sha512-p+U/M/oqV7HC4erQ5TVWHhJU1984QD+wQBPxslAYq751bOQGm0R/mXK42GjugqjnR6yYrAiwKKbpq4iWVXNePA==",
"cpu": [
"arm64"
],
@@ -182,9 +182,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.6.13.tgz",
- "integrity": "sha512-2OysYSYtdw79prJYuKIiux/Gj0iaGEbpS2QZWCIY4X9sGoETJ5iMg+lY+YCrIxdkkNYd7OhIbXdYFyGs/w5LDg==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.3.tgz",
+ "integrity": "sha512-s6VzyaJwaRGTi2mz2h6Ywxfmgpkc69IxhuMzl+sl34plH0V0RgnZDm14HoCGIKIzRk4+a2EcBV1ZLAfWmPACQg==",
"cpu": [
"arm64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.6.13.tgz",
- "integrity": "sha512-PkR4CZYJNk5hcd2+tMWBpnisnmYsUzazI1O5X7VkIGFcGePTqJ/bWlfUIVVExWxvAI33PQFzLbzmN5scyIUyGQ==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.3.tgz",
+ "integrity": "sha512-IrFY48C356Z2dU2pjYg080yvMXzmSV3Lmm/Wna4cfcB1nkVLjWsuYwwRAk9CY7E19c+q8N1sMNggubAUDYoX2g==",
"cpu": [
"x64"
],
@@ -214,9 +214,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.6.13.tgz",
- "integrity": "sha512-OdsY7wryTxCKwGQcwW9jwWg3cxaHBkTTHi91+5nm7hFPpmZMz1HivJrWAMwVE7iXFw+M4l6ugB/wCvpYrUAAjA==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.3.tgz",
+ "integrity": "sha512-qoLgxBlBnnyUEDu5vmRQqX90h9jldU1JXI96e6eh2d1gJyKRA0oSK7xXmTzorv1fGHiHulv9qiJOUG+g6uzJWg==",
"cpu": [
"x64"
],
@@ -230,9 +230,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.6.13.tgz",
- "integrity": "sha512-ap6uNmYjwk9M/+bFEuWRNl3hq4VqgQ/Lk+ID/F5WGqczNr0L7vEf+pOsRAn0F6EV+o/nyb3ePt8rLhE/wjHpPg==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.3.tgz",
+ "integrity": "sha512-OAd7jVVJ7nb0Ev80VAa1aeK+FldPeC4eZ35H4Qn6EICzIz0iqJo2T33qLKkSZiZEBKSoF4KcwrqYfkjLOp5qWg==",
"cpu": [
"arm64"
],
@@ -246,9 +246,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.6.13.tgz",
- "integrity": "sha512-IJ8KH4yIUHTnS/U1jwQmtbfQals7zWPG0a9hbEfIr4zI0yKzjd83lmtS09lm2Q24QBWOCFGEEbuZxR4tIlvfzA==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.3.tgz",
+ "integrity": "sha512-31+Le1NyfSnILFV9+AhxfFOG0DK0272MNhbIlbcv4w/iqpjkhaOnNQnLsYJD1Ow7lTX1MtIZzTjOhRlzSviRWg==",
"cpu": [
"ia32"
],
@@ -262,9 +262,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
- "version": "1.6.13",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.6.13.tgz",
- "integrity": "sha512-f6/sx6LMuEnbuxtiSL/EkR0Y6qUHFw1XVrh6rwzKXptTipUdOY+nXpKoh+1UsBm/r7H0/5DtOdrn3q5ZHbFZjQ==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.3.tgz",
+ "integrity": "sha512-jVQPbYrwcuueI4QB0fHC29SVrkFOBcfIspYDlgSoHnEz6tmLMqUy+txZUypY/ZH/KaK0HEY74JkzgbRC1S6LFQ==",
"cpu": [
"x64"
],
@@ -284,9 +284,9 @@
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
- "version": "0.1.9",
- "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.9.tgz",
- "integrity": "sha512-qKnCno++jzcJ4lM4NTfYifm1EFSCeIfKiAHAfkENZAV5Kl9PjJIyd2yeeVv6c/2CckuLyv2NmRC5pv6pm2WQBg==",
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz",
+ "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
@@ -417,9 +417,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001642",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz",
- "integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==",
+ "version": "1.0.30001643",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz",
+ "integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==",
"funding": [
{
"type": "opencollective",
@@ -703,9 +703,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.4.829",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.829.tgz",
- "integrity": "sha512-5qp1N2POAfW0u1qGAxXEtz6P7bO1m6gpZr5hdf5ve6lxpLM7MpiM4jIPz7xcrNlClQMafbyUDDWjlIQZ1Mw0Rw==",
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.2.tgz",
+ "integrity": "sha512-kc4r3U3V3WLaaZqThjYz/Y6z8tJe+7K0bbjUVo3i+LWIypVdMx5nXCkwRe6SWbY6ILqLdc1rKcKmr3HoH7wjSQ==",
"license": "ISC"
},
"node_modules/entities": {
@@ -831,9 +831,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.17",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz",
- "integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==",
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
+ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"license": "MIT"
},
"node_modules/normalize-range": {
@@ -884,9 +884,9 @@
"license": "ISC"
},
"node_modules/postcss": {
- "version": "8.4.39",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
- "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
+ "version": "8.4.40",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
+ "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
"funding": [
{
"type": "opencollective",
diff --git a/public/images/circle-check-solid.svg b/public/images/circle-check-solid.svg
new file mode 100644
index 0000000..a561398
--- /dev/null
+++ b/public/images/circle-check-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/images/circle-xmark-solid.svg b/public/images/circle-xmark-solid.svg
new file mode 100644
index 0000000..083c430
--- /dev/null
+++ b/public/images/circle-xmark-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/images/mobile-screen-solid.svg b/public/images/mobile-screen-solid.svg
new file mode 100644
index 0000000..94f8033
--- /dev/null
+++ b/public/images/mobile-screen-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php
index 38f2659..f1b5252 100644
--- a/src/HanyuuContext.php
+++ b/src/HanyuuContext.php
@@ -89,7 +89,7 @@ class HanyuuContext {
'Misuzu',
(string)$request->getCookie('msz_auth'),
$_SERVER['REMOTE_ADDR'],
- [60]
+ [60, 120]
);
});
diff --git a/src/OAuth2/OAuth2DeviceInfo.php b/src/OAuth2/OAuth2DeviceInfo.php
index c26da84..081885c 100644
--- a/src/OAuth2/OAuth2DeviceInfo.php
+++ b/src/OAuth2/OAuth2DeviceInfo.php
@@ -13,7 +13,6 @@ class OAuth2DeviceInfo {
private ?string $userId,
private string $code,
private string $userCode,
- private int $attempts,
private int $interval,
private int $polled,
private string $scope,
@@ -29,13 +28,12 @@ class OAuth2DeviceInfo {
userId: $result->getStringOrNull(2),
code: $result->getString(3),
userCode: $result->getString(4),
- attempts: $result->getInteger(5),
- interval: $result->getInteger(6),
- polled: $result->getInteger(7),
- scope: $result->getString(8),
- approval: $result->getString(9),
- created: $result->getInteger(10),
- expires: $result->getInteger(11),
+ interval: $result->getInteger(5),
+ polled: $result->getInteger(6),
+ scope: $result->getString(7),
+ approval: $result->getString(8),
+ created: $result->getInteger(9),
+ expires: $result->getInteger(10),
);
}
@@ -61,7 +59,7 @@ class OAuth2DeviceInfo {
public function getUserCode(): string {
return $this->userCode;
}
- public function getUserCodeDashed(int $interval = 4): string {
+ public function getUserCodeDashed(int $interval = 3): string {
// how is this a stdlib function????? i mean thanks but ?????
// update: it was too good to be true, i need to trim anyway...
return trim(chunk_split($this->userCode, $interval, '-'), '-');
diff --git a/src/OAuth2/OAuth2DevicesData.php b/src/OAuth2/OAuth2DevicesData.php
index e70484d..6b97bb6 100644
--- a/src/OAuth2/OAuth2DevicesData.php
+++ b/src/OAuth2/OAuth2DevicesData.php
@@ -6,10 +6,11 @@ use RuntimeException;
use Index\XString;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
-use Index\Serialisation\Base32;
use Hanyuu\Apps\AppInfo;
class OAuth2DevicesData {
+ private const USER_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+
private IDbConnection $dbConn;
private DbStatementCache $cache;
@@ -49,14 +50,14 @@ class OAuth2DevicesData {
}
if($userCode !== null) {
$selectors[] = 'dev_user_code = ?';
- $values[] = $userCode;
+ $values[] = preg_replace('#[^A-Za-z2-7]#', '', strtr($userCode, ['0' => 'O', '1' => 'I', '8' => 'B']));
}
if(empty($selectors))
throw new RuntimeException('Insufficient data to do device authorisation request lookup.');
$args = 0;
- $stmt = $this->cache->get('SELECT dev_id, app_id, user_id, dev_code, dev_user_code, dev_attempts, dev_interval, UNIX_TIMESTAMP(dev_polled), dev_scope, dev_approval, UNIX_TIMESTAMP(dev_created), UNIX_TIMESTAMP(dev_expires) FROM hau_oauth2_device WHERE ' . implode(' AND ', $selectors));
+ $stmt = $this->cache->get('SELECT dev_id, app_id, user_id, dev_code, dev_user_code, dev_interval, UNIX_TIMESTAMP(dev_polled), dev_scope, dev_approval, UNIX_TIMESTAMP(dev_created), UNIX_TIMESTAMP(dev_expires) FROM hau_oauth2_device WHERE ' . implode(' AND ', $selectors));
foreach($values as $value)
$stmt->addParameter(++$args, $value);
$stmt->execute();
@@ -76,7 +77,7 @@ class OAuth2DevicesData {
?string $userCode = null
): OAuth2DeviceInfo {
$code ??= XString::random(60);
- $userCode ??= Base32::encode(random_bytes(5));
+ $userCode ??= XString::random(9, self::USER_CODE_CHARS);
$stmt = $this->cache->get('INSERT INTO hau_oauth2_device (app_id, user_id, dev_code, dev_user_code, dev_scope) VALUES (?, ?, ?, ?, ?)');
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
@@ -120,17 +121,11 @@ class OAuth2DevicesData {
$stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_approval = ?, user_id = COALESCE(user_id, ?) WHERE dev_id = ? AND dev_approval = "pending" AND user_id IS NULL');
$stmt->addParameter(1, $approval ? 'approved' : 'denied');
- $stmt->addParameter(2, $userId);
+ $stmt->addParameter(2, $approval ? $userId : null);
$stmt->addParameter(3, $deviceInfo);
$stmt->execute();
}
- public function decrementDeviceUserAttempts(OAuth2DeviceInfo|string $deviceInfo): void {
- $stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_attempts = MAX(0, dev_attempts - 1) WHERE dev_id = ?');
- $stmt->addParameter(1, $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo);
- $stmt->execute();
- }
-
public function bumpDevicePollTime(OAuth2DeviceInfo|string $deviceInfo): void {
$stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_polled = NOW() WHERE dev_id = ?');
$stmt->addParameter(1, $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo);
diff --git a/src/OAuth2/OAuth2ErrorRoutes.php b/src/OAuth2/OAuth2ErrorRoutes.php
new file mode 100644
index 0000000..e69de29
diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php
index 6dd8a76..1a8cf46 100644
--- a/src/OAuth2/OAuth2Routes.php
+++ b/src/OAuth2/OAuth2Routes.php
@@ -23,6 +23,16 @@ final class OAuth2Routes extends RouteHandler {
throw new InvalidArgumentException('$getAuthInfo must be callable');
}
+ private static function error(string $code, string $message = '', string $url = ''): array {
+ $info = ['error' => $code];
+ if($message !== '')
+ $info['error_description'] = $message;
+ if($url !== '')
+ $info['error_uri'] = $url;
+
+ return $info;
+ }
+
private static function buildCallbackUri(string $target, array $params): string {
if($target === '')
$target = '/oauth2/error';
@@ -368,28 +378,121 @@ final class OAuth2Routes extends RouteHandler {
];
}
- private static function error(string $code, string $message = '', string $url = ''): array {
- $info = ['error' => $code];
- if($message !== '')
- $info['error_description'] = $message;
- if($url !== '')
- $info['error_uri'] = $url;
-
- return $info;
- }
-
#[HttpGet('/oauth2/device/verify')]
- public function getDeviceVerify() {
- return 'TODO: make this page';
+ public function getDeviceVerify($response, $request) {
+ $authInfo = ($this->getAuthInfo)();
+ if(!isset($authInfo->user))
+ return $this->templating->render('oauth2/login', [
+ 'auth' => $authInfo,
+ ]);
+
+ $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
+
+ return $this->templating->render('oauth2/device/verify', [
+ 'csrfp_token' => $csrfp->createToken(),
+ ]);
}
#[HttpPost('/oauth2/device/verify')]
- public function postDeviceVerify() {
+ public function postDeviceVerify($response, $request) {
+ if(!$request->isFormContent())
+ return 400;
+
+ // TODO: RATE LIMITING
+
+ $authInfo = ($this->getAuthInfo)();
+ if(!isset($authInfo->user))
+ return ['error' => 'auth'];
+
+ $content = $request->getContent();
+
+ $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
+ if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
+ return ['error' => 'csrf'];
+
+ $devicesData = $this->oauth2Ctx->getDevicesData();
+ try {
+ $deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$content->getParam('code'));
+ } catch(RuntimeException $ex) {
+ return ['error' => 'code'];
+ }
+
+ if(!$deviceInfo->isPending())
+ return ['error' => 'approval'];
+
+ $approve = (string)$content->getParam('approve');
+ if(!in_array($approve, ['yes', 'no']))
+ return ['error' => 'invalid'];
+
+ $approved = $approve === 'yes';
+ $devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->user->id);
+
return [
- 'TODO' => 'make this endpoint',
+ 'approval' => $approved ? 'approved' : 'denied',
];
}
+ #[HttpGet('/oauth2/device/resolve')]
+ public function getDeviceResolve($response, $request) {
+ // TODO: RATE LIMITING
+
+ $authInfo = ($this->getAuthInfo)();
+ if(!isset($authInfo->user))
+ return ['error' => 'auth'];
+
+ $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
+ if(!$csrfp->verifyToken((string)$request->getParam('csrfp')))
+ return ['error' => 'csrf'];
+
+ $devicesData = $this->oauth2Ctx->getDevicesData();
+ try {
+ $deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$request->getParam('code'));
+ } catch(RuntimeException $ex) {
+ return ['error' => 'code'];
+ }
+
+ if(!$deviceInfo->isPending())
+ return ['error' => 'approval'];
+
+ $appsData = $this->appsCtx->getData();
+ try {
+ $appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false);
+ } catch(RuntimeException $ex) {
+ return ['error' => 'code'];
+ }
+
+ $result = [
+ 'device' => [
+ 'code' => $deviceInfo->getUserCode(),
+ ],
+ 'app' => [
+ 'name' => $appInfo->getName(),
+ 'summary' => $appInfo->getSummary(),
+ 'trusted' => $appInfo->isTrusted(),
+ 'links' => [
+ ['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()],
+ ],
+ ],
+ 'user' => [
+ 'name' => $authInfo->user->name,
+ 'colour' => $authInfo->user->colour,
+ 'profile_uri' => $authInfo->user->profile_url,
+ 'avatar_uri' => $authInfo->user->avatars->x120,
+ ],
+ ];
+
+ if(isset($authInfo->guise))
+ $result['user']['guise'] = [
+ 'name' => $authInfo->guise->name,
+ 'colour' => $authInfo->guise->colour,
+ 'profile_uri' => $authInfo->guise->profile_url,
+ 'revert_uri' => $authInfo->guise->revert_url,
+ 'avatar_uri' => $authInfo->guise->avatars->x60,
+ ];
+
+ return $result;
+ }
+
#[HttpPost('/oauth2/device/authorise')]
public function postDeviceAuthorise($response, $request) {
$response->setHeader('Cache-Control', 'no-store');
diff --git a/templates/oauth2/authorise.twig b/templates/oauth2/authorise.twig
index 9120276..abadc5d 100644
--- a/templates/oauth2/authorise.twig
+++ b/templates/oauth2/authorise.twig
@@ -1,12 +1,14 @@
{% extends 'oauth2/master.twig' %}
-{% block body %}
+{% set body_title = 'Authorisation Request' %}
+
+{% block body_header %}