Merged OAuth2 handling into Misuzu.
This commit is contained in:
parent
1994a9892d
commit
534e947522
115 changed files with 4556 additions and 77 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
43
assets/oauth2.css/appinfo.css
Normal file
43
assets/oauth2.css/appinfo.css
Normal 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;
|
||||
}
|
35
assets/oauth2.css/approval.css
Normal file
35
assets/oauth2.css/approval.css
Normal 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;
|
||||
}
|
72
assets/oauth2.css/authorise.css
Normal file
72
assets/oauth2.css/authorise.css
Normal 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;
|
||||
}
|
21
assets/oauth2.css/banner.css
Normal file
21
assets/oauth2.css/banner.css
Normal 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;
|
||||
}
|
||||
|
24
assets/oauth2.css/device.css
Normal file
24
assets/oauth2.css/device.css
Normal 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;
|
||||
}
|
3
assets/oauth2.css/error.css
Normal file
3
assets/oauth2.css/error.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.oauth2-errorbody p {
|
||||
margin: .5em 1em;
|
||||
}
|
36
assets/oauth2.css/loading.css
Normal file
36
assets/oauth2.css/loading.css
Normal 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;
|
||||
}
|
97
assets/oauth2.css/main.css
Normal file
97
assets/oauth2.css/main.css
Normal 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;
|
38
assets/oauth2.css/scope.css
Normal file
38
assets/oauth2.css/scope.css
Normal 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;
|
||||
}
|
30
assets/oauth2.css/simplehead.css
Normal file
30
assets/oauth2.css/simplehead.css
Normal 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;
|
||||
}
|
64
assets/oauth2.css/userhead.css
Normal file
64
assets/oauth2.css/userhead.css
Normal 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;
|
||||
}
|
30
assets/oauth2.js/app/info.jsx
Normal file
30
assets/oauth2.js/app/info.jsx
Normal 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; },
|
||||
};
|
||||
};
|
33
assets/oauth2.js/app/scope.jsx
Normal file
33
assets/oauth2.js/app/scope.jsx
Normal 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; },
|
||||
};
|
||||
};
|
232
assets/oauth2.js/authorise.js
Normal file
232
assets/oauth2.js/authorise.js
Normal 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
24
assets/oauth2.js/csrf.js
Normal 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;
|
||||
},
|
||||
};
|
||||
})();
|
57
assets/oauth2.js/header/header.js
Normal file
57
assets/oauth2.js/header/header.js
Normal 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,
|
||||
};
|
||||
};
|
53
assets/oauth2.js/header/user.jsx
Normal file
53
assets/oauth2.js/header/user.jsx
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
84
assets/oauth2.js/loading.jsx
Normal file
84
assets/oauth2.js/loading.jsx
Normal 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
10
assets/oauth2.js/main.js
Normal 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
170
assets/oauth2.js/utility.js
Normal 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
178
assets/oauth2.js/verify.js
Normal 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
117
assets/oauth2.js/xhr.js
Normal 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),
|
||||
};
|
||||
})();
|
Loading…
Add table
Add a link
Reference in a new issue