Restructured code authorisation flow.
This commit is contained in:
parent
5fa03dd551
commit
53822c5fd9
21 changed files with 521 additions and 605 deletions
|
@ -1,22 +1,3 @@
|
|||
.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;
|
||||
|
|
|
@ -1,22 +1,3 @@
|
|||
.oauth2-errorhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oauth2-errorhead-icon {
|
||||
flex: 0 0 auto;
|
||||
background-color: #fff;
|
||||
mask: url('/images/circle-exclamation-solid.svg') no-repeat center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.oauth2-errorhead-text {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.oauth2-errorbody p {
|
||||
margin: .5em 1em;
|
||||
}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
.oauth2-loginhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oauth2-loginhead-icon {
|
||||
flex: 0 0 auto;
|
||||
background-color: #fff;
|
||||
mask: url('/images/user-lock-solid.svg') no-repeat center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.oauth2-loginhead-text {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.4em;
|
||||
}
|
|
@ -38,8 +38,8 @@
|
|||
@include loading.css;
|
||||
@include banner.css;
|
||||
@include error.css;
|
||||
@include login.css;
|
||||
@include device.css;
|
||||
@include simplehead.css;
|
||||
@include userhead.css;
|
||||
@include appinfo.css;
|
||||
@include scope.css;
|
||||
|
|
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;
|
||||
}
|
236
assets/oauth2.js/authorise.js
Normal file
236
assets/oauth2.js/authorise.js
Normal file
|
@ -0,0 +1,236 @@
|
|||
#include loading.jsx
|
||||
#include app/info.jsx
|
||||
#include app/scope.jsx
|
||||
#include header/header.js
|
||||
#include header/user.jsx
|
||||
|
||||
const HanyuuOAuth2AuthoriseErrors = 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 HanyuuOAuth2Authorise = async () => {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const loading = new HanyuuOAuth2Loading('.js-loading');
|
||||
const header = new HanyuuOAuth2Header;
|
||||
|
||||
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 HanyuuOAuth2AuthoriseErrors) {
|
||||
const errInfo = HanyuuOAuth2AuthoriseErrors[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 => {
|
||||
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');
|
||||
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('csrfp', HanyuuCSRFP.getToken());
|
||||
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 resolved = (await $x.get(`/oauth2/resolve-authorise-app?${resolveParams}`, { type: 'json' })).body();
|
||||
if(!resolved)
|
||||
throw 'authorisation resolve failed';
|
||||
if(typeof resolved.error === 'string')
|
||||
return translateError(resolved.error);
|
||||
|
||||
const userHeader = new HanyuuOAuth2UserHeader(resolved.user);
|
||||
header.setElement(userHeader);
|
||||
|
||||
const verifyAuthsRequest = async () => {
|
||||
const params = {
|
||||
_csrfp: HanyuuCSRFP.getToken(),
|
||||
client: queryParams.get('client_id'),
|
||||
cc: codeChallenge,
|
||||
ccm: codeChallengeMethod,
|
||||
};
|
||||
if(redirectUriRaw !== undefined)
|
||||
params.redirect = redirectUriRaw;
|
||||
if(scope !== undefined)
|
||||
params.scope = scope;
|
||||
|
||||
try {
|
||||
const response = (await $x.post('/oauth2/authorise', { type: 'json' }, params)).body();
|
||||
if(!response)
|
||||
throw 'authorisation failed';
|
||||
if(typeof response.error === 'string')
|
||||
return translateError(response.error);
|
||||
|
||||
const authoriseUri = new URL(response.redirect);
|
||||
authoriseUri.searchParams.set('code', response.code);
|
||||
if(state !== undefined)
|
||||
authoriseUri.searchParams.set('state', state.toString());
|
||||
|
||||
window.location.assign(authoriseUri);
|
||||
} catch(ex) {
|
||||
console.error(ex);
|
||||
translateError('authorise');
|
||||
}
|
||||
};
|
||||
|
||||
if(resolved.app.trusted && resolved.user.guise === undefined) {
|
||||
if(userHeader)
|
||||
userHeader.guiseVisible = false;
|
||||
|
||||
verifyAuthsRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
const appElem = new HanyuuOAuth2AppInfo(resolved.app);
|
||||
eAuthsInfo.replaceWith(appElem.element);
|
||||
|
||||
const scopeElem = new HanyuuOAuth2AppScopeList();
|
||||
eAuthsScope.replaceWith(scopeElem.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.');
|
||||
}
|
||||
};
|
|
@ -15,11 +15,13 @@ const HanyuuOAuth2Header = function(element = '.js-oauth2-header', simpleElement
|
|||
if(hasSimpleElement)
|
||||
simpleElement.classList.toggle('hidden', !state);
|
||||
};
|
||||
const setSimpleData = (name, text) => {
|
||||
const setSimpleData = (icon, text) => {
|
||||
if(hasSimpleElement) {
|
||||
simpleElement.className = `oauth2-${name}`;
|
||||
simpleElementIcon.className = `oauth2-${name}-icon`;
|
||||
simpleElementText.className = `oauth2-${name}-text`;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,37 +1,9 @@
|
|||
#include authorise.js
|
||||
#include verify.js
|
||||
|
||||
(() => {
|
||||
const authoriseButtons = document.querySelectorAll('.js-authorise-action');
|
||||
|
||||
for(const button of authoriseButtons) {
|
||||
button.disabled = false;
|
||||
|
||||
button.onclick = () => {
|
||||
for(const other of authoriseButtons)
|
||||
other.disabled = true;
|
||||
|
||||
const body = [];
|
||||
for(const name in button.dataset)
|
||||
body.push(encodeURIComponent(name) + '=' + encodeURIComponent(button.dataset[name]));
|
||||
|
||||
const xhr = new XMLHttpRequest;
|
||||
xhr.responseType = 'json';
|
||||
xhr.open('POST', '/oauth2/authorise');
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.onload = () => {
|
||||
if(xhr.response.redirect)
|
||||
location.assign(xhr.response.redirect);
|
||||
else
|
||||
location.assign('/oauth2/error?error=invalid_request');
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
for(const other of authoriseButtons)
|
||||
other.disabled = false;
|
||||
};
|
||||
xhr.send(body.join('&'));
|
||||
};
|
||||
}
|
||||
|
||||
if(location.pathname === '/oauth2/authorise')
|
||||
HanyuuOAuth2Authorise();
|
||||
if(location.pathname === '/oauth2/verify')
|
||||
HanyuuOAuth2Verify();
|
||||
})();
|
||||
|
|
|
@ -18,49 +18,48 @@ const HanyuuOAuth2Verify = () => {
|
|||
|
||||
let userCode = '';
|
||||
let userHeader;
|
||||
|
||||
const verifyAuthsRequest = async approve => {
|
||||
return await $x.post('/oauth2/verify', { type: 'json' }, {
|
||||
_csrfp: HanyuuCSRFP.getToken(),
|
||||
code: userCode,
|
||||
approve: approve === true ? 'yes' : 'no',
|
||||
});
|
||||
};
|
||||
try {
|
||||
const response = (await $x.post('/oauth2/verify', { type: 'json' }, {
|
||||
_csrfp: HanyuuCSRFP.getToken(),
|
||||
code: userCode,
|
||||
approve: approve === true ? 'yes' : 'no',
|
||||
})).body();
|
||||
|
||||
const handleVerifyAuthsResponse = result => {
|
||||
const response = result.body();
|
||||
if(!response)
|
||||
throw 'response is empty';
|
||||
|
||||
if(!response) {
|
||||
if(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 authorisation request.');
|
||||
else if(response.error === 'approval')
|
||||
alert('The 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');
|
||||
} catch(ex) {
|
||||
alert('Request to verify endpoint failed. Please try again.');
|
||||
loading.visible = false;
|
||||
fAuths.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if(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 authorisation request.');
|
||||
else if(response.error === 'approval')
|
||||
alert('The 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 => {
|
||||
|
@ -72,8 +71,7 @@ const HanyuuOAuth2Verify = () => {
|
|||
if(userHeader)
|
||||
userHeader.guiseVisible = false;
|
||||
|
||||
verifyAuthsRequest(ev.submitter.value === 'yes')
|
||||
.then(handleVerifyAuthsResponse);
|
||||
verifyAuthsRequest(ev.submitter.value === 'yes');
|
||||
};
|
||||
|
||||
const fCode = document.querySelector('.js-verify-code');
|
||||
|
@ -85,7 +83,7 @@ const HanyuuOAuth2Verify = () => {
|
|||
loading.visible = true;
|
||||
fCode.classList.add('hidden');
|
||||
|
||||
$x.get(`/oauth2/resolve-request?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${encodeURIComponent(eUserCode.value)}`, { type: 'json' })
|
||||
$x.get(`/oauth2/resolve-verify?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${encodeURIComponent(eUserCode.value)}`, { type: 'json' })
|
||||
.then(result => {
|
||||
const response = result.body();
|
||||
|
||||
|
@ -123,7 +121,7 @@ const HanyuuOAuth2Verify = () => {
|
|||
if(userHeader)
|
||||
userHeader.guiseVisible = false;
|
||||
|
||||
verifyAuthsRequest(true).then(handleVerifyAuthsResponse);
|
||||
verifyAuthsRequest(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -135,6 +133,10 @@ const HanyuuOAuth2Verify = () => {
|
|||
|
||||
loading.visible = false;
|
||||
fAuths.classList.remove('hidden');
|
||||
}).catch(() => {
|
||||
alert('Request to resolve endpoint failed. Please try again.');
|
||||
loading.visible = false;
|
||||
fCode.classList.remove('hidden');
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Data\Migration\IDbMigration;
|
||||
|
||||
final class RemoveStateFieldsFromAuthorisationsDb_20240730_211859 implements IDbMigration {
|
||||
public function migrate(IDbConnection $conn): void {
|
||||
$conn->execute('ALTER TABLE hau_oauth2_authorise DROP COLUMN auth_state, DROP COLUMN auth_approval;');
|
||||
}
|
||||
}
|
1
public/images/circle-question-solid.svg
Normal file
1
public/images/circle-question-solid.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
|
After Width: | Height: | Size: 669 B |
1
public/images/ellipsis-solid.svg
Normal file
1
public/images/ellipsis-solid.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>
|
After Width: | Height: | Size: 362 B |
|
@ -9,7 +9,7 @@ use Index\Data\IDbConnection;
|
|||
use Hanyuu\Apps\AppInfo;
|
||||
use Hanyuu\Apps\AppUriInfo;
|
||||
|
||||
class OAuth2AuthoriseData {
|
||||
class OAuth2AuthorisationData {
|
||||
private IDbConnection $dbConn;
|
||||
private DbStatementCache $cache;
|
||||
|
||||
|
@ -18,26 +18,21 @@ class OAuth2AuthoriseData {
|
|||
$this->cache = new DbStatementCache($dbConn);
|
||||
}
|
||||
|
||||
public function getAuthoriseInfo(
|
||||
?string $authoriseId = null,
|
||||
public function getAuthorisationInfo(
|
||||
?string $authsId = null,
|
||||
AppInfo|string|null $appInfo = null,
|
||||
?string $userId = null,
|
||||
?string $code = null
|
||||
): OAuth2AuthoriseInfo {
|
||||
): OAuth2AuthorisationInfo {
|
||||
$selectors = [];
|
||||
$values = [];
|
||||
if($authoriseId !== null) {
|
||||
if($authsId !== null) {
|
||||
$selectors[] = 'auth_id = ?';
|
||||
$values[] = $authoriseId;
|
||||
$values[] = $authsId;
|
||||
}
|
||||
if($appInfo !== null) {
|
||||
$selectors[] = 'app_id = ?';
|
||||
$values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo;
|
||||
}
|
||||
if($userId !== null) {
|
||||
$selectors[] = 'user_id = ?';
|
||||
$values[] = $userId;
|
||||
}
|
||||
if($code !== null) {
|
||||
$selectors[] = 'auth_code = ?';
|
||||
$values[] = $code;
|
||||
|
@ -47,7 +42,7 @@ class OAuth2AuthoriseData {
|
|||
throw new RuntimeException('Insufficient data to do authorisation request lookup.');
|
||||
|
||||
$args = 0;
|
||||
$stmt = $this->cache->get('SELECT auth_id, app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code, auth_approval, UNIX_TIMESTAMP(auth_created), UNIX_TIMESTAMP(auth_expires) FROM hau_oauth2_authorise WHERE ' . implode(' AND ', $selectors));
|
||||
$stmt = $this->cache->get('SELECT auth_id, app_id, user_id, uri_id, auth_challenge_code, auth_challenge_method, auth_scope, auth_code, UNIX_TIMESTAMP(auth_created), UNIX_TIMESTAMP(auth_expires) FROM hau_oauth2_authorise WHERE ' . implode(' AND ', $selectors));
|
||||
foreach($values as $value)
|
||||
$stmt->addParameter(++$args, $value);
|
||||
$stmt->execute();
|
||||
|
@ -56,45 +51,41 @@ class OAuth2AuthoriseData {
|
|||
if(!$result->next())
|
||||
throw new RuntimeException('Authorise request not found.');
|
||||
|
||||
return OAuth2AuthoriseInfo::fromResult($result);
|
||||
return OAuth2AuthorisationInfo::fromResult($result);
|
||||
}
|
||||
|
||||
public function createAuthorise(
|
||||
public function createAuthorisation(
|
||||
AppInfo|string $appInfo,
|
||||
string $userId,
|
||||
AppUriInfo|string $appUriInfo,
|
||||
string $state,
|
||||
string $challengeCode,
|
||||
string $challengeMethod,
|
||||
string $scope,
|
||||
?string $code = null,
|
||||
bool $preapprove = false
|
||||
): OAuth2AuthoriseInfo {
|
||||
?string $code = null
|
||||
): OAuth2AuthorisationInfo {
|
||||
$code ??= XString::random(60);
|
||||
|
||||
$stmt = $this->cache->get('INSERT INTO hau_oauth2_authorise (app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code, auth_approval) VALUES (?, ?, ?, ?, ?, ?, ?, ?, IF(?, "approved", DEFAULT(auth_approval)))');
|
||||
$stmt = $this->cache->get('INSERT INTO hau_oauth2_authorise (app_id, user_id, uri_id, auth_challenge_code, auth_challenge_method, auth_scope, auth_code) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
|
||||
$stmt->addParameter(2, $userId);
|
||||
$stmt->addParameter(3, $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo);
|
||||
$stmt->addParameter(4, $state);
|
||||
$stmt->addParameter(5, $challengeCode);
|
||||
$stmt->addParameter(6, $challengeMethod);
|
||||
$stmt->addParameter(7, $scope);
|
||||
$stmt->addParameter(8, $code);
|
||||
$stmt->addParameter(9, $preapprove ? 1 : 0);
|
||||
$stmt->addParameter(4, $challengeCode);
|
||||
$stmt->addParameter(5, $challengeMethod);
|
||||
$stmt->addParameter(6, $scope);
|
||||
$stmt->addParameter(7, $code);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->getAuthoriseInfo(authoriseId: (string)$this->dbConn->getLastInsertId());
|
||||
return $this->getAuthorisationInfo(authsId: (string)$this->dbConn->getLastInsertId());
|
||||
}
|
||||
|
||||
public function deleteAuthorise(
|
||||
OAuth2AuthoriseInfo|string|null $authoriseInfo = null
|
||||
public function deleteAuthorisation(
|
||||
OAuth2AuthorisationInfo|string|null $authsInfo = null
|
||||
): void {
|
||||
$selectors = [];
|
||||
$values = [];
|
||||
if($authoriseInfo !== null) {
|
||||
if($authsInfo !== null) {
|
||||
$selectors[] = 'auth_id = ?';
|
||||
$values[] = $authoriseInfo instanceof OAuth2AuthoriseInfo ? $authoriseInfo->getId() : $authoriseInfo;
|
||||
$values[] = $authsInfo instanceof OAuth2AuthorisationInfo ? $authsInfo->getId() : $authsInfo;
|
||||
}
|
||||
|
||||
$query = 'DELETE FROM hau_oauth2_authorise';
|
||||
|
@ -107,18 +98,4 @@ class OAuth2AuthoriseData {
|
|||
$stmt->addParameter(++$args, $value);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
public function setAuthoriseApproval(OAuth2AuthoriseInfo|string $authoriseInfo, bool $approval): void {
|
||||
if($authoriseInfo instanceof OAuth2AuthoriseInfo) {
|
||||
if(!$authoriseInfo->isPending())
|
||||
return;
|
||||
|
||||
$authoriseInfo = $authoriseInfo->getId();
|
||||
}
|
||||
|
||||
$stmt = $this->cache->get('UPDATE hau_oauth2_authorise SET auth_approval = ? WHERE auth_id = ? AND auth_approval = "pending"');
|
||||
$stmt->addParameter(1, $approval ? 'approved' : 'denied');
|
||||
$stmt->addParameter(2, $authoriseInfo);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
|
@ -4,36 +4,32 @@ namespace Hanyuu\OAuth2;
|
|||
use Index\Data\IDbResult;
|
||||
use Index\Serialisation\UriBase64;
|
||||
|
||||
class OAuth2AuthoriseInfo {
|
||||
class OAuth2AuthorisationInfo {
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private string $appId,
|
||||
private string $userId,
|
||||
private string $uriId,
|
||||
private string $state,
|
||||
private string $challengeCode,
|
||||
private string $challengeMethod,
|
||||
private string $scope,
|
||||
private string $code,
|
||||
private string $approval,
|
||||
private int $created,
|
||||
private int $expires
|
||||
) {}
|
||||
|
||||
public static function fromResult(IDbResult $result): OAuth2AuthoriseInfo {
|
||||
return new OAuth2AuthoriseInfo(
|
||||
public static function fromResult(IDbResult $result): OAuth2AuthorisationInfo {
|
||||
return new OAuth2AuthorisationInfo(
|
||||
id: $result->getString(0),
|
||||
appId: $result->getString(1),
|
||||
userId: $result->getString(2),
|
||||
uriId: $result->getString(3),
|
||||
state: $result->getString(4),
|
||||
challengeCode: $result->getString(5),
|
||||
challengeMethod: $result->getString(6),
|
||||
scope: $result->getString(7),
|
||||
code: $result->getString(8),
|
||||
approval: $result->getString(9),
|
||||
created: $result->getInteger(10),
|
||||
expires: $result->getInteger(11),
|
||||
challengeCode: $result->getString(4),
|
||||
challengeMethod: $result->getString(5),
|
||||
scope: $result->getString(6),
|
||||
code: $result->getString(7),
|
||||
created: $result->getInteger(8),
|
||||
expires: $result->getInteger(9),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -53,10 +49,6 @@ class OAuth2AuthoriseInfo {
|
|||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getState(): string {
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function getChallengeCode(): string {
|
||||
return $this->challengeCode;
|
||||
}
|
||||
|
@ -89,19 +81,6 @@ class OAuth2AuthoriseInfo {
|
|||
return $this->code;
|
||||
}
|
||||
|
||||
public function getApproval(): string {
|
||||
return $this->approval;
|
||||
}
|
||||
public function isPending(): bool {
|
||||
return strcasecmp($this->approval, 'pending') === 0;
|
||||
}
|
||||
public function isApproved(): bool {
|
||||
return strcasecmp($this->approval, 'approved') === 0;
|
||||
}
|
||||
public function isDenied(): bool {
|
||||
return strcasecmp($this->approval, 'denied') === 0;
|
||||
}
|
||||
|
||||
public function getCreatedTime(): int {
|
||||
return $this->created;
|
||||
}
|
|
@ -5,18 +5,18 @@ use Index\Data\IDbConnection;
|
|||
use Hanyuu\Apps\AppInfo;
|
||||
|
||||
class OAuth2Context {
|
||||
private OAuth2AuthoriseData $authorise;
|
||||
private OAuth2AuthorisationData $authorisations;
|
||||
private OAuth2TokensData $tokens;
|
||||
private OAuth2DevicesData $devices;
|
||||
|
||||
public function __construct(IDbConnection $dbConn) {
|
||||
$this->authorise = new OAuth2AuthoriseData($dbConn);
|
||||
$this->authorisations = new OAuth2AuthorisationData($dbConn);
|
||||
$this->tokens = new OAuth2TokensData($dbConn);
|
||||
$this->devices = new OAuth2DevicesData($dbConn);
|
||||
}
|
||||
|
||||
public function getAuthoriseData(): OAuth2AuthoriseData {
|
||||
return $this->authorise;
|
||||
public function getAuthorisationData(): OAuth2AuthorisationData {
|
||||
return $this->authorisations;
|
||||
}
|
||||
|
||||
public function getTokensData(): OAuth2TokensData {
|
||||
|
|
|
@ -33,15 +33,6 @@ final class OAuth2Routes extends RouteHandler {
|
|||
return $info;
|
||||
}
|
||||
|
||||
private static function buildCallbackUri(string $target, array $params): string {
|
||||
if($target === '')
|
||||
$target = '/oauth2/error';
|
||||
|
||||
$target .= strpos($target, '?') === false ? '?' : '&';
|
||||
$target .= http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||
return $target;
|
||||
}
|
||||
|
||||
private static function filterScopes(string $source): array {
|
||||
$scopes = [];
|
||||
|
||||
|
@ -57,205 +48,16 @@ final class OAuth2Routes extends RouteHandler {
|
|||
return $scopes;
|
||||
}
|
||||
|
||||
private const AUTHORISE_ERRORS = [
|
||||
'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.',
|
||||
'allow_description' => true,
|
||||
],
|
||||
'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' => [
|
||||
'status' => 500,
|
||||
'description' => 'The authorisation server encountered an unexpected condition that prevented it from fulfilling the request.',
|
||||
],
|
||||
'temporarily_unavailable' => [
|
||||
'status' => 503,
|
||||
'description' => 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.',
|
||||
],
|
||||
];
|
||||
|
||||
#[HttpGet('/oauth2/error')]
|
||||
public function getError($response, $request) {
|
||||
$error = (string)$request->getParam('error');
|
||||
if(!array_key_exists($error, self::AUTHORISE_ERRORS))
|
||||
return 404;
|
||||
|
||||
$info = self::AUTHORISE_ERRORS[$error];
|
||||
|
||||
$description = $info['description'];
|
||||
if($request->hasParam('error_description') && array_key_exists('allow_description', $info) && $info['allow_description'])
|
||||
$description = $request->getParam('error_description');
|
||||
|
||||
$statusCode = 400;
|
||||
if(array_key_exists('status', $info))
|
||||
$statusCode = $info['status'];
|
||||
|
||||
$response->setStatusCode($statusCode);
|
||||
|
||||
return $this->templating->render('oauth2/error', [
|
||||
'error_code' => $error,
|
||||
'error_description' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
#[HttpGet('/oauth2/authorise')]
|
||||
public function getAuthorise($response, $request) {
|
||||
$redirectUri = (string)$request->getParam('redirect_uri');
|
||||
if($redirectUri !== '' && filter_var($redirectUri, FILTER_VALIDATE_URL) === false)
|
||||
return $response->redirect(self::buildCallbackUri('', [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Request URI must be a correctly formatted absolute URI.',
|
||||
]));
|
||||
|
||||
$clientId = (string)$request->getParam('client_id');
|
||||
$appsData = $this->appsCtx->getData();
|
||||
try {
|
||||
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
|
||||
} catch(RuntimeException $ex) {
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Client ID is missing or is not associated with an existing application.',
|
||||
]));
|
||||
}
|
||||
|
||||
$state = (string)$request->getParam('state');
|
||||
if(strlen($state) > 255)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'State parameter may not be longer than 255 characters.',
|
||||
]));
|
||||
|
||||
if($redirectUri === '') {
|
||||
$uriInfos = $appsData->countAppUris($appInfo);
|
||||
if($uriInfos !== 1)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'A registered redirect URI must be specified.',
|
||||
'state' => $state,
|
||||
]));
|
||||
|
||||
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
||||
if(count($uriInfos) !== 1)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'A registered redirect URI must be specified (congrats, you hit an edge case!).',
|
||||
'state' => $state,
|
||||
]));
|
||||
|
||||
$uriInfo = array_pop($uriInfos);
|
||||
$redirectUriId = $uriInfo->getId();
|
||||
$redirectUri = $uriInfo->getString();
|
||||
} else {
|
||||
$redirectUriId = $appsData->getAppUriId($appInfo, $redirectUri);
|
||||
if($redirectUriId === null)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Specified redirect URI is not registered with the application.',
|
||||
'state' => $state,
|
||||
]));
|
||||
}
|
||||
|
||||
$type = (string)$request->getParam('response_type');
|
||||
if($type !== 'code')
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'unsupported_response_type',
|
||||
'state' => $state,
|
||||
]));
|
||||
|
||||
$codeChallengeMethod = (string)$request->getParam('code_challenge_method');
|
||||
if($codeChallengeMethod === '') $codeChallengeMethod = 'plain';
|
||||
|
||||
$codeChallenge = (string)$request->getParam('code_challenge');
|
||||
$codeChallengeLength = strlen($codeChallenge);
|
||||
|
||||
if($codeChallengeMethod === 'plain') {
|
||||
if($codeChallengeLength < 43)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Code challenge must be at least 43 characters long.',
|
||||
'state' => $state,
|
||||
]));
|
||||
elseif($codeChallengeLength > 128)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Code challenge may not be longer than 128 characters.',
|
||||
'state' => $state,
|
||||
]));
|
||||
} elseif($codeChallengeMethod === 'S256') {
|
||||
if($codeChallengeLength !== 43)
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Specified code challenge is not a valid SHA-256 hash.',
|
||||
'state' => $state,
|
||||
]));
|
||||
} else {
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Request code challenge method is not supported.',
|
||||
'state' => $state,
|
||||
]));
|
||||
}
|
||||
|
||||
$scopes = self::filterScopes((string)$request->getParam('scope'));
|
||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_scope',
|
||||
'state' => $state,
|
||||
]));
|
||||
$scope = implode(' ', $scopes);
|
||||
|
||||
$authInfo = ($this->getAuthInfo)();
|
||||
if(!isset($authInfo->user))
|
||||
return $this->templating->render('oauth2/login', [
|
||||
'app' => $appInfo,
|
||||
'auth' => $authInfo,
|
||||
]);
|
||||
|
||||
$authoriseData = $this->oauth2Ctx->getAuthoriseData();
|
||||
try {
|
||||
$authoriseInfo = $authoriseData->createAuthorise(
|
||||
$appInfo,
|
||||
$authInfo->user->id,
|
||||
$redirectUriId,
|
||||
$state,
|
||||
$codeChallenge,
|
||||
$codeChallengeMethod,
|
||||
$scope,
|
||||
preapprove: $appInfo->isTrusted()
|
||||
);
|
||||
} catch(RuntimeException $ex) {
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'server_error',
|
||||
'state' => $state,
|
||||
]));
|
||||
}
|
||||
|
||||
if($authoriseInfo->isApproved()) {
|
||||
$response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'code' => $authoriseInfo->getCode(),
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
return $this->templating->render('oauth2/login', ['auth' => $authInfo]);
|
||||
|
||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
|
||||
|
||||
return $this->templating->render('oauth2/authorise', [
|
||||
'app' => $appInfo,
|
||||
'req' => $authoriseInfo,
|
||||
'auth' => $authInfo,
|
||||
'csrfp_token' => $csrfp->createToken(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -264,127 +66,155 @@ final class OAuth2Routes extends RouteHandler {
|
|||
if(!$request->isFormContent())
|
||||
return 400;
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
$redirectUri = (string)$content->getParam('redirect');
|
||||
if(filter_var($redirectUri, FILTER_VALIDATE_URL) === false) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri('', [
|
||||
'error' => 'invalid_request',
|
||||
]),
|
||||
];
|
||||
}
|
||||
// TODO: RATE LIMITING
|
||||
|
||||
$authInfo = ($this->getAuthInfo)();
|
||||
if(!isset($authInfo->user)) {
|
||||
$response->setStatusCode(403);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'access_denied',
|
||||
]),
|
||||
];
|
||||
}
|
||||
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'))) {
|
||||
$response->setStatusCode(403);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Request verification failed.',
|
||||
]),
|
||||
];
|
||||
if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
|
||||
return ['error' => 'csrf'];
|
||||
|
||||
$codeChallengeMethod = 'plain';
|
||||
if($content->hasParam('ccm')) {
|
||||
$codeChallengeMethod = $content->getParam('ccm');
|
||||
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
|
||||
return ['error' => 'method'];
|
||||
}
|
||||
|
||||
$approve = (string)$content->getParam('approve');
|
||||
if(!in_array($approve, ['yes', 'no'])) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$authoriseData = $this->oauth2Ctx->getAuthoriseData();
|
||||
try {
|
||||
$authoriseInfo = $authoriseData->getAuthoriseInfo(
|
||||
userId: $authInfo->user->id,
|
||||
code: (string)$content->getParam('code'),
|
||||
);
|
||||
} catch(RuntimeException $ex) {
|
||||
$response->setStatusCode(404);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Could not find authorisation request.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if(!$authoriseInfo->isPending()) {
|
||||
$response->setStatusCode(410);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'This authorisation request has already been handled.',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
$codeChallenge = $content->getParam('cc');
|
||||
$codeChallengeLength = strlen($codeChallenge);
|
||||
if($codeChallengeMethod === 'S256') {
|
||||
if($codeChallengeLength !== 43)
|
||||
return ['error' => 'length'];
|
||||
} else {
|
||||
if($codeChallengeLength < 43 || $codeChallengeLength > 128)
|
||||
return ['error' => 'length'];
|
||||
}
|
||||
|
||||
$appsData = $this->appsCtx->getData();
|
||||
try {
|
||||
$uriInfo = $appsData->getAppUriInfo($authoriseInfo->getUriId());
|
||||
$appInfo = $appsData->getAppInfo(clientId: (string)$content->getParam('client'), deleted: false);
|
||||
} catch(RuntimeException $ex) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'This authorisation request was made with a redirect URI that is no longer registered with this application.',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
return ['error' => 'client'];
|
||||
}
|
||||
|
||||
if($uriInfo->getString() !== $redirectUri) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Attempt at request forgery detected.',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
$scope = '';
|
||||
if($content->hasParam('scope')) {
|
||||
$scopes = self::filterScopes((string)$content->getParam('scope'));
|
||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
||||
return ['error' => 'scope'];
|
||||
|
||||
$scope = implode(' ', $scopes);
|
||||
}
|
||||
|
||||
$approved = $approve === 'yes';
|
||||
$authoriseData->setAuthoriseApproval($authoriseInfo, $approved);
|
||||
if($content->hasParam('redirect')) {
|
||||
$redirectUri = (string)$content->getParam('redirect');
|
||||
$redirectUriId = $appsData->getAppUriId($appInfo, $redirectUri);
|
||||
if($redirectUriId === null)
|
||||
return ['error' => 'format'];
|
||||
} else {
|
||||
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
||||
if(count($uriInfos) !== 1)
|
||||
return ['error' => 'required'];
|
||||
|
||||
if($approved)
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($uriInfo->getString(), [
|
||||
'code' => $authoriseInfo->getCode(),
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
$uriInfo = array_pop($uriInfos);
|
||||
$redirectUri = $uriInfo->getString();
|
||||
$redirectUriId = $uriInfo->getId();
|
||||
}
|
||||
|
||||
$authsData = $this->oauth2Ctx->getAuthorisationData();
|
||||
try {
|
||||
$authsInfo = $authsData->createAuthorisation(
|
||||
$appInfo,
|
||||
$authInfo->user->id,
|
||||
$redirectUriId,
|
||||
$codeChallenge,
|
||||
$codeChallengeMethod,
|
||||
$scope
|
||||
);
|
||||
} catch(RuntimeException $ex) {
|
||||
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
|
||||
}
|
||||
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($uriInfo->getString(), [
|
||||
'error' => 'access_denied',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
'code' => $authsInfo->getCode(),
|
||||
'redirect' => $redirectUri,
|
||||
];
|
||||
}
|
||||
|
||||
#[HttpGet('/oauth2/resolve-authorise-app')]
|
||||
public function getResolveAuthorise($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'];
|
||||
|
||||
$appsData = $this->appsCtx->getData();
|
||||
try {
|
||||
$appInfo = $appsData->getAppInfo(clientId: (string)$request->getParam('client'), deleted: false);
|
||||
} catch(RuntimeException $ex) {
|
||||
return ['error' => 'client'];
|
||||
}
|
||||
|
||||
if($request->hasParam('redirect')) {
|
||||
$redirectUri = (string)$request->getParam('redirect');
|
||||
if($appsData->getAppUriId($appInfo, $redirectUri) === null)
|
||||
return ['error' => 'format'];
|
||||
} else {
|
||||
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
||||
if(count($uriInfos) !== 1)
|
||||
return ['error' => 'required'];
|
||||
}
|
||||
|
||||
if($request->hasParam('scope')) {
|
||||
$scopes = self::filterScopes((string)$request->getParam('scope'));
|
||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
||||
return ['error' => 'scope'];
|
||||
}
|
||||
|
||||
$result = [
|
||||
'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;
|
||||
}
|
||||
|
||||
#[HttpGet('/oauth2/verify')]
|
||||
public function getVerify($response, $request) {
|
||||
$authInfo = ($this->getAuthInfo)();
|
||||
if(!isset($authInfo->user))
|
||||
return $this->templating->render('oauth2/login', [
|
||||
'auth' => $authInfo,
|
||||
]);
|
||||
return $this->templating->render('oauth2/login', ['auth' => $authInfo]);
|
||||
|
||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
|
||||
|
||||
|
@ -432,8 +262,8 @@ final class OAuth2Routes extends RouteHandler {
|
|||
];
|
||||
}
|
||||
|
||||
#[HttpGet('/oauth2/resolve-request')]
|
||||
public function getResolveRequest($response, $request) {
|
||||
#[HttpGet('/oauth2/resolve-verify')]
|
||||
public function getResolveVerify($response, $request) {
|
||||
// TODO: RATE LIMITING
|
||||
|
||||
$authInfo = ($this->getAuthInfo)();
|
||||
|
@ -652,9 +482,9 @@ final class OAuth2Routes extends RouteHandler {
|
|||
|
||||
$type = (string)$content->getParam('grant_type');
|
||||
if($type === 'authorization_code') {
|
||||
$authoriseData = $this->oauth2Ctx->getAuthoriseData();
|
||||
$authsData = $this->oauth2Ctx->getAuthorisationData();
|
||||
try {
|
||||
$authoriseInfo = $authoriseData->getAuthoriseInfo(
|
||||
$authsInfo = $authsData->getAuthorisationInfo(
|
||||
appInfo: $appInfo,
|
||||
code: (string)$content->getParam('code'),
|
||||
);
|
||||
|
@ -663,24 +493,19 @@ final class OAuth2Routes extends RouteHandler {
|
|||
return self::error('invalid_grant', 'No authorisation request with this code exists.');
|
||||
}
|
||||
|
||||
if($authoriseInfo->hasExpired()) {
|
||||
if($authsInfo->hasExpired()) {
|
||||
$response->setStatusCode(400);
|
||||
return self::error('invalid_grant', 'Authorisation request has expired.');
|
||||
}
|
||||
|
||||
if(!$authoriseInfo->verifyCodeChallenge((string)$content->getParam('code_verifier'))) {
|
||||
if(!$authsInfo->verifyCodeChallenge((string)$content->getParam('code_verifier'))) {
|
||||
$response->setStatusCode(400);
|
||||
return self::error('invalid_request', 'Code challenge verification failed.');
|
||||
}
|
||||
|
||||
if(!$authoriseInfo->isApproved()) {
|
||||
$response->setStatusCode(400);
|
||||
return self::error('invalid_grant', 'Authorisation request has not been approved.');
|
||||
}
|
||||
$authsData->deleteAuthorisation($authsInfo);
|
||||
|
||||
$authoriseData->deleteAuthorise($authoriseInfo);
|
||||
|
||||
$scopes = $authoriseInfo->getScopes();
|
||||
$scopes = $authsInfo->getScopes();
|
||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) {
|
||||
$response->setStatusCode(400);
|
||||
return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
|
||||
|
@ -690,12 +515,12 @@ final class OAuth2Routes extends RouteHandler {
|
|||
$tokensData = $this->oauth2Ctx->getTokensData();
|
||||
$accessInfo = $tokensData->createAccess(
|
||||
$appInfo,
|
||||
$authoriseInfo->getUserId(),
|
||||
$authsInfo->getUserId(),
|
||||
scope: $scope,
|
||||
);
|
||||
|
||||
// 'scope' only has to be in the response if it differs from what was requested
|
||||
if($scope === $authoriseInfo->getScope())
|
||||
if($scope === $authsInfo->getScope())
|
||||
unset($scope);
|
||||
|
||||
if($appInfo->shouldIssueRefreshToken())
|
||||
|
|
|
@ -1,81 +1,29 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% set body_header_icon = 'wait' %}
|
||||
{% set body_header_text = 'Loading...' %}
|
||||
{% set body_title = 'Authorisation Request' %}
|
||||
|
||||
{% block body_header %}
|
||||
<header class="oauth2-header"{% if auth.user.colour != 'inherit' %} style="background-color: {{ auth.user.colour }}"{% endif %}>
|
||||
<div class="oauth2-userhead">
|
||||
<div class="oauth2-userhead-main">
|
||||
<div class="oauth2-userhead-main-avatar">
|
||||
<div class="oauth2-userhead-main-avatar-image">
|
||||
<img src="{{ auth.user.avatars.x120 }}" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth2-userhead-main-name">
|
||||
<a href="{{ auth.user.profile_url }}" target="_blank">{{ auth.user.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if auth.guise is defined %}
|
||||
<div class="oauth2-userhead-guise">
|
||||
<div class="oauth2-userhead-guise-avatar">
|
||||
<div class="oauth2-userhead-guise-avatar-image">
|
||||
<img src="{{ auth.guise.avatars.x60 }}" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth2-userhead-guise-text">
|
||||
<p>Are you <a href="{{ auth.guise.profile_url }}" target="_blank" style="color: {{ auth.guise.colour }}">{{ auth.guise.name }}</a> and did you mean to use your own account?</p>
|
||||
<p><a href="{{ auth.guise.revert_url }}" target="_blank">Click here</a> and reload this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_content %}
|
||||
<div class="oauth2-authorise-requesting">
|
||||
<p>A third-party application is requesting permission to access your account.</p>
|
||||
</div>
|
||||
<div class="js-loading"></div>
|
||||
|
||||
<div class="oauth2-appinfo">
|
||||
<div class="oauth2-appinfo-name">
|
||||
{{ app.name }}
|
||||
</div>{# TODO: author should be listed #}
|
||||
<div class="oauth2-appinfo-links">
|
||||
<a href="{{ app.website }}" target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title="Website">
|
||||
<div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div>
|
||||
<div class="oauth2-appinfo-link-text">{{ app.websiteDisplay }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="oauth2-appinfo-summary">
|
||||
<p>{{ app.summary }}</p>
|
||||
<div class="js-authorise-error hidden">
|
||||
<div class="oauth2-errorbody">
|
||||
<p class="js-authorise-error-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-scope">
|
||||
<div class="oauth2-scope-header">This application will be able to:</div>
|
||||
<div class="oauth2-scope-perms">
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">Do anything because I have not made up scopes yet.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">Eat soup.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">These are placeholders.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">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.</div>
|
||||
</div>
|
||||
<form class="js-authorise-form hidden">
|
||||
<div class="oauth2-authorise-requesting">
|
||||
<p>A third-party application is requesting permission to access your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-authorise-buttons">
|
||||
<button type="button" class="oauth2-authorise-button oauth2-authorise-button-accept js-authorise-action" data-redirect="{{ redirect_uri }}" data-csrfp="{{ csrfp_token }}" data-approve="yes" data-code="{{ req.code }}" disabled>Authorise</button>
|
||||
<button type="button" class="oauth2-authorise-button oauth2-authorise-button-deny js-authorise-action" data-redirect="{{ redirect_uri }}" data-csrfp="{{ csrfp_token }}" data-approve="no" data-code="{{ req.code }}" disabled>Cancel</button>
|
||||
</div>
|
||||
<div class="js-authorise-form-info"></div>
|
||||
<div class="js-authorise-form-scope"></div>
|
||||
|
||||
<div class="oauth2-authorise-buttons">
|
||||
<button name="approve" value="yes" class="oauth2-authorise-button oauth2-authorise-button-accept">Authorise</button>
|
||||
<button name="approve" value="no" class="oauth2-authorise-button oauth2-authorise-button-deny">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% set body_header_class = 'errorhead' %}
|
||||
{% set body_header_text = 'Error' %}
|
||||
{% set body_title = 'An error occurred' %}
|
||||
|
||||
{% block body_content %}
|
||||
<div class="oauth2-errorbody">
|
||||
<p>{{ error_description }}</p>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,6 +1,6 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% set body_header_class = 'loginhead' %}
|
||||
{% set body_header_icon = 'login' %}
|
||||
{% set body_header_text = 'Not logged in' %}
|
||||
{% set body_title = 'Authorisation Request' %}
|
||||
|
||||
|
|
|
@ -17,9 +17,10 @@
|
|||
{% block body %}
|
||||
{% block body_header %}
|
||||
<header class="oauth2-header js-oauth2-header">
|
||||
<div class="oauth2-{{ body_header_class|default('') }} js-oauth2-header-simple">
|
||||
<div class="oauth2-{{ body_header_class|default('') }}-icon js-oauth2-header-simple-icon"></div>
|
||||
<div class="oauth2-{{ body_header_class|default('') }}-text js-oauth2-header-simple-text">
|
||||
{% set body_header_icon = body_header_icon|default('') %}
|
||||
<div class="oauth2-simplehead js-oauth2-header-simple{% if body_header_icon == '' %} hidden{% endif %}">
|
||||
<div class="oauth2-simplehead-icon oauth2-simplehead-icon--{{ body_header_icon }} js-oauth2-header-simple-icon"></div>
|
||||
<div class="oauth2-simplehead-text js-oauth2-header-simple-text">
|
||||
{{ body_header_text|default('') }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% set body_header_class = 'devicehead' %}
|
||||
{% set body_header_icon = 'code' %}
|
||||
{% set body_header_text = 'Code authorisation' %}
|
||||
{% set body_title = 'Authorisation Request' %}
|
||||
|
||||
|
|
Loading…
Reference in a new issue