Merged OAuth2 handling into Misuzu.

This commit is contained in:
flash 2025-02-02 02:09:56 +00:00
parent 1994a9892d
commit 534e947522
115 changed files with 4556 additions and 77 deletions

View file

@ -106,24 +106,31 @@ const $e = function(info, attrs, child, created) {
for(const child of children) {
switch(typeof child) {
case 'string':
elem.appendChild($t(child));
elem.appendChild(document.createTextNode(child));
break;
case 'object':
if(child instanceof Element)
if(child instanceof Element) {
elem.appendChild(child);
else if(child.getElement) {
} 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
} else {
elem.appendChild($e(child));
}
break;
default:
elem.appendChild($t(child.toString()));
elem.appendChild(document.createTextNode(child.toString()));
break;
}
}

View file

@ -0,0 +1,43 @@
.oauth2-appinfo {}
.oauth2-appinfo-name {
font-size: 2em;
line-height: 1.5em;
}
.oauth2-appinfo-links {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.oauth2-appinfo-link {
display: flex;
color: inherit;
gap: 5px;
align-items: center;
background: #333;
padding: 2px 6px;
border-radius: 4px;
}
.oauth2-appinfo-link-icon {
flex: 0 0 auto;
background-color: #fff;
width: 12px;
height: 12px;
}
.oauth2-appinfo-link-icon-globe {
mask: url('/images/globe-solid.svg') no-repeat center;
}
.oauth2-appinfo-link-text {
font-size: .8em;
line-height: 1.4em;
}
.oauth2-appinfo-summary {
font-size: .9em;
line-height: 1.4em;
}
.oauth2-appinfo-summary p {
margin: .5em 0;
}

View file

@ -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;
}

View file

@ -0,0 +1,72 @@
.oauth2-authorise-requesting {
font-size: .8em;
line-height: 1.4em;
border-bottom: 1px solid #333;
}
.oauth2-authorise-requesting p {
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;
justify-content: center;
gap: 10px;
}
.oauth2-authorise-button {
background-color: #191919;
font-family: var(--font-regular);
font-size: 1.2em;
line-height: 1.4em;
padding: 5px 10px;
min-width: 140px;
text-align: center;
cursor: pointer;
transition: color .2s, background-color .2s, opacity .2s;
border: 1px solid;
border-radius: 2px;
display: inline-flex;
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] {
opacity: .5;
}
.oauth2-authorise-button-accept {
color: #080;
border-color: #0a0;
}
.oauth2-authorise-button-accept:hover,
.oauth2-authorise-button-accept:focus {
color: #191919;
background-color: #0a0;
}
.oauth2-authorise-button-deny {
color: #c00;
border-color: #a00;
}
.oauth2-authorise-button-deny:hover,
.oauth2-authorise-button-deny:focus {
color: #191919;
background-color: #a00;
}

View file

@ -0,0 +1,21 @@
.oauth2-banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.oauth2-banner-text {
font-size: .9em;
line-height: 1.4em;
flex: 1 1 auto;
}
.oauth2-banner-logo {
flex: 0 0 auto;
background-color: #fff;
mask: url('/images/flashii.svg') no-repeat center;
width: 30px;
height: 30px;
font-size: 0;
}

View file

@ -0,0 +1,24 @@
.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;
}

View file

@ -0,0 +1,3 @@
.oauth2-errorbody p {
margin: .5em 1em;
}

View file

@ -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;
}

View file

@ -0,0 +1,97 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
position: relative;
}
html, body {
width: 100%;
height: 100%;
}
[hidden],
.hidden {
display: none !important;
}
:root {
--font-regular: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
--font-monospace: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
}
body {
background-color: #111;
color: #fff;
font-size: 16px;
line-height: 25px;
font-family: var(--font-regular);
overflow-y: scroll;
position: static;
display: flex;
flex-direction: column;
}
pre, code {
font-family: var(--font-monospace);
}
a {
color: #1e90ff;
text-decoration: none;
}
a:visited {
color: #6B4F80;
}
a:hover,
a:focus {
text-decoration: underline;
}
.oauth2-wrapper {
display: flex;
flex-direction: column;
flex: 1 0 auto;
margin-bottom: 10px;
}
.oauth2-dialog {
display: flex;
flex: 1 0 auto;
padding: 10px;
width: 100%;
align-items: center;
justify-content: center;
}
.oauth2-dialog-body {
max-width: 500px;
width: 100%;
background: #191919;
box-shadow: 0 1px 2px #0009;
display: flex;
flex-direction: column;
}
.oauth2-header {
background-image: url('/images/clouds.png');
background-blend-mode: multiply;
background-color: #8559a5;
width: 100%;
min-height: 4px;
}
.oauth2-body {
margin: 10px;
}
@include loading.css;
@include banner.css;
@include error.css;
@include device.css;
@include simplehead.css;
@include userhead.css;
@include appinfo.css;
@include scope.css;
@include authorise.css;
@include approval.css;

View file

@ -0,0 +1,38 @@
.oauth2-scope {
background: #292929;
border-radius: 4px;
padding: 4px 8px;
}
.oauth2-scope-header {
border-bottom: 1px solid #494949;
}
.oauth2-scope-perms {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0;
}
.oauth2-scope-perm {
display: flex;
align-items: center;
gap: 4px;
}
.oauth2-scope-perm-icon {
width: 16px;
height: 16px;
mask: url('/images/circle-check-regular.svg') no-repeat center;
flex: 0 0 auto;
background-color: #0a0;
margin: 2px;
}
.oauth2-scope-perm-icon-warn {
mask: url('/images/circle-regular.svg') no-repeat center, url('/images/exclamation-solid.svg') no-repeat center center / 10px 10px;
background-color: #c80;
}
.oauth2-scope-perm-text {
font-size: .8em;
line-height: 1.4em;
}

View file

@ -0,0 +1,30 @@
.oauth2-simplehead {
display: flex;
align-items: center;
}
.oauth2-simplehead-icon {
flex: 0 0 auto;
background-color: #fff;
mask: url('/images/circle-question-solid.svg') no-repeat center;
width: 40px;
height: 40px;
margin: 10px;
}
.oauth2-simplehead-icon--code {
mask-image: url('/images/mobile-screen-solid.svg');
}
.oauth2-simplehead-icon--error {
mask-image: url('/images/circle-exclamation-solid.svg');
}
.oauth2-simplehead-icon--login {
mask-image: url('/images/user-lock-solid.svg');
}
.oauth2-simplehead-icon--wait {
mask-image: url('/images/ellipsis-solid.svg');
}
.oauth2-simplehead-text {
font-size: 1.8em;
line-height: 1.4em;
}

View file

@ -0,0 +1,64 @@
.oauth2-userhead {
display: flex;
flex-direction: column;
background-color: #0005;
}
.oauth2-userhead-main {
display: flex;
align-items: center;
}
.oauth2-userhead-main-avatar {
flex: 0 0 auto;
margin: 10px;
}
.oauth2-userhead-main-avatar-image {
width: 60px;
height: 60px;
overflow: hidden;
border-radius: 4px;
}
.oauth2-userhead-main-avatar-image img {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
.oauth2-userhead-main-name {
font-size: 1.8em;
line-height: 1.4em;
}
.oauth2-userhead-main-name a {
color: inherit;
}
.oauth2-userhead-guise {
display: flex;
align-items: center;
background-image: repeating-linear-gradient(-45deg, #8559a57f, #8559a57f 10px, #1111117f 10px, #1111117f 20px);
}
.oauth2-userhead-guise-avatar {
flex: 0 0 auto;
margin: 10px;
}
.oauth2-userhead-guise-avatar-image {
width: 30px;
height: 30px;
overflow: hidden;
border-radius: 4px;
}
.oauth2-userhead-guise-avatar-image img {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
.oauth2-userhead-guise-text {
font-size: .8em;
line-height: 1.5em;
overflow: hidden;
padding: 2px 0;
}
.oauth2-userhead-guise-text p {
margin: 1px 0;
}

View file

@ -0,0 +1,30 @@
const MszOAuth2AppInfoLink = function(info) {
const element = <a href={info.uri} target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title={info.title}>
<div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div>
<div class="oauth2-appinfo-link-text">{info.display}</div>
</a>;
return {
get element() { return element; },
};
};
const MszOAuth2AppInfo = function(info) {
const linksElem = <div class="oauth2-appinfo-links"/>;
if(Array.isArray(info.links))
for(const link of info.links)
linksElem.appendChild((new MszOAuth2AppInfoLink(link)).element);
// TODO: author should be listed
const element = <div class="oauth2-appinfo">
<div class="oauth2-appinfo-name">{info.name}</div>
{linksElem}
<div class="oauth2-appinfo-summary">
<p>{info.summary}</p>
</div>
</div>;
return {
get element() { return element; },
};
};

View file

@ -0,0 +1,33 @@
const MszOAuth2AppScopeEntry = function(text, warn) {
const icon = <div class="oauth2-scope-perm-icon"/>;
if(warn)
icon.classList.add('oauth2-scope-perm-icon-warn');
const element = <div class="oauth2-scope-perm">
{icon}
<div class="oauth2-scope-perm-text">{text}</div>
</div>;
return {
get element() { return element; },
};
};
const MszOAuth2AppScopeList = function(scopes) {
const permsElem = <div class="oauth2-scope-perms"/>;
if(Array.isArray(scopes) && scopes.length > 0) {
for(const scope of scopes)
if(typeof scope === 'string')
permsElem.appendChild(new MszOAuth2AppScopeEntry(scope).element);
} else
permsElem.appendChild(new MszOAuth2AppScopeEntry('A limited amount of things. No scope was specified by the developer.', true).element);
const element = <div class="oauth2-scope">
<div class="oauth2-scope-header">This application will be able to:</div>
{permsElem}
</div>;
return {
get element() { return element; },
};
};

View file

@ -0,0 +1,232 @@
#include loading.jsx
#include xhr.js
#include app/info.jsx
#include app/scope.jsx
#include header/header.js
#include header/user.jsx
const MszOAuth2AuthoriseErrors = Object.freeze({
'invalid_request': {
description: 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
},
'unauthorized_client': {
description: 'The client is not authorised to request an authorisation code using this method.',
},
'access_denied': {
description: 'The resource owner or authorization server denied the request.',
},
'unsupported_response_type': {
description: 'The authorisation server does not support obtaining an authorisation code using this method.',
},
'invalid_scope': {
description: 'The requested scope is invalid, unknown, or malformed.',
},
'server_error': {
description: 'The authorisation server encountered an unexpected condition that prevented it from fulfilling the request.',
},
'temporarily_unavailable': {
description: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.',
},
});
const MszOAuth2Authorise = async () => {
const queryParams = new URLSearchParams(window.location.search);
const loading = new MszOAuth2Loading('.js-loading');
const header = new MszOAuth2Header;
const fAuths = document.querySelector('.js-authorise-form');
const eAuthsInfo = document.querySelector('.js-authorise-form-info');
const eAuthsScope = document.querySelector('.js-authorise-form-scope');
const dError = document.querySelector('.js-authorise-error');
const dErrorText = dError?.querySelector('.js-authorise-error-text');
let scope;
let state;
let redirectUri;
let redirectUriRaw;
const displayError = (error, description, documentation) => {
if(redirectUri === undefined) {
if(error in MszOAuth2AuthoriseErrors) {
const errInfo = MszOAuth2AuthoriseErrors[error];
description ??= errInfo.description;
} else
description = `An unknown error occurred: ${error}`;
dErrorText.textContent = description;
header.setSimpleData('error', 'An error occurred!');
header.removeElement();
loading.visible = false;
fAuths.classList.add('hidden');
dError.classList.remove('hidden');
return;
}
const errorUri = new URL(redirectUri);
errorUri.searchParams.set('error', error?.toString() ?? 'invalid_request');
if(description)
errorUri.searchParams.set('error_description', description.toString());
if(documentation)
errorUri.searchParams.set('error_uri', documentation.toString());
if(state !== undefined)
errorUri.searchParams.set('state', state.toString());
window.location.assign(errorUri);
};
const translateError = (serverError, detail) => {
if(serverError === 'auth')
return displayError('access_denied');
if(serverError === 'csrf')
return displayError('invalid_request', 'Request verification failed.');
if(serverError === 'client')
return displayError('invalid_request', 'There is no application associated with the specified Client ID.');
if(serverError === 'format')
return displayError('invalid_request', 'Redirect URI specified is not registered with this application.');
if(serverError === 'method')
return displayError('invalid_request', 'Requested code challenge method is not supported.');
if(serverError === 'length')
return displayError('invalid_request', 'Code challenge length is not acceptable.');
if(serverError === 'required')
return displayError('invalid_request', 'A registered redirect URI must be specified.');
if(serverError === 'scope')
return displayError('invalid_scope', detail === undefined ? undefined : `Requested scope "${detail.scope}" is ${detail.reason}.`);
if(serverError === 'authorise')
return displayError('server_error', 'Server was unable to complete authorisation.');
return displayError('invalid_request', `An unknown error occurred: ${serverError}.`);
};
if(queryParams.has('redirect_uri'))
try {
const qRedirectUriRaw = queryParams.get('redirect_uri');
const qRedirectUri = new URL(qRedirectUriRaw);
if(qRedirectUri.protocol !== 'https:')
throw 'protocol must be https';
redirectUri = qRedirectUri;
redirectUriRaw = qRedirectUriRaw;
} catch(ex) {
return displayError('invalid_request', 'Invalid redirect URI specified.');
}
if(queryParams.has('state')) {
const qState = queryParams.get('state');
if(qState.length > 1000)
return displayError('invalid_request', 'State parameter may not be longer than 255 characters.');
state = qState;
}
if(queryParams.get('response_type') !== 'code')
return displayError('unsupported_response_type');
let codeChallengeMethod = 'plain';
if(queryParams.has('code_challenge_method')) {
codeChallengeMethod = queryParams.get('code_challenge_method');
if(!['plain', 'S256'].includes(codeChallengeMethod))
return translateError('method');
}
if(!queryParams.has('code_challenge'))
return displayError('invalid_request', 'code_challenge must be specified.');
const codeChallenge = queryParams.get('code_challenge');
if(codeChallengeMethod === 'S256') {
if(codeChallenge.length !== 43)
return displayError('invalid_request', 'Specified code challenge is not a valid SHA-256 hash.');
} else {
if(codeChallenge.length < 43)
return displayError('invalid_request', 'Code challenge must be at least 43 characters long.');
if(codeChallenge.length > 128)
return displayError('invalid_request', 'Code challenge may not be longer than 128 characters.');
}
if(!queryParams.has('client_id'))
return displayError('invalid_request', 'client_id must be specified.');
const resolveParams = new URLSearchParams;
resolveParams.set('client', queryParams.get('client_id'));
if(redirectUriRaw !== undefined)
resolveParams.set('redirect', redirectUriRaw);
if(queryParams.has('scope')) {
scope = queryParams.get('scope');
resolveParams.set('scope', scope);
}
try {
const { body } = await $x.get(`/oauth2/resolve-authorise-app?${resolveParams}`, { authed: true, csrf: true, type: 'json' });
if(!body)
throw 'authorisation resolve failed';
if(typeof body.error === 'string')
return translateError(body.error, body);
const userHeader = new MszOAuth2UserHeader(body.user);
header.setElement(userHeader);
const verifyAuthsRequest = async () => {
const params = {
client: queryParams.get('client_id'),
cc: codeChallenge,
ccm: codeChallengeMethod,
};
if(redirectUriRaw !== undefined)
params.redirect = redirectUriRaw;
if(scope !== undefined)
params.scope = scope;
try {
const { body } = await $x.post('/oauth2/authorise', { authed: true, csrf: true, type: 'json' }, params);
if(!body)
throw 'authorisation failed';
if(typeof body.error === 'string')
return translateError(body.error, body);
const authoriseUri = new URL(body.redirect);
authoriseUri.searchParams.set('code', body.code);
if(state !== undefined)
authoriseUri.searchParams.set('state', state.toString());
window.location.assign(authoriseUri);
} catch(ex) {
console.error(ex);
translateError('authorise');
}
};
if(body.app.trusted && body.user.guise === undefined) {
if(userHeader)
userHeader.guiseVisible = false;
verifyAuthsRequest();
return;
}
eAuthsInfo.replaceWith(new MszOAuth2AppInfo(body.app).element);
eAuthsScope.replaceWith(new MszOAuth2AppScopeList(body.scope).element);
fAuths.onsubmit = ev => {
ev.preventDefault();
loading.visible = true;
fAuths.classList.add('hidden');
if(userHeader)
userHeader.guiseVisible = false;
if(ev.submitter?.value === 'yes')
verifyAuthsRequest();
else
displayError('access_denied');
};
loading.visible = false;
fAuths.classList.remove('hidden');
} catch(ex) {
console.error(ex);
displayError('server_error', 'Server was unable to respond to the client info request.');
}
};

24
assets/oauth2.js/csrf.js Normal file
View file

@ -0,0 +1,24 @@
#include utility.js
const MszCSRF = (() => {
let elem;
const getElement = () => {
if(elem === undefined)
elem = $q('meta[name="csrf-token"]');
return elem;
};
return {
get token() {
return getElement()?.content ?? '';
},
set token(token) {
if(typeof token !== 'string')
throw 'token must be a string';
const elem = getElement();
if(elem instanceof HTMLMetaElement)
elem.content = token;
},
};
})();

View file

@ -0,0 +1,57 @@
const MszOAuth2Header = 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 = (icon, text) => {
if(hasSimpleElement) {
for(const className of simpleElementIcon.classList)
if(className.startsWith('oauth2-simplehead-icon--'))
simpleElementIcon.classList.remove(className);
simpleElementIcon.classList.add(`oauth2-simplehead-icon--${icon}`);
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,
};
};

View file

@ -0,0 +1,53 @@
const MszOAuth2UserGuiseHeader = function(guise) {
const element = <div class="oauth2-userhead-guise">
<div class="oauth2-userhead-guise-avatar">
<div class="oauth2-userhead-guise-avatar-image">
<img src={guise.avatar_uri} alt=""/>
</div>
</div>
<div class="oauth2-userhead-guise-text">
<p>Are you <a href={guise.profile_uri} target="_blank" style={`color: ${guise.colour}`}>{guise.name}</a> and did you mean to use your own account?</p>
<p><a href={guise.revert_uri} target="_blank">Click here</a> and reload this page.</p>
</div>
</div>;
return {
get element() { return element; },
get visible() { return !element.classList.contains('hidden'); },
set visible(state) {
element.classList.toggle('hidden', !state);
},
};
};
const MszOAuth2UserHeader = function(user) {
const element = <div class="oauth2-userhead">
<div class="oauth2-userhead-main">
<div class="oauth2-userhead-main-avatar">
<div class="oauth2-userhead-main-avatar-image">
<img src={user.avatar_uri} alt=""/>
</div>
</div>
<div class="oauth2-userhead-main-name">
<a href={user.profile_uri} target="_blank">{user.name}</a>
</div>
</div>
</div>;
let guiseInfo;
if(user.guise) {
guiseInfo = new MszOAuth2UserGuiseHeader(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;
},
};
};

View file

@ -0,0 +1,84 @@
const MszOAuth2LoadingIcon = function() {
const element = <div class="oauth2-loading-icon"/>;
for(let i = 0; i < 9; ++i)
element.appendChild(<div class="oauth2-loading-icon-block"/>);
// 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 MszOAuth2Loading = function(element) {
if(typeof element === 'string')
element = document.querySelector(element);
if(!(element instanceof HTMLElement))
element = <div class="oauth2-loading"/>;
if(!element.classList.contains('oauth2-loading'))
element.classList.add('oauth2-loading');
let icon;
if(element.childElementCount < 1) {
icon = new MszOAuth2LoadingIcon;
icon.play();
element.appendChild(<div class="oauth2-loading-frame">{icon}</div>);
}
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); },
};
};

10
assets/oauth2.js/main.js Normal file
View file

@ -0,0 +1,10 @@
#include utility.js
#include authorise.js
#include verify.js
(() => {
if(location.pathname === '/oauth2/authorise' || location.pathname === '/oauth2/authorize')
MszOAuth2Authorise();
if(location.pathname === '/oauth2/verify')
MszOAuth2Verify();
})();

170
assets/oauth2.js/utility.js Normal file
View file

@ -0,0 +1,170 @@
const $i = document.getElementById.bind(document);
const $c = document.getElementsByClassName.bind(document);
const $q = document.querySelector.bind(document);
const $qa = document.querySelectorAll.bind(document);
const $t = document.createTextNode.bind(document);
const $r = function(element) {
if(element && element.parentNode)
element.parentNode.removeChild(element);
};
const $ri = function(name) {
$r($i(name));
};
const $rq = function(query) {
$r($q(query));
};
const $ib = function(ref, elem) {
ref.parentNode.insertBefore(elem, ref);
};
const $rc = function(element) {
while(element.lastChild)
element.removeChild(element.lastChild);
};
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 });
const $ar = function(array, index) {
array.splice(index, 1);
};
const $ari = function(array, item) {
let index;
while(array.length > 0 && (index = array.indexOf(item)) >= 0)
$ar(array, index);
};
const $arf = function(array, predicate) {
let index;
while(array.length > 0 && (index = array.findIndex(predicate)) >= 0)
$ar(array, index);
};
const $as = function(array) {
if(array.length < 2)
return;
for(let i = array.length - 1; i > 0; --i) {
let j = Math.floor(Math.random() * (i + 1)),
tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
};

178
assets/oauth2.js/verify.js Normal file
View file

@ -0,0 +1,178 @@
#include loading.jsx
#include xhr.js
#include app/info.jsx
#include app/scope.jsx
#include header/header.js
#include header/user.jsx
const MszOAuth2Verify = () => {
const queryParams = new URLSearchParams(window.location.search);
const loading = new MszOAuth2Loading('.js-loading');
const header = new MszOAuth2Header;
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 verifyAuthsRequest = async approve => {
try {
const { body } = await $x.post('/oauth2/verify', { authed: true, csrf: true, type: 'json' }, {
code: userCode,
approve: approve === true ? 'yes' : 'no',
});
if(!body)
throw 'response is empty';
if(typeof body.error === 'string') {
// TODO: nicer errors
if(body.error === 'auth')
alert('You are not logged in.');
else if(body.error === 'csrf')
alert('Request verification failed, please refresh and try again.');
else if(body.error === 'code')
alert('This code is not associated with any authorisation request.');
else if(body.error === 'approval')
alert('The authorisation request associated with this code is not pending approval.');
else if(body.error === 'expired')
alert('The authorisation request has expired, please restart the process from the application or device.');
else if(body.error === 'invalid')
alert('Invalid approval state specified.');
else if(body.error === 'scope') {
alert(`Requested scope "${body.scope}" is ${body.reason}.`);
loading.visible = false;
rDenied.classList.remove('hidden');
return;
} else
alert(`An unknown error occurred: ${body.error}`);
loading.visible = false;
fAuths.classList.remove('hidden');
return;
}
loading.visible = false;
if(body.approval === 'approved')
rApproved.classList.remove('hidden');
else
rDenied.classList.remove('hidden');
} catch(ex) {
alert('Request to verify endpoint failed. Please try again.');
loading.visible = false;
fAuths.classList.remove('hidden');
}
};
fAuths.onsubmit = ev => {
ev.preventDefault();
loading.visible = true;
fAuths.classList.add('hidden');
if(userHeader)
userHeader.guiseVisible = false;
verifyAuthsRequest(ev.submitter.value === 'yes');
};
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');
userCode = encodeURIComponent(eUserCode.value);
$x.get(`/oauth2/resolve-verify?code=${userCode}`, { authed: true, csrf: true, type: 'json' })
.then(result => {
const body = result.body;
if(!body) {
alert('Request to resolve endpoint failed. Please try again.');
loading.visible = false;
fCode.classList.remove('hidden');
return;
}
if(typeof body.error === 'string') {
// TODO: nicer errors
if(body.error === 'auth')
alert('You are not logged in.');
else if(body.error === 'csrf')
alert('Request verification failed, please refresh and try again.');
else if(body.error === 'code')
alert('This code is not associated with any authorisation request.');
else if(body.error === 'expired')
alert('The authorisation request has expired, please restart the process from the application or device.');
else if(body.error === 'approval')
alert('The authorisation request associated with this code is not pending approval.');
else if(body.error === 'scope') {
verifyAuthsRequest(false).finally(() => {
alert(`Requested scope "${body.scope}" is ${body.reason}.`);
});
return;
} else
alert(`An unknown error occurred: ${body.error}`);
loading.visible = false;
fCode.classList.remove('hidden');
return;
}
userCode = body.req.code;
userHeader = new MszOAuth2UserHeader(body.user);
header.setElement(userHeader);
if(body.app.trusted && body.user.guise === undefined) {
if(userHeader)
userHeader.guiseVisible = false;
verifyAuthsRequest(true);
return;
}
eAuthsInfo.replaceWith(new MszOAuth2AppInfo(body.app).element);
eAuthsScope.replaceWith(new MszOAuth2AppScopeList(body.scope).element);
loading.visible = false;
fAuths.classList.remove('hidden');
}).catch(() => {
alert('Request to resolve endpoint failed. Please try again.');
loading.visible = false;
fCode.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 eCode.length > 0;
};
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');
}
};

117
assets/oauth2.js/xhr.js Normal file
View file

@ -0,0 +1,117 @@
#include csrf.js
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';
Object.freeze(options);
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(options.csrf)
requestHeaders.set('x-csrf-token', MszCSRF.token);
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) => {
xhr.onload = ev => {
const headers = (headersString => {
const headers = new Map;
const raw = headersString.trim().split(/[\r\n]+/);
for(const name in raw)
if(raw.hasOwnProperty(name)) {
const parts = raw[name].split(': ');
headers.set(parts.shift(), parts.join(': '));
}
return headers;
})(xhr.getAllResponseHeaders());
if(options.csrf && headers.has('x-csrf-token'))
MszCSRF.token = headers.get('x-csrf-token');
resolve({
get ev() { return ev; },
get xhr() { return xhr; },
get status() { return xhr.status; },
get headers() { return headers; },
get body() { return xhr.response; },
get text() { return xhr.responseText; },
});
};
xhr.onerror = ev => reject({
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),
};
})();