Restructured code authorisation flow.

This commit is contained in:
flash 2024-07-30 21:24:20 +00:00
parent 5fa03dd551
commit 53822c5fd9
21 changed files with 521 additions and 605 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View file

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

View file

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

View file

@ -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 {

View file

@ -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())

View file

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

View file

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

View file

@ -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' %}

View file

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

View file

@ -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' %}